source: Klonkt/src/middleware/render.js@ e64a49b

main
Last change on this file since e64a49b was e64a49b, checked in by roboburr <roboburr@…>, 5 weeks ago

Berichten uit inline script naar een module (shaer-bqr, stap 2)

De enige bevestigde stukke plek. Kwam je hier via een link BINNEN de site, dan
arriveerde het script via htmx met een nonce die het document niet kent, en
weigerde de CSP het (shaer-0i6). Chips, zoeken en het in-/uitklappen deden dan
niets, en de reply-editor laadde niet -- dat was Barts melding.

Een pagina vraagt nu om zijn module met pageJs; de shell zet dat op body[data-js]
en de bootstrap importeert het. De waarde wordt een PAD, dus hij gaat door
/[a-z0-9 -]*$/ voordat hij de locals in mag.

TWEE DINGEN MOESTEN VERANDEREN, en niet omdat de code fout was: een module leeft
anders dan een inline script.

NIETS VASTHOUDEN het inline script pakte .msg-list, #msg-q en .msg-nomatch

een keer bij het inladen. Een module wordt per document maar
EEN keer geimporteerd, dus wie Berichten verlaat en
terugkomt houdt verwijzingen over naar elementen die er niet
meer zijn. Nu per keer opzoeken, en de invoerluisteraar
gedelegeerd in plaats van op het veld zelf.

OPNIEUW INDEXEREN bij binnenkomst op de pagina, niet alleen bij "meer laden".

Anders is de lijst na een navigatie niet geindexeerd en
filtert het zoeken op een lege verzameling.

Dat eerste is precies de val waar de chrome-partials al een comment over hebben
staan ("a once-captured reference goes stale"), en het geldt hier dubbel omdat
een module niet opnieuw draait.

Templates compileren, suite 556/556.

  • Property mode set to 100644
File size: 18.2 KB
Line 
1/**
2 * Render helper — THE pattern for the entire app.
3 *
4 * Two modes:
5 * 1. HTMX request → render just the page content (no shell)
6 * 2. Full page request → render content, then embed in shell
7 *
8 * Usage:
9 * renderPage(req, res, 'pages/home', { posts, ...data })
10 */
11
12import fs from 'fs';
13import path from 'path';
14import { fileURLToPath } from 'url';
15import ejs from 'ejs';
16import db from '../config/database.js';
17import PermissionsService from '../services/PermissionsService.js';
18import { isViewer } from './auth.js';
19import { getSetting, apEnabled } from '../services/SettingsService.js';
20import { isPremium as isPremiumInstance, premiumEnabled, premiumUnlocked } from '../services/PatreonService.js';
21import { emojiHtml, emojiName, parseQuote } from '../services/NoteRender.js';
22import ActivityPubService from '../services/ActivityPubService.js';
23import { imgProxyUrl } from '../services/ThumbnailService.js';
24import { audioEnabled as audioFeatureEnabled } from '../config/features.js';
25
26// Add the per-request CSP nonce to every <script> tag that doesn't already have one, so the
27// strict script-src (nonce + 'strict-dynamic') allows them — including scripts in htmx
28// partials. HTML-escaped "&lt;script" in rendered content (e.g. sanitized post bodies) won't
29// match, so this only touches real tags.
30export function injectCspNonce(html, nonce) {
31 if (!html || !nonce) return html;
32 return String(html).replace(/<script(?![^>]*\snonce=)/gi, () => `<script nonce="${nonce}"`);
33}
34import { PLATFORMS as PLATFORMS_CATALOG } from '../services/PlatformIcons.js';
35import { t as i18nT, resolveLang, SUPPORTED as LANGS, LANG_NAMES } from '../services/i18n.js';
36
37const __dirname = path.dirname(fileURLToPath(import.meta.url));
38const VIEWS_DIR = path.join(__dirname, '..', 'views');
39
40/**
41 * Render a post body to an HTML string with the SAME partial de Krant uses.
42 *
43 * For surfaces that are not an EJS page: the Guardian PWA builds its cards in
44 * the browser, so it gets the finished HTML in its state blob instead of the
45 * raw columns. One renderer, so a post cannot drift into looking different
46 * depending on where you run into it.
47 */
48export function renderNoteBody(nb, lang) {
49 if (!nb || !nb.content) return '';
50 const _l = lang || 'nl';
51 // ejs.renderFile hands back a Promise even with async:false, and the callers
52 // here are plain synchronous route code. Compile the file ourselves instead;
53 // `filename` is what lets the partial's own relative includes resolve.
54 const file = path.join(VIEWS_DIR, 'partials', 'note-body.ejs');
55 try {
56 return ejs.render(fs.readFileSync(file, 'utf8'), {
57 nb,
58 t: (key, vars) => i18nT(_l, key, vars),
59 emojiHtml,
60 emojiName,
61 noteQuote: parseQuote,
62 thumb: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 480) : url),
63 avatar: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 128) : url),
64 }, { filename: file, async: false });
65 } catch (e) {
66 console.warn('[render] note body failed:', e.message);
67 return '';
68 }
69}
70
71// App version (from package.json) + short commit hash (from .klonkt-version, written by
72// the deploy script) — shown in the footer next to "Klonkt Beta". The hash is updated
73// automatically on every deploy, so the displayed version is never stale.
74let APP_VERSION = '';
75try {
76 APP_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')).version || '';
77 try {
78 const sha = fs.readFileSync(path.join(__dirname, '..', '..', '.klonkt-version'), 'utf8').trim().slice(0, 7);
79 if (sha) APP_VERSION += ' · ' + sha;
80 } catch { /* no .klonkt-version (local dev) */ }
81} catch { /* no version available */ }
82
83// Site timezone (Admin → Settings). Empty = server default (UTC). Applied to
84// all server-side formatted dates so they display in the site's timezone instead of UTC.
85const siteTimezone = () => getSetting('timezone') || undefined;
86
87/**
88 * Read a stored timestamp as the moment it actually is.
89 *
90 * SQLite's CURRENT_TIMESTAMP writes UTC without saying so ("2026-07-28
91 * 18:20:33"), and new Date() reads a string in that shape as LOCAL time. That
92 * is right only as long as the server runs on UTC; set the machine to
93 * Europe/Amsterdam and every stored date silently shifts two hours. So say UTC
94 * out loud. Anything already carrying a zone (AP `published` ends in Z) is left
95 * to the normal parser.
96 */
97const parseStamp = (v) => {
98 if (!v) return null;
99 if (typeof v === 'number') { // epoch ms (e.g. an availability endTime)
100 const d = new Date(v);
101 return Number.isNaN(d.getTime()) ? null : d;
102 }
103 const s = String(v);
104 const d = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(s)
105 ? new Date(`${s.replace(' ', 'T')}Z`)
106 : new Date(s);
107 return Number.isNaN(d.getTime()) ? null : d;
108};
109
110const formatDate = (iso) => {
111 const d = parseStamp(iso);
112 return d ? d.toLocaleDateString('nl-NL', { timeZone: siteTimezone(), day: 'numeric', month: 'long', year: 'numeric' }) : '';
113};
114
115/** A timestamp in the site's own timezone (Beheer → Instellingen). Exported so
116 * surfaces outside the EJS pages (the Guardian PWA) read the same clock. */
117export const formatDateTime = (iso) => {
118 const d = parseStamp(iso);
119 return d ? d.toLocaleString('nl-NL', { timeZone: siteTimezone(), dateStyle: 'medium', timeStyle: 'short' }) : '';
120};
121
122export async function renderPage(req, res, viewName, data = {}) {
123 // Decide: partial (HTMX) or full?
124 const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1';
125
126 // Prevent the browser from caching an htmx PARTIAL (only #pcms-main, without <head>/CSS)
127 // under the same URL and serving it as a full page on "back" → unstyled HTML.
128 // Vary: HX-Request separates partial and full responses in the cache;
129 // no-store on the partial forces "back" to always re-fetch the full page.
130 // (Vary also applies to intermediate caches / Cloudflare.)
131 res.setHeader('Vary', 'HX-Request');
132 // A full HTML page must always be revalidated so an online visitor gets the
133 // fresh site, never a heuristically-cached copy. no-cache (not no-store) still
134 // allows bfcache and conditional requests. Partials stay no-store (see above).
135 res.setHeader('Cache-Control', isPartial ? 'no-store' : 'no-cache');
136
137 // Does this (non-god) user own a site? Determines whether they see an "Admin"
138 // entry (artist self-manage). god always sees admin (by role).
139 let _u = req.session?.user || null;
140 // Refresh avatar + role from the DB so a stale session (e.g. after an
141 // avatar change or role switch) heals itself without a new login.
142 if (_u && _u.id) {
143 const _fresh = db.prepare('SELECT role, lang FROM users WHERE id = ?').get(_u.id);
144 if (_fresh) _u = { ..._u, role: _fresh.role, lang: _fresh.lang };
145 // ONE image: a user's avatar everywhere (nav, account, comments) is simply their SITE
146 // photo — there is no separate account avatar. Falls back to the initial-letter
147 // placeholder when the site has no photo yet.
148 const _sp = db.prepare("SELECT profile_photo FROM sites WHERE owner_id = ? AND profile_photo IS NOT NULL ORDER BY is_primary DESC, created_at ASC LIMIT 1").get(_u.id);
149 _u = { ..._u, avatar_url: (_sp && _sp.profile_photo) || null };
150 }
151 const userOwnsSite = !!(_u && _u.role !== 'god' &&
152 db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id));
153
154 const _site = data.site || res.locals.site || null;
155 // The site header uses the SITE photo (site.profile_photo) — the one and only image,
156 // set in site settings. (No separate account-avatar fallback anymore.)
157 const siteOwnerAvatar = null;
158
159 // Viewer mode: may view everything, change nothing. Views use canMutate
160 // to hide/disable write buttons (post, save, delete).
161 const _isViewer = isViewer(_u);
162
163 // Embeds are framed broadly (frame-src https: globally), EXCEPT on authorize_interaction:
164 // that page renders untrusted remote content next to the interact buttons, so lock its
165 // frame-src down to 'self' (no embeds → no clickjacking/overlay over the buttons).
166 if (viewName === 'pages/authorize-interaction') {
167 try {
168 const csp = res.getHeader('Content-Security-Policy');
169 if (csp) res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src [^;]*/i, "frame-src 'self'"));
170 } catch { /* best-effort */ }
171 }
172
173 // Who sees the "Admin" link? god/admin, a site owner (artist self-manage),
174 // and a viewer (may view Admin read-only). One source of truth,
175 // mirrored in topnav/hub-nav/profile sheet — otherwise the link gets hidden
176 // for those who should see it (viewer didn't see it anywhere before).
177 const _role = _u ? _u.role : null;
178 const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite));
179 // Who may use the fediverse client (timeline/notifications/blocking) — actual
180 // site managers only (these routes are requireSiteManager; viewers are excluded).
181 const canManageFedi = !!(_u && (_role === 'god' || _role === 'admin' || userOwnsSite));
182
183 // Interface language: session choice (this session) → logged-in user's own preference
184 // (users.lang) → admin-set default (Admin → Settings) → env → browser → nl.
185 const _lang = resolveLang(req, {
186 userLang: _u && _u.lang,
187 defaultLang: getSetting('default_lang'),
188 });
189
190 // Common locals
191 const locals = {
192 user: _u,
193 lang: _lang,
194 t: (key, vars) => i18nT(_lang, key, vars),
195 langs: LANGS.map((c) => ({ code: c, name: LANG_NAMES[c], active: c === _lang })),
196 timezone: getSetting('timezone') || '',
197 notifUnread: (canManageFedi && _site) ? ActivityPubService.countUnseenNotifications(_site.slug) : 0,
198 userOwnsSite,
199 canSeeBeheer,
200 canManageFedi,
201 apEnabled: apEnabled(),
202 // Cirkel = the artists you feature (auto-boost): shown when AP is on and
203 // you auto-boost at least one account.
204 hasCirkel: !!(_site && apEnabled() && (ActivityPubService.autoBoostCount(_site.slug) > 0 || ActivityPubService.boostedCount(_site.slug) > 0)),
205 isViewer: _isViewer,
206 canMutate: !_isViewer,
207 isPremium: isPremiumInstance(),
208 premiumEnabled: premiumEnabled(),
209 premiumUnlocked: premiumUnlocked(),
210 siteOwnerAvatar,
211 site: _site,
212 audioEnabled: audioFeatureEnabled(),
213 audioTracks: data.audioTracks || res.locals.audioTracks || [],
214 siteUrlBase: res.locals.siteUrlBase || '',
215 footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
216 agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
217 platforms_catalog: PLATFORMS_CATALOG,
218 permissions: PermissionsService,
219 formatDate,
220 formatDateTime,
221 // Render the Shaer-native bits server-side so the web timeline matches the
222 // apps: FEP-9098 custom emojis in content/names, and the FEP-044f quote.
223 emojiHtml, // (html, emoji_json) → HTML with :shortcode: as <img>
224 emojiName, // (text, emoji_json) → escaped name with :shortcode: as <img>
225 noteQuote: parseQuote, // (quote_json) → the resolved quoted-post object or null
226 // Rewrite a local /media/<file> cover to its on-demand downscaled thumbnail
227 // (crisp grid/list images). External URLs + already-thumb URLs pass through.
228 thumb: (url, w) => {
229 if (!url || typeof url !== 'string') return url;
230 // Local cover → local thumb route; remote (federated) cover → signed downscale
231 // proxy (same as avatars), so remote line-art covers aren't browser-downscaled jagged.
232 if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 480}/${url.slice(7)}`;
233 if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 480);
234 return url;
235 },
236 // Crisp avatars: a local /media avatar goes through the local thumb route; a REMOTE
237 // (fediverse) avatar through the signed downscaling proxy. Same downscale, the remote
238 // one is just fetched first. Default 128px (covers feed 44px → profile ~120px).
239 avatar: (url, w) => {
240 if (!url || typeof url !== 'string') return url;
241 if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 128}/${url.slice(7)}`;
242 if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 128);
243 return url;
244 },
245 // pageTitleKey (translated with the resolved language) wins over a raw pageTitle string,
246 // so admin page titles aren't hardcoded in one language. Falls back to the site title.
247 pageTitle: (data.pageTitleKey ? i18nT(_lang, data.pageTitleKey, data.pageTitleVars) : data.pageTitle)
248 || (data.site && data.site.title) || 'Klonkt',
249 appVersion: APP_VERSION,
250 bodyClass: data.bodyClass || 'on-home',
251 // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
252 // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
253 // Alleen kleine letters, cijfers, streepjes en spaties -- de naam wordt een
254 // pad.
255 pageJs: /^[a-z0-9 -]*$/.test(String(data.pageJs || '')) ? (data.pageJs || '') : '',
256 socialDescr: data.socialDescr || '',
257 socialImage: data.socialImage || '',
258 cspNonce: () => '',
259 currentPath: req.path,
260 // Absolute origin (for building absolute URLs like the generated og:image).
261 ogOrigin: (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host') || ''}`).replace(/\/+$/, ''),
262 ...data,
263 };
264
265 try {
266 // Step 1: Render the page view to HTML
267 const viewPath = path.join(VIEWS_DIR, viewName + '.ejs');
268 const pageContent = await ejs.renderFile(viewPath, locals, { async: false });
269
270 if (isPartial) {
271 // A "Load more" append (hx-swap=beforeend into a sub-list) is NOT a
272 // navigation: it only adds rows to the existing page. It must NOT touch
273 // the site chrome or the body class. Emitting the nav HX-Trigger + OOB
274 // chrome here (below) rebuilds the header for the DEFAULT bodyClass —
275 // which, on a 'on-special' page like Messages, swaps in the full Klonkt
276 // header that the page had hidden. So for an append, send content only.
277 if (req.query.append === '1') {
278 return res.send(injectCspNonce(pageContent, res.locals.cspNonce));
279 }
280 // HTMX: just send the content. Set HX-Trigger for body class swap.
281 // HTTP-header values are Latin-1 only — a title with an em-dash, smart
282 // quote or emoji (e.g. "Welkom — gebouwd met Klonkt") would make
283 // setHeader throw ERR_INVALID_CHAR and 500 the partial, so the card
284 // looks "unclickable". Escape any non-ASCII to \uXXXX: the header stays
285 // ASCII-safe and remains valid JSON that htmx parses back unchanged.
286 // Per-site accent + palette live in the shell <head> (style#pcms-site-accent
287 // + html[data-palette]) and are NOT swapped during htmx navigation. Send them along
288 // so the client updates them — otherwise an artist inherits the previous page's
289 // colours (e.g. hub-purple instead of their own green). Same derivation as shell.ejs.
290 const _navAccent = (_site && _site.accent && /^#[0-9a-fA-F]{6}$/.test(_site.accent))
291 ? _site.accent : '#e8b04b';
292 const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
293 // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell
294 // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er
295 // altijd bij, want die komt bij elke navigatie opnieuw binnen.
296 const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
297 const triggerJson = JSON.stringify({
298 pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
299 pcmsPostSwap: data.post ? {
300 title: data.post.title,
301 slug: data.post.slug,
302 pageTitle: locals.pageTitle,
303 } : null,
304 }).replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
305 res.setHeader('HX-Trigger-After-Settle', triggerJson);
306 // Render the site chrome out-of-band so the header (topnav/profile header/
307 // view-switcher) ALWAYS matches the new page/artist on navigation —
308 // while the audio player (separate in document.body) keeps playing (no
309 // interruption). htmx replaces #pcms-chrome via hx-swap-oob. Non-critical:
310 // if it fails, the old chrome remains (no crash).
311 let oobChrome = '';
312 try {
313 oobChrome = await ejs.renderFile(
314 path.join(VIEWS_DIR, 'partials', 'chrome.ejs'),
315 { ...locals, oob: true },
316 { async: false },
317 );
318 } catch (e) { /* skip chrome OOB */ }
319 return res.send(injectCspNonce(pageContent + oobChrome, res.locals.cspNonce));
320 }
321
322 // Full: wrap content in shell (rendered to a string so we can inject the CSP nonce).
323 locals.pageContent = pageContent;
324 const shellHtml = await ejs.renderFile(path.join(VIEWS_DIR, 'shell.ejs'), locals, { async: false });
325 res.send(injectCspNonce(shellHtml, res.locals.cspNonce));
326 } catch (err) {
327 console.error('[renderPage] Error rendering', viewName, err);
328 if (process.env.NODE_ENV === 'production') {
329 return res.status(500).send('Internal Server Error');
330 }
331 // Dev: surface the underlying cause prominently. EJS rewrites err.message
332 // to include the file/line/code-context, so we also surface name+stack
333 // separately in case the message was truncated or empty.
334 const escape = (s) => String(s == null ? '' : s)
335 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
336 res.status(500).send(`<!doctype html>
337<meta charset="utf-8">
338<title>Render error: ${escape(viewName)}</title>
339<style>
340 body { font: 14px/1.5 ui-monospace, monospace; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; background:#1a1a1a; color:#eee; }
341 h1 { color:#dc2626; font-family: ui-sans-serif, system-ui; }
342 h2 { color:#fb923c; font-size:1rem; margin-top:1.5rem; }
343 pre { background:#0a0a0a; border:1px solid #333; border-radius:6px; padding:1rem; overflow:auto; white-space:pre-wrap; word-break:break-word; }
344 .cause { background:#3d0a0a; border-color:#7a1a1a; color:#fca5a5; font-weight:600; }
345</style>
346<h1>Render error in ${escape(viewName)}</h1>
347<h2>Cause</h2>
348<pre class="cause">${escape(err.name || 'Error')}: ${escape(err.message || '(no message)')}</pre>
349<h2>Stack</h2>
350<pre>${escape(err.stack || '(no stack)')}</pre>
351${err.path ? `<h2>File</h2><pre>${escape(err.path)}</pre>` : ''}
352`);
353 }
354}
Note: See TracBrowser for help on using the repository browser.