Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
80 changes: 80 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,80 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
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 resolveServableDoc} swaps in
* their prebuilt compiled binary (read-only, never compiles). Uploaded binaries
* pass through untouched. A generated doc whose compiled artifact isn't built yet
* returns 409 rather than serving raw source under a binary content type.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
try {
const limited = await enforcePublicFileRateLimit(request, 'content')
if (limited) return limited

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 servable = file.workspaceId
? await resolveServableDoc(file.workspaceId, raw, file.originalName)
: ({ kind: 'passthrough' } as const)

if (servable.kind === 'unavailable') {
logger.info('Public shared doc not yet compiled', { token, key: file.key })
return NextResponse.json(
{ error: 'This document is still being prepared. Please try again shortly.' },
{ status: 409 }
)
}

const buffer = servable.kind === 'artifact' ? servable.buffer : raw
const contentType = servable.kind === 'artifact' ? servable.contentType : file.contentType

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

// Revalidate every request: a shared file can be unshared, edited, or deleted,
// so the fixed token URL must never serve stale bytes from a long-lived cache.
return createFileResponse({
buffer,
contentType,
filename: file.originalName,
cacheControl: 'private, no-cache, must-revalidate',
})
} 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.
75 changes: 75 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,75 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

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

vi.mock('@/lib/public-shares/rate-limit', () => ({
enforcePublicFileRateLimit: mockEnforceRateLimit,
}))

import { NextResponse } from 'next/server'
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()
mockEnforceRateLimit.mockResolvedValue(null) // allow by default
})

it('returns 429 when the per-IP rate limit is exceeded', async () => {
mockEnforceRateLimit.mockResolvedValueOnce(
NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 })
)
const res = await GET(request(), params())
expect(res.status).toBe(429)
expect(mockResolveActiveShareByToken).not.toHaveBeenCalled()
})

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')
})
})
52 changes: 52 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,52 @@
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 { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
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 limited = await enforcePublicFileRateLimit(request, 'metadata')
if (limited) return limited

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 }
)
}
}
)
116 changes: 116 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,116 @@
/**
* @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,
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