1import type { Database } from "bun:sqlite";234export 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;89const PREVIEW_BYTES = 16 * 1024;101112const 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 33 head: Uint8Array | null;34};353637export 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 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}646566export 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}747576export 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}103104105export 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}116117118export 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}