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

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

auth: password admin + per-instance Google for listeners (no broker)

Robin's choice: every self-hoster has their own password admin account,
and can optionally let listeners log in to comment using their OWN Google
client. No central broker (that would tie every customer site to Robin's
Google Cloud -> systemic risk on abuse).

  • Admin = username/password (bcrypt). First-time setup via /auth/register (only when there are 0 users); closed afterwards. No public registration.
  • Forgot password: /auth/reset-request -> email (if SMTP configured) with reset link; CLI break-glass npm run reset-admin always works (no email needed).
  • Change password (logged in) restored in /account.
  • Google = per-instance own credentials, OPTIONAL, listeners only -> always role member, never admin (god/admin email is rejected; google_sub mismatch too).
  • config/google.js back to direct Google OAuth; config/mailer.js new (nodemailer).
  • jose removed from deps; nodemailer added.

Security review (workflow) incorporated:

  • Reset token no longer in production logs (dev only).
  • Reset link from PUBLIC_BASE_URL instead of X-Forwarded-Host (host poisoning).
  • Reset tokens stored SHA-256-hashed in the DB.
  • Same-origin check on all state-modifying POSTs (CSRF layer on top of sameSite-lax).
  • Login always runs one bcrypt comparison (no timing enumeration).

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

  • Property mode set to 100644
File size: 13.1 KB
RevLine 
[7bc636b]1/**
2 * PrutFolio v1 — server bootstrap
3 *
4 * Persoonlijk multi-site platform forked van PrutCMS v9 (PHP, file-based).
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 PrutterService from './services/PrutterService.js';
20import { WebSocketServer } from 'ws';
21
22import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
23import authRoutes from './routes/auth.js';
24import accountRoutes from './routes/account.js';
25import adminRoutes from './routes/admin.js';
26import adminAudioRoutes from './routes/admin-audio.js';
27import adminPlaylistsRoutes from './routes/admin-playlists.js';
28import adminSitesRoutes from './routes/admin-sites.js';
29import adminUsersRoutes from './routes/admin-users.js';
30import adminCommentsRoutes from './routes/admin-comments.js';
31import prutterRoutes from './routes/prutter.js';
32import audioRoutes from './routes/audio.js';
33import searchRoutes from './routes/search.js';
34import commentsRoutes from './routes/comments.js';
35import tagsRoutes from './routes/tags.js';
36import typesRoutes from './routes/types.js';
37import usersRoutes from './routes/users.js';
38import feedRoutes from './routes/feed.js';
39import postsRoutes from './routes/posts.js';
40
41if (!process.env.SESSION_SECRET) {
42 console.error('❌ FATAL: SESSION_SECRET is required');
43 process.exit(1);
44}
45
46if (process.env.NODE_ENV === 'production' && process.env.SESSION_SECRET.length < 32) {
47 console.error('❌ FATAL: SESSION_SECRET too weak for production');
48 process.exit(1);
49}
50
51const __dirname = path.dirname(fileURLToPath(import.meta.url));
52const PORT = process.env.PORT || 3000;
53const isDev = process.env.NODE_ENV !== 'production';
54
55const app = express();
56const server = http.createServer(app);
57
58app.use(helmet({
59 contentSecurityPolicy: {
60 directives: {
61 defaultSrc: ["'self'"],
62 scriptSrc: ["'self'", "'unsafe-inline'"],
63 styleSrc: ["'self'", "'unsafe-inline'"],
64 imgSrc: ["'self'", "data:", "https:"],
65 connectSrc: ["'self'", "wss:", "ws:"],
[353c39c]66 // blob: is required for the audio player — it fetch()es track bytes and
67 // plays from a blob: object URL (Spotify-style). Without blob: here the
68 // CSP silently blocks <audio>.src = blob:… → the player fires 'error' and
69 // auto-skips every track. 'self'/https: do NOT imply blob:.
70 mediaSrc: ["'self'", "https:", "blob:"],
[7bc636b]71 fontSrc: ["'self'"],
72 frameSrc: [
73 "'self'",
74 "https://open.spotify.com",
75 "https://w.soundcloud.com",
76 "https://bandcamp.com",
77 "https://embed.music.apple.com",
78 "https://www.youtube-nocookie.com",
79 "https://player.vimeo.com",
80 ],
81 },
82 },
83 hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
84 frameguard: { action: 'sameorigin' },
85 referrerPolicy: { policy: 'no-referrer-when-downgrade' },
86}));
87
88app.set('view engine', 'ejs');
89app.set('views', path.join(__dirname, 'views'));
90
91app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
92app.use(bodyParser.json({ limit: '10mb' }));
93
94// Trust one upstream proxy in production. NPM (or Caddy / nginx) terminates
95// HTTPS and forwards to us over plain HTTP, setting X-Forwarded-Proto: https.
96// Without this, Express sees req.protocol === 'http' and won't issue secure
97// cookies — sessions never persist past the redirect after login.
98if (!isDev) app.set('trust proxy', 1);
99
100// Session middleware extracted into a variable so the WebSocket upgrade
101// handler can reuse it (it needs req.session to authenticate sockets).
102const sessionMiddleware = session({
103 store: new SqliteSessionStore(),
104 secret: process.env.SESSION_SECRET,
105 resave: false,
106 saveUninitialized: false,
107 name: 'pcms.sid',
108 cookie: {
109 httpOnly: true,
110 secure: !isDev,
111 sameSite: 'lax',
112 maxAge: 30 * 24 * 60 * 60 * 1000,
113 },
114});
115app.use(sessionMiddleware);
116
117app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
118app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media'));
119
[942028f]120// (Verwijderd) TWA / digital-asset-links — alleen nodig voor de APK/TWA-variant.
121// Klonkt is PWA-only; geen assetlinks.json meer.
[7bc636b]122
123initializeDatabase();
124
125// Bundle HTMX: copy from node_modules into our own assets dir so we can serve
126// it locally (no third-party CDN). Idempotent — only copies if size differs.
127(function ensureLocalHtmx() {
128 const src = path.join(__dirname, '..', 'node_modules', 'htmx.org', 'dist', 'htmx.min.js');
129 const dest = path.join(__dirname, 'assets', 'js', 'htmx.min.js');
130 try {
131 const srcStat = fs.statSync(src);
132 const destStat = fs.existsSync(dest) ? fs.statSync(dest) : null;
133 if (!destStat || destStat.size !== srcStat.size) {
134 fs.copyFileSync(src, dest);
135 console.log(`📦 HTMX bundled locally: ${srcStat.size} bytes`);
136 }
137 } catch (e) {
138 console.warn('⚠️ Could not bundle HTMX:', e.message, '— run `npm install`');
139 }
140})();
141
142// Singleton PrutterService — routes get it via req.app.locals.prutter.
143const prutter = new PrutterService(db);
144app.locals.prutter = prutter;
145
146app.use(resolveSite);
147app.use(loadAudioTracks);
148app.use(loadTheme);
149
[9e27d64]150// Lichtgewicht CSRF-defense: weiger cross-origin state-wijzigende requests.
151// Same-origin forms + HTMX sturen een matchende Origin; ontbreekt Origin dan
152// laten we door (non-browser clients). sameSite:'lax' op de sessiecookie is de
153// tweede laag. (Geldt niet voor GET/HEAD/OPTIONS.)
154app.use((req, res, next) => {
155 if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
156 const origin = req.get('origin');
157 if (!origin) return next(); // geen Origin -> geen browser-CSRF-vector
158 let originHost;
159 try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); }
160 if (originHost !== req.get('host')) return res.status(403).send('Cross-origin request geweigerd');
161 next();
162});
163
[7bc636b]164app.use('/auth', authRoutes);
165app.use('/account', accountRoutes);
166app.use('/admin/audio', adminAudioRoutes);
167app.use('/admin/playlists', adminPlaylistsRoutes);
168app.use('/admin/sites', adminSitesRoutes);
169app.use('/admin/users', adminUsersRoutes);
170app.use('/admin/comments', adminCommentsRoutes);
171app.use('/admin', adminRoutes);
172app.use('/prutter', prutterRoutes);
173app.use('/audio', audioRoutes);
174app.use('/search', searchRoutes);
175app.use('/comments', commentsRoutes);
176app.use('/tag', tagsRoutes);
177app.use('/type', typesRoutes);
178app.use('/users', usersRoutes);
179// Feed/sitemap routes are mounted at root because they're at well-known paths
180app.use('/', feedRoutes);
181app.use('/', postsRoutes);
182
183app.get('/manifest.webmanifest', (req, res) => {
184 const site = res.locals.site;
185
186 // PWA scope: confines installed apps to ONE site. If a user is in the
187 // bedrijf1 PWA and clicks a link to /sites/bedrijf2/..., the browser will
188 // open it in a regular tab (out-of-scope) instead of within the PWA.
189 // Same applies to APK packaging — the WebView is locked to this scope.
190 //
191 // For path-mounted sites: scope = /sites/<slug>/
192 // For root/subdomain sites: scope = /
193 const base = res.locals.siteUrlBase || ''; // '' or '/sites/<slug>'
194 const scope = (base || '') + '/';
195 const startUrl = (base || '') + '/?source=pwa';
196
197 // A stable identity per site so installs don't collide (Chromium uses `id`)
198 const idBase = site?.slug ? `prutfolio-${site.slug}` : 'prutfolio';
199
200 res.set('Cache-Control', 'no-cache');
201 res.json({
202 id: idBase,
203 name: site?.title || 'PrutFolio',
204 short_name: (site?.title || 'PrutFolio').slice(0, 12),
205 description: site?.description || site?.tagline || '',
206 scope,
207 start_url: startUrl,
208 display: 'standalone',
209 display_override: ['standalone', 'minimal-ui'],
210 orientation: 'any',
211 background_color: '#1a1a17',
212 theme_color: site?.accent || '#c2410c',
213 lang: site?.language || 'nl',
214 icons: [
215 { src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
216 { src: '/favicon.ico', sizes: '64x64', type: 'image/x-icon' },
217 ],
218 // Hint to capable browsers: capture all in-scope links inside the PWA
219 capture_links: 'existing-client-navigate',
220 });
221});
222
223// Favicon — served as SVG so it picks up the site's accent color dynamically.
224// Browsers also request /favicon.ico by convention; we serve the same SVG
225// content there with a forgiving content-type since modern browsers accept it.
226function _renderFavicon(res, accent) {
227 const safeAccent = /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : '#c2410c';
[b5bae24]228 // Site mark: rounded square in the site accent + bold white 'SF'
[7bc636b]229 const svg = `<?xml version="1.0" encoding="UTF-8"?>
230<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
231 <rect width="64" height="64" rx="14" fill="${safeAccent}"/>
232 <text x="50%" y="50%" dy="0.36em" text-anchor="middle"
[b5bae24]233 font-family="Arial, Helvetica, sans-serif"
234 font-size="30" font-weight="800" letter-spacing="-1" fill="#fff">SF</text>
[7bc636b]235</svg>`;
236 res.set('Content-Type', 'image/svg+xml');
237 res.set('Cache-Control', 'public, max-age=86400');
238 res.send(svg);
239}
240
241app.get('/favicon.svg', (req, res) => {
242 _renderFavicon(res, res.locals.site?.accent);
243});
244app.get('/favicon.ico', (req, res) => {
245 // Browsers requesting .ico will accept SVG content; chrome/firefox both fine.
246 // Keeping the route prevents 404 spam in the console.
247 _renderFavicon(res, res.locals.site?.accent);
248});
249
250app.get('/sw.js', (req, res) => {
251 res.set('Content-Type', 'application/javascript');
252 res.set('Cache-Control', 'no-cache');
253 res.send(`
254const CACHE_VERSION = 'pcms-v10-' + new Date().toISOString().split('T')[0];
255self.addEventListener('install', e => {
256 e.waitUntil(caches.open(CACHE_VERSION).then(c => c.addAll(['/'])));
257 self.skipWaiting();
258});
259self.addEventListener('activate', e => {
260 e.waitUntil(caches.keys().then(keys => Promise.all(
261 keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k))
262 )));
263 self.clients.claim();
264});
265self.addEventListener('fetch', e => {
266 if (e.request.method !== 'GET') return;
267 e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
268});
269 `);
270});
271
272process.on('unhandledRejection', (reason) => {
273 console.error('⚠️ Unhandled Rejection:', reason);
274});
275
276app.use((err, req, res, next) => {
277 console.error('❌ Error:', err);
278 res.status(err.status || 500).send(
279 isDev ? `<pre>${err.stack || err.message}</pre>` : 'Internal Server Error'
280 );
281});
282
283app.use((req, res) => {
284 res.status(404).send(`
285 <div style="font-family:system-ui;max-width:500px;margin:4rem auto;text-align:center;padding:2rem;">
286 <h1 style="font-size:5rem;margin:0;color:#c33;">404</h1>
287 <p>Not found</p>
288 <a href="/" style="color:#c2410c;">← Home</a>
289 </div>
290 `);
291});
292
293// ==================== WebSocket: Prutter real-time ====================
294// Authenticate via the existing session cookie. We reuse sessionMiddleware
295// during the HTTP upgrade so req.session is populated; if no user, abort.
296const wss = new WebSocketServer({ noServer: true });
297
298server.on('upgrade', (req, socket, head) => {
299 if (req.url !== '/ws/prutter') {
300 socket.destroy();
301 return;
302 }
303 // Run session middleware on the upgrade request.
304 // (Express's middleware accepts (req, res, next); we pass a stub res.)
305 const stubRes = { setHeader: () => {}, getHeader: () => undefined, on: () => {}, end: () => {} };
306 sessionMiddleware(req, stubRes, () => {
307 if (!req.session?.user) {
308 socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
309 socket.destroy();
310 return;
311 }
312 wss.handleUpgrade(req, socket, head, (ws) => {
313 ws.userId = req.session.user.id;
314 wss.emit('connection', ws, req);
315 });
316 });
317});
318
319wss.on('connection', (ws) => {
320 prutter.addConnection(ws.userId, ws);
321 ws.on('close', () => prutter.removeConnection(ws.userId, ws));
322 ws.on('error', () => prutter.removeConnection(ws.userId, ws));
323 // Optional: ping every 30s to keep connections alive through proxies
324 ws.isAlive = true;
325 ws.on('pong', () => { ws.isAlive = true; });
326});
327const wsPing = setInterval(() => {
328 for (const ws of wss.clients) {
329 if (ws.isAlive === false) { ws.terminate(); continue; }
330 ws.isAlive = false;
331 try { ws.ping(); } catch {}
332 }
333}, 30000);
334if (wsPing.unref) wsPing.unref();
335
336server.listen(PORT, () => {
337 console.log('');
338 console.log('🪶 PrutFolio v1 — alpha');
339 console.log(` http://localhost:${PORT}`);
340 console.log('');
341 console.log(` ✓ Security: Helmet, CSP, secure sessions`);
342 console.log(` ✓ Privacy: Self-hosted fonts, no third-party requests`);
343 console.log(` ✓ Layout: v9 editorial feel (top nav, profile header)`);
[9e27d64]344 console.log(` ✓ Auth: wachtwoord (beheer) + Google (luisteraars) / logout`);
[7bc636b]345 console.log(` ✓ Posts: create / edit / view / archive`);
346 console.log(` ✓ Realtime: WebSocket server ready (Prutter)`);
347 console.log('');
348 console.log(` Mode: ${isDev ? 'development' : 'PRODUCTION'}`);
349 console.log('');
350});
351
352export default app;
Note: See TracBrowser for help on using the repository browser.