source: Klonkt/src/middleware/render.js@ 746d98a

main
Last change on this file since 746d98a was 746d98a, checked in by Robin <roboburr@…>, 6 hours ago

Wardmodus: een besloten Klonkt met alleen het profiel openbaar

Robin, 30-9: "Ward Profile mode gaat actief wanneer ward gekoppeld is, dus
wanneer de persoon guardian(s) heeft. Een besloten Klonkt met enkel het profiel
van de ward publiek. Wij denken dat dit het beste valt bij emancipation om de
functie weer naar normaal Klonkt te brengen."

AFGELEID, NIET OPGESLAGEN. Heeft de site geaccepteerde guardians, dan staat de
modus aan; is de set leeg, dan uit. listGuardians telt alleen geaccepteerde
guardianships, dus een voorstel zet niets dicht, en de set leegmaken is
emancipatie (FEP-633c 3.4) -- precies waar het ontwerp de weg terug legt. Een
schakelaar zou een tweede waarheid naast de guardianship zijn.

STANDAARD DICHT (middleware/ward-profile.js, in server.js direct na de
siteresolver en de schrijfpoort). Een bezoeker ziet op / het profiel en verder
niets: een pagina gaat terug naar het profiel, al het andere is 404, een POST
bereikt zijn handler niet, en een htmx-navigatie krijgt HX-Redirect in plaats
van een brokstuk. Wat niet op de lijst staat komt er niet langs, zodat een
nieuwe route niet vanzelf een kind openzet.

Open blijft: inloggen, account, beheer, het guardianpaneel, /lang, de bestanden
van de pagina zelf, en /audio/stream -- die bewaakt elk bestand zelf, en een
track die de ward op de fediverse opende moet daar speelbaar blijven. De
federatie (ActivityPub, WebFinger, OAuth voor Shaer, /media, /assets, /og) hangt
al VOOR de poort; de paden staan toch op de lijst, voor als de volgorde ooit
omgaat, want dan zou een ward stil van de fediverse verdwijnen.

Wie alles ziet: de ward zelf, en god, kijker en sitebeheer. Een andere
ingelogde gebruiker is gewoon een bezoeker.

DE PROFIELPAGINA (pages/ward-profile.ejs) mag verzorgd, want dit is alles wat
een bezoeker ziet: foto met een ring in de accentkleur, de naam groot, de
handle op een pil, de bio, de links als brede knoppen in hun merkkleur, een
volgknop die de bestaande fediverse-dialoog opent (een volgverzoek aan een ward
gaat eerst langs de guardians), en een neutrale regel dat berichten alleen
voor volgers zijn. Nergens "kind" of "ward": dat de account guardians heeft
staat al openbaar op de fediverse. Dezelfde bronnen als de profielkop, zodat
het profiel op geen twee plekken iets anders kan zeggen. De chrome biedt een
bezoeker niets aan wat dicht is: geen zoekknop, geen archief, geen
Lezen/Grid-schakelaar.

DE PROFIELLINKS verhuizen van Appearance naar het account (/account#links),
bij naam, bio en foto. Twee vallen onderweg, allebei getoetst:

  • Appearance schreef profile_links altijd, uit de formuliervelden. Met de velden weg zou elke keer opslaan daar de links wissen. Die pagina schrijft ze niet meer.
  • ownedSite haalde profile_links niet op, dus de accountpagina toonde nul links en opslaan wiste ze. Gevonden bij het bekijken, niet door een toets; nu wel.

buildProfileLinks staat in PlatformIcons.js, en de rijen-knoppen in een eigen
module (mod/profile-links.js).

Getoetst: gewone Klonkt zonder guardians, een voorstel zet niets dicht,
gekoppeld alleen het profiel, pagina's terug, 404 voor wat geen pagina is, geen
handler voor een POST, HX-Redirect, wat open moet blijft open, ward en beheer
zien alles, een andere ingelogde is een bezoeker, emancipatie maakt het weer
gewoon, de links bij het account en Appearance wist ze niet. Tegenproef per
zaak. Volledige suite 1325 groen.

Nagekeken in de browser op 375 en 1280 pixels: het profiel past op een
telefoonscherm, knoppen van 55 pixels, geen overloop, de volgknop opent de
dialoog; de accountpagina toont de bestaande links en voegt er een toe.

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

  • Property mode set to 100644
File size: 18.4 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
110export const 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 // De wardmodus (middleware/ward-profile.js): een bezoeker van een ward-site ziet
216 // alleen het profiel, en de chrome biedt dan niets aan wat toch dicht is.
217 wardProfile: !!res.locals.wardProfile,
218 footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
219 agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
220 platforms_catalog: PLATFORMS_CATALOG,
221 permissions: PermissionsService,
222 formatDate,
223 formatDateTime,
224 // Render the Shaer-native bits server-side so the web timeline matches the
225 // apps: FEP-9098 custom emojis in content/names, and the FEP-044f quote.
226 emojiHtml, // (html, emoji_json) → HTML with :shortcode: as <img>
227 emojiName, // (text, emoji_json) → escaped name with :shortcode: as <img>
228 noteQuote: parseQuote, // (quote_json) → the resolved quoted-post object or null
229 // Rewrite a local /media/<file> cover to its on-demand downscaled thumbnail
230 // (crisp grid/list images). External URLs + already-thumb URLs pass through.
231 thumb: (url, w) => {
232 if (!url || typeof url !== 'string') return url;
233 // Local cover → local thumb route; remote (federated) cover → signed downscale
234 // proxy (same as avatars), so remote line-art covers aren't browser-downscaled jagged.
235 if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 480}/${url.slice(7)}`;
236 if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 480);
237 return url;
238 },
239 // Crisp avatars: a local /media avatar goes through the local thumb route; a REMOTE
240 // (fediverse) avatar through the signed downscaling proxy. Same downscale, the remote
241 // one is just fetched first. Default 128px (covers feed 44px → profile ~120px).
242 avatar: (url, w) => {
243 if (!url || typeof url !== 'string') return url;
244 if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 128}/${url.slice(7)}`;
245 if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 128);
246 return url;
247 },
248 // pageTitleKey (translated with the resolved language) wins over a raw pageTitle string,
249 // so admin page titles aren't hardcoded in one language. Falls back to the site title.
250 pageTitle: (data.pageTitleKey ? i18nT(_lang, data.pageTitleKey, data.pageTitleVars) : data.pageTitle)
251 || (data.site && data.site.title) || 'Klonkt',
252 appVersion: APP_VERSION,
253 bodyClass: data.bodyClass || 'on-home',
254 // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
255 // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
256 // Alleen kleine letters, cijfers, streepjes en spaties -- de naam wordt een
257 // pad.
258 pageJs: /^[a-z0-9 -]*$/.test(String(data.pageJs || '')) ? (data.pageJs || '') : '',
259 socialDescr: data.socialDescr || '',
260 socialImage: data.socialImage || '',
261 cspNonce: () => '',
262 currentPath: req.path,
263 // Absolute origin (for building absolute URLs like the generated og:image).
264 ogOrigin: (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host') || ''}`).replace(/\/+$/, ''),
265 ...data,
266 };
267
268 try {
269 // Step 1: Render the page view to HTML
270 const viewPath = path.join(VIEWS_DIR, viewName + '.ejs');
271 const pageContent = await ejs.renderFile(viewPath, locals, { async: false });
272
273 if (isPartial) {
274 // A "Load more" append (hx-swap=beforeend into a sub-list) is NOT a
275 // navigation: it only adds rows to the existing page. It must NOT touch
276 // the site chrome or the body class. Emitting the nav HX-Trigger + OOB
277 // chrome here (below) rebuilds the header for the DEFAULT bodyClass —
278 // which, on a 'on-special' page like Messages, swaps in the full Klonkt
279 // header that the page had hidden. So for an append, send content only.
280 if (req.query.append === '1') {
281 return res.send(injectCspNonce(pageContent, res.locals.cspNonce));
282 }
283 // HTMX: just send the content. Set HX-Trigger for body class swap.
284 // HTTP-header values are Latin-1 only — a title with an em-dash, smart
285 // quote or emoji (e.g. "Welkom — gebouwd met Klonkt") would make
286 // setHeader throw ERR_INVALID_CHAR and 500 the partial, so the card
287 // looks "unclickable". Escape any non-ASCII to \uXXXX: the header stays
288 // ASCII-safe and remains valid JSON that htmx parses back unchanged.
289 // Per-site accent + palette live in the shell <head> (style#pcms-site-accent
290 // + html[data-palette]) and are NOT swapped during htmx navigation. Send them along
291 // so the client updates them — otherwise an artist inherits the previous page's
292 // colours (e.g. hub-purple instead of their own green). Same derivation as shell.ejs.
293 const _navAccent = (_site && _site.accent && /^#[0-9a-fA-F]{6}$/.test(_site.accent))
294 ? _site.accent : '#e8b04b';
295 const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
296 // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell
297 // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er
298 // altijd bij, want die komt bij elke navigatie opnieuw binnen.
299 const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
300 const triggerJson = JSON.stringify({
301 pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
302 pcmsPostSwap: data.post ? {
303 title: data.post.title,
304 slug: data.post.slug,
305 pageTitle: locals.pageTitle,
306 } : null,
307 }).replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
308 res.setHeader('HX-Trigger-After-Settle', triggerJson);
309 // Render the site chrome out-of-band so the header (topnav/profile header/
310 // view-switcher) ALWAYS matches the new page/artist on navigation —
311 // while the audio player (separate in document.body) keeps playing (no
312 // interruption). htmx replaces #pcms-chrome via hx-swap-oob. Non-critical:
313 // if it fails, the old chrome remains (no crash).
314 let oobChrome = '';
315 try {
316 oobChrome = await ejs.renderFile(
317 path.join(VIEWS_DIR, 'partials', 'chrome.ejs'),
318 { ...locals, oob: true },
319 { async: false },
320 );
321 } catch (e) { /* skip chrome OOB */ }
322 return res.send(injectCspNonce(pageContent + oobChrome, res.locals.cspNonce));
323 }
324
325 // Full: wrap content in shell (rendered to a string so we can inject the CSP nonce).
326 locals.pageContent = pageContent;
327 const shellHtml = await ejs.renderFile(path.join(VIEWS_DIR, 'shell.ejs'), locals, { async: false });
328 res.send(injectCspNonce(shellHtml, res.locals.cspNonce));
329 } catch (err) {
330 console.error('[renderPage] Error rendering', viewName, err);
331 if (process.env.NODE_ENV === 'production') {
332 return res.status(500).send('Internal Server Error');
333 }
334 // Dev: surface the underlying cause prominently. EJS rewrites err.message
335 // to include the file/line/code-context, so we also surface name+stack
336 // separately in case the message was truncated or empty.
337 const escape = (s) => String(s == null ? '' : s)
338 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
339 res.status(500).send(`<!doctype html>
340<meta charset="utf-8">
341<title>Render error: ${escape(viewName)}</title>
342<style>
343 body { font: 14px/1.5 ui-monospace, monospace; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; background:#1a1a1a; color:#eee; }
344 h1 { color:#dc2626; font-family: ui-sans-serif, system-ui; }
345 h2 { color:#fb923c; font-size:1rem; margin-top:1.5rem; }
346 pre { background:#0a0a0a; border:1px solid #333; border-radius:6px; padding:1rem; overflow:auto; white-space:pre-wrap; word-break:break-word; }
347 .cause { background:#3d0a0a; border-color:#7a1a1a; color:#fca5a5; font-weight:600; }
348</style>
349<h1>Render error in ${escape(viewName)}</h1>
350<h2>Cause</h2>
351<pre class="cause">${escape(err.name || 'Error')}: ${escape(err.message || '(no message)')}</pre>
352<h2>Stack</h2>
353<pre>${escape(err.stack || '(no stack)')}</pre>
354${err.path ? `<h2>File</h2><pre>${escape(err.path)}</pre>` : ''}
355`);
356 }
357}
Note: See TracBrowser for help on using the repository browser.