files.ts

4.7 KB · raw

1import type { Database } from "bun:sqlite";23// §23: what an issue or reply may carry.4export const FILES_PER_POST = 4;5export const FILE_BYTES_PER_HOUR = 64 * 1024 * 1024;6const IMAGE_BYTES = 8 * 1024 * 1024;7const LOG_BYTES = 2 * 1024 * 1024;8// A log shows this much of its start in the thread; the whole file is a link away.9const PREVIEW_BYTES = 16 * 1024;1011// A picture is known by its first bytes, never by the name or type the browser sent.12const 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];18export const FILE_EXTENSIONS: Record<string, string> = {19	...Object.fromEntries(IMAGES.map(([type, ext]) => [type, ext])),20	"text/plain": "txt",21};2223export type Upload = { name: string; type: string; data: Uint8Array };24type StoredFile = { name: string; type: string; data: Uint8Array<ArrayBuffer> };2526export 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};3536/** Checks the files sent with one post, returning them ready to keep or the reason to refuse. */37export 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			// The bytes decide the type, so the name follows them: a PNG called report.html would49			// otherwise be offered for download under an extension its bytes are not (§25).50			const stem = name.replace(/\.\w+$/, "");51			uploads.push({ name: `${stem || "screenshot"}.${image[1]}`, type: image[0], data });52			continue;53		}54		if (!/\.(txt|log)$/i.test(name)) {55			return `${name || "A file"} isn't a PNG, JPEG, GIF or WebP picture, or a .txt or .log file.`;56		}57		if (data.length > LOG_BYTES) return `${name} is over 2 MB.`;58		try {59			new TextDecoder("utf-8", { fatal: true }).decode(data);60		} catch {61			return `${name} isn't UTF-8 text.`;62		}63		uploads.push({ name, type: "text/plain", data });64	}65	return uploads;66}6768/** Bytes attached from one hashed IP since a time, hidden posts' included. */69export function fileBytesSince(db: Database, ipHash: string, since: number): number {70	const row = db71		.query<{ n: number | null }, [string, number]>(72			"SELECT SUM(size) AS n FROM issue_files WHERE ip_hash = ? AND created_at > ?",73		)74		.get(ipHash, since);75	return row?.n ?? 0;76}7778/** Keeps a post's files: the issue's own when `entryId` is null, else one reply's. */79export function attachFiles(80	db: Database,81	issueId: number,82	entryId: number | null,83	uploads: Upload[],84	ipHash: string,85	at: number,86): void {87	const insert = db.query(88		`INSERT INTO issue_files (issue_id, entry_id, name, type, size, data, ip_hash, created_at)89VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,90	);91	db.transaction(() => {92		for (const upload of uploads) {93			insert.run(94				issueId,95				entryId,96				upload.name,97				upload.type,98				upload.data.length,99				upload.data,100				ipHash,101				at,102			);103		}104	})();105}106107/** Every file of an issue's posts, oldest first, without their bytes beyond a log's start. */108export function issueAttachments(db: Database, issueId: number): Attachment[] {109	return db110		.query<Attachment, [number, number]>(111			`SELECT id, entry_id, name, type, size,112	CASE WHEN type = 'text/plain' THEN substr(data, 1, ?) END AS head113FROM issue_files114WHERE issue_id = ?115ORDER BY id`,116		)117		.all(PREVIEW_BYTES, issueId);118}119120/** One file, only while its issue, its repo's issue list and its post are all visible. */121export function visibleFile(122	db: Database,123	repo: string,124	number: number,125	id: number,126): StoredFile | null {127	return db128		.query<StoredFile, [string, number, number]>(129			`SELECT f.name, f.type, f.data130FROM issue_files f131JOIN issues i ON i.id = f.issue_id132LEFT JOIN issue_entries e ON e.id = f.entry_id133WHERE i.repo = ? AND i.number = ? AND f.id = ? AND i.hidden = 0134	AND (f.entry_id IS NULL OR e.hidden = 0)`,135		)136		.get(repo, number, id);137}