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

main
Last change on this file since d9ad6c5 was d9ad6c5, checked in by Robin Genis <roboburr@โ€ฆ>, 6 weeks ago

Een hulpvraag hoort in Berichten, niet in de Krant

Een ๐Ÿ›Ÿ van een ward kwam bij de guardian op twee plekken binnen: als mention in
Berichten en de Guardian PWA, maar ook als gewone post in de Krant. Op
sound-fabrics.com stonden vijf van de zes hulpvragen in allebei.

De oorzaak zat in de inbox: de tijdlijn-insert vroeg alleen "is dit een
top-level post van iemand die ik volg" en keek niet naar wie de post geadresseerd
was. Dat is nu belongsInTimeline: een directe note is aan iemand persoonlijk
gericht, dus een bericht en geen post. Dat dekt meteen de wave en de gewone DM,
die om dezelfde reden in de Krant terechtkwamen. De self-heal ruimt de al
opgeslagen exemplaren op, beperkt tot de twee soorten die achteraf nog te
herkennen zijn; een publieke mention van iemand die je volgt is wel een post en
blijft staan.

Tweede helft: de weergave gelijkgetrokken. De Krant rendert een post met
emoji's, een quote- of linkkaart en de media; Berichten liet daar niks van zien
(zelfs de shortcodes bleven staan, want ap_mentions had geen emoji_json) en de
Guardian PWA plakte de kale content in een div. Die opmaak zat bovendien in de
<style> van de Krant zelf, dus een post buiten de Krant kwam sowieso ongestyled
binnen.

Nu is er รฉรฉn partial, note-body, met de opmaak in shared-styles ernaast. Alle
drie de oppervlakken gebruiken hem: de PWA bouwt zijn kaarten in de browser en
krijgt de body server-side gerenderd mee. ap_mentions en ap_interactions kregen
de kolommen die daarvoor nodig zijn, gevuld bij binnenkomst, met de quote- of
linkkaart out of band zoals de tijdlijn dat al deed.

En passant: de embed-gate stond alleen op de C2S-read, dus een ward zag in de
web-Krant nog steeds linkvoorbeelden die de guardians hadden uitgezet. Die gate
zit nu ook op /news en /messages.

Changed files:
src/services/ActivityPubService.js

  • belongsInTimeline: een directe note is geen tijdlijn-post
  • self-heal v21 verwijdert al opgeslagen hulpvragen en waves uit ap_timeline
  • resolveCard: quote of linkvoorbeeld, รฉรฉn kaart, out of band opgelost
  • mentions en replies slaan emoji's, media en die kaart op
  • getNotifications geeft die kolommen door aan Berichten

src/config/database.js

  • kolommen op ap_mentions en ap_interactions voor emoji's, media, quote, embed

src/middleware/render.js

  • renderNoteBody: dezelfde partial als string, voor niet-EJS oppervlakken

src/routes/posts.js

  • gateEmbeds op /news en /messages (FEP-633c gated feature)

src/routes/guardian.js

  • hulpvragen krijgen body_html en name_html mee

src/assets/js/guardian.js

  • kaart rendert die body in plaats van de kale content

src/assets/css/guardian.css

  • opmaak voor de gedeelde post-body in de kleuren van de PWA

src/views/partials/tl-item.ejs

  • body vervangen door de gedeelde partial

src/views/partials/msg-item.ejs

  • idem, plus een ๐Ÿ›Ÿ-markering bij een hulpvraag

src/views/partials/shared-styles.ejs

  • .tl-content, .tl-quote* en .tl-media* hierheen verhuisd

src/views/pages/news.ejs

  • die regels weggehaald, alleen Krant-eigen opmaak blijft

src/services/i18n.js

  • msg.help_request in nl, en, de

New file:
src/views/partials/note-body.ejs

  • de body van een post: content, quote/linkkaart, media

test/help-request-timeline.test.js

  • een ๐Ÿ›Ÿ blijft uit de Krant en in Berichten

test/note-body-shared.test.js

  • รฉรฉn renderer, en beide views gaan er doorheen

remarks: geverifieerd op een wegwerp-database in de browser: de hulpvraag
verdwijnt bij het opstarten uit de Krant en staat mรฉt quote en capture in
Berichten en de PWA. Nog niet uitgerold.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@โ€ฆ>

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