source: Klonkt/src/server.js@ 7ccdced

main
Last change on this file since 7ccdced was 31288dc, checked in by Robin <roboburr@…>, 29 hours ago

Wakker worden: aankondigen en inhalen na echte afwezigheid

De wakker-helft van shaer-xzab (xzab.4 en .5), zoals Bart hem ontwierp: één
routine met twee helften, en wakker worden herkend aan een sprong in de
wandklok tussen twee ticks of aan het opstarten na lang uit -- niet aan elke
herstart, want npm herstart Klonkt bij elke opgeslagen wijziging.

services/wake.js. Het laatste teken van leven staat elke minuut in
app_settings (last_alive_at), zodat het een herstart overleeft. Meer dan een
half uur stilte is een terugkomst; dan:

.5 AANKONDIGEN: per site een actor-Update naar de gedeelde inboxes van de
volgers (deliverActorUpdate). Uit xzab.6: een binnenkomende POST is wat
Mastodon, Akkoma en Misskey weer naar ons laat bezorgen, en wat bij Pleroma
de controle van onze / start. Een half uur en niet langer, omdat Pleroma ons
al na één uitgeputte bezorging afschrijft.

.4 INHALEN: de outboxes van wie je volgt (alleen geaccepteerd), ondertekend,
vanaf het wegvallen. backfillFromOutbox kreeg daarvoor since; een post
zonder datum telt dan niet mee. Niet verder terug dan een week, hoogstens 40
posts per account en 200 accounts per site, na elkaar met een adempauze,
zodat een week weg geen storm wordt.

Eerst aankondigen, dan inhalen: dan bezorgen de anderen alweer terwijl wij
nog lezen. Een site die niet lukt, houdt de rest niet op.

test/wake.test.js: negen toetsen, het inhalen tegen een echte outbox op
127.0.0.1. Elf mutaties apart gedraaid, elk gevangen. Volledige suite 1371
groen.

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

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