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
+63 -10
View File
@@ -10,7 +10,8 @@ import express from 'express';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
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 ──────────────────────────────────────────────────
export const PORT = 4173;
@@ -57,9 +58,9 @@ function readProfile(raw) {
}
}
function errorPage(res, title, message) {
function errorPage(res, status, title, message) {
res
.status(502)
.status(status)
.type('html')
.send(`<!doctype html>
<html lang="en">
@@ -103,11 +104,12 @@ export function createApp(overrides = {}) {
};
const domainCodes = new Set(cfg.domains.map((d) => d.code));
const domainCache = new Map(); // phone → { codes, checkedAt }
const cropCache = cfg.cropCache || new Map(); // phone → { allowed, checkedAt }
const app = express();
app.disable('x-powered-by');
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) =>
res.cookie('session', value, {
@@ -174,28 +176,29 @@ export function createApp(overrides = {}) {
});
const data = await r.json().catch(() => null);
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);
} 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) => {
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 {
const r = await cfg.fetch(
`${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth/${encodeURIComponent(sessionId)}/cookie`
);
const data = await r.json().catch(() => null);
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') {
return errorPage(
res,
401,
'Login failed',
data.status === 'TimedOut'
? 'Your login session timed out. Please start again.'
@@ -204,7 +207,7 @@ export function createApp(overrides = {}) {
}
const sessionValue = extractPbCookieValue(data.cookie || '');
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);
setProfileCookie(req, res, {
@@ -214,7 +217,7 @@ export function createApp(overrides = {}) {
});
res.redirect('/fingerprint');
} 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'));
});
// ── 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 ──────────
const a2loftUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });