source: Klonkt/src/server.js@ 746d98a

main
Last change on this file since 746d98a was 746d98a, checked in by Robin <roboburr@…>, 3 hours ago

Wardmodus: een besloten Klonkt met alleen het profiel openbaar

Robin, 30-9: "Ward Profile mode gaat actief wanneer ward gekoppeld is, dus
wanneer de persoon guardian(s) heeft. Een besloten Klonkt met enkel het profiel
van de ward publiek. Wij denken dat dit het beste valt bij emancipation om de
functie weer naar normaal Klonkt te brengen."

AFGELEID, NIET OPGESLAGEN. Heeft de site geaccepteerde guardians, dan staat de
modus aan; is de set leeg, dan uit. listGuardians telt alleen geaccepteerde
guardianships, dus een voorstel zet niets dicht, en de set leegmaken is
emancipatie (FEP-633c 3.4) -- precies waar het ontwerp de weg terug legt. Een
schakelaar zou een tweede waarheid naast de guardianship zijn.

STANDAARD DICHT (middleware/ward-profile.js, in server.js direct na de
siteresolver en de schrijfpoort). Een bezoeker ziet op / het profiel en verder
niets: een pagina gaat terug naar het profiel, al het andere is 404, een POST
bereikt zijn handler niet, en een htmx-navigatie krijgt HX-Redirect in plaats
van een brokstuk. Wat niet op de lijst staat komt er niet langs, zodat een
nieuwe route niet vanzelf een kind openzet.

Open blijft: inloggen, account, beheer, het guardianpaneel, /lang, de bestanden
van de pagina zelf, en /audio/stream -- die bewaakt elk bestand zelf, en een
track die de ward op de fediverse opende moet daar speelbaar blijven. De
federatie (ActivityPub, WebFinger, OAuth voor Shaer, /media, /assets, /og) hangt
al VOOR de poort; de paden staan toch op de lijst, voor als de volgorde ooit
omgaat, want dan zou een ward stil van de fediverse verdwijnen.

Wie alles ziet: de ward zelf, en god, kijker en sitebeheer. Een andere
ingelogde gebruiker is gewoon een bezoeker.

DE PROFIELPAGINA (pages/ward-profile.ejs) mag verzorgd, want dit is alles wat
een bezoeker ziet: foto met een ring in de accentkleur, de naam groot, de
handle op een pil, de bio, de links als brede knoppen in hun merkkleur, een
volgknop die de bestaande fediverse-dialoog opent (een volgverzoek aan een ward
gaat eerst langs de guardians), en een neutrale regel dat berichten alleen
voor volgers zijn. Nergens "kind" of "ward": dat de account guardians heeft
staat al openbaar op de fediverse. Dezelfde bronnen als de profielkop, zodat
het profiel op geen twee plekken iets anders kan zeggen. De chrome biedt een
bezoeker niets aan wat dicht is: geen zoekknop, geen archief, geen
Lezen/Grid-schakelaar.

DE PROFIELLINKS verhuizen van Appearance naar het account (/account#links),
bij naam, bio en foto. Twee vallen onderweg, allebei getoetst:

  • Appearance schreef profile_links altijd, uit de formuliervelden. Met de velden weg zou elke keer opslaan daar de links wissen. Die pagina schrijft ze niet meer.
  • ownedSite haalde profile_links niet op, dus de accountpagina toonde nul links en opslaan wiste ze. Gevonden bij het bekijken, niet door een toets; nu wel.

buildProfileLinks staat in PlatformIcons.js, en de rijen-knoppen in een eigen
module (mod/profile-links.js).

Getoetst: gewone Klonkt zonder guardians, een voorstel zet niets dicht,
gekoppeld alleen het profiel, pagina's terug, 404 voor wat geen pagina is, geen
handler voor een POST, HX-Redirect, wat open moet blijft open, ward en beheer
zien alles, een andere ingelogde is een bezoeker, emancipatie maakt het weer
gewoon, de links bij het account en Appearance wist ze niet. Tegenproef per
zaak. Volledige suite 1325 groen.

Nagekeken in de browser op 375 en 1280 pixels: het profiel past op een
telefoonscherm, knoppen van 55 pixels, geen overloop, de volgknop opent de
dialoog; de accountpagina toont de bestaande links en voegt er een toe.

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

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