files.ts

4.4 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			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}6465/** Bytes attached from one hashed IP since a time, hidden posts' included. */66export 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}7475/** Keeps a post's files: the issue's own when `entryId` is null, else one reply's. */76export 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)86VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,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}103104/** Every file of an issue's posts, oldest first, without their bytes beyond a log's start. */105export 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 head110FROM issue_files111WHERE issue_id = ?112ORDER BY id`,113		)114		.all(PREVIEW_BYTES, issueId);115}116117/** One file, only while its issue, its repo's issue list and its post are all visible. */118export 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.data127FROM issue_files f128JOIN issues i ON i.id = f.issue_id129LEFT JOIN issue_entries e ON e.id = f.entry_id130WHERE 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}