0cb5291985c4
attach screenshots and logs to issues and replies, served sandboxed while visible (v3.1.25)
- time
- 2026-10-08 18:50 UTC · 4 days ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- community issue threads
- parent
- fa3e2190d483
↓ kepr-0cb52919.tar.gzatlas at this save
files changed10 files
package.json+1 −1public/css/kepr.css+112 −0public/js/kepr.js+53 −3src/issues/files.ts+134 −0src/issues/pages.ts+121 −17src/issues/store.ts+19 −17src/server/app.ts+15 −5src/server/db.ts+14 −0tests/issues.test.ts+43 −6tests/server.test.ts+12 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.1.24",3+ "version": "3.1.25",44 "private": true,55 "type": "module",66 "scripts": {public/css/kepr.css+112 −0
@@ -1923,6 +1923,118 @@19231923 font-size: 0.75rem;19241924 color: var(--text-faint);19251925 }1926+.issue-attached {1927+ display: flex;1928+ flex-wrap: wrap;1929+ gap: 6px;1930+ margin: 0;1931+ padding: 0;1932+ list-style: none;1933+ font-size: 0.6875rem;1934+ color: var(--text-secondary);1935+}1936+.issue-attached:empty {1937+ display: none;1938+}1939+.issue-attached li {1940+ display: flex;1941+ align-items: center;1942+ gap: 6px;1943+ padding: 3px 8px 3px 3px;1944+ border: 1px solid var(--border);1945+ border-radius: 4px;1946+ overflow-wrap: anywhere;1947+}1948+.issue-attached img {1949+ width: 28px;1950+ height: 28px;1951+ border-radius: 2px;1952+ object-fit: cover;1953+}1954+/* A post's pictures: one shows whole, several sit in a grid of crops that open full size. */1955+.issue-shots {1956+ display: grid;1957+ grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));1958+ gap: 8px;1959+ margin-top: 12px;1960+}1961+.issue-shot {1962+ display: block;1963+ overflow: hidden;1964+ border: 1px solid var(--border);1965+ border-radius: 4px;1966+ background: var(--bg);1967+}1968+.issue-shot:hover {1969+ border-color: var(--accent-dim);1970+}1971+.issue-shot img {1972+ display: block;1973+ width: 100%;1974+ height: 10rem;1975+ object-fit: cover;1976+}1977+.issue-shot:only-child {1978+ grid-column: 1 / -1;1979+}1980+.issue-shot:only-child img {1981+ height: auto;1982+ max-height: 30rem;1983+ object-fit: contain;1984+}1985+.issue-log {1986+ margin-top: 12px;1987+ border: 1px solid var(--border);1988+ border-radius: 4px;1989+ background: var(--bg);1990+}1991+.issue-log-name {1992+ padding: 8px 12px;1993+ font-size: 0.6875rem;1994+ color: var(--text-secondary);1995+ cursor: pointer;1996+}1997+.issue-log-name:hover {1998+ color: var(--accent);1999+}2000+.issue-log pre {2001+ max-height: 22rem;2002+ margin: 0;2003+ padding: 10px 12px;2004+ overflow: auto;2005+ font-family: var(--mono);2006+ font-size: 0.75rem;2007+ line-height: 1.5;2008+ border-top: 1px solid var(--border);2009+}2010+.issue-log-foot {2011+ margin: 0;2012+ padding: 6px 12px 8px;2013+ font-size: 0.625rem;2014+ color: var(--text-faint);2015+}2016+.issue-log-whole {2017+ color: var(--accent);2018+}2019+.issues-file {2020+ font-family: var(--mono);2021+ font-size: 0.6875rem;2022+ color: var(--text-muted);2023+}2024+.issues-file::file-selector-button {2025+ margin-right: 10px;2026+ padding: 5px 12px;2027+ font: inherit;2028+ color: var(--text-secondary);2029+ background: var(--bg);2030+ border: 1px solid var(--border);2031+ border-radius: 4px;2032+ cursor: pointer;2033+}2034+.issues-file::file-selector-button:hover {2035+ color: var(--accent);2036+ border-color: var(--accent-dim);2037+}19262038 19272039 /* ----- front page --------------------------------------------------------- */19282040 public/js/kepr.js+53 −3
@@ -1,7 +1,7 @@11 // Kepr's browser islands (§18). Every page is complete without this file; it only adds the2-// ticker's pace, the drifting timeline, copy buttons, the inline panels, issue replies, history's3-// scrolling, line-range highlighting, the Atlas panel, picker, filter and file viewer, the signed-in4-// editor, the phone nav menu, and the profile's visibility pause.2+// ticker's pace, the drifting timeline, copy buttons, the inline panels, issue replies and3+// attachments, history's scrolling, line-range highlighting, the Atlas panel, picker, filter and4+// file viewer, the signed-in editor, the phone nav menu, and the profile's visibility pause.55 66 // Kepr 2's save kinds (canvas.js), first match wins: each dot's colour and strength.77 const KINDS = [@@ -366,6 +366,55 @@366366 );367367 }368368 369+// Pictures pasted or dropped into an issue's form join its attachments (§23), and each chosen file370+// is listed under the field, a picture with its thumbnail, before anything is sent.371+function issueAttachments() {372+ const fileInput = (target) =>373+ target.closest?.("form")?.querySelector("input[type=file][name=files]") ?? null;374+ const add = (input, files) => {375+ const all = new DataTransfer();376+ for (const file of [...input.files, ...files]) all.items.add(file);377+ input.files = all.files;378+ input.dispatchEvent(new Event("change", { bubbles: true }));379+ };380+ document.addEventListener("paste", (event) => {381+ const input = fileInput(event.target);382+ const files = [...(event.clipboardData?.files ?? [])];383+ if (!input || files.length === 0) return;384+ event.preventDefault();385+ add(input, files);386+ });387+ document.addEventListener("dragover", (event) => {388+ if (fileInput(event.target) && event.dataTransfer?.types.includes("Files")) {389+ event.preventDefault();390+ }391+ });392+ document.addEventListener("drop", (event) => {393+ const input = fileInput(event.target);394+ if (!input || !event.dataTransfer?.files.length) return;395+ event.preventDefault();396+ add(input, event.dataTransfer.files);397+ });398+ document.addEventListener("change", (event) => {399+ const input = event.target;400+ const list = input.closest?.("form")?.querySelector(".issue-attached");401+ if (input.name !== "files" || !list) return;402+ for (const image of list.querySelectorAll("img")) URL.revokeObjectURL(image.src);403+ list.replaceChildren(404+ ...[...input.files].map((file) => {405+ const item = element("li", "", file.name);406+ if (file.type.startsWith("image/")) {407+ const image = element("img", "");408+ image.src = URL.createObjectURL(file);409+ image.alt = "";410+ item.prepend(image);411+ }412+ return item;413+ }),414+ );415+ });416+}417+369418 // History and the Atlas's save picker hold one page of saves; scrolling to the end of either370419 // brings in the next page's rows. The pager is for pages without JavaScript.371420 function saveLists() {@@ -1036,6 +1085,7 @@10361085 copyButtons(document);10371086 panels();10381087 issueReplies();1088+issueAttachments();10391089 saveLists();10401090 atlasPicker();10411091 repoEditor();src/issues/files.ts+134 −0
@@ -0,0 +1,134 @@1+import type { Database } from "bun:sqlite";2+3+// §23: what an issue or reply may carry.4+export const FILES_PER_POST = 4;5+export const FILE_BYTES_PER_HOUR = 64 * 1024 * 1024;6+const IMAGE_BYTES = 8 * 1024 * 1024;7+const LOG_BYTES = 2 * 1024 * 1024;8+// A log shows this much of its start in the thread; the whole file is a link away.9+const PREVIEW_BYTES = 16 * 1024;10+11+// A picture is known by its first bytes, never by the name or type the browser sent.12+const IMAGES: [type: string, ext: string, magic: (head: string) => boolean][] = [13+ ["image/png", "png", (head) => head.startsWith("\x89PNG\r\n\x1a\n")],14+ ["image/jpeg", "jpg", (head) => head.startsWith("\xff\xd8\xff")],15+ ["image/gif", "gif", (head) => head.startsWith("GIF87a") || head.startsWith("GIF89a")],16+ ["image/webp", "webp", (head) => head.startsWith("RIFF") && head.slice(8, 12) === "WEBP"],17+];18+export const FILE_EXTENSIONS: Record<string, string> = {19+ ...Object.fromEntries(IMAGES.map(([type, ext]) => [type, ext])),20+ "text/plain": "txt",21+};22+23+export type Upload = { name: string; type: string; data: Uint8Array };24+type StoredFile = { name: string; type: string; data: Uint8Array<ArrayBuffer> };25+26+export type Attachment = {27+ id: number;28+ entry_id: number | null;29+ name: string;30+ type: string;31+ size: number;32+ /** A log's first bytes, for its preview; null for a picture. */33+ head: Uint8Array | null;34+};35+36+/** Checks the files sent with one post, returning them ready to keep or the reason to refuse. */37+export async function readUploads(files: File[]): Promise<Upload[] | string> {38+ if (files.length > FILES_PER_POST) return `Attach at most ${FILES_PER_POST} files.`;39+ const uploads: Upload[] = [];40+ for (const file of files) {41+ const data = new Uint8Array(await file.arrayBuffer());42+ // Shown, never used as a path; control characters would only garble the page.43+ const name = (file.name.split(/[\\/]/).pop() ?? "").replace(/\p{Cc}/gu, "").slice(0, 100);44+ const head = Buffer.from(data.subarray(0, 12)).toString("latin1");45+ const image = IMAGES.find(([, , magic]) => magic(head));46+ if (image) {47+ if (data.length > IMAGE_BYTES) return `${name} is over 8 MB.`;48+ uploads.push({ name: name || `screenshot.${image[1]}`, type: image[0], data });49+ continue;50+ }51+ if (!/\.(txt|log)$/i.test(name)) {52+ return `${name || "A file"} isn't a PNG, JPEG, GIF or WebP picture, or a .txt or .log file.`;53+ }54+ if (data.length > LOG_BYTES) return `${name} is over 2 MB.`;55+ try {56+ new TextDecoder("utf-8", { fatal: true }).decode(data);57+ } catch {58+ return `${name} isn't UTF-8 text.`;59+ }60+ uploads.push({ name, type: "text/plain", data });61+ }62+ return uploads;63+}64+65+/** Bytes attached from one hashed IP since a time, hidden posts' included. */66+export function fileBytesSince(db: Database, ipHash: string, since: number): number {67+ const row = db68+ .query<{ n: number | null }, [string, number]>(69+ "SELECT SUM(size) AS n FROM issue_files WHERE ip_hash = ? AND created_at > ?",70+ )71+ .get(ipHash, since);72+ return row?.n ?? 0;73+}74+75+/** Keeps a post's files: the issue's own when `entryId` is null, else one reply's. */76+export function attachFiles(77+ db: Database,78+ issueId: number,79+ entryId: number | null,80+ uploads: Upload[],81+ ipHash: string,82+ at: number,83+): void {84+ const insert = db.query(85+ `INSERT INTO issue_files (issue_id, entry_id, name, type, size, data, ip_hash, created_at)86+VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,87+ );88+ db.transaction(() => {89+ for (const upload of uploads) {90+ insert.run(91+ issueId,92+ entryId,93+ upload.name,94+ upload.type,95+ upload.data.length,96+ upload.data,97+ ipHash,98+ at,99+ );100+ }101+ })();102+}103+104+/** Every file of an issue's posts, oldest first, without their bytes beyond a log's start. */105+export function issueAttachments(db: Database, issueId: number): Attachment[] {106+ return db107+ .query<Attachment, [number, number]>(108+ `SELECT id, entry_id, name, type, size,109+ CASE WHEN type = 'text/plain' THEN substr(data, 1, ?) END AS head110+FROM issue_files111+WHERE issue_id = ?112+ORDER BY id`,113+ )114+ .all(PREVIEW_BYTES, issueId);115+}116+117+/** One file, only while its issue, its repo's issue list and its post are all visible. */118+export function visibleFile(119+ db: Database,120+ repo: string,121+ number: number,122+ id: number,123+): StoredFile | null {124+ return db125+ .query<StoredFile, [string, number, number]>(126+ `SELECT f.name, f.type, f.data127+FROM issue_files f128+JOIN issues i ON i.id = f.issue_id129+LEFT JOIN issue_entries e ON e.id = f.entry_id130+WHERE i.repo = ? AND i.number = ? AND f.id = ? AND i.hidden = 0131+ AND (f.entry_id IS NULL OR e.hidden = 0)`,132+ )133+ .get(repo, number, id);134+}src/issues/pages.ts+121 −17
@@ -4,6 +4,7 @@44 import {55 cacheFor,66 formatDate,7+ formatSize,78 notFound,89 openPublicRepo,910 type PageContext,@@ -15,6 +16,18 @@1516 import { visibleReleases } from "../releases/releases.ts";1617 import { notify } from "../server/db.ts";1718 import {19+ type Attachment,20+ attachFiles,21+ FILE_BYTES_PER_HOUR,22+ FILE_EXTENSIONS,23+ FILES_PER_POST,24+ fileBytesSince,25+ issueAttachments,26+ readUploads,27+ type Upload,28+ visibleFile,29+} from "./files.ts";30+import {1831 checkStamp,1932 FILINGS_PER_HOUR,2033 formStamp,@@ -70,10 +83,10 @@7083 const repo = openPublicRepo(ctx, c.req.param("repo"));7184 if (!repo) return notFound(c, ctx);7285 if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403);73- const fields = await readFields(c, FORM_FIELDS);86+ const { fields, files } = await readForm(c, FORM_FIELDS);7487 const now = unixNow();7588 const refuse = (message: string, status: 400 | 403 | 429) =>76- formPage(c, ctx, repo, fields, message, status);89+ formPage(c, ctx, repo, fields, attachAgain(message, files), status);7790 // A bot that fills the hidden field learns nothing from the answer.7891 if (fields.website !== "") return refuse("This issue could not be filed.", 400);7992 const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);@@ -94,8 +107,10 @@94107 if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {95108 return refuse("Too many issues from here this hour. Please try again later.", 429);96109 }110+ const uploads = await checkUploads(ctx, files, hash, now);111+ if (!Array.isArray(uploads)) return refuse(uploads.error, uploads.status);97112 const name = repo.settings.name;98- const number = fileIssue(ctx.kepr, {113+ const filed = fileIssue(ctx.kepr, {99114 repo: name,100115 title,101116 body,@@ -106,15 +121,31 @@106121 ipHash: hash,107122 filedAt: now,108123 });124+ attachFiles(ctx.kepr, filed.id, null, uploads, hash, now);109125 notify(110126 ctx.kepr,111127 "issue_filed",112- `New issue ${name} #${number}: ${title}`,113- `/admin/issues/${name}/${number}`,128+ `New issue ${name} #${filed.number}: ${title}`,129+ `/admin/issues/${name}/${filed.number}`,114130 );115- return c.redirect(`/${name}/issues/${number}`, 303);131+ return c.redirect(`/${name}/issues/${filed.number}`, 303);116132 });117133 134+ app.get("/:repo/issues/:n{[0-9]{1,9}}/files/:file{[0-9]{1,9}\\.[a-z]{3,4}}", (c) => {135+ const repo = openPublicRepo(ctx, c.req.param("repo"));136+ const [id, ext] = c.req.param("file").split(".");137+ const file =138+ repo && visibleFile(ctx.kepr, repo.settings.name, Number(c.req.param("n")), Number(id));139+ // One address per file: a name ending in another type's extension is as missing as no file.140+ if (!repo || !file || FILE_EXTENSIONS[file.type] !== ext) return notFound(c, ctx);141+ return c.body(file.data, 200, {142+ "Content-Type": file.type === "text/plain" ? "text/plain; charset=utf-8" : file.type,143+ "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`,144+ // Short, so a file hidden as spam soon leaves shared caches too.145+ "Cache-Control": "public, max-age=300",146+ });147+ });148+118149 app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {119150 const repo = openPublicRepo(ctx, c.req.param("repo"));120151 const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));@@ -138,18 +169,18 @@138169 const repo = openPublicRepo(ctx, c.req.param("repo"));139170 const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));140171 if (!repo || !issue) return notFound(c, ctx);141- const fields = await readFields(c, REPLY_FIELDS);172+ const { fields, files } = await readForm(c, REPLY_FIELDS);142173 const entries = threadEntries(ctx.kepr, issue.id);143174 const replyTo = entries.find((entry) => String(entry.id) === fields.reply_to)?.id ?? null;144175 const body = fields.body?.trim() ?? "";145176 const name = fields.name?.trim() ?? "";146177 const now = unixNow();147178 privatePage(c);148- const refuse = (error: string, status: 400 | 403 | 429) =>149- c.html(150- threadPage(c, ctx, repo, issue, entries, { error, body: fields.body, name, replyTo }),151- status,152- );179+ const refuse = (message: string, status: 400 | 403 | 429) => {180+ const error = attachAgain(message, files);181+ const draft = { error, body: fields.body, name, replyTo };182+ return c.html(threadPage(c, ctx, repo, issue, entries, draft), status);183+ };153184 if (fields.website !== "") return refuse("This reply could not be posted.", 400);154185 if (issue.status === "closed") return refuse("This issue is closed to new replies.", 403);155186 const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);@@ -164,6 +195,8 @@164195 if (repliesSince(ctx.kepr, hash, now - 3600) >= REPLIES_PER_HOUR) {165196 return refuse("Too many replies from here this hour. Please try again later.", 429);166197 }198+ const uploads = await checkUploads(ctx, files, hash, now);199+ if (!Array.isArray(uploads)) return refuse(uploads.error, uploads.status);167200 const entry = addEntry(ctx.kepr, issue.id, {168201 author: "reporter",169202 name,@@ -172,6 +205,7 @@172205 ipHash: hash,173206 replyTo,174207 });208+ attachFiles(ctx.kepr, issue.id, entry, uploads, hash, now);175209 notify(176210 ctx.kepr,177211 "issue_followup",@@ -182,16 +216,44 @@182216 });183217 }184218 185-async function readFields(c: Context, names: string[]): Promise<Fields> {186- const form = await c.req.parseBody();219+async function readForm(c: Context, names: string[]): Promise<{ fields: Fields; files: File[] }> {220+ const form = await c.req.parseBody({ all: true });187221 const fields: Fields = {};188222 for (const name of names) {189223 const value = form[name];190224 fields[name] = typeof value === "string" ? value : "";191225 }192- return fields;226+ // An empty file input still sends one nameless, empty part.227+ const files = [form.files ?? []]228+ .flat()229+ .filter((file): file is File => file instanceof File && file.size > 0);230+ return { fields, files };193231 }194232 233+/** A post's files, checked last so the post's own refusals come first; or why they're refused. */234+async function checkUploads(235+ ctx: PageContext,236+ files: File[],237+ hash: string,238+ now: number,239+): Promise<Upload[] | { error: string; status: 400 | 429 }> {240+ const uploads = await readUploads(files);241+ if (typeof uploads === "string") return { error: uploads, status: 400 };242+ const bytes = uploads.reduce((sum, upload) => sum + upload.data.length, 0);243+ if (bytes > 0 && fileBytesSince(ctx.kepr, hash, now - 3600) + bytes > FILE_BYTES_PER_HOUR) {244+ return {245+ error: "Too many attachments from here this hour. Please try again later.",246+ status: 429,247+ };248+ }249+ return uploads;250+}251+252+// A browser can't refill a file input, so a refused post says its files need choosing again.253+function attachAgain(message: string, files: File[]): string {254+ return files.length > 0 ? `${message} Please attach your files again.` : message;255+}256+195257 // A page carrying a form stamp is never cached, and its URL never leaks as a referrer.196258 function privatePage(c: Context): void {197259 c.header("Cache-Control", "no-store");@@ -281,7 +343,8 @@281343 : html`${crumbs(name)}282344 <h1 class="file-page-title">report an issue</h1>283345 ${error && html`<p class="issues-error mono" role="alert">${error}</p>`}284-<form class="issues-form" method="post" action="/${name}/issues/new">346+<form class="issues-form" method="post" action="/${name}/issues/new"347+enctype="multipart/form-data">285348 <input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">286349 <label class="issues-hp" aria-hidden="true">leave this empty287350 <input name="website" tabindex="-1" autocomplete="off"></label>@@ -291,6 +354,7 @@291354 <textarea class="issues-textarea" id="issue-body" name="body" rows="10" required292355 maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>293356 ${markdownHint}357+${attachField("issue-files")}294358 <label class="issues-label" for="issue-type">type</label>295359 <select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>296360 <label class="issues-label" for="issue-version">version you're on</label>@@ -319,6 +383,41 @@319383 const markdownHint = html`<p class="issue-hint mono">Markdown works: **bold**, \`code\`,320384 \`\`\` around logs, > to quote.</p>`;321385 386+function attachField(id: string): Html {387+ return html`<label class="issues-label" for="${id}">screenshots or logs388+<span class="muted">(optional, up to ${FILES_PER_POST})</span></label>389+<input class="issues-file" id="${id}" name="files" type="file" multiple390+accept="image/png,image/jpeg,image/gif,image/webp,.txt,.log">391+<ul class="issue-attached mono" aria-live="polite"></ul>392+<p class="issue-hint mono">PNG, JPEG, GIF or WebP up to 8 MB; .txt or .log up to 2 MB.393+Pictures can be pasted or dropped into the text box too.</p>`;394+}395+396+/** A post's pictures side by side, and each log as its start with the whole file a link away. */397+export function attachmentsHtml(base: string, files: Attachment[]): Html {398+ const url = (file: Attachment) => `${base}/files/${file.id}.${FILE_EXTENSIONS[file.type]}`;399+ const images = files.filter((file) => file.type !== "text/plain");400+ const logs = files.filter((file) => file.type === "text/plain");401+ return html`${402+ images.length > 0 &&403+ html`<div class="issue-shots">${images.map(404+ (file) => html`<a class="issue-shot" href="${url(file)}" target="_blank" rel="noopener">405+<img src="${url(file)}" alt="${file.name}" loading="lazy"></a>`,406+ )}</div>`407+ }${logs.map(408+ (file) => html`<details class="issue-log">409+<summary class="issue-log-name mono">${file.name}410+<span class="muted">${formatSize(file.size)}</span></summary>411+<pre><code>${new TextDecoder().decode(file.head ?? new Uint8Array())}</code></pre>412+<p class="issue-log-foot mono">413+<a class="issue-log-whole" href="${url(file)}">open the whole log</a>${414+ file.size > (file.head?.length ?? 0) &&415+ ` · showing its first ${formatSize(file.head?.length ?? 0)}`416+ }</p>417+</details>`,418+ )}`;419+}420+322421 function threadPage(323422 c: Context,324423 ctx: PageContext,@@ -334,10 +433,12 @@334433 entry.author === "operator" ? "operator" : entry.name || "anonymous";335434 const byId = new Map(entries.map((entry) => [entry.id, entry]));336435 const answering = draft.replyTo ? byId.get(draft.replyTo) : undefined;436+ const files = issueAttachments(ctx.kepr, issue.id);337437 // The issue itself is post 0: quoting it quotes what was filed.338438 const post = (key: number, author: string, operator: boolean, at: number, body: string) => {339439 const id = key === 0 ? "issue" : `entry-${key}`;340440 const answers = byId.get(byId.get(key)?.reply_to ?? -1);441+ const attached = files.filter((file) => (file.entry_id ?? 0) === key);341442 return html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}"342443 data-entry="${key === 0 ? "" : String(key)}" data-author="${author}">343444 <p class="issue-post-head mono">${@@ -350,6 +451,7 @@350451 <a href="#entry-${answers.id}">${who(answers)}</a></span>`351452 }<a class="history-when" href="#${id}" title="${formatDate(at)}">${relativeTime(at)}</a></p>352453 <div class="markdown issue-text">${renderComment(body)}</div>454+${attached.length > 0 && attachmentsHtml(base, attached)}353455 ${354456 open &&355457 html`<p class="issue-post-actions mono">@@ -379,7 +481,8 @@379481 </div>380482 ${381483 open382- ? html`<form class="issue-composer" id="reply" method="post" action="${base}">484+ ? html`<form class="issue-composer" id="reply" method="post" action="${base}"485+enctype="multipart/form-data">383486 <input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">384487 <label class="issues-hp" aria-hidden="true">leave this empty385488 <input name="website" tabindex="-1" autocomplete="off"></label>@@ -395,6 +498,7 @@395498 maxlength="${MAX_CHARS}" placeholder="More detail, another angle, what you tried…"396499 >${draft.body ?? ""}</textarea>397500 ${markdownHint}501+${attachField("reply-files")}398502 <label class="issues-label" for="reply-name">name to show399503 <span class="muted">(optional)</span></label>400504 <input class="issues-input" id="reply-name" name="name" maxlength="${NAME_CHARS}"src/issues/store.ts+19 −17
@@ -55,8 +55,8 @@5555 const PUBLIC_COLUMNS =5656 "id, number, title, body, type, version, reporter_name, filed_at, status, fixed_in";5757 58-/** Files an issue under the repo's next number and returns that number (§23). */59-export function fileIssue(db: Database, issue: NewIssue): number {58+/** Files an issue under the repo's next number (§23). */59+export function fileIssue(db: Database, issue: NewIssue): { id: number; number: number } {6060 // Immediate: it reads the next number before writing, and the sync's thread writes kepr.db too.6161 return db6262 .transaction(() => {@@ -67,23 +67,25 @@6767 WHERE repo = ?`,6868 )6969 .get(issue.repo) as { next: number };70- db.query(71- `INSERT INTO issues (70+ const { lastInsertRowid } = db71+ .query(72+ `INSERT INTO issues (7273 repo, number, title, body, type, version, reporter_name, contact_email, filed_at, ip_hash7374 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,74- ).run(75- issue.repo,76- row.next,77- issue.title,78- issue.body,79- issue.type,80- issue.version,81- issue.reporterName,82- issue.contactEmail,83- issue.filedAt,84- issue.ipHash,85- );86- return row.next;75+ )76+ .run(77+ issue.repo,78+ row.next,79+ issue.title,80+ issue.body,81+ issue.type,82+ issue.version,83+ issue.reporterName,84+ issue.contactEmail,85+ issue.filedAt,86+ issue.ipHash,87+ );88+ return { id: Number(lastInsertRowid), number: row.next };8789 })8890 .immediate();8991 }src/server/app.ts+15 −5
@@ -38,9 +38,12 @@3838 import { registerLogin } from "./login.ts";3939 4040 const MAX_BODY_BYTES = 64 * 1024;41-// §25's cap, except for the operator's picture and video uploads, which only a session reaches.41+// §25's cap, except for the operator's picture and video uploads, which only a session reaches,42+// and an issue or reply with its attachments (§23: four files of up to 8 MB).4243 const MAX_MEDIA_BYTES = 64 * 1024 * 1024;44+const MAX_ISSUE_BYTES = 34 * 1024 * 1024;4345 const MEDIA_UPLOAD = /^\/admin\/repos\/[^/]+\/media$/;46+const ISSUE_POST = /^\/[^/]+\/issues\/(new|[0-9]{1,9})$/;4447 export const PUBLIC_DIR = join(import.meta.dir, "../../public");4548 // §18 allows Bunny Fonts: its stylesheet and the font files it points at.4649 @@ -81,10 +84,11 @@8184 ): Hono {8285 const app = new Hono();8386 // §25 allows images from the presigned host, which is the bucket endpoint's origin; uploaded84- // videos come from there too.87+ // videos come from there too. `blob:` is an issue attachment's preview, made by the page itself.8588 const imgSrc = [8689 "'self'",8790 () => (config.bucket.kind === "s3" ? new URL(config.bucket.endpoint).origin : ""),91+ "blob:",8892 ];8993 app.use("*", async (c, next) => {9094 await next();@@ -94,7 +98,10 @@9498 }9599 if (/^\/(admin(?:\/|$)|sign-in$|connect$)/.test(path))96100 c.header("Referrer-Policy", "same-origin");97- if (/^\/[^/]+\/raw\/.+/.test(path) || path.startsWith("/static/assets/")) {101+ if (102+ /^\/[^/]+\/(raw|issues\/[0-9]+\/files)\/.+/.test(path) ||103+ path.startsWith("/static/assets/")104+ ) {98105 const policy = c.res.headers.get("Content-Security-Policy");99106 c.header("Content-Security-Policy", policy ? `${policy}; sandbox` : "sandbox");100107 }@@ -130,8 +137,11 @@130137 ? noCode(c)131138 : c.text("Request body too large", 413),132139 });133- const [small, large] = [limit(MAX_BODY_BYTES), limit(MAX_MEDIA_BYTES)];134- app.use((c, next) => (MEDIA_UPLOAD.test(c.req.path) ? large : small)(c, next));140+ const [small, issue, large] = [MAX_BODY_BYTES, MAX_ISSUE_BYTES, MAX_MEDIA_BYTES].map(limit);141+ app.use((c, next) => {142+ const path = c.req.path;143+ return (MEDIA_UPLOAD.test(path) ? large : ISSUE_POST.test(path) ? issue : small)(c, next);144+ });135145 // `/static/assets/` is the operator's uploads in `data/assets/` (§19), such as the signature.136146 const assetsDir = join(config.dataDir, "assets");137147 app.get("/static/:path{.+}", async (c, next) => {src/server/db.ts+14 −0
@@ -93,6 +93,20 @@9393 reply_to INTEGER9494 );9595 CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);96+-- §23: screenshots and logs, on an issue itself (entry_id NULL) or on one of its replies.97+CREATE TABLE IF NOT EXISTS issue_files (98+ id INTEGER PRIMARY KEY,99+ issue_id INTEGER NOT NULL REFERENCES issues (id),100+ entry_id INTEGER REFERENCES issue_entries (id),101+ name TEXT NOT NULL,102+ type TEXT NOT NULL,103+ size INTEGER NOT NULL,104+ data BLOB NOT NULL,105+ ip_hash TEXT NOT NULL DEFAULT '',106+ created_at INTEGER NOT NULL107+);108+CREATE INDEX IF NOT EXISTS issue_files_issue ON issue_files (issue_id, id);109+CREATE INDEX IF NOT EXISTS issue_files_ip ON issue_files (ip_hash, created_at);96110 -- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.97111 CREATE TABLE IF NOT EXISTS sessions (98112 token_hash TEXT PRIMARY KEY,tests/issues.test.ts+43 −6
@@ -39,24 +39,24 @@3939 const db = openKeprDb(await mkdtemp(join(tmpdir(), "kepr-issues-")));4040 4141 test("numbers are per repo and continue after an imported number", () => {42- expect(fileIssue(db, newIssue("app", "one"))).toBe(1);43- expect(fileIssue(db, newIssue("app", "two"))).toBe(2);44- expect(fileIssue(db, newIssue("lib", "first"))).toBe(1);42+ expect(fileIssue(db, newIssue("app", "one")).number).toBe(1);43+ expect(fileIssue(db, newIssue("app", "two")).number).toBe(2);44+ expect(fileIssue(db, newIssue("lib", "first")).number).toBe(1);4545 db.run(4646 "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('lib', 41, 'from kepr 2', 'x', 'idea', 5)",4747 );48- expect(fileIssue(db, newIssue("lib", "next"))).toBe(42);48+ expect(fileIssue(db, newIssue("lib", "next")).number).toBe(42);4949 });5050 5151 test("public reads leave out contact and IP", () => {52- const issue = findIssue(db, "app", fileIssue(db, newIssue("app", "secret")));52+ const issue = findIssue(db, "app", fileIssue(db, newIssue("app", "secret")).number);5353 expect(Object.keys(issue ?? {})).not.toContain("contact_email");5454 expect(Object.keys(issue ?? {})).not.toContain("ip_hash");5555 });5656 5757 test("hidden issues vanish from lists, counts and lookups but still count against the IP", () => {5858 const before = filingsSince(db, "ip-a", 0);59- const number = fileIssue(db, newIssue("app", "spam"));59+ const { number } = fileIssue(db, newIssue("app", "spam"));6060 db.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = ?", [number]);6161 expect(findIssue(db, "app", number)).toBeNull();6262 expect(listIssues(db, "app", "open").map((i) => i.title)).not.toContain("spam");@@ -139,6 +139,18 @@139139 version: "1.2.0",140140 };141141 const page = async (path: string) => (await app.request(path)).text();142+ const postFiles = (path: string, session: Session, body: string, files: File[]) => {143+ const form = new FormData();144+ form.append("stamp", session.stamp);145+ form.append("website", "");146+ form.append("body", body);147+ for (const file of files) form.append("files", file);148+ return app.request(149+ path,150+ { method: "POST", headers: { cookie: session.cookie }, body: form },151+ { requestIP: () => ({ address: "203.0.113.9" }) },152+ );153+ };142154 143155 beforeAll(async () => {144156 setSystemTime(new Date(clock));@@ -265,6 +277,31 @@265277 const thread = await page("/app/issues/1");266278 expect(thread).toContain(`replying to\n<a href="#entry-${bobsReply}">Bob</a>`);267279 expect(thread).toContain('<span class="issue-author">anonymous</span>');280+ });281+282+ test("pictures and logs attach to a reply, are judged by their bytes, and hide with it", async () => {283+ const session = await openForm("/app/issues/1");284+ tick(3);285+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);286+ const disguised = new File(["<svg onload=alert(1)>"], "shot.png", { type: "image/png" });287+ expect((await postFiles("/app/issues/1", session, "x", [disguised])).status).toBe(400);288+ const five = Array.from({ length: 5 }, (_, i) => new File([png], `${i}.png`));289+ expect((await postFiles("/app/issues/1", session, "x", five)).status).toBe(400);290+ const files = [new File([png], "shot.png"), new File(["one\nline <two>\n"], "crash.log")];291+ expect((await postFiles("/app/issues/1", session, "See attached.", files)).status).toBe(303);292+ const thread = await page("/app/issues/1");293+ expect(thread).toContain("line <two>");294+ const shot = /src="(\/app\/issues\/1\/files\/\d+\.png)"/.exec(thread)?.[1] ?? "";295+ const log = /href="(\/app\/issues\/1\/files\/\d+\.txt)"/.exec(thread)?.[1] ?? "";296+ const image = await app.request(shot);297+ expect(image.headers.get("content-type")).toBe("image/png");298+ expect(image.headers.get("content-security-policy")).toContain("sandbox");299+ expect(new Uint8Array(await image.arrayBuffer())).toEqual(png);300+ expect((await app.request(log)).headers.get("content-type")).toBe("text/plain; charset=utf-8");301+ expect((await app.request(shot.replace(".png", ".gif"))).status).toBe(404);302+ kepr.run("UPDATE issue_entries SET hidden = 1 WHERE body = 'See attached.'");303+ expect((await app.request(shot)).status).toBe(404);304+ expect(await page("/app/issues/1")).not.toContain(shot);268305 });269306 270307 test("a refused reply keeps what was typed", async () => {tests/server.test.ts+12 −0
@@ -61,3 +61,15 @@6161 expect((await post(64 * 1024)).status).toBe(200);6262 expect((await post(64 * 1024 + 1)).status).toBe(413);6363 });64+65+test("an issue or reply may carry its attachments, up to 34 MB, and nothing else may", async () => {66+ const app = createApp(config, openKeprDb(mkdtempSync(join(tmpdir(), "kepr-body-"))));67+ app.post("/app/issues/7/x", (c) => c.text("ok"));68+ const post = (path: string, bytes: number) =>69+ app.request(path, { method: "POST", body: "x".repeat(bytes) });70+ // The repo is unknown, so a body the cap lets through is answered 404.71+ expect((await post("/app/issues/new", 1024 * 1024)).status).toBe(404);72+ expect((await post("/app/issues/7", 34 * 1024 * 1024)).status).toBe(404);73+ expect((await post("/app/issues/7", 34 * 1024 * 1024 + 1)).status).toBe(413);74+ expect((await post("/app/issues/7/x", 64 * 1024 + 1)).status).toBe(413);75+});