source: Klonkt/src/server.js@ 2091647

main
Last change on this file since 2091647 was fbfd7a1, checked in by Robin <roboburr@…>, 4 weeks ago

Je berichten verhuizen mee: FEP-1580 plus een webinterface ervoor

FEP-7628 verhuist je volgers en zegt zelf dat de inhoud een ander probleem is.
Dat probleem stond open: na een Move bleven je berichten op de oude instantie
staan, en elke reactie van een derde wees naar een URI die verdwijnt zodra dat
domein opgezegd wordt. FEP-1580 regelt dat, status DRAFT.

DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant behandelt een ondertekend
verzoek namens de doel-actor alsof de bron-actor het zelf deed. Dat mag omdat
moveAccount() no_backreference weigert: moved_to staat er alleen als de
doel-actor ons al in alsoKnownAs had. Beide kanten hebben ooit ja gezegd, dus er
is geen tweede vertrouwensmechanisme nodig. Een typefout komt hier niet binnen,
want die haalt de move zelf niet. Dat dit veilig is leunt op de keyId-binding
uit shaer-xd8i: zonder die controle is "wie tekende dit" te zacht om je hele
geschiedenis aan af te geven.

NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een verhuisd bericht
krijgt een eigen URI, want het staat op een ander domein. De migration-collectie
mapt oud naar nieuw en derden lezen die om hun eigen verwijzingen bij te werken.
Zonder die collectie is de draad kapot, met die collectie is het een
verhuisbericht. Niet-publieke items staan er alleen in voor wie ze mocht zien:
een lijst met de URIs van je fan-only posts is een lek, ook zonder de inhoud.

Er gaat geen Create de deur uit. Je volgers hebben die berichten jaren geleden
al gezien; driehonderd posts die als nieuw de tijdlijn in klateren is geen
verhuizing maar spam.

Daarnaast /admin/migrate: exporteren, importeren en ophalen via de
webinterface, zodat verhuizen geen SSH-toegang meer vraagt. Importeren gaat
altijd eerst droog, met een verslag en pas daarna een knop die het echt doet.

Getest op twee draaiende instanties, A verhuisd naar B via de echte
moveAccount. Anoniem zag A 3 van de 4 berichten; ondertekend als de doel-actor
kwamen alle 4 mee, inclusief de fan-only. Titel, webadres en publicatiedatum
blijven staan. Media komt echt over: gedownload, in de mediatabel, B serveert
het. Migration-collectie 4 rijen totaal, 3 publiek.

Changed files:
src/services/ActivityPubService.js

  • isMoveTarget: het hele autorisatiepredicaat van de bronkant
  • outboxAudience en mayReadNote: de doel-actor krijgt onze eigen kijkrechten
  • buildActor adverteert migration en moves, ook leeg (de FEP wijst er apart op dat "niets verhuisd" anders niet te onderscheiden is van "kent dit niet")
  • signedGetJson geexporteerd, de ingest heeft hem nodig
  • isMoveTarget en signedGetJson in de default export (movedLock verstopte zich een dag eerder precies zo)

src/services/ap-core.js

  • FEP-1580-termen in de JSON-LD-context

src/services/ArchiveImportService.js

  • een import uit een zip vult dezelfde vertaaltabel; de spec wil dat een geexporteerde collectie identiek behandeld wordt

src/routes/activitypub.js

  • /ap/users/:slug/migration en /moves
  • de blocked-collectie gaat open voor de doel-actor, want zichtbaarheidsvoorkeuren moeten meeverhuizen

src/config/database.js

  • ap_migration en ap_moves, plus sites.migration_complete

src/server.js

  • /admin/migrate aangesloten

src/views/pages/admin.ejs

  • knop naar Migreren

src/services/i18n.js

  • mig.* in nl/en/de

New file:
src/services/MigrationService.js

  • de doelkant: ingest-routine, migration- en moves-collectie, statusvlag

src/routes/admin-migrate.js

  • exporteren, droog importeren, echt importeren, ophalen bij de oude Klonkt

src/views/pages/admin-migrate.ejs

  • de pagina

test/fep1580-migration.test.js

  • 22 tests over beide rollen, plus de regressietest bij 4101c89

remarks: FEP-8b32 ontbreekt volledig (shaer-j1v0), dus er staat geen
handtekening onder de moves-collectie en we zijn niet naleveringsklaar. Bewust
geen leeg proof-veld: een derde die het controleert wordt dan misleid. De DERDE
rol zit er ook niet in, Klonkt leest nog geen migration-collecties van anderen,
dus andermans verhuizing repareert onze verwijzingen nog niet. Alle betrokken
FEPs zijn DRAFT, ook 7628 die we al volgden; 1580 is vers en de auteur schrijft
zelf dat het een audit verdient.

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

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