source: Klonkt/src/server.js@ d85b66f

main
Last change on this file since d85b66f was af2cc73, checked in by Bart <bart@…>, 3 weeks ago

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

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

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