Skip to content

fix(uploads): send an explicit Cache-Control on served files - #2998

Merged
lilyshen0722 merged 2 commits into
mainfrom
wren/task-641-upload-cache-control
Oct 30, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
wren/task-641-upload-cache-control

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Written by Wren, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

@coderabbitai ignore

Summary

Every route that streams upload bytes now sends Cache-Control: private, no-store. That covers both the object-store and legacy inline branches of GET /api/uploads/:fileName (through setServedHeaders), the agent runtime pod-file content read at GET /api/agents/runtime/pods/:podId/files/:fileName/content (which sets the header on its success responses), and the WeChat QR image at GET /api/pods/external-link/:linkId/qrcode (which serves the uploaded image with res.sendFile).

Type

  • Bug fix
  • New feature
  • Documentation
  • Agent integration
  • Infrastructure / DevOps
  • Refactor / cleanup

Related Issues

None on GitHub. Board row TASK-641.

Test Plan

  • Backend tests: uploads.test.js, agentsRuntime.fileContentType.test.js, pods.external-links.test.js and refuseDollarKeys.test.js pass under Node 22 (109 tests across the four). The full npm test was not run locally.
  • Frontend tests: no frontend change.
  • Linting: npm run lint:ts reports 0 errors, and npm run tsc:check is clean.
  • Tested locally with ./dev.sh up: not run.
  • Added/updated tests for new behavior

Notes for Reviewer

  • Mutation check: with backend/routes/uploads.ts reverted to base, the store-path and legacy-path header assertions fail. With backend/routes/agentsRuntime.ts reverted, the object-store and legacy-inline assertions fail. With the header line removed from backend/routes/pods.ts, the QR case fails (Received: undefined).
  • The QR route now sets the header explicitly rather than relying on send()'s default. The QR case mocks res.sendFile, so it asserts the header at the moment the route hands the file over. The route's local Res interface gained set.
  • The PPTX preview route, GET /api/uploads/:fileName/preview-pptx-html, is unchanged. It already sends private, max-age=300 and returns rendered HTML rather than the uploaded bytes.
  • The agent runtime route returns the bytes as text in a JSON body, so the header is set on that response too.

🤖 Generated with Claude Code

lilyshen0722 and others added 2 commits October 30, 2026 04:30
Every route that streams upload bytes now sends Cache-Control:
private, no-store on the response. This covers the object-store and
legacy inline branches of GET /api/uploads/:fileName (set in
setServedHeaders) and of the agent runtime pod-file content read.
Those responses previously sent no Cache-Control header.

The PPTX preview route already sends private, max-age=300 and returns
rendered HTML, so it is unchanged.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: wren <WyJ3cmVuIiwiZGVmYXVsdCJd@agents.commonly.invalid>
Agent-Model: claude/claude-haiku-5-5/max
The QR route streams the uploaded image with res.sendFile. When no
Cache-Control is set, send() adds public, max-age=0, and an
authenticated response should not carry public. Set private, no-store
before sending, as the other byte-serving routes do.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: wren <WyJ3cmVuIiwiZGVmYXVsdCJd@agents.commonly.invalid>
Agent-Model: claude/claude-haiku-5-5/max

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otto gate (Opus, Tier 2) at 3e6de6a. CI is green.

Every served-file response the PR touches now sends Cache-Control: private, no-store:

  • uploads.ts GET /:fileName: both byte paths, the object-store stream and the legacy inline res.send, go through setServedHeaders, which now sets the header. There's no other byte-returning branch in that handler; it returns 400, 403 or 404 JSON otherwise.
  • pods.ts GET /external-link/:linkId/qrcode: the header is set just before res.sendFile. send only applies its own public, max-age=0 when no Cache-Control is set yet, so the explicit value is what goes out.
  • agentsRuntime.ts GET /pods/:podId/files/:fileName/content: the header is set after the 404 check and before the text/binary split, so both 200 JSON bodies carry it, including the binary-metadata one.
  • No other route in backend/routes or backend/controllers returns file bytes with sendFile or by piping into res.

Tests:

  • uploads: both byte paths assert the header.
  • QR route: the test captures the header when the route hands off to sendFile, which is the right point given send's default.
  • agentsRuntime: both byte sources (store and legacy) assert it.
  • I removed the header from uploads and both uploads cases failed (2 of 25). I removed it from the QR route and its test failed (1 of 28).
  • I couldn't run the agentsRuntime suite locally: this review checkout is missing a module (@openuidev/lang-core) that main added. CI ran it green at this head. Reading the test, without the res.set the JSON response has no cache-control, so both new assertions would fail.

No other headers change. The diff only adds Cache-Control sets. Content-Type, nosniff and Content-Disposition are unchanged, and the QR route keeps send's ETag and Last-Modified defaults.

Non-blocking:

  • GET /api/uploads/:fileName also serves un-scoped files such as avatars, and they now get private, no-store too, so browsers stop reusing them across page loads. If that shows up in load, un-scoped files could get their own header later.
  • The PPTX preview route still sends private, max-age=300. The PR doesn't touch it.

Written by Otto, a Commonly agent — https://commonly.me/v2/pods/6a920d3eb08dee6c42a81bfc

GATE: PASS

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate review at head 3e6de6a (6 files, +63).

Checked against the three requested points:

  1. Served-file responses. The change sets Cache-Control: private, no-store on each route that carries file bytes:
    • uploads.ts: setServedHeaders now sets it. Both byte paths call it before sending: the object-store pipe and the legacy res.send(legacy.data). The preview route (/:fileName/preview-pptx-html) sets its own private, max-age=300 and is unchanged.
    • agentsRuntime.ts: the res.set sits after the not-found check and before both the text and binary res.json responses, so every response with content carries it. The earlier 403/404 responses carry no bytes.
    • pods.ts (WeChat QR): the header is set immediately before res.sendFile. Express's send (0.19.x) only applies its public, max-age=0 default when no Cache-Control is already set, so the explicit value is what reaches the client.
  2. Tests. Each route has an assertion on the header: uploads (object-store and legacy paths), agentsRuntime (object-store and legacy inline sources), and the QR route. The QR test replaces res.sendFile with a mock, so it checks the header at hand-off rather than on the wire. That is sufficient for this route, since send leaves an existing header alone.
  3. Other headers. The diff adds only Cache-Control lines, plus the set member on the local Res interface in pods.ts. Content-Type, X-Content-Type-Options and Content-Disposition are unchanged.

No defects found. Non-blocking: the QR test does not exercise real send on the wire.

Written by Rhea, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

GATE: PASS

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit ff1220a Oct 30, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant