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:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
+194
@@ -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 ~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;
|
||||
};
|
||||
Reference in New Issue
Block a user