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
This commit is contained in:
2026-10-01 01:13:30 +01:00
parent 531c419e32
commit d689434047
13 changed files with 2496 additions and 21 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ WORKDIR /app
COPY tools/package.json tools/package-lock.json ./ COPY tools/package.json tools/package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force
COPY tools/server.js tools/yrka-auth.js tools/permit.js ./ COPY tools/server.js tools/yrka-auth.js tools/permit.js tools/cropper.js ./
COPY tools/public ./public COPY tools/public ./public
USER node USER node
+25 -1
View File
@@ -64,15 +64,39 @@ Everything is hardcoded in `tools/server.js` by design — port `4173`, portal
catalog. Change them there; there are no environment variables or config catalog. Change them there; there are no environment variables or config
files. files.
## The Image Cropper
The `/cropper` page (Smart Photo Editor) lets operators review, crop and save
identity photos. It reuses the YOUreka session for identity, requires the
`crop_image` permission on `domain:idid` for every proxied action (verified
against the portal's OpenFGA endpoint — live on save, 5-minute cached on
reads), and keeps the a2loft OTP requirement only on the save step.
Its service credentials are the sole environment variables this app uses
(same names the old `id-image-bot` used — an existing `.env` is drop-in):
```sh
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
AZURE_STORAGE_CONNECTION_STRING=…
NODERED_HTTP_USERNAME=… NODERED_HTTP_PASSWORD=…
CARVEKIT_API_KEY=…
```
Without them the rest of the app works and `/cropper` routes answer `503`.
Run locally with `cp tools/.env.example tools/.env` + your values and
`node --env-file=tools/.env tools/server.js` (or `docker run --env-file …`).
## Tests ## Tests
```sh ```sh
cd tools cd tools
npm test # node --test — 27 tests, no network or hardware required npm test # node --test — 35 tests, no network or hardware required
``` ```
## Docker ## Docker
## Docker
The app is stateless, so the container only serves the web front-end; fpagent The app is stateless, so the container only serves the web front-end; fpagent
keeps running on the operator's laptop (the wizard connects to keeps running on the operator's laptop (the wizard connects to
`http://127.0.0.1:9000` from the browser, wherever this container lives). `http://127.0.0.1:9000` from the browser, wherever this container lives).
+9
View File
@@ -0,0 +1,9 @@
# Image Cropper service credentials — the ONLY environment variables this app
# uses. Everything else (URLs, port, domain catalog) is hardcoded in source.
# Without these the rest of the app works and /cropper answers 503.
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
AZURE_STORAGE_CONNECTION_STRING=
NODERED_HTTP_USERNAME=
NODERED_HTTP_PASSWORD=
CARVEKIT_API_KEY=
+336
View File
@@ -0,0 +1,336 @@
// 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;
}
+1138
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,7 +9,9 @@
"test": "node --test" "test": "node --test"
}, },
"dependencies": { "dependencies": {
"azure-storage": "^2.10.7",
"express": "^4.21.2", "express": "^4.21.2",
"minio": "^8.0.7",
"pocketbase": "^0.27.0" "pocketbase": "^0.27.0"
} }
} }
+46 -3
View File
@@ -9,14 +9,14 @@ export function stripPhonePrefix(phone) {
return (phone || '').replace(/^\+/, ''); return (phone || '').replace(/^\+/, '');
} }
async function verifyOnce(fetchImpl, portalUrl, phone, object) { async function verifyOnce(fetchImpl, portalUrl, phone, object, relation = 'capture_biometric') {
const r = await fetchImpl(`${portalUrl.replace(/\/+$/, '')}/api/openfga/verify`, { const r = await fetchImpl(`${portalUrl.replace(/\/+$/, '')}/api/openfga/verify`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
subsystem: 'idid', subsystem: 'idid',
user: `user:${phone}`, user: `user:${phone}`,
relation: 'capture_biometric', relation,
object, object,
}), }),
}); });
@@ -31,7 +31,7 @@ export async function checkDomain(fetchImpl, portalUrl, phone, code) {
let answered = false; let answered = false;
for (const object of [`domain:${code}`, 'domain:idid']) { for (const object of [`domain:${code}`, 'domain:idid']) {
try { try {
const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object); const allowed = await verifyOnce(fetchImpl, portalUrl, phone, object, 'capture_biometric');
answered = true; answered = true;
if (allowed) return true; if (allowed) return true;
} catch { } catch {
@@ -42,6 +42,49 @@ export async function checkDomain(fetchImpl, portalUrl, phone, code) {
return false; return false;
} }
/**
* Single-object permission check for an arbitrary relation — fail closed:
* returns true only for an explicit ok+allowed, false on denial, and throws
* when the check itself fails.
*/
export async function checkPermission(fetchImpl, portalUrl, phone, { relation, object }) {
return verifyOnce(fetchImpl, portalUrl, phone, object, relation);
}
/**
* Cached `crop_image` permission on domain:idid for the cropper. Same shape as
* permittedDomains: 5-minute per-phone cache, `force` bypasses the cache for
* live checks and refuses while fresh, failures fail closed (denied).
* Returns { allowed, checkedAt }.
*/
export async function cropPermission({
phone,
portalUrl,
fetchImpl = fetch,
cache,
ttlMs = DOMAIN_TTL_MS,
now = Date.now,
force = false,
}) {
const hit = cache.get(phone);
const fresh = !!(hit && now() - hit.checkedAt < ttlMs);
if (fresh && !force) {
return { allowed: hit.allowed, checkedAt: hit.checkedAt };
}
const checkedAt = now();
let allowed = false;
try {
allowed = await checkPermission(fetchImpl, portalUrl, phone, {
relation: 'crop_image',
object: 'domain:idid',
});
} catch {
allowed = false; // fail closed
}
cache.set(phone, { allowed, checkedAt });
return { allowed, checkedAt };
}
/** /**
* Permitted domain codes for phone, cached per phone for ttlMs. * Permitted domain codes for phone, cached per phone for ttlMs.
* force=true re-checks everything; while the cache is still fresh it refuses * force=true re-checks everything; while the cache is still fresh it refuses
+270
View File
@@ -0,0 +1,270 @@
/* Image Cropper — review, crop, clean and save identity photos.
Identity comes from the YOUreka session; the only operator credential is
the save-time OTP (required by a2loft). */
/* global Vue, axios, toastr, Croppa */
Vue.use(Croppa);
(function () {
'use strict';
var REVIEW_OPTIONS = [
'IMAGE: Not in the midline of the photo frame (aligned to the left or right)',
'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)',
'IMAGE: Blurry ',
'IMAGE: Improper lighting ',
'IMAGE: Not coloured.',
'IMAGE: Scanned photo',
'IMAGE: Has no adequate space to allow for proper cropping ',
'IMAGE: Not taken on a recommended background.',
'IMAGE: Photo is a selfie',
'DRESSING: Unacceptable dress. ',
'HEAD: Skewed (leaning away from the midline - vertically or horizontally)',
'EYES: Not looking into the camera (looking downward, upward, or sideward)',
'EYES: Not properly opened',
'EARS: Not visible ',
'EARS: Both ears are not equally visible (face not aligned central)',
'FACIAL EXPRESSION: NOT neutral (subject has a facial expression)',
'GLASSES: Subject is wearing glasses ',
'JUST WRING: Goes against almost all acceptable passport criteria.',
'Others not specified.',
];
var app = new Vue({
el: '#app',
data: {
loading: true,
session: null,
message: {
message: { caption: null, file_url: null, domain: '' },
},
agent: null,
process: null,
reviewed: false,
accepted: false,
cleanedImage: null,
requiresCleaning: '',
otp: '',
reviewOptions: REVIEW_OPTIONS.map(function (name, index) {
return { index: index + 1, name: name };
}),
selectedReviewOptions: [],
otherReviewOption: '',
busy: { load: false, clean: false, review: false, save: false },
myCroppa: {},
},
computed: {
initials: function () {
var n = (this.session && (this.session.firstName || this.session.phone)) || '?';
return n.slice(0, 2).toUpperCase();
},
canReview: function () {
if (this.busy.review) return false;
if (this.accepted) return true;
return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0;
},
canSave: function () {
return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== '';
},
},
created: function () {
var self = this;
axios.get('/auth/session').then(function (r) {
self.session = r.data;
self.loading = false;
}).catch(function (err) {
if (err.response && err.response.status === 401) {
window.location.href = '/auth/login';
return;
}
self.loading = false;
toastr.error('Could not load your session. Please refresh the page.', 'Oops!');
});
},
methods: {
reset: function () {
this.message = { message: { caption: null, file_url: null, domain: '' } };
this.agent = null;
this.process = null;
this.reviewed = false;
this.accepted = false;
this.cleanedImage = null;
this.selectedReviewOptions = [];
this.otherReviewOption = '';
this.requiresCleaning = '';
this.otp = '';
},
loadPicture: async function (mode) {
var self = this;
this.busy.load = true;
try {
var uploadId = null;
if (mode === 'by-id') {
uploadId = prompt('Enter picture upload ID');
if (!uploadId) return;
}
var { data } = await axios.get(
uploadId ? '/cropper/picture/load/' + encodeURIComponent(uploadId) : '/cropper/picture/next'
);
if (!data.ok) {
this.reset(); // nothing (more) to review — back to the load state
toastr.error(data.message, 'Oops!');
return;
}
this.message = data.data;
var image = await axios.get(
'/cropper/picture/' + encodeURIComponent(this.message.message.file_name.replace(/\//gi, '_'))
);
if (!image.data.ok) {
this.reset();
toastr.error("Couldn't load the attached image. Please try again.", 'Oops!');
return;
}
this.message.message.file_url = image.data.data;
this.cleanedImage = null;
this.agent = null;
this.process = this.message.message.process || null;
if (this.message.message.upload_id) {
var review = await axios.get(
'/cropper/picture/review/' + encodeURIComponent(this.message.message.upload_id)
);
this.agent = review.data.ok ? review.data.data : null;
}
} catch ({ message }) {
toastr.error(message, 'Operation Failed');
} finally {
this.reviewed = false;
this.accepted = false;
this.selectedReviewOptions = [];
this.otherReviewOption = '';
this.requiresCleaning = '';
this.otp = '';
this.busy.load = false;
}
},
cleanImage: async function () {
var self = this;
this.busy.clean = true;
try {
var dataUrl = this.myCroppa.generateDataUrl('image/jpeg');
var response = await fetch('/cropper/api/removebg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_file_b64: dataUrl.split(',')[1] }),
});
if (!response.ok) {
var err = await response.json().catch(function () { return {}; });
throw new Error(err.message || 'Failed to clean image.');
}
var contentType = response.headers.get('content-type') || '';
if (contentType.indexOf('image/') !== 0) {
throw new Error('Image cleanup service returned an invalid response.');
}
var blob = await response.blob();
var fr = new FileReader();
fr.onload = function () { self.cleanedImage = fr.result; };
fr.readAsDataURL(blob);
} catch ({ message }) {
toastr.error(message, 'Image Cleanup Failed');
} finally {
this.busy.clean = false;
}
},
reviewPicture: async function () {
var self = this;
if (!this.canReview) return;
if (!this.accepted && this.selectedReviewOptions.length === 0 && !this.otherReviewOption) {
toastr.error('Please indicate why this image is not good enough.', 'Operation Failed');
return;
}
this.busy.review = true;
var options = [...this.selectedReviewOptions, this.otherReviewOption].filter(function (o) {
return o && o.length > 0;
});
var reviewMessage = this.accepted ? null : options.join('\n\n');
try {
var hasProcess = !!this.message.message.process;
var data = {};
if (hasProcess) {
var { domain, reference } = this.message.message.process.variables;
data.process = {
correlationKey: domain + '-' + reference,
name: 'PictureReviewed',
variables: { ok: this.accepted, reviewRejectMessage: reviewMessage },
};
} else {
data.raw = { phone: this.message.message.caption, reviewRejectMessage: reviewMessage };
}
var { data: result } = await axios.post('/cropper/picture/review', {
deleteFromQueue: true,
message: this.message,
mode: hasProcess ? 'process' : 'raw',
...data,
});
if (!result.ok) {
toastr.error(result.message, 'Operation Failed');
return;
}
if (reviewMessage) {
toastr.success('Profile owner has been notified of the review.', 'Review Sent');
}
this.reset();
} catch ({ message }) {
toastr.error(message, 'Operation Failed');
} finally {
this.busy.review = false;
}
},
removeFromQueue: async function () {
var self = this;
if (!confirm('Are you sure you want to dequeue?')) return;
this.busy.review = true;
try {
var { data } = await axios.post('/cropper/dequeue', { message: this.message });
if (!data.ok) {
toastr.error(data.message, 'Not Removed');
return;
}
toastr.success(data.message, 'Removed!');
this.reset();
} catch ({ message }) {
toastr.error(message, 'Not Removed');
} finally {
this.busy.review = false;
}
},
saveChanges: async function () {
var self = this;
if (!this.canSave) return;
this.busy.save = true;
try {
var { data } = await axios.post(
'/cropper/profile/picture?otp=' + encodeURIComponent(this.otp),
{
pictureDataUrl: this.cleanedImage || this.myCroppa.generateDataUrl('image/jpeg'),
phone: this.message.message.caption,
message: this.message,
requiresCleaning: this.requiresCleaning !== 'No',
}
);
if (!data.ok) {
toastr.error(data.message, 'Not Saved');
return;
}
toastr.success('Picture saved for ' + this.message.message.caption, 'Saved');
this.reset();
} catch ({ message }) {
toastr.error(message, 'Not Saved');
} finally {
this.busy.save = false;
}
},
},
});
})();
+180
View File
@@ -0,0 +1,180 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Image Cropper · YOUreka Tools</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-croppa@1.3.8/dist/vue-croppa.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='6' fill='%233b4fd8'/><path d='M12 5a6 6 0 0 0-6 6c0 2.6.8 4.1 1.5 5.2' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M15.5 19a7.5 7.5 0 0 0 3-9.7A6 6 0 0 0 7.1 7.9' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/><path d='M9.5 18.5a5.5 5.5 0 0 1-.8-7.6 3.8 3.8 0 0 1 7.2 1.5 4.3 4.3 0 0 1-3.4 4.6' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round'/></svg>">
<style>
.croppa-pad { border: 1px dashed var(--faint); border-radius: 12px; width: 252px; height: 270px; overflow: hidden; background: #fafbfe; }
.croppa-pad img { width: 250px; display: block; }
.review-options .form-check { margin-bottom: 0.35rem; }
</style>
</head>
<body class="app">
<div id="app">
<nav class="nav">
<div class="container">
<a class="brand" href="/">
<span class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
</span>
YOUreka <small>Tools</small>
</a>
<div class="nav-right" v-cloak v-if="session">
<span class="user-chip">
<span class="avatar">{{ initials }}</span>
{{ session.firstName || session.phone }}
</span>
<a class="linklike" href="/auth/logout">Sign out</a>
</div>
</div>
</nav>
<main class="container" v-cloak v-if="session">
<div class="page-head">
<div>
<h1>Image Cropper</h1>
<p class="sub">Review, crop and save identity photos</p>
</div>
</div>
<!-- 1 · load -->
<section class="card panel">
<div class="panel-head">
<span class="step-chip">1</span>
<h2>Load a picture</h2>
<span class="hint" v-if="message && message.message">Working on {{ message.message.caption }} &middot; {{ message.message.domain }}</span>
</div>
<div class="row-gap">
<button class="btn btn-primary" v-bind:disabled="busy.load" v-on:click="loadPicture('next')">
<span v-if="!busy.load">Load next queued</span>
<span v-else>Loading&hellip;</span>
</button>
<button class="btn btn-ghost" v-bind:disabled="busy.load" v-on:click="loadPicture('by-id')">Load by upload ID</button>
<button class="btn btn-ghost" v-if="message && message.message" v-on:click="reset">Clear</button>
</div>
</section>
<!-- 2 · review & crop -->
<section class="card panel" v-if="message && message.message">
<div class="panel-head">
<span class="step-chip">2</span>
<h2>Review picture</h2>
<span class="hint">Accept, or reject with reasons</span>
</div>
<dl class="details">
<div><dt>Phone</dt><dd>{{ message.message.caption }}</dd></div>
<div><dt>Domain</dt><dd>{{ message.message.domain }}</dd></div>
<div v-if="agent"><dt>Agent</dt><dd>{{ agent.name }} <span class="faint">{{ agent.phone }}</span></dd></div>
<div v-if="message.message.process">
<dt>Process</dt>
<dd>{{ message.message.process.variables.name }} <span class="badge-soft badge-brand">{{ message.message.process.variables.environment }}</span></dd>
</div>
</dl>
<div class="finger-grid mt-2">
<div>
<label>Original — crop it</label>
<div class="croppa-pad mt-1">
<croppa v-model="myCroppa" :show-remove-button="false" :width="250" :height="267.44" :quality="2">
<img slot="initial" crossOrigin="anonymous" :src="message.message.file_url" style="width: 300px">
</croppa>
</div>
</div>
<div v-if="cleanedImage">
<label>Cleaned preview</label>
<div class="croppa-pad mt-1">
<img :src="cleanedImage" alt="cleaned preview">
</div>
</div>
</div>
<div class="row-gap mt-2" style="align-items:flex-end;">
<div style="min-width:200px;">
<label for="requiresCleaning">Requires cleaning?</label>
<select id="requiresCleaning" class="form-control" v-model="requiresCleaning">
<option value="">--Choose--</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
<button class="btn btn-primary" v-if="requiresCleaning === 'Yes'" v-bind:disabled="busy.clean" v-on:click="cleanImage">
<span v-if="!busy.clean">Clean background</span>
<span v-else>Cleaning&hellip;</span>
</button>
<button class="btn btn-danger-ghost" v-if="cleanedImage" v-on:click="cleanedImage = null">Remove cleaned image</button>
</div>
<hr class="mt-2">
<div class="row-gap" v-if="!reviewed">
<div class="btn btn-success" style="border-radius:10px; padding:0.55rem 1.1rem; font-weight:600; cursor:pointer;" v-on:click="reviewed = true; accepted = true">Accept picture</div>
<div class="btn btn-danger" style="border-radius:10px; padding:0.55rem 1.1rem; font-weight:600; cursor:pointer;" v-on:click="reviewed = true; accepted = false">Reject picture</div>
</div>
<div v-if="reviewed && accepted" class="mt-2">
<div class="row-gap" style="align-items:flex-end;">
<div style="min-width:150px; max-width:200px;">
<label for="otpSave">OTP</label>
<input id="otpSave" class="form-control" v-model.trim="otp" autocomplete="one-time-code">
</div>
<button class="btn btn-primary" v-bind:disabled="!canSave" v-on:click="saveChanges">
<span v-if="!busy.save">Crop and upload picture</span>
<span v-else>Saving&hellip;</span>
</button>
<button class="btn btn-ghost" v-on:click="reviewed = false">Change review</button>
</div>
</div>
<div v-if="reviewed && !accepted" class="mt-2">
<label>Please state why this picture is not good enough.</label>
<div class="review-options mt-1">
<div class="form-check" v-for="item in reviewOptions" v-bind:key="item.index">
<input class="form-check-input" type="checkbox" v-model="selectedReviewOptions" v-bind:id="'reason-' + item.index" v-bind:value="item.name">
<label class="form-check-label" v-bind:for="'reason-' + item.index">{{ item.name }}</label>
</div>
</div>
<div class="mt-1" style="max-width:480px;">
<label for="otherReviewOption">Others (please specify).</label>
<textarea id="otherReviewOption" rows="3" class="form-control" v-model="otherReviewOption"></textarea>
</div>
<div class="row-gap mt-2">
<button class="btn btn-primary" v-bind:disabled="!canReview" v-on:click="reviewPicture()">
<span v-if="!busy.review">Send review</span>
<span v-else>Sending&hellip;</span>
</button>
<button class="btn btn-danger-ghost" v-if="!message.message.process" v-bind:disabled="!canReview" v-on:click="removeFromQueue">Dequeue only</button>
<button class="btn btn-ghost" v-on:click="reviewed = false">Change review</button>
</div>
</div>
</section>
</main>
<main class="container" v-cloak v-if="loading && !session">
<div class="narrow center-v">
<div class="card panel" style="text-align:center;">Loading your session&hellip;</div>
</div>
</main>
<footer class="foot" v-cloak v-if="session">
<div class="container">
<span>Smart Photo Editor</span>
<span>Signed in as {{ session.phone }}</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-croppa@1.3.8/dist/vue-croppa.min.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.4.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/js/toastr.min.js"></script>
<script src="/assets/cropper/app.js"></script>
</div>
</body>
</html>
+3 -3
View File
@@ -30,14 +30,14 @@
</div> </div>
<div class="tool-grid"> <div class="tool-grid">
<a class="tool-card" href="https://smart-pxa.yrka.app" target="_blank" rel="noopener"> <a class="tool-card" href="/cropper">
<span class="tool-icon"> <span class="tool-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
</span> </span>
<h3>Image Cropper</h3> <h3>Image Cropper</h3>
<p>Prepare and crop identity photos to the dimensions required by YOUreka profiles.</p> <p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
<span class="go">Open Image Cropper <span class="go">Open Image Cropper
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg> <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</span> </span>
</a> </a>
+63 -10
View File
@@ -10,7 +10,8 @@ import express from 'express';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js'; import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS } from './permit.js'; import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js';
import { createCropperRouter, buildCropperClients } from './cropper.js';
// ── Hardcoded configuration ────────────────────────────────────────────────── // ── Hardcoded configuration ──────────────────────────────────────────────────
export const PORT = 4173; export const PORT = 4173;
@@ -57,9 +58,9 @@ function readProfile(raw) {
} }
} }
function errorPage(res, title, message) { function errorPage(res, status, title, message) {
res res
.status(502) .status(status)
.type('html') .type('html')
.send(`<!doctype html> .send(`<!doctype html>
<html lang="en"> <html lang="en">
@@ -103,11 +104,12 @@ export function createApp(overrides = {}) {
}; };
const domainCodes = new Set(cfg.domains.map((d) => d.code)); const domainCodes = new Set(cfg.domains.map((d) => d.code));
const domainCache = new Map(); // phone → { codes, checkedAt } const domainCache = new Map(); // phone → { codes, checkedAt }
const cropCache = cfg.cropCache || new Map(); // phone → { allowed, checkedAt }
const app = express(); const app = express();
app.disable('x-powered-by'); app.disable('x-powered-by');
app.set('trust proxy', 1); // https termination upstream → correct Secure-cookie flag app.set('trust proxy', 1); // https termination upstream → correct Secure-cookie flag
app.use(express.json()); app.use(express.json({ limit: '50mb' })); // cropper pictures travel as data URLs
const setSessionCookie = (req, res, value) => const setSessionCookie = (req, res, value) =>
res.cookie('session', value, { res.cookie('session', value, {
@@ -174,28 +176,29 @@ export function createApp(overrides = {}) {
}); });
const data = await r.json().catch(() => null); const data = await r.json().catch(() => null);
if (!r.ok || !data?.ok || !data.loginUrl) { if (!r.ok || !data?.ok || !data.loginUrl) {
return errorPage(res, 'Login unavailable', data?.message || 'Could not start the YOUreka login flow. Please try again.'); return errorPage(res, 502, 'Login unavailable', data?.message || 'Could not start the YOUreka login flow. Please try again.');
} }
res.redirect(data.loginUrl); res.redirect(data.loginUrl);
} catch { } catch {
errorPage(res, 'Login unavailable', 'Could not reach the YOUreka portal. Please try again.'); errorPage(res, 502, 'Login unavailable', 'Could not reach the YOUreka portal. Please try again.');
} }
}); });
app.get('/auth/callback', async (req, res) => { app.get('/auth/callback', async (req, res) => {
const { sessionId } = req.query; const { sessionId } = req.query;
if (!sessionId) return errorPage(res, 'Login failed', 'Missing login session. Please start again.'); if (!sessionId) return errorPage(res, 401, 'Login failed', 'Missing login session. Please start again.');
try { try {
const r = await cfg.fetch( const r = await cfg.fetch(
`${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth/${encodeURIComponent(sessionId)}/cookie` `${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth/${encodeURIComponent(sessionId)}/cookie`
); );
const data = await r.json().catch(() => null); const data = await r.json().catch(() => null);
if (!r.ok || !data?.ok) { if (!r.ok || !data?.ok) {
return errorPage(res, 'Login failed', 'Authentication failed. The link may have expired — please try again.'); return errorPage(res, 401, 'Login failed', 'Authentication failed. The link may have expired — please try again.');
} }
if (data.status === 'Initialized' || data.status === 'TimedOut') { if (data.status === 'Initialized' || data.status === 'TimedOut') {
return errorPage( return errorPage(
res, res,
401,
'Login failed', 'Login failed',
data.status === 'TimedOut' data.status === 'TimedOut'
? 'Your login session timed out. Please start again.' ? 'Your login session timed out. Please start again.'
@@ -204,7 +207,7 @@ export function createApp(overrides = {}) {
} }
const sessionValue = extractPbCookieValue(data.cookie || ''); const sessionValue = extractPbCookieValue(data.cookie || '');
if (!sessionValue) { if (!sessionValue) {
return errorPage(res, 'Login failed', 'The portal did not return a usable session. Please try again.'); return errorPage(res, 401, 'Login failed', 'The portal did not return a usable session. Please try again.');
} }
setSessionCookie(req, res, sessionValue); setSessionCookie(req, res, sessionValue);
setProfileCookie(req, res, { setProfileCookie(req, res, {
@@ -214,7 +217,7 @@ export function createApp(overrides = {}) {
}); });
res.redirect('/fingerprint'); res.redirect('/fingerprint');
} catch { } catch {
errorPage(res, 'Login failed', 'Could not reach the YOUreka portal. Please try again.'); errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
} }
}); });
@@ -279,6 +282,56 @@ export function createApp(overrides = {}) {
res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html')); res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html'));
}); });
// ── cropper page + namespaced API (session + crop_image required) ────────
const cropGateContext = {
portalUrl: cfg.portalUrl,
fetchImpl: cfg.fetch,
cache: cropCache,
ttlMs: cfg.ttlMs,
now: cfg.now,
};
const pageCropGate = async (req, res, next) => {
let allowed = false;
try {
({ allowed } = await cropPermission({
phone: stripPhonePrefix(req.session.phone),
...cropGateContext,
}));
} catch {
allowed = false; // fail closed
}
if (!allowed) {
return errorPage(
res,
403,
'Not permitted',
'You are not permitted to crop images. Contact your administrator if you believe this is a mistake.'
);
}
next();
};
app.get('/cropper', requirePage, pageCropGate, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'assets', 'cropper', 'index.html'));
});
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
if (cropperClients) {
app.use(
'/cropper',
createCropperRouter({
...cropperClients,
a2loftBase: cfg.a2loftBase,
removebgUrl: cfg.removebgUrl,
noderedBase: cfg.noderedBase,
...cropGateContext,
requireApi,
})
);
} else {
app.use('/cropper', (req, res) =>
res.status(503).json({ ok: false, message: 'The image cropper is not configured on this deployment.' })
);
}
// ── a2loft proxies: same-origin, phone injected from the session ────────── // ── a2loft proxies: same-origin, phone injected from the session ──────────
const a2loftUrl = (domain, reference, otp, phone) => { const a2loftUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp }); const q = new URLSearchParams({ phone, otp });
+420
View File
@@ -0,0 +1,420 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from '../server.js';
import {
makeJwt,
makePbCookie,
startServer,
stopServer,
urlOf,
cookieValue,
} from './helpers.js';
// Fake portal: web-auth + openfga verify with a configurable crop_image verdict.
async function startFakePortal({ cropAllowed = true } = {}) {
const state = { verifyCalls: 0 };
const server = await startServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const json = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.method === 'POST' && req.url === '/api/web/auth') {
return json(200, { ok: true, sessionId: 's1', loginUrl: 'https://portal.example/login' });
}
if (req.url.startsWith('/api/web/auth/s1/cookie')) {
return json(200, {
ok: true,
sessionId: 's1',
status: 'Activated',
cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })),
phone: '+2348068287222',
firstName: 'Ada',
lastName: 'Obi',
});
}
if (req.method === 'POST' && req.url === '/api/openfga/verify') {
state.verifyCalls += 1;
const body = JSON.parse(raw);
// capture_biometric checks (session endpoint) always pass; crop_image
// follows the configured verdict.
const allowed = body.relation === 'crop_image' ? cropAllowed : true;
return json(200, { ok: true, allowed });
}
json(404, { ok: false });
});
});
return { server, state, stop: () => stopServer(server) };
}
// Fake a2loft: dims load, save echo; records calls.
async function startFakeA2loft() {
const calls = [];
const server = await startServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
const json = (body) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.url.startsWith('/identity/dims/picture/')) {
return json({
ok: true,
data: {
upload_id: 'up-1',
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
caption: '08068287222',
domain: 'ucth',
raw_file_id: 'raw-1',
},
});
}
if (req.url.includes('/profile/picture')) {
return json({ ok: true, message: 'Picture uploaded.' });
}
json({ ok: true });
});
});
return { calls, server, stop: () => stopServer(server) };
}
// Fake minio + azure queue — plain objects exposing just what the router uses.
const fakeMinio = () => ({
presignedGetObject: async (_bucket, name) => `https://storage.example/${name}`,
});
const fakeQueue = () => {
const calls = { getMessage: 0, deleteMessage: 0 };
const item = {
messageId: 'm-1',
popReceipt: 'pop-1',
dequeueCount: 1,
messageText: JSON.stringify({
upload_id: 'up-1',
file_name: 'id-domains/ucth/raw-pictures/x/img.jpg',
caption: '08068287222',
domain: 'ucth',
raw_file_id: 'raw-1',
}),
};
return {
calls,
// azure-storage callback signatures
getMessage: (_queue, _opts, cb) => {
calls.getMessage += 1;
cb(null, item);
},
deleteMessage: (_queue, _id, _pop, cb) => {
calls.deleteMessage += 1;
cb(null, { isSuccessful: true });
},
};
};
// Fake removebg + nodered upstreams.
async function startFakeRemovebg() {
const calls = [];
const server = await startServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
calls.push({ url: req.url, key: req.headers['x-api-key'] });
res.writeHead(200, { 'content-type': 'image/png' });
res.end('PNGDATA');
});
});
return { calls, server, stop: () => stopServer(server) };
}
async function startFakeNodered() {
const calls = [];
const server = await startServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
calls.push({ method: req.method, url: req.url, auth: req.headers.authorization });
const json = (body) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.url === '/idid/picture/review/up-1') {
return json({ ok: true, data: { agentPhoneHash: '12345' } });
}
if (req.url === '/unical/agents') {
return json([{ phoneHashes: [12345], name: 'Agent A', phone: '+2340000000000' }]);
}
if (req.url === '/zeebe/picture/review') {
return json({ ok: true });
}
json({ ok: false });
});
});
return { calls, server, stop: () => stopServer(server) };
}
async function boot({ cropAllowed = true, cropperClients } = {}) {
const portal = await startFakePortal({ cropAllowed });
const a2loft = await startFakeA2loft();
const removebg = await startFakeRemovebg();
const nodered = await startFakeNodered();
// presigned URLs from the fake minio must not hit the real network
const testFetch = async (url, init) => {
if (String(url).startsWith('https://storage.example/')) {
return {
ok: true,
status: 200,
headers: { get: () => 'image/png' },
arrayBuffer: async () => Buffer.from('PNGDATA'),
json: async () => ({}),
text: async () => 'PNGDATA',
};
}
return fetch(url, init);
};
const app = createApp({
portalUrl: urlOf(portal.server),
a2loftBase: urlOf(a2loft.server),
removebgUrl: urlOf(removebg.server),
noderedBase: urlOf(nodered.server),
fetch: testFetch,
cropperClients: cropperClients !== undefined ? cropperClients : {
minio: fakeMinio(),
queue: fakeQueue(),
noderedAuth: { username: 'nr', password: 'nr-secret' },
carvekitKey: 'carve-key',
},
});
const httpServer = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s));
});
const base = urlOf(httpServer);
const stop = async () => {
await new Promise((r) => httpServer.close(r));
await portal.stop();
await a2loft.stop();
await removebg.stop();
await nodered.stop();
};
return { base, a2loft, removebg, nodered, portal, stop };
}
const manual = { redirect: 'manual' };
async function login(base) {
const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
assert.equal(res.status, 302);
return `session=${cookieValue(res, 'session')}; user_profile=${cookieValue(res, 'user_profile')}`;
}
test('unconfigured deployment: cropper answers 503, other tools unaffected', async () => {
const { base, stop } = await boot({ cropperClients: null });
try {
const cookies = await login(base);
const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
assert.equal(res.status, 503);
const landing = await fetch(`${base}/`);
assert.equal(landing.status, 200);
} finally {
await stop();
}
});
test('crop gate: permitted operator loads the page; denied operator does not', async () => {
const ok = await boot({ cropAllowed: true });
try {
const cookies = await login(ok.base);
const page = await fetch(`${ok.base}/cropper`, { headers: { cookie: cookies } });
assert.equal(page.status, 200);
assert.match(await page.text(), /Smart Photo Editor|Image Cropper/);
} finally {
await ok.stop();
}
const denied = await boot({ cropAllowed: false });
try {
const cookies = await login(denied.base);
const page = await fetch(`${denied.base}/cropper`, { headers: { cookie: cookies } });
assert.equal(page.status, 403);
assert.match(await page.text(), /not permitted/i);
} finally {
await denied.stop();
}
});
test('picture routes are gated and proxied; reads use the cached gate', async () => {
const { base, portal, stop } = await boot();
try {
const cookies = await login(base);
const before = portal.state.verifyCalls;
const next = await fetch(`${base}/cropper/picture/next`, { headers: { cookie: cookies } });
assert.equal(next.status, 200);
const body = await next.json();
assert.equal(body.ok, true);
assert.equal(body.data.message.upload_id, 'up-1');
// load-by-id goes to a2loft dims; image fetch returns a data URL
const loaded = await fetch(`${base}/cropper/picture/load/up-1`, { headers: { cookie: cookies } });
assert.equal((await loaded.json()).ok, true);
const image = await fetch(`${base}/cropper/picture/id-domains_ucth_raw-pictures_x_img.jpg`, {
headers: { cookie: cookies },
});
const imageBody = await image.json();
assert.equal(imageBody.ok, true);
assert.match(imageBody.data, /^data:image\/png;base64,/);
// several reads, but only one crop verify within the TTL
assert.equal(portal.state.verifyCalls - before, 1);
} finally {
await stop();
}
});
test('removebg is gated and proxies the cleanup service', async () => {
const denied = await boot({ cropAllowed: false });
try {
const cookies = await login(denied.base);
const res = await fetch(`${denied.base}/cropper/api/removebg`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify({ image_file_b64: 'QUJD' }),
});
assert.equal(res.status, 403);
assert.equal(denied.removebg.calls.length, 0, 'cleanup service not contacted');
} finally {
await denied.stop();
}
const ok = await boot({ cropAllowed: true });
try {
const cookies = await login(ok.base);
const res = await fetch(`${ok.base}/cropper/api/removebg`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify({ image_file_b64: 'QUJD' }),
});
assert.equal(res.status, 200);
assert.equal(await res.text(), 'PNGDATA');
assert.equal(ok.removebg.calls[0].key, 'carve-key');
} finally {
await ok.stop();
}
});
test('save verifies the gate live and proxies to a2loft with session identity', async () => {
const { base, a2loft, portal, stop } = await boot();
try {
const cookies = await login(base);
// prime the cache with a denied verdict for the reads, then flip the
// portal to allowed — the live save check must see the new verdict.
const payload = {
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
phone: '08068287222',
requiresCleaning: true,
message: {
pop_receipt: 'pop-1',
message_id: 'm-1',
message: {
upload_id: 'up-1',
domain: 'ucth',
agentDeviceHash: 'agent-1',
caption: '08068287222',
file_url: null,
},
},
};
const res = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
const save = a2loft.calls.find((c) => c.url.includes('/profile/picture'));
assert.ok(save, 'a2loft save contacted');
const u = new URL(save.url, 'http://x');
assert.equal(decodeURIComponent(u.pathname), '/identity/ucth/profile/picture');
assert.equal(u.searchParams.get('phone'), '+2348068287222', 'session phone is the validating identity');
assert.equal(u.searchParams.get('otp'), '778899');
assert.equal(save.body.phone, '08068287222', 'profile phone from the picture');
assert.equal(save.body.uploadId, 'up-1');
assert.ok(portal.state.verifyCalls > 0);
} finally {
await stop();
}
});
test('save refuses without an OTP and without the crop permission', async () => {
const { base, a2loft, stop } = await boot({ cropAllowed: false });
try {
const cookies = await login(base);
const payload = {
pictureDataUrl: 'data:image/jpeg;base64,QUJD',
phone: '08068287222',
message: {
pop_receipt: 'custom',
message_id: 'm-1',
message: { upload_id: 'up-1', domain: 'ucth', caption: '08068287222', file_url: null },
},
};
const noOtp = await fetch(`${base}/cropper/profile/picture`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
// permission denial dominates; 400 (missing OTP) or 403 both satisfy the spec
assert.ok([400, 403].includes(noOtp.status), `got ${noOtp.status}`);
const denied = await fetch(`${base}/cropper/profile/picture?otp=778899`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
assert.equal(denied.status, 403);
assert.equal(a2loft.calls.filter((c) => c.url.includes('/profile/picture')).length, 0, 'a2loft never contacted');
} finally {
await stop();
}
});
test('review and dequeue proxy with the queued message', async () => {
const { base, nodered, stop } = await boot();
try {
const cookies = await login(base);
const agent = await fetch(`${base}/cropper/picture/review/up-1`, { headers: { cookie: cookies } });
const agentBody = await agent.json();
assert.equal(agentBody.ok, true, JSON.stringify(agentBody));
assert.equal(agentBody.data.name, 'Agent A');
const review = await fetch(`${base}/cropper/picture/review`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify({
deleteFromQueue: true,
message: { message_id: 'm-1', pop_receipt: 'pop-1', message: { domain: 'ucth' } },
mode: 'raw',
raw: { phone: '08068287222', reviewRejectMessage: 'blurry' },
}),
});
const reviewBody = await review.json();
assert.equal(reviewBody.ok, true, JSON.stringify(reviewBody));
assert.ok(nodered.calls.find((c) => c.url === '/zeebe/picture/review'));
} finally {
await stop();
}
});
test('baseUrl exposes the identity-prefixed a2loft base', async () => {
const { base, stop } = await boot();
try {
const cookies = await login(base);
const res = await fetch(`${base}/cropper/api/baseUrl`, { headers: { cookie: cookies } });
const body = await res.json();
assert.equal(body.ok, true);
assert.match(body.data, /\/identity$/);
assert.ok(body.data.startsWith('http://127.0.0.1:'));
} finally {
await stop();
}
});
+3 -3
View File
@@ -124,8 +124,8 @@ test('landing page links both tools', async () => {
const res = await fetch(`${base}/`); const res = await fetch(`${base}/`);
const html = await res.text(); const html = await res.text();
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.match(html, /smart-pxa\.yrka\.app/); assert.match(html, /href="\/cropper"/);
assert.match(html, /\/fingerprint/); assert.match(html, /href="\/fingerprint"/);
} finally { } finally {
await stop(); await stop();
} }
@@ -148,7 +148,7 @@ test('auth callback rejects unredeemable sessions without setting cookies', asyn
const { base, stop } = await bootApp({}, { cookieStatus: 'TimedOut' }); const { base, stop } = await bootApp({}, { cookieStatus: 'TimedOut' });
try { try {
const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual); const res = await fetch(`${base}/auth/callback?sessionId=s1`, manual);
assert.equal(res.status, 502); assert.equal(res.status, 401);
assert.equal(cookieValue(res, 'session'), undefined); assert.equal(cookieValue(res, 'session'), undefined);
const html = await res.text(); const html = await res.text();
assert.match(html, /timed out/i); assert.match(html, /timed out/i);