Skip to content

Commit 2dffa1e

Browse files
committed
fix(shares, resources): close what the review pass turned up
Nine findings, four of them mine from earlier in this branch. **The R6 guarantee was not enforced.** `check-resource-views.ts` reported `R6 … 0 (at baseline)` while a real violation sat in the tree: `useParams<{ workspaceId: string }>()` in `add-connector-modal.tsx`. The pattern demanded `(` immediately after the hook name, so the generic form — the dominant idiom in this repo, quoted in two of the rule's own TSDoc blocks — was invisible. Widened the pattern, watched it go 0 → 1, threaded `workspaceId` in as a prop exactly as its sibling modal already documents, confirmed 0. **An inline-image fan-out charged against a whole-file budget.** Every embedded image spent a `content` token — 60/min, sized for downloading a file. A thirty-image shared document spent half a reader's minute on one page view and the second view inside that minute returned 429, which renders as broken images with no stated reason. Added an `inline` scope denominated in what actually happens (`INLINE_IMAGES_PER_VIEW * INLINE_VIEWS_PER_MINUTE`). **The per-share ceiling equalled the per-IP budget it backstops.** Both 60/min for `content`, so one visitor at full rate saturated the link and the aggregate bound before the bucket it exists to protect. Now derived from the per-IP config rather than written out, so `aggregate > per-IP` holds by construction. Dropped the `execute` tier — no caller, no consumer of its type. **My `.webm` fix reasoned from a false premise.** Routing `getContentType` through `resolveEffectiveMimeType` was a no-op: `contentTypeMap` was consulted first, so `DUAL_CONTAINER_MIME` never ran. The real defect was that `contentTypeMap` duplicated 38 of `EXTENSION_TO_MIME`'s entries — drift between two hand-maintained tables is what left `.mkv`/`.flac`/`.aac`/`.opus`/`.avi` at `application/octet-stream`, unseekable. Collapsed to the three Google pseudo-extensions no MIME table knows. Verified empirically before collapsing: exactly five resolutions change, three are those pseudo-types, and `js`/`ts` move to the WHATWG spellings while staying attachments either way. The TSDoc's `.mp4` example was also false — staging already had that entry. Also: one home for `REVALIDATE_CACHE_CONTROL` (four copies, three files); `getContentType` no longer computed twice per serve branch; a dead default parameter, a dead `binaryExtensions` export, and two TSDoc blocks that had drifted above the wrong function; `toError` in place of the banned `instanceof Error ? … : new Error(…)` in the three routes this branch touches. New tests for `lib/public-shares`, which had none — 12 pinning the aggregate-exceeds-per-IP invariant, the inline fan-out budget, bucket separation and the 429 shape. Verified they fail against the original bug: setting the multiple back to 1 turns two of them red. Suite: 21113 passed. R6/R3c 0, 23 audits, lint and type-check clean.
1 parent 47e2469 commit 2dffa1e

16 files changed

Lines changed: 350 additions & 132 deletions

File tree

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
3+
import { toError } from '@sim/utils/errors'
34
import type { NextRequest } from 'next/server'
45
import { NextResponse } from 'next/server'
56
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
@@ -19,6 +20,7 @@ import {
1920
createFileResponse,
2021
FileNotFoundError,
2122
getContentType,
23+
REVALIDATE_CACHE_CONTROL,
2224
} from '@/app/api/files/utils'
2325

2426
export const dynamic = 'force-dynamic'
@@ -163,7 +165,7 @@ export const GET = withRouteHandler(
163165
size: head.size,
164166
contentType: mediaContentType,
165167
filename: file.originalName,
166-
cacheControl: 'private, no-cache, must-revalidate',
168+
cacheControl: REVALIDATE_CACHE_CONTROL,
167169
rangeHeader,
168170
})
169171
}
@@ -231,14 +233,14 @@ export const GET = withRouteHandler(
231233
buffer,
232234
contentType,
233235
filename: file.originalName,
234-
cacheControl: 'private, no-cache, must-revalidate',
236+
cacheControl: REVALIDATE_CACHE_CONTROL,
235237
})
236238
} catch (error) {
237239
logger.error('Error serving public shared file:', error)
238240
if (error instanceof FileNotFoundError) {
239241
return createErrorResponse(error)
240242
}
241-
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
243+
return createErrorResponse(toError(error))
242244
}
243245
}
244246
)

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,21 +125,26 @@ describe('GET /api/files/public/[token]/inline', () => {
125125
expect(mockDownloadFile).not.toHaveBeenCalled()
126126
})
127127

128-
it('charges the per-IP content bucket exactly once', async () => {
128+
/**
129+
* The `inline` scope, not `content`. One page view fans out to one request per
130+
* embedded image, so charging these against the whole-file download budget
131+
* made the second view of an image-heavy document 429.
132+
*/
133+
it('charges the per-IP inline bucket exactly once', async () => {
129134
await GET(req(`fileId=${FILE_ID}`), params)
130135
expect(mockEnforcePerIp).toHaveBeenCalledTimes(1)
131-
expect(mockEnforcePerIp).toHaveBeenCalledWith(expect.anything(), 'content')
136+
expect(mockEnforcePerIp).toHaveBeenCalledWith(expect.anything(), 'inline')
132137
})
133138

134139
/**
135140
* One page of a shared document fans out to many inline requests, so the
136141
* aggregate per-share ceiling matters most here — the per-IP bucket alone does
137142
* not bound a link that is passed around.
138143
*/
139-
it('enforces the per-share content bucket with the resolved share id', async () => {
144+
it('enforces the per-share inline bucket with the resolved share id', async () => {
140145
await GET(req(`fileId=${FILE_ID}`), params)
141146
expect(mockEnforcePerShare).toHaveBeenCalledTimes(1)
142-
expect(mockEnforcePerShare).toHaveBeenCalledWith('content', 'sh_1')
147+
expect(mockEnforcePerShare).toHaveBeenCalledWith('inline', 'sh_1')
143148
})
144149

145150
it('never charges the per-share bucket for a request that fails the auth gate', async () => {

apps/sim/app/api/files/public/[token]/inline/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
3+
import { toError } from '@sim/utils/errors'
34
import type { NextRequest } from 'next/server'
45
import { NextResponse } from 'next/server'
56
import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares'
@@ -43,7 +44,7 @@ export const GET = withRouteHandler(
4344
const requestId = generateRequestId()
4445

4546
try {
46-
const limited = await enforcePerIpRateLimit(request, 'content')
47+
const limited = await enforcePerIpRateLimit(request, 'inline')
4748
if (limited) return limited
4849

4950
const parsed = await parseRequest(getPublicInlineFileContract, request, context)
@@ -76,7 +77,7 @@ export const GET = withRouteHandler(
7677
* storage — one page of a shared document fans out to many inline
7778
* requests.
7879
*/
79-
const shareLimited = await enforcePerShareRateLimit('content', resolved.share.id)
80+
const shareLimited = await enforcePerShareRateLimit('inline', resolved.share.id)
8081
if (shareLimited) return shareLimited
8182

8283
const { file: doc } = resolved
@@ -125,7 +126,7 @@ export const GET = withRouteHandler(
125126
return createErrorResponse(error)
126127
}
127128
logger.error('Error serving public inline image:', error)
128-
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
129+
return createErrorResponse(toError(error))
129130
}
130131
}
131132
)

apps/sim/app/api/files/serve-inline-image.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import type { NextResponse } from 'next/server'
33
import { downloadFile } from '@/lib/uploads/core/storage-service'
44
import type { ResolvedInlineImage } from '@/lib/uploads/server/inline-image'
55
import { sniffImageContentType } from '@/lib/uploads/utils/validation'
6-
import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
6+
import {
7+
createFileResponse,
8+
FileNotFoundError,
9+
REVALIDATE_CACHE_CONTROL,
10+
} from '@/app/api/files/utils'
711

812
const logger = createLogger('InlineImageServe')
913

@@ -13,7 +17,6 @@ const logger = createLogger('InlineImageServe')
1317
* server-side deletion/authorization check rather than serving a stale (possibly no-longer-authorized)
1418
* image from cache. Private so no shared cache/CDN ever stores it.
1519
*/
16-
const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
1720

1821
/**
1922
* Download and respond with an already-workspace-scoped inline image — the single serving tail for both
@@ -41,6 +44,6 @@ export async function serveInlineImage(
4144
buffer,
4245
contentType,
4346
filename: image.filename,
44-
cacheControl: INLINE_CACHE_CONTROL,
47+
cacheControl: REVALIDATE_CACHE_CONTROL,
4548
})
4649
}

apps/sim/app/api/files/serve/[...path]/route.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
6969

7070
vi.mock('@/lib/uploads/utils/file-utils', () => ({
7171
inferContextFromKey: mockInferContextFromKey,
72+
sanitizeFileKey: (key: string) => key,
73+
/**
74+
* `getContentType` resolves every extension through here now that the local
75+
* 41-entry copy of the MIME table is gone, so the mock has to answer for the
76+
* extensions these tests serve.
77+
*/
78+
resolveEffectiveMimeType: (_declared: string | undefined, filename: string) => {
79+
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
80+
const types: Record<string, string> = {
81+
pdf: 'application/pdf',
82+
jpg: 'image/jpeg',
83+
jpeg: 'image/jpeg',
84+
png: 'image/png',
85+
txt: 'text/plain',
86+
mp4: 'video/mp4',
87+
webm: 'video/webm',
88+
}
89+
return types[ext] ?? 'application/octet-stream'
90+
},
7291
}))
7392

7493
vi.mock('@/lib/uploads/setup.server', () => ({}))
@@ -93,6 +112,7 @@ vi.mock('@/app/api/files/utils', () => ({
93112
extractStorageKey: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
94113
extractFilename: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
95114
findLocalFile: mockFindLocalFile,
115+
REVALIDATE_CACHE_CONTROL: 'private, no-cache, must-revalidate',
96116
}))
97117

98118
import { GET } from '@/app/api/files/serve/[...path]/route'

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createReadStream } from 'fs'
22
import { readFile, stat } from 'fs/promises'
33
import { createLogger } from '@sim/logger'
4+
import { toError } from '@sim/utils/errors'
45
import type { NextRequest } from 'next/server'
56
import { NextResponse } from 'next/server'
67
import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer'
@@ -25,6 +26,7 @@ import {
2526
FileNotFoundError,
2627
findLocalFile,
2728
getContentType,
29+
REVALIDATE_CACHE_CONTROL,
2830
} from '@/app/api/files/utils'
2931

3032
const logger = createLogger('FilesServeAPI')
@@ -103,7 +105,6 @@ function getWorkspaceIdForCompile(key: string): string | undefined {
103105
}
104106

105107
const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable'
106-
const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
107108
/** For the genuinely-public, pre-auth asset routes (avatars, OG images, workspace logos) — these are
108109
* intentionally shared-cacheable. Passed EXPLICITLY so the default response cache stays `private`. */
109110
const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000'
@@ -120,7 +121,7 @@ function resolveServeCacheControl(
120121
context: string | undefined
121122
): string | undefined {
122123
if (versioned) return IMMUTABLE_CACHE_CONTROL
123-
return context === 'workspace' ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined
124+
return context === 'workspace' ? REVALIDATE_CACHE_CONTROL : undefined
124125
}
125126

126127
export const GET = withRouteHandler(
@@ -217,7 +218,7 @@ export const GET = withRouteHandler(
217218
return createErrorResponse(error)
218219
}
219220

220-
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
221+
return createErrorResponse(toError(error))
221222
}
222223
}
223224
)
@@ -227,7 +228,7 @@ async function handleLocalFile(
227228
userId: string,
228229
options: ServeOptions,
229230
signal: AbortSignal | undefined,
230-
rangeHeader: string | null = null
231+
rangeHeader: string | null
231232
): Promise<NextResponse> {
232233
const ownerKey = `user:${userId}`
233234
try {
@@ -262,14 +263,15 @@ async function handleLocalFile(
262263
* than a storage key, so it opens its own stream — without this branch the
263264
* scrubber works against cloud storage and silently dies on a dev machine.
264265
*/
265-
if (isMediaContentType(getContentType(displayName))) {
266+
const mediaType = getContentType(displayName)
267+
if (isMediaContentType(mediaType)) {
266268
const { size } = await stat(filePath)
267269
logger.info('Local media served', { userId, filename, size })
268270

269271
return await createByteRangeResponse({
270272
openStream: (range) => createReadStream(filePath, range),
271273
size,
272-
contentType: getContentType(displayName),
274+
contentType: mediaType,
273275
filename: displayName,
274276
cacheControl: resolveServeCacheControl(options.versioned, contextParam),
275277
rangeHeader,
@@ -307,7 +309,7 @@ async function handleCloudProxy(
307309
userId: string,
308310
options: ServeOptions,
309311
signal: AbortSignal | undefined,
310-
rangeHeader: string | null = null
312+
rangeHeader: string | null
311313
): Promise<NextResponse> {
312314
const ownerKey = `user:${userId}`
313315
try {
@@ -337,7 +339,8 @@ async function handleCloudProxy(
337339
* generated-document compile, which no media file is subject to. `copilot`
338340
* keeps the buffered path — its bytes come from a different reader.
339341
*/
340-
if (context !== 'copilot' && isMediaContentType(getContentType(displayName))) {
342+
const mediaType = getContentType(displayName)
343+
if (context !== 'copilot' && isMediaContentType(mediaType)) {
341344
const head = await headObject(cloudKey, context)
342345
if (!head) throw new FileNotFoundError(`File not found: ${cloudKey}`)
343346

@@ -346,7 +349,7 @@ async function handleCloudProxy(
346349
return await createByteRangeResponse({
347350
openStream: (range) => downloadFileStream({ key: cloudKey, context, range }),
348351
size: head.size,
349-
contentType: getContentType(displayName),
352+
contentType: mediaType,
350353
filename: displayName,
351354
cacheControl: resolveServeCacheControl(options.versioned, context),
352355
rangeHeader,

apps/sim/app/api/files/utils.ts

Lines changed: 35 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
parseByteRange,
1010
unsatisfiableContentRangeHeader,
1111
} from '@/lib/uploads/utils/byte-range'
12-
import { getMimeTypeFromExtension, sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
12+
import { resolveEffectiveMimeType, sanitizeFileKey } from '@/lib/uploads/utils/file-utils'
1313

1414
const logger = createLogger('FilesUtils')
1515

@@ -44,79 +44,51 @@ export class InvalidRequestError extends Error {
4444
}
4545
}
4646

47-
export const contentTypeMap: Record<string, string> = {
48-
txt: 'text/plain',
49-
csv: 'text/csv',
50-
json: 'application/json',
51-
xml: 'application/xml',
52-
md: 'text/markdown',
53-
html: 'text/html',
54-
css: 'text/css',
55-
js: 'application/javascript',
56-
ts: 'application/typescript',
57-
pdf: 'application/pdf',
47+
/**
48+
* The pseudo-extensions no real filename ends in, so no MIME table knows them.
49+
*
50+
* Everything else resolves through {@link resolveEffectiveMimeType}. This map
51+
* used to carry 41 entries, 38 of which duplicated `EXTENSION_TO_MIME`
52+
* byte-for-byte — and that duplication was the bug: a container present in one
53+
* table and missing from the other (`.mkv`, `.flac`, `.aac`, `.opus`, `.avi`)
54+
* resolved to `application/octet-stream`, which silently disabled byte-range
55+
* serving and left it unseekable. Two tables cannot be kept in agreement by
56+
* hand, so there is now one.
57+
*/
58+
/**
59+
* Cache policy for bytes a viewer must be re-authorized for on every request:
60+
* private, and revalidated rather than served from the browser's back-forward
61+
* cache after access is revoked.
62+
*
63+
* One export because it is the caching policy for every authenticated and every
64+
* shared byte, and it was previously written out in four places across three
65+
* files — kept in agreement only by eye.
66+
*/
67+
export const REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
68+
69+
const GOOGLE_PSEUDO_MIME: Record<string, string> = {
5870
googleDoc: 'application/vnd.google-apps.document',
59-
doc: 'application/msword',
60-
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
61-
xls: 'application/vnd.ms-excel',
62-
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
6371
googleSheet: 'application/vnd.google-apps.spreadsheet',
64-
ppt: 'application/vnd.ms-powerpoint',
65-
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
66-
png: 'image/png',
67-
jpg: 'image/jpeg',
68-
jpeg: 'image/jpeg',
69-
gif: 'image/gif',
70-
svg: 'image/svg+xml',
71-
webp: 'image/webp',
72-
avif: 'image/avif',
73-
bmp: 'image/bmp',
74-
ico: 'image/x-icon',
75-
mp3: 'audio/mpeg',
76-
m4a: 'audio/mp4',
77-
wav: 'audio/wav',
78-
ogg: 'audio/ogg',
79-
flac: 'audio/flac',
80-
aac: 'audio/aac',
81-
opus: 'audio/opus',
82-
mp4: 'video/mp4',
83-
mov: 'video/quicktime',
84-
avi: 'video/x-msvideo',
85-
mkv: 'video/x-matroska',
86-
webm: 'video/webm',
87-
zip: 'application/zip',
8872
googleFolder: 'application/vnd.google-apps.folder',
8973
}
9074

91-
export const binaryExtensions = [
92-
'doc',
93-
'docx',
94-
'xls',
95-
'xlsx',
96-
'ppt',
97-
'pptx',
98-
'zip',
99-
'png',
100-
'jpg',
101-
'jpeg',
102-
'gif',
103-
'webp',
104-
'pdf',
105-
]
106-
10775
/**
10876
* Content type for a stored file, by extension.
10977
*
110-
* {@link contentTypeMap} is consulted first because it carries entries the
111-
* extension map has no notion of (the Google Workspace pseudo-types), then the
112-
* canonical {@link getMimeTypeFromExtension} covers everything else — audio and
113-
* video included. Keeping only the local map meant `.mp4` resolved to
114-
* `application/octet-stream`, which silently disabled byte-range serving and
115-
* left media unseekable.
78+
* {@link GOOGLE_PSEUDO_MIME} is consulted first for the three Workspace
79+
* pseudo-extensions no MIME table knows; everything else goes to
80+
* {@link resolveEffectiveMimeType}.
81+
*
82+
* That resolver rather than the raw extension table because what a response
83+
* declares IS presentation, and it is the only one that knows a dual container.
84+
* `.webm` holds either audio or video; the extension table answers `audio/webm`
85+
* so the speech-to-text route does not send it down an ffmpeg path it does not
86+
* need, while the viewer routes it to a `<video>`. Declaring `audio/webm` on the
87+
* wire would leave one user-facing surface disagreeing with the rest.
11688
*/
11789
export function getContentType(filename: string): string {
11890
const extension = filename.split('.').pop()?.toLowerCase() || ''
119-
return contentTypeMap[extension] || getMimeTypeFromExtension(extension)
91+
return GOOGLE_PSEUDO_MIME[extension] || resolveEffectiveMimeType(undefined, filename)
12092
}
12193

12294
export function extractFilename(path: string): string {

0 commit comments

Comments
 (0)