Skip to content

Commit dde9ff5

Browse files
committed
fix(files): bound the content-addressed inline-image cache so deletions propagate
A one-year immutable cache on key= embeds bypassed the server-side deletion/revocation check for up to a year. Content-addressed bytes are stable but availability is not, so cache them with a short revalidating window (private, max-age=300, must-revalidate) instead of immutable: still avoids the re-download storm on rapid doc re-opens, but a deleted or access-revoked image drops out within ~5 minutes. fileId= embeds and the public-share route are unchanged.
1 parent 144c506 commit dde9ff5

3 files changed

Lines changed: 22 additions & 17 deletions

File tree

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,31 @@ const logger = createLogger('InlineImageServe')
99

1010
/**
1111
* A `fileId=` embed (or a shared/revocable audience) must never serve stale bytes from its fixed inline
12-
* URL, so it revalidates on each request. See `immutable` below for the cacheable case.
12+
* URL, so it revalidates on each request. See the content-addressed constant below for the cacheable case.
1313
*/
1414
const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
1515

1616
/**
17-
* A `key=` embed addresses a CONTENT-ADDRESSED, immutable storage key (a re-upload mints a new key), so
18-
* its bytes never change — safe to cache hard in the (private) browser cache, avoiding a re-download of
19-
* every embedded image on each doc re-open/re-render. NEVER use this for the public-share route (a share
20-
* can be revoked) or a `fileId=` embed (the underlying key can change under a stable fileId).
17+
* A `key=` embed addresses a CONTENT-ADDRESSED storage key (a re-upload mints a new key), so its bytes
18+
* never change — safe to reuse briefly from the (private) browser cache, avoiding a re-download of every
19+
* embedded image on each doc re-open/re-render. A SHORT window (not `immutable`): the bytes are stable but
20+
* the file can still be DELETED or its access REVOKED, and each revalidation re-runs that server-side
21+
* check, so a short max-age bounds how long a deleted/revoked image can linger in a client's cache. NEVER
22+
* use this for a `fileId=` embed (the underlying key can change under a stable fileId → stale bytes).
2123
*/
22-
const INLINE_IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable'
24+
const INLINE_CONTENT_ADDRESSED_CACHE_CONTROL = 'private, max-age=300, must-revalidate'
2325

2426
/**
2527
* Download and respond with an already-workspace-scoped inline image — the single serving tail for both
2628
* the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the
2729
* served content type is derived from the bytes and non-raster content is refused with 404; otherwise the
28-
* stored content type is served, matching the in-app serve route. `immutable` opts a content-addressed
29-
* (`key=`) in-app embed into a long private cache; leave it false for `fileId=` embeds and public shares.
30+
* stored content type is served, matching the in-app serve route. `contentAddressed` opts a
31+
* content-addressed (`key=`) in-app embed into a short private cache; leave it false for `fileId=` embeds
32+
* and public shares.
3033
*/
3134
export async function serveInlineImage(
3235
image: ResolvedInlineImage,
33-
{ sniff, immutable = false }: { sniff: boolean; immutable?: boolean }
36+
{ sniff, contentAddressed = false }: { sniff: boolean; contentAddressed?: boolean }
3437
): Promise<NextResponse> {
3538
const buffer = await downloadFile({ key: image.key, context: 'workspace' })
3639

@@ -48,6 +51,6 @@ export async function serveInlineImage(
4851
buffer,
4952
contentType,
5053
filename: image.filename,
51-
cacheControl: immutable ? INLINE_IMMUTABLE_CACHE_CONTROL : INLINE_CACHE_CONTROL,
54+
cacheControl: contentAddressed ? INLINE_CONTENT_ADDRESSED_CACHE_CONTROL : INLINE_CACHE_CONTROL,
5255
})
5356
}

apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ describe('GET /api/workspaces/[id]/files/inline', () => {
4646
expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate')
4747
})
4848

49-
it('serves a workspace-scoped image by key with an immutable (content-addressed) cache', async () => {
49+
it('serves a workspace-scoped image by key with a short content-addressed cache', async () => {
5050
const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params)
5151
expect(res.status).toBe(200)
52-
// A `key=` embed addresses an immutable storage key → cache hard (privately) to avoid re-downloads.
53-
expect(res.headers.get('Cache-Control')).toBe('private, max-age=31536000, immutable')
52+
// A `key=` embed addresses a content-addressed storage key → cache briefly (privately) to avoid
53+
// re-download storms, but revalidate often enough that a deleted image drops out (not `immutable`).
54+
expect(res.headers.get('Cache-Control')).toBe('private, max-age=300, must-revalidate')
5455
})
5556

5657
it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => {

apps/sim/app/api/workspaces/[id]/files/inline/route.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ export const GET = withRouteHandler(
4747
throw new FileNotFoundError('Not found')
4848
}
4949

50-
// A `key=` embed addresses a content-addressed, immutable storage key → cache it hard (privately)
51-
// so re-opening a doc doesn't re-download every embedded image. A `fileId=` embed can point at new
52-
// bytes after a re-upload, so it must keep revalidating.
53-
return await serveInlineImage(image, { sniff: false, immutable: 'key' in ref })
50+
// A `key=` embed addresses a content-addressed storage key → cache it briefly (privately) so
51+
// re-opening a doc doesn't re-download every embedded image, while still revalidating often enough
52+
// that a deleted/revoked image drops out. A `fileId=` embed can point at new bytes after a re-upload,
53+
// so it must revalidate every time.
54+
return await serveInlineImage(image, { sniff: false, contentAddressed: 'key' in ref })
5455
} catch (error) {
5556
if (error instanceof FileNotFoundError) {
5657
return createErrorResponse(error)

0 commit comments

Comments
 (0)