Skip to content

Commit 02cd2f6

Browse files
authored
feat(project-files): add public sharing and protected delivery (#9019)
* feat(project-files): add public sharing and protected delivery * fix(project-files): align public sharing with current renderer contracts * test(project-files): follow canonical inline cache policy * fix(project-files): enforce public delivery and share policy safety * test(project-files): explicitly retire fixture shares during teardown * test(project-files): restore sharing suite environment
1 parent 96ab45e commit 02cd2f6

52 files changed

Lines changed: 4269 additions & 1806 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/api/chat/utils.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import {
77
type DeploymentAuthResource,
88
setDeploymentAuthCookie,
99
} from '@/lib/core/security/deployment'
10-
import {
11-
type DeploymentAuthBody,
12-
type DeploymentAuthResult,
13-
validateDeploymentAuth,
14-
} from '@/lib/core/security/deployment-auth'
10+
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
11+
import type {
12+
DeploymentAuthBody,
13+
DeploymentAuthResult,
14+
} from '@/lib/core/security/deployment-credentials'
1515

1616
export async function setChatAuthCookie(
1717
response: NextResponse,

‎apps/sim/app/api/files/public/[token]/content/route.test.ts‎

Lines changed: 0 additions & 90 deletions
This file was deleted.
Lines changed: 40 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,175 +1,65 @@
1-
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
21
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'
53
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
64
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'
106
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'
197
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'
2520

2621
export const dynamic = 'force-dynamic'
27-
2822
const logger = createLogger('PublicFileContentAPI')
2923

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. */
4425
export const GET = withRouteHandler(
4526
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
46-
const requestId = generateRequestId()
47-
4827
try {
4928
const limited = await enforcePublicFileRateLimit(request, 'content')
5029
if (limited) return limited
51-
5230
const parsed = await parseRequest(getPublicFileContentContract, request, context)
5331
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)),
8135
})
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+
})
12845
}
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',
15449
request,
15550
})
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+
)
16560
} catch (error) {
16661
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')
17363
}
17464
}
17565
)

0 commit comments

Comments
 (0)