Skip to content

Commit 04e8424

Browse files
committed
fix(files): serve inline images with no-cache so deletion/authorization is enforced per request
Embedded images are authenticated content whose backing file can be deleted or have its access revoked at any time. Both key= and fileId= embeds serve private, no-cache, must-revalidate, so every request re-runs the server-side deletion/authorization check instead of serving a possibly-stale image from cache.
1 parent 144c506 commit 04e8424

3 files changed

Lines changed: 14 additions & 23 deletions

File tree

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

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,22 @@ import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils'
88
const logger = createLogger('InlineImageServe')
99

1010
/**
11-
* 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.
11+
* An embedded image is authenticated content served from a fixed inline URL, and the file behind it can
12+
* be DELETED or its access REVOKED at any time — so it always revalidates, letting each request re-run the
13+
* server-side deletion/authorization check rather than serving a stale (possibly no-longer-authorized)
14+
* image from cache. Private so no shared cache/CDN ever stores it.
1315
*/
1416
const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
1517

16-
/**
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).
21-
*/
22-
const INLINE_IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable'
23-
2418
/**
2519
* Download and respond with an already-workspace-scoped inline image — the single serving tail for both
2620
* the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the
2721
* 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.
22+
* stored content type is served, matching the in-app serve route.
3023
*/
3124
export async function serveInlineImage(
3225
image: ResolvedInlineImage,
33-
{ sniff, immutable = false }: { sniff: boolean; immutable?: boolean }
26+
{ sniff }: { sniff: boolean }
3427
): Promise<NextResponse> {
3528
const buffer = await downloadFile({ key: image.key, context: 'workspace' })
3629

@@ -48,6 +41,6 @@ export async function serveInlineImage(
4841
buffer,
4942
contentType,
5043
filename: image.filename,
51-
cacheControl: immutable ? INLINE_IMMUTABLE_CACHE_CONTROL : INLINE_CACHE_CONTROL,
44+
cacheControl: INLINE_CACHE_CONTROL,
5245
})
5346
}

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,20 @@ describe('GET /api/workspaces/[id]/files/inline', () => {
3838
mockDownloadFile.mockResolvedValue(PNG)
3939
})
4040

41-
it('serves a workspace-scoped image by fileId (revalidated — the key can change on re-upload)', async () => {
41+
it('serves a workspace-scoped image by fileId, always revalidating', async () => {
4242
const res = await GET(req('fileId=wf_abc'), params)
4343
expect(res.status).toBe(200)
4444
expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' })
45-
// A fileId points at whatever bytes are current, so it must NOT be cached immutably.
45+
// Authenticated content: always revalidate so a deletion/revocation is enforced on the next request.
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, always revalidating', 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+
// Same policy as fileId: authenticated content never cached past a revalidation, so a deleted or
53+
// access-revoked image drops out immediately rather than lingering in a private browser cache.
54+
expect(res.headers.get('Cache-Control')).toBe('private, no-cache, 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: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,7 @@ 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+
return await serveInlineImage(image, { sniff: false })
5451
} catch (error) {
5552
if (error instanceof FileNotFoundError) {
5653
return createErrorResponse(error)

0 commit comments

Comments
 (0)