import type { Database } from "bun:sqlite"; // §23: what an issue or reply may carry. export const FILES_PER_POST = 4; export const FILE_BYTES_PER_HOUR = 64 * 1024 * 1024; const IMAGE_BYTES = 8 * 1024 * 1024; const LOG_BYTES = 2 * 1024 * 1024; // A log shows this much of its start in the thread; the whole file is a link away. const PREVIEW_BYTES = 16 * 1024; // A picture is known by its first bytes, never by the name or type the browser sent. const IMAGES: [type: string, ext: string, magic: (head: string) => boolean][] = [ ["image/png", "png", (head) => head.startsWith("\x89PNG\r\n\x1a\n")], ["image/jpeg", "jpg", (head) => head.startsWith("\xff\xd8\xff")], ["image/gif", "gif", (head) => head.startsWith("GIF87a") || head.startsWith("GIF89a")], ["image/webp", "webp", (head) => head.startsWith("RIFF") && head.slice(8, 12) === "WEBP"], ]; export const FILE_EXTENSIONS: Record = { ...Object.fromEntries(IMAGES.map(([type, ext]) => [type, ext])), "text/plain": "txt", }; export type Upload = { name: string; type: string; data: Uint8Array }; type StoredFile = { name: string; type: string; data: Uint8Array }; export type Attachment = { id: number; entry_id: number | null; name: string; type: string; size: number; /** A log's first bytes, for its preview; null for a picture. */ head: Uint8Array | null; }; /** Checks the files sent with one post, returning them ready to keep or the reason to refuse. */ export async function readUploads(files: File[]): Promise { if (files.length > FILES_PER_POST) return `Attach at most ${FILES_PER_POST} files.`; const uploads: Upload[] = []; for (const file of files) { const data = new Uint8Array(await file.arrayBuffer()); // Shown, never used as a path; control characters would only garble the page. const name = (file.name.split(/[\\/]/).pop() ?? "").replace(/\p{Cc}/gu, "").slice(0, 100); const head = Buffer.from(data.subarray(0, 12)).toString("latin1"); const image = IMAGES.find(([, , magic]) => magic(head)); if (image) { if (data.length > IMAGE_BYTES) return `${name} is over 8 MB.`; // The bytes decide the type, so the name follows them: a PNG called report.html would // otherwise be offered for download under an extension its bytes are not (§25). const stem = name.replace(/\.\w+$/, ""); uploads.push({ name: `${stem || "screenshot"}.${image[1]}`, type: image[0], data }); continue; } if (!/\.(txt|log)$/i.test(name)) { return `${name || "A file"} isn't a PNG, JPEG, GIF or WebP picture, or a .txt or .log file.`; } if (data.length > LOG_BYTES) return `${name} is over 2 MB.`; try { new TextDecoder("utf-8", { fatal: true }).decode(data); } catch { return `${name} isn't UTF-8 text.`; } uploads.push({ name, type: "text/plain", data }); } return uploads; } /** Bytes attached from one hashed IP since a time, hidden posts' included. */ export function fileBytesSince(db: Database, ipHash: string, since: number): number { const row = db .query<{ n: number | null }, [string, number]>( "SELECT SUM(size) AS n FROM issue_files WHERE ip_hash = ? AND created_at > ?", ) .get(ipHash, since); return row?.n ?? 0; } /** Keeps a post's files: the issue's own when `entryId` is null, else one reply's. */ export function attachFiles( db: Database, issueId: number, entryId: number | null, uploads: Upload[], ipHash: string, at: number, ): void { const insert = db.query( `INSERT INTO issue_files (issue_id, entry_id, name, type, size, data, ip_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ); db.transaction(() => { for (const upload of uploads) { insert.run( issueId, entryId, upload.name, upload.type, upload.data.length, upload.data, ipHash, at, ); } })(); } /** Every file of an issue's posts, oldest first, without their bytes beyond a log's start. */ export function issueAttachments(db: Database, issueId: number): Attachment[] { return db .query( `SELECT id, entry_id, name, type, size, CASE WHEN type = 'text/plain' THEN substr(data, 1, ?) END AS head FROM issue_files WHERE issue_id = ? ORDER BY id`, ) .all(PREVIEW_BYTES, issueId); } /** One file, only while its issue, its repo's issue list and its post are all visible. */ export function visibleFile( db: Database, repo: string, number: number, id: number, ): StoredFile | null { return db .query( `SELECT f.name, f.type, f.data FROM issue_files f JOIN issues i ON i.id = f.issue_id LEFT JOIN issue_entries e ON e.id = f.entry_id WHERE i.repo = ? AND i.number = ? AND f.id = ? AND i.hidden = 0 AND (f.entry_id IS NULL OR e.hidden = 0)`, ) .get(repo, number, id); }