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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4820-a-note-from-the-forge-button-may-carry-a → main 5 commits fork 3 threads · 3 unresolved
16 files
changed +513
−16
docs/APPS.md +13
| @@ -115,6 +115,19 @@ Under the box, four starter sentences are plain links (no script): | ||
| 115 | 115 | Beyond the per-minute limits an app takes 30 notes an hour and 100 a day. |
| 116 | 116 | The script grew to about 15 KB (cached, immutable); the notes are polled only |
| 117 | 117 | 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). | |
| 118 | 131 | - **The forge pill acts.** The pill the proxy adds to an app's pages |
| 119 | 132 | (`"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, |
| 120 | 133 | `/: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 | ||
| 7 | 7 | - `.tell-here` — the fixed box holding that composer: pinned to the bottom of the viewport, at most 620px wide and |
| 8 | 8 | centred, above the page; the `.comment-form` inside drops its margin and takes `--shadow-2` so it reads as raised. |
| 9 | 9 | |
| 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 | ||
| 10 | 13 | ## Client home: `client-app-finished` |
| 11 | 14 | |
| 12 | 15 | 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 @@ | ||
| 5 | 5 | * (`apps/feedback.ts`) and the terminal's palette (`tui/screens/notes.ts`) |
| 6 | 6 | * both go through here, so the limits and the gating are one set. |
| 7 | 7 | */ |
| 8 | import { dropHiddenPictures } from "../core/note_pictures.ts"; | |
| 8 | 9 | import type { App } from "../app.ts"; |
| 9 | 10 | import { banVisitor, bannedNotes, unbanVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts"; |
| 10 | 11 | import type { AppRecord } from "../core/apps.ts"; |
| @@ -265,6 +266,8 @@ export async function moderateNote( | ||
| 265 | 266 | } |
| 266 | 267 | hideNote(app.db, note.id); |
| 267 | 268 | 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); | |
| 268 | 271 | return ok(true); |
| 269 | 272 | } |
| 270 | 273 |
src/apps/feedback.ts +42 −2
| @@ -8,6 +8,9 @@ | ||
| 8 | 8 | * sender's own, with a live status. Moderation is not here: the app's own |
| 9 | 9 | * code could call it with a visiting admin's cookie, so it happens on the |
| 10 | 10 | * 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. | |
| 11 | 14 | * - Same origin only, a body read only up to `REQUEST_MAX` bytes, and limits |
| 12 | 15 | * keyed on the socket's client (`http/peer.ts`), never on a header the |
| 13 | 16 | * client wrote. |
| @@ -17,6 +20,8 @@ import type { App } from "../app.ts"; | ||
| 17 | 20 | import { notesFor, sendNote, type Sender } from "../actions/app_notes.ts"; |
| 18 | 21 | import type { AppRecord } from "../core/apps.ts"; |
| 19 | 22 | 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"; | |
| 20 | 25 | import { getUserById, isActive, type User } from "../core/users.ts"; |
| 21 | 26 | import { sha256 } from "../kernel/hash.ts"; |
| 22 | 27 | import { NOTE_FAILED } from "./note_words.ts"; |
| @@ -190,6 +195,41 @@ async function send(ask: FeedbackAsk, door: Door): Promise<Response> { | ||
| 190 | 195 | return json(201, { note: sent.value, name: sender.name }, cookies); |
| 191 | 196 | } |
| 192 | 197 | |
| 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 | ||
| 193 | 233 | function list(ask: FeedbackAsk, door: Door, proven: boolean): Response { |
| 194 | 234 | const { sender, fresh } = senderOf(ask, door); |
| 195 | 235 | 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> | ||
| 205 | 245 | const path = ask.url.pathname; |
| 206 | 246 | const method = ask.request.method; |
| 207 | 247 | 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; | |
| 209 | 249 | const host = ask.request.headers.get("host"); |
| 210 | 250 | const proven = spendProof(ask.request.headers.get(PROOF_HEADER), ask.grantCookie, host); |
| 211 | 251 | 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 | ||
| 222 | 262 | } |
| 223 | 263 | if (method !== "POST") return json(405, { error: "method" }, { allow: "POST" }); |
| 224 | 264 | 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); | |
| 226 | 266 | } |
src/apps/toolbar.ts +81 −8
| @@ -101,6 +101,16 @@ export const toolbarJs = `(function () { | ||
| 101 | 101 | tellText.required = true; |
| 102 | 102 | tellText.setAttribute("aria-label", "Your note to the builders"); |
| 103 | 103 | 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"; | |
| 112 | tellPicRow.appendChild(tellPic); | |
| 113 | tellPicRow.appendChild(tellPicText); | |
| 104 | 114 | var tellGo = document.createElement("button"); |
| 105 | 115 | tellGo.type = "submit"; |
| 106 | 116 | tellGo.textContent = "Send"; |
| @@ -112,9 +122,11 @@ export const toolbarJs = `(function () { | ||
| 112 | 122 | mine.style.cssText = "display:flex;flex-direction:column;gap:6px"; |
| 113 | 123 | ring(tellGo); |
| 114 | 124 | ring(tellText); |
| 125 | ring(tellPic); | |
| 115 | 126 | tellBox.appendChild(tellHead); |
| 116 | 127 | tellBox.appendChild(tellHint); |
| 117 | 128 | tellBox.appendChild(tellText); |
| 129 | tellBox.appendChild(tellPicRow); | |
| 118 | 130 | tellBox.appendChild(tellGo); |
| 119 | 131 | tellBox.appendChild(tellSay); |
| 120 | 132 | tellBox.appendChild(mine); |
| @@ -172,25 +184,86 @@ export const toolbarJs = `(function () { | ||
| 172 | 184 | }) |
| 173 | 185 | .catch(function () {}); |
| 174 | 186 | } |
| 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 | } | |
| 175 | 229 | tellBox.addEventListener("submit", function (e) { |
| 176 | 230 | e.preventDefault(); |
| 177 | 231 | var text = tellText.value.trim(); |
| 178 | 232 | if (!text) { tellSay.textContent = ${JSON.stringify(NOTE_EMPTY)}; return; } |
| 179 | 233 | if (tellGo.disabled) return; |
| 234 | var wantPicture = tellPic.checked === true; | |
| 180 | 235 | tellGo.disabled = true; |
| 181 | 236 | tellGo.setAttribute("aria-busy", "true"); |
| 182 | 237 | tellGo.style.opacity = ".6"; |
| 183 | 238 | 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 | }); | |
| 189 | 257 | }) |
| 190 | .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); }) | |
| 191 | .then(function (a) { | |
| 258 | .then(function (x) { | |
| 192 | 259 | 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 | } | |
| 194 | 267 | else tellSay.textContent = a.j && a.j.message ? a.j.message : ${JSON.stringify(NOTE_FAILED)}; |
| 195 | 268 | }) |
| 196 | 269 | .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 { | ||
| 230 | 230 | } from "../web/pages/issues.ts"; |
| 231 | 231 | import { noteModerate } from "../web/studio/notes.ts"; |
| 232 | 232 | import { tellPage } from "../web/studio/tell.ts"; |
| 233 | import { issuePicture } from "../web/studio/picture.ts"; | |
| 233 | 234 | import { tellHerePage } from "../web/studio/tell_here.ts"; |
| 234 | 235 | import { globalMapPage } from "../web/pages/global-map.ts"; |
| 235 | 236 | import { mapPage } from "../web/pages/map.ts"; |
| @@ -1118,6 +1119,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1118 | 1119 | router.add("POST", "/:owner/:name/issues/new", newIssueSubmit); |
| 1119 | 1120 | router.add("GET", "/:owner/:name/issues/:number", tellPage); |
| 1120 | 1121 | router.add("GET", "/:owner/:name/issues/:number/thread", issueThreadFragment); |
| 1122 | router.add("GET", "/:owner/:name/issues/:number/picture", issuePicture); | |
| 1121 | 1123 | router.add( |
| 1122 | 1124 | "POST", |
| 1123 | 1125 | "/: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"; | ||
| 24 | 24 | import { listTeams } from "../../core/teams.ts"; |
| 25 | 25 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 26 | 26 | import { alreadyDone } from "../../core/done_on_main.ts"; |
| 27 | import { hasPicture } from "../../core/note_pictures.ts"; | |
| 27 | 28 | import { getIssue, type Issue, type Label } from "../../core/issues.ts"; |
| 28 | 29 | import type { Repo } from "../../core/repos.ts"; |
| 29 | 30 | import type { IssueDetail, RepoSource } from "../../view/source.ts"; |
| @@ -117,6 +118,12 @@ export function issueScreen( | ||
| 117 | 118 | labels = listed.ok ? listed.value : []; |
| 118 | 119 | }; |
| 119 | 120 | |
| 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 | ||
| 120 | 127 | /** `a` — hand the issue to a person, an agent, or nobody. */ |
| 121 | 128 | const assign = (): void => { |
| 122 | 129 | if (repo === undefined || issueRow === undefined) return; |
| @@ -277,7 +284,7 @@ export function issueScreen( | ||
| 277 | 284 | x, |
| 278 | 285 | head!.y + 1, |
| 279 | 286 | 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()}`, | |
| 281 | 288 | Math.max(0, right - x), |
| 282 | 289 | ), |
| 283 | 290 | 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"; | ||
| 17 | 17 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 18 | 18 | import { getAppByRepo } from "../../core/apps.ts"; |
| 19 | 19 | import { LATER_TITLED } from "../../core/pillars.ts"; |
| 20 | import { hasPicture } from "../../core/note_pictures.ts"; | |
| 20 | 21 | import { linkedIssueIds, repoGraph } from "../../core/dependencies.ts"; |
| 21 | 22 | import { |
| 22 | 23 | getIssuesByNumbers, |
| @@ -568,6 +569,15 @@ async function studioTell( | ||
| 568 | 569 | </div> |
| 569 | 570 | <aside> |
| 570 | 571 | ${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} | |
| 571 | 581 | ${runsCard} |
| 572 | 582 | ${closingCard} |
| 573 | 583 | ${dependencies} |
src/web/style/components.ts +1
| @@ -670,6 +670,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 670 | 670 | |
| 671 | 671 | /* ---- tell the crew, in place: fixed, so nothing shifts ---- */ |
| 672 | 672 | .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; } | |
| 673 | 674 | .tell-here .comment-form { margin: 0; box-shadow: var(--shadow-2); } |
| 674 | 675 | |
| 675 | 676 | /* ---- command palette ---- */ |
test/apps-crew-pill.test.ts +11 −3
| @@ -199,13 +199,21 @@ describe("the crew line", () => { | ||
| 199 | 199 | expect(toolbarJs).toContain("/__forge/crew"); |
| 200 | 200 | // One fetch is the crew line; the other is `call`, the feedback door's |
| 201 | 201 | // 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. | |
| 203 | 204 | expect(toolbarJs.match(/fetch\(/g)?.length).toBe(2); |
| 204 | 205 | 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( | |
| 206 | 213 | /document\.cookie|querySelector|getElementById|setTimeout/, |
| 207 | 214 | ); |
| 215 | expect(toolbarJs).not.toContain("document.cookie"); | |
| 208 | 216 | expect(toolbarJs).toContain('aria-live", "polite"'); |
| 209 | expect(toolbarJs.length).toBeLessThan(16 * 1024); | |
| 217 | expect(toolbarJs.length).toBeLessThan(20 * 1024); | |
| 210 | 218 | }); |
| 211 | 219 | }); |
test/apps-toolbar.test.ts +1 −1
| @@ -366,7 +366,7 @@ describe("the forge pill", () => { | ||
| 366 | 366 | expect(res.headers.get("content-type")).toContain("javascript"); |
| 367 | 367 | const code = await res.text(); |
| 368 | 368 | expect(code).toBe(toolbarJs); |
| 369 | expect(code.length).toBeLessThan(16 * 1024); | |
| 369 | expect(code.length).toBeLessThan(20 * 1024); | |
| 370 | 370 | expect(code).not.toMatch(/\beval\b|new Function|\son[a-z]+\s*=/); |
| 371 | 371 | expect(code).toContain("attachShadow"); |
| 372 | 372 | }); |
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", () => { | ||
| 89 | 89 | test("the bar links to the forge page and never posts or reads the app", () => { |
| 90 | 90 | expect(toolbarJs).toContain('"/toolbar"'); |
| 91 | 91 | // 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`). | |
| 93 | 94 | expect(toolbarJs.match(/fetch\(/g)?.length).toBe(2); |
| 95 | expect(toolbarJs).toContain('call("/__forge/note/picture?note="'); | |
| 94 | 96 | expect(toolbarJs).toContain('fetch("/__forge/crew"'); |
| 95 | 97 | expect(toolbarJs).toContain('call("/__forge/note"'); |
| 96 | 98 | expect(toolbarJs).not.toMatch(/FormData|\.send\(/); |
new above
offline
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.