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:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Generated
+1138
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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 }} · {{ 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…</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…</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…</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…</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…</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>
|
||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user