2026-10-01 01:13:30 +01:00
|
|
|
// 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';
|
2026-10-01 11:50:06 +01:00
|
|
|
/**
|
|
|
|
|
* Queue domains that differ from their a2loft identity code.
|
|
|
|
|
*
|
|
|
|
|
* A queue item's `domain` comes from the Telegram intake: the operator tags a
|
|
|
|
|
* photo (e.g. `#core`) and the bot stores that label verbatim, then translated
|
|
|
|
|
* it before every a2loft call. a2loft has no `core` domain — `GET
|
|
|
|
|
* {a2loft}/identity/core/units` answers "Invalid domain code specified." while
|
|
|
|
|
* `uc` is valid — so the label must be mapped before the save is forwarded.
|
|
|
|
|
* (The proper fix is on the intake side: enqueue the a2loft code, or register
|
|
|
|
|
* `core` upstream; until then this keeps existing queue data working.)
|
|
|
|
|
*/
|
2026-10-01 10:35:02 +01:00
|
|
|
const DOMAIN_ALIASES = { core: 'uc' };
|
2026-10-01 01:13:30 +01:00
|
|
|
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.
|
|
|
|
|
*/
|
2026-10-01 04:36:31 +01:00
|
|
|
/** 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',
|
|
|
|
|
];
|
|
|
|
|
|
2026-10-01 01:13:30 +01:00
|
|
|
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',
|
2026-10-01 03:12:02 +01:00
|
|
|
domainCodes = new Set(),
|
2026-10-01 01:13:30 +01:00
|
|
|
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);
|
|
|
|
|
|
2026-10-01 03:12:02 +01:00
|
|
|
// 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;
|
2026-10-01 01:13:30 +01:00
|
|
|
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.' });
|
|
|
|
|
}
|
2026-10-01 03:12:02 +01:00
|
|
|
let data;
|
|
|
|
|
try {
|
|
|
|
|
data = JSON.parse(message.messageText);
|
|
|
|
|
} catch {
|
|
|
|
|
return res.json({ ok: false, message: 'Queue item is malformed.' });
|
|
|
|
|
}
|
2026-10-01 01:13:30 +01:00
|
|
|
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 {
|
2026-10-01 03:12:02 +01:00
|
|
|
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.' });
|
2026-10-01 01:13:30 +01:00
|
|
|
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')}` },
|
2026-10-01 03:12:02 +01:00
|
|
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
2026-10-01 01:13:30 +01:00
|
|
|
})
|
|
|
|
|
).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')}` },
|
2026-10-01 03:12:02 +01:00
|
|
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
2026-10-01 01:13:30 +01:00
|
|
|
})
|
|
|
|
|
).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, '/');
|
2026-10-01 03:12:02 +01:00
|
|
|
if (!file_name.startsWith('id-domains/') || file_name.includes('..')) {
|
|
|
|
|
return res.status(400).json({ ok: false, message: 'Invalid picture reference.' });
|
|
|
|
|
}
|
2026-10-01 01:13:30 +01:00
|
|
|
try {
|
|
|
|
|
const url = await minio.presignedGetObject(BUCKET, file_name);
|
2026-10-01 03:12:02 +01:00
|
|
|
const r = await fetchImpl(url, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) });
|
2026-10-01 01:13:30 +01:00
|
|
|
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) => {
|
2026-10-01 03:12:02 +01:00
|
|
|
if (!requireMessageBody(req, res)) return;
|
2026-10-01 01:13:30 +01:00
|
|
|
const {
|
2026-10-01 03:12:02 +01:00
|
|
|
message: { pop_receipt, message_id } = {},
|
2026-10-01 01:13:30 +01:00
|
|
|
deleteFromQueue,
|
|
|
|
|
...body
|
|
|
|
|
} = req.body;
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetchImpl(nodered('/zeebe/picture/review'), {
|
2026-10-01 03:12:02 +01:00
|
|
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
2026-10-01 01:13:30 +01:00
|
|
|
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) => {
|
2026-10-01 03:12:02 +01:00
|
|
|
if (!requireMessageBody(req, res)) return;
|
2026-10-01 01:13:30 +01:00
|
|
|
const {
|
2026-10-01 03:12:02 +01:00
|
|
|
message: { pop_receipt, message_id } = {},
|
2026-10-01 01:13:30 +01:00
|
|
|
} = 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, {
|
2026-10-01 03:12:02 +01:00
|
|
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
2026-10-01 01:13:30 +01:00
|
|
|
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) => {
|
2026-10-01 03:12:02 +01:00
|
|
|
if (!requireMessageBody(req, res)) return;
|
2026-10-01 01:13:30 +01:00
|
|
|
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,
|
2026-10-01 03:12:02 +01:00
|
|
|
message: { upload_id, domain, agentDeviceHash } = {},
|
2026-10-01 01:13:30 +01:00
|
|
|
},
|
|
|
|
|
} = req.body;
|
2026-10-01 10:35:02 +01:00
|
|
|
const urlDomain = DOMAIN_ALIASES[domain] || domain;
|
|
|
|
|
if (!domainCodes.has(urlDomain)) {
|
2026-10-01 03:12:02 +01:00
|
|
|
return res.status(400).json({ ok: false, message: 'Unknown domain.' });
|
|
|
|
|
}
|
2026-10-01 01:13:30 +01:00
|
|
|
const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp });
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetchImpl(
|
2026-10-01 10:35:02 +01:00
|
|
|
`${base}/${encodeURIComponent(urlDomain)}/profile/picture?${q.toString()}`,
|
2026-10-01 01:13:30 +01:00
|
|
|
{
|
2026-10-01 03:12:02 +01:00
|
|
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
2026-10-01 01:13:30 +01:00
|
|
|
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;
|
|
|
|
|
}
|