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

scout/forge:scout/4820-a-note-from-the-forge-button-may-carry-a → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/4820-a-note-from-the-forge-button-may-carry-a was merged into main as 699bdc4.

Review

Sign in to review this change.
16 files changed +513 −16
docs/APPS.md +13
@@ -115,6 +115,19 @@ Under the box, four starter sentences are plain links (no script):
115115 Beyond the per-minute limits an app takes 30 notes an hour and 100 a day.
116116 The script grew to about 15 KB (cached, immutable); the notes are polled only
117117 while the panel is open.
118 - **A note may carry a picture of the page (#4820).** The panel has an
119 unticked *Include a picture of this page* box; nothing is captured unless it
120 is ticked. The picture is taken in the browser with no dependency: the
121 visible page is cloned with its computed styles inlined, drawn through an SVG
122 `foreignObject` onto a canvas and encoded as PNG (JPEG if over 2 MB). Images
123 and fonts loaded from other origins do not travel with it, and a browser that
124 refuses to encode it sends the note without, saying so. The note is filed
125 first; the picture follows as `POST /__forge/note/picture?note=<id>` (raw
126 bytes, same-origin, the note's own sender only, one per note). The server
127 reads at most 2 MB, accepts only PNG or JPEG by their first bytes, never by
128 the declared type, and keeps the file under `FORGE_APP_DATA/note-pictures`.
129 The issue page shows it from `/:owner/:name/issues/:n/picture`, which answers
130 exactly those who can read the issue (a sealed report gives the not-found).
118131 - **The forge pill acts.** The pill the proxy adds to an app's pages
119132 (`"toolbar": false` in `forge.json` turns it off; to write it in, the proxy asks the app for the page uncompressed, so a page that carries the pill goes out without gzip) links to a forge page,
120133 `/:owner/:name/toolbar?env=production|pull-N`, opened in a tab of its own: the
docs/design/areas/tell.md +3
@@ -7,6 +7,9 @@ shifts and Enter sends it with the page's path as `from`. Any failure falls back
77 - `.tell-here` — the fixed box holding that composer: pinned to the bottom of the viewport, at most 620px wide and
88 centred, above the page; the `.comment-form` inside drops its margin and takes `--shadow-2` so it reads as raised.
99
10 - `.tell-picture` — the picture a sender chose to send with a note, on its issue page: at most the card's width, height
11 following (no layout shift beyond the image's own load).
12
1013 ## Client home: `client-app-finished`
1114
1215 A muted line on a client's app row, `Finished <time>: <title>`, for the app's
src/actions/app_notes.ts +3
@@ -5,6 +5,7 @@
55 * (`apps/feedback.ts`) and the terminal's palette (`tui/screens/notes.ts`)
66 * both go through here, so the limits and the gating are one set.
77 */
8 import { dropHiddenPictures } from "../core/note_pictures.ts";
89 import type { App } from "../app.ts";
910 import { banVisitor, bannedNotes, unbanVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts";
1011 import type { AppRecord } from "../core/apps.ts";
@@ -265,6 +266,8 @@ export async function moderateNote(
265266 }
266267 hideNote(app.db, note.id);
267268 if (action === "ban") banVisitor(app.db, record.id, note.visitor);
269 // A hidden note keeps no picture: the owner's take-down reaches the bytes.
270 dropHiddenPictures(app.db, app.config.dataDir);
268271 return ok(true);
269272 }
270273
src/apps/feedback.ts +42 −2
@@ -8,6 +8,9 @@
88 * sender's own, with a live status. Moderation is not here: the app's own
99 * code could call it with a visiting admin's cookie, so it happens on the
1010 * forge's origin (`moderateNote`), and `/__forge/note/moderate` is a 404.
11 * - `POST /__forge/note/picture?note=<id>` adds the picture a sender chose to
12 * send (#4820) to a note of theirs: raw PNG or JPEG bytes, at most
13 * `PICTURE_MAX`, judged by their signature, one per note.
1114 * - Same origin only, a body read only up to `REQUEST_MAX` bytes, and limits
1215 * keyed on the socket's client (`http/peer.ts`), never on a header the
1316 * client wrote.
@@ -17,6 +20,8 @@ import type { App } from "../app.ts";
1720 import { notesFor, sendNote, type Sender } from "../actions/app_notes.ts";
1821 import type { AppRecord } from "../core/apps.ts";
1922 import { canAdmin, canRead, getRepoById, type Repo } from "../core/repos.ts";
23 import { getNote } from "../core/app_notes.ts";
24 import { PICTURE_MAX, savePicture, sniffPicture } from "../core/note_pictures.ts";
2025 import { getUserById, isActive, type User } from "../core/users.ts";
2126 import { sha256 } from "../kernel/hash.ts";
2227 import { NOTE_FAILED } from "./note_words.ts";
@@ -190,6 +195,41 @@ async function send(ask: FeedbackAsk, door: Door): Promise<Response> {
190195 return json(201, { note: sent.value, name: sender.name }, cookies);
191196 }
192197
198 /** The body's bytes, read no further than `PICTURE_MAX`; `undefined` when it is longer. */
199 async function readPicture(request: Request): Promise<Uint8Array | undefined> {
200 const declared = Number(request.headers.get("content-length") ?? "0");
201 if (declared > PICTURE_MAX || request.body === null) return undefined;
202 const reader = request.body.getReader();
203 const chunks: Uint8Array[] = [];
204 let size = 0;
205 for (;;) {
206 const { done, value } = await reader.read();
207 if (done) break;
208 size += value.byteLength;
209 if (size > PICTURE_MAX) {
210 await reader.cancel().catch(() => undefined);
211 return undefined;
212 }
213 chunks.push(value);
214 }
215 return Buffer.concat(chunks);
216 }
217
218 async function picture(ask: FeedbackAsk, door: Door): Promise<Response> {
219 const { sender } = senderOf(ask, door);
220 const asked = ask.url.searchParams.get("note") ?? "";
221 const note = /^[0-9a-z]{10,40}$/.test(asked) ? getNote(ask.app.db, ask.record.id, asked) : undefined;
222 // Only the sender's own note takes a picture.
223 if (note === undefined || note.visitor !== sender.key || note.hidden) return json(404, { error: "not_found" });
224 const bytes = await readPicture(ask.request);
225 if (bytes === undefined) return json(413, { error: "too_large", message: "That picture is too big. It must be under 2 MB." });
226 const kind = sniffPicture(bytes);
227 if (kind === undefined) return json(415, { error: "not_a_picture", message: "Only a PNG or JPEG picture can be sent." });
228 if (!savePicture(ask.app.db, ask.app.config.dataDir, note, kind, bytes))
229 return json(409, { error: "has_picture", message: "That note already has a picture." });
230 return json(201, { ok: true });
231 }
232
193233 function list(ask: FeedbackAsk, door: Door, proven: boolean): Response {
194234 const { sender, fresh } = senderOf(ask, door);
195235 const notes = notesFor(ask.app, ask.record, door.repo, ask.forgeUrl, sender);
@@ -205,7 +245,7 @@ export async function feedback(ask: FeedbackAsk): Promise<Response | undefined>
205245 const path = ask.url.pathname;
206246 const method = ask.request.method;
207247 if (path === "/__forge/note/moderate") return json(404, { error: "not_found" });
208 if (path !== "/__forge/note" && path !== "/__forge/notes") return undefined;
248 if (path !== "/__forge/note" && path !== "/__forge/notes" && path !== "/__forge/note/picture") return undefined;
209249 const host = ask.request.headers.get("host");
210250 const proven = spendProof(ask.request.headers.get(PROOF_HEADER), ask.grantCookie, host);
211251 const answer = await answerNote(proven ? ask : { ...ask, viewerId: undefined }, path, method, proven);
@@ -222,5 +262,5 @@ async function answerNote(ask: FeedbackAsk, path: string, method: string, proven
222262 }
223263 if (method !== "POST") return json(405, { error: "method" }, { allow: "POST" });
224264 if (!sameOrigin(ask.request)) return json(403, { error: "cross_origin" });
225 return send(ask, door);
265 return path === "/__forge/note/picture" ? picture(ask, door) : send(ask, door);
226266 }
src/apps/toolbar.ts +81 −8
@@ -101,6 +101,16 @@ export const toolbarJs = `(function () {
101101 tellText.required = true;
102102 tellText.setAttribute("aria-label", "Your note to the builders");
103103 tellText.style.cssText = "box-sizing:border-box;width:100%;font:16px/1.3 system-ui,sans-serif;padding:8px;border-radius:8px;border:1px solid #57606a;background:#0d1117;color:#fff;resize:vertical";
104 var tellPicRow = document.createElement("label");
105 tellPicRow.style.cssText = "display:flex;align-items:center;gap:8px;min-height:44px";
106 var tellPic = document.createElement("input");
107 tellPic.type = "checkbox";
108 tellPic.checked = false;
109 tellPic.style.cssText = "width:20px;height:20px;margin:0";
110 var tellPicText = document.createElement("span");
111 tellPicText.textContent = "Include a picture of this page, visible to anyone who can read the report";
outdated

Consent should say who will see it: the picture is shown to everyone who can read the issue, which is everyone on a public app.

112 tellPicRow.appendChild(tellPic);
113 tellPicRow.appendChild(tellPicText);
104114 var tellGo = document.createElement("button");
105115 tellGo.type = "submit";
106116 tellGo.textContent = "Send";
@@ -112,9 +122,11 @@ export const toolbarJs = `(function () {
112122 mine.style.cssText = "display:flex;flex-direction:column;gap:6px";
113123 ring(tellGo);
114124 ring(tellText);
125 ring(tellPic);
115126 tellBox.appendChild(tellHead);
116127 tellBox.appendChild(tellHint);
117128 tellBox.appendChild(tellText);
129 tellBox.appendChild(tellPicRow);
118130 tellBox.appendChild(tellGo);
119131 tellBox.appendChild(tellSay);
120132 tellBox.appendChild(mine);
@@ -172,25 +184,86 @@ export const toolbarJs = `(function () {
172184 })
173185 .catch(function () {});
174186 }
187 var PICTURE_MAX = 2097152;
188 function shoot() {
189 return new Promise(function (resolve) {
190 var done = false;
191 function end(blob) { if (!done) { done = true; resolve(blob || null); } }
192 setTimeout(end, 8000);
193 try {
194 var w = window.innerWidth, h = window.innerHeight;
195 var src = document.documentElement;
196 var all = src.querySelectorAll("*");
197 var copy = src.cloneNode(true);
198 var twins = copy.querySelectorAll("*");
199 for (var i = 0; i < twins.length && i < all.length; i++) {
200 var t = twins[i], tag = t.tagName;
201 if (tag === "SCRIPT" || tag === "NOSCRIPT" || tag === "IFRAME" || t.id === "forge-toolbar") { t.setAttribute("data-drop", "1"); continue; }
202 if (i > 4000) { t.setAttribute("data-drop", "1"); continue; }
203 var cs = getComputedStyle(all[i]), css = "";
204 for (var k = 0; k < cs.length; k++) css += cs[k] + ":" + cs.getPropertyValue(cs[k]) + ";";
205 t.setAttribute("style", css);
206 }
207 copy.querySelectorAll("[data-drop]").forEach(function (n) { n.remove(); });
208 var cb = getComputedStyle(src);
209 copy.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
210 copy.setAttribute("style", "margin:0;background:" + (cb.backgroundColor || "#fff") + ";width:" + src.scrollWidth + "px;transform:translate(" + (-window.scrollX) + "px," + (-window.scrollY) + "px)");
211 var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '"><foreignObject width="100%" height="100%">' + new XMLSerializer().serializeToString(copy) + "</foreignObject></svg>";
212 var img = new Image();
213 img.onload = function () {
214 try {
215 var c = document.createElement("canvas");
216 c.width = w; c.height = h;
217 c.getContext("2d").drawImage(img, 0, 0);
218 c.toBlob(function (png) {
219 if (png && png.size <= PICTURE_MAX) return end(png);
220 c.toBlob(function (jpg) { end(jpg && jpg.size <= PICTURE_MAX ? jpg : null); }, "image/jpeg", 0.7);
221 }, "image/png");
222 } catch (e) { end(); }
223 };
224 img.onerror = function () { end(); };
225 img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
226 } catch (e) { end(); }
227 });
228 }
175229 tellBox.addEventListener("submit", function (e) {
176230 e.preventDefault();
177231 var text = tellText.value.trim();
178232 if (!text) { tellSay.textContent = ${JSON.stringify(NOTE_EMPTY)}; return; }
179233 if (tellGo.disabled) return;
234 var wantPicture = tellPic.checked === true;
180235 tellGo.disabled = true;
181236 tellGo.setAttribute("aria-busy", "true");
182237 tellGo.style.opacity = ".6";
183238 function idle() { tellGo.disabled = false; tellGo.removeAttribute("aria-busy"); tellGo.style.opacity = "1"; }
184 call("/__forge/note", {
185 method: "POST",
186 credentials: "same-origin",
187 headers: { "content-type": "application/json" },
188 body: JSON.stringify({ text: text, page: location.href, viewport: window.innerWidth + "x" + window.innerHeight })
239 var taken = wantPicture ? shoot() : Promise.resolve(null);
240 taken.then(function (blob) {
241 return call("/__forge/note", {
242 method: "POST",
243 credentials: "same-origin",
244 headers: { "content-type": "application/json" },
245 body: JSON.stringify({ text: text, page: location.href, viewport: window.innerWidth + "x" + window.innerHeight })
246 })
247 .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
248 .then(function (a) {
249 if (!a.ok || !blob || !a.j || !a.j.note || !a.j.note.id) return { a: a, pic: false };
250 return call("/__forge/note/picture?note=" + encodeURIComponent(a.j.note.id), {
251 method: "POST",
252 credentials: "same-origin",
253 headers: { "content-type": blob.type },
254 body: blob
255 }).then(function (r) { return { a: a, pic: r.ok }; }, function () { return { a: a, pic: false }; });
256 });
189257 })
190 .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
191 .then(function (a) {
258 .then(function (x) {
192259 idle();
193 if (a.ok) { tellText.value = ""; tellSay.textContent = "Sent. The builders will see it."; refresh(true); }
260 var a = x.a;
261 if (a.ok) {
262 tellText.value = "";
263 tellPic.checked = false;
264 tellSay.textContent = wantPicture && x.pic !== true ? "Sent, without the picture. The builders will see it." : "Sent. The builders will see it.";
265 refresh(true);
266 }
194267 else tellSay.textContent = a.j && a.j.message ? a.j.message : ${JSON.stringify(NOTE_FAILED)};
195268 })
196269 .catch(function () { idle(); tellSay.textContent = ${JSON.stringify(NOTE_FAILED)}; });
src/core/migrations/20261022140000-note-pictures.ts added +14
@@ -0,0 +1,14 @@
1 // The picture a sender chose to send with a note from the forge button
2 // (#4820): which note it belongs to, its kind and size. The bytes are a file
3 // under the data directory. Additive.
4 export default `
5 CREATE TABLE IF NOT EXISTS app_note_picture (
6 note_id TEXT PRIMARY KEY,
7 repo_id TEXT NOT NULL,
8 issue_number INTEGER NOT NULL,
9 kind TEXT NOT NULL,
10 bytes INTEGER NOT NULL,
11 created_at TEXT NOT NULL
12 );
13 CREATE INDEX IF NOT EXISTS app_note_picture_issue ON app_note_picture(repo_id, issue_number);
14 `;
src/core/note_pictures.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * The optional picture of a page that comes with a note from the forge
3 * button (#4820): PNG or JPEG only, judged by its first bytes and never by
4 * what the sender called it, at most `PICTURE_MAX` bytes, kept as a file
5 * under the data directory. Pure data, no authz: who may see it is the
6 * issue's readers, decided where it is served.
7 */
8 import type { Database } from "bun:sqlite";
9 import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10 import { join } from "node:path";
11
12 export const PICTURE_MAX = 2 * 1024 * 1024;
13
14 export type PictureKind = "png" | "jpeg";
15 export const PICTURE_TYPE: Record<PictureKind, string> = { png: "image/png", jpeg: "image/jpeg" };
16
17 /** What the bytes are, from their signature: `undefined` for anything but PNG or JPEG. */
18 export function sniffPicture(bytes: Uint8Array): PictureKind | undefined {
19 const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
20 if (bytes.length > png.length && png.every((b, i) => bytes[i] === b)) return "png";
21 if (bytes.length > 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "jpeg";
22 return undefined;
23 }
24
25 const dirOf = (dataDir: string): string => join(dataDir, "note-pictures");
26 // The note id is ours (a generated id), but the path never trusts that.
27 const safe = (noteId: string): boolean => /^[0-9a-z]{10,40}$/.test(noteId);
28
29 /** Keep a note's picture; false when it already has one or the id is odd. */
30 export function savePicture(
31 db: Database,
32 dataDir: string,
33 note: { id: string; repoId: string; issueNumber: number },
34 kind: PictureKind,
35 bytes: Uint8Array,
36 ): boolean {
37 if (!safe(note.id) || bytes.length > PICTURE_MAX) return false;
38 if (db.query("SELECT 1 FROM app_note_picture WHERE note_id = ?1").get(note.id) !== null) return false;
39 // The file first: a failed write leaves no row, so the sender can try again.
40 const file = join(dirOf(dataDir), `${note.id}.${kind}`);
41 mkdirSync(dirOf(dataDir), { recursive: true });
42 writeFileSync(file, bytes);
43 const taken = db
44 .query("INSERT OR IGNORE INTO app_note_picture (note_id, repo_id, issue_number, kind, bytes, created_at) VALUES (?1,?2,?3,?4,?5,?6)")
45 .run(note.id, note.repoId, note.issueNumber, kind, bytes.length, new Date().toISOString());
46 if (taken.changes === 0) {
47 rmSync(file, { force: true });
48 return false;
49 }
50 return true;
51 }
52
53 /** Take down the pictures of notes the owner hid or banned: the row and the file. */
54 export function dropHiddenPictures(db: Database, dataDir: string): void {
55 const rows = db
56 .query<{ note_id: string; kind: string }, []>(
57 "SELECT p.note_id, p.kind FROM app_note_picture p JOIN app_note n ON n.id = p.note_id WHERE n.hidden = 1",
58 )
59 .all();
60 for (const r of rows) {
61 if (safe(r.note_id) && (r.kind === "png" || r.kind === "jpeg")) rmSync(join(dirOf(dataDir), `${r.note_id}.${r.kind}`), { force: true });
62 db.query("DELETE FROM app_note_picture WHERE note_id = ?1").run(r.note_id);
63 }
64 }
65
66 export interface Picture {
67 readonly noteId: string;
68 readonly kind: PictureKind;
69 readonly bytes: Uint8Array;
70 }
71
72 /** The picture sent with an issue's note, if any. */
73 export function pictureOfIssue(db: Database, dataDir: string, repoId: string, issueNumber: number): Picture | undefined {
74 const row = db
75 .query<{ note_id: string; kind: string }, [string, number]>(
76 "SELECT p.note_id, p.kind FROM app_note_picture p JOIN app_note n ON n.id = p.note_id WHERE p.repo_id = ?1 AND p.issue_number = ?2 AND n.hidden = 0 LIMIT 1",
77 )
78 .get(repoId, issueNumber);
79 if (row === null || (row.kind !== "png" && row.kind !== "jpeg") || !safe(row.note_id)) return undefined;
80 try {
81 return { noteId: row.note_id, kind: row.kind, bytes: readFileSync(join(dirOf(dataDir), `${row.note_id}.${row.kind}`)) };
82 } catch {
83 return undefined;
84 }
85 }
86
87 /** Whether an issue has a picture, without reading it. */
88 export function hasPicture(db: Database, repoId: string, issueNumber: number): boolean {
89 return db
90 .query("SELECT 1 FROM app_note_picture p JOIN app_note n ON n.id = p.note_id WHERE p.repo_id = ?1 AND p.issue_number = ?2 AND n.hidden = 0")
91 .get(repoId, issueNumber) !== null;
92 }
src/http/server.ts +2
@@ -230,6 +230,7 @@ import {
230230 } from "../web/pages/issues.ts";
231231 import { noteModerate } from "../web/studio/notes.ts";
232232 import { tellPage } from "../web/studio/tell.ts";
233 import { issuePicture } from "../web/studio/picture.ts";
233234 import { tellHerePage } from "../web/studio/tell_here.ts";
234235 import { globalMapPage } from "../web/pages/global-map.ts";
235236 import { mapPage } from "../web/pages/map.ts";
@@ -1118,6 +1119,7 @@ export function buildRouter(): Router<Handler> {
11181119 router.add("POST", "/:owner/:name/issues/new", newIssueSubmit);
11191120 router.add("GET", "/:owner/:name/issues/:number", tellPage);
11201121 router.add("GET", "/:owner/:name/issues/:number/thread", issueThreadFragment);
1122 router.add("GET", "/:owner/:name/issues/:number/picture", issuePicture);
11211123 router.add(
11221124 "POST",
11231125 "/:owner/:name/issues/:number/comments",
src/tui/screens/issue.ts +8 −1
@@ -24,6 +24,7 @@ import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
2424 import { listTeams } from "../../core/teams.ts";
2525 import { listRepoRuns } from "../../actions/runs.ts";
2626 import { alreadyDone } from "../../core/done_on_main.ts";
27 import { hasPicture } from "../../core/note_pictures.ts";
2728 import { getIssue, type Issue, type Label } from "../../core/issues.ts";
2829 import type { Repo } from "../../core/repos.ts";
2930 import type { IssueDetail, RepoSource } from "../../view/source.ts";
@@ -117,6 +118,12 @@ export function issueScreen(
117118 labels = listed.ok ? listed.value : [];
118119 };
119120
121 /** The path of the picture a note sent with this issue, when it has one. */
122 const pictureText = (): string =>
123 repo !== undefined && issueRow !== undefined && hasPicture(f.db, repo.id, issueRow.number)
124 ? ` · picture: /${repo.ownerName}/${repo.name}/issues/${issueRow.number}/picture`
125 : "";
126
120127 /** `a` — hand the issue to a person, an agent, or nobody. */
121128 const assign = (): void => {
122129 if (repo === undefined || issueRow === undefined) return;
@@ -277,7 +284,7 @@ export function issueScreen(
277284 x,
278285 head!.y + 1,
279286 truncate(
280 ` ${issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)} · ${plural(detail.comments.length, "comment")}${assignedText(issueRow, ctx.icons)}${doneText(doneBy, mayWrite())}`,
287 ` ${issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)} · ${plural(detail.comments.length, "comment")}${assignedText(issueRow, ctx.icons)}${doneText(doneBy, mayWrite())}${pictureText()}`,
281288 Math.max(0, right - x),
282289 ),
283290 style(c.mutedFg, c.bg),
src/web/studio/picture.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * The picture a sender chose to send with a note from the forge button
3 * (#4820): `GET /:owner/:name/issues/:number/picture`. It is read by exactly
4 * those who can read the issue (`threadContext`, which also hides a sealed
5 * report); everyone else gets the forge's not-found.
6 */
7 import { PICTURE_TYPE, pictureOfIssue } from "../../core/note_pictures.ts";
8 import type { Ctx } from "../../http/context.ts";
9 import { threadContext } from "../pages/thread.ts";
10 import { notFoundPage } from "../chrome.ts";
11
12 export function issuePicture(ctx: Ctx): Response {
13 const found = threadContext(ctx, "issue");
14 if (found instanceof Response) return found;
15 const pic = pictureOfIssue(ctx.app.db, ctx.app.config.dataDir, found.repo.id, found.issue.number);

High: this serves the picture even after the owner hid the note. Hide only closes the issue. Return not-found when the note is hidden, and delete the file on hide or ban.

16 if (pic === undefined) return notFoundPage(ctx);
17 return new Response(pic.bytes, {
18 headers: {
19 "content-type": PICTURE_TYPE[pic.kind],
20 "x-content-type-options": "nosniff",
21 // A private repository's picture is not for a shared cache.
22 "cache-control": "private, max-age=3600",
23 "content-security-policy": "default-src 'none'; sandbox",
24 },
25 });
26 }
src/web/studio/tell.ts +10
@@ -17,6 +17,7 @@ import { alreadyDone } from "../../core/done_on_main.ts";
1717 import { appsRuntime } from "../../apps/runtime.ts";
1818 import { getAppByRepo } from "../../core/apps.ts";
1919 import { LATER_TITLED } from "../../core/pillars.ts";
20 import { hasPicture } from "../../core/note_pictures.ts";
2021 import { linkedIssueIds, repoGraph } from "../../core/dependencies.ts";
2122 import {
2223 getIssuesByNumbers,
@@ -568,6 +569,15 @@ async function studioTell(
568569 </div>
569570 <aside>
570571 ${privateWorkCard(ctx, repo, issue, `${thread}/private`)}
572 ${hasPicture(db, repo.id, issue.number)
573 ? card(
574 html`<h2>Picture of the page</h2>
575 <a href="${thread}/picture"
576 ><img src="${thread}/picture" alt="The page the sender was looking at" loading="lazy" class="tell-picture"
outdated

Inline style in a page. Use a named component class from docs/design/areas/<area>.md (DESIGN.md: tokens and components, not hand-rolled styles).

577 /></a>`,
578 { extra: "mt" },
579 )
580 : null}
571581 ${runsCard}
572582 ${closingCard}
573583 ${dependencies}
src/web/style/components.ts +1
@@ -670,6 +670,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); }
670670
671671 /* ---- tell the crew, in place: fixed, so nothing shifts ---- */
672672 .tell-here { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; width: min(620px, 100%); margin: 0 auto; padding: 8px; }
673 .tell-picture { max-width: 100%; height: auto; }
673674 .tell-here .comment-form { margin: 0; box-shadow: var(--shadow-2); }
674675
675676 /* ---- command palette ---- */
test/apps-crew-pill.test.ts +11 −3
@@ -199,13 +199,21 @@ describe("the crew line", () => {
199199 expect(toolbarJs).toContain("/__forge/crew");
200200 // One fetch is the crew line; the other is `call`, the feedback door's
201201 // single path (#4758): the sender's notes, polled only while the panel
202 // is open, and the send, each carrying the proof header in and out.
202 // is open, the send, and (#4820) the picture when ticked, each carrying
203 // the proof header in and out.
203204 expect(toolbarJs.match(/fetch\(/g)?.length).toBe(2);
204205 expect(toolbarJs.match(/setInterval/g)?.length).toBe(1);
205 expect(toolbarJs).not.toMatch(
206 // The one place the page is read is `shoot`, which runs only when the
207 // sender ticked the picture box; nothing else touches it.
208 const from = toolbarJs.indexOf("function shoot()");
209 const to = toolbarJs.indexOf('tellBox.addEventListener("submit"');
210 expect(from).toBeGreaterThan(0);
211 expect(to).toBeGreaterThan(from);
212 expect(toolbarJs.slice(0, from) + toolbarJs.slice(to)).not.toMatch(
206213 /document\.cookie|querySelector|getElementById|setTimeout/,
207214 );
215 expect(toolbarJs).not.toContain("document.cookie");
208216 expect(toolbarJs).toContain('aria-live", "polite"');
209 expect(toolbarJs.length).toBeLessThan(16 * 1024);
217 expect(toolbarJs.length).toBeLessThan(20 * 1024);
210218 });
211219 });
test/apps-toolbar.test.ts +1 −1
@@ -366,7 +366,7 @@ describe("the forge pill", () => {
366366 expect(res.headers.get("content-type")).toContain("javascript");
367367 const code = await res.text();
368368 expect(code).toBe(toolbarJs);
369 expect(code.length).toBeLessThan(16 * 1024);
369 expect(code.length).toBeLessThan(20 * 1024);
370370 expect(code).not.toMatch(/\beval\b|new Function|\son[a-z]+\s*=/);
371371 expect(code).toContain("attachShadow");
372372 });
test/note-pictures.test.ts added +203
@@ -0,0 +1,203 @@
1 /**
2 * The optional picture of a page that rides with a note from the forge
3 * button (#4820): only when sent, only PNG or JPEG under 2 MB judged by its
4 * bytes, kept under the data directory, and read only by who reads the issue.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { existsSync } from "node:fs";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { newApp } from "../src/actions/apps.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
15 import { toolbarJs } from "../src/apps/toolbar.ts";
16 import { PICTURE_MAX, savePicture } from "../src/core/note_pictures.ts";
17 import { createIssue } from "../src/core/issues.ts";
18 import { insertNote } from "../src/core/app_notes.ts";
19 import { moderateNote } from "../src/actions/app_notes.ts";
20 import { getAppByRepo } from "../src/core/apps.ts";
21 import { getRepo, getRepoById } from "../src/core/repos.ts";
22 import { createSession } from "../src/core/sessions.ts";
23 import { createUser, getUserByName } from "../src/core/users.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 const DOMAIN = "apps.test";
30
31 describe("a note may carry a picture of the page", () => {
32 let dataDir: string;
33 let app: App;
34 let servers: Servers;
35 let runtime: AppsRuntime;
36
37 beforeAll(async () => {
38 dataDir = await mkdtemp(join(tmpdir(), "forge-app-feedback-"));
39 app = createApp({ dataDir, port: 0, host: "forge.test" });
40 servers = await serve(app);
41 runtime = new AppsRuntime(app, {
42 domain: DOMAIN,
43 scheme: "http",
44 publicPort: servers.port,
45 forgeUrl: servers.url,
46 concurrency: 1,
47 });
48 await runtime.start();
49 const a = await createUser(app.db, { origin: "seed", trusted: true,
50 username: "alice",
51 email: "[email protected]",
52 password: "a-good-passphrase",
53 adminIfFirst: true,
54 });
55 if (!a.ok) throw new Error(a.error);
56 for (const [name, visibility] of [
57 ["notes", "public"],
58 ["diary", "private"],
59 ] as const) {
60 const made = await newApp(app, a.value, { name, description: "Words", visibility });
61 if (!made.ok) throw new Error(String(made.error));
62 }
63 }, 120_000);
64
65 afterAll(async () => {
66 await runtime.stop();
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 const host = (slug: string) => `${slug}.${DOMAIN}:${servers.port}`;
73 const at = (slug: string, path: string, init: RequestInit = {}, cookie = "", source = "") =>
74 fetch(`${servers.url}${path}`, {
75 ...init,
76 headers: {
77 host: host(slug),
78 ...(init.method === "POST" ? { origin: `http://${host(slug)}`, "content-type": "application/json" } : {}),
79 ...(cookie ? { cookie } : {}),
80 ...(source ? { "x-forwarded-for": source } : {}),
81 ...(init.headers as Record<string, string>),
82 },
83 redirect: "manual",
84 });
85 const post = (text: string, cookie = "", source = "", slug = "notes") =>
86 at(slug, "/__forge/note", { method: "POST", body: JSON.stringify({ text, page: `http://${host(slug)}/lists?x=1`, viewport: "390x844" }) }, cookie, source);
87 const cookieOf = (res: Response) => (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
88 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
89 const JPG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4]);
90 const note = async (source: string) => {
91 const res = await post("The header looks wrong", "", source);
92 const j = (await res.json()) as { note: { id: string; url: string } };
93 return { cookie: cookieOf(res), id: j.note.id, number: Number(j.note.url.split("/").pop()) };
94 };
95 const upload = (id: string, bytes: Uint8Array, cookie: string, type = "image/png") =>
96 at("notes", `/__forge/note/picture?note=${id}`, { method: "POST", body: bytes, headers: { "content-type": type } }, cookie);
97 const issuePicture = (number: number) =>
98 fetch(`${servers.url}/alice/notes/issues/${number}/picture`, { redirect: "manual" });
99
100 test("the panel's box starts unticked and a picture is taken only when it is ticked", () => {
101 expect(toolbarJs).toContain("Include a picture of this page");
102 expect(toolbarJs).toContain("tellPic.checked = false");
103 expect(toolbarJs).toContain("wantPicture ? shoot() : Promise.resolve(null)");
104 expect(toolbarJs).toContain("/__forge/note/picture");
105 });
106
107 test("an unticked note sends no image: none is stored or shown", async () => {
108 const n = await note("10.9.0.1");
109 expect(app.db.query("SELECT 1 FROM app_note_picture WHERE note_id = ?1").get(n.id)).toBeNull();
110 expect((await issuePicture(n.number)).status).toBe(404);
111 });
112
113 test("a PNG and a JPEG are kept under the data directory and shown on the issue", async () => {
114 for (const [source, bytes, type, ext] of [["10.9.0.2", PNG, "image/png", "png"], ["10.9.0.3", JPG, "image/jpeg", "jpeg"]] as const) {
115 const n = await note(source);
116 const up = await upload(n.id, bytes, n.cookie, type);
117 expect(up.status).toBe(201);
118 expect(existsSync(join(dataDir, "note-pictures", `${n.id}.${ext}`))).toBe(true);
119 const shown = await issuePicture(n.number);
120 expect(shown.status).toBe(200);
121 expect(shown.headers.get("content-type")).toBe(type);
122 expect(shown.headers.get("x-content-type-options")).toBe("nosniff");
123 expect(new Uint8Array(await shown.arrayBuffer())).toEqual(bytes);
124 // one picture per note
125 expect((await upload(n.id, bytes, n.cookie, type)).status).toBe(409);
126 }
127 const n = await note("10.9.0.4");
128 await upload(n.id, PNG, n.cookie);
129 const page = await (await fetch(`${servers.url}/alice/notes/issues/${n.number}`)).text();
130 expect(page).toContain(`/alice/notes/issues/${n.number}/picture`);
131 });
132
133 test("an oversized upload is refused, and so is one that is not a PNG or JPEG whatever it is called", async () => {
134 const n = await note("10.9.0.5");
135 const big = new Uint8Array(PICTURE_MAX + 1);
136 big.set(PNG);
137 expect((await upload(n.id, big, n.cookie)).status).toBe(413);
138 const text = new TextEncoder().encode("<svg onload=alert(1)></svg>");
139 expect((await upload(n.id, text, n.cookie, "image/png")).status).toBe(415);
140 expect((await upload(n.id, new TextEncoder().encode("GIF89a....."), n.cookie, "image/jpeg")).status).toBe(415);
141 expect((await issuePicture(n.number)).status).toBe(404);
142 // exactly the cap is fine
143 const edge = new Uint8Array(PICTURE_MAX);
144 edge.set(PNG);
145 expect((await upload(n.id, edge, n.cookie)).status).toBe(201);
146 });
147
148 test("only the sender adds a picture, from this origin", async () => {
149 const n = await note("10.9.0.6");
150 const other = await note("10.9.0.7");
151 expect((await upload(n.id, PNG, other.cookie)).status).toBe(404);
152 expect((await upload(n.id, PNG, "")).status).toBe(404);
153 expect((await upload("nope", PNG, n.cookie)).status).toBe(404);
154 const cross = await fetch(`${servers.url}/__forge/note/picture?note=${n.id}`, {
155 method: "POST", body: PNG, headers: { host: host("notes"), origin: "http://evil.test", cookie: n.cookie },
156 });
157 expect(cross.status).toBe(403);
158 expect((await issuePicture(n.number)).status).toBe(404);
159 });
160
161 test("the picture is not readable by someone who cannot read the issue", async () => {
162 const n = await note("10.9.0.8");
163 await upload(n.id, PNG, n.cookie);
164 expect((await issuePicture(n.number)).status).toBe(200);
165 app.db.query("UPDATE issue SET sealed = 1 WHERE number = ?1").run(n.number);
166 expect((await issuePicture(n.number)).status).toBe(404);
167 expect((await fetch(`${servers.url}/alice/notes/issues/${n.number}`)).status).toBe(404);
168 });
169
170 test("hiding a note takes its picture down: 404, no row, no file; banning does too", async () => {
171 for (const how of ["hide", "ban"] as const) {
172 const n = await note(how === "hide" ? "10.9.0.9" : "10.9.0.10");
173 await upload(n.id, PNG, n.cookie);
174 expect((await issuePicture(n.number)).status).toBe(200);
175 const repo = app.db.query<{ id: string }, [string]>("SELECT repo_id AS id FROM app_note WHERE id = ?1").get(n.id)!;
176 expect(repo).not.toBeNull();
177 const owner = getUserByName(app.db, "alice")!;
178 const found = getRepoById(app.db, repo.id)!;
179 const done = await moderateNote(app, getAppByRepo(app.db, found.id)!, found, owner, n.id, how);
180 expect(done.ok).toBe(true);
181 expect((await issuePicture(n.number)).status).toBe(404);
182 expect(app.db.query("SELECT 1 FROM app_note_picture WHERE note_id = ?1").get(n.id)).toBeNull();
183 expect(existsSync(join(dataDir, "note-pictures", `${n.id}.png`))).toBe(false);
184 }
185 });
186
187 test("a signed-in non-member gets 404 on a private app's picture", async () => {
188 const owner = getUserByName(app.db, "alice")!;
189 const repo = getRepo(app.db, "alice", "diary")!;
190 const issue = createIssue(app.db, { repoId: repo.id, authorId: owner.id, title: "From the app: The diary header" });
191 if (!issue.ok) throw new Error(issue.error);
192 const number = issue.value.number;
193 const kept = insertNote(app.db, { appId: getAppByRepo(app.db, repo.id)!.id, repoId: repo.id, issueNumber: number, visitor: "u:alice", name: "alice" });
194 expect(savePicture(app.db, dataDir, kept, "png", PNG)).toBe(true);
195 const alice = `forge_session=${createSession(app.db, owner.id).token}`;
196 expect((await fetch(`${servers.url}/alice/diary/issues/${number}/picture`, { headers: { cookie: alice }, redirect: "manual" })).status).toBe(200);
197 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
198 if (!b.ok) throw new Error(b.error);
199 const session = createSession(app.db, b.value.id);
200 const reader = await fetch(`${servers.url}/alice/diary/issues/${number}/picture`, { headers: { cookie: `forge_session=${session.token}` }, redirect: "manual" });
201 expect(reader.status).toBe(404);
202 });
203 });
test/toolbar-actions.test.ts +3 −1
@@ -89,8 +89,10 @@ describe("toolbar actions", () => {
8989 test("the bar links to the forge page and never posts or reads the app", () => {
9090 expect(toolbarJs).toContain('"/toolbar"');
9191 // Its requests are all to the app's own origin: the crew line (#2233) and,
92 // for the feedback door (#4758), the sender's notes and their status.
92 // for the feedback door (#4758), the sender's notes and their status, and
93 // the picture a sender ticked to send (#4820, sent through `call`).
9394 expect(toolbarJs.match(/fetch\(/g)?.length).toBe(2);
95 expect(toolbarJs).toContain('call("/__forge/note/picture?note="');
9496 expect(toolbarJs).toContain('fetch("/__forge/crew"');
9597 expect(toolbarJs).toContain('call("/__forge/note"');
9698 expect(toolbarJs).not.toMatch(/FormData|\.send\(/);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs