source: Klonkt/src/server.js@ 7842ca1

main
Last change on this file since 7842ca1 was 7842ca1, checked in by Claude (agent) <aiclaude@…>, 5 weeks ago

Reactie-migratie draait bij boot, inclusief hersleutelen (shaer-9e9)

Een upgrade naar de fase-2-code was tot nu toe onveilig. Het schema migreert
vanzelf (ensureColumn bij boot), maar voor data was er niets: de backfill was een
handmatig script. Bij een vloot die met klonkt-update in een stap naar nieuwe code
gaat wordt dat vergeten, en het falen is stil -- een reactie die niemand meer
ziet geeft geen fout, en de gebruiker klikt gewoon opnieuw, met een tweede Like
de fediverse in als gevolg.

migrateReactions() draait nu bij boot, één keer per REACTIONS_MIGRATION_VERSION,
op dezelfde manier als selfHealTimeline. Twee stappen:

HERSLEUTELEN De oude interact-route bewaarde de URI waarmee je binnenkwam, en

de bookmarklet geeft window.location.href door -- dus de
permalink. Sinds canonicalReactionUri op de object-URI gezocht
wordt, zouden die rijen wees zijn. Dit gat zat NIET in mijn
eerste backfill-script; dat vulde alleen aan vanuit de kolommen
en zag deze rijen niet. Op dev viel het niet op omdat daar alles
al canoniek stond.

AANVULLEN Alles wat op oude code via de Krant is gegeven staat alleen in

ap_timeline.liked/boosted.

Volgorde binnen het hersleutelen: eerst de canonieke variant erbij, dan de
permalink weg. Een onderbreking levert dan hooguit een dubbele rij op en nooit
een verdwenen reactie. Bij hersleutelen reist created_at mee; bij aanvullen
bestaat die informatie niet en wordt het de migratiedatum.

Mislukt de migratie, dan wordt de versievlag niet gezet en probeert de volgende
boot het opnieuw. De kolommen blijven staan, dus de oude waarheid is intussen
niet weg.

Het losse script is nu een schil om diezelfde functie, met --dry-run en --force.
Twee implementaties van een migratie lopen uiteen, en dan repareert de ene wat de
andere niet ziet.

Droogloop tegen de dev-database: 0 te hersleutelen, 0 aan te vullen -- die is al
schoon, wat meteen de idempotentie bevestigt. Suite 438/438.

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

  • Property mode set to 100644
File size: 29.5 KB
Line 
1/**
2 * Klonkt Beta — server bootstrap
3 *
4 * Personal multi-site platform — Node + SQLite + htmx.
5 * Stack: Express + better-sqlite3 + EJS + htmx + ws.
6 */
7
8import 'dotenv/config';
9import express from 'express';
10import helmet from 'helmet';
11import session from 'express-session';
12import bodyParser from 'body-parser';
13import path from 'path';
14import fs from 'fs';
15import crypto from 'crypto';
16import { fileURLToPath } from 'url';
17import http from 'http';
18import db, { initializeDatabase } from './config/database.js';
19import { startScheduler } from './services/Scheduler.js';
20import { SqliteSessionStore } from './services/SqliteSessionStore.js';
21import { ensurePrimarySite } from './services/ensurePrimarySite.js';
22import { getThumbnail, getRemoteThumbnail, verifyImg, THUMB_SIZES } from './services/ThumbnailService.js';
23import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
24import { isViewer } from './middleware/auth.js';
25import { renderPage } from './middleware/render.js';
26import { audioEnabled } from './config/features.js';
27import authRoutes from './routes/auth.js';
28import accountRoutes from './routes/account.js';
29import adminRoutes from './routes/admin.js';
30import adminAudioRoutes from './routes/admin-audio.js';
31import adminPlaylistsRoutes from './routes/admin-playlists.js';
32import adminSitesRoutes from './routes/admin-sites.js';
33import adminUsersRoutes from './routes/admin-users.js';
34import adminSettingsRoutes from './routes/admin-settings.js';
35import adminSeoRoutes from './routes/admin-seo.js';
36import audioRoutes from './routes/audio.js';
37import searchRoutes from './routes/search.js';
38import tagsRoutes from './routes/tags.js';
39import typesRoutes from './routes/types.js';
40import usersRoutes from './routes/users.js';
41import feedRoutes from './routes/feed.js';
42import postsRoutes from './routes/posts.js';
43import paidRoutes from './routes/paid.js';
44import langRoutes from './routes/lang.js';
45import adminUpdatesRoutes from './routes/admin-updates.js';
46import adminPatreonRoutes from './routes/admin-patreon.js';
47import adminStatsRoutes from './routes/admin-stats.js';
48import adminPaidRoutes from './routes/admin-paid.js';
49import adminPushRoutes from './routes/admin-push.js';
50import pushRoutes from './routes/push.js';
51import guardianRoutes from './routes/guardian.js';
52import adminMediaRoutes from './routes/admin-media.js';
53import circleRoutes from './routes/circle.js';
54import epkRoutes from './routes/epk.js';
55import newsletterRoutes from './routes/newsletter.js';
56import adminNewsletterRoutes from './routes/admin-newsletter.js';
57import downloadRoutes from './routes/download.js';
58import linkbioRoutes from './routes/linkbio.js';
59import embedRoutes from './routes/embed.js';
60import showsRoutes from './routes/shows.js';
61import adminShowsRoutes from './routes/admin-shows.js';
62import adminEpkRoutes from './routes/admin-epk.js';
63import changelogRoutes from './routes/changelog.js';
64import ogRoutes from './routes/og.js';
65import apRoutes from './routes/activitypub.js';
66import oauthRoutes from './routes/oauth.js';
67import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
68
69// SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
70// and persist it next to the database, so it stays stable across restarts and
71// updates. This lets Docker / bare-Node installs run with zero manual config.
72if (!process.env.SESSION_SECRET) {
73 const dataDir = path.dirname(process.env.DATABASE_PATH || './storage/database.sqlite');
74 const secretFile = path.join(dataDir, '.session-secret');
75 try { process.env.SESSION_SECRET = fs.readFileSync(secretFile, 'utf8').trim(); } catch { /* not yet generated */ }
76 if (!process.env.SESSION_SECRET) {
77 fs.mkdirSync(dataDir, { recursive: true });
78 process.env.SESSION_SECRET = crypto.randomBytes(32).toString('hex');
79 fs.writeFileSync(secretFile, process.env.SESSION_SECRET, { mode: 0o600 });
80 console.log(`🔑 Generated a SESSION_SECRET (stored in ${secretFile})`);
81 }
82}
83
84// A SESSION_SECRET that was explicitly set in the env must still be strong in prod.
85if (process.env.NODE_ENV === 'production' && process.env.SESSION_SECRET.length < 32) {
86 console.error('❌ FATAL: SESSION_SECRET is too weak for production (set a longer, random one in .env)');
87 process.exit(1);
88}
89
90const __dirname = path.dirname(fileURLToPath(import.meta.url));
91const PORT = process.env.PORT || 3000;
92// Interface to bind. Default 0.0.0.0 (needed for Docker port-forwarding). Behind a
93// reverse proxy on the same host, set HOST=127.0.0.1 so the app is NOT reachable
94// directly from the internet (only via the proxy) — see README/install docs.
95const HOST = process.env.HOST || '0.0.0.0';
96const isDev = process.env.NODE_ENV !== 'production';
97
98const app = express();
99const server = http.createServer(app);
100
101// Per-request CSP nonce for the strict script-src (nonce + strict-dynamic). Must be set
102// before helmet builds the CSP header below. The nonce is injected into every <script> tag
103// at render time (see middleware/render.js injectCspNonce).
104app.use((req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); next(); });
105
106// HSTS. The default ships a plain long max-age — safe on ANY domain. includeSubDomains +
107// preload are aggressive (they affect the operator's OTHER subdomains and can get their
108// domain baked into browsers near-permanently), so they're opt-in via HSTS_STRICT=1 — set
109// only on domains you fully own (e.g. the klonkt.com fleet). Self-hosters get the safe default.
110// NB: Helmet defaults includeSubDomains to true, so the safe default must disable it explicitly.
111const hstsOptions = { maxAge: 31536000, includeSubDomains: false, preload: false };
112if (process.env.HSTS_STRICT === '1') { hstsOptions.includeSubDomains = true; hstsOptions.preload = true; }
113
114app.use(helmet({
115 contentSecurityPolicy: {
116 directives: {
117 defaultSrc: ["'none'"],
118 // Strict CSP: a per-request nonce + 'strict-dynamic' (no 'unsafe-inline', no broad host
119 // sources — securityheaders/Observatory flag those). Trusted (nonce'd) scripts may load
120 // further scripts, which covers htmx-swapped inline scripts AND the external player APIs
121 // that embed-player.js injects (YouTube/SoundCloud/Spotify). The nonce is added to every
122 // <script> tag at render time (middleware/render.js injectCspNonce).
123 scriptSrc: [
124 "'strict-dynamic'",
125 (req, res) => `'nonce-${res.locals.cspNonce}'`,
126 ],
127 // No inline event handlers anywhere: every on* attribute was moved to a
128 // delegated data-* handler (the shared script in shell.ejs), so inline
129 // handlers are blocked entirely — this closes the last 'unsafe-inline' in
130 // the script directives.
131 scriptSrcAttr: ["'none'"],
132 styleSrc: ["'self'", "'unsafe-inline'"],
133 // blob: required for the image editor (Cropper) — it displays the chosen
134 // photo via URL.createObjectURL(blob:…). Without blob: the CSP silently
135 // blocks the <img> → empty edit window. (media-src already has blob: for audio.)
136 imgSrc: ["'self'", "data:", "https:", "blob:"],
137 connectSrc: ["'self'", "wss:", "ws:", "https://*.spotifycdn.com", "https://*.scdn.co"],
138 // blob: is required for the audio player — it fetch()es track bytes and
139 // plays from a blob: object URL (Spotify-style). Without blob: here the
140 // CSP silently blocks <audio>.src = blob:… → the player fires 'error' and
141 // auto-skips every track. 'self'/https: do NOT imply blob:.
142 mediaSrc: ["'self'", "https:", "blob:"],
143 fontSrc: ["'self'"],
144 // Embeds (platform players + cross-site Klonkt audio players) are framed broadly:
145 // ANY https origin, so embeds work in any context (feed, htmx/PWA nav, public pages).
146 // The sensitive /authorize_interaction page tightens frame-src back to 'self' in
147 // renderPage — it shows untrusted remote content next to the interact buttons.
148 frameSrc: ["'self'", "https:"],
149 // default-src is 'none' (deny by default), so resource types that were implicitly covered
150 // by the old default-src 'self' must be listed explicitly: the PWA manifest and the
151 // service worker. (base-uri/form-action/frame-ancestors/object-src 'none' come from
152 // Helmet's defaults; img/style/connect/media/font/frame are set above.)
153 manifestSrc: ["'self'"],
154 workerSrc: ["'self'", "blob:"],
155 },
156 },
157 hsts: hstsOptions,
158 frameguard: { action: 'sameorigin' },
159 referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
160}));
161
162// Permissions-Policy: disable powerful features Klonkt never uses (camera, microphone,
163// geolocation) and opt out of the Topics API. Features that embeds legitimately need
164// (autoplay, fullscreen, encrypted-media, picture-in-picture) are left at their default
165// allowlist, so YouTube/Spotify/SoundCloud players keep working.
166app.use((req, res, next) => {
167 res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), browsing-topics=()');
168 next();
169});
170
171app.set('view engine', 'ejs');
172app.set('views', path.join(__dirname, 'views'));
173
174app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
175app.use(bodyParser.json({ limit: '10mb' }));
176
177// Trust one upstream proxy in production. NPM (or Caddy / nginx) terminates
178// HTTPS and forwards to us over plain HTTP, setting X-Forwarded-Proto: https.
179// Without this, Express sees req.protocol === 'http' and won't issue secure
180// cookies — sessions never persist past the redirect after login.
181if (!isDev) app.set('trust proxy', 1);
182
183// Collapse leading duplicate slashes in the path. A reverse proxy that proxies with
184// `RewriteRule ^(.*)$ http://localhost:3000/$1` (Apache [P]) sends "//" for the root and
185// "//path" for sub-paths (the captured $1 keeps its leading slash) → Express matches no
186// route → the whole site 404'd behind such a proxy. Normalising here makes Klonkt resilient
187// to that common reverse-proxy setup. (Only the leading slashes; the query string is intact.)
188app.use((req, res, next) => {
189 if (req.url.startsWith('//')) req.url = req.url.replace(/^\/+/, '/');
190 next();
191});
192
193// Create/migrate the schema BEFORE anything touches the DB: the session store
194// queries the `sessions` table on construction, so on a fresh install the tables
195// must exist first (otherwise: "no such table: sessions" → crash loop on first boot).
196initializeDatabase();
197startScheduler(); // release planning: publish scheduled posts when publish_at is reached
198startDeliveryWorker(); // retry failed fediverse deliveries with backoff
199// Once per REACTIONS_MIGRATION_VERSION bump: reacties naar de tussentabel, onder
200// de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code
201// die tabel -- draait hij niet, dan tonen oude likes als niet-gegeven.
202migrateReactions();
203selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
204
205// Safety net: guarantee that there is always a primary site (solo/hub/circle).
206// Idempotent — does nothing if a site already exists or there is no admin yet.
207ensurePrimarySite();
208
209// Session middleware extracted into a variable so the WebSocket upgrade
210// handler can reuse it (it needs req.session to authenticate sockets).
211const sessionMiddleware = session({
212 store: new SqliteSessionStore(),
213 secret: process.env.SESSION_SECRET,
214 resave: false,
215 saveUninitialized: false,
216 name: 'pcms.sid',
217 cookie: {
218 httpOnly: true,
219 secure: !isDev,
220 sameSite: 'lax',
221 maxAge: 30 * 24 * 60 * 60 * 1000,
222 },
223});
224app.use(sessionMiddleware);
225
226app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
227
228// On-demand cover thumbnails: /media/thumb/<w>/<path> → a small lanczos-downscaled WebP
229// (cached on disk), so the browser doesn't jaggily shrink a high-res cover for the grid/
230// list. Mounted BEFORE the /media static so it catches the thumb path first.
231app.get('/media/thumb/:w/*', async (req, res) => {
232 const w = parseInt(req.params.w, 10);
233 const rel = req.params[0] || '';
234 if (!THUMB_SIZES.has(w)) return res.status(400).end();
235 let file = null;
236 try { file = await getThumbnail(rel, w); } catch { /* fall through to original */ }
237 if (!file) {
238 // Generation unavailable/failed → serve the original instead of 404'ing.
239 return res.redirect(302, '/media/' + rel.split('/').map(encodeURIComponent).join('/'));
240 }
241 res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
242 res.setHeader('Cache-Control', isDev ? 'no-cache' : 'public, max-age=31536000, immutable');
243 res.type('webp');
244 res.sendFile(file);
245});
246
247// Signed remote-image proxy: downscale a REMOTE avatar/image (SSRF-safe via safeFetch)
248// to a cached WebP, so line-art fediverse avatars don't render jagged. Only HMAC-signed
249// URLs (produced by the avatar() view helper) are accepted — not an open resizer.
250app.get('/img/a/:w', async (req, res) => {
251 const w = parseInt(req.params.w, 10);
252 const url = typeof req.query.u === 'string' ? req.query.u : '';
253 const sig = typeof req.query.s === 'string' ? req.query.s : '';
254 if (!THUMB_SIZES.has(w) || !verifyImg(url, w, sig)) return res.status(400).end();
255 let file = null;
256 try { file = await getRemoteThumbnail(url, w); } catch { /* fall through to original */ }
257 if (!file) return res.redirect(302, url); // fetch/downscale failed → let the browser load the remote original
258 res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
259 res.setHeader('Cache-Control', isDev ? 'no-cache' : 'public, max-age=604800');
260 res.type('webp');
261 res.sendFile(file);
262});
263
264app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
265 // Public media (post covers, avatars) must be cross-origin embeddable by other
266 // Klonkt sites in their CIRCLE. Helmet sets CORP=same-origin by default, which
267 // causes the browser to block those images (the file arrives, but the browser
268 // refuses to render it). Set cross-origin explicitly for /media.
269 setHeaders: (res) => res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'),
270 // An upload never changes under its name (unique filenames; a new upload is
271 // a new name), so say so. Without this the default is max-age=0 and every
272 // platform image-loader may re-ask for every image on every screen: Shaer's
273 // cards visibly re-loaded what the previous view had just shown. The thumbs
274 // and the avatar proxy already declared this; the originals were the one
275 // place that forgot.
276 maxAge: isDev ? 0 : '1y',
277 immutable: !isDev,
278}));
279
280// (Removed) TWA / digital-asset-links — only needed for the APK/TWA variant.
281// Klonkt is PWA-only; assetlinks.json is no longer served.
282
283// Bundle HTMX: copy from node_modules into our own assets dir so we can serve
284// it locally (no third-party CDN). Idempotent — only copies if size differs.
285(function ensureLocalHtmx() {
286 const src = path.join(__dirname, '..', 'node_modules', 'htmx.org', 'dist', 'htmx.min.js');
287 const dest = path.join(__dirname, 'assets', 'js', 'htmx.min.js');
288 try {
289 const srcStat = fs.statSync(src);
290 const destStat = fs.existsSync(dest) ? fs.statSync(dest) : null;
291 if (!destStat || destStat.size !== srcStat.size) {
292 fs.copyFileSync(src, dest);
293 console.log(`📦 HTMX bundled locally: ${srcStat.size} bytes`);
294 }
295 } catch (e) {
296 console.warn('⚠️ Could not bundle HTMX:', e.message, '— run `npm install`');
297 }
298})();
299
300// ActivityPub: WebFinger + /ap/* (site-agnostic, resolves the site by slug).
301app.use(apRoutes);
302// ActivityPub C2S: OAuth 2.0 (native/web clients). Site-agnostic; the consent
303// screen picks which site the token can post as.
304app.use(oauthRoutes);
305
306// Themed OG cards (/og/:slug.png) — resolve the site by slug themselves, so they
307// run before resolveSite and need no site context.
308app.use('/og', ogRoutes);
309
310app.use(resolveSite);
311app.use(loadAudioTracks);
312app.use(loadTheme);
313
314// ActivityPub content negotiation on the human URLs: an AP request (Accept:
315// application/activity+json) to a profile/post URL is redirected to its /ap/*
316// representation — same URL serves HTML to browsers, AP-JSON to servers (this is
317// how Mastodon resolves a pasted profile/post URL). Gated on apWants() so normal
318// browser requests pay nothing.
319app.use((req, res, next) => {
320 if (req.method !== 'GET' || !apWants(req)) return next();
321 const site = res.locals.site;
322 if (!site || !site.slug) return next();
323 const seg = req.path.replace(/^\/+|\/+$/g, '');
324 if (seg === '') return res.redirect(302, `/ap/users/${encodeURIComponent(site.slug)}`);
325 if (!seg.includes('/')) {
326 try {
327 const post = db.prepare(
328 "SELECT id FROM posts WHERE site_id = ? AND slug = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
329 ).get(site.id, seg);
330 if (post) return res.redirect(302, `/ap/notes/${post.id}`);
331 } catch { /* fall through to normal HTML handling */ }
332 }
333 return next();
334});
335
336// Lightweight CSRF defense: reject cross-origin state-mutating requests.
337// Same-origin forms + HTMX send a matching Origin; missing Origin is allowed
338// through (non-browser clients). sameSite:'lax' on the session cookie is the
339// second layer. (Does not apply to GET/HEAD/OPTIONS.)
340app.use((req, res, next) => {
341 if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
342 const origin = req.get('origin');
343 if (!origin) return next(); // no Origin → no browser CSRF vector
344 let originHost;
345 try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); }
346 // Behind a reverse proxy the raw Host is the backend bind (e.g. localhost:3000, when the
347 // proxy doesn't preserve it — common with Apache .htaccess proxying), so also accept the
348 // operator-configured PUBLIC_BASE_URL host and the proxy's X-Forwarded-Host. Both are
349 // operator/proxy-controlled and can't be forged via a victim's browser, so this is safe.
350 const allowedHosts = [req.get('host'), req.get('x-forwarded-host')];
351 if (process.env.PUBLIC_BASE_URL) { try { allowedHosts.push(new URL(process.env.PUBLIC_BASE_URL).host); } catch { /* ignore bad config */ } }
352 if (!allowedHosts.includes(originHost)) return res.status(403).send('Cross-origin request geweigerd');
353 next();
354});
355
356// Viewer accounts: may view everything (including Admin), change nothing. This is
357// the ONLY write gate — fail-closed, before all route handlers. Every state-mutating
358// method is rejected (the login POST sets the session after this guard, so it is
359// not affected). Instead of raw 403 text we render a clean page (or, for HTMX,
360// a swapped-in message).
361app.use((req, res, next) => {
362 const mutating = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS';
363 if (mutating && isViewer(req.session?.user)) {
364 if (req.headers['hx-request'] === 'true') {
365 // htmx doesn't swap on 4xx; send 200 + retarget so the message appears in #pcms-main.
366 res.setHeader('HX-Retarget', '#pcms-main');
367 res.setHeader('HX-Reswap', 'innerHTML');
368 res.status(200);
369 } else {
370 res.status(403);
371 }
372 return renderPage(req, res, 'pages/viewer-blocked', {
373 pageTitle: 'Kijker-modus',
374 bodyClass: 'on-special',
375 });
376 }
377 next();
378});
379
380app.use('/auth', authRoutes);
381app.use('/account', accountRoutes);
382// NB: /notifications is the fediverse notifications page (in postsRoutes). The old
383// user-notifications route was removed — it collided with the fedi route after the
384// /meldingen -> /notifications rename, and the user-notifications system is dead.
385if (audioEnabled()) {
386 app.use('/admin/audio', adminAudioRoutes);
387 app.use('/admin/playlists', adminPlaylistsRoutes);
388}
389app.use('/admin/media', adminMediaRoutes); // image library + cleanup (works in lite mode too)
390app.use('/admin/sites', adminSitesRoutes);
391app.use('/admin/users', adminUsersRoutes);
392app.use('/admin/settings', adminSettingsRoutes);
393app.use('/admin/seo', adminSeoRoutes);
394app.use('/admin/updates', adminUpdatesRoutes);
395app.use('/admin/patreon', adminPatreonRoutes);
396app.use('/admin/stats', adminStatsRoutes);
397app.use('/admin/paid', adminPaidRoutes);
398app.use('/admin/push', adminPushRoutes);
399app.use('/admin/newsletter', adminNewsletterRoutes);
400app.use('/admin/shows', adminShowsRoutes);
401app.use('/admin/epk', adminEpkRoutes);
402app.use('/admin', adminRoutes);
403if (audioEnabled()) app.use('/audio', audioRoutes);
404app.use('/search', searchRoutes);
405app.use('/tag', tagsRoutes);
406app.use('/type', typesRoutes);
407app.use('/users', usersRoutes);
408// Feed/sitemap routes are mounted at root because they're at well-known paths
409app.use('/', feedRoutes);
410app.use('/', circleRoutes); // /cirkel-feed (solo: next() -> postsRoutes)
411app.use('/', epkRoutes); // /pers perskit (premium; niet-premium: next() -> 404)
412app.use('/', newsletterRoutes); // /nieuwsbrief in/uitschrijven (premium; niet-premium: next())
413if (audioEnabled()) app.use('/', downloadRoutes); // /downloads + /download/:id (audio; lite: uit)
414app.use('/', linkbioRoutes); // /links link-in-bio + klikstats (premium)
415if (audioEnabled()) app.use('/', embedRoutes); // /embed inbedbare audiospeler (audio; lite: uit)
416app.use('/', showsRoutes); // /shows agenda + notify-me (premium)
417app.use('/', changelogRoutes); // /changelog publieke release-/wijzigingen-pagina
418app.use('/', langRoutes); // /lang/:code — interface-taal kiezen (vóór de catch-all)
419app.use('/paid', paidRoutes); // paid-posts patron/passkey flow (before the /:slug catch-all)
420app.use('/push', pushRoutes); // web-push subscribe/test (before the /:slug catch-all)
421app.use('/guardian', guardianRoutes); // the Guardian PWA (FEP-633c): losse guardians, meekijken, follow-gating, wave, invite (was guardian2, v1 verwijderd)
422app.use('/', postsRoutes);
423
424app.get('/manifest.webmanifest', (req, res) => {
425 const site = res.locals.site;
426
427 // PWA scope: confines installed apps to ONE site. If a user is in the
428 // bedrijf1 PWA and clicks a link to /sites/bedrijf2/..., the browser will
429 // open it in a regular tab (out-of-scope) instead of within the PWA.
430 // Same applies to APK packaging — the WebView is locked to this scope.
431 //
432 // For path-mounted sites: scope = /sites/<slug>/
433 // For root/subdomain sites: scope = /
434 const base = res.locals.siteUrlBase || ''; // '' or '/sites/<slug>'
435 const scope = (base || '') + '/';
436 const startUrl = (base || '') + '/?source=pwa';
437
438 // A stable identity per site so installs don't collide (Chromium uses `id`).
439 // NB: changing the id orphans existing PWA installs (no migration carries an
440 // install across an id change) — anyone who already installed the site as a
441 // PWA will need to reinstall once. Data stays server-side, so nothing is lost.
442 const idBase = site?.slug ? `klonkt-${site.slug}` : 'klonkt';
443
444 res.set('Cache-Control', 'no-cache');
445 res.json({
446 id: idBase,
447 name: site?.title || 'Klonkt',
448 short_name: (site?.title || 'Klonkt').slice(0, 12),
449 description: site?.description || site?.tagline || '',
450 scope,
451 start_url: startUrl,
452 display: 'standalone',
453 display_override: ['standalone', 'minimal-ui'],
454 orientation: 'any',
455 background_color: '#1a1a17',
456 theme_color: site?.accent || '#e8b04b',
457 lang: site?.language || 'nl',
458 icons: [
459 { src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
460 { src: '/favicon.ico', sizes: '64x64', type: 'image/x-icon' },
461 ],
462 // Hint to capable browsers: capture all in-scope links inside the PWA
463 capture_links: 'existing-client-navigate',
464 });
465});
466
467// Favicon — served as SVG so it picks up the site's accent color dynamically.
468// Browsers also request /favicon.ico by convention; we serve the same SVG
469// content there with a forgiving content-type since modern browsers accept it.
470function _renderFavicon(res, accent) {
471 const safeAccent = /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : '#e8b04b';
472 // Site mark: rounded square in the site accent + bold white 'K' (Klonkt)
473 const svg = `<?xml version="1.0" encoding="UTF-8"?>
474<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
475 <rect width="64" height="64" rx="14" fill="${safeAccent}"/>
476 <text x="50%" y="50%" dy="0.35em" text-anchor="middle"
477 font-family="Arial, Helvetica, sans-serif"
478 font-size="42" font-weight="800" fill="#fff">K</text>
479</svg>`;
480 res.set('Content-Type', 'image/svg+xml');
481 res.set('Cache-Control', 'public, max-age=86400');
482 res.send(svg);
483}
484
485app.get('/favicon.svg', (req, res) => {
486 _renderFavicon(res, res.locals.site?.accent);
487});
488app.get('/favicon.ico', (req, res) => {
489 // Browsers requesting .ico will accept SVG content; chrome/firefox both fine.
490 // Keeping the route prevents 404 spam in the console.
491 _renderFavicon(res, res.locals.site?.accent);
492});
493
494app.get('/sw.js', (req, res) => {
495 res.set('Content-Type', 'application/javascript');
496 res.set('Cache-Control', 'no-cache');
497 res.send(`
498const CACHE_VERSION = 'pcms-v19-' + new Date().toISOString().split('T')[0];
499self.addEventListener('install', e => {
500 e.waitUntil(caches.open(CACHE_VERSION).then(c => c.addAll(['/'])));
501 self.skipWaiting();
502});
503self.addEventListener('activate', e => {
504 e.waitUntil(caches.keys().then(keys => Promise.all(
505 keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k))
506 )));
507 self.clients.claim();
508});
509// ONLY intercept navigations (HTML pages) for an offline fallback.
510// Do NOT touch images, CSS, JS or /media — let the browser handle those natively.
511// Otherwise a failed network fetch could fall back to an empty cache match
512// (undefined) and "break" an image on a normal refresh (hard reload bypasses
513// the SW, which is why that case worked fine).
514self.addEventListener('fetch', e => {
515 if (e.request.method !== 'GET') return;
516 if (e.request.mode !== 'navigate') return; // page loads only
517 // Same-origin ONLY. A cross-origin navigate request is an <iframe> embed
518 // (YouTube/Spotify/SoundCloud …) — routing those through the SW yields an
519 // opaque/altered response the iframe cannot render → blank embeds in the
520 // installed PWA (which is always SW-controlled). Let the browser load them.
521 try { if (new URL(e.request.url).origin !== self.location.origin) return; } catch (err) { return; }
522 e.respondWith(
523 // { cache: 'no-store' }: go to the network for the page, bypassing the browser's
524 // HTTP cache, so an online visitor ALWAYS gets the fresh site and never a
525 // heuristically-cached copy served through the SW. The cache is only a
526 // last-resort offline fallback (the .catch below).
527 fetch(e.request, { cache: 'no-store' }).then(resp => {
528 // Network-first: always serve fresh when online. Also refresh the '/' offline
529 // fallback with the homepage we just served, so a later cold start on a flaky or
530 // offline connection no longer shows the stale install-time snapshot ("old data
531 // on first PWA load").
532 try {
533 if (resp && resp.ok && new URL(e.request.url).pathname === '/') {
534 const copy = resp.clone();
535 e.waitUntil(caches.open(CACHE_VERSION).then(c => c.put('/', copy)).catch(() => {}));
536 }
537 } catch (err) { /* ignore cache refresh failures */ }
538 return resp;
539 }).catch(() => caches.match('/').then(r => r || Response.error()))
540 );
541});
542// Web push (docs/webpush-design.md): payload is JSON {type,title,body,url},
543// encrypted end-to-end to this browser (RFC 8291). Show it; click opens url.
544self.addEventListener('push', e => {
545 let d = {};
546 try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ }
547 const title = d.title || 'Klonkt';
548 e.waitUntil(self.registration.showNotification(title, {
549 body: d.body || '',
550 icon: '/favicon.svg',
551 badge: '/favicon.svg',
552 tag: d.type ? ('klonkt-' + d.type) : undefined, // collapse same-type bursts
553 data: { url: d.url || '/' },
554 }));
555});
556self.addEventListener('notificationclick', e => {
557 e.notification.close();
558 const url = (e.notification.data && e.notification.data.url) || '/';
559 e.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
560 for (const c of list) {
561 if (new URL(c.url).origin === self.location.origin && 'focus' in c) { c.navigate(url); return c.focus(); }
562 }
563 return clients.openWindow(url);
564 }));
565});
566 `);
567});
568
569process.on('unhandledRejection', (reason) => {
570 console.error('⚠️ Unhandled Rejection:', reason);
571});
572
573app.use((err, req, res, next) => {
574 console.error('❌ Error:', err);
575 res.status(err.status || 500).send(
576 isDev ? `<pre>${err.stack || err.message}</pre>` : 'Internal Server Error'
577 );
578});
579
580app.use((req, res) => {
581 res.status(404);
582 // Clean, mobile-friendly 404 via the shell (viewport + nav + site theme).
583 // Falls back to bare HTML if rendering unexpectedly fails.
584 try {
585 return renderPage(req, res, 'pages/404', {
586 pageTitle: '404 — niet gevonden',
587 bodyClass: 'on-special on-404',
588 });
589 } catch (e) {
590 return res.send('<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><div style="font-family:system-ui;max-width:500px;margin:4rem auto;text-align:center;padding:2rem"><h1 style="font-size:4rem;margin:0">404</h1><p>Pagina niet gevonden</p><a href="/">← Home</a></div>');
591 }
592});
593
594server.listen(PORT, HOST, () => {
595 const baseUrl = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
596 console.log('');
597 console.log('🪶 Klonkt');
598 console.log(` ${baseUrl || `http://localhost:${PORT}`}`);
599 if (baseUrl) console.log(` (bound to ${HOST}:${PORT})`);
600 console.log('');
601 console.log(` ✓ Security: Helmet, CSP, secure sessions`);
602 console.log(` ✓ Privacy: Self-hosted fonts, no third-party requests`);
603 console.log(` ✓ Layout: v9 editorial feel (top nav, profile header)`);
604 console.log(` ✓ Auth: wachtwoord (beheer) + Google (luisteraars) / logout`);
605 console.log(` ✓ Posts: create / edit / view / archive`);
606 console.log('');
607 console.log(` Mode: ${isDev ? 'development' : 'PRODUCTION'}`);
608 console.log('');
609});
610
611export default app;
Note: See TracBrowser for help on using the repository browser.