Files
waseel/lib/uploads.ts
T
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

195 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
};