fix(webapp): a page's picture is as private as the page (#894) #927

Merged
claude-bot merged 3 commits from fix/894-private-page-pictures into main 2026-09-29 08:43:28 +00:00
Collaborator

Fixes #894, and a second door to the same kind of file found on the way.

The bug. A wiki page's picture was written to the public media store and served by nginx's /media/ with public, max-age=31536000, immutable, whatever the page's audience. A GM-only page's picture was anyone's who had or was forwarded the URL, and it kept working after the page narrowed.

The fix follows handout attachments (W7):

  • Pictures are stored under media/private/ and served only by GET /api/campaigns/{cid}/lore/{id}/image. That route checks the reader against the page on every request (a handout reveal opens it too, as for the page) and answers with X-Accel-Redirect. image_url is that route, versioned by content hash.
  • Existing pictures: nginx now refuses /media/campaigns/ (only page pictures ever lived there), so every old public address stops working at once. A page read presents its old URL as the route, and the route moves the file into the private store the first time it serves it. No migration: the migrate container can't see the media volume.
  • A GM's picture from a typed web address (W16) is left alone.

Also fixed: the backend's own /media StaticFiles mount (main.py, for the dev stack or anywhere the backend's port is reachable) served the whole volume, private/ included: every handout, private map and tile, by URL with no check. It refuses private/ and campaigns/ now, as nginx does.

Checked

  • New tests/wiki/test_page_picture_privacy.py: storage path; GM 200 / player 404 / non-member refused; cache headers; legacy move on first read, with the page presenting the route; delete removes legacy copies; typed URLs untouched; the backend mount refuses private/, campaigns/ and a ../ walk but serves public files. The nginx test pins the new block. The import test expects the route. 159 related tests pass; ruff clean.
  • Real nginx (the e2e stack's frontend-prod image): GM 200 image/png private, no-store; Priya 404; table page 200 private, max-age=3600; /media/campaigns/…, /media/private/… and /internal-files/… all 404; the picture displays on Priya's article page.

Deploy note: after this lands, dev's existing pictures move to private storage one at a time as they're viewed; nothing needs running.

🤖 Generated with Claude Code

Fixes #894, and a second door to the same kind of file found on the way. **The bug.** A wiki page's picture was written to the public media store and served by nginx's `/media/` with `public, max-age=31536000, immutable`, whatever the page's audience. A GM-only page's picture was anyone's who had or was forwarded the URL, and it kept working after the page narrowed. **The fix** follows handout attachments (W7): - Pictures are stored under `media/private/` and served only by `GET /api/campaigns/{cid}/lore/{id}/image`. That route checks the reader against the page on every request (a handout reveal opens it too, as for the page) and answers with `X-Accel-Redirect`. `image_url` is that route, versioned by content hash. - **Existing pictures:** nginx now refuses `/media/campaigns/` (only page pictures ever lived there), so every old public address stops working at once. A page read presents its old URL as the route, and the route moves the file into the private store the first time it serves it. No migration: the migrate container can't see the media volume. - A GM's picture from a typed web address (W16) is left alone. **Also fixed:** the backend's own `/media` StaticFiles mount (`main.py`, for the dev stack or anywhere the backend's port is reachable) served the **whole** volume, `private/` included: every handout, private map and tile, by URL with no check. It refuses `private/` and `campaigns/` now, as nginx does. **Checked** - New `tests/wiki/test_page_picture_privacy.py`: storage path; GM 200 / player 404 / non-member refused; cache headers; legacy move on first read, with the page presenting the route; delete removes legacy copies; typed URLs untouched; the backend mount refuses `private/`, `campaigns/` and a `../` walk but serves public files. The nginx test pins the new block. The import test expects the route. 159 related tests pass; ruff clean. - **Real nginx** (the e2e stack's frontend-prod image): GM 200 `image/png` `private, no-store`; Priya 404; table page 200 `private, max-age=3600`; `/media/campaigns/…`, `/media/private/…` and `/internal-files/…` all 404; the picture displays on Priya's article page. **Deploy note:** after this lands, dev's existing pictures move to private storage one at a time as they're viewed; nothing needs running. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(webapp): a page's picture is as private as the page (#894)
Some checks failed
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Has been cancelled
CI / Bot tests and audit (pull_request) Has been cancelled
CI / Backend lint (ruff) (pull_request) Has been cancelled
CI / Feedback site tests (pull_request) Has been cancelled
CI / Bot/backend version sync (pull_request) Has been cancelled
CI / Docker image build (pull_request) Has been cancelled
CI / Does this change touch the recording path? (pull_request) Has been cancelled
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Has been cancelled
CI / Frontend tests, audit, and build (pull_request) Has been cancelled
9d7352879b
A page's picture was written to the public media store and served by
nginx's /media/ with public, cache-forever headers, whatever the page's
audience: a GM-only page's picture was anyone's who had the URL.

- Pictures are stored under media/private/ and served by
  GET /lore/{id}/image, which checks the reader against the page (a
  handout reveal opens it too) and answers with X-Accel-Redirect, like
  attachments (W7). image_url is that route, versioned by content.
- A picture stored before this moves into the private store the first
  time the route serves it; the page already names the route when read.
  nginx refuses /media/campaigns/, where they lived, so every old
  address stops working at once rather than picture by picture.
- The backend's own /media mount (the dev stack, or anywhere its port is
  reachable) served the whole volume, private/ included: every handout,
  private map and tile, with no check. It refuses private/ and
  campaigns/ now, as nginx does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ci: run the full pipeline now that #927 targets main
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m1s
CI / Backend lint (ruff) (pull_request) Has been cancelled
CI / Feedback site tests (pull_request) Has been cancelled
CI / Bot/backend version sync (pull_request) Has been cancelled
CI / Docker image build (pull_request) Has been cancelled
CI / Does this change touch the recording path? (pull_request) Has been cancelled
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Has been cancelled
CI / Frontend tests, audit, and build (pull_request) Has been cancelled
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
CI / Bot tests and audit (pull_request) Has been cancelled
2ca30cfd3c
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ci: re-run (the runner died mid-job with three runs at once)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 38s
CI / Feedback site tests (pull_request) Successful in 49s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m4s
CI / Does this change touch the recording path? (pull_request) Successful in 24s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot/backend version sync (pull_request) Successful in 1m17s
CI / Docker image build (pull_request) Successful in 1m24s
CI / Bot tests and audit (pull_request) Successful in 2m4s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 24m43s
CI / Backend migration, tests, and audit (pull_request) Successful in 32m17s
CI / Frontend tests, audit, and build (pull_request) Successful in 33m37s
c646e40ee1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.