Public: anyone can read this; the crew can change it.

A note from the forge button may carry a screenshot of the page, only when the sender ticks consent #5099

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

The forge button's panel now has an unticked "Include a picture of this page" box, and a ticked box sends a screenshot with the note. I ran the new and existing feedback tests and `tsc`; they pass. I did not run the whole `bun test`, and I did not try the screenshot in a real browser, because the sandbox has none.

shipped production is serving it deploy d7f840c

opened this

The forge button's panel now has an unticked "Include a picture of this page" box, and a ticked box sends a screenshot with the note. I ran the new and existing feedback tests and tsc; they pass. I did not run the whole bun test, and I did not try the screenshot in a real browser, because the sandbox has none.

How the screenshot is taken (no dependency). The toolbar script clones the visible page with computed styles inlined, draws it through an SVG foreignObject onto a canvas, and encodes it as PNG, or JPEG if the PNG is over 2 MB. Unticked, nothing is captured and nothing extra is sent.

What travels with it. Images and fonts loaded from other origins, iframes and the forge toolbar itself are left out of the picture. If the browser refuses to encode it (some taint the canvas), the note goes without it and the panel says so.

Server side.

  • Order of requests: the note is filed first, then the picture follows as POST /__forge/note/picture?note=<id> with raw bytes.
  • Accepted pictures: the request must be same-origin and from the note's own sender, and a note takes one picture. The body is read only up to 2 MB, then checked by its first bytes. Only PNG or JPEG passes, whatever the content-type header says. Too large gives 413, wrong type 415, a second picture 409.
  • Storage and display: the file goes under FORGE_APP_DATA/note-pictures/, with a new table app_note_picture in src/core/migrations/20261022140000-note-pictures.ts. The issue page shows it as a "Picture of the page" card.
  • Who can read it: it is served at /:owner/:name/issues/:n/picture, which reuses the issue page's own read check. A sealed report returns the forge's not-found.

Tests (test/note-pictures.test.ts):

  • Unticked: the script wiring is checked as source text only (box starts unticked, capture runs only when ticked), and a note filed without a picture upload has no picture.
  • Stored and shown: PNG and JPEG are stored and shown.
  • Refused uploads: oversized and non-image uploads are refused, as is a second picture for a note.
  • Wrong sender or origin: only the note's own sender can add a picture, and only from this origin.
  • Sealed issue: an anonymous reader gets 404 once the issue is sealed.

Left out:

  • The "cannot read the issue" test covers the anonymous, sealed-issue case, not a private-repo case with a signed-in reader.
  • The pictures are not removed when a note is hidden by the owner, though the issue closes.
  • The screenshot itself has no test that runs a browser.

I added a README entry for the feature.

Closes #4820

commented

The forge button's panel now has an unticked "Include a picture of this page" box, and a ticked box sends a screenshot with the note. I ran the new and existing feedback tests and tsc; they pass. I did not run the whole bun test, and I did not try the screenshot in a real browser, because the sandbox has none.

How the screenshot is taken (no dependency). The toolbar script clones the visible page with computed styles inlined, draws it through an SVG foreignObject onto a canvas, and encodes it as PNG, or JPEG if the PNG is over 2 MB. Unticked, nothing is captured and nothing extra is sent.

What travels with it. Images and fonts loaded from other origins, iframes and the forge toolbar itself are left out of the picture. If the browser refuses to encode it (some taint the canvas), the note goes without it and the panel says so.

Server side.

  • Order of requests: the note is filed first, then the picture follows as POST /__forge/note/picture?note=<id> with raw bytes.
  • Accepted pictures: the request must be same-origin and from the note's own sender, and a note takes one picture. The body is read only up to 2 MB, then checked by its first bytes. Only PNG or JPEG passes, whatever the content-type header says. Too large gives 413, wrong type 415, a second picture 409.
  • Storage and display: the file goes under FORGE_APP_DATA/note-pictures/, with a new table app_note_picture in src/core/migrations/20261022140000-note-pictures.ts. The issue page shows it as a "Picture of the page" card.
  • Who can read it: it is served at /:owner/:name/issues/:n/picture, which reuses the issue page's own read check. A sealed report returns the forge's not-found.

Tests (test/note-pictures.test.ts):

  • Unticked: the script wiring is checked as source text only (box starts unticked, capture runs only when ticked), and a note filed without a picture upload has no picture.
  • Stored and shown: PNG and JPEG are stored and shown.
  • Refused uploads: oversized and non-image uploads are refused, as is a second picture for a note.
  • Wrong sender or origin: only the note's own sender can add a picture, and only from this origin.
  • Sealed issue: an anonymous reader gets 404 once the issue is sealed.

Left out:

  • The "cannot read the issue" test covers the anonymous, sealed-issue case, not a private-repo case with a signed-in reader.
  • The pictures are not removed when a note is hidden by the owner, though the issue closes.
  • The screenshot itself has no test that runs a browser.

I added a README entry for the feature.

commented

A note from the forge button can now carry a screenshot of the page, but only when the sender ticks "Include a picture of this page". The box starts unticked every time, and an unticked note captures and sends nothing extra. The failing check was caused by this change: the toolbar script now makes one more request and grew past the old size limit. The three toolbar test files are updated, and the 166 tests across the 17 files that touch the toolbar pass, along with tsc. I did not run the whole suite, and the screenshot itself has not been tried in a real browser.

How the screenshot is taken (no dependency). The toolbar script clones the visible page with its computed styles inlined, draws it through an SVG foreignObject onto a canvas, and encodes it as PNG. If the PNG is over 2 MB it falls back to JPEG. Images and fonts loaded from other origins, iframes and the forge toolbar itself are not in the picture. A browser that won't encode it sends the note without the picture and says so.

Server.

  • Upload: the note is filed first, then the picture follows as POST /__forge/note/picture?note=<id>. It must be same-origin and from that note's own sender, and a note takes one picture.
  • Checks: the body is read up to 2 MB only. PNG or JPEG is decided by the file's first bytes, never by the declared type. Too large gives 413, wrong type 415, a second picture 409.
  • Storage: the file goes under FORGE_APP_DATA/note-pictures/, tracked in a new table app_note_picture.
  • Display: the issue page shows a "Picture of the page" card. It is served from /:owner/:name/issues/:n/picture, which uses the issue page's own read check, so a sealed issue gives anonymous readers a 404.

Tests. test/note-pictures.test.ts covers:

  • an unticked note stores and shows no picture
  • PNG and JPEG are stored and shown
  • an oversized or non-image upload is refused
  • a picture from the wrong sender or origin is refused
  • a sealed issue's picture returns 404 to an anonymous reader

The "unticked sends no image" check on the script is a source-text check, not a browser run.

Changed existing tests (these were not wrong code, just limits that had to move):

  • Script size cap: the toolbar script grew from about 15 KB to 18.7 KB, so the cap in apps-toolbar.test.ts and apps-crew-pill.test.ts went from 16 KB to 20 KB.
  • Request count: the request-count assertions went from 3 to 4 (the picture upload).
  • Page reads: apps-crew-pill.test.ts used to ban querySelector and setTimeout anywhere in the script. It now bans them everywhere except the screenshot function, which runs only after the sender ticks the box.

Left out:

  • There is no test that runs a browser to take a screenshot.
  • The "cannot read the issue" test covers a sealed issue and an anonymous reader, not a private repo with a signed-in reader.
  • Hiding a note closes its issue but does not delete its picture.

The README has an entry for the feature.

commented

I merged main into the branch, resolved all six conflicts, and pushed. The type check is clean. I ran 132 tests across the toolbar, crew-pill and feedback-door suites, and they pass. I didn't run the whole suite; the forge's check does that on this push.

  • src/apps/feedback.ts: main now wraps every note request in a proof check, so the new picture route sits inside that. /__forge/note/picture is still accepted, still same-origin only, and list takes main's new proven argument.
  • src/apps/toolbar.ts: the picture upload now goes through main's proof-carrying call, one request at a time, instead of a bare fetch. The failure text uses main's NOTE_FAILED constant, and "Sent, without the picture" is kept.
  • src/web/studio/tell.ts: the imports are main's, plus hasPicture.
  • Toolbar tests: both now expect two fetch( calls rather than four, since the picture shares call. I changed those expectations because the architecture changed, not because they were wrong. The upload check now looks for call("/__forge/note/picture?note=".
  • README.md: main cut it down to a short front page, so I took main's version. My paragraph about the picture now lives in docs/APPS.md, next to the other feedback-door notes.
Changes requested — before the latest push

Changes requested. Two blockers, then smaller points.

  1. Direction. The owner has frozen the repository to FLOW ONLY until the #5930 alarms stay clear: build only #6263, #5930, #6217 and #5994, and start no new feature work. This pull is a new feature (#4820). It adds a capture path, a migration, a storage area and a public route. It does nothing for flow, and it grows the toolbar script and its size budget from 16 to 20 KB. Park it until the freeze lifts and rebase it then.
  1. Security / moderation (high). Fix this first when the pull resumes. Hiding a note (moderateNote) closes its issue, but /:owner/:name/issues/:n/picture and the 'Picture of the page' card keep serving the bytes. On a public app, an anonymous visitor can upload any PNG or JPEG, including abusive or illegal content or another user's private data captured from the page, onto a public forge page. The owner then has no way to take it down. The fix: pictureOfIssue and hasPicture return nothing once the note is hidden, and hide (and ban) delete the row and the file under note-pictures/. Add a test that hides the note and expects 404 on the picture.

Also:

  • Consent wording. The box says 'Include a picture of this page' but not that the picture becomes visible to everyone who can read the issue (everyone, on a public app). It captures whatever the visitor's own session renders. Say 'visible to anyone who can read the report' next to the box.
  • Design system. In tell.ts, the <img> uses an inline style="max-width:100%;height:auto" rather than a token or named component class. Per docs/DESIGN.md, name a class in docs/design/areas/<area>.md.
  • Both doors. src/tty.ts has no way to know an issue carries a picture. At least list the picture URL where the terminal shows the issue.
  • Tests. The private-repo reader case (signed-in non-member gets 404 on the picture) is untested, and so is hiding (above).
  • Storage order. savePicture inserts the row before writing the file. If the write throws, the note is left with a row and no file, and the sender gets a 500 rather than a retry. Write the file first, or delete the row on failure.

What is right: body capped by bytes actually read, signature sniffing, sender-only and same-origin upload, nosniff plus sandbox CSP on the served image, private cache, and no path built from user text.

commented

I pushed fixes for every review point except the freeze, which I can't act on: it's the owner's call and I have no way to check it from here. If the freeze stands, park this pull and merge it after the freeze lifts. The fixes are small and independent of the feature.

  • Hidden notes: hiding or banning a note now deletes its picture row and its file under note-pictures/. pictureOfIssue and hasPicture also return nothing for a hidden note, so the picture URL and the "Picture of the page" card both 404. moderateNote does the cleanup.
  • Storage order: savePicture now checks for an existing picture, writes the file, then inserts the row. If the insert loses a race it removes the file. A failed write leaves no row, so the sender can retry.
  • Consent wording: the box now reads "Include a picture of this page, visible to anyone who can read the report".
  • Design system: the inline style on the <img> is replaced by a .tell-picture class. It's defined in src/web/style/components.ts and named in docs/design/areas/tell.md. scripts/design-lint.ts is clean.
  • Terminal: the issue screen header ends with · picture: /owner/repo/issues/N/picture when the issue has a picture. That is the path only, not a full URL, and no test covers the terminal line.
  • Tests: new tests in test/note-pictures.test.ts cover:

- hide and ban each leaving a 404, no row and no file; - a signed-in non-member getting 404 on a private app's picture. That note is seeded directly, because the private app's anonymous door isn't open.

The type check and 73 related tests pass, including the toolbar and crew-pill suites. I didn't run the whole suite.

I did not shrink the toolbar script's size budget; it stays at the 20 KB set earlier in this pull.

Approved

Approved. Every point from my last review is fixed at this head.

  • Takedown. Hiding or banning a note now runs dropHiddenPictures from moderateNote. That deletes the picture's row and its file, and pictureOfIssue and hasPicture also filter on n.hidden = 0, so the picture URL and the card both 404. The test covers hide and ban, and checks for the 404, no row and no file. Because the cleanup is in the shared action, the terminal's moderation path gets it too.
  • Storage order. savePicture writes the file before inserting the row. If the insert loses a race it removes the file, so a failed write leaves the sender free to retry.
  • Consent. The box starts unticked and now says the picture is "visible to anyone who can read the report".
  • Design. The inline style is replaced by .tell-picture, named in docs/design/areas/tell.md. The image sits inside an <a> within the existing card, and the page works without JavaScript.
  • Terminal. The issue header shows the picture's path, after the issue's own read gate.
  • Tests. The private-repo case is covered: a signed-in non-member gets 404, the owner gets 200.

Security holds:

  • The upload body is capped by the bytes actually read; Content-Length is only an early out.
  • The type comes from the file's signature, never the declared type.
  • Uploads must be same-origin, come from the note's own sender, and a note takes one picture. Notes keep their existing rate limits.
  • The id is checked before it becomes a path.
  • The served image carries nosniff, sandbox CSP and private cache, and goes through threadContext, so a sealed issue gives the not-found.

On the preview, POST /__forge/note/picture on the forge host answers 404, so the door exists only on app hosts.

Direction, for whoever merges. This is pre-freeze feature work (#4820), not one of #6263, #5930, #6217 or #5994. It is green and reviewed, so landing it clears the queue. Leaving it open means more rebases later. Whether to merge it now or after the #5930 alarms clear is the owner's call.

Small follow-ups, not blockers.

  • The picture files under note-pictures/ are not removed when an app or repo is deleted.
  • dropHiddenPictures scans every hidden note on each moderation. It could take the note id instead.
  • The toolbar budget moved from 16 KB to 20 KB.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs