source: Klonkt/src/middleware/render.js@ 70677e96

main
Last change on this file since 70677e96 was a7bcf66, checked in by Robin Genis <roboburr@…>, 6 weeks ago

De Guardian PWA liep twee uur achter

Op sound-fabrics staat de tijdzone op Europe/Amsterdam, maar een hulpvraag van
20:20 stond in de PWA als 18:20. Het dashboard bouwt zijn kaarten in de browser
en sneed de rauwe UTC-string af (slice(0,16)) in plaats van hem om te rekenen.
De server geeft de tijd nu geformatteerd mee, met dezelfde formatDateTime die de
Krant en Berichten gebruiken; het afsnijden blijft alleen als terugval staan.

In formatDateTime zat een tweede probleem, dat nog niet zichtbaar was. SQLite
schrijft CURRENT_TIMESTAMP als UTC zonder dat erbij te zeggen ("2026-07-28
18:20:33"), en new Date() leest een string in die vorm als LOKALE tijd. Dat gaat
goed zolang de machine op UTC staat, wat nu toevallig zo is. Zet de VPS ooit op
Amsterdam en elke opgeslagen datum in de hele app schuift twee uur op. De parser
zegt nu expliciet UTC.

Onderweg bleek Berichten en de PWA ook niet dezelfde tijd te tonen voor dezelfde
post: 20:12 tegenover 20:20. Berichten liet zien wanneer wij de post ontvingen,
de PWA en de Krant wanneer hij geschreven is. Berichten toont nu ook de
publicatietijd. Sorteren en de "nieuw sinds je laatste bezoek"-stip blijven op
de ontvangsttijd: een post die laat federeert is nog steeds nieuw voor jou.

Changed files:
src/middleware/render.js

  • parseStamp leest een tijdstempel zonder zone als UTC
  • formatDateTime geexporteerd voor oppervlakken buiten de EJS-pagina's

src/routes/guardian.js

  • when_text bij hulpvragen en bij de tijdlijn van de wards

src/assets/js/guardian.js

  • when() gebruikt dat veld, afsnijden alleen nog als terugval

src/services/ActivityPubService.js

  • getNotifications geeft published mee naast created_at

src/views/partials/msg-item.ejs

  • toont de publicatietijd, met created_at als terugval

New file:
test/timestamps.test.js

  • de tijdzone-instelling wordt toegepast, en een SQLite-tijdstempel hangt niet af van de tijdzone van de machine

remarks: geverifieerd op de wegwerp-database met Europe/Amsterdam: Berichten en
de PWA tonen allebei 28 jul 2026, 20:20 voor dezelfde post.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 17.5 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 const s = String(v);
100 const d = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(s)
101 ? new Date(`${s.replace(' ', 'T')}Z`)
102 : new Date(s);
103 return Number.isNaN(d.getTime()) ? null : d;
104};
105
106const formatDate = (iso) => {
107 const d = parseStamp(iso);
108 return d ? d.toLocaleDateString('nl-NL', { timeZone: siteTimezone(), day: 'numeric', month: 'long', year: 'numeric' }) : '';
109};
110
111/** A timestamp in the site's own timezone (Beheer → Instellingen). Exported so
112 * surfaces outside the EJS pages (the Guardian PWA) read the same clock. */
113export const formatDateTime = (iso) => {
114 const d = parseStamp(iso);
115 return d ? d.toLocaleString('nl-NL', { timeZone: siteTimezone(), dateStyle: 'medium', timeStyle: 'short' }) : '';
116};
117
118export async function renderPage(req, res, viewName, data = {}) {
119 // Decide: partial (HTMX) or full?
120 const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1';
121
122 // Prevent the browser from caching an htmx PARTIAL (only #pcms-main, without <head>/CSS)
123 // under the same URL and serving it as a full page on "back" → unstyled HTML.
124 // Vary: HX-Request separates partial and full responses in the cache;
125 // no-store on the partial forces "back" to always re-fetch the full page.
126 // (Vary also applies to intermediate caches / Cloudflare.)
127 res.setHeader('Vary', 'HX-Request');
128 // A full HTML page must always be revalidated so an online visitor gets the
129 // fresh site, never a heuristically-cached copy. no-cache (not no-store) still
130 // allows bfcache and conditional requests. Partials stay no-store (see above).
131 res.setHeader('Cache-Control', isPartial ? 'no-store' : 'no-cache');
132
133 // Does this (non-god) user own a site? Determines whether they see an "Admin"
134 // entry (artist self-manage). god always sees admin (by role).
135 let _u = req.session?.user || null;
136 // Refresh avatar + role from the DB so a stale session (e.g. after an
137 // avatar change or role switch) heals itself without a new login.
138 if (_u && _u.id) {
139 const _fresh = db.prepare('SELECT role, lang FROM users WHERE id = ?').get(_u.id);
140 if (_fresh) _u = { ..._u, role: _fresh.role, lang: _fresh.lang };
141 // ONE image: a user's avatar everywhere (nav, account, comments) is simply their SITE
142 // photo — there is no separate account avatar. Falls back to the initial-letter
143 // placeholder when the site has no photo yet.
144 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);
145 _u = { ..._u, avatar_url: (_sp && _sp.profile_photo) || null };
146 }
147 const userOwnsSite = !!(_u && _u.role !== 'god' &&
148 db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id));
149
150 const _site = data.site || res.locals.site || null;
151 // The site header uses the SITE photo (site.profile_photo) — the one and only image,
152 // set in site settings. (No separate account-avatar fallback anymore.)
153 const siteOwnerAvatar = null;
154
155 // Viewer mode: may view everything, change nothing. Views use canMutate
156 // to hide/disable write buttons (post, save, delete).
157 const _isViewer = isViewer(_u);
158
159 // Embeds are framed broadly (frame-src https: globally), EXCEPT on authorize_interaction:
160 // that page renders untrusted remote content next to the interact buttons, so lock its
161 // frame-src down to 'self' (no embeds → no clickjacking/overlay over the buttons).
162 if (viewName === 'pages/authorize-interaction') {
163 try {
164 const csp = res.getHeader('Content-Security-Policy');
165 if (csp) res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src [^;]*/i, "frame-src 'self'"));
166 } catch { /* best-effort */ }
167 }
168
169 // Who sees the "Admin" link? god/admin, a site owner (artist self-manage),
170 // and a viewer (may view Admin read-only). One source of truth,
171 // mirrored in topnav/hub-nav/profile sheet — otherwise the link gets hidden
172 // for those who should see it (viewer didn't see it anywhere before).
173 const _role = _u ? _u.role : null;
174 const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite));
175 // Who may use the fediverse client (timeline/notifications/blocking) — actual
176 // site managers only (these routes are requireSiteManager; viewers are excluded).
177 const canManageFedi = !!(_u && (_role === 'god' || _role === 'admin' || userOwnsSite));
178
179 // Interface language: session choice (this session) → logged-in user's own preference
180 // (users.lang) → admin-set default (Admin → Settings) → env → browser → nl.
181 const _lang = resolveLang(req, {
182 userLang: _u && _u.lang,
183 defaultLang: getSetting('default_lang'),
184 });
185
186 // Common locals
187 const locals = {
188 user: _u,
189 lang: _lang,
190 t: (key, vars) => i18nT(_lang, key, vars),
191 langs: LANGS.map((c) => ({ code: c, name: LANG_NAMES[c], active: c === _lang })),
192 timezone: getSetting('timezone') || '',
193 notifUnread: (canManageFedi && _site) ? ActivityPubService.countUnseenNotifications(_site.slug) : 0,
194 userOwnsSite,
195 canSeeBeheer,
196 canManageFedi,
197 apEnabled: apEnabled(),
198 // Cirkel = the artists you feature (auto-boost). Shown when AP is on and you
199 // auto-boost ≥1 account, or (legacy) on a circle-tenancy site.
200 hasCirkel: !!(_site && apEnabled() && (ActivityPubService.autoBoostCount(_site.slug) > 0 || ActivityPubService.boostedCount(_site.slug) > 0)),
201 isViewer: _isViewer,
202 canMutate: !_isViewer,
203 isPremium: isPremiumInstance(),
204 premiumEnabled: premiumEnabled(),
205 premiumUnlocked: premiumUnlocked(),
206 siteOwnerAvatar,
207 site: _site,
208 audioEnabled: audioFeatureEnabled(),
209 audioTracks: data.audioTracks || res.locals.audioTracks || [],
210 siteUrlBase: res.locals.siteUrlBase || '',
211 tenancy: res.locals.tenancy || 'solo',
212 hubTitle: getSetting('hub_title') || '',
213 footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
214 agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
215 platforms_catalog: PLATFORMS_CATALOG,
216 permissions: PermissionsService,
217 formatDate,
218 formatDateTime,
219 // Render the Shaer-native bits server-side so the web timeline matches the
220 // apps: FEP-9098 custom emojis in content/names, and the FEP-044f quote.
221 emojiHtml, // (html, emoji_json) → HTML with :shortcode: as <img>
222 emojiName, // (text, emoji_json) → escaped name with :shortcode: as <img>
223 noteQuote: parseQuote, // (quote_json) → the resolved quoted-post object or null
224 // Rewrite a local /media/<file> cover to its on-demand downscaled thumbnail
225 // (crisp grid/list images). External URLs + already-thumb URLs pass through.
226 thumb: (url, w) => {
227 if (!url || typeof url !== 'string') return url;
228 // Local cover → local thumb route; remote (federated) cover → signed downscale
229 // proxy (same as avatars), so remote line-art covers aren't browser-downscaled jagged.
230 if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 480}/${url.slice(7)}`;
231 if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 480);
232 return url;
233 },
234 // Crisp avatars: a local /media avatar goes through the local thumb route; a REMOTE
235 // (fediverse) avatar through the signed downscaling proxy. Same downscale, the remote
236 // one is just fetched first. Default 128px (covers feed 44px → profile ~120px).
237 avatar: (url, w) => {
238 if (!url || typeof url !== 'string') return url;
239 if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 128}/${url.slice(7)}`;
240 if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 128);
241 return url;
242 },
243 // pageTitleKey (translated with the resolved language) wins over a raw pageTitle string,
244 // so admin page titles aren't hardcoded in one language. Falls back to the site title.
245 pageTitle: (data.pageTitleKey ? i18nT(_lang, data.pageTitleKey, data.pageTitleVars) : data.pageTitle)
246 || (data.site && data.site.title) || 'Klonkt',
247 appVersion: APP_VERSION,
248 bodyClass: data.bodyClass || 'on-home',
249 socialDescr: data.socialDescr || '',
250 socialImage: data.socialImage || '',
251 cspNonce: () => '',
252 currentPath: req.path,
253 // Absolute origin (for building absolute URLs like the generated og:image).
254 ogOrigin: (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host') || ''}`).replace(/\/+$/, ''),
255 ...data,
256 };
257
258 try {
259 // Step 1: Render the page view to HTML
260 const viewPath = path.join(VIEWS_DIR, viewName + '.ejs');
261 const pageContent = await ejs.renderFile(viewPath, locals, { async: false });
262
263 if (isPartial) {
264 // A "Load more" append (hx-swap=beforeend into a sub-list) is NOT a
265 // navigation: it only adds rows to the existing page. It must NOT touch
266 // the site chrome or the body class. Emitting the nav HX-Trigger + OOB
267 // chrome here (below) rebuilds the header for the DEFAULT bodyClass —
268 // which, on a 'on-special' page like Messages, swaps in the full Klonkt
269 // header that the page had hidden. So for an append, send content only.
270 if (req.query.append === '1') {
271 return res.send(injectCspNonce(pageContent, res.locals.cspNonce));
272 }
273 // HTMX: just send the content. Set HX-Trigger for body class swap.
274 // HTTP-header values are Latin-1 only — a title with an em-dash, smart
275 // quote or emoji (e.g. "Welkom — gebouwd met Klonkt") would make
276 // setHeader throw ERR_INVALID_CHAR and 500 the partial, so the card
277 // looks "unclickable". Escape any non-ASCII to \uXXXX: the header stays
278 // ASCII-safe and remains valid JSON that htmx parses back unchanged.
279 // Per-site accent + palette live in the shell <head> (style#pcms-site-accent
280 // + html[data-palette]) and are NOT swapped during htmx navigation. Send them along
281 // so the client updates them — otherwise an artist inherits the previous page's
282 // colours (e.g. hub-purple instead of their own green). Same derivation as shell.ejs.
283 const _navAccent = (_site && _site.accent && /^#[0-9a-fA-F]{6}$/.test(_site.accent))
284 ? _site.accent : '#e8b04b';
285 const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
286 const triggerJson = JSON.stringify({
287 pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette },
288 pcmsPostSwap: data.post ? {
289 title: data.post.title,
290 slug: data.post.slug,
291 pageTitle: locals.pageTitle,
292 } : null,
293 }).replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
294 res.setHeader('HX-Trigger-After-Settle', triggerJson);
295 // Render the site chrome out-of-band so the header (topnav/profile header/
296 // view-switcher) ALWAYS matches the new page/artist on navigation —
297 // while the audio player (separate in document.body) keeps playing (no
298 // interruption). htmx replaces #pcms-chrome via hx-swap-oob. Non-critical:
299 // if it fails, the old chrome remains (no crash).
300 let oobChrome = '';
301 try {
302 oobChrome = await ejs.renderFile(
303 path.join(VIEWS_DIR, 'partials', 'chrome.ejs'),
304 { ...locals, oob: true },
305 { async: false },
306 );
307 } catch (e) { /* skip chrome OOB */ }
308 return res.send(injectCspNonce(pageContent + oobChrome, res.locals.cspNonce));
309 }
310
311 // Full: wrap content in shell (rendered to a string so we can inject the CSP nonce).
312 locals.pageContent = pageContent;
313 const shellHtml = await ejs.renderFile(path.join(VIEWS_DIR, 'shell.ejs'), locals, { async: false });
314 res.send(injectCspNonce(shellHtml, res.locals.cspNonce));
315 } catch (err) {
316 console.error('[renderPage] Error rendering', viewName, err);
317 if (process.env.NODE_ENV === 'production') {
318 return res.status(500).send('Internal Server Error');
319 }
320 // Dev: surface the underlying cause prominently. EJS rewrites err.message
321 // to include the file/line/code-context, so we also surface name+stack
322 // separately in case the message was truncated or empty.
323 const escape = (s) => String(s == null ? '' : s)
324 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
325 res.status(500).send(`<!doctype html>
326<meta charset="utf-8">
327<title>Render error: ${escape(viewName)}</title>
328<style>
329 body { font: 14px/1.5 ui-monospace, monospace; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; background:#1a1a1a; color:#eee; }
330 h1 { color:#dc2626; font-family: ui-sans-serif, system-ui; }
331 h2 { color:#fb923c; font-size:1rem; margin-top:1.5rem; }
332 pre { background:#0a0a0a; border:1px solid #333; border-radius:6px; padding:1rem; overflow:auto; white-space:pre-wrap; word-break:break-word; }
333 .cause { background:#3d0a0a; border-color:#7a1a1a; color:#fca5a5; font-weight:600; }
334</style>
335<h1>Render error in ${escape(viewName)}</h1>
336<h2>Cause</h2>
337<pre class="cause">${escape(err.name || 'Error')}: ${escape(err.message || '(no message)')}</pre>
338<h2>Stack</h2>
339<pre>${escape(err.stack || '(no stack)')}</pre>
340${err.path ? `<h2>File</h2><pre>${escape(err.path)}</pre>` : ''}
341`);
342 }
343}
Note: See TracBrowser for help on using the repository browser.