// On-disk storage for the images a driver uploads. // // Two kinds, kept in separate directories because they have opposite audiences // and must never be reachable through each other's route: // // "document" — licence, ID card and vehicle registration scans. Identity // documents, so they are deliberately NOT served from a public static // directory: every file gets an unguessable name, is written outside the // web root, and is read back only through /(api)/driver/documents?name=…, // which checks the caller owns the document or is an owner reviewing it. // // "photo" — the driver's profile photo, which exists precisely to be shown // to riders choosing between drivers. Served unauthenticated (see // /(api)/driver/photo) because it is rendered by plain tags all // over the rider app; the unguessable name is what keeps it from being // enumerable, and the route still refuses any name no driver row points at. // // The separate directories are the guarantee: a name that addresses a scan // cannot resolve under the photo directory, so a bug in the public route can // never hand out someone's ID card. // // The `drivers.profile_image_url` / `license_image_url` / `id_image_url` / // `vehicle_reg_image_url` columns hold the bare stored name ("a1b2….jpg"), not // a URL — the mobile app and the admin dashboard reach the API on different // origins and each builds its own URL from the name. `profile_image_url` is // the exception that also accepts a full external URL, because an owner can // set one from the admin dashboard. import { randomBytes } from "crypto"; import { mkdir, readFile, readdir, stat, unlink, writeFile } from "fs/promises"; import path from "path"; export type UploadKind = "document" | "photo"; /** Uploads live outside the bundle so a rebuild never wipes them. */ const uploadRoot = (): string => process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.join(process.cwd(), ".uploads"); const SUBDIRECTORY: Record = { document: "driver-documents", photo: "driver-photos", }; const uploadDir = (kind: UploadKind): string => path.join(uploadRoot(), SUBDIRECTORY[kind]); /** Phone cameras produce JPEG; PNG and WebP cover gallery picks and screenshots. */ const EXTENSIONS: Record = { "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/webp": "webp", }; export const SUPPORTED_IMAGE_TYPES = Object.keys(EXTENSIONS); /** * A document scan of a national ID at readable resolution is ~1–3 MB. 10 MB * leaves room for a high-end camera without letting a client push arbitrary * amounts of data onto the disk. */ export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; /** Names are generated here, so anything not matching this was not. */ const NAME_PATTERN = /^[a-f0-9]{32}\.(jpg|png|webp)$/; export const isStoredUploadName = (value: unknown): value is string => typeof value === "string" && NAME_PATTERN.test(value); const MIME_BY_EXTENSION: Record = { jpg: "image/jpeg", png: "image/png", webp: "image/webp", }; export const uploadMimeType = (name: string): string => MIME_BY_EXTENSION[name.split(".").pop() ?? ""] ?? "application/octet-stream"; /** * Trusting the client's declared media type would let a caller store a .jpg * that is really something else, so the magic bytes decide. Returns null when * the buffer is not one of the formats we accept. */ export const sniffImageType = (buffer: Buffer): string | null => { if (buffer.length < 12) return null; // JPEG: FF D8 FF if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { return "image/jpeg"; } // PNG: 89 50 4E 47 0D 0A 1A 0A if (buffer.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) { return "image/png"; } // WebP: "RIFF" .... "WEBP" if ( buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP" ) { return "image/webp"; } return null; }; /** Writes an upload under a random name and returns that name. */ export const storeUpload = async ( buffer: Buffer, mimeType: string, kind: UploadKind, ): Promise => { const extension = EXTENSIONS[mimeType]; if (!extension) throw new Error(`Unsupported image type: ${mimeType}`); const dir = uploadDir(kind); await mkdir(dir, { recursive: true }); const name = `${randomBytes(16).toString("hex")}.${extension}`; await writeFile(path.join(dir, name), buffer); return name; }; /** Reads a stored upload back, or null when it is gone. */ export const readUpload = async ( name: string, kind: UploadKind, ): Promise => { if (!isStoredUploadName(name)) return null; try { // The name pattern already rules out separators and "..", so this join // cannot escape the directory — the check above is the guard, not this. return await readFile(path.join(uploadDir(kind), name)); } catch { return null; } }; export const deleteUpload = async ( name: string, kind: UploadKind, ): Promise => { if (!isStoredUploadName(name)) return; try { await unlink(path.join(uploadDir(kind), name)); } catch { // Already gone, which is the state we wanted. } }; /** * A driver who scans their licence and then abandons onboarding leaves a file * behind that no row references. Sweeping anything older than a day that isn't * referenced keeps identity documents from piling up indefinitely; the grace * period is what keeps an upload alive between the upload and the submit. * * `referenced` must be the full set of names still in use for that kind — * passing a partial set would delete live files, so the caller queries every * column that can hold one. */ const ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000; export const pruneOrphanUploads = async ( referenced: Set, kind: UploadKind, ): Promise => { let removed = 0; try { const dir = uploadDir(kind); const names = await readdir(dir); const cutoff = Date.now() - ORPHAN_GRACE_MS; for (const name of names) { if (!isStoredUploadName(name) || referenced.has(name)) continue; const info = await stat(path.join(dir, name)).catch(() => null); if (!info || info.mtimeMs >= cutoff) continue; await deleteUpload(name, kind); removed += 1; } } catch { // The directory may not exist yet. Nothing to prune either way. } return removed; };