import { preflight, withCors } from "@/lib/admin"; import { sql } from "@/lib/db"; import { requireAuth } from "@/lib/jwt"; import { deleteUpload, isStoredUploadName, MAX_UPLOAD_BYTES, pruneOrphanUploads, readUpload, sniffImageType, storeUpload, uploadMimeType, } from "@/lib/uploads"; // The driver's profile photo — the face a rider sees beside a driver's name // when picking between offers, and what they check the arriving car's driver // against. // // POST uploads it (authenticated, driver-role only). GET serves it, and unlike // the document route it does NOT require a token: this image is rendered by // plain / tags across the rider app, the driver map and the admin // dashboard, none of which can attach an Authorization header without turning // every avatar into a bespoke fetch-and-blob dance. What protects it instead // is that the name is 128 bits of randomness and the route refuses any name no // driver row actually points at — so it cannot be enumerated, and it cannot be // used as a general-purpose anonymous image host for whatever somebody // uploaded and abandoned. // // This is the opposite trade to /(api)/driver/documents, which is why the two // live in separate directories on disk: a name that addresses a licence scan // resolves to nothing here. export async function OPTIONS(request: Request) { return preflight(request); } export async function GET(request: Request) { const name = new URL(request.url).searchParams.get("name"); const notFound = () => withCors(request, Response.json({ error: "Not found." }, { status: 404 })); // Rejecting the name before it reaches the filesystem is what keeps a // crafted "../../.env" from ever being joined onto the upload directory. if (!isStoredUploadName(name)) return notFound(); try { // Only photos a driver profile actually points at are served. Without // this, any signed-in driver could upload an arbitrary image and walk away // with a permanent public URL for it. const rows = await sql<{ used: boolean }>` SELECT EXISTS ( SELECT 1 FROM drivers WHERE profile_image_url = ${name} ) AS used `; if (!rows[0]?.used) return notFound(); const bytes = await readUpload(name, "photo"); if (!bytes) return notFound(); return withCors( request, new Response(new Uint8Array(bytes), { headers: { "Content-Type": uploadMimeType(name), "Content-Length": String(bytes.length), // The name changes whenever the photo does, so the bytes behind a // given URL are immutable and can be cached hard. That matters: the // rider's nearby-drivers view re-renders these constantly. "Cache-Control": "public, max-age=604800, immutable", "X-Content-Type-Options": "nosniff", }, }), ); } catch (error) { console.error("[DRIVER_PHOTO_GET]: ", error); return withCors( request, Response.json({ error: "Internal Server Error" }, { status: 500 }), ); } } /** * Photos are cheap compared with a scan (no Vision call), but still a disk * write, so keep a lid on how fast one account can retake theirs. */ const PHOTO_LIMIT = 15; const PHOTO_WINDOW_MS = 60 * 60 * 1000; const recentUploads = new Map(); const overPhotoLimit = (userId: string): boolean => { const now = Date.now(); const cutoff = now - PHOTO_WINDOW_MS; const history = (recentUploads.get(userId) ?? []).filter((at) => at > cutoff); if (history.length >= PHOTO_LIMIT) { recentUploads.set(userId, history); return true; } history.push(now); recentUploads.set(userId, history); if (recentUploads.size > 500) { for (const [key, times] of recentUploads) { if (times.every((at) => at <= cutoff)) recentUploads.delete(key); } } return false; }; const PRUNE_INTERVAL_MS = 60 * 60 * 1000; let lastPruneAt = 0; /** * A driver who takes a photo and then abandons onboarding leaves a file * nothing points at. Same sweep as the scan route, over the photo directory. */ const pruneOrphansOccasionally = async (): Promise => { if (Date.now() - lastPruneAt < PRUNE_INTERVAL_MS) return; lastPruneAt = Date.now(); try { const rows = await sql<{ profile_image_url: string | null }>` SELECT profile_image_url FROM drivers WHERE profile_image_url IS NOT NULL `; const referenced = new Set( rows.map((row) => row.profile_image_url).filter(Boolean) as string[], ); await pruneOrphanUploads(referenced, "photo"); } catch (error) { console.error("[DRIVER_PHOTO_PRUNE]: ", error); } }; // POST — upload or replace the driver's profile photo. // // A driver who already has a profile row gets it attached straight away, so // retaking a bad photo is one step. During onboarding there is no row yet, so // the name is just returned and travels up with the profile submission. export async function POST(req: Request) { const auth = requireAuth(req); if ("error" in auth) return auth.error; try { 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 upload a driver photo." }, { status: 403 }, ); } if (overPhotoLimit(auth.userId)) { return Response.json( { error: "Too many uploads. Wait a few minutes and try again.", code: "PHOTO_RATE_LIMIT", }, { status: 429 }, ); } const body = await req.json(); const raw = body.image_base64; if (typeof raw !== "string" || raw.length === 0) { return Response.json( { error: "image_base64 is required." }, { status: 400 }, ); } 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 }, ); } const mimeType = sniffImageType(image); if (!mimeType) { return Response.json( { error: "Upload a JPEG, PNG or WebP photo.", code: "UNSUPPORTED_IMAGE", }, { status: 400 }, ); } const photo = await storeUpload(image, mimeType, "photo"); // Attach it now if the driver already has a profile, so retaking a bad // photo is a single step. Mid-onboarding there is no row yet and the name // simply travels up with the profile submission instead. // // This deliberately does not touch approval_status: a driver swapping a // blurry photo for a clear one shouldn't be knocked out of service, and // the reviewer sees whatever the current photo is when they next open the // profile. const existing = await sql<{ profile_image_url: string | null }>` SELECT profile_image_url FROM drivers WHERE user_id = ${auth.userId} `; const attached = existing.length > 0; if (attached) { await sql` UPDATE drivers SET profile_image_url = ${photo} WHERE user_id = ${auth.userId} `; // Only a name we stored is safe to unlink — an owner may have set an // external URL from the dashboard, and that is not ours to delete. const previous = existing[0].profile_image_url; if (previous && previous !== photo && isStoredUploadName(previous)) { await deleteUpload(previous, "photo"); } } void pruneOrphansOccasionally(); return Response.json({ data: { /** Opaque stored name; send it with the profile if onboarding. */ photo, attached, }, }); } catch (error) { console.error("[DRIVER_PHOTO_POST]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } }