fix photo ID upload: use workers for MinIO storage + public presigned URLs

- id-upload.ts: replace broken direct minio import with workers presign/upload
- job_server.py: add minio-upload handler for API to store files via workers
- rewrite presigned URLs from internal minio:9000 to public minio.performancewest.net
- fixes: thumbnail not showing after phone upload, base64 fallback storage
This commit is contained in:
justin 2026-05-30 18:12:06 -05:00
parent f60c5229ab
commit 7ef509c247
2 changed files with 102 additions and 43 deletions

View file

@ -1,4 +1,3 @@
// @ts-nocheck — minio is optional, only available in workers container
/**
* Photo ID upload secure upload + storage + polling.
*
@ -13,32 +12,63 @@ import { v4 as uuidv4 } from "uuid";
const router = Router();
// MinIO storage for ID documents
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || "minio";
const MINIO_PORT = parseInt(process.env.MINIO_PORT || "9000");
const MINIO_ACCESS = process.env.MINIO_ACCESS_KEY || "";
const MINIO_SECRET = process.env.MINIO_SECRET_KEY || "";
const WORKER_URL = process.env.WORKER_URL || "http://workers:8090";
const MINIO_BUCKET = process.env.MINIO_BUCKET || "performancewest";
let minioClient: any = null;
async function getMinio() {
if (minioClient) return minioClient;
/** 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 minio = await eval('import("minio")') as { Client: any };
const { Client } = minio;
minioClient = new Client({
endPoint: MINIO_ENDPOINT,
port: MINIO_PORT,
useSSL: false,
accessKey: MINIO_ACCESS,
secretKey: MINIO_SECRET,
const r = await fetch(`${WORKER_URL}/jobs/presign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, expires, method }),
});
return minioClient;
if (!r.ok) return null;
const data = (await r.json()) as { url?: string };
let url = data.url || null;
// Rewrite internal minio:9000 to public HTTPS endpoint
if (url) {
url = url.replace(/^http:\/\/minio:9000\//, "https://minio.performancewest.net/");
}
return url;
} catch {
return null;
}
}
/** Upload a buffer to MinIO using a presigned PUT URL. */
async function uploadToMinio(key: string, buffer: Buffer, contentType: string): Promise<boolean> {
const putUrl = await presign(key, "PUT", 300);
if (!putUrl) return false;
try {
// The presigned PUT URL may have internal hostname — use the workers endpoint directly
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,
}),
});
return r.ok;
} catch {
// Fallback: try presigned PUT
try {
const resp = await fetch(putUrl.replace("https://minio.performancewest.net/", "http://minio:9000/"), {
method: "PUT",
headers: { "Content-Type": contentType },
body: buffer,
});
return resp.ok;
} catch {
return false;
}
}
}
// Ensure the id_upload_tokens table exists
(async () => {
try {
@ -94,7 +124,6 @@ router.post("/api/v1/id-upload/token", async (req: Request, res: Response) => {
});
// POST /api/v1/id-upload/:token — Upload ID image
// Accepts raw body (base64 JSON) or multipart form data
router.post("/api/v1/id-upload/:token", async (req: Request, res: Response) => {
try {
const { token } = req.params;
@ -127,27 +156,22 @@ router.post("/api/v1/id-upload/:token", async (req: Request, res: Response) => {
}
// Decode base64
const base64Data = image_data.replace(/^data:image\/\w+;base64,/, "");
const base64Data = image_data.replace(/^data:[^;]+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Store in MinIO
// 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 minio = await getMinio();
if (minio) {
await minio.putObject(MINIO_BUCKET, minioPath, buffer, buffer.length, {
"Content-Type": content_type || "image/jpeg",
"x-amz-meta-order": tokenData.order_number as string,
"x-amz-meta-type": "photo-id",
});
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 {
// Fallback: store base64 in DB (not ideal but works)
paths.front = `base64:${base64Data.substring(0, 50)}...`;
console.warn("[id-upload] MinIO not available — stored reference only");
// Fallback: store base64 reference in DB
paths.front = `base64-pending:${buffer.length}bytes`;
console.warn("[id-upload] MinIO upload failed — stored reference only");
}
// Update token
@ -167,12 +191,10 @@ router.post("/api/v1/id-upload/:token", async (req: Request, res: Response) => {
).catch(() => {});
}
// Generate a presigned thumbnail URL for the desktop to display
// Generate a presigned thumbnail URL for the browser to display
let thumbnailUrl = "";
if (minio) {
try {
thumbnailUrl = await minio.presignedGetObject(MINIO_BUCKET, minioPath, 3600);
} catch {}
if (uploaded) {
thumbnailUrl = (await presign(minioPath, "GET", 3600)) || "";
}
res.json({
@ -203,13 +225,8 @@ router.get("/api/v1/id-upload/:token/status", async (req: Request, res: Response
// Generate thumbnail URL if uploaded
let thumbnailUrl = "";
if (t.front_uploaded && paths.front) {
const minio = await getMinio();
if (minio) {
try {
thumbnailUrl = await minio.presignedGetObject(MINIO_BUCKET, paths.front, 3600);
} catch {}
}
if (t.front_uploaded && paths.front && !paths.front.startsWith("base64")) {
thumbnailUrl = (await presign(paths.front, "GET", 3600)) || "";
}
res.json({

View file

@ -1569,6 +1569,47 @@ def handle_presign(payload: dict) -> dict:
return {"error": str(exc)}
def handle_minio_upload(payload: dict) -> dict:
"""Upload a base64-encoded file to MinIO.
Payload: { key: str, data: str (base64), content_type: str, bucket: str }
Returns: { success: bool, key: str, size: int }
"""
key = payload.get("key", "")
data_b64 = payload.get("data", "")
content_type = payload.get("content_type", "application/octet-stream")
bucket = payload.get("bucket", os.getenv("MINIO_BUCKET", "performancewest"))
if not key or not data_b64:
return {"error": "key and data required"}
try:
import base64
from io import BytesIO
from minio import Minio as _Minio
buf = base64.b64decode(data_b64)
mc = _Minio(
f"{os.getenv('MINIO_ENDPOINT', 'minio')}:{os.getenv('MINIO_PORT', '9000')}",
access_key=os.getenv("MINIO_ACCESS_KEY", ""),
secret_key=os.getenv("MINIO_SECRET_KEY", ""),
secure=os.getenv("MINIO_SECURE", "false").lower() == "true",
)
if not mc.bucket_exists(bucket):
mc.make_bucket(bucket)
mc.put_object(
bucket, key, BytesIO(buf), len(buf),
content_type=content_type,
metadata={"x-amz-meta-type": "photo-id"},
)
LOG.info("[minio-upload] Stored %s (%d bytes)", key, len(buf))
return {"success": True, "key": key, "size": len(buf)}
except Exception as exc:
LOG.error("[minio-upload] Failed to upload %s: %s", key, exc)
return {"error": str(exc)}
def handle_esign_completed(payload: dict) -> dict:
"""Generic eSign completion callback — resume the service pipeline.
@ -1786,6 +1827,7 @@ JOB_HANDLERS = {
"purchase_client_selections": handle_purchase_client_selections,
# eSign / MinIO helpers
"presign": handle_presign,
"minio-upload": handle_minio_upload,
"esign_completed": handle_esign_completed,
"resume_crtc_pipeline": handle_resume_crtc_pipeline,
# Compliance calendar renewal