refactor(ui): inject the session server-side instead of fetching it client-side

The pages are rendered by the server, which already holds the session — so
/fingerprint, /cropper and the landing now embed window.__SESSION__ (JSON,
with '<' escaped) instead of each fetching /auth/session on load:

- /fingerprint gets token, identity, agentUrl and the permitted domains
  (the same cached context the endpoint returned); /cropper gets identity
- landing nav is now server-rendered (Sign in vs user chip + Sign out);
  its client fetch script is deleted
- per-user HTML is sent with Cache-Control: no-store and Vary: Cookie
- express.static runs with index:false so '/' can be session-aware while
  assets stay session-free
- GET /auth/session remains as the documented JSON contract (and the
  canonical place the domain-filter caching is exercised in tests)

Client code shrinks by a fetch + error path per page; 3 new tests
(injection, escaping, landing header) — 43 total.
This commit is contained in:
2026-10-01 03:28:31 +01:00
parent 431a452d52
commit 22735c0709
6 changed files with 137 additions and 60 deletions
+64 -5
View File
@@ -8,6 +8,7 @@
// 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';
@@ -160,7 +161,7 @@ export function createApp(overrides = {}) {
});
// ── static assets (no session work per asset) ─────────────────────────────
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.static(path.join(__dirname, 'public'), { index: false })); // assets only; '/' is session-aware
// ── session middleware: recover / refresh the YOUreka Auth session ────────
app.use(async (req, res, next) => {
@@ -277,6 +278,56 @@ export function createApp(overrides = {}) {
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) => {
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(html.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);
const nav = req.session
? `<span class="user-chip"><span class="avatar">${esc((req.session.firstName || req.session.phone || '?').trim().slice(0, 2).toUpperCase())}</span>${esc(req.session.firstName || req.session.phone || '')}</span><a class="linklike" href="/auth/logout">Sign out</a>`
: '<a class="linklike" href="/auth/login">Sign in</a>';
res
.set('Cache-Control', 'no-store')
.set('Vary', 'Cookie')
.type('html')
.send(html.replace('<!--session-nav-->', nav));
});
});
app.get('/auth/session', requireApi, async (req, res, next) => {
try {
const { codes, checkedAt } = await domainContext(req);
@@ -310,8 +361,12 @@ export function createApp(overrides = {}) {
});
// ── wizard page (session required) ────────────────────────────────────────
app.get('/fingerprint', requirePage, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html'));
app.get('/fingerprint', requirePage, async (req, res, next) => {
try {
renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true }));
} catch (e) {
next(e);
}
});
// ── cropper page + namespaced API (session + crop_image required) ────────
@@ -343,8 +398,12 @@ export function createApp(overrides = {}) {
}
next();
};
app.get('/cropper', requirePage, pageCropGate, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'assets', 'cropper', 'index.html'));
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
renderPage(res, 'cropper/index.html', {
phone: req.session.phone,
firstName: req.session.firstName,
lastName: req.session.lastName,
});
});
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
if (cropperClients) {