// Image Cropper — namespaced router ported from id-image-bot's web surface. // Every route sits behind the YOUreka session (requireApi) and the crop_image // OpenFGA gate; reads may use the 5-minute cached result, the a2loft save // always verifies live. Minio/queue/nodered/removebg stay external — this only // proxies. All clients are injectable for tests. import { Router } from 'express'; import { createRequire } from 'node:module'; import { stripPhonePrefix, cropPermission } from './permit.js'; // minio/azure-storage keep-alive timers would hold the process open even when // the cropper is unconfigured, so they load only when credentials exist. const require = createRequire(import.meta.url); const QUEUE_NAME = 'id-images'; /** Queue domains that differ from their a2loft code (legacy bot behaviour). */ const DOMAIN_ALIASES = { core: 'uc' }; const BUCKET = 'files'; const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg'; /** * Builds the real cropper clients from the environment. Returns null when any * credential is missing — the router then answers 503 and nothing else in the * app is affected. URLs are hardcoded here; only credentials come from env. */ /** Credentials the cropper needs (used for the startup warning). */ export const CROPPER_CREDENTIALS = [ 'MINIO_ACCESS_KEY', 'MINIO_SECRET_KEY', 'AZURE_STORAGE_CONNECTION_STRING', 'NODERED_HTTP_USERNAME', 'NODERED_HTTP_PASSWORD', 'CARVEKIT_API_KEY', ]; export function buildCropperClients(env = process.env) { const { MINIO_ACCESS_KEY, MINIO_SECRET_KEY, AZURE_STORAGE_CONNECTION_STRING, NODERED_HTTP_USERNAME, NODERED_HTTP_PASSWORD, CARVEKIT_API_KEY, } = env; if ( !MINIO_ACCESS_KEY || !MINIO_SECRET_KEY || !AZURE_STORAGE_CONNECTION_STRING || !NODERED_HTTP_USERNAME || !NODERED_HTTP_PASSWORD || !CARVEKIT_API_KEY ) { return null; } const { Client: MinioClient } = require('minio'); const azure = require('azure-storage'); return { minio: new MinioClient({ endPoint: 'storage.a2loft.net', useSSL: true, accessKey: MINIO_ACCESS_KEY, secretKey: MINIO_SECRET_KEY, }), queue: azure.createQueueService(AZURE_STORAGE_CONNECTION_STRING), noderedAuth: { username: NODERED_HTTP_USERNAME, password: NODERED_HTTP_PASSWORD, }, carvekitKey: CARVEKIT_API_KEY, }; } export function createCropperRouter(cfg) { const { a2loftBase, portalUrl, fetchImpl = globalThis.fetch, now = Date.now, ttlMs, minio, queue, noderedAuth, carvekitKey, removebgUrl = REMOVEBG_URL, noderedBase = 'https://nodered.a2loft.net', domainCodes = new Set(), requireApi, cache: cropCache, } = cfg; const router = Router(); router.use(requireApi); const cropGate = async (req, res, next) => { try { const { allowed } = await cropPermission({ phone: stripPhonePrefix(req.session.phone), portalUrl, fetchImpl, cache: cropCache, ttlMs, now, }); if (allowed) return next(); res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' }); } catch { res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' }); } }; router.use(cropGate); // Express 4 does not catch async rejections: an unhandled throw would hang // the request (and, on Node's default, exit the process). Wrap every route // handler so failures become a 500 via next(err). for (const method of ['get', 'post']) { const register = router[method].bind(router); router[method] = (path, ...handlers) => register(path, ...handlers.map((h) => (h.length >= 3 ? h : (req, res, next) => Promise.resolve(h(req, res, next)).catch(next)))); } /** Reject malformed bodies up front instead of throwing on destructuring. */ const requireMessageBody = (req, res) => { if (!req.body || typeof req.body.message !== 'object' || req.body.message === null) { res.status(400).json({ ok: false, message: 'A message body is required.' }); return false; } return true; }; const UPSTREAM_TIMEOUT_MS = 15000; const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`; const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`; const getMessage = () => new Promise((resolve, reject) => queue.getMessage(QUEUE_NAME, { visibilityTimeout: 2 * 60 }, (error, message) => error ? reject(error) : resolve(message) ) ); const deleteMessage = (messageId, popReceipt) => new Promise((resolve, reject) => queue.deleteMessage(QUEUE_NAME, messageId, popReceipt, (error, result) => error ? reject(error) : resolve(result) ) ); const requireOtp = (req, res) => { if (!req.query.otp) { res.status(400).json({ ok: false, message: 'OTP is required.' }); return false; } return true; }; // ── pictures ──────────────────────────────────────────────────────────── router.get('/picture/next', async (req, res) => { let message; try { message = await getMessage(); } catch (error) { return res.json({ ok: false, message: error.message }); } if (!message) { return res.json({ ok: false, message: 'Queue is currently empty.' }); } let data; try { data = JSON.parse(message.messageText); } catch { return res.json({ ok: false, message: 'Queue item is malformed.' }); } const { messageId, popReceipt, dequeueCount } = message; res.json({ ok: true, data: { message_id: messageId, pop_receipt: popReceipt, dequeue_count: dequeueCount, message: data }, }); }); router.get('/picture/load/:id', async (req, res) => { try { const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(req.params.id)}`, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); const body = await r.json().catch(() => null); const data = body && body.data; if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' }); const { upload_id, file_name, caption, domain, raw_file_id } = data; return res.json({ ok: true, data: { message_id: 'custom', pop_receipt: 'custom', dequeue_count: 0, message: { upload_id, file_name, caption, domain, raw_file_id, from_queue: false }, }, }); } catch (error) { return res.json({ ok: false, message: error.message }); } }); router.get('/picture/review/:review_id', async (req, res) => { try { const review = await ( await fetchImpl(nodered(`/idid/picture/review/${encodeURIComponent(req.params.review_id)}`), { headers: { authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}` }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }) ).json(); if (!review.ok) { return res.json({ ok: false, message: 'Invalid picture review id specified.' }); } const agents = await ( await fetchImpl(nodered('/unical/agents'), { headers: { authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}` }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }) ).json(); const agent = (Array.isArray(agents) ? agents : []).find( (u) => u.phoneHashes.includes(parseInt(review.data.agentPhoneHash)) ); res.json({ ok: agent !== undefined, data: agent }); } catch (error) { res.json({ ok: false, message: error.message }); } }); router.get('/picture/:file_name', async (req, res) => { const file_name = req.params.file_name.replace(/_/gi, '/'); if (!file_name.startsWith('id-domains/') || file_name.includes('..')) { return res.status(400).json({ ok: false, message: 'Invalid picture reference.' }); } try { const url = await minio.presignedGetObject(BUCKET, file_name); const r = await fetchImpl(url, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }); const buf = Buffer.from(await r.arrayBuffer()); res.json({ ok: true, data: `data:image/png;base64,${buf.toString('base64')}` }); } catch (error) { res.json({ ok: false, message: error.message }); } }); router.post('/picture/review', async (req, res) => { if (!requireMessageBody(req, res)) return; const { message: { pop_receipt, message_id } = {}, deleteFromQueue, ...body } = req.body; try { const r = await fetchImpl(nodered('/zeebe/picture/review'), { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), method: 'POST', headers: { 'content-type': 'application/json', authorization: `Basic ${Buffer.from(`${noderedAuth.username}:${noderedAuth.password}`).toString('base64')}`, }, body: JSON.stringify(body), }); const data = await r.json(); if (!data.ok) { return res.json({ ok: false, message: 'Reject process failed.' }); } if (deleteFromQueue) { if (pop_receipt === 'custom') { return res.json({ ok: true, message: 'Operation successful.' }); } const result = await deleteMessage(message_id, pop_receipt); if (result.isSuccessful) { return res.json({ ok: true, message: 'Operation successful.' }); } return res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` }); } res.json({ ok: true, message: 'Operation successful.' }); } catch (error) { res.json({ ok: false, message: error.message }); } }); router.post('/dequeue', async (req, res) => { if (!requireMessageBody(req, res)) return; const { message: { pop_receipt, message_id } = {}, } = req.body; try { const result = await deleteMessage(message_id, pop_receipt); if (result.isSuccessful) { return res.json({ ok: true, message: 'Operation successful.' }); } res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` }); } catch (error) { res.json({ ok: false, message: error.message }); } }); // ── cleanup proxy ──────────────────────────────────────────────────────── router.post('/api/removebg', async (req, res) => { try { const fd = new FormData(); fd.append('image_file_b64', req.body.image_file_b64); const r = await fetchImpl(removebgUrl, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), method: 'POST', headers: { 'x-api-key': carvekitKey }, body: fd, }); if (!r.ok) { const text = await r.text(); return res.status(r.status).json({ ok: false, message: text || 'Cleanup failed.' }); } res.set('Content-Type', r.headers.get('content-type') || 'image/png'); res.send(Buffer.from(await r.arrayBuffer())); } catch (error) { res.status(500).json({ ok: false, message: error.message }); } }); router.get('/api/baseUrl', (req, res) => { res.json({ ok: true, data: base }); }); // ── save (the one a2loft call that needs the operator OTP) ────────────── router.post('/profile/picture', async (req, res) => { if (!requireMessageBody(req, res)) return; if (!requireOtp(req, res)) return; let live; try { live = await cropPermission({ phone: stripPhonePrefix(req.session.phone), portalUrl, fetchImpl, cache: cropCache, ttlMs, now, force: true, // saving always verifies live }); } catch { live = { allowed: false }; } if (!live.allowed) { return res.status(403).json({ ok: false, message: 'You are not permitted to crop images.' }); } const { pictureDataUrl, phone, requiresCleaning, message: { pop_receipt, message_id, message: { upload_id, domain, agentDeviceHash } = {}, }, } = req.body; const urlDomain = DOMAIN_ALIASES[domain] || domain; if (!domainCodes.has(urlDomain)) { return res.status(400).json({ ok: false, message: 'Unknown domain.' }); } const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp }); try { const r = await fetchImpl( `${base}/${encodeURIComponent(urlDomain)}/profile/picture?${q.toString()}`, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pictureDataUrl, phone, uploadId: upload_id, agentDeviceHash, requiresCleaning: requiresCleaning == null ? true : requiresCleaning, }), } ); const data = await r.json(); if (!data.ok) return res.json(data); if (pop_receipt === 'custom') { return res.json({ ok: true, message: 'Custom operation successful' }); } const result = await deleteMessage(message_id, pop_receipt); if (result.isSuccessful) { res.json({ ok: true, message: 'Operation successful.' }); } else { res.json({ ok: false, message: `Error dequeuing message. ${result.statusCode}` }); } } catch { res.status(502).json({ ok: false, message: 'Could not reach the identity service.' }); } }); return router; }