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
10 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 −0
package.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": {

view file

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 

view file

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();

view file

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+}

view file

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, &gt; 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}"

view file

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 }

view file

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) => {

view file

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,

view file

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 &lt;two&gt;");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 () => {

view file

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+});

view file