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:
+63
-10
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user