Files
tools-legacy/tools/server.js
T
adebisi-fa b3b75ffac0 feat(fpagent): public installer and private-release proxy
Serve /fpagent/install.sh (also curl|bash) and proxy the private
fingerprint-agent releases through /fpagent/releases/* using
GITEA_RELEASE_INSTALL_KEY, so a field laptop needs no forge credentials.
The installer picks the offline bundle for the host codename when present,
verifies the published sha256, then hands off to the bundle's own installer.
2026-10-02 09:53:35 +01:00

779 lines
36 KiB
JavaScript

// YOUreka Tools — Express host for the tools landing page and the
// /fingerprint enrollment wizard, whose captures run through the local
// fpagent binary (Socket.IO on 127.0.0.1:9000).
//
// Every endpoint and the port are hardcoded in this file by explicit design
// decision (openspec change fpagent-tools-ui): no env vars, no config files.
// createApp() accepts overrides so tests can point the app at fake upstreams;
// production always runs on the constants below.
import express from 'express';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js';
import { createCropperRouter, buildCropperClients, CROPPER_CREDENTIALS } from './cropper.js';
// ── Hardcoded configuration ──────────────────────────────────────────────────
export const PORT = 4173;
export const PORTAL_URL = 'https://portal.yrka.app'; // web-auth + openfga/verify
export const PB_URL = 'https://pb.itcass.net'; // PocketBase session refresh
export const A2LOFT_BASE = 'https://apis.a2loft.net'; // staff lookup + fingerprint save
export const AGENT_URL = 'http://127.0.0.1:9000'; // fpagent Socket.IO
export const GITEA_URL = 'https://sources.yrka.app'; // fpagent release forge (private)
export const FPAGENT_REPO = 'YOUreka/fingerprint-agent';
// The Gitea token used to read the private fpagent releases. The operator's
// laptop never sees it: /fpagent/* proxies the bundles through this server.
const GITEA_RELEASE_KEY = process.env.GITEA_RELEASE_INSTALL_KEY || '';
export const DOMAINS = [
{ code: 'uc', name: 'UNICAL' },
{ code: 'mtr', name: 'Metropolitan Hotel' },
{ code: 'ucth', name: 'UCTH' },
{ code: 'uri', name: 'UNICAL Radio International' },
{ code: 'yrka', name: 'YOUreka' },
];
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Bumped once per process start, so every deployment serves fresh asset URLs.
export const DEPLOY_VERSION = Date.now().toString(36);
/**
* Append the per-deploy version to every /assets URL in a served page, so the
* browser fetches fresh CSS/JS/images after a deploy instead of reusing cached
* ones. Idempotent: URLs that already carry `?v=` are left alone.
*/
function versionAssets(html) {
return html.replace(/(["'(])(\/assets\/[^"')\s?]+)/g, `$1$2?v=${DEPLOY_VERSION}`);
}
// ── helpers ──────────────────────────────────────────────────────────────────
// Express does not parse cookies; this is all we need.
function cookie(req, name) {
const header = req.headers.cookie;
if (!header) return undefined;
for (const part of header.split(';')) {
const i = part.indexOf('=');
if (i < 0) continue;
if (part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim());
}
return undefined;
}
function readProfile(raw) {
if (!raw) return { firstName: '', lastName: '', phone: '' };
try {
const p = JSON.parse(raw);
return {
firstName: typeof p.firstName === 'string' ? p.firstName : '',
lastName: typeof p.lastName === 'string' ? p.lastName : '',
phone: typeof p.phone === 'string' ? p.phone : '',
};
} catch {
return { firstName: '', lastName: '', phone: '' };
}
}
function errorPage(res, status, title, message, actions = {}) {
const primary = actions.primary || { href: '/auth/login', label: 'Try again' };
const secondary = actions.secondary === null ? null : actions.secondary || { href: '/', label: 'Back to Tools' };
const icon = actions.icon || 'alert';
res
.status(status)
.type('html')
.send(versionAssets(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)} · YOUreka Tools</title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" type="image/png" href="/assets/youreka-favicon.png">
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/modal.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/ui-shell.min.js"></script>
<script type="module" src="https://1.www.s81c.com/common/carbon/web-components/version/v2.50.0/tag.min.js"></script>
</head>
<body class="app cds--g100">
${appHeader(null, null)}
<main class="container narrow center-v">
<div class="card message-card">
<div class="message-icon error">${ICONS[icon] || ICONS.alert}</div>
<h1>${esc(title)}</h1>
<p class="muted">${esc(message)}</p>
<a class="btn btn-primary" href="${esc(primary.href)}">${esc(primary.label)}</a>
${secondary ? `<a class="btn btn-ghost" href="${esc(secondary.href)}">${esc(secondary.label)}</a>` : ''}
</div>
</main>
</body>
</html>`));
}
const ICONS = {
alert:
'<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
lock:
'<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
};
/** HTML-escape interpolated values — error pages may carry upstream text. */
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
/**
* The application header and mobile side nav (Carbon ui-shell), shared by
* every page. `session` is present when the operator is signed in; `active`
* marks the current tool. Rendered server-side so pages need no client
* session fetch and hold a single header definition.
*/
const appHeader = (session, active) => {
const toolLink = (href, label, key) =>
`<cds-header-nav-item href="${href}"${key === active ? ' aria-current="page"' : ''}>${label}</cds-header-nav-item>`;
const sideLink = (href, label, key, icon) =>
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${icon || ''}${label}</cds-side-nav-link>`;
const name = session ? (session.firstName || session.phone || '') : '';
// Help and the auth action are icon-only global header actions.
const headerAction = (href, label, svg, extra = '') =>
`<cds-header-global-action href="${href}"${extra}>${svg.replace('slot="title-icon"', 'slot="icon"')}<span class="cds--visually-hidden">${label}</span></cds-header-global-action>`;
const headerAuth = session
? headerAction('/auth/logout', 'Sign out', SIDE_NAV_ICONS.logout, ' data-signout')
: headerAction('/auth/login', 'Sign in', SIDE_NAV_ICONS.login);
const sideAuth = session
? `<cds-side-nav-link href="/auth/logout" data-signout>${SIDE_NAV_ICONS.logout}Sign out</cds-side-nav-link>`
: `<cds-side-nav-link href="/auth/login">${SIDE_NAV_ICONS.login}Sign in</cds-side-nav-link>`;
return `
<cds-header aria-label="YOUreka Tools">
<cds-header-menu-button button-label-active="Close menu" button-label-inactive="Open menu" id="header-menu-button"></cds-header-menu-button>
<cds-header-name href="/">
<span class="brand-inner"><span class="brand-logo"><img src="/assets/youreka-logo.png" alt="" width="20" height="20"></span><span>YOUreka<span class="brand-suffix"> Tools</span></span></span>
</cds-header-name>
<cds-header-nav>
${toolLink('/', 'Home', 'home')}
${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')}
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}` : ''}
</cds-header-nav>
${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''}
<div class="header-right">
${headerAction('/help', 'Help', SIDE_NAV_ICONS.help)}
${headerAuth}
${session ? `<span class="user-chip"><span class="avatar">${esc(name.trim().slice(0, 2).toUpperCase())}</span>${esc(name)}</span>` : ''}
</div>
</cds-header>
<cds-side-nav aria-label="Tools" id="side-nav">
<div class="side-nav-brand">
<img src="/assets/youreka-logo.png" alt="YOUreka" width="56" height="56">
</div>
<cds-side-nav-items>
${sideLink('/', 'Home', 'home', SIDE_NAV_ICONS.home)}
<cds-side-nav-divider></cds-side-nav-divider>
${session ? `${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)}
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)}
<cds-side-nav-divider></cds-side-nav-divider>` : ''}
${sideLink('/help', 'Help', 'help', SIDE_NAV_ICONS.help)}
${sideAuth}
</cds-side-nav-items>
</cds-side-nav>
${session ? `<cds-modal id="signout-modal" size="xs" aria-label="Sign out of YOUreka Tools">
<cds-modal-header>
<cds-modal-close-button></cds-modal-close-button>
<cds-modal-label>Account</cds-modal-label>
<cds-modal-heading>Sign out of YOUreka Tools?</cds-modal-heading>
</cds-modal-header>
<cds-modal-body>You will need to authenticate again to use the tools.</cds-modal-body>
<cds-modal-footer>
<cds-modal-footer-button kind="secondary" data-modal-close><svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M17.4141 16L24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16z"/></svg>Cancel</cds-modal-footer-button>
<cds-modal-footer-button kind="danger" id="signout-confirm"><svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M6,30H18a2.0023,2.0023,0,0,0,2-2V25H18v3H6V4H18V7h2V4a2.0023,2.0023,0,0,0-2-2H6A2.0023,2.0023,0,0,0,4,4V28A2.0023,2.0023,0,0,0,6,30Z"/><path d="M20.586 20.586L24.172 17 10 17 10 15 24.172 15 20.586 11.414 22 10 28 16 22 22 20.586 20.586z"/></svg>Sign out</cds-modal-footer-button>
</cds-modal-footer>
</cds-modal>
<script>
// Confirm before signing out (Carbon modal)
(function () {
function wire() {
var modal = document.getElementById('signout-modal');
var confirm = document.getElementById('signout-confirm');
if (!modal || !confirm) return;
document.addEventListener('click', function (event) {
var trigger = event.target && event.target.closest ? event.target.closest('[data-signout]') : null;
if (!trigger) return;
event.preventDefault();
modal.open = true;
});
confirm.addEventListener('click', function () {
window.location.href = '/auth/logout';
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire();
})();
</script>` : ''}
`;
};
/** Carbon icons for the mobile side nav (inlined so they inherit the theme). */
const SIDE_NAV_ICONS = {
crop: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M25,20H23V9H12V7H23a2,2,0,0,1,2,2Z"/><path d="M9,23V2H7V7H2V9H7V23a2,2,0,0,0,2,2H23v5h2V25h5V23Z"/></svg>',
fingerprint: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M7,5.21a.77.77,0,0,1-.46-1.38A15.46,15.46,0,0,1,16,1c2.66,0,6.48.45,9.5,2.62a.77.77,0,0,1,.18,1.07.78.78,0,0,1-1.08.17A15,15,0,0,0,16,2.53,14,14,0,0,0,7.5,5.05.74.74,0,0,1,7,5.21Z"/><path d="M28.23,12.26a.78.78,0,0,1-.63-.33C25.87,9.49,22.78,6.24,16,6.24a14,14,0,0,0-11.63,5.7.77.77,0,0,1-1.07.17A.76.76,0,0,1,3.15,11,15.54,15.54,0,0,1,16,4.71c5.61,0,9.81,2.08,12.84,6.34a.77.77,0,0,1-.19,1.07A.79.79,0,0,1,28.23,12.26Z"/><path d="M12.28,31a.78.78,0,0,1-.72-.49.75.75,0,0,1,.44-1c4.37-1.68,7-5.12,7-9.21a2.8,2.8,0,0,0-3-3c-1.86,0-2.76,1-3,3.35a4.27,4.27,0,0,1-4.52,3.83,4.27,4.27,0,0,1-4.32-4.59A11.71,11.71,0,0,1,16,8.39a12,12,0,0,1,12,11.93,18.66,18.66,0,0,1-1.39,6.5.78.78,0,0,1-1,.41.76.76,0,0,1-.41-1,17.25,17.25,0,0,0,1.27-5.91A10.45,10.45,0,0,0,16,9.92a10.18,10.18,0,0,0-10.38,10,2.77,2.77,0,0,0,2.79,3.06,2.74,2.74,0,0,0,3-2.48c.36-3.11,1.89-4.69,4.56-4.69a4.31,4.31,0,0,1,4.52,4.56c0,4.74-3,8.72-8,10.63A.92.92,0,0,1,12.28,31Z"/><path d="M19.77,30.28a.81.81,0,0,1-.52-.2.76.76,0,0,1,0-1.08,12.63,12.63,0,0,0,3.54-8.68c0-1.56-.48-6.65-6.7-6.65a6.83,6.83,0,0,0-4.94,1.87A6.17,6.17,0,0,0,9.32,20a.77.77,0,0,1-.77.76h0A.76.76,0,0,1,7.78,20,7.73,7.73,0,0,1,10,14.46a8.34,8.34,0,0,1,6-2.32c6.08,0,8.24,4.4,8.24,8.18A14.09,14.09,0,0,1,20.34,30,.75.75,0,0,1,19.77,30.28Z"/><path d="M8.66,27.74a14.14,14.14,0,0,1-1.56-.09.76.76,0,1,1,.17-1.52c2.49.28,4.45-.16,5.84-1.32a6.37,6.37,0,0,0,2.12-4.53.75.75,0,0,1,.82-.71.78.78,0,0,1,.72.81A7.89,7.89,0,0,1,14.09,26,8.2,8.2,0,0,1,8.66,27.74Z"/></svg>',
logout: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6,30H18a2.0023,2.0023,0,0,0,2-2V25H18v3H6V4H18V7h2V4a2.0023,2.0023,0,0,0-2-2H6A2.0023,2.0023,0,0,0,4,4V28A2.0023,2.0023,0,0,0,6,30Z"/><path d="M20.586 20.586L24.172 17 10 17 10 15 24.172 15 20.586 11.414 22 10 28 16 22 22 20.586 20.586z"/></svg>',
login: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26,30H14a2,2,0,0,1-2-2V25h2v3H26V4H14V7H12V4a2,2,0,0,1,2-2H26a2,2,0,0,1,2,2V28A2,2,0,0,1,26,30Z"/><path d="M14.59 20.59L18.17 17 4 17 4 15 18.17 15 14.59 11.41 16 10 22 16 16 22 14.59 20.59z"/></svg>',
help: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2Zm0,26A12,12,0,1,1,28,16,12,12,0,0,1,16,28Z"/><path d="M17,8H15.5A4.49,4.49,0,0,0,11,12.5V13h2v-.5A2.5,2.5,0,0,1,15.5,10H17a2.5,2.5,0,0,1,0,5H15v4.5h2V17a4.5,4.5,0,0,0,0-9Z"/></svg>',
home: '<svg slot="title-icon" width="16" height="16" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16.6123,2.2138a1.01,1.01,0,0,0-1.2427,0L1,13.4194l1.2427,1.5717L4,13.6209V26a2.0041,2.0041,0,0,0,2,2H26a2.0037,2.0037,0,0,0,2-2V13.63L29.7573,15,31,13.4282ZM18,26H14V18h4Zm2,0V18a2.0023,2.0023,0,0,0-2-2H14a2.002,2.002,0,0,0-2,2v8H6V12.0615l10-7.79,10,7.8005V26Z"/></svg>',
};
/**
* Landing-page body: the tool tiles when signed in, a sign-in call to action
* when not. Both are injected server-side into index.html's `<!--landing-view-->`
* marker so the two states never share markup or flash the wrong one.
*/
const LANDING_TOOLS = `
<div class="tool-grid">
<cds-clickable-tile href="/cropper">
<div class="tool-card">
<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>
</span>
<h3>Image Cropper</h3>
<p>Review, crop and save identity photos submitted for YOUreka ID domain profiles.</p>
<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="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</span>
</div>
</cds-clickable-tile>
<cds-clickable-tile href="/fingerprint">
<div class="tool-card">
<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="M12 2a8 8 0 0 0-8 8c0 3.5 1 5.5 2 7"/><path d="M16 21a10 10 0 0 0 4-13 8 8 0 0 0-15.7-1"/><path d="M8 21a8 8 0 0 1-1-11 5 5 0 0 1 9.7 1.4A6 6 0 0 1 12 17"/><path d="M12 21a4 4 0 0 0 4-4 4 4 0 0 0-1.2-2.9"/></svg>
</span>
<h3>Fingerprint Agent</h3>
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
<span class="go">Open Fingerprint Agent
<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>
</div>
</cds-clickable-tile>
</div>`;
const LANDING_SIGNIN = `
<div class="card message-card narrow">
<div class="message-icon info">${ICONS.lock}</div>
<h1>Sign in required</h1>
<p class="muted">Sign in with your YOUreka account to open the Image Cropper and Fingerprint Agent.</p>
<a class="btn btn-primary" href="/auth/login">Sign in with YOUreka<svg width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M26,30H14a2,2,0,0,1-2-2V25h2v3H26V4H14V7H12V4a2,2,0,0,1,2-2H26a2,2,0,0,1,2,2V28A2,2,0,0,1,26,30Z"/><path d="M14.59 20.59L18.17 17 4 17 4 15 18.17 15 14.59 11.41 16 10 22 16 16 22 14.59 20.59z"/></svg></a>
</div>`;
// ── app factory ──────────────────────────────────────────────────────────────
export function createApp(overrides = {}) {
const cfg = {
portalUrl: PORTAL_URL,
pbUrl: PB_URL,
a2loftBase: A2LOFT_BASE,
agentUrl: AGENT_URL,
domains: DOMAINS,
ttlMs: DOMAIN_TTL_MS,
giteaUrl: GITEA_URL,
fpagentRepo: FPAGENT_REPO,
giteaToken: GITEA_RELEASE_KEY,
giteaFetch: fetch,
fetch: fetch,
now: Date.now,
...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
// cropper pictures travel as data URLs (large); everything else stays small
app.use('/cropper', express.json({ limit: '50mb' }));
app.use(express.json({ limit: '1mb' }));
const setSessionCookie = (req, res, value) =>
res.cookie('session', value, {
path: '/',
httpOnly: true,
secure: req.secure, // https in production; plain http dev on localhost still works
sameSite: 'lax',
maxAge: SESSION_MAX_AGE_MS,
});
const setProfileCookie = (req, res, profile) =>
res.cookie('user_profile', JSON.stringify(profile), {
path: '/',
httpOnly: true,
secure: req.secure,
sameSite: 'lax',
maxAge: SESSION_MAX_AGE_MS,
});
// ── static assets (no session work per asset) ─────────────────────────────
// Versioned asset URLs are safe to cache hard; the .html templates that also
// live under /assets must never be cached (they are read server-side anyway).
app.use(
express.static(path.join(__dirname, 'public'), {
index: false,
setHeaders: (res, filePath) => {
res.setHeader(
'Cache-Control',
filePath.endsWith('.html') || filePath.endsWith('.sh')
? 'no-store'
: 'public, max-age=31536000, immutable'
);
},
})
); // assets only; '/' is session-aware
// ── fpagent installer: public proxy for the private release bundles ───────
// The install script (public/fpagent/install.sh) runs on a field laptop with
// no Gitea credentials, so this server reads the private release with the
// token and streams the bundle back. The token and forge host never leave
// here. Registered before the session middleware: the installer is public.
const giteaBase = () => cfg.giteaUrl.replace(/\/+$/, '');
const giteaHeaders = () => (cfg.giteaToken ? { authorization: `token ${cfg.giteaToken}` } : {});
const SAFE_ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const installerNotConfigured = (res) =>
res.status(503).json({
ok: false,
message: 'The Fingerprint Agent installer is not configured on this deployment.',
});
app.get('/fpagent/releases/latest', async (req, res) => {
if (!cfg.giteaToken) return installerNotConfigured(res);
try {
const r = await cfg.giteaFetch(`${giteaBase()}/api/v1/repos/${cfg.fpagentRepo}/releases/latest`, {
headers: giteaHeaders(),
});
if (!r.ok) {
return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
}
const release = await r.json();
const tag = String(release?.tag_name || '');
const assets = (Array.isArray(release?.assets) ? release.assets : [])
.map((a) => String(a?.name || ''))
.filter((name) => SAFE_ASSET.test(name))
.map((name) => ({
name,
url: `/fpagent/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`,
}));
if (!tag || assets.length === 0) {
return res
.status(502)
.json({ ok: false, message: 'The Fingerprint Agent release has no downloadable bundles.' });
}
res.set('Cache-Control', 'no-store').json({ ok: true, tag, assets });
} catch {
res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
}
});
app.get('/fpagent/releases/download/:tag/:name', async (req, res) => {
if (!cfg.giteaToken) return installerNotConfigured(res);
const { tag, name } = req.params;
if (!SAFE_ASSET.test(tag) || !SAFE_ASSET.test(name)) {
return res.status(400).json({ ok: false, message: 'Unknown release asset.' });
}
try {
const r = await cfg.giteaFetch(
`${giteaBase()}/${cfg.fpagentRepo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`,
{ headers: giteaHeaders() }
);
if (!r.ok) {
return res
.status(r.status === 404 ? 404 : 502)
.json({ ok: false, message: 'Release asset not available.' });
}
res
.set('Content-Type', r.headers.get('content-type') || 'application/octet-stream')
.set('Cache-Control', 'no-store')
.send(Buffer.from(await r.arrayBuffer()));
} catch {
res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' });
}
});
// ── session middleware: recover / refresh the YOUreka Auth session ────────
app.use(async (req, res, next) => {
req.session = null;
const raw = cookie(req, 'session');
if (raw) {
try {
const yrka = await loadYrkaSession(raw, { pbUrl: cfg.pbUrl });
if (!yrka) {
res.clearCookie('session', { path: '/' });
res.clearCookie('user_profile', { path: '/' });
} else {
if (yrka.refreshed && yrka.cookie) setSessionCookie(req, res, yrka.cookie);
req.session = {
token: yrka.pb.authStore.token,
...readProfile(cookie(req, 'user_profile')),
};
}
} catch {
req.session = null;
}
}
next();
});
const requirePage = (req, res, next) => {
if (!req.session) return res.redirect('/auth/login');
next();
};
const requireApi = (req, res, next) => {
if (!req.session) return res.status(401).json({ ok: false, message: 'Authentication required.' });
next();
};
// ── static assets & landing page ──────────────────────────────────────────
// ── auth: YOUreka portal web-auth flow ────────────────────────────────────
app.get('/auth/login', async (req, res) => {
const returnUrl = `${req.protocol}://${req.get('host')}/auth/callback`;
try {
const r = await cfg.fetch(`${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ returnUrl }),
});
const data = await r.json().catch(() => null);
if (!r.ok || !data?.ok || !data.loginUrl) {
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, 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, 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, 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.'
: 'Verification incomplete. Please complete the login flow.'
);
}
const sessionValue = extractPbCookieValue(data.cookie || '');
if (!sessionValue) {
return errorPage(res, 401, 'Login failed', 'The portal did not return a usable session. Please try again.');
}
setSessionCookie(req, res, sessionValue);
setProfileCookie(req, res, {
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
phone: data.phone ?? '',
});
res.redirect('/');
} catch {
errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
}
});
app.get('/auth/logout', (req, res) => {
res.clearCookie('session', { path: '/' });
res.clearCookie('user_profile', { path: '/' });
res.redirect('/');
});
// ── session context + permitted domains ───────────────────────────────────
const domainContext = async (req, { force = false } = {}) =>
permittedDomains({
domains: cfg.domains,
phone: stripPhonePrefix(req.session.phone),
portalUrl: cfg.portalUrl,
fetchImpl: cfg.fetch,
cache: domainCache,
ttlMs: cfg.ttlMs,
now: cfg.now,
force,
});
const domainPayload = (codes, checkedAt) => ({
domains: cfg.domains.filter((d) => codes.includes(d.code)),
domainsCheckedAt: checkedAt,
});
const sessionPayload = async (req, { withDomains = false } = {}) => {
const base = {
phone: req.session.phone,
firstName: req.session.firstName,
lastName: req.session.lastName,
};
if (!withDomains) return base;
const { codes, checkedAt } = await domainContext(req);
return {
...base,
token: req.session.token,
agentUrl: cfg.agentUrl,
domainCacheTtlMs: cfg.ttlMs,
...domainPayload(codes, checkedAt),
};
};
/**
* Render a page with the operator's session injected server-side, so the
* client never has to fetch it (and never flashes a loading state).
* Per-user content — must not be cached.
*/
const renderPage = (res, file, session, active) => {
fs.readFile(path.join(__dirname, 'public', 'assets', file), 'utf8', (error, html) => {
if (error) return errorPage(res, 500, 'Something went wrong', 'The page could not be loaded.');
const data = session
? JSON.stringify(session).replace(/</g, '\\u003c') // keep markup out of the script tag
: 'null';
res
.set('Cache-Control', 'no-store')
.set('Vary', 'Cookie')
.type('html')
.send(
versionAssets(
html
.replace('<!--app-header-->', appHeader(session, active))
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
)
);
});
};
app.get('/', (req, res, next) => {
fs.readFile(path.join(__dirname, 'public', 'index.html'), 'utf8', (error, html) => {
if (error) return next(error);
res
.set('Cache-Control', 'no-store')
.set('Vary', 'Cookie')
.type('html')
.send(
versionAssets(
html
.replace('<!--app-header-->', appHeader(req.session, 'home'))
.replace('<!--landing-view-->', req.session ? LANDING_TOOLS : LANDING_SIGNIN)
)
);
});
});
app.get('/help', (req, res) => {
renderPage(res, 'help.html', req.session, 'help');
});
app.get('/auth/session', requireApi, async (req, res, next) => {
try {
const { codes, checkedAt } = await domainContext(req);
res.json({
ok: true,
token: req.session.token,
phone: req.session.phone,
firstName: req.session.firstName,
lastName: req.session.lastName,
agentUrl: cfg.agentUrl,
domainCacheTtlMs: cfg.ttlMs,
...domainPayload(codes, checkedAt),
});
} catch (e) {
next(e);
}
});
app.post('/auth/domains/refresh', requireApi, async (req, res, next) => {
try {
const { codes, checkedAt } = await domainContext(req, { force: true });
res.json({ ok: true, ...domainPayload(codes, checkedAt) });
} catch (e) {
if (e?.code === CACHE_FRESH) {
return res
.status(409)
.json({ ok: false, message: 'Permissions were checked recently. The refresh option unlocks when the cache expires.' });
}
next(e);
}
});
// ── wizard page (session required) ────────────────────────────────────────
app.get('/fingerprint', requirePage, async (req, res, next) => {
try {
renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true }), 'fingerprint');
} catch (e) {
next(e);
}
});
// ── 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,
'No access',
'Your account doesn\u2019t have permission to crop images. Ask a domain administrator to grant you access.',
{ icon: 'lock', primary: { href: '/', label: 'Back to Tools' }, secondary: null }
);
}
next();
};
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
if (!cropperClients) {
return errorPage(
res,
503,
'Cropper unavailable',
'The image cropper is not configured on this deployment. Contact your administrator.'
);
}
renderPage(
res,
'cropper/index.html',
{ phone: req.session.phone, firstName: req.session.firstName, lastName: req.session.lastName },
'cropper'
);
});
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
if (!cropperClients) {
const missing = CROPPER_CREDENTIALS.filter((name) => !process.env[name]);
console.warn(
`[cropper] not configured — missing ${missing.join(', ')}; /cropper will answer 503`
);
}
if (cropperClients) {
app.use(
'/cropper',
createCropperRouter({
...cropperClients,
a2loftBase: cfg.a2loftBase,
domainCodes,
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 });
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(
reference
)}?${q.toString()}`;
};
const a2loftSaveUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(
reference
)}?${q.toString()}`;
};
const validateProxy = (req, res) => {
const { domain, reference } = req.params;
if (!domainCodes.has(domain)) {
res.status(400).json({ ok: false, message: 'Unknown domain.' });
return false;
}
if (!reference) {
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
return false;
}
if (!req.query.otp) {
res.status(400).json({ ok: false, message: 'OTP is required.' });
return false;
}
if (!req.session.phone) {
res.status(400).json({ ok: false, message: 'The signed-in account has no phone number.' });
return false;
}
return true;
};
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
if (!validateProxy(req, res)) return;
try {
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone));
res.status(r.status).type('json').send(await r.text());
} catch {
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
}
});
app.post('/api/:domain/staff/:reference/fingerprints', requireApi, async (req, res) => {
if (!validateProxy(req, res)) return;
if (!Array.isArray(req.body?.fingerprints) || req.body.fingerprints.length === 0) {
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
}
try {
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body)
});
res.status(r.status).type('json').send(await r.text());
} catch {
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
}
});
// ── error handler (async handlers are wrapped; this catches next(err)) ─────
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
const status = err.status || err.statusCode || 500;
if (status >= 500) console.error('[error]', req.method, req.path, err && err.message);
if (res.headersSent) return next(err);
const message =
status === 413 ? 'The request is too large.'
: status >= 500 ? 'An unexpected error occurred. Please try again.'
: err.message || 'The request could not be processed.';
if (req.path === '/cropper' || req.path.startsWith('/auth/')) {
return errorPage(res, status, status === 413 ? 'Request too large' : 'Something went wrong', message);
}
res.status(status).json({ ok: false, message });
});
return app;
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
createApp().listen(PORT, () => {
console.log(`YOUreka Tools listening on http://localhost:${PORT}`);
});
}