Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/sim/app/api/files/public/[token]/content/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { loadServableDocArtifact } from '@/lib/copilot/tools/server/files/doc-compile'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
import { downloadFile } from '@/lib/uploads/core/storage-service'
import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils'

export const dynamic = 'force-dynamic'

const logger = createLogger('PublicFileContentAPI')

/**
* GET /api/files/public/[token]/content
* Public, unauthenticated bytes for a shared file. Authorized solely by an active
* share token — never by workspace membership. 404 for unknown/inactive/deleted
* shares. Disposition (inline vs attachment) is resolved from the file type by
* {@link createFileResponse}; the public page's Download button uses `<a download>`.
*
* Generated office docs are stored as source; {@link loadServableDocArtifact}
* swaps in their prebuilt compiled binary (read-only, never compiles). Uploaded
* binaries pass through untouched.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
try {
const parsed = await parseRequest(getPublicFileContentContract, request, context)
if (!parsed.success) return parsed.response
const { token } = parsed.data.params

const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
throw new FileNotFoundError('Not found')
}

const { file } = resolved
const raw = await downloadFile({ key: file.key, context: 'workspace' })

const artifact = file.workspaceId
? await loadServableDocArtifact(file.workspaceId, raw, file.originalName)
: null
const buffer = artifact?.buffer ?? raw
const contentType = artifact?.contentType ?? file.contentType
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

logger.info('Public shared file served', { token, key: file.key, size: buffer.length })

return createFileResponse({ buffer, contentType, filename: file.originalName })
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
} catch (error) {
logger.error('Error serving public shared file:', error)
if (error instanceof FileNotFoundError) {
return createErrorResponse(error)
}
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
}
}
)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
59 changes: 59 additions & 0 deletions apps/sim/app/api/files/public/[token]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockResolveActiveShareByToken } = vi.hoisted(() => ({
mockResolveActiveShareByToken: vi.fn(),
}))

vi.mock('@/lib/public-shares/share-manager', () => ({
resolveActiveShareByToken: mockResolveActiveShareByToken,
}))

import { GET } from '@/app/api/files/public/[token]/route'

const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`)

describe('GET /api/files/public/[token]', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('returns 404 for an unknown or inactive token', async () => {
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
const res = await GET(request(), params())
expect(res.status).toBe(404)
})

it('returns public-safe metadata (name/type/size + provenance) without leaking the key or workspace id', async () => {
mockResolveActiveShareByToken.mockResolvedValueOnce({
share: { id: 'sh_1', token: 'tok_1' },
file: {
id: 'wf_1',
key: 'workspace/ws/secret-key.pdf',
workspaceId: 'ws-secret',
originalName: 'report.pdf',
contentType: 'application/pdf',
size: 2048,
},
workspaceName: 'Acme Workspace',
ownerName: 'Jane Doe',
})
const res = await GET(request(), params())
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({
token: 'tok_1',
name: 'report.pdf',
type: 'application/pdf',
size: 2048,
workspaceName: 'Acme Workspace',
ownerName: 'Jane Doe',
})
expect(JSON.stringify(body)).not.toContain('secret-key')
expect(JSON.stringify(body)).not.toContain('ws-secret')
})
})
48 changes: 48 additions & 0 deletions apps/sim/app/api/files/public/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getPublicFileContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'

export const dynamic = 'force-dynamic'

const logger = createLogger('PublicFileMetadataAPI')

/**
* GET /api/files/public/[token]
* Public, unauthenticated metadata for a shared file. Returns 404 for unknown,
* inactive, or deleted shares — the existence of a file is never leaked.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
try {
const parsed = await parseRequest(getPublicFileContract, request, context)
if (!parsed.success) return parsed.response
const { token } = parsed.data.params

const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}

const { file, workspaceName, ownerName } = resolved
return NextResponse.json({
token,
name: file.originalName,
type: file.contentType,
size: file.size,
workspaceName,
ownerName,
})
} catch (error) {
logger.error('Error fetching public file metadata:', error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to fetch file') },
{ status: 500 }
)
}
}
)
118 changes: 118 additions & 0 deletions apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @vitest-environment node
*/
import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare } = vi.hoisted(() => ({
mockGetWorkspaceFile: vi.fn(),
mockGetShareForResource: vi.fn(),
mockUpsertFileShare: vi.fn(),
}))

vi.mock('@/lib/uploads/contexts/workspace', () => ({
getWorkspaceFile: mockGetWorkspaceFile,
}))

vi.mock('@/lib/public-shares/share-manager', () => ({
getShareForResource: mockGetShareForResource,
upsertFileShare: mockUpsertFileShare,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@sim/audit', () => auditMock)

const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
const FILE_ID = 'wf_abc'

import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'

const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })

const putRequest = (body: unknown) =>
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})

const getRequest = () =>
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`)

const SHARE = {
id: 'sh_1',
token: 'tok_1',
url: 'https://sim.ai/f/tok_1',
isActive: true,
accessLevel: 'view' as const,
authType: 'public' as const,
resourceType: 'file' as const,
resourceId: FILE_ID,
}

describe('share route', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
})
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' })
mockGetShareForResource.mockResolvedValue(SHARE)
mockUpsertFileShare.mockResolvedValue(SHARE)
})

describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValueOnce(null)
const res = await GET(getRequest(), params())
expect(res.status).toBe(401)
})

it('returns 403 when the caller has no workspace access', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null)
const res = await GET(getRequest(), params())
expect(res.status).toBe(403)
})

it('returns the share for a member', async () => {
const res = await GET(getRequest(), params())
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ share: SHARE })
})
})

describe('PUT', () => {
it('returns 403 for a read-only member', async () => {
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(403)
expect(mockUpsertFileShare).not.toHaveBeenCalled()
})

it('returns 404 when the file is not in the workspace', async () => {
mockGetWorkspaceFile.mockResolvedValueOnce(null)
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(404)
expect(mockUpsertFileShare).not.toHaveBeenCalled()
})

it('enables the share for a writer', async () => {
const res = await PUT(putRequest({ isActive: true }), params())
expect(res.status).toBe(200)
expect(mockUpsertFileShare).toHaveBeenCalledWith({
workspaceId: WS,
fileId: FILE_ID,
userId: 'user-1',
isActive: true,
})
expect(await res.json()).toEqual({ share: SHARE })
})

it('rejects a missing isActive body', async () => {
const res = await PUT(putRequest({}), params())
expect(res.status).toBe(400)
})
})
})
Loading
Loading