de5ab9186c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Add English versions of the privacy policy, EULA and offer (ui/legal/*_en.md) and render each legal page as one self-contained bilingual document: both language bodies plus a header language toggle and theme toggle (no back), a small inline ES5 script that switches language client-side (no reload, default Russian + persisted) and applies the theme (system default + persisted override) — mirroring the landing. The offer's English view transliterates the Russian product names spliced from the live catalog. renderLegalHtml now takes both language sources; renderOffer splices the price list into both; the renderer bakes and reads the _en sources and pre-renders the static pages at boot. Also wrap the /offer/ contour probe in the same retry+timeout as the legal probe (the offer page is now bilingual and larger). Docs + renderer/ui tests updated.
122 lines
5.8 KiB
JavaScript
122 lines
5.8 KiB
JavaScript
// The render sidecar: a minimal internal HTTP service on skia-canvas and the shared ui/src/lib
|
|
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
|
|
//
|
|
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
|
|
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
|
|
// price list spliced in; caddy routes /offer/ here)
|
|
// GET /privacy/ → text/html (the privacy policy: ui/legal/privacy_ru.md, static)
|
|
// GET /eula/ → text/html (the EULA: ui/legal/eula_ru.md, static)
|
|
// GET /offer, /privacy, /eula → 301 to the trailing-slash form
|
|
// GET /healthz → 200
|
|
//
|
|
// /render is internal-only (the backend calls it; authentication, participant checks and the signed
|
|
// public URL live in the backend). The legal pages (/offer/, /privacy/, /eula/) are reachable from the
|
|
// edge but read-only and unprivileged: /offer/ splices the price list fetched from the backend's
|
|
// internal endpoint into the committed offer markdown; /privacy/ and /eula/ are static committed
|
|
// markdown. No user input reaches any of them.
|
|
import { createServer } from 'node:http';
|
|
import { readFileSync } from 'node:fs';
|
|
import { renderRequest } from './render.mjs';
|
|
import { renderOffer } from './offer.mjs';
|
|
import { renderPrivacy, renderEula } from './legal.mjs';
|
|
|
|
const PORT = Number(process.env.RENDERER_PORT || 8090);
|
|
// A render request is a finished game's journal — generously capped.
|
|
const MAX_BODY = 2 * 1024 * 1024;
|
|
|
|
// Each legal page is bilingual (RU + EN); both markdown sources are baked into the image
|
|
// (renderer/Dockerfile) and read once at boot. The offer's price list is the only dynamic part
|
|
// (fetched per request from the backend's internal endpoint and spliced in); privacy + eula carry no
|
|
// dynamic data. The RENDERER_*_MD / RENDERER_*_EN_MD env vars override the baked paths for a local run.
|
|
const rd = (p) => readFileSync(p, 'utf8');
|
|
const OFFER_MD = rd(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url));
|
|
const OFFER_EN_MD = rd(process.env.RENDERER_OFFER_EN_MD || new URL('../legal/offer_en.md', import.meta.url));
|
|
const PRIVACY_MD = rd(process.env.RENDERER_PRIVACY_MD || new URL('../legal/privacy_ru.md', import.meta.url));
|
|
const PRIVACY_EN_MD = rd(process.env.RENDERER_PRIVACY_EN_MD || new URL('../legal/privacy_en.md', import.meta.url));
|
|
const EULA_MD = rd(process.env.RENDERER_EULA_MD || new URL('../legal/eula_ru.md', import.meta.url));
|
|
const EULA_EN_MD = rd(process.env.RENDERER_EULA_EN_MD || new URL('../legal/eula_en.md', import.meta.url));
|
|
// Render the static legal pages once at boot and serve the cached HTML: re-parsing the large EULA on
|
|
// every request is slow enough under deploy-time host contention (the renderer is CPU/memory-capped)
|
|
// to flake the contour probe. The offer stays per-request because it splices in the live price list.
|
|
const PRIVACY_HTML = renderPrivacy(PRIVACY_MD, PRIVACY_EN_MD);
|
|
const EULA_HTML = renderEula(EULA_MD, EULA_EN_MD);
|
|
const OFFER_PRICING_URL =
|
|
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
|
|
|
|
// fetchOfferPricing returns the two catalog price tables as markdown from the backend, or throws a
|
|
// 502 (upstream error) / 504 (timeout) so the offer never renders with a broken price list.
|
|
async function fetchOfferPricing() {
|
|
const resp = await fetch(OFFER_PRICING_URL, { signal: AbortSignal.timeout(5000) });
|
|
if (!resp.ok) {
|
|
throw Object.assign(new Error(`offer pricing upstream ${resp.status}`), { status: 502 });
|
|
}
|
|
return resp.text();
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
let size = 0;
|
|
req.on('data', (c) => {
|
|
size += c.length;
|
|
if (size > MAX_BODY) {
|
|
reject(Object.assign(new Error('body too large'), { status: 413 }));
|
|
req.destroy();
|
|
return;
|
|
}
|
|
chunks.push(c);
|
|
});
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
const path = (req.url || '').split('?')[0];
|
|
if (req.method === 'GET' && path === '/healthz') {
|
|
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
|
|
return;
|
|
}
|
|
if (req.method === 'GET' && path === '/offer') {
|
|
res.writeHead(301, { location: '/offer/' }).end();
|
|
return;
|
|
}
|
|
if (req.method === 'GET' && path === '/offer/') {
|
|
const html = renderOffer(OFFER_MD, OFFER_EN_MD, await fetchOfferPricing());
|
|
res
|
|
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
|
.end(html);
|
|
return;
|
|
}
|
|
if (req.method === 'GET' && (path === '/privacy' || path === '/eula')) {
|
|
res.writeHead(301, { location: path + '/' }).end();
|
|
return;
|
|
}
|
|
if (req.method === 'GET' && path === '/privacy/') {
|
|
res
|
|
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
|
.end(PRIVACY_HTML);
|
|
return;
|
|
}
|
|
if (req.method === 'GET' && path === '/eula/') {
|
|
res
|
|
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
|
.end(EULA_HTML);
|
|
return;
|
|
}
|
|
if (req.method === 'POST' && path === '/render') {
|
|
const png = await renderRequest(JSON.parse(await readBody(req)));
|
|
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
|
|
return;
|
|
}
|
|
res.writeHead(404).end();
|
|
} catch (err) {
|
|
const status = err.status || (err instanceof SyntaxError ? 400 : 500);
|
|
console.error(`render error: ${err.message}`);
|
|
res.writeHead(status, { 'content-type': 'text/plain' }).end('render failed');
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => console.log(`renderer listening on :${PORT}`));
|