Files
waseel/app/(api)/driver/photo+api.ts
KrikoriosandClaude Opus 5 8807ff41c5 Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:17:55 +03:00

258 lines
8.2 KiB
TypeScript

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 <Image>/<img> 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<string, number[]>();
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<void> => {
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 });
}
}