- Client-side: Laplacian variance blur detection in photo quality check (very blurry / somewhat blurry / acceptable / good) - Server-side: async Ollama vision model validates uploaded image is a real government ID (minicpm-v:8b), flags non-ID uploads - Corporate check: sole proprietors now get yellow 'form an LLC' upsell, formal entities get annual report/RA reminder
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* Photo ID upload — secure upload + storage + polling.
|
|
*
|
|
* POST /api/v1/id-upload/token — Generate upload token (from intake form)
|
|
* POST /api/v1/id-upload/:token — Upload ID image (from phone or desktop)
|
|
* GET /api/v1/id-upload/:token/status — Poll upload status (desktop polls this)
|
|
*/
|
|
|
|
import { Router, type Request, type Response } from "express";
|
|
import { pool } from "../db.js";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
const router = Router();
|
|
|
|
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
|
|
const MINIO_BUCKET = process.env.MINIO_BUCKET || "performancewest";
|
|
|
|
/** Ask the workers for a presigned MinIO URL (GET or PUT). */
|
|
async function presign(key: string, method: "GET" | "PUT", expires = 3600): Promise<string | null> {
|
|
if (!key) return null;
|
|
try {
|
|
const r = await fetch(`${WORKER_URL}/jobs/presign`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ key, expires, method }),
|
|
});
|
|
if (!r.ok) return null;
|
|
const data = (await r.json()) as { url?: string };
|
|
let url = data.url || null;
|
|
if (url) {
|
|
url = url.replace(/^http:\/\/minio:9000\//, "https://minio.performancewest.net/");
|
|
}
|
|
return url;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Upload a buffer to MinIO via the workers. */
|
|
async function uploadToMinio(key: string, buffer: Buffer, contentType: string): Promise<boolean> {
|
|
try {
|
|
const r = await fetch(`${WORKER_URL}/jobs/minio-upload`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
key,
|
|
data: buffer.toString("base64"),
|
|
content_type: contentType,
|
|
bucket: MINIO_BUCKET,
|
|
}),
|
|
});
|
|
if (!r.ok) return false;
|
|
const data = (await r.json()) as { success?: boolean };
|
|
return !!data.success;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Ensure the id_upload_tokens table exists
|
|
(async () => {
|
|
try {
|
|
await pool.query(`
|
|
CREATE TABLE IF NOT EXISTS id_upload_tokens (
|
|
id SERIAL PRIMARY KEY,
|
|
token TEXT NOT NULL UNIQUE,
|
|
order_number TEXT,
|
|
customer_email TEXT,
|
|
customer_name TEXT,
|
|
order_reference TEXT,
|
|
front_uploaded BOOLEAN DEFAULT FALSE,
|
|
back_uploaded BOOLEAN DEFAULT FALSE,
|
|
used BOOLEAN DEFAULT FALSE,
|
|
minio_paths JSONB DEFAULT '{}',
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
)
|
|
`);
|
|
} catch {}
|
|
})();
|
|
|
|
// POST /api/v1/id-upload/token — Generate a one-time upload token
|
|
router.post("/api/v1/id-upload/token", async (req: Request, res: Response) => {
|
|
try {
|
|
const { order_number, customer_email, customer_name } = req.body ?? {};
|
|
if (!order_number) {
|
|
res.status(400).json({ error: "order_number is required." });
|
|
return;
|
|
}
|
|
|
|
const token = uuidv4();
|
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
|
|
await pool.query(
|
|
`INSERT INTO id_upload_tokens (token, order_number, customer_email, customer_name, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[token, order_number, customer_email || null, customer_name || null, expiresAt],
|
|
);
|
|
|
|
const domain = process.env.DOMAIN || "performancewest.net";
|
|
const uploadUrl = `https://${domain}/portal/upload-id/?order=${order_number}&upload_token=${token}`;
|
|
|
|
res.status(201).json({
|
|
token,
|
|
upload_url: uploadUrl,
|
|
expires_at: expiresAt.toISOString(),
|
|
});
|
|
} catch (err) {
|
|
console.error("[id-upload] Token creation error:", err);
|
|
res.status(500).json({ error: "Could not create upload token." });
|
|
}
|
|
});
|
|
|
|
// POST /api/v1/id-upload/:token — Upload ID image
|
|
router.post("/api/v1/id-upload/:token", async (req: Request, res: Response) => {
|
|
try {
|
|
const { token } = req.params;
|
|
|
|
// Validate token
|
|
const tokenResult = await pool.query(
|
|
"SELECT * FROM id_upload_tokens WHERE token = $1",
|
|
[token],
|
|
);
|
|
if (tokenResult.rows.length === 0) {
|
|
res.status(404).json({ error: "Invalid upload token." });
|
|
return;
|
|
}
|
|
const tokenData = tokenResult.rows[0] as Record<string, unknown>;
|
|
|
|
if (tokenData.used) {
|
|
res.status(410).json({ error: "This upload link has already been used." });
|
|
return;
|
|
}
|
|
if (new Date(tokenData.expires_at as string) < new Date()) {
|
|
res.status(410).json({ error: "This upload link has expired." });
|
|
return;
|
|
}
|
|
|
|
// Accept base64 image in JSON body
|
|
const { image_data, filename, content_type } = req.body ?? {};
|
|
if (!image_data) {
|
|
res.status(400).json({ error: "image_data (base64) is required." });
|
|
return;
|
|
}
|
|
|
|
// Decode base64
|
|
const base64Data = image_data.replace(/^data:[^;]+;base64,/, "");
|
|
const buffer = Buffer.from(base64Data, "base64");
|
|
|
|
// Store in MinIO via workers
|
|
const ext = (content_type || "image/jpeg").split("/")[1] || "jpg";
|
|
const minioPath = `identity-docs/${tokenData.order_number}/${token}.${ext}`;
|
|
const paths: Record<string, string> = (tokenData.minio_paths as Record<string, string>) || {};
|
|
|
|
const uploaded = await uploadToMinio(minioPath, buffer, content_type || "image/jpeg");
|
|
if (uploaded) {
|
|
paths.front = minioPath;
|
|
console.log(`[id-upload] Stored ${minioPath} (${buffer.length} bytes)`);
|
|
} else {
|
|
console.error("[id-upload] MinIO upload failed for", minioPath);
|
|
res.status(500).json({ error: "Upload failed — please try again, or upload the file from your computer instead." });
|
|
return;
|
|
}
|
|
|
|
// Update token
|
|
await pool.query(
|
|
`UPDATE id_upload_tokens SET front_uploaded = TRUE, used = TRUE, minio_paths = $1 WHERE token = $2`,
|
|
[JSON.stringify(paths), token],
|
|
);
|
|
|
|
// Also update the compliance order's intake_data with photo flag
|
|
if (tokenData.order_number) {
|
|
await pool.query(
|
|
`UPDATE compliance_orders SET intake_data = jsonb_set(
|
|
COALESCE(intake_data, '{}'::jsonb),
|
|
'{photo_id_uploaded}', 'true'::jsonb
|
|
) WHERE order_number = $1`,
|
|
[tokenData.order_number],
|
|
).catch(() => {});
|
|
}
|
|
|
|
// Generate a presigned thumbnail URL for the browser to display
|
|
let thumbnailUrl = "";
|
|
if (uploaded) {
|
|
thumbnailUrl = (await presign(minioPath, "GET", 3600)) || "";
|
|
}
|
|
|
|
// Kick off async Ollama ID validation (non-blocking)
|
|
validateIdWithOllama(base64Data, tokenData.order_number as string, token).catch(() => {});
|
|
|
|
res.json({
|
|
success: true,
|
|
uploaded: true,
|
|
minio_path: minioPath,
|
|
thumbnail_url: thumbnailUrl,
|
|
});
|
|
} catch (err) {
|
|
console.error("[id-upload] Upload error:", err);
|
|
res.status(500).json({ error: "Upload failed." });
|
|
}
|
|
});
|
|
|
|
// GET /api/v1/id-upload/:token/status — Check upload status (desktop polling)
|
|
router.get("/api/v1/id-upload/:token/status", async (req: Request, res: Response) => {
|
|
try {
|
|
const result = await pool.query(
|
|
"SELECT front_uploaded, minio_paths, order_number, expires_at FROM id_upload_tokens WHERE token = $1",
|
|
[req.params.token],
|
|
);
|
|
if (result.rows.length === 0) {
|
|
res.status(404).json({ error: "Token not found." });
|
|
return;
|
|
}
|
|
const t = result.rows[0] as Record<string, unknown>;
|
|
const paths = (t.minio_paths as Record<string, string>) || {};
|
|
|
|
// Generate thumbnail URL if uploaded
|
|
let thumbnailUrl = "";
|
|
if (t.front_uploaded && paths.front && !paths.front.startsWith("base64")) {
|
|
thumbnailUrl = (await presign(paths.front, "GET", 3600)) || "";
|
|
}
|
|
|
|
res.json({
|
|
uploaded: !!t.front_uploaded,
|
|
thumbnail_url: thumbnailUrl || null,
|
|
expired: new Date(t.expires_at as string) < new Date(),
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: "Could not check status." });
|
|
}
|
|
});
|
|
|
|
|
|
// ── Ollama Vision ID Validation ────────────────────────────────────
|
|
|
|
const OLLAMA_URL = process.env.OLLAMA_URL || "http://ollama:11434";
|
|
const OLLAMA_MODEL = process.env.OLLAMA_VISION_MODEL || "minicpm-v:8b";
|
|
|
|
async function validateIdWithOllama(
|
|
base64Image: string,
|
|
orderNumber: string,
|
|
token: string,
|
|
): Promise<void> {
|
|
try {
|
|
const prompt = `Analyze this image. Is this a government-issued photo ID (driver's license, passport, state ID, or military ID)?
|
|
|
|
Reply in this exact JSON format:
|
|
{"is_id": true/false, "id_type": "driver_license/passport/state_id/military_id/unknown", "readable": true/false, "issues": ["list of issues if any"], "confidence": 0.0-1.0}
|
|
|
|
Rules:
|
|
- "is_id" = true only if this clearly shows a government photo ID
|
|
- "readable" = true only if text on the ID appears legible
|
|
- List issues like "blurry", "too dark", "partial/cropped", "glare", "wrong document"
|
|
- Be strict: a photo of a credit card, insurance card, or random document is NOT an ID`;
|
|
|
|
const resp = await fetch(`${OLLAMA_URL}/api/generate`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model: OLLAMA_MODEL,
|
|
prompt,
|
|
images: [base64Image.substring(0, 500000)], // Cap at ~375KB decoded
|
|
stream: false,
|
|
}),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
console.warn("[id-validate] Ollama returned", resp.status);
|
|
return;
|
|
}
|
|
|
|
const data = (await resp.json()) as { response?: string };
|
|
const raw = data.response || "";
|
|
console.log("[id-validate] Ollama response:", raw.substring(0, 300));
|
|
|
|
// Parse JSON from response
|
|
const jsonMatch = raw.match(/\{[\s\S]*?\}/);
|
|
if (!jsonMatch) return;
|
|
|
|
let validation: Record<string, unknown>;
|
|
try {
|
|
validation = JSON.parse(jsonMatch[0]);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
// Store validation result in the token record
|
|
await pool.query(
|
|
`UPDATE id_upload_tokens SET minio_paths = jsonb_set(
|
|
COALESCE(minio_paths, '{}'::jsonb),
|
|
'{validation}',
|
|
$1::jsonb
|
|
) WHERE token = $2`,
|
|
[JSON.stringify(validation), token],
|
|
).catch(() => {});
|
|
|
|
// If it's clearly not an ID, flag it in the order
|
|
if (validation.is_id === false) {
|
|
await pool.query(
|
|
`UPDATE compliance_orders SET intake_data = jsonb_set(
|
|
COALESCE(intake_data, '{}'::jsonb),
|
|
'{photo_id_validation}',
|
|
$1::jsonb
|
|
) WHERE order_number = $2`,
|
|
[JSON.stringify({
|
|
valid: false,
|
|
reason: "Uploaded image does not appear to be a government-issued photo ID",
|
|
issues: validation.issues || [],
|
|
checked_at: new Date().toISOString(),
|
|
}), orderNumber],
|
|
).catch(() => {});
|
|
|
|
console.warn(`[id-validate] Order ${orderNumber}: uploaded image is NOT a valid ID`);
|
|
} else {
|
|
console.log(`[id-validate] Order ${orderNumber}: valid ${validation.id_type}, readable=${validation.readable}`);
|
|
}
|
|
} catch (err) {
|
|
console.warn("[id-validate] Ollama validation failed:", err);
|
|
}
|
|
}
|
|
|
|
export default router;
|