import { sql } from "@/lib/db"; import { isDocumentType, OcrUnavailableError, parseDocumentText, recogniseDocument, } from "@/lib/document-ocr"; import { requireAuth } from "@/lib/jwt"; import { MAX_UPLOAD_BYTES, pruneOrphanUploads, sniffImageType, storeUpload, } from "@/lib/uploads"; // POST — a driver photographs one of their documents; we keep the scan and // read what we can off it to prefill the onboarding form. // // The scan is stored whether or not OCR succeeds: the reviewer wants to see the // actual licence next to the numbers the driver submitted, and that value does // not depend on Vision having had a good day. When OCR fails the route still // answers 200 with an empty field set and a code the client uses to say "type // these in yourself" — an unreadable photo is a normal outcome, not an error. /** * Scans are the most expensive call in the app (a paid Vision request plus a * disk write), so cap how fast one account can make them. In-process and * therefore per-server — enough to stop a stuck retry loop or a bored driver * burning the Vision quota, not a defence against a distributed attacker. */ const SCAN_LIMIT = 20; const SCAN_WINDOW_MS = 60 * 60 * 1000; const recentScans = new Map(); const overScanLimit = (userId: string): boolean => { const now = Date.now(); const cutoff = now - SCAN_WINDOW_MS; const history = (recentScans.get(userId) ?? []).filter((at) => at > cutoff); if (history.length >= SCAN_LIMIT) { recentScans.set(userId, history); return true; } history.push(now); recentScans.set(userId, history); // Without this the map grows one entry per driver forever. Anything whose // whole history has aged out is a driver who isn't scanning any more. if (recentScans.size > 500) { for (const [key, times] of recentScans) { if (times.every((at) => at <= cutoff)) recentScans.delete(key); } } return false; }; /** * Abandoned onboarding leaves identity documents on disk that nothing points * at. Sweeping them here rather than on a cron keeps the deployment to one * process; once an hour is often enough for files that get a day's grace. */ const PRUNE_INTERVAL_MS = 60 * 60 * 1000; let lastPruneAt = 0; const pruneOrphansOccasionally = async (): Promise => { if (Date.now() - lastPruneAt < PRUNE_INTERVAL_MS) return; lastPruneAt = Date.now(); try { const rows = await sql<{ license_image_url: string | null; id_image_url: string | null; vehicle_reg_image_url: string | null; }>` SELECT license_image_url, id_image_url, vehicle_reg_image_url FROM drivers WHERE license_image_url IS NOT NULL OR id_image_url IS NOT NULL OR vehicle_reg_image_url IS NOT NULL `; const referenced = new Set(); for (const row of rows) { for (const name of Object.values(row)) { if (name) referenced.add(name); } } await pruneOrphanUploads(referenced, "document"); } catch (error) { // A failed sweep must never fail the driver's scan. console.error("[DRIVER_SCAN_PRUNE]: ", error); } }; export async function POST(req: Request) { const auth = requireAuth(req); if ("error" in auth) return auth.error; try { const body = await req.json(); const { doc_type: docType } = body; if (!isDocumentType(docType)) { return Response.json( { error: "doc_type must be license, id or vehicle_reg." }, { status: 400 }, ); } // Same gate as onboarding itself: only a driver-role account has any // business uploading driver documents. const users = await sql<{ role: string | null }>` SELECT role FROM users WHERE id = ${auth.userId} `; if (users[0]?.role !== "driver") { return Response.json( { error: "Only driver accounts can scan documents." }, { status: 403 }, ); } if (overScanLimit(auth.userId)) { return Response.json( { error: "Too many scans. Wait a few minutes and try again.", code: "SCAN_RATE_LIMIT", }, { status: 429 }, ); } const raw = body.image_base64; if (typeof raw !== "string" || raw.length === 0) { return Response.json( { error: "image_base64 is required." }, { status: 400 }, ); } // Some clients send a full data URI. Take the payload either way. const encoded = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw; // Base64 inflates by 4/3, so reject on the encoded length before // allocating — otherwise an oversized upload is buffered just to be // refused. if (encoded.length > MAX_UPLOAD_BYTES * 1.4) { return Response.json( { error: "That image is too large.", code: "IMAGE_TOO_LARGE" }, { status: 413 }, ); } const image = Buffer.from(encoded, "base64"); if (image.length > MAX_UPLOAD_BYTES) { return Response.json( { error: "That image is too large.", code: "IMAGE_TOO_LARGE" }, { status: 413 }, ); } // The magic bytes decide the type, not whatever the client claimed, so a // non-image can't be parked on the disk under a .jpg name. const mimeType = sniffImageType(image); if (!mimeType) { return Response.json( { error: "Upload a JPEG, PNG or WebP photo.", code: "UNSUPPORTED_IMAGE", }, { status: 400 }, ); } const document = await storeUpload(image, mimeType, "document"); void pruneOrphansOccasionally(); let fields = {}; let ocrFailed = false; try { const text = await recogniseDocument(image); fields = parseDocumentText(text, docType); } catch (error) { if (!(error instanceof OcrUnavailableError)) throw error; // Logged, not surfaced: the message can name the API key's failure mode // and the driver can do nothing with it but type the fields manually. console.error("[DRIVER_SCAN_OCR]: ", error.message); ocrFailed = true; } return Response.json({ data: { doc_type: docType, /** Opaque stored name; submit it with the profile to attach the scan. */ document, fields, ...(ocrFailed ? { code: "OCR_UNAVAILABLE" } : {}), }, }); } catch (error) { console.error("[DRIVER_SCAN_POST]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } }