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:
+64
-5
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user