| 1 | import { randomBytes } from "node:crypto"; |
| 2 | import { mkdir, writeFile } from "node:fs/promises"; |
| 3 | import path from "node:path"; |
| 4 | |
| 5 | export const UPLOADS_DIR = |
| 6 | process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads"); |
| 7 | |
| 8 | export const MAX_IMAGE_BYTES = 8 * 1024 * 1024; |
| 9 | export const MAX_PDF_BYTES = 15 * 1024 * 1024; |
| 10 | |
| 11 | const CONTENT_TYPES: Record<string, string> = { |
| 12 | jpg: "image/jpeg", |
| 13 | png: "image/png", |
| 14 | webp: "image/webp", |
| 15 | pdf: "application/pdf", |
| 16 | }; |
| 17 | |
| 18 | export function contentTypeFor(ext: string): string | null { |
| 19 | return CONTENT_TYPES[ext] ?? null; |
| 20 | } |
| 21 | |
| 22 | export const UPLOAD_NAME_RE = /^[a-f0-9]{32}\.[a-z0-9]{3,4}$/; |
| 23 | |
| 24 | function sniffExt(buf: Buffer): keyof typeof CONTENT_TYPES | null { |
| 25 | if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) { |
| 26 | return "jpg"; |
| 27 | } |
| 28 | if ( |
| 29 | buf.length >= 8 && |
| 30 | buf |
| 31 | .subarray(0, 8) |
| 32 | .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) |
| 33 | ) { |
| 34 | return "png"; |
| 35 | } |
| 36 | if ( |
| 37 | buf.length >= 12 && |
| 38 | buf.subarray(0, 4).toString("ascii") === "RIFF" && |
| 39 | buf.subarray(8, 12).toString("ascii") === "WEBP" |
| 40 | ) { |
| 41 | return "webp"; |
| 42 | } |
| 43 | if (buf.length >= 4 && buf.subarray(0, 4).toString("ascii") === "%PDF") { |
| 44 | return "pdf"; |
| 45 | } |
| 46 | return null; |
| 47 | } |
| 48 | |
| 49 | export type UploadKind = "image" | "pdf"; |
| 50 | |
| 51 | export async function saveUpload(file: File, kind: UploadKind): Promise<string> { |
| 52 | const max = kind === "pdf" ? MAX_PDF_BYTES : MAX_IMAGE_BYTES; |
| 53 | if (file.size === 0) throw new Error("Empty file."); |
| 54 | if (file.size > max) { |
| 55 | throw new Error(`File too large. Max ${Math.round(max / 1024 / 1024)}MB.`); |
| 56 | } |
| 57 | const buf = Buffer.from(await file.arrayBuffer()); |
| 58 | const ext = sniffExt(buf); |
| 59 | const accepted = |
| 60 | kind === "pdf" ? ext === "pdf" : ext === "jpg" || ext === "png" || ext === "webp"; |
| 61 | if (!ext || !accepted) { |
| 62 | throw new Error( |
| 63 | kind === "pdf" |
| 64 | ? "Only PDF files are accepted." |
| 65 | : "Only JPEG, PNG, or WebP images are accepted.", |
| 66 | ); |
| 67 | } |
| 68 | const name = `${randomBytes(16).toString("hex")}.${ext}`; |
| 69 | await mkdir(UPLOADS_DIR, { recursive: true }); |
| 70 | await writeFile(path.join(UPLOADS_DIR, name), buf); |
| 71 | return `/api/uploads/${name}`; |
| 72 | } |