|
1 | | -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' |
2 | 1 | import { createLogger } from '@sim/logger' |
3 | | -import type { NextRequest } from 'next/server' |
4 | | -import { NextResponse } from 'next/server' |
| 2 | +import { type NextRequest, NextResponse } from 'next/server' |
5 | 3 | import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares' |
6 | 4 | import { parseRequest } from '@/lib/api/server' |
7 | | -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' |
8 | | -import { generateRequestId } from '@/lib/core/utils/request' |
9 | | -import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' |
| 5 | +import { getClientIp } from '@/lib/core/utils/request' |
10 | 6 | import { withRouteHandler } from '@/lib/core/utils/with-route-handler' |
11 | | -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' |
12 | | -import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' |
13 | | -import { downloadFile } from '@/lib/uploads/core/storage-service' |
14 | | -import { resolveServableDoc } from '@/lib/uploads/documents/compile' |
15 | | -import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' |
16 | | -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' |
17 | | -import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' |
18 | | -import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server' |
19 | 7 | import { |
20 | | - createFileErrorResponse, |
21 | | - createFileResponse, |
22 | | - FileNotFoundError, |
23 | | - getContentType, |
24 | | -} from '@/app/api/files/utils' |
| 8 | + publicFileAuthDenied, |
| 9 | + publicFileBinaryErrorResponse, |
| 10 | + publicFileShareCredential, |
| 11 | +} from '@/lib/public-shares/api' |
| 12 | +import { |
| 13 | + authorizePublicFileShare, |
| 14 | + checkPublicFileShareContent, |
| 15 | + readPublicFileShareContent, |
| 16 | +} from '@/lib/public-shares/application' |
| 17 | +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' |
| 18 | +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' |
| 19 | +import { createConditionalFileResponse } from '@/app/api/files/utils' |
25 | 20 |
|
26 | 21 | export const dynamic = 'force-dynamic' |
27 | | - |
28 | 22 | const logger = createLogger('PublicFileContentAPI') |
29 | 23 |
|
30 | | -/** |
31 | | - * GET /api/files/public/[token]/content |
32 | | - * Public, unauthenticated bytes for a shared file. Authorized solely by an active |
33 | | - * share token — never by workspace membership. 404 for unknown/inactive/deleted |
34 | | - * shares. Disposition (inline vs attachment) is resolved from the file type by |
35 | | - * {@link createFileResponse}; the public page's Download button uses `<a download>`. |
36 | | - * |
37 | | - * Generated office docs are stored as source; {@link resolveServableDoc} swaps in |
38 | | - * their prebuilt compiled binary (read-only, never compiles). Uploaded binaries |
39 | | - * pass through untouched, except under `preview=1`, where a format no browser |
40 | | - * decodes is substituted with a renderable derivative. A generated doc whose |
41 | | - * compiled artifact isn't built yet returns 409 rather than serving raw source |
42 | | - * under a binary content type. |
43 | | - */ |
| 24 | +/** Binary delivery rechecks the current bearer grant and preserves the token URL's revalidation policy. */ |
44 | 25 | export const GET = withRouteHandler( |
45 | 26 | async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { |
46 | | - const requestId = generateRequestId() |
47 | | - |
48 | 27 | try { |
49 | 28 | const limited = await enforcePublicFileRateLimit(request, 'content') |
50 | 29 | if (limited) return limited |
51 | | - |
52 | 30 | const parsed = await parseRequest(getPublicFileContentContract, request, context) |
53 | 31 | if (!parsed.success) return parsed.response |
54 | | - const { token } = parsed.data.params |
55 | | - const preview = parsed.data.query.preview === '1' |
56 | | - |
57 | | - const resolved = await resolveActiveShareByToken(token) |
58 | | - if (!resolved) { |
59 | | - throw new FileNotFoundError('Not found') |
60 | | - } |
61 | | - |
62 | | - const auth = await validateDeploymentAuth( |
63 | | - requestId, |
64 | | - resolved.share, |
65 | | - request, |
66 | | - undefined, |
67 | | - 'file' |
68 | | - ) |
69 | | - if (!auth.authorized) { |
70 | | - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) |
71 | | - } |
72 | | - |
73 | | - const { file } = resolved |
74 | | - // The same ceiling the authenticated serve route reads this object under |
75 | | - // (`fetchWorkspaceFileBuffer`). Without it a share link is the one way to ask |
76 | | - // an unauthenticated caller's request to hold a 5 GB workspace file resident. |
77 | | - const raw = await downloadFile({ |
78 | | - key: file.key, |
79 | | - context: 'workspace', |
80 | | - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, |
| 32 | + const auth = await authorizePublicFileShare({ |
| 33 | + token: parsed.data.params.token, |
| 34 | + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), |
81 | 35 | }) |
82 | | - |
83 | | - const servable = file.workspaceId |
84 | | - ? await resolveServableDoc(file.workspaceId, raw, file.originalName, { |
85 | | - sourceMime: file.contentType, |
86 | | - }) |
87 | | - : ({ kind: 'passthrough' } as const) |
88 | | - |
89 | | - if (servable.kind === 'unavailable') { |
90 | | - logger.info('Public shared doc not yet compiled', { token, key: file.key }) |
91 | | - return NextResponse.json( |
92 | | - { error: 'This document is still being prepared. Please try again shortly.' }, |
93 | | - { status: 409 } |
94 | | - ) |
95 | | - } |
96 | | - |
97 | | - // This response is `nosniff`, so a stored `application/octet-stream` refuses to render |
98 | | - // even though the bytes are fine. Resolving from the filename also keeps this route on |
99 | | - // the same inline allowlist as the workspace serve route. |
100 | | - let buffer = raw |
101 | | - let contentType = getContentType(file.originalName) |
102 | | - if (servable.kind === 'artifact') { |
103 | | - buffer = servable.buffer |
104 | | - contentType = servable.contentType |
105 | | - } else if ( |
106 | | - // Sim pages store an extensionless name — the record type marks them; |
107 | | - // legacy pages still carry .html. |
108 | | - (file.contentType === SIM_PAGE_CONTENT_TYPE || |
109 | | - file.originalName.toLowerCase().endsWith('.html')) && |
110 | | - isSimPageSource(raw.toString('utf8')) |
111 | | - ) { |
112 | | - // The pdf model for pages: the stored .html is source; a share serves |
113 | | - // the fully styled compiled document, matching the preview and serve |
114 | | - // routes. sim: links resolve to workspace routes (a viewer without |
115 | | - // workspace access simply lands on the sign-in gate). |
116 | | - buffer = Buffer.from( |
117 | | - await renderSimPageDocumentWithAssets(raw.toString('utf8'), { |
118 | | - workspaceId: file.workspaceId ?? undefined, |
119 | | - }), |
120 | | - 'utf8' |
121 | | - ) |
122 | | - contentType = 'text/html' |
123 | | - } else if (preview) { |
124 | | - // Only for a render request: the Download button omits `preview`, so a saved |
125 | | - // file is always the bytes that were shared. |
126 | | - const image = await resolveServableImageBytes(raw, file.key) |
127 | | - if (image) ({ buffer, contentType } = image) |
| 36 | + if (!auth.authorized) return publicFileAuthDenied(auth) |
| 37 | + if (request.method === 'HEAD') { |
| 38 | + await checkPublicFileShareContent(auth.grant) |
| 39 | + return new NextResponse(null, { |
| 40 | + headers: { |
| 41 | + 'Cache-Control': FILE_CACHE_CONTROL.revalidate, |
| 42 | + 'X-Content-Type-Options': 'nosniff', |
| 43 | + }, |
| 44 | + }) |
128 | 45 | } |
129 | | - |
130 | | - // Bounding the source read does not bound the response: each branch above can |
131 | | - // replace it with bytes fetched or produced separately — a compiled artifact, a |
132 | | - // page with its images inlined, a transcoded derivative. This is an anonymous |
133 | | - // route, so the bytes it actually returns are what has to fit. |
134 | | - assertKnownSizeWithinLimit(buffer.length, MAX_BUFFERED_TRANSFER_BYTES, 'served file response') |
135 | | - |
136 | | - logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) |
137 | | - |
138 | | - // Anonymous access: null actor (owner-as-actor would misread as a self-download). |
139 | | - recordAudit({ |
140 | | - workspaceId: file.workspaceId ?? null, |
141 | | - actorId: null, |
142 | | - action: AuditAction.FILE_DOWNLOADED, |
143 | | - resourceType: AuditResourceType.FILE, |
144 | | - resourceId: file.id, |
145 | | - resourceName: file.originalName, |
146 | | - description: `Public share download of "${file.originalName}"`, |
147 | | - metadata: { |
148 | | - access: 'public_share', |
149 | | - anonymous: true, |
150 | | - sharedByUserId: file.userId, |
151 | | - fileName: file.originalName, |
152 | | - bytes: buffer.length, |
153 | | - }, |
| 46 | + const result = await readPublicFileShareContent({ |
| 47 | + grant: auth.grant, |
| 48 | + preview: parsed.data.query.preview === '1', |
154 | 49 | request, |
155 | 50 | }) |
156 | | - |
157 | | - // Revalidate every request: a shared file can be unshared, edited, or deleted, |
158 | | - // so the fixed token URL must never serve stale bytes from a long-lived cache. |
159 | | - return createFileResponse({ |
160 | | - buffer, |
161 | | - contentType, |
162 | | - filename: file.originalName, |
163 | | - cacheControl: 'private, no-cache, must-revalidate', |
164 | | - }) |
| 51 | + return createConditionalFileResponse( |
| 52 | + { |
| 53 | + buffer: result.buffer, |
| 54 | + contentType: result.contentType, |
| 55 | + filename: result.file.originalName, |
| 56 | + cacheControl: FILE_CACHE_CONTROL.revalidate, |
| 57 | + }, |
| 58 | + request.headers.get('if-none-match') |
| 59 | + ) |
165 | 60 | } catch (error) { |
166 | 61 | logger.error('Error serving public shared file:', error) |
167 | | - if (error instanceof FileNotFoundError) { |
168 | | - return createFileErrorResponse(error) |
169 | | - } |
170 | | - return createFileErrorResponse( |
171 | | - error instanceof Error ? error : new Error('Failed to serve file') |
172 | | - ) |
| 62 | + return publicFileBinaryErrorResponse(error, 'Failed to serve file') |
173 | 63 | } |
174 | 64 | } |
175 | 65 | ) |
0 commit comments