Files
tools-legacy/tools/cropper.js
T
adebisi-fa d689434047 feat(cropper): move the Image Cropper into the tools app at /cropper
- Smart Photo Editor ported from id-image-bot: queue/by-ID picture
  loading, vue-croppa editing, carvekit background cleanup, nodered
  review flow, a2loft save — restyled to the tools design language
- unified login: YOUreka session identity, phone prefilled, OTP only at
  save (a2loft's requirement); artist-login step removed
- crop_image @ domain:idid OpenFGA gate on every cropper route: cached
  5-min on reads, live on save, fail closed
- credentials (minio/azure/nodered/carvekit) via env, sole exception to
  hardcoded config; 503 when unconfigured; native FormData replaces
  form-data; new deps minio + azure-storage
- 10 new tests (35 total) with fake minio/queue/nodered/removebg/a2loft
2026-10-01 01:13:30 +01:00

337 lines
11 KiB
JavaScript

// 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';
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.
*/
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',
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);
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.' });
}
const data = JSON.parse(message.messageText);
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)}`);
const { data } = await r.json();
if (!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')}` },
})
).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')}` },
})
).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, '/');
try {
const url = await minio.presignedGetObject(BUCKET, file_name);
const r = await fetchImpl(url);
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) => {
const {
message: { pop_receipt, message_id },
deleteFromQueue,
...body
} = req.body;
try {
const r = await fetchImpl(nodered('/zeebe/picture/review'), {
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) => {
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, {
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 (!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 q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp });
try {
const r = await fetchImpl(
`${base}/${encodeURIComponent(domain)}/profile/picture?${q.toString()}`,
{
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;
}