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>
This commit is contained in:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+194
View File
@@ -0,0 +1,194 @@
// 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 <Image> 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<UploadKind, string> = {
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<string, string> = {
"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 ~13 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<string, string> = {
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<string> => {
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<Buffer | null> => {
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<void> => {
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<string>,
kind: UploadKind,
): Promise<number> => {
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;
};