Skip to content

Commit f0b3550

Browse files
feat(files): public share links for workspace files (#5130)
* feat(files): public share links for workspace files * improvement(files): drop reserved public_share columns until used; sync audit mock * fix(files): share modal tracks authoritative saved state until toggled * feat(files): per-IP rate limit on public share endpoints * fix(files): address PR review — public CSV OOM, content cache, share FK, soft-delete filter, download anchor * fix(files): disable CSV import action in read-only preview (public share) * refactor(files): drive CSV preview import affordance off readOnly, not disableImport * fix(files): version public viewer caches by file updatedAt so edits aren't stale * fix(files): 409 (not corrupt source) when a shared generated doc has no compiled artifact * feat(files): gate public sharing behind an access-control permission
1 parent 267e49c commit f0b3550

38 files changed

Lines changed: 18222 additions & 28 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { NextRequest } from 'next/server'
3+
import { NextResponse } from 'next/server'
4+
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
9+
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
10+
import { downloadFile } from '@/lib/uploads/core/storage-service'
11+
import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
const logger = createLogger('PublicFileContentAPI')
16+
17+
/**
18+
* GET /api/files/public/[token]/content
19+
* Public, unauthenticated bytes for a shared file. Authorized solely by an active
20+
* share token — never by workspace membership. 404 for unknown/inactive/deleted
21+
* shares. Disposition (inline vs attachment) is resolved from the file type by
22+
* {@link createFileResponse}; the public page's Download button uses `<a download>`.
23+
*
24+
* Generated office docs are stored as source; {@link resolveServableDoc} swaps in
25+
* their prebuilt compiled binary (read-only, never compiles). Uploaded binaries
26+
* pass through untouched. A generated doc whose compiled artifact isn't built yet
27+
* returns 409 rather than serving raw source under a binary content type.
28+
*/
29+
export const GET = withRouteHandler(
30+
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
31+
try {
32+
const limited = await enforcePublicFileRateLimit(request, 'content')
33+
if (limited) return limited
34+
35+
const parsed = await parseRequest(getPublicFileContentContract, request, context)
36+
if (!parsed.success) return parsed.response
37+
const { token } = parsed.data.params
38+
39+
const resolved = await resolveActiveShareByToken(token)
40+
if (!resolved) {
41+
throw new FileNotFoundError('Not found')
42+
}
43+
44+
const { file } = resolved
45+
const raw = await downloadFile({ key: file.key, context: 'workspace' })
46+
47+
const servable = file.workspaceId
48+
? await resolveServableDoc(file.workspaceId, raw, file.originalName)
49+
: ({ kind: 'passthrough' } as const)
50+
51+
if (servable.kind === 'unavailable') {
52+
logger.info('Public shared doc not yet compiled', { token, key: file.key })
53+
return NextResponse.json(
54+
{ error: 'This document is still being prepared. Please try again shortly.' },
55+
{ status: 409 }
56+
)
57+
}
58+
59+
const buffer = servable.kind === 'artifact' ? servable.buffer : raw
60+
const contentType = servable.kind === 'artifact' ? servable.contentType : file.contentType
61+
62+
logger.info('Public shared file served', { token, key: file.key, size: buffer.length })
63+
64+
// Revalidate every request: a shared file can be unshared, edited, or deleted,
65+
// so the fixed token URL must never serve stale bytes from a long-lived cache.
66+
return createFileResponse({
67+
buffer,
68+
contentType,
69+
filename: file.originalName,
70+
cacheControl: 'private, no-cache, must-revalidate',
71+
})
72+
} catch (error) {
73+
logger.error('Error serving public shared file:', error)
74+
if (error instanceof FileNotFoundError) {
75+
return createErrorResponse(error)
76+
}
77+
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
78+
}
79+
}
80+
)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockResolveActiveShareByToken, mockEnforceRateLimit } = vi.hoisted(() => ({
8+
mockResolveActiveShareByToken: vi.fn(),
9+
mockEnforceRateLimit: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/public-shares/share-manager', () => ({
13+
resolveActiveShareByToken: mockResolveActiveShareByToken,
14+
}))
15+
16+
vi.mock('@/lib/public-shares/rate-limit', () => ({
17+
enforcePublicFileRateLimit: mockEnforceRateLimit,
18+
}))
19+
20+
import { NextResponse } from 'next/server'
21+
import { GET } from '@/app/api/files/public/[token]/route'
22+
23+
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
24+
const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`)
25+
26+
describe('GET /api/files/public/[token]', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks()
29+
mockEnforceRateLimit.mockResolvedValue(null) // allow by default
30+
})
31+
32+
it('returns 429 when the per-IP rate limit is exceeded', async () => {
33+
mockEnforceRateLimit.mockResolvedValueOnce(
34+
NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 })
35+
)
36+
const res = await GET(request(), params())
37+
expect(res.status).toBe(429)
38+
expect(mockResolveActiveShareByToken).not.toHaveBeenCalled()
39+
})
40+
41+
it('returns 404 for an unknown or inactive token', async () => {
42+
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
43+
const res = await GET(request(), params())
44+
expect(res.status).toBe(404)
45+
})
46+
47+
it('returns public-safe metadata (name/type/size + provenance) without leaking the key or workspace id', async () => {
48+
mockResolveActiveShareByToken.mockResolvedValueOnce({
49+
share: { id: 'sh_1', token: 'tok_1' },
50+
file: {
51+
id: 'wf_1',
52+
key: 'workspace/ws/secret-key.pdf',
53+
workspaceId: 'ws-secret',
54+
originalName: 'report.pdf',
55+
contentType: 'application/pdf',
56+
size: 2048,
57+
},
58+
workspaceName: 'Acme Workspace',
59+
ownerName: 'Jane Doe',
60+
})
61+
const res = await GET(request(), params())
62+
expect(res.status).toBe(200)
63+
const body = await res.json()
64+
expect(body).toEqual({
65+
token: 'tok_1',
66+
name: 'report.pdf',
67+
type: 'application/pdf',
68+
size: 2048,
69+
workspaceName: 'Acme Workspace',
70+
ownerName: 'Jane Doe',
71+
})
72+
expect(JSON.stringify(body)).not.toContain('secret-key')
73+
expect(JSON.stringify(body)).not.toContain('ws-secret')
74+
})
75+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import { getPublicFileContract } from '@/lib/api/contracts/public-shares'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
9+
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('PublicFileMetadataAPI')
14+
15+
/**
16+
* GET /api/files/public/[token]
17+
* Public, unauthenticated metadata for a shared file. Returns 404 for unknown,
18+
* inactive, or deleted shares — the existence of a file is never leaked.
19+
*/
20+
export const GET = withRouteHandler(
21+
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
22+
try {
23+
const limited = await enforcePublicFileRateLimit(request, 'metadata')
24+
if (limited) return limited
25+
26+
const parsed = await parseRequest(getPublicFileContract, request, context)
27+
if (!parsed.success) return parsed.response
28+
const { token } = parsed.data.params
29+
30+
const resolved = await resolveActiveShareByToken(token)
31+
if (!resolved) {
32+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
33+
}
34+
35+
const { file, workspaceName, ownerName } = resolved
36+
return NextResponse.json({
37+
token,
38+
name: file.originalName,
39+
type: file.contentType,
40+
size: file.size,
41+
workspaceName,
42+
ownerName,
43+
})
44+
} catch (error) {
45+
logger.error('Error fetching public file metadata:', error)
46+
return NextResponse.json(
47+
{ error: getErrorMessage(error, 'Failed to fetch file') },
48+
{ status: 500 }
49+
)
50+
}
51+
}
52+
)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } =
9+
vi.hoisted(() => ({
10+
mockGetWorkspaceFile: vi.fn(),
11+
mockGetShareForResource: vi.fn(),
12+
mockUpsertFileShare: vi.fn(),
13+
mockValidateSharing: vi.fn(),
14+
}))
15+
16+
vi.mock('@/lib/uploads/contexts/workspace', () => ({
17+
getWorkspaceFile: mockGetWorkspaceFile,
18+
}))
19+
20+
vi.mock('@/lib/public-shares/share-manager', () => ({
21+
getShareForResource: mockGetShareForResource,
22+
upsertFileShare: mockUpsertFileShare,
23+
}))
24+
25+
vi.mock('@/ee/access-control/utils/permission-check', () => {
26+
class PublicFileSharingNotAllowedError extends Error {
27+
constructor() {
28+
super('Public file sharing is not allowed based on your permission group settings')
29+
this.name = 'PublicFileSharingNotAllowedError'
30+
}
31+
}
32+
return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError }
33+
})
34+
35+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
36+
vi.mock('@sim/audit', () => auditMock)
37+
38+
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
39+
const FILE_ID = 'wf_abc'
40+
41+
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
42+
43+
const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })
44+
45+
const putRequest = (body: unknown) =>
46+
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, {
47+
method: 'PUT',
48+
headers: { 'Content-Type': 'application/json' },
49+
body: JSON.stringify(body),
50+
})
51+
52+
const getRequest = () =>
53+
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`)
54+
55+
const SHARE = {
56+
id: 'sh_1',
57+
token: 'tok_1',
58+
url: 'https://sim.ai/f/tok_1',
59+
isActive: true,
60+
resourceType: 'file' as const,
61+
resourceId: FILE_ID,
62+
}
63+
64+
describe('share route', () => {
65+
beforeEach(() => {
66+
vi.clearAllMocks()
67+
authMockFns.mockGetSession.mockResolvedValue({
68+
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
69+
})
70+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
71+
mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' })
72+
mockGetShareForResource.mockResolvedValue(SHARE)
73+
mockUpsertFileShare.mockResolvedValue(SHARE)
74+
mockValidateSharing.mockResolvedValue(undefined) // policy allows by default
75+
})
76+
77+
describe('GET', () => {
78+
it('returns 401 when unauthenticated', async () => {
79+
authMockFns.mockGetSession.mockResolvedValueOnce(null)
80+
const res = await GET(getRequest(), params())
81+
expect(res.status).toBe(401)
82+
})
83+
84+
it('returns 403 when the caller has no workspace access', async () => {
85+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null)
86+
const res = await GET(getRequest(), params())
87+
expect(res.status).toBe(403)
88+
})
89+
90+
it('returns the share for a member', async () => {
91+
const res = await GET(getRequest(), params())
92+
expect(res.status).toBe(200)
93+
expect(await res.json()).toEqual({ share: SHARE })
94+
})
95+
})
96+
97+
describe('PUT', () => {
98+
it('returns 403 for a read-only member', async () => {
99+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
100+
const res = await PUT(putRequest({ isActive: true }), params())
101+
expect(res.status).toBe(403)
102+
expect(mockUpsertFileShare).not.toHaveBeenCalled()
103+
})
104+
105+
it('returns 404 when the file is not in the workspace', async () => {
106+
mockGetWorkspaceFile.mockResolvedValueOnce(null)
107+
const res = await PUT(putRequest({ isActive: true }), params())
108+
expect(res.status).toBe(404)
109+
expect(mockUpsertFileShare).not.toHaveBeenCalled()
110+
})
111+
112+
it('enables the share for a writer', async () => {
113+
const res = await PUT(putRequest({ isActive: true }), params())
114+
expect(res.status).toBe(200)
115+
expect(mockUpsertFileShare).toHaveBeenCalledWith({
116+
workspaceId: WS,
117+
fileId: FILE_ID,
118+
userId: 'user-1',
119+
isActive: true,
120+
})
121+
expect(await res.json()).toEqual({ share: SHARE })
122+
})
123+
124+
it('returns 403 when org access-control disables public sharing (enable)', async () => {
125+
const { PublicFileSharingNotAllowedError } = await import(
126+
'@/ee/access-control/utils/permission-check'
127+
)
128+
mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError())
129+
const res = await PUT(putRequest({ isActive: true }), params())
130+
expect(res.status).toBe(403)
131+
expect(mockUpsertFileShare).not.toHaveBeenCalled()
132+
})
133+
134+
it('allows disabling a share even when policy disallows enabling', async () => {
135+
mockValidateSharing.mockRejectedValue(new Error('should not be called for disable'))
136+
const res = await PUT(putRequest({ isActive: false }), params())
137+
expect(res.status).toBe(200)
138+
expect(mockValidateSharing).not.toHaveBeenCalled()
139+
expect(mockUpsertFileShare).toHaveBeenCalledWith({
140+
workspaceId: WS,
141+
fileId: FILE_ID,
142+
userId: 'user-1',
143+
isActive: false,
144+
})
145+
})
146+
147+
it('rejects a missing isActive body', async () => {
148+
const res = await PUT(putRequest({}), params())
149+
expect(res.status).toBe(400)
150+
})
151+
})
152+
})

0 commit comments

Comments
 (0)