diff --git a/api/src/routes/id-upload.ts b/api/src/routes/id-upload.ts index 8386f21..13ace7e 100644 --- a/api/src/routes/id-upload.ts +++ b/api/src/routes/id-upload.ts @@ -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 { + 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 { + 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 = (tokenData.minio_paths as Record) || {}; - 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({ diff --git a/scripts/workers/job_server.py b/scripts/workers/job_server.py index 5c5a15c..d5292fa 100644 --- a/scripts/workers/job_server.py +++ b/scripts/workers/job_server.py @@ -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