2026-09-30 23:12:51 +01:00
|
|
|
// 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';
|
2026-10-01 03:28:31 +01:00
|
|
|
import fs from 'node:fs';
|
2026-09-30 23:12:51 +01:00
|
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
|
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
|
2026-10-01 01:13:30 +01:00
|
|
|
import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js';
|
2026-10-01 04:36:31 +01:00
|
|
|
import { createCropperRouter, buildCropperClients, CROPPER_CREDENTIALS } from './cropper.js';
|
2026-09-30 23:12:51 +01:00
|
|
|
|
|
|
|
|
// ── 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
|
2026-10-02 09:53:35 +01:00
|
|
|
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 || '';
|
2026-09-30 23:12:51 +01:00
|
|
|
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));
|
|
|
|
|
|
2026-10-01 14:39:50 +01:00
|
|
|
// 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}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 23:12:51 +01:00
|
|
|
// ── 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: '' };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 01:35:37 +01:00
|
|
|
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';
|
2026-09-30 23:12:51 +01:00
|
|
|
res
|
2026-10-01 01:13:30 +01:00
|
|
|
.status(status)
|
2026-09-30 23:12:51 +01:00
|
|
|
.type('html')
|
2026-10-01 14:39:50 +01:00
|
|
|
.send(versionAssets(`<!doctype html>
|
2026-09-30 23:12:51 +01:00
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
2026-10-01 03:12:02 +01:00
|
|
|
<title>${esc(title)} · YOUreka Tools</title>
|
2026-09-30 23:12:51 +01:00
|
|
|
<link rel="stylesheet" href="/assets/style.css">
|
2026-10-01 04:08:09 +01:00
|
|
|
<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>
|
2026-09-30 23:12:51 +01:00
|
|
|
</head>
|
2026-10-01 04:01:44 +01:00
|
|
|
<body class="app cds--g100">
|
2026-10-01 03:53:24 +01:00
|
|
|
${appHeader(null, null)}
|
2026-09-30 23:12:51 +01:00
|
|
|
<main class="container narrow center-v">
|
|
|
|
|
<div class="card message-card">
|
2026-10-01 01:35:37 +01:00
|
|
|
<div class="message-icon error">${ICONS[icon] || ICONS.alert}</div>
|
2026-10-01 03:12:02 +01:00
|
|
|
<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>` : ''}
|
2026-09-30 23:12:51 +01:00
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
</body>
|
2026-10-01 14:39:50 +01:00
|
|
|
</html>`));
|
2026-09-30 23:12:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>',
|
2026-10-01 01:35:37 +01:00
|
|
|
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>',
|
2026-09-30 23:12:51 +01:00
|
|
|
};
|
|
|
|
|
|
2026-10-01 03:12:02 +01:00
|
|
|
/** HTML-escape interpolated values — error pages may carry upstream text. */
|
|
|
|
|
function esc(value) {
|
|
|
|
|
return String(value ?? '')
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-01 03:53:24 +01:00
|
|
|
/**
|
|
|
|
|
* 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>`;
|
2026-10-01 06:21:27 +01:00
|
|
|
const sideLink = (href, label, key, icon) =>
|
|
|
|
|
`<cds-side-nav-link href="${href}"${key === active ? ' aria-current="page"' : ''}>${icon || ''}${label}</cds-side-nav-link>`;
|
2026-10-01 03:53:24 +01:00
|
|
|
const name = session ? (session.firstName || session.phone || '') : '';
|
2026-10-01 14:53:10 +01:00
|
|
|
// 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>`;
|
2026-10-01 14:39:50 +01:00
|
|
|
const headerAuth = session
|
2026-10-01 14:53:10 +01:00
|
|
|
? headerAction('/auth/logout', 'Sign out', SIDE_NAV_ICONS.logout, ' data-signout')
|
|
|
|
|
: headerAction('/auth/login', 'Sign in', SIDE_NAV_ICONS.login);
|
2026-10-01 14:39:50 +01:00
|
|
|
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>`;
|
2026-10-01 03:53:24 +01:00
|
|
|
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="/">
|
2026-10-01 04:08:09 +01:00
|
|
|
<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>
|
2026-10-01 03:53:24 +01:00
|
|
|
</cds-header-name>
|
|
|
|
|
<cds-header-nav>
|
2026-10-01 14:39:50 +01:00
|
|
|
${toolLink('/', 'Home', 'home')}
|
|
|
|
|
${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')}
|
2026-10-01 14:53:10 +01:00
|
|
|
${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')}` : ''}
|
2026-10-01 03:53:24 +01:00
|
|
|
</cds-header-nav>
|
|
|
|
|
${active === 'fingerprint' ? '<cds-tag id="agent-status" type="red" class="header-agent">Agent offline</cds-tag>' : ''}
|
2026-10-01 14:53:10 +01:00
|
|
|
<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>
|
2026-10-01 03:53:24 +01:00
|
|
|
</cds-header>
|
|
|
|
|
<cds-side-nav aria-label="Tools" id="side-nav">
|
|
|
|
|
<div class="side-nav-brand">
|
2026-10-01 14:39:50 +01:00
|
|
|
<img src="/assets/youreka-logo.png" alt="YOUreka" width="56" height="56">
|
2026-10-01 03:53:24 +01:00
|
|
|
</div>
|
|
|
|
|
<cds-side-nav-items>
|
2026-10-01 14:39:50 +01:00
|
|
|
${sideLink('/', 'Home', 'home', SIDE_NAV_ICONS.home)}
|
2026-10-01 14:53:10 +01:00
|
|
|
<cds-side-nav-divider></cds-side-nav-divider>
|
2026-10-01 14:39:50 +01:00
|
|
|
${session ? `${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)}
|
2026-10-01 06:21:27 +01:00
|
|
|
${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)}
|
2026-10-01 14:39:50 +01:00
|
|
|
<cds-side-nav-divider></cds-side-nav-divider>` : ''}
|
|
|
|
|
${sideLink('/help', 'Help', 'help', SIDE_NAV_ICONS.help)}
|
|
|
|
|
${sideAuth}
|
2026-10-01 03:53:24 +01:00
|
|
|
</cds-side-nav-items>
|
|
|
|
|
</cds-side-nav>
|
2026-10-01 14:39:50 +01:00
|
|
|
${session ? `<cds-modal id="signout-modal" size="xs" aria-label="Sign out of YOUreka Tools">
|
2026-10-01 04:08:09 +01:00
|
|
|
<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>
|
2026-10-01 14:39:50 +01:00
|
|
|
<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>
|
2026-10-01 04:08:09 +01:00
|
|
|
</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();
|
|
|
|
|
})();
|
2026-10-01 14:39:50 +01:00
|
|
|
</script>` : ''}
|
2026-10-01 03:53:24 +01:00
|
|
|
`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-01 06:21:27 +01:00
|
|
|
/** 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>',
|
2026-10-01 14:39:50 +01:00
|
|
|
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>',
|
2026-10-01 06:21:27 +01:00
|
|
|
};
|
|
|
|
|
|
2026-10-01 14:39:50 +01:00
|
|
|
/**
|
|
|
|
|
* 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>`;
|
|
|
|
|
|
2026-09-30 23:12:51 +01:00
|
|
|
// ── 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,
|
2026-10-02 09:53:35 +01:00
|
|
|
giteaUrl: GITEA_URL,
|
|
|
|
|
fpagentRepo: FPAGENT_REPO,
|
|
|
|
|
giteaToken: GITEA_RELEASE_KEY,
|
|
|
|
|
giteaFetch: fetch,
|
2026-09-30 23:12:51 +01:00
|
|
|
fetch: fetch,
|
|
|
|
|
now: Date.now,
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
const domainCodes = new Set(cfg.domains.map((d) => d.code));
|
|
|
|
|
const domainCache = new Map(); // phone → { codes, checkedAt }
|
2026-10-01 01:13:30 +01:00
|
|
|
const cropCache = cfg.cropCache || new Map(); // phone → { allowed, checkedAt }
|
2026-09-30 23:12:51 +01:00
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
app.disable('x-powered-by');
|
|
|
|
|
app.set('trust proxy', 1); // https termination upstream → correct Secure-cookie flag
|
2026-10-01 03:12:02 +01:00
|
|
|
// cropper pictures travel as data URLs (large); everything else stays small
|
|
|
|
|
app.use('/cropper', express.json({ limit: '50mb' }));
|
|
|
|
|
app.use(express.json({ limit: '1mb' }));
|
2026-09-30 23:12:51 +01:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-01 03:12:02 +01:00
|
|
|
// ── static assets (no session work per asset) ─────────────────────────────
|
2026-10-01 14:39:50 +01:00
|
|
|
// 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',
|
2026-10-02 09:53:35 +01:00
|
|
|
filePath.endsWith('.html') || filePath.endsWith('.sh')
|
|
|
|
|
? 'no-store'
|
|
|
|
|
: 'public, max-age=31536000, immutable'
|
2026-10-01 14:39:50 +01:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
); // assets only; '/' is session-aware
|
2026-10-01 03:12:02 +01:00
|
|
|
|
2026-10-02 09:53:35 +01:00
|
|
|
// ── 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.',
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-02 11:26:35 +01:00
|
|
|
const releasePayload = async (res, giteaPath) => {
|
2026-10-02 09:53:35 +01:00
|
|
|
try {
|
2026-10-02 11:26:35 +01:00
|
|
|
const r = await cfg.giteaFetch(`${giteaBase()}${giteaPath}`, { headers: giteaHeaders() });
|
2026-10-02 09:53:35 +01:00
|
|
|
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.' });
|
|
|
|
|
}
|
2026-10-02 11:26:35 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
app.get('/fpagent/releases/latest', (req, res) => {
|
|
|
|
|
if (!cfg.giteaToken) return installerNotConfigured(res);
|
|
|
|
|
return releasePayload(res, `/api/v1/repos/${cfg.fpagentRepo}/releases/latest`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/fpagent/releases/tags/:tag', (req, res) => {
|
|
|
|
|
if (!cfg.giteaToken) return installerNotConfigured(res);
|
|
|
|
|
if (!SAFE_ASSET.test(req.params.tag)) {
|
|
|
|
|
return res.status(400).json({ ok: false, message: 'Unknown release tag.' });
|
|
|
|
|
}
|
|
|
|
|
return releasePayload(
|
|
|
|
|
res,
|
|
|
|
|
`/api/v1/repos/${cfg.fpagentRepo}/releases/tags/${encodeURIComponent(req.params.tag)}`
|
|
|
|
|
);
|
2026-10-02 09:53:35 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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.' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 23:12:51 +01:00
|
|
|
// ── 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) {
|
2026-10-01 01:13:30 +01:00
|
|
|
return errorPage(res, 502, 'Login unavailable', data?.message || 'Could not start the YOUreka login flow. Please try again.');
|
2026-09-30 23:12:51 +01:00
|
|
|
}
|
|
|
|
|
res.redirect(data.loginUrl);
|
|
|
|
|
} catch {
|
2026-10-01 01:13:30 +01:00
|
|
|
errorPage(res, 502, 'Login unavailable', 'Could not reach the YOUreka portal. Please try again.');
|
2026-09-30 23:12:51 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/auth/callback', async (req, res) => {
|
|
|
|
|
const { sessionId } = req.query;
|
2026-10-01 01:13:30 +01:00
|
|
|
if (!sessionId) return errorPage(res, 401, 'Login failed', 'Missing login session. Please start again.');
|
2026-09-30 23:12:51 +01:00
|
|
|
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) {
|
2026-10-01 01:13:30 +01:00
|
|
|
return errorPage(res, 401, 'Login failed', 'Authentication failed. The link may have expired — please try again.');
|
2026-09-30 23:12:51 +01:00
|
|
|
}
|
|
|
|
|
if (data.status === 'Initialized' || data.status === 'TimedOut') {
|
|
|
|
|
return errorPage(
|
|
|
|
|
res,
|
2026-10-01 01:13:30 +01:00
|
|
|
401,
|
2026-09-30 23:12:51 +01:00
|
|
|
'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) {
|
2026-10-01 01:13:30 +01:00
|
|
|
return errorPage(res, 401, 'Login failed', 'The portal did not return a usable session. Please try again.');
|
2026-09-30 23:12:51 +01:00
|
|
|
}
|
|
|
|
|
setSessionCookie(req, res, sessionValue);
|
|
|
|
|
setProfileCookie(req, res, {
|
|
|
|
|
firstName: data.firstName ?? '',
|
|
|
|
|
lastName: data.lastName ?? '',
|
|
|
|
|
phone: data.phone ?? '',
|
|
|
|
|
});
|
2026-10-01 14:39:50 +01:00
|
|
|
res.redirect('/');
|
2026-09-30 23:12:51 +01:00
|
|
|
} catch {
|
2026-10-01 01:13:30 +01:00
|
|
|
errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.');
|
2026-09-30 23:12:51 +01:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-01 03:28:31 +01:00
|
|
|
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.
|
|
|
|
|
*/
|
2026-10-01 03:53:24 +01:00
|
|
|
const renderPage = (res, file, session, active) => {
|
2026-10-01 03:28:31 +01:00
|
|
|
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')
|
2026-10-01 03:53:24 +01:00
|
|
|
.send(
|
2026-10-01 14:39:50 +01:00
|
|
|
versionAssets(
|
|
|
|
|
html
|
|
|
|
|
.replace('<!--app-header-->', appHeader(session, active))
|
|
|
|
|
.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`)
|
|
|
|
|
)
|
2026-10-01 03:53:24 +01:00
|
|
|
);
|
2026-10-01 03:28:31 +01:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-01 03:53:24 +01:00
|
|
|
|
|
|
|
|
|
2026-10-01 03:28:31 +01:00
|
|
|
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')
|
2026-10-01 14:39:50 +01:00
|
|
|
.send(
|
|
|
|
|
versionAssets(
|
|
|
|
|
html
|
|
|
|
|
.replace('<!--app-header-->', appHeader(req.session, 'home'))
|
|
|
|
|
.replace('<!--landing-view-->', req.session ? LANDING_TOOLS : LANDING_SIGNIN)
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-10-01 03:28:31 +01:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-01 14:39:50 +01:00
|
|
|
app.get('/help', (req, res) => {
|
|
|
|
|
renderPage(res, 'help.html', req.session, 'help');
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 23:12:51 +01:00
|
|
|
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) ────────────────────────────────────────
|
2026-10-01 03:28:31 +01:00
|
|
|
app.get('/fingerprint', requirePage, async (req, res, next) => {
|
|
|
|
|
try {
|
2026-10-01 03:53:24 +01:00
|
|
|
renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true }), 'fingerprint');
|
2026-10-01 03:28:31 +01:00
|
|
|
} catch (e) {
|
|
|
|
|
next(e);
|
|
|
|
|
}
|
2026-09-30 23:12:51 +01:00
|
|
|
});
|
|
|
|
|
|
2026-10-01 01:13:30 +01:00
|
|
|
// ── 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,
|
2026-10-01 01:35:37 +01:00
|
|
|
'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 }
|
2026-10-01 01:13:30 +01:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
next();
|
|
|
|
|
};
|
2026-10-01 03:28:31 +01:00
|
|
|
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
|
2026-10-01 04:36:31 +01:00
|
|
|
if (!cropperClients) {
|
|
|
|
|
return errorPage(
|
|
|
|
|
res,
|
|
|
|
|
503,
|
|
|
|
|
'Cropper unavailable',
|
|
|
|
|
'The image cropper is not configured on this deployment. Contact your administrator.'
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-01 03:53:24 +01:00
|
|
|
renderPage(
|
|
|
|
|
res,
|
|
|
|
|
'cropper/index.html',
|
|
|
|
|
{ phone: req.session.phone, firstName: req.session.firstName, lastName: req.session.lastName },
|
|
|
|
|
'cropper'
|
|
|
|
|
);
|
2026-10-01 01:13:30 +01:00
|
|
|
});
|
|
|
|
|
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
|
2026-10-01 04:36:31 +01:00
|
|
|
if (!cropperClients) {
|
|
|
|
|
const missing = CROPPER_CREDENTIALS.filter((name) => !process.env[name]);
|
|
|
|
|
console.warn(
|
|
|
|
|
`[cropper] not configured — missing ${missing.join(', ')}; /cropper will answer 503`
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-01 01:13:30 +01:00
|
|
|
if (cropperClients) {
|
|
|
|
|
app.use(
|
|
|
|
|
'/cropper',
|
|
|
|
|
createCropperRouter({
|
|
|
|
|
...cropperClients,
|
|
|
|
|
a2loftBase: cfg.a2loftBase,
|
2026-10-01 03:12:02 +01:00
|
|
|
domainCodes,
|
2026-10-01 01:13:30 +01:00
|
|
|
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.' })
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 20:41:43 +01:00
|
|
|
// ── a2loft proxies: same-origin, bearer token from the session ────────────
|
|
|
|
|
const a2loftUrl = (domain, reference) =>
|
|
|
|
|
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(reference)}`;
|
|
|
|
|
const a2loftSaveUrl = (domain, reference) =>
|
|
|
|
|
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(reference)}`;
|
2026-09-30 23:12:51 +01:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
|
|
|
|
|
if (!validateProxy(req, res)) return;
|
|
|
|
|
try {
|
2026-10-02 20:41:43 +01:00
|
|
|
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference), {
|
|
|
|
|
headers: { authorization: `Bearer ${req.session.token}` },
|
|
|
|
|
});
|
2026-09-30 23:12:51 +01:00
|
|
|
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 {
|
2026-10-02 20:41:43 +01:00
|
|
|
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference), {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'content-type': 'application/json',
|
|
|
|
|
authorization: `Bearer ${req.session.token}`,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(req.body),
|
2026-09-30 23:12:51 +01:00
|
|
|
});
|
|
|
|
|
res.status(r.status).type('json').send(await r.text());
|
|
|
|
|
} catch {
|
|
|
|
|
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-01 03:12:02 +01:00
|
|
|
// ── 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 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-30 23:12:51 +01:00
|
|
|
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}`);
|
|
|
|
|
});
|
|
|
|
|
}
|