Skip to content

Commit 52c7b92

Browse files
feat(files): public share links for workspace files
1 parent a028d07 commit 52c7b92

29 files changed

Lines changed: 18039 additions & 24 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { NextRequest } from 'next/server'
3+
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { loadServableDocArtifact } from '@/lib/copilot/tools/server/files/doc-compile'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
8+
import { downloadFile } from '@/lib/uploads/core/storage-service'
9+
import { createErrorResponse, createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const logger = createLogger('PublicFileContentAPI')
14+
15+
/**
16+
* GET /api/files/public/[token]/content
17+
* Public, unauthenticated bytes for a shared file. Authorized solely by an active
18+
* share token — never by workspace membership. 404 for unknown/inactive/deleted
19+
* shares. Disposition (inline vs attachment) is resolved from the file type by
20+
* {@link createFileResponse}; the public page's Download button uses `<a download>`.
21+
*
22+
* Generated office docs are stored as source; {@link loadServableDocArtifact}
23+
* swaps in their prebuilt compiled binary (read-only, never compiles). Uploaded
24+
* binaries pass through untouched.
25+
*/
26+
export const GET = withRouteHandler(
27+
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
28+
try {
29+
const parsed = await parseRequest(getPublicFileContentContract, request, context)
30+
if (!parsed.success) return parsed.response
31+
const { token } = parsed.data.params
32+
33+
const resolved = await resolveActiveShareByToken(token)
34+
if (!resolved) {
35+
throw new FileNotFoundError('Not found')
36+
}
37+
38+
const { file } = resolved
39+
const raw = await downloadFile({ key: file.key, context: 'workspace' })
40+
41+
const artifact = file.workspaceId
42+
? await loadServableDocArtifact(file.workspaceId, raw, file.originalName)
43+
: null
44+
const buffer = artifact?.buffer ?? raw
45+
const contentType = artifact?.contentType ?? file.contentType
46+
47+
logger.info('Public shared file served', { token, key: file.key, size: buffer.length })
48+
49+
return createFileResponse({ buffer, contentType, filename: file.originalName })
50+
} catch (error) {
51+
logger.error('Error serving public shared file:', error)
52+
if (error instanceof FileNotFoundError) {
53+
return createErrorResponse(error)
54+
}
55+
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
56+
}
57+
}
58+
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 } = vi.hoisted(() => ({
8+
mockResolveActiveShareByToken: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/public-shares/share-manager', () => ({
12+
resolveActiveShareByToken: mockResolveActiveShareByToken,
13+
}))
14+
15+
import { GET } from '@/app/api/files/public/[token]/route'
16+
17+
const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
18+
const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`)
19+
20+
describe('GET /api/files/public/[token]', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
})
24+
25+
it('returns 404 for an unknown or inactive token', async () => {
26+
mockResolveActiveShareByToken.mockResolvedValueOnce(null)
27+
const res = await GET(request(), params())
28+
expect(res.status).toBe(404)
29+
})
30+
31+
it('returns public-safe metadata (name/type/size + provenance) without leaking the key or workspace id', async () => {
32+
mockResolveActiveShareByToken.mockResolvedValueOnce({
33+
share: { id: 'sh_1', token: 'tok_1' },
34+
file: {
35+
id: 'wf_1',
36+
key: 'workspace/ws/secret-key.pdf',
37+
workspaceId: 'ws-secret',
38+
originalName: 'report.pdf',
39+
contentType: 'application/pdf',
40+
size: 2048,
41+
},
42+
workspaceName: 'Acme Workspace',
43+
ownerName: 'Jane Doe',
44+
})
45+
const res = await GET(request(), params())
46+
expect(res.status).toBe(200)
47+
const body = await res.json()
48+
expect(body).toEqual({
49+
token: 'tok_1',
50+
name: 'report.pdf',
51+
type: 'application/pdf',
52+
size: 2048,
53+
workspaceName: 'Acme Workspace',
54+
ownerName: 'Jane Doe',
55+
})
56+
expect(JSON.stringify(body)).not.toContain('secret-key')
57+
expect(JSON.stringify(body)).not.toContain('ws-secret')
58+
})
59+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
const logger = createLogger('PublicFileMetadataAPI')
13+
14+
/**
15+
* GET /api/files/public/[token]
16+
* Public, unauthenticated metadata for a shared file. Returns 404 for unknown,
17+
* inactive, or deleted shares — the existence of a file is never leaked.
18+
*/
19+
export const GET = withRouteHandler(
20+
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
21+
try {
22+
const parsed = await parseRequest(getPublicFileContract, request, context)
23+
if (!parsed.success) return parsed.response
24+
const { token } = parsed.data.params
25+
26+
const resolved = await resolveActiveShareByToken(token)
27+
if (!resolved) {
28+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
29+
}
30+
31+
const { file, workspaceName, ownerName } = resolved
32+
return NextResponse.json({
33+
token,
34+
name: file.originalName,
35+
type: file.contentType,
36+
size: file.size,
37+
workspaceName,
38+
ownerName,
39+
})
40+
} catch (error) {
41+
logger.error('Error fetching public file metadata:', error)
42+
return NextResponse.json(
43+
{ error: getErrorMessage(error, 'Failed to fetch file') },
44+
{ status: 500 }
45+
)
46+
}
47+
}
48+
)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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 } = vi.hoisted(() => ({
9+
mockGetWorkspaceFile: vi.fn(),
10+
mockGetShareForResource: vi.fn(),
11+
mockUpsertFileShare: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/uploads/contexts/workspace', () => ({
15+
getWorkspaceFile: mockGetWorkspaceFile,
16+
}))
17+
18+
vi.mock('@/lib/public-shares/share-manager', () => ({
19+
getShareForResource: mockGetShareForResource,
20+
upsertFileShare: mockUpsertFileShare,
21+
}))
22+
23+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
24+
vi.mock('@sim/audit', () => auditMock)
25+
26+
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
27+
const FILE_ID = 'wf_abc'
28+
29+
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
30+
31+
const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })
32+
33+
const putRequest = (body: unknown) =>
34+
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, {
35+
method: 'PUT',
36+
headers: { 'Content-Type': 'application/json' },
37+
body: JSON.stringify(body),
38+
})
39+
40+
const getRequest = () =>
41+
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`)
42+
43+
const SHARE = {
44+
id: 'sh_1',
45+
token: 'tok_1',
46+
url: 'https://sim.ai/f/tok_1',
47+
isActive: true,
48+
accessLevel: 'view' as const,
49+
authType: 'public' as const,
50+
resourceType: 'file' as const,
51+
resourceId: FILE_ID,
52+
}
53+
54+
describe('share route', () => {
55+
beforeEach(() => {
56+
vi.clearAllMocks()
57+
authMockFns.mockGetSession.mockResolvedValue({
58+
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
59+
})
60+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
61+
mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' })
62+
mockGetShareForResource.mockResolvedValue(SHARE)
63+
mockUpsertFileShare.mockResolvedValue(SHARE)
64+
})
65+
66+
describe('GET', () => {
67+
it('returns 401 when unauthenticated', async () => {
68+
authMockFns.mockGetSession.mockResolvedValueOnce(null)
69+
const res = await GET(getRequest(), params())
70+
expect(res.status).toBe(401)
71+
})
72+
73+
it('returns 403 when the caller has no workspace access', async () => {
74+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null)
75+
const res = await GET(getRequest(), params())
76+
expect(res.status).toBe(403)
77+
})
78+
79+
it('returns the share for a member', async () => {
80+
const res = await GET(getRequest(), params())
81+
expect(res.status).toBe(200)
82+
expect(await res.json()).toEqual({ share: SHARE })
83+
})
84+
})
85+
86+
describe('PUT', () => {
87+
it('returns 403 for a read-only member', async () => {
88+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
89+
const res = await PUT(putRequest({ isActive: true }), params())
90+
expect(res.status).toBe(403)
91+
expect(mockUpsertFileShare).not.toHaveBeenCalled()
92+
})
93+
94+
it('returns 404 when the file is not in the workspace', async () => {
95+
mockGetWorkspaceFile.mockResolvedValueOnce(null)
96+
const res = await PUT(putRequest({ isActive: true }), params())
97+
expect(res.status).toBe(404)
98+
expect(mockUpsertFileShare).not.toHaveBeenCalled()
99+
})
100+
101+
it('enables the share for a writer', async () => {
102+
const res = await PUT(putRequest({ isActive: true }), params())
103+
expect(res.status).toBe(200)
104+
expect(mockUpsertFileShare).toHaveBeenCalledWith({
105+
workspaceId: WS,
106+
fileId: FILE_ID,
107+
userId: 'user-1',
108+
isActive: true,
109+
})
110+
expect(await res.json()).toEqual({ share: SHARE })
111+
})
112+
113+
it('rejects a missing isActive body', async () => {
114+
const res = await PUT(putRequest({}), params())
115+
expect(res.status).toBe(400)
116+
})
117+
})
118+
})

0 commit comments

Comments
 (0)