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>
195 lines
6.4 KiB
TypeScript
195 lines
6.4 KiB
TypeScript
// 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 ~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<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;
|
||
};
|