source: Klonkt/src/server.js@ 91094a4

main
Last change on this file since 91094a4 was 91094a4, checked in by roboburr <roboburr@…>, 3 months ago

Download-for-email (premium feature #2)

Fan leaves their email -> receives the file; address is added to the mailing list
(source 'download', single opt-in so the download is not behind a confirm barrier).
Reuses SubscriberService (#1).

  • DB: audio_tracks.downloadable (default 0).
  • admin-audio: per-track no-JS toggle (POST /admin/audio/:id/downloadable) + ⬇ button in the admin list (premium-gated); downloadable also in the /api/:id whitelist.
  • routes/download.js (premium-gated): GET /downloads (list), GET /download/:id (capture page), POST /download/:id (save email + session grant), GET /download/:id/bestand (serves attachment from storage/audio, 15-min session window, path-traversal guards).
  • views: pages/downloads.ejs + pages/download.ejs (form/ready, auto-start download).
  • admin dashboard: ⬇ Downloads link (premium, non-hub).

node --check passed. Nothing else reuses this; #8 notify reuses subscribers.

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

  • Property mode set to 100644
File size: 19.1 KB
Line 
1/**
2 * Klonkt Beta — server bootstrap
3 *
4 * Persoonlijk 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 { fileURLToPath } from 'url';
16import http from 'http';
17import db, { initializeDatabase } from './config/database.js';
18import { SqliteSessionStore } from './services/SqliteSessionStore.js';
19import { ensurePrimarySite } from './services/ensurePrimarySite.js';
20import PrutterService from './services/PrutterService.js';
21import { WebSocketServer } from 'ws';
22
23import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
24import { isViewer } from './middleware/auth.js';
25import { renderPage } from './middleware/render.js';
26import authRoutes from './routes/auth.js';
27import accountRoutes from './routes/account.js';
28import adminRoutes from './routes/admin.js';
29import adminAudioRoutes from './routes/admin-audio.js';
30import adminPlaylistsRoutes from './routes/admin-playlists.js';
31import adminSitesRoutes from './routes/admin-sites.js';
32import adminUsersRoutes from './routes/admin-users.js';
33import adminCommentsRoutes from './routes/admin-comments.js';
34import adminSettingsRoutes from './routes/admin-settings.js';
35import adminSeoRoutes from './routes/admin-seo.js';
36import prutterRoutes from './routes/prutter.js';
37import audioRoutes from './routes/audio.js';
38import searchRoutes from './routes/search.js';
39import commentsRoutes from './routes/comments.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 hubRoutes from './routes/hub.js';
45import artistsRoutes from './routes/artists.js';
46import postsRoutes from './routes/posts.js';
47import federationRoutes from './routes/federation.js';
48import { startCircleSyncLoop } from './services/CircleService.js';
49import adminCircleRoutes from './routes/admin-circle.js';
50import adminUpdatesRoutes from './routes/admin-updates.js';
51import adminPatreonRoutes from './routes/admin-patreon.js';
52import adminStatsRoutes from './routes/admin-stats.js';
53import circleRoutes from './routes/circle.js';
54import epkRoutes from './routes/epk.js';
55import newsletterRoutes from './routes/newsletter.js';
56import adminNewsletterRoutes from './routes/admin-newsletter.js';
57import downloadRoutes from './routes/download.js';
58
59if (!process.env.SESSION_SECRET) {
60 console.error('❌ FATAL: SESSION_SECRET is required');
61 process.exit(1);
62}
63
64if (process.env.NODE_ENV === 'production' && process.env.SESSION_SECRET.length < 32) {
65 console.error('❌ FATAL: SESSION_SECRET too weak for production');
66 process.exit(1);
67}
68
69const __dirname = path.dirname(fileURLToPath(import.meta.url));
70const PORT = process.env.PORT || 3000;
71const isDev = process.env.NODE_ENV !== 'production';
72
73const app = express();
74const server = http.createServer(app);
75
76app.use(helmet({
77 contentSecurityPolicy: {
78 directives: {
79 defaultSrc: ["'self'"],
80 scriptSrc: [
81 "'self'",
82 "'unsafe-inline'",
83 // Eigen custom-embeds (embed-player.js) laden de OFFICIELE player-API's
84 // van deze hosts. Zonder deze whitelist blokkeert de CSP ze stil (alleen
85 // een console-fout) en faalt de embed-speler.
86 "https://www.youtube.com", // YouTube IFrame Player API (+ www-widgetapi.js)
87 "https://s.ytimg.com", // YouTube player-assets
88 "https://w.soundcloud.com", // SoundCloud Widget API (api.js)
89 "https://open.spotify.com", // Spotify iFrame API
90 ],
91 // Helmet's default zet script-src-attr op 'none', wat ALLE inline event-
92 // handlers (onchange/onclick/onsubmit) blokkeert — daardoor deed o.a. de
93 // avatar-upload (<input onchange="this.form.submit()">) en de rol-dropdown
94 // niets. We staan inline handlers expliciet toe, consistent met de al
95 // toegestane inline <script> hierboven.
96 scriptSrcAttr: ["'unsafe-inline'"],
97 styleSrc: ["'self'", "'unsafe-inline'"],
98 imgSrc: ["'self'", "data:", "https:"],
99 connectSrc: ["'self'", "wss:", "ws:"],
100 // blob: is required for the audio player — it fetch()es track bytes and
101 // plays from a blob: object URL (Spotify-style). Without blob: here the
102 // CSP silently blocks <audio>.src = blob:… → the player fires 'error' and
103 // auto-skips every track. 'self'/https: do NOT imply blob:.
104 mediaSrc: ["'self'", "https:", "blob:"],
105 fontSrc: ["'self'"],
106 frameSrc: [
107 "'self'",
108 "https://open.spotify.com",
109 "https://w.soundcloud.com",
110 "https://bandcamp.com",
111 "https://embed.music.apple.com",
112 "https://www.youtube-nocookie.com",
113 "https://www.youtube.com", // YouTube IFrame API maakt soms een www.youtube.com-iframe
114 "https://player.vimeo.com",
115 ],
116 },
117 },
118 hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
119 frameguard: { action: 'sameorigin' },
120 referrerPolicy: { policy: 'no-referrer-when-downgrade' },
121}));
122
123app.set('view engine', 'ejs');
124app.set('views', path.join(__dirname, 'views'));
125
126app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
127app.use(bodyParser.json({ limit: '10mb' }));
128
129// Trust one upstream proxy in production. NPM (or Caddy / nginx) terminates
130// HTTPS and forwards to us over plain HTTP, setting X-Forwarded-Proto: https.
131// Without this, Express sees req.protocol === 'http' and won't issue secure
132// cookies — sessions never persist past the redirect after login.
133if (!isDev) app.set('trust proxy', 1);
134
135// Schema aanmaken/bijwerken VÓÓR iets de DB aanraakt: de session-store doet
136// bij constructie al een query op de `sessions`-tabel, dus bij een verse
137// install moeten de tabellen eerst bestaan (anders: "no such table: sessions"
138// → crash-loop op de allereerste boot).
139initializeDatabase();
140
141// Vangnet: garandeer dat er altijd een primaire site is (solo/hub/circle).
142// Idempotent — doet niets als er al een site is of nog geen beheerder.
143ensurePrimarySite();
144
145// Session middleware extracted into a variable so the WebSocket upgrade
146// handler can reuse it (it needs req.session to authenticate sockets).
147const sessionMiddleware = session({
148 store: new SqliteSessionStore(),
149 secret: process.env.SESSION_SECRET,
150 resave: false,
151 saveUninitialized: false,
152 name: 'pcms.sid',
153 cookie: {
154 httpOnly: true,
155 secure: !isDev,
156 sameSite: 'lax',
157 maxAge: 30 * 24 * 60 * 60 * 1000,
158 },
159});
160app.use(sessionMiddleware);
161
162app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
163app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
164 // Publieke media (post-covers, avatars) moet door andere Klonkt-sites in hun
165 // CIRKEL cross-origin embedbaar zijn. Helmet zet standaard CORP=same-origin,
166 // wat die afbeeldingen in de browser blokkeert (bestand komt wél binnen, maar
167 // de browser weigert 'm te tonen). Voor /media dus expliciet cross-origin.
168 setHeaders: (res) => res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'),
169}));
170
171// (Verwijderd) TWA / digital-asset-links — alleen nodig voor de APK/TWA-variant.
172// Klonkt is PWA-only; geen assetlinks.json meer.
173
174// Cirkels: periodieke achtergrond-sync van remote instances (no-op tenzij tenancy='circle').
175startCircleSyncLoop();
176
177// Bundle HTMX: copy from node_modules into our own assets dir so we can serve
178// it locally (no third-party CDN). Idempotent — only copies if size differs.
179(function ensureLocalHtmx() {
180 const src = path.join(__dirname, '..', 'node_modules', 'htmx.org', 'dist', 'htmx.min.js');
181 const dest = path.join(__dirname, 'assets', 'js', 'htmx.min.js');
182 try {
183 const srcStat = fs.statSync(src);
184 const destStat = fs.existsSync(dest) ? fs.statSync(dest) : null;
185 if (!destStat || destStat.size !== srcStat.size) {
186 fs.copyFileSync(src, dest);
187 console.log(`📦 HTMX bundled locally: ${srcStat.size} bytes`);
188 }
189 } catch (e) {
190 console.warn('⚠️ Could not bundle HTMX:', e.message, '— run `npm install`');
191 }
192})();
193
194// Singleton PrutterService — routes get it via req.app.locals.prutter.
195const prutter = new PrutterService(db);
196app.locals.prutter = prutter;
197
198// Cirkels-federatie: publieke, site-agnostische endpoints (/.klonkt/*).
199// Vóór resolveSite/theme — ze hebben geen site-context nodig.
200app.use(federationRoutes);
201
202app.use(resolveSite);
203app.use(loadAudioTracks);
204app.use(loadTheme);
205
206// Lichtgewicht CSRF-defense: weiger cross-origin state-wijzigende requests.
207// Same-origin forms + HTMX sturen een matchende Origin; ontbreekt Origin dan
208// laten we door (non-browser clients). sameSite:'lax' op de sessiecookie is de
209// tweede laag. (Geldt niet voor GET/HEAD/OPTIONS.)
210app.use((req, res, next) => {
211 if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
212 const origin = req.get('origin');
213 if (!origin) return next(); // geen Origin -> geen browser-CSRF-vector
214 let originHost;
215 try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); }
216 if (originHost !== req.get('host')) return res.status(403).send('Cross-origin request geweigerd');
217 next();
218});
219
220// Kijker-accounts: alles bekijken mag (incl. Beheer), niets wijzigen. Dit is de
221// ENIGE schrijf-blokkade — fail-closed, vóór alle route-handlers. Elke state-
222// wijzigende methode wordt geweigerd (de login-POST zet de sessie pas ná deze
223// guard, dus die valt er niet onder). I.p.v. rauwe 403-tekst tonen we een nette
224// pagina (of, bij HTMX, een ingeswapte melding).
225app.use((req, res, next) => {
226 const mutating = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS';
227 if (mutating && isViewer(req.session?.user)) {
228 if (req.headers['hx-request'] === 'true') {
229 // htmx swapt niet op 4xx; stuur 200 + retarget zodat de melding in #pcms-main verschijnt.
230 res.setHeader('HX-Retarget', '#pcms-main');
231 res.setHeader('HX-Reswap', 'innerHTML');
232 res.status(200);
233 } else {
234 res.status(403);
235 }
236 return renderPage(req, res, 'pages/viewer-blocked', {
237 pageTitle: 'Kijker-modus',
238 bodyClass: 'on-special',
239 });
240 }
241 next();
242});
243
244app.use('/auth', authRoutes);
245app.use('/account', accountRoutes);
246app.use('/admin/audio', adminAudioRoutes);
247app.use('/admin/playlists', adminPlaylistsRoutes);
248app.use('/admin/sites', adminSitesRoutes);
249app.use('/admin/users', adminUsersRoutes);
250app.use('/admin/comments', adminCommentsRoutes);
251app.use('/admin/settings', adminSettingsRoutes);
252app.use('/admin/seo', adminSeoRoutes);
253app.use('/admin/circle', adminCircleRoutes);
254app.use('/admin/updates', adminUpdatesRoutes);
255app.use('/admin/patreon', adminPatreonRoutes);
256app.use('/admin/stats', adminStatsRoutes);
257app.use('/admin/newsletter', adminNewsletterRoutes);
258app.use('/admin', adminRoutes);
259app.use('/prutter', prutterRoutes);
260app.use('/audio', audioRoutes);
261app.use('/search', searchRoutes);
262app.use('/comments', commentsRoutes);
263app.use('/tag', tagsRoutes);
264app.use('/type', typesRoutes);
265app.use('/users', usersRoutes);
266// Feed/sitemap routes are mounted at root because they're at well-known paths
267app.use('/', feedRoutes);
268app.use('/leden', artistsRoutes); // doorzoekbare leden-directory (alleen hub; solo: next())
269app.get('/artiesten', (req, res) => res.redirect(301, req.originalUrl.replace(/^\/artiesten/, '/leden'))); // oude URL -> /leden
270app.use('/', hubRoutes); // hub-overview op '/' (solo: next() -> postsRoutes)
271app.use('/', circleRoutes); // /cirkel-feed (solo/hub: next() -> postsRoutes)
272app.use('/', epkRoutes); // /pers perskit (premium; niet-premium: next() -> 404)
273app.use('/', newsletterRoutes); // /nieuwsbrief in/uitschrijven (premium; niet-premium: next())
274app.use('/', downloadRoutes); // /downloads + /download/:id download-voor-email (premium)
275app.use('/', postsRoutes);
276
277app.get('/manifest.webmanifest', (req, res) => {
278 const site = res.locals.site;
279
280 // PWA scope: confines installed apps to ONE site. If a user is in the
281 // bedrijf1 PWA and clicks a link to /sites/bedrijf2/..., the browser will
282 // open it in a regular tab (out-of-scope) instead of within the PWA.
283 // Same applies to APK packaging — the WebView is locked to this scope.
284 //
285 // For path-mounted sites: scope = /sites/<slug>/
286 // For root/subdomain sites: scope = /
287 const base = res.locals.siteUrlBase || ''; // '' or '/sites/<slug>'
288 const scope = (base || '') + '/';
289 const startUrl = (base || '') + '/?source=pwa';
290
291 // A stable identity per site so installs don't collide (Chromium uses `id`).
292 // NB: een id-wissel orphant bestaande PWA-installs (er is geen migratie die een
293 // install over een id-verandering heen tilt) — wie een site al als PWA had,
294 // moet 'm één keer opnieuw installeren. Data blijft server-side, dus niets kwijt.
295 const idBase = site?.slug ? `klonkt-${site.slug}` : 'klonkt';
296
297 res.set('Cache-Control', 'no-cache');
298 res.json({
299 id: idBase,
300 name: site?.title || 'Klonkt',
301 short_name: (site?.title || 'Klonkt').slice(0, 12),
302 description: site?.description || site?.tagline || '',
303 scope,
304 start_url: startUrl,
305 display: 'standalone',
306 display_override: ['standalone', 'minimal-ui'],
307 orientation: 'any',
308 background_color: '#1a1a17',
309 theme_color: site?.accent || '#c2410c',
310 lang: site?.language || 'nl',
311 icons: [
312 { src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
313 { src: '/favicon.ico', sizes: '64x64', type: 'image/x-icon' },
314 ],
315 // Hint to capable browsers: capture all in-scope links inside the PWA
316 capture_links: 'existing-client-navigate',
317 });
318});
319
320// Favicon — served as SVG so it picks up the site's accent color dynamically.
321// Browsers also request /favicon.ico by convention; we serve the same SVG
322// content there with a forgiving content-type since modern browsers accept it.
323function _renderFavicon(res, accent) {
324 const safeAccent = /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : '#c2410c';
325 // Site mark: rounded square in the site accent + bold white 'K' (Klonkt)
326 const svg = `<?xml version="1.0" encoding="UTF-8"?>
327<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
328 <rect width="64" height="64" rx="14" fill="${safeAccent}"/>
329 <text x="50%" y="50%" dy="0.35em" text-anchor="middle"
330 font-family="Arial, Helvetica, sans-serif"
331 font-size="42" font-weight="800" fill="#fff">K</text>
332</svg>`;
333 res.set('Content-Type', 'image/svg+xml');
334 res.set('Cache-Control', 'public, max-age=86400');
335 res.send(svg);
336}
337
338app.get('/favicon.svg', (req, res) => {
339 _renderFavicon(res, res.locals.site?.accent);
340});
341app.get('/favicon.ico', (req, res) => {
342 // Browsers requesting .ico will accept SVG content; chrome/firefox both fine.
343 // Keeping the route prevents 404 spam in the console.
344 _renderFavicon(res, res.locals.site?.accent);
345});
346
347app.get('/sw.js', (req, res) => {
348 res.set('Content-Type', 'application/javascript');
349 res.set('Cache-Control', 'no-cache');
350 res.send(`
351const CACHE_VERSION = 'pcms-v11-' + new Date().toISOString().split('T')[0];
352self.addEventListener('install', e => {
353 e.waitUntil(caches.open(CACHE_VERSION).then(c => c.addAll(['/'])));
354 self.skipWaiting();
355});
356self.addEventListener('activate', e => {
357 e.waitUntil(caches.keys().then(keys => Promise.all(
358 keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k))
359 )));
360 self.clients.claim();
361});
362// ALLEEN navigaties (HTML-pagina's) onderscheppen, voor een offline-fallback.
363// Afbeeldingen, CSS, JS en /media NIET aanraken — laat de browser die native
364// afhandelen. Anders kon een mislukte netwerk-fetch terugvallen op een lege
365// cache-match (undefined) en zo een afbeelding "kapot" maken bij een gewone
366// refresh (hard reload omzeilt de SW en werkte daarom wél).
367self.addEventListener('fetch', e => {
368 if (e.request.method !== 'GET') return;
369 if (e.request.mode !== 'navigate') return; // alleen page-loads
370 e.respondWith(
371 fetch(e.request).catch(() => caches.match('/').then(r => r || Response.error()))
372 );
373});
374 `);
375});
376
377process.on('unhandledRejection', (reason) => {
378 console.error('⚠️ Unhandled Rejection:', reason);
379});
380
381app.use((err, req, res, next) => {
382 console.error('❌ Error:', err);
383 res.status(err.status || 500).send(
384 isDev ? `<pre>${err.stack || err.message}</pre>` : 'Internal Server Error'
385 );
386});
387
388app.use((req, res) => {
389 res.status(404).send(`
390 <div style="font-family:system-ui;max-width:500px;margin:4rem auto;text-align:center;padding:2rem;">
391 <h1 style="font-size:5rem;margin:0;color:#c33;">404</h1>
392 <p>Not found</p>
393 <a href="/" style="color:#c2410c;">← Home</a>
394 </div>
395 `);
396});
397
398// ==================== WebSocket: Prutter real-time ====================
399// Authenticate via the existing session cookie. We reuse sessionMiddleware
400// during the HTTP upgrade so req.session is populated; if no user, abort.
401const wss = new WebSocketServer({ noServer: true });
402
403server.on('upgrade', (req, socket, head) => {
404 if (req.url !== '/ws/prutter') {
405 socket.destroy();
406 return;
407 }
408 // Run session middleware on the upgrade request.
409 // (Express's middleware accepts (req, res, next); we pass a stub res.)
410 const stubRes = { setHeader: () => {}, getHeader: () => undefined, on: () => {}, end: () => {} };
411 sessionMiddleware(req, stubRes, () => {
412 if (!req.session?.user) {
413 socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
414 socket.destroy();
415 return;
416 }
417 // Kijker-accounts zijn alleen-lezen: weiger de WS-upgrade. De HTTP-guard
418 // dekt geen WS, dus dit is de plek om schrijven via een (toekomstige)
419 // message-handler te voorkomen.
420 if (isViewer(req.session.user)) {
421 socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
422 socket.destroy();
423 return;
424 }
425 wss.handleUpgrade(req, socket, head, (ws) => {
426 ws.userId = req.session.user.id;
427 wss.emit('connection', ws, req);
428 });
429 });
430});
431
432wss.on('connection', (ws) => {
433 prutter.addConnection(ws.userId, ws);
434 ws.on('close', () => prutter.removeConnection(ws.userId, ws));
435 ws.on('error', () => prutter.removeConnection(ws.userId, ws));
436 // Optional: ping every 30s to keep connections alive through proxies
437 ws.isAlive = true;
438 ws.on('pong', () => { ws.isAlive = true; });
439});
440const wsPing = setInterval(() => {
441 for (const ws of wss.clients) {
442 if (ws.isAlive === false) { ws.terminate(); continue; }
443 ws.isAlive = false;
444 try { ws.ping(); } catch {}
445 }
446}, 30000);
447if (wsPing.unref) wsPing.unref();
448
449server.listen(PORT, () => {
450 console.log('');
451 console.log('🪶 Klonkt Beta');
452 console.log(` http://localhost:${PORT}`);
453 console.log('');
454 console.log(` ✓ Security: Helmet, CSP, secure sessions`);
455 console.log(` ✓ Privacy: Self-hosted fonts, no third-party requests`);
456 console.log(` ✓ Layout: v9 editorial feel (top nav, profile header)`);
457 console.log(` ✓ Auth: wachtwoord (beheer) + Google (luisteraars) / logout`);
458 console.log(` ✓ Posts: create / edit / view / archive`);
459 console.log(` ✓ Realtime: WebSocket server ready (Prutter)`);
460 console.log('');
461 console.log(` Mode: ${isDev ? 'development' : 'PRODUCTION'}`);
462 console.log('');
463});
464
465export default app;
Note: See TracBrowser for help on using the repository browser.