source: Klonkt/src/server.js@ 9da8027

main
Last change on this file since 9da8027 was 732272a, checked in by Robin <roboburr@…>, 4 weeks ago

Luisteraars: een eigen tab, want het is een eigen soort relatie (shaer-0nh)

Accounts die aan de BIBLIOTHEEK hangen in plaats van aan de actor. Ze krijgen de
muziek en met opzet NIET de gewone posts -- wie zich op een platenkast
abonneert heeft niet om de Krant gevraagd.

EEN EIGEN TABEL EN GEEN VLAG, en dat is de kern. Zolang ze in
ap_library_followers staan kan een postbezorging ze niet per ongeluk meenemen.
Een vlag op ap_followers die iemand vergeet te filteren doet dat wel, en die
fout is aan onze kant onzichtbaar: de posts komen gewoon aan bij mensen die er
niet om vroegen. De vorm moet de fout onmogelijk maken, niet alleen
onwaarschijnlijk. Een test bewaakt dat ap_followers leeg blijft.

WAT ER WERKT

  • Follow op /ap/users/<slug>/library wordt herkend, meteen geaccepteerd en vastgelegd. Meteen, want de bibliotheek is openbaar (alles erin is fedi_open) -- er valt niets goed te keuren, en dan is wachten oneerlijk.
  • Undo(Follow) haalt hem er meteen weer uit.
  • Volgen is idempotent; remote servers sturen een Follow gerust nog eens.
  • inboxen() ontdubbelt op gedeelde inbox: twee luisteraars op dezelfde instance krijgen EEN bezorging.
  • de tab in Mediabeheer, met naam, handle, sinds en laatste bezorging, in drie talen.

DE HERKENNING IS STRENG. libraryOwnerSlug eist dat de uri met ONZE basis begint
en dat de site bestaat -- via localSlugOf, dezelfde zeef als gisteren. Anders
levert andermans /library met dezelfde padstaart hier een luisteraar op onze
naam op.

WAT ER NOG NIET IS, en dat is de volgende stap: de BEZORGING. Er gaat nog geen
Create(Audio) naar deze inboxen. De lijst vult zich dus wel en "laatste
bezorging" blijft leeg -- eerlijk, want er is niets bezorgd.

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

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