Changeset 834bcc3 in Klonkt for src/routes
- Timestamp:
- 06/23/2026 06:14:27 PM (3 months ago)
- Branches:
- main
- Children:
- d774679
- Parents:
- bb42dfb
- Location:
- src/routes
- Files:
-
- 33 edited
-
account.js (modified) (7 diffs)
-
admin-audio.js (modified) (11 diffs)
-
admin-circle.js (modified) (6 diffs)
-
admin-comments.js (modified) (1 diff)
-
admin-epk.js (modified) (2 diffs)
-
admin-newsletter.js (modified) (2 diffs)
-
admin-patreon.js (modified) (1 diff)
-
admin-seo.js (modified) (1 diff)
-
admin-settings.js (modified) (14 diffs)
-
admin-shows.js (modified) (3 diffs)
-
admin-sites.js (modified) (8 diffs)
-
admin-stats.js (modified) (2 diffs)
-
admin-updates.js (modified) (3 diffs)
-
admin-users.js (modified) (4 diffs)
-
admin.js (modified) (6 diffs)
-
artists.js (modified) (2 diffs)
-
audio.js (modified) (2 diffs)
-
auth.js (modified) (26 diffs)
-
changelog.js (modified) (1 diff)
-
circle.js (modified) (5 diffs)
-
comments.js (modified) (2 diffs)
-
download.js (modified) (10 diffs)
-
embed.js (modified) (2 diffs)
-
epk.js (modified) (4 diffs)
-
federation.js (modified) (4 diffs)
-
hub.js (modified) (5 diffs)
-
lang.js (modified) (2 diffs)
-
linkbio.js (modified) (3 diffs)
-
newsletter.js (modified) (3 diffs)
-
notifications.js (modified) (1 diff)
-
posts.js (modified) (16 diffs)
-
search.js (modified) (7 diffs)
-
shows.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/account.js
rbb42dfb r834bcc3 66 66 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth'); 67 67 const googleLinked = !!(account && account.google_sub); 68 if (account) { delete account.password_hash; delete account.google_sub; } // niet naar de view lekken68 if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view 69 69 70 70 renderPage(req, res, 'pages/account', { … … 81 81 }); 82 82 83 // ==================== PERSO ONLIJKE INTERFACE-TAAL====================84 // S laat de taalkeuze op het account op (reist mee over apparaten/sessies) én85 // zet 'm meteen in de sessie zodat 't direct effect heeft.83 // ==================== PERSONAL INTERFACE LANGUAGE ==================== 84 // Saves the language choice on the account (persists across devices/sessions) and 85 // also sets it in the session immediately so it takes effect right away. 86 86 router.post('/lang', requireAuth, (req, res) => { 87 87 const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null; … … 94 94 }); 95 95 96 // De site die deze gebruiker mag bewerken vanuit z'n account: z'n eigen site97 // (owner_id), o f voor een god de primaire site. Null als er niets is.96 // The site this user may edit from their account: their own site 97 // (owner_id), or for a god the primary site. Null if nothing found. 98 98 function ownedSite(user) { 99 99 if (!user) return null; 100 100 let site = db.prepare('SELECT id, title, tagline, slug, owner_id FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id); 101 101 if (!site && user.role === 'god') { 102 site = getPrimarySite(); // prima ire/hoofd-site als fallback102 site = getPrimarySite(); // primary/main site as fallback 103 103 } 104 104 return site || null; … … 124 124 const bio = (req.body.bio || '').toString().slice(0, 500).trim(); 125 125 126 // E -mail (optioneel mee te wijzigen). Validatie: geldig formaat + niet al door127 // een ander account in gebruik. E-mail is het login-/reset-anker, dus uniek.126 // Email (optionally also changed). Validation: valid format + not already in use 127 // by another account. Email is the login/reset anchor, so it must be unique. 128 128 const email = (req.body.email || '').toString().trim(); 129 129 if (email) { … … 138 138 db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') 139 139 .run(email, req.session.user.id); 140 req.session.user.email = email; // sessie bijwerken zodat de UI klopt140 req.session.user.email = email; // update session so the UI reflects the change 141 141 } 142 142 … … 166 166 167 167 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id); 168 // Google-only accounts (l uisteraars) hebben geen echt wachtwoord.168 // Google-only accounts (listeners) have no real password. 169 169 if (!row || !row.password_hash || row.password_hash === '!google-oauth') { 170 170 return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)')); … … 181 181 }); 182 182 183 // Google-account ontkoppelen. Alleen toegestaan als er nog een wachtwoord is,184 // anders zou je jezelf buitensluiten (geen login-methode meer over).183 // Unlink Google account. Only allowed if a password is set, 184 // otherwise the user would lock themselves out (no login method left). 185 185 router.post('/google/unlink', requireAuth, (req, res) => { 186 186 const row = db.prepare('SELECT password_hash, google_sub FROM users WHERE id = ?').get(req.session.user.id); -
src/routes/admin-audio.js
rbb42dfb r834bcc3 38 38 const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']); 39 39 const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); 40 const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — gecomprimeerde formaten(mp3/m4a/ogg/…)41 const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is ongecomprimeerd, dus ruimer40 const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — compressed formats (mp3/m4a/ogg/…) 41 const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is uncompressed, so a higher limit 42 42 const MAX_COVER_BYTES = 5 * 1024 * 1024; // 5 MB 43 43 44 // Per- bestand bovengrens op basis van extensie. multer's globale limiet is de45 // h oogste (WAV); de echte controle per type gebeurt in de upload-handler.44 // Per-file upper limit based on extension. multer's global limit is the 45 // highest (WAV); the real per-type check happens in the upload handler. 46 46 const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES); 47 47 … … 59 59 const upload = multer({ 60 60 storage, 61 limits: { fileSize: MAX_WAV_BYTES }, // h oogste bovengrens (WAV) — per-type check in de handler61 limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler 62 62 fileFilter: (req, file, cb) => { 63 63 const ext = path.extname(file.originalname).toLowerCase(); … … 73 73 const router = express.Router(); 74 74 75 // "Open in" -platformlinks per track: alleen https + de juiste host accepteren76 // (href komt ongeescaped in de view → scheme/host-guard tegen misbruik).75 // "Open in" platform links per track: only https + the correct host accepted 76 // (href arrives unescaped in the view → scheme/host guard against abuse). 77 77 const LINK_DOMAINS = { 78 78 spotify: ['spotify.com'], … … 86 86 const h = new URL(u).hostname.toLowerCase(); 87 87 if (domains.some((d) => h === d || h.endsWith('.' + d))) return u; 88 } catch (e) { /* ongeldigeURL */ }88 } catch (e) { /* invalid URL */ } 89 89 return null; 90 90 } … … 148 148 } 149 149 150 // Per-type audio size check. multer's global e limiet was de WAV-bovengrens151 // (100MB); gecomprimeerde formaten blijven op50MB.150 // Per-type audio size check. multer's global limit was the WAV upper bound 151 // (100MB); compressed formats stay at 50MB. 152 152 const audioExt = path.extname(audioFile.originalname).toLowerCase(); 153 153 const audioLimit = audioByteLimitFor(audioExt); … … 185 185 const finalArtist = artist?.trim() || null; 186 186 const finalAlbum = album?.trim() || null; 187 // Eigenaarschap/licentie. credit valt terug op de artiest; deze gaan zowel de188 // DB in als de ID3-tags van de mp3 (copyright + comment).187 // Ownership/licence. credit falls back to the artist; these go both into the 188 // DB and into the ID3 tags of the mp3 (copyright + comment). 189 189 const finalCredit = (req.body.credit || '').trim() || finalArtist || null; 190 190 const finalLicense = (req.body.license || '').trim() || null; … … 231 231 `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path); 232 232 233 // Du ur automatisch: primair uit de transcode (ffmpeg codecData), anders een234 // optionele client-side waarde (bulk-uploader leest <audio>.duration uit),235 // anders NULL (UI toont dan '—:—', handmatig bij te werken in de editor).233 // Duration automatically: primarily from the transcode (ffmpeg codecData), then 234 // an optional client-side value (bulk uploader reads <audio>.duration), 235 // otherwise NULL (UI then shows '—:—', editable manually in the editor). 236 236 const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN; 237 237 const finalDuration = … … 275 275 }); 276 276 277 // Download- voor-email per track aan/uit (premium #2). Zonder-JS toggle vanaf de278 // audio -beheerlijst → flip + terug.277 // Download-for-email per track on/off (premium #2). No-JS toggle from the 278 // audio admin list → flip + back. 279 279 router.post('/:id/downloadable', requireGod, (req, res) => { 280 280 const site = res.locals.site; … … 394 394 395 395 /** GET /admin/audio/api/:id — single track with all metadata */ 396 // Maak een track ZONDER audiobestand (alleen titel + open-in links). Verschijnt397 // in albums/playlists in de lijst, met open-in-iconen maar zonder afspeelknop.396 // Create a track WITHOUT an audio file (title + open-in links only). Appears 397 // in albums/playlists in the list, with open-in icons but no play button. 398 398 router.post('/create-link', requireGod, express.json(), (req, res) => { 399 399 const site = res.locals.site; … … 507 507 } 508 508 509 // Verse rij + (als tag-velden wijzigden) de mp3 her-taggen, zodat de eigenaar/510 // licen tie ook IN het bestand staat (ID3) en meereist bij een download.509 // Fresh row + (if tag fields changed) retag the mp3, so that the owner/ 510 // licence is also IN the file (ID3) and travels with it on download. 511 511 const fresh = db.prepare(` 512 512 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path … … 527 527 } }); 528 528 } catch (e) { 529 console.warn('[admin-audio] ID3 her-taggen mislukt (DB is wel bijgewerkt):', e.message);529 console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message); 530 530 } 531 531 } -
src/routes/admin-circle.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: Cir kel-beheer(god-only).3 * GET /admin/circle -> li jst van cirkel-links + status4 * POST /admin/circle/add -> Klonkt-URL toevoegen2 * Admin: Circle management (god-only). 3 * GET /admin/circle -> list of circle links + status 4 * POST /admin/circle/add -> add a Klonkt URL 5 5 * POST /admin/circle/:id/remove 6 * POST /admin/circle/:id/sync -> nu verversen (pull + verifieer)7 * POST /admin/circle/allow -> toggle "ma g in cirkels van anderen verschijnen"6 * POST /admin/circle/:id/sync -> refresh now (pull + verify) 7 * POST /admin/circle/allow -> toggle "may appear in others' circles" 8 8 * 9 * Zie docs/cirkels-v1-spec.md §5d.9 * See docs/cirkels-v1-spec.md §5d. 10 10 */ 11 11 … … 51 51 const site = primarySite(); 52 52 if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden')); 53 // Schema automatisch aanvullen: een kale domeinnaam → https://, een getypte54 // http:// → https:// (federati e is bewust https-only, getekende feeds). Zo hoeft55 // de gebruiker nooit zelf http(s):// te typen.53 // Auto-complete the scheme: a bare domain name → https://, a typed 54 // http:// → https:// (federation is intentionally https-only, signed feeds). So the 55 // user never has to type http(s):// themselves. 56 56 let url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, ''); 57 57 if (url && !/^[a-z]+:\/\//i.test(url)) url = 'https://' + url; … … 68 68 return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel')); 69 69 } 70 // Meteen ophalen i.p.v. wachten op de 15-min-loop.70 // Fetch immediately instead of waiting for the 15-minute loop. 71 71 try { 72 72 const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(id); … … 75 75 } catch (e) { 76 76 const msg = String((e && e.message) || e); 77 // 404 = geen cirkel-endpoint. Hubs federeren bewust NIET (hun/.klonkt/actor.json78 // geeft 404), net als losse niet-Klonkt-sites. Niet toevoegen: rol de insert terug79 // zodat er geen dode "fout"-rij in de cirkel blijft staan.77 // 404 = no circle endpoint. Hubs intentionally do NOT federate (their /.klonkt/actor.json 78 // returns 404), same as standalone non-Klonkt sites. Don't add: roll back the insert 79 // so no dead "error" row is left in the circle. 80 80 if (/\b404\b/.test(msg)) { 81 81 db.prepare('DELETE FROM circle_links WHERE id = ?').run(id); 82 82 return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet toegevoegd: deze site doet niet mee aan cirkels. Een hub kan geen cirkel-partner zijn (en losse/niet-Klonkt-sites ook niet).')); 83 83 } 84 // Andere (mogelijk tijdelijke) fout → link blijft staan; later "Verversen".84 // Other (possibly temporary) error → link stays; use "Refresh" later to retry. 85 85 return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — synchroniseren mislukte (klik "Verversen" om opnieuw te proberen)')); 86 86 } … … 91 91 if (link) { 92 92 db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id); 93 // Gecachte content opruimen als geen andere link nog naar deze actor wijst.93 // Clean up cached content if no other link still points to this actor. 94 94 if (link.remote_actor_id) { 95 95 const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id); … … 117 117 }); 118 118 119 // Alles in één keer verversen (handig "voor de zekerheid").119 // Refresh everything at once (handy "just to be sure"). 120 120 router.post('/sync-all', requireGod, async (req, res) => { 121 121 try { -
src/routes/admin-comments.js
rbb42dfb r834bcc3 61 61 const site = res.locals.site; 62 62 if (!site) return res.status(404).send('No site'); 63 const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub -artiestcontext, anders''63 const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub artist context, otherwise '' 64 64 65 65 const row = db.prepare(` -
src/routes/admin-epk.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: Perskit (EPK) bewerken — per-site bio + pers-contact.2 * Admin: Edit press kit (EPK) — per-site bio + press contact. 3 3 * 4 * GET /admin/epk -> form ulier met huidigebio + contact5 * POST /admin/epk -> opslaan(app_settings: epk_bio_<siteId> / epk_contact_<siteId>)4 * GET /admin/epk -> form with current bio + contact 5 * POST /admin/epk -> save (app_settings: epk_bio_<siteId> / epk_contact_<siteId>) 6 6 * 7 * De perskit-pagina zelf (/pers) leest deze waarden; tracks + recente posts komen8 * automati sch. Perskit is premium + solo (zie routes/epk.js).7 * The press kit page itself (/pers) reads these values; tracks + recent posts come 8 * automatically. Press kit is premium + solo (see routes/epk.js). 9 9 */ 10 10 … … 49 49 setSetting('epk_bio_' + site.id, (req.body.epk_bio || '').toString().slice(0, 1000).trim()); 50 50 setSetting('epk_contact_' + site.id, (req.body.epk_contact || '').toString().slice(0, 300).trim()); 51 // Gekozen nummers: alleen ids van DEZE site, max 5, in de aangeleverde volgorde.51 // Chosen tracks: only ids belonging to THIS site, max 5, in the supplied order. 52 52 let ids = req.body.epk_tracks; 53 53 if (!Array.isArray(ids)) ids = ids ? [ids] : []; -
src/routes/admin-newsletter.js
rbb42dfb r834bcc3 1 1 /** 2 * N ieuwsbrief — beheerkant(premium feature #1).2 * Newsletter — admin side (premium feature #1). 3 3 * 4 * GET /admin/newsletter -> opstellen + abonnee-aantallen + historie5 * POST /admin/newsletter/send -> verstuur naar alle BEVESTIGDE abonnees (SMTP)4 * GET /admin/newsletter -> compose + subscriber counts + history 5 * POST /admin/newsletter/send -> send to all CONFIRMED subscribers (SMTP) 6 6 * 7 * Premium-gated + site -beheerder. Versturen vereist ingestelde SMTP; zonderSMTP8 * worden aanmeldingen wél verzameld (single opt-in), alleen versturen kan dan niet.7 * Premium-gated + site manager. Sending requires configured SMTP; without SMTP 8 * sign-ups are still collected (single opt-in), only sending is unavailable. 9 9 */ 10 10 … … 86 86 }); 87 87 sent++; 88 } catch (e) { /* s la deze ontvanger over, ga door*/ }88 } catch (e) { /* skip this recipient, continue */ } 89 89 } 90 90 db.prepare('INSERT INTO newsletters (id, site_id, subject, body, recipient_count) VALUES (?,?,?,?,?)') -
src/routes/admin-patreon.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: Patreon koppelen voor de premium-laag(god-only).2 * Admin: Link Patreon for the premium layer (god-only). 3 3 * 4 * GET /admin/patreon/connect -> stuur de beheerder naar de license-server5 * (oauth/start) met onze callback als return.6 * GET /admin/patreon/callback -> license -server keert terug met?klonkt_token7 * (o f ?klonkt_error). Verifieer + sla op.8 * GET /admin/patreon/disconnect -> entitlement wissen.4 * GET /admin/patreon/connect -> redirect the admin to the license server 5 * (oauth/start) with our callback as return URL. 6 * GET /admin/patreon/callback -> license server returns with ?klonkt_token 7 * (or ?klonkt_error). Verify + store. 8 * GET /admin/patreon/disconnect -> clear entitlement. 9 9 * 10 * Het echte verdienmodel-slot zit in het ondertekende token (alleen de11 * license -server kan tekenen). Zie PatreonService.js.10 * The real monetisation lock is in the signed token (only the 11 * license server can sign). See PatreonService.js. 12 12 */ 13 13 -
src/routes/admin-seo.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: geavanceerde SEO-instellingen van de primairesite.2 * Admin: advanced SEO settings for the primary site. 3 3 * 4 * GET /admin/seo -> form ulier met alle SEO/social-velden van de hoofdsite5 * POST /admin/seo -> opslaan(god-only)4 * GET /admin/seo -> form with all SEO/social fields for the main site 5 * POST /admin/seo -> save (god-only) 6 6 * 7 * Deze velden worden al door de <head> (shell.ejs) en de JSON-LD/OpenGraph-8 * tags geconsumeerd, maar waren tot nu toe nergens te bewerken. De basis-9 * velden (titel/bio/robots) blijven in Uiterlijk; dit is de geavanceerde laag:10 * tit el-sjabloon, canonical, social-share-afbeelding, verificatie-metas,11 * publisher/JSON-LD en OpenGraph-locale.7 * These fields are already consumed by the <head> (shell.ejs) and the JSON-LD/ 8 * OpenGraph tags, but were previously not editable anywhere. The basic 9 * fields (title/bio/robots) remain in Appearance; this is the advanced layer: 10 * title template, canonical, social share image, verification metas, 11 * publisher/JSON-LD and OpenGraph locale. 12 12 * 13 * Werkt op de PRIMAIRE site (solo = de enige site; hub = de bedrijfssite).13 * Operates on the PRIMARY site (solo = the only site; hub = the company site). 14 14 */ 15 15 -
src/routes/admin-settings.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: global e instellingen.3 * - tenancy -modus(Solo/Hub)4 * - hub -branding (naam/tagline/intro/hero van de generieke hub-hoofdpagina)2 * Admin: global settings. 3 * - tenancy mode (Solo/Hub) 4 * - hub branding (name/tagline/intro/hero of the generic hub home page) 5 5 * 6 * GET /admin/settings -> toon huidige instellingen7 * POST /admin/settings -> s la op (god-only). Accepteert nu ook een geuploade8 * hero -afbeelding (multipart); een upload wint van het9 * URL -tekstveld. Zonder upload blijft het URL-veld leidend.6 * GET /admin/settings -> show current settings 7 * POST /admin/settings -> save (god-only). Also accepts an uploaded 8 * hero image (multipart); an upload wins over the 9 * URL text field. Without an upload the URL field is leading. 10 10 * 11 * De hub-pagina is generiek (van geen enkele user); deze branding leeftin12 * global e settings, niet in eensite.11 * The hub page is generic (belonging to no user); this branding lives in 12 * global settings, not in a site. 13 13 */ 14 14 … … 30 30 const router = express.Router(); 31 31 32 // Hero dark -overlay: percentage 0-100 (0 = geen overlay, 100 = volledig zwart).33 // Default 45 = de oude hardgecodeerde waarde, zodat bestaande hubs niet wijzigen.32 // Hero dark overlay: percentage 0-100 (0 = no overlay, 100 = fully black). 33 // Default 45 = the old hard-coded value, so existing hubs don't change appearance. 34 34 function clampOverlay(raw) { 35 35 const v = parseInt(raw, 10); … … 38 38 39 39 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 40 // Hero -uploads landen in storage/media/hero → bereikbaar als /media/hero/<file>41 // ( de /media static handler serveert storage/media). Zelfde model als avatars.40 // Hero uploads land in storage/media/hero → accessible as /media/hero/<file> 41 // (the /media static handler serves storage/media). Same model as avatars. 42 42 const HERO_DIR = path.resolve( 43 43 process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero') … … 45 45 fs.mkdirSync(HERO_DIR, { recursive: true }); 46 46 47 // Alleen raster-formaten voor de upload. SVG mag bewust NIET via upload (raw48 // SVG kan script bevatten → opgeslagen-XSS bij direct openen); een SVG-hero kan49 // nog steeds via het URL-veld (zoals de meegeleverde demo-placeholder).47 // Only raster formats for upload. SVG is intentionally NOT allowed via upload 48 // (raw SVG can contain scripts → stored-XSS when opened directly); an SVG hero 49 // can still be set via the URL field (like the bundled demo placeholder). 50 50 const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); 51 51 const MAX_HERO_BYTES = 5 * 1024 * 1024; … … 95 95 96 96 router.post('/', requireGod, (req, res) => { 97 // multer.single verwerkt multipart (hub-branding form). Bij een gewone98 // urlencoded POST (tenancy -form) doet multer niets en blijft req.bodyintact.97 // multer.single processes multipart (hub branding form). For a plain 98 // urlencoded POST (tenancy form) multer does nothing and req.body stays intact. 99 99 heroUpload.single('hub_hero_file')(req, res, (err) => { 100 100 if (err) { … … 103 103 104 104 if (typeof req.body.tenancy !== 'undefined') { 105 // Hub -modus is een premium-feature: alleen naar hub schakelen als premium106 // ontgrendeld is (premium-laag uit = vrij; aan = Patreon vereist). Al-hub107 // blijven mag altijd, zodat een instance nooit vastloopt.105 // Hub mode is a premium feature: only switch to hub if premium is 106 // unlocked (premium layer off = free; on = Patreon required). Staying on 107 // hub is always allowed, so an instance can never get stuck. 108 108 if (req.body.tenancy === 'hub' && !premiumUnlocked() && getTenancy() !== 'hub') { 109 109 return res.redirect('/admin/settings?error=' + encodeURIComponent('Hub-modus is een premium-functie — koppel Patreon in Beheer → Instellingen.')); … … 112 112 } 113 113 if (typeof req.body.default_lang !== 'undefined') { 114 // Standaardtaal voor bezoekers (leeg = volg env/browser). Valideert tegenNL/EN/DE.114 // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE. 115 115 const dl = (req.body.default_lang || '').toString().toLowerCase(); 116 116 setSetting('default_lang', SUPPORTED.includes(dl) ? dl : ''); 117 117 } 118 118 if (typeof req.body.timezone !== 'undefined') { 119 // Site -tijdzone (IANA, bv. Europe/Amsterdam). Leeg = server-default (UTC).120 // Valid eer met Intl zodat een onzin-waarde nooit de datum-rendering breekt.119 // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC). 120 // Validate with Intl so a nonsense value never breaks date rendering. 121 121 const tz = (req.body.timezone || '').toString().trim(); 122 122 let valid = ''; … … 134 134 } 135 135 136 // Hero: een geüploade afbeelding wint; anders het URL-tekstveld.136 // Hero: an uploaded image wins; otherwise the URL text field. 137 137 if (req.file) { 138 138 const newUrl = `/media/hero/${toWebp(req.file)}`; 139 // Ruim een vorige geüploade hero op (alleen als die uit onze hero-map kwam).139 // Clean up a previously uploaded hero (only if it came from our hero dir). 140 140 const old = getSetting('hub_hero_image') || ''; 141 141 if (old.startsWith('/media/hero/')) { … … 155 155 }); 156 156 157 // Google -login op een eigen Beheer-pagina (los van de algemene instellingen).157 // Google login on its own admin page (separate from the general settings). 158 158 router.get('/google', requireGod, (req, res) => { 159 159 renderPage(req, res, 'pages/admin-google', { … … 171 171 }); 172 172 173 // Google-login (luisteraars) configureren— Client ID + Secret in app_settings.174 // De redirect-URI leiden we af van PUBLIC_BASE_URL (zie config/google.js).173 // Configure Google login (listeners) — Client ID + Secret in app_settings. 174 // The redirect URI is derived from PUBLIC_BASE_URL (see config/google.js). 175 175 router.post('/google', requireGod, (req, res) => { 176 176 if (req.body.clear === '1') { … … 180 180 } 181 181 setSetting('google_client_id', (req.body.google_client_id || '').toString().trim()); 182 // Secret alleen overschrijven als er een nieuwe waarde is ingevoerd (leeg = laat staan).182 // Only overwrite the secret if a new value was entered (empty = leave as-is). 183 183 const secret = (req.body.google_client_secret || '').toString().trim(); 184 184 if (secret) setSetting('google_client_secret', secret); … … 197 197 setSetting('smtp_user', (b.smtp_user || '').toString().trim()); 198 198 setSetting('smtp_from', (b.smtp_from || '').toString().trim()); 199 // Wachtwoord alleen overschrijven als er een nieuwe waarde is ingevoerd.199 // Only overwrite the password if a new value was entered. 200 200 const pass = (b.smtp_pass || '').toString(); 201 201 if (pass) setSetting('smtp_pass', pass); … … 203 203 }); 204 204 205 // N ieuwsbrief-aanmelding in de footer aan/uit.205 // Newsletter sign-up in the footer on/off. 206 206 router.post('/footer', requireGod, (req, res) => { 207 207 setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0'); … … 209 209 }); 210 210 211 // Testmail sturen naar een opgegeven adres (of de ingelogde gebruiker).211 // Send a test email to a specified address (or the logged-in user). 212 212 router.post('/smtp/test', requireGod, async (req, res) => { 213 213 const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim(); -
src/routes/admin-shows.js
rbb42dfb r834bcc3 1 1 /** 2 * Show -agenda (premium feature #8) — beheerkant.2 * Show agenda (premium feature #8) — admin side. 3 3 * 4 * GET /admin/shows -> li jst + toevoeg-formulier5 * POST /admin/shows -> show toevoegen (optioneel notify-mail naar abonnees)4 * GET /admin/shows -> list + add form 5 * POST /admin/shows -> add show (optional notify email to subscribers) 6 6 * POST /admin/shows/:id/delete 7 7 * 8 * Premium + site -beheerder. Notify-mail vereist SMTP; zonder SMTP wordt de show9 * gewoon opgeslagen (geen mail).8 * Premium + site manager. Notify email requires SMTP; without SMTP the show is 9 * simply saved (no email sent). 10 10 */ 11 11 … … 79 79 }); 80 80 sent++; 81 } catch { /* s la over*/ }81 } catch { /* skip */ } 82 82 } 83 83 } … … 86 86 87 87 router.post('/toggle', requireSiteManager, premiumGate, (req, res) => { 88 // Agenda tonen op de site (Agenda-knop in de pill + /shows-pagina).88 // Show the agenda on the site (Agenda button in the pill + /shows page). 89 89 setSetting('agenda_enabled', req.body.enabled ? '1' : '0'); 90 90 res.redirect((res.locals.siteUrlBase || '') + '/admin/shows'); -
src/routes/admin-sites.js
rbb42dfb r834bcc3 137 137 } 138 138 139 /** Geldige user-id voor owner-toewijzing, of null bij leeg/onbekend. */139 /** Valid user-id for owner assignment, or null if empty/unknown. */ 140 140 function validOwnerId(raw) { 141 141 const id = (raw || '').toString().trim(); … … 144 144 } 145 145 146 /** G eef een user admin-rechten op eensite (idempotent upsert). */146 /** Grant a user admin rights on a site (idempotent upsert). */ 147 147 function grantSiteAdmin(siteId, userId) { 148 148 db.prepare(` … … 152 152 } 153 153 154 /** Kandidaat-owners voor het owner-keuzeveld (god-only). */154 /** Candidate owners for the owner selector field (god-only). */ 155 155 function listOwnerCandidates() { 156 156 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all(); … … 183 183 bodyClass: 'on-admin', 184 184 isNew: true, 185 // ?owner=<id> ( vanaf de gebruikers-pagina: "geef deze user een Klonkt") wordt186 // voorgeselecteerd; anders de aanmakendegod.185 // ?owner=<id> (from the users page: "give this user a Klonkt") is 186 // pre-selected; otherwise defaults to the creating god. 187 187 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() }, 188 188 users: listOwnerCandidates(), … … 211 211 const f = { ...siteEditableFields(), ...req.body }; 212 212 213 // Owner: god ma g de site aan een ANDERE gebruiker toewijzen — dit is de kern214 // van hub-modus (elke gebruiker z'n eigen, zelf te beheren Klonkt). Leeg of215 // ongeldig → de aanmakende god zelf.213 // Owner: god may assign the site to a DIFFERENT user — this is the core of 214 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the 215 // creating god themselves. 216 216 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id; 217 217 … … 242 242 ); 243 243 244 // De OWNER (niet per se de aanmaker) krijgt een site_members-admin-rij → zo komt245 // 'ie door canAdminSite + de requireSiteManager-gates en beheert 'ie z'nsite.244 // The OWNER (not necessarily the creator) gets a site_members admin row → this 245 // lets them pass canAdminSite + requireSiteManager gates to manage their site. 246 246 grantSiteAdmin(siteId, ownerId); 247 247 … … 332 332 ); 333 333 334 // Owner (her)toewijzen — ALLEEN god. Een site-owner die z'n eigen site bewerkt335 // kan de eigenaar niet wijzigen (het veld wordt voor niet-god ook niet getoond).334 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot 335 // change the owner (the field is not shown to non-god users either). 336 336 if (req.session.user.role === 'god') { 337 337 const newOwner = validOwnerId(req.body.owner_id); … … 345 345 }); 346 346 347 // ==================== MA AK PRIMAIR====================348 // God kiest welke site de primaire/hoofd-site is (de label-/bedrijfssite in hub;349 // in solo dé site). Precies één site is primair → eerst alles uit, dan deze aan.347 // ==================== MAKE PRIMARY ==================== 348 // God chooses which site is the primary/main site (the label/company site in hub; 349 // in solo mode: the one site). Exactly one site is primary → clear all, then set this one. 350 350 router.post('/:slug/make-primary', requireGod, (req, res) => { 351 351 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug); -
src/routes/admin-stats.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: Statisti eken (premium-module, god-only).2 * Admin: Statistics (premium module, god-only). 3 3 * 4 * GET /admin/stats -> cookie vrije statistieken: bezoekers/weergaven per dag,5 * plays, en de populairsteposts/tracks.4 * GET /admin/stats -> cookie-free statistics: visitors/views per day, 5 * plays, and the most popular posts/tracks. 6 6 * 7 * Premium-gated via premiumUnlocked() (premium -laag uit = gewoon beschikbaar;8 * aan = Patreon vereist). Tracking zit in StatsService (geencookies).7 * Premium-gated via premiumUnlocked() (premium layer off = freely available; 8 * on = Patreon required). Tracking is in StatsService (no cookies). 9 9 */ 10 10 … … 22 22 return res.status(403).send('Statistieken is een premium-functie — koppel Patreon in Beheer → Instellingen.'); 23 23 } 24 // Link-in-bio klikken (premium #6) voor de huidigesite.24 // Link-in-bio clicks (premium #6) for the current site. 25 25 let linkClicks = []; 26 26 if (res.locals.site) { -
src/routes/admin-updates.js
rbb42dfb r834bcc3 1 1 /** 2 2 * Admin: Updates (god-only). 3 * Git- gebaseerde v1 voor instances die via de bare-repo draaien.4 * GET /admin/updates -> huidige vs. nieuwste versie+ status5 * POST /admin/updates/run -> haal nieuwste main op + herstart (detached script)3 * Git-based v1 for instances running from a bare repo. 4 * GET /admin/updates -> current vs. latest version + status 5 * POST /admin/updates/run -> fetch latest main + restart (detached script) 6 6 * 7 * De instance kent z'n "huidige" commit uit .klonkt-version (door het script8 * geschreven) en de "nieuwste" uit de bare repo (KLONKT_GIT_DIR). Voor externe9 * self-hosters komt later een GESIGNEERDE release-feed (zie monetization-plan);10 * deze v1 is bewust simpel en alleen voor Robins eigen VPS-instances.7 * The instance knows its "current" commit from .klonkt-version (written by the 8 * script) and the "latest" from the bare repo (KLONKT_GIT_DIR). For external 9 * self-hosters a SIGNED release feed will follow later (see monetization plan); 10 * this v1 is intentionally simple and only for Robin's own VPS instances. 11 11 */ 12 12 … … 37 37 } 38 38 39 // La atste 5 commits op main = de "laatste wijzigingen" die je bij bijwerken krijgt.39 // Last 5 commits on main = the "recent changes" you'll get when updating. 40 40 function recentChanges() { 41 41 const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', 'main']); … … 72 72 } 73 73 try { 74 // Detached + losgekoppeld: overleeft de pm2-reload die deze app herstart.74 // Detached + unlinked: survives the pm2-reload that restarts this app. 75 75 const child = spawn('bash', [UPDATE_SCRIPT, process.cwd()], { detached: true, stdio: 'ignore' }); 76 76 child.unref(); -
src/routes/admin-users.js
rbb42dfb r834bcc3 20 20 const router = express.Router(); 21 21 22 // 'kijker' = alleen-lezen demonstratie/audit-account: mag alles bekijken (incl.23 // Beheer), maar de globale guard blokkeert elke wijziging. Vervangt de oude24 // losse 'kijk-modus'-vlag (readonly), die nu door deze rol wordt afgedekt.22 // 'kijker' = read-only demo/audit account: may view everything (incl. admin panel), 23 // but the global guard blocks all mutations. Replaces the old separate 24 // 'kijk-modus' flag (readonly), which is now covered by this role. 25 25 const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']); 26 26 … … 69 69 } 70 70 71 // readonly=0: de alleen-lezen-status zit nu volledig in de 'kijker'-rol, dus72 // bij elke rolwijziging ruimen we de legacy-vlag op (geen dubbele bron).71 // readonly=0: read-only status now lives entirely in the 'kijker' role, so 72 // on every role change we clear the legacy flag (no dual source of truth). 73 73 db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?') 74 74 .run(newRole, userId); … … 89 89 } 90 90 91 // Cascade -verwijderen: de sites van deze user (+ posts/playlists/audio/leden/92 // comments daaronder), z'n eigen content elders, en daarna de user zelf.93 // Atomi sch in een transactie — faalt er een FK, dan rolt alles terug.91 // Cascade delete: this user's sites (+ posts/playlists/audio/members/ 92 // comments under them), their own content elsewhere, then the user themselves. 93 // Atomic in a transaction — if any FK fails, everything rolls back. 94 94 const del = db.transaction(() => { 95 95 const sites = db.prepare('SELECT id FROM sites WHERE owner_id = ?').all(userId).map((s) => s.id); … … 102 102 db.prepare('DELETE FROM sites WHERE id = ?').run(sid); 103 103 } 104 // Eigen content op andere sites + losse koppelingen.104 // Own content on other sites + loose associations. 105 105 db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)').run(userId); 106 106 db.prepare('DELETE FROM posts WHERE author_id = ?').run(userId); -
src/routes/admin.js
rbb42dfb r834bcc3 14 14 const router = express.Router(); 15 15 16 // Recent e posts van één site, CONCEPTEN BOVENAAN, met mode-bewuste edit/view-URLs.17 // Lost op dat drafts (status != published) nergens terug te vinden waren: de18 // t ijdlijn toont alleen gepubliceerdeposts.16 // Recent posts from one site, DRAFTS ON TOP, with mode-aware edit/view URLs. 17 // Solves the problem that drafts (status != published) were not findable anywhere: 18 // the timeline shows only published posts. 19 19 function sitePosts(siteId, siteSlug, tenancy, limit = 60) { 20 20 const base = tenancy === 'hub' ? `/user/${siteSlug}` : ''; … … 35 35 const user = req.session.user; 36 36 37 // Een kijker mag het volledige (god-)Beheer alleen-lezen inzien — net als god 38 // dus, alleen schrijven is globaal geblokkeerd. Een gewone artiest die een 39 // eigen site bezit krijgt een "Mijn Klonkt Hub"-dashboard, gescopet op z'n 40 // eigen site. Bezit 'ie geen site -> geen beheer. 37 // A kijker may view the full (god) admin panel read-only — same as god, 38 // but writing is globally blocked. A regular artist who owns a site gets 39 // a "My Klonkt Hub" dashboard, scoped to their own site. No site -> no admin. 41 40 if (user.role !== 'god' && user.role !== 'kijker') { 42 41 const mySite = db.prepare( … … 60 59 const tenancy = getTenancy(); 61 60 62 // De primaire/hoofd-site — in solo dé site, in hub de hoofdsite. Geeft de63 // " Uiterlijk"-tegel z'n edit-link + de posts/concepten-lijst.61 // The primary/main site — in solo THE site, in hub the main site. Provides the 62 // "Appearance" tile with its edit link + the posts/drafts list. 64 63 const primarySite = getPrimarySite(); 65 64 … … 73 72 }; 74 73 75 // Sites/users -tabellen zijn alleen in hub relevant; in solo besparen we de query.74 // Sites/users tables are only relevant in hub mode; in solo we skip the query. 76 75 const sites = tenancy === 'hub' ? db.prepare(` 77 76 SELECT s.slug, s.title, s.created_at, u.username AS owner_username … … 89 88 `).all() : []; 90 89 91 // Posts/ concepten van de primaire site (in solo = de site; in hub = de92 // hoofdsite van de admin). Concepten staan bovenaan zodat ze vindbaar zijn.90 // Posts/drafts of the primary site (in solo = the site; in hub = the admin's 91 // main site). Drafts are listed first so they are easy to find. 93 92 const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug, tenancy) : []; 94 93 … … 105 104 }); 106 105 107 // Handleiding — doorzoekbare uitleg van alle Beheer-functies. Zichtbaar voor wie108 // het Beheer mag zien (ingelogd); puur statische hulptekst, niets gevoeligs.106 // Handleiding — searchable explanation of all admin features. Visible to anyone 107 // who may view the admin panel (logged in); purely static help text, nothing sensitive. 109 108 router.get('/handleiding', requireAuth, (req, res) => { 110 109 renderPage(req, res, 'pages/admin-help', { -
src/routes/artists.js
rbb42dfb r834bcc3 1 1 /** 2 * Arti esten-directory — alleen in hub-modus.2 * Artists directory — hub mode only. 3 3 * 4 * GET /leden?q=&page= -> doorzoekbare, gepagineerde lijst van ALLE5 * Klonkt -site's. De hub-home toont maar een beperkte selectie; deze pagina6 * sc haalt naar honderden/duizenden artiesten via zoeken + paginering.4 * GET /leden?q=&page= -> searchable, paginated list of ALL 5 * Klonkt sites. The hub home shows only a limited selection; this page 6 * scales to hundreds/thousands of artists via search + pagination. 7 7 * 8 * In solo -modus bestaat er maar één site -> next() (valt door naarpostsRoutes,9 * die 'artiesten' als onbekende slug afhandelt).8 * In solo mode there is only one site -> next() (falls through to postsRoutes, 9 * which handles 'artiesten' as an unknown slug). 10 10 */ 11 11 … … 26 26 if (!Number.isFinite(page) || page < 1) page = 1; 27 27 28 // De hoofd-/labelsite (oudste) is geen artiest -> uit de directory weren,29 // consistent met de hub-home die 'm apart toont.28 // The main/label site (oldest) is not an artist -> exclude from the directory, 29 // consistent with the hub home which displays it separately. 30 30 const mainRow = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get(); 31 31 const mainId = mainRow ? mainRow.id : ''; 32 32 33 // Zoekterm tegen titel/slug/tagline (case-insensitive via LIKE; SQLite LIKE is34 // standaard ongevoelig voor ASCII-hoofdletters). De ESCAPE '\' maakt %, _ en\35 // in de zoekterm letterlijk (anders zouden ze als wildcards werken).33 // Search term against title/slug/tagline (case-insensitive via LIKE; SQLite LIKE is 34 // case-insensitive for ASCII by default). ESCAPE '\' makes %, _, and \ 35 // in the search term literal (otherwise they would act as wildcards). 36 36 const like = '%' + q.replace(/[\\%_]/g, (m) => '\\' + m) + '%'; 37 37 const conds = ['s.id != @mainId']; -
src/routes/audio.js
rbb42dfb r834bcc3 92 92 const range = req.headers.range; 93 93 94 // Statisti eken: tel één play bij de initiële player-fetch (niet bijscrub/95 // range -continuaties; replays binnen 24u komen uit de browsercache → geen96 // d ubbeltelling). Best-effort, mag nooit de stream breken.94 // Statistics: count one play on the initial player fetch (not on scrub/ 95 // range continuations; replays within 24h come from the browser cache → no 96 // double counting). Best-effort, must never break the stream. 97 97 if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) { 98 98 try { … … 141 141 }); 142 142 143 // W elke post bevat deze track? (voor de mini-speler → "spring naar de post +144 // scroll naar de track".) Pakt de nieuwste gepubliceerde post met[[track:<id>]].143 // Which post contains this track? (for the mini-player → "jump to the post + 144 // scroll to the track".) Fetches the newest published post with [[track:<id>]]. 145 145 router.get('/track/:id/post', (req, res) => { 146 146 const id = String(req.params.id || ''); -
src/routes/auth.js
rbb42dfb r834bcc3 10 10 import { premiumUnlocked } from '../services/PatreonService.js'; 11 11 12 // Fan -login (luisteraars inloggen met Google om te reageren) is een premium-13 // feature: beschikbaar als Google is ingesteld ÉN de premium-laag ontgrendeld is14 // (premium uit = vrij; aan = Patreon vereist).12 // Fan login (listeners signing in with Google to comment) is a premium feature: 13 // available when Google is configured AND the premium layer is unlocked 14 // (premium off = open to all; on = Patreon required). 15 15 function fanLoginReady() { 16 16 return googleConfigured() && premiumUnlocked(); … … 22 22 const router = express.Router(); 23 23 24 // Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de25 // user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie.24 // Fixed dummy hash: ensures login always runs one bcrypt comparison, even when the 25 // user doesn't exist or has no password — no timing oracle for enumeration. 26 26 const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10); 27 27 28 // Canoni eke basis-URL voor links in e-mails (reset). Uit headers bouwenis29 // spoof baar (X-Forwarded-Host); een vaste config sluit dat uit.28 // Canonical base URL for links in emails (reset). Building it from headers is 29 // spoofable (X-Forwarded-Host); a fixed config eliminates that risk. 30 30 function publicBaseUrl(req) { 31 31 const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, ''); 32 32 if (cfg) return cfg; 33 // Fallback (dev): trust-proxy- gesaneerde protocol + Host-header (NIET de rauwe33 // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw 34 34 // X-Forwarded-Host). 35 35 return `${req.protocol}://${req.get('host')}`; … … 40 40 } 41 41 42 // Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een43 // beheerder aanmaken. Daarna is registratie dicht (luisteraars komenvia Google).42 // First-time setup? Only while there are no users yet may /register create an 43 // admin account. Afterwards registration is closed (listeners come via Google). 44 44 function isSetupMode() { 45 45 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0; … … 47 47 48 48 // ==================== LOGIN ==================== 49 // Publieke loginpagina: voor BEZOEKERS alleen Google-login (luisteraars/fans). 50 // De beheerders-login (wachtwoord) staat hier bewust NIET — die zit verborgen op 51 // /auth/admin (zie hieronder), zodat de admin-login niet zichtbaar is op de plek 52 // waar bezoekers heen worden gestuurd. 49 // Public login page: for VISITORS only Google-login (listeners/fans). 50 // The admin login (password) is intentionally NOT here — it lives hidden at 51 // /auth/admin (see below), so the admin login is not visible where visitors land. 53 52 router.get('/login', (req, res) => { 54 53 const next = safeNext(req.query.next) || ''; … … 68 67 }); 69 68 70 // Verborgen beheerders-login (gebruikersnaam + wachtwoord). Nergens in de UI71 // gelinkt — de beheerder navigeert hier rechtstreeks naartoe(/auth/admin).69 // Hidden admin login (username + password). Not linked anywhere in the UI — 70 // the admin navigates here directly (/auth/admin). 72 71 router.get('/admin', (req, res) => { 73 72 const next = safeNext(req.query.next) || ''; … … 91 90 const next = safeNext(req.body.next) || ''; 92 91 93 // Foutweergave op de (verborgen) beheerders-loginpagina: toon het wachtwoord-94 // form ulier opnieuw (adminLogin:true), niet de Google-only publieke pagina.92 // Error display on the (hidden) admin login page: re-show the password 93 // form (adminLogin:true), not the Google-only public page. 95 94 const renderErr = (error, status = 400) => { 96 95 res.status(status); … … 105 104 106 105 const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username); 107 // Al tijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord108 // heeft) zodat de responstijd niets over het bestaan van een account verraadt.106 // Always one bcrypt comparison (dummy if the user has no usable password) 107 // so response time reveals nothing about whether the account exists. 109 108 const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth'); 110 109 const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH); … … 119 118 }); 120 119 121 // ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ====================120 // ==================== FIRST-TIME SETUP (create admin account) ==================== 122 121 router.get('/register', (req, res) => { 123 122 const next = safeNext(req.query.next) || ''; 124 123 if (req.session.user) return res.redirect(next || '/'); 125 // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt.124 // No public registration: only the very first admin may be created here. 126 125 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : '')); 127 126 renderPage(req, res, 'pages/auth-register', { … … 139 138 }); 140 139 141 // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via dezeroute.140 // Hard-closed once a user exists — prevents a second "admin" via this route. 142 141 if (!isSetupMode()) return res.redirect('/auth/login'); 143 142 … … 150 149 const userId = uuid(); 151 150 const hash = bcrypt.hashSync(password, 10); 152 // De allereerste gebruiker is de beheerder (god).151 // The very first user is the administrator (god). 153 152 db.prepare(` 154 153 INSERT INTO users (id, username, email, password_hash, role, theme, palette) … … 156 155 `).run(userId, username, email, hash); 157 156 158 // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgtlater).159 // Setup -wizard: sitenaam + taal komen uit het formulier; taal = de taal waarin160 // de bezoeker de wizard invulde (resolveLang) en wordt meteen de site-standaard.157 // Auto-create a personal site (single-tenant restructure follows later). 158 // Setup wizard: site name + language come from the form; language = the language 159 // the visitor used to fill in the wizard (resolveLang) and becomes the site default. 161 160 if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 162 161 const siteId = uuid(); … … 168 167 `).run(siteId, username.toLowerCase(), title, '', userId, lang); 169 168 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 170 try { setSetting('default_lang', lang); } catch (e) { /* n iet fataal */ }169 try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ } 171 170 } 172 171 … … 175 174 }); 176 175 177 // ==================== WACHTWOORD VERGETEN (aanvraag) ====================176 // ==================== FORGOT PASSWORD (request) ==================== 178 177 router.get('/reset-request', (req, res) => { 179 178 if (req.session.user) return res.redirect('/'); … … 191 190 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email); 192 191 if (user) { 193 const token = crypto.randomBytes(32).toString('hex'); // r uw: gaat alleen de mail/link in192 const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link 194 193 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min 195 // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op.194 // Store only the HASH: so DB read access yields no usable token. 196 195 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?') 197 196 .run(hashToken(token), expires, user.id); … … 211 210 } 212 211 } else if (process.env.NODE_ENV !== 'production') { 213 // Dev zonder SMTP: link in log + op de pagina tonen.212 // Dev without SMTP: show the link in the log + on the page. 214 213 console.log(`[password-reset] ${user.email} -> ${url}`); 215 214 devResetUrl = url; 216 215 } else { 217 // Producti e zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass.216 // Production without SMTP: NEVER log the token. Refer to the CLI break-glass. 218 217 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`); 219 218 } … … 221 220 } 222 221 223 // Anti-enumerati e: zelfde antwoord ongeacht of het adres bestaat.222 // Anti-enumeration: same response regardless of whether the address exists. 224 223 renderPage(req, res, 'pages/auth-reset-request', { 225 224 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', … … 228 227 }); 229 228 230 // ==================== WACHTWOORD RESETTEN (toepassen) ====================229 // ==================== RESET PASSWORD (apply) ==================== 231 230 router.get('/reset/:token', (req, res) => { 232 231 const row = db.prepare(` … … 266 265 }); 267 266 268 // ==================== GOOGLE -LOGIN (luisteraars/reageerders) ====================269 // Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer.267 // ==================== GOOGLE LOGIN (listeners/commenters) ==================== 268 // Per-instance, own Google client. ALWAYS grants role member — never admin. 270 269 router.get('/google', (req, res) => { 271 270 if (!fanLoginReady()) { … … 279 278 }); 280 279 281 // Google KOPPELEN aan het huidige (ingelogde) account — bv. een beheerder die282 // voortaan óók met Google wil inloggen. Vereist dat je al ingelogd bent (met283 // wachtwoord); de koppeling slaat de google_sub op het eigen account op.284 // Alleen googleConfigured() nodig (geen premium-gate — dit is geen fan-login).280 // LINK Google to the current (logged-in) account — e.g. an admin who also wants 281 // to log in with Google. Requires being already logged in (with password); the 282 // link stores the google_sub on their own account. 283 // Only googleConfigured() needed (no premium gate — this is not fan login). 285 284 router.get('/google/link', requireAuth, (req, res) => { 286 285 if (!googleConfigured()) { … … 289 288 const state = crypto.randomBytes(16).toString('hex'); 290 289 req.session.oauthState = state; 291 req.session.oauthLink = true; // koppel-modus i.p.v. login-modus290 req.session.oauthLink = true; // link mode instead of login mode 292 291 res.redirect(authorizeUrl(state)); 293 292 }); … … 320 319 const email = (info.email || '').trim().toLowerCase(); 321 320 322 // ── KOPPEL-MODUS: Google aan het huidige (ingelogde) account hangen──321 // ── LINK MODE: attach Google to the current (logged-in) account ── 323 322 if (linking) { 324 323 delete req.session.oauthState; delete req.session.oauthLink; … … 326 325 if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.'); 327 326 if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.'); 328 // Dit Google-account mag niet al aan een ANDER account hangen.327 // This Google account must not already be linked to a DIFFERENT account. 329 328 const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id); 330 329 if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.'); … … 336 335 } 337 336 338 // ── LOGIN -MODUS (luisteraars/fans + gekoppelde beheerder) ──337 // ── LOGIN MODE (listeners/fans + linked admin) ── 339 338 if (!fanLoginReady()) return failLogin('unavailable'); 340 339 const next = safeNext(req.session.oauthNext) || ''; … … 342 341 if (!email || info.email_verified === false) return failLogin('email'); 343 342 344 // Zoek EERST op de gekoppelde Google-account (google_sub). Een sub-match is het345 // expliciete koppel-bewijs → log in met de eigen rol, OOK als het Google-346 // mailadres afwijkt van het account-mailadres (bv. een beheerder die een ander347 // Gmail koppelt). Daarna pas op e-mail.343 // Look FIRST by linked Google account (google_sub). A sub-match is explicit 344 // proof of the link → log in with their own role, EVEN IF the Google email 345 // differs from the account email (e.g. an admin who linked a different Gmail). 346 // Only then fall back to email lookup. 348 347 let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null; 349 348 350 349 if (user) { 351 // Gekoppeld account gevonden → eigen rol behouden. Avatar bijwerken indien leeg.350 // Linked account found → keep their own role. Update avatar if empty. 352 351 db.prepare(` 353 352 UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? … … 356 355 user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email); 357 356 if (user && (user.role === 'god' || user.role === 'admin')) { 358 // Beheerder gevonden op e-mail maar ZONDER gekoppelde sub → Google geeft359 // nooit beheer. Eerst koppelen via Account → Inloggen metGoogle.357 // Admin found by email but WITHOUT a linked sub → Google never grants admin. 358 // Must first link via Account → Sign in with Google. 360 359 return failLogin('admin'); 361 360 } else if (user) { 362 // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken.361 // Existing listener: link google_sub/avatar if missing. 363 362 db.prepare(` 364 363 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?), … … 366 365 `).run(info.sub || null, info.picture || null, user.id); 367 366 } else { 368 // N ieuwe luisteraar — altijdmember.367 // New listener — always member. 369 368 const userId = uuid(); 370 369 const username = uniqueUsername(info.name || email.split('@')[0]); -
src/routes/changelog.js
rbb42dfb r834bcc3 1 1 /** 2 * Publi eke wijzigingen-/release-pagina.2 * Public changelog / release page. 3 3 * 4 * GET /changelog -> render t CHANGELOG.md (de bron van waarheid voor releases).4 * GET /changelog -> renders CHANGELOG.md (the source of truth for releases). 5 5 * 6 * De app-versie (footer, package.json) is bewust losgekoppeld van de7 * cir kel-federatie-proto (KLONKT_PROTO): een versie-bump is cosmetisch en raakt8 * de federatie niet. We tonen de proto hier expliciet zodat per release zichtbaar9 * is met welke federatie-versie deze instance praat (cirkels = lockstep per proto).6 * The app version (footer, package.json) is intentionally decoupled from the 7 * circle federation proto (KLONKT_PROTO): a version bump is cosmetic and does not 8 * affect federation. We show the proto here explicitly so that each release 9 * makes visible which federation version this instance speaks (circles = lockstep per proto). 10 10 */ 11 11 -
src/routes/circle.js
rbb42dfb r834bcc3 1 1 /** 2 * Cir kel-feed + lokale lees-pagina.3 * GET /cirkel -> over zicht (zelfde timeline/grid-view als de home)4 * GET /cirkel/:id -> losse remote-post in eigen chrome (blijf op jesite)5 * Alleen actief als tenancy === 'circle' (andersnext() -> postsRoutes/404).6 * Zie docs/cirkels-v1-spec.md §5c.2 * Circle feed + local reading page. 3 * GET /cirkel -> overview (same timeline/grid view as the home) 4 * GET /cirkel/:id -> individual remote post in own chrome (stay on your site) 5 * Only active when tenancy === 'circle' (otherwise next() -> postsRoutes/404). 6 * See docs/cirkels-v1-spec.md §5c. 7 7 */ 8 8 … … 25 25 } 26 26 27 // ── Over zicht────────────────────────────────────────────────27 // ── Overview ───────────────────────────────────────────────── 28 28 router.get('/cirkel', (req, res, next) => { 29 29 if (getTenancy() !== 'circle') return next(); … … 42 42 return { 43 43 id: r.id, 44 // Lo kale lees-pagina -> de kaart blijft op de eigen site (post-card linkt45 // lo kaal + htmx, GEENexternal_url).44 // Local reading page -> the card stays on the own site (post-card links 45 // locally + htmx, NO external_url). 46 46 slug: 'cirkel/' + encodeURIComponent(r.id), 47 47 title: r.title || '(zonder titel)', … … 58 58 }); 59 59 60 // Sites in de cirkel — alleen actieve links (outdated/error vallen weg, net als61 // hun posts). Voor de grafische header metavatars.60 // Sites in the circle — active links only (outdated/error ones are excluded, along 61 // with their posts). Used for the graphic header with avatars. 62 62 const sites = db.prepare(` 63 63 SELECT a.name, a.url, a.avatar … … 76 76 }); 77 77 78 // ── Losse remote-post (lokaal lezen) ─────────────────────────78 // ── Individual remote post (local reading) ──────────────────── 79 79 router.get('/cirkel/:id', (req, res, next) => { 80 80 if (getTenancy() !== 'circle') return next(); -
src/routes/comments.js
rbb42dfb r834bcc3 58 58 if (!parent) return res.status(400).send('Invalid parent comment'); 59 59 resolvedParent = parent.parent_comment_id || parent.id; 60 parentAuthorId = parent.author_id; // ontvanger van de "antwoord"-melding60 parentAuthorId = parent.author_id; // recipient of the "reply" notification 61 61 } 62 62 … … 77 77 `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status); 78 78 79 // Melding (alleen bij een zichtbare reactie): antwoord → de auteur van de reactie 80 // waarop gereageerd is; top-level reactie → de auteur van de post. notify() slaat 81 // jezelf-notificeren over. 79 // Notification (only for visible comments): reply → author of the parent comment; 80 // top-level comment → author of the post. notify() skips self-notifications. 82 81 if (status === 'approved') { 83 82 const url = `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`; -
src/routes/download.js
rbb42dfb r834bcc3 1 1 /** 2 * Download- voor-email (premium feature #2).2 * Download-for-email (premium feature #2). 3 3 * 4 * GET /downloads -> li jst van downloadbare tracks (premium; anders 404)5 * GET /download/:id -> e -mail-capture-pagina voor ééntrack6 * POST /download/:id -> e-mail opslaan (-> mailinglijst) + download vrijgeven7 * GET /download/:id/bestand -> serve ert het bestand (sessie-gated nacapture)4 * GET /downloads -> list of downloadable tracks (premium; 404 otherwise) 5 * GET /download/:id -> email capture page for a single track 6 * POST /download/:id -> save email (-> mailing list) + unlock download 7 * GET /download/:id/bestand -> serves the file (session-gated after capture) 8 8 * 9 * De fan laat z'n e-mail achter en krijgt het bestand; het adres komt in de10 * subscribers -lijst (source 'download', single opt-in — geen confirm-drempel vóór de9 * The fan leaves their email and receives the file; the address is added to the 10 * subscribers list (source 'download', single opt-in — no confirm step before the 11 11 * download). Hub: via /user/:slug/... (resolveSite + siteUrlBase). 12 12 */ … … 24 24 const router = express.Router(); 25 25 26 // Als er een echte (gepinde) post met slug 'downloads' bestaat, hangt de27 // downloads-lijst feitelijk aan die post. Dan tonen we óók de Newer/Older-postnav,28 // zodat de bezoeker net als bij een post verder kan bladeren.26 // If a real (pinned) post with slug 'downloads' exists, the downloads list is 27 // effectively attached to that post. We then also show the Newer/Older post nav 28 // so the visitor can browse just like on a regular post. 29 29 function downloadsPostNav(req, res) { 30 30 const site = res.locals.site; … … 40 40 41 41 const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' }; 42 const GRACE_MS = 15 * 60 * 1000; // download -venster nacapture42 const GRACE_MS = 15 * 60 * 1000; // download window after capture 43 43 44 44 function dlTrack(siteId, id) { … … 55 55 } 56 56 57 // Li jst van downloadbare tracks.57 // List of downloadable tracks. 58 58 router.get('/downloads', (req, res, next) => { 59 59 if (!premiumUnlocked()) return next(); … … 67 67 renderPage(req, res, 'pages/downloads', { 68 68 pageTitle: 'Downloads — ' + (site.title || ''), 69 // on-special = compact e profielkop (zoals op een post); on-downloads = pill grijs70 // + feature-route -gedrag. Samen → downloads ziet er net zo uit als eenpost.69 // on-special = compact profile header (like on a post); on-downloads = grey pill 70 // + feature-route behaviour. Together → downloads looks just like a post. 71 71 bodyClass: 'on-downloads on-special', 72 72 dlTracks: tracks, … … 76 76 }); 77 77 78 // Capture -pagina voor ééntrack.78 // Capture page for a single track. 79 79 router.get('/download/:id', (req, res, next) => { 80 80 if (!premiumUnlocked()) return next(); … … 93 93 }); 94 94 95 // E-mail opslaan + download vrijgeven.95 // Save email + unlock download. 96 96 router.post('/download/:id', (req, res, next) => { 97 97 if (!premiumUnlocked()) return next(); … … 109 109 }); 110 110 } 111 // Download vrijgeven in de sessie (kort venster).111 // Unlock download in the session (short window). 112 112 if (!req.session.dl) req.session.dl = {}; 113 113 req.session.dl[track.id] = Date.now(); … … 118 118 }); 119 119 120 // Het bestand serveren — alleen als er net een e-mail is achtergelaten (sessie).120 // Serve the file — only if an email was just submitted (session-gated). 121 121 router.get('/download/:id/bestand', (req, res, next) => { 122 122 if (!premiumUnlocked()) return next(); … … 129 129 return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.'); 130 130 } 131 // De speelbare/te-downloaden file = de KALE filename (storage_path is een132 // absolu ut pad → faalt de slash-guard). Zelfde aanpak als /audio/stream.131 // The playable/downloadable file = the BARE filename (storage_path is an 132 // absolute path → fails the slash-guard). Same approach as /audio/stream. 133 133 const sp = track.filename; 134 134 if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path'); -
src/routes/embed.js
rbb42dfb r834bcc3 1 1 /** 2 * Embed bare player (premium feature #7).2 * Embeddable player (premium feature #7). 3 3 * 4 * GET /embed -> een zelfstandige, compacte audiospeler-pagina (geenshell),5 * bedoeld om op EXTERNE sites in een <iframe> te zetten.4 * GET /embed -> a standalone, compact audio player page (no shell), 5 * intended to be placed in an <iframe> on EXTERNAL sites. 6 6 * 7 * De pagina wordt door ons (klonkt-origin) geserveerd, dus de audio-requests vanuit8 * het iframe blijven same-origin → de /audio/stream-gate laat ze door, ook al staat9 * het iframe op een vreemde site. We overrulen alleen Helmet's frameguard +10 * frame-ancestors zodat externe sites mógen inbedden. Hub: /user/:slug/embed.7 * The page is served by us (klonkt-origin), so audio requests from within 8 * the iframe remain same-origin → the /audio/stream gate lets them through, 9 * even when the iframe is on a foreign site. We only override Helmet's frameguard 10 * + frame-ancestors so that external sites are allowed to embed us. Hub: /user/:slug/embed. 11 11 */ 12 12 … … 21 21 if (!site) return next(); 22 22 23 // Inbedden op externe sites toestaan (overrule de globaleframeguard/CSP).23 // Allow embedding on external sites (override the global frameguard/CSP). 24 24 res.removeHeader('X-Frame-Options'); 25 25 res.setHeader( -
src/routes/epk.js
rbb42dfb r834bcc3 1 1 /** 2 * EPK / p erskit (premium) — een deelbare perspagina per Klonkt-site.2 * EPK / press kit (premium) — a shareable press page per Klonkt site. 3 3 * 4 * GET /pers (solo) o f/user/:slug/pers (hub, via resolveSite + siteUrlBase)5 * -> nette, openbare perskit: hero (foto/titel/tagline), korte bio, topnummers,6 * recent e posts en een contact-knop. Bedoeld om naar boekers/pers te sturen.4 * GET /pers (solo) or /user/:slug/pers (hub, via resolveSite + siteUrlBase) 5 * -> clean, public press kit: hero (photo/title/tagline), short bio, top tracks, 6 * recent posts and a contact button. Intended to share with bookers/press. 7 7 * 8 * Premium-gated: n iet-premium instances hebben GEEN /pers (next() -> 404 via de9 * catch-all). De PAGINA zelf is openbaar (geen login) zodat pers 'm kan bekijken;10 * alleen het BESTAAN ervan is premium. Geen login-e-mail lekken: contact loopt via11 * e en expliciet ingesteld pers-adres (epk_contact, per site) of anders de site zelf.8 * Premium-gated: non-premium instances have NO /pers (next() -> 404 via the 9 * catch-all). The PAGE itself is public (no login) so press can view it; 10 * only its EXISTENCE is premium. No login email leak: contact goes via an 11 * explicitly configured press address (epk_contact, per site) or the site itself. 12 12 */ 13 13 … … 21 21 22 22 router.get('/pers', (req, res, next) => { 23 if (!premiumUnlocked()) return next(); // geen premium -> geen perskit23 if (!premiumUnlocked()) return next(); // no premium -> no press kit 24 24 const site = res.locals.site; 25 25 if (!site) return next(); 26 26 27 // Nummers op de perskit: een door de admin GEKOZEN selectie (max 5, in eigen28 // volgorde) als die is ingesteld; anders automatisch de top 5 meest beluisterde.27 // Tracks on the press kit: an admin-CHOSEN selection (max 5, in custom order) 28 // if configured; otherwise automatically the top 5 most-listened. 29 29 let chosenIds = []; 30 30 try { 31 31 const raw = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); 32 32 if (Array.isArray(raw)) chosenIds = raw.filter((x) => typeof x === 'string').slice(0, 5); 33 } catch (e) { /* ongeldige JSON → val terug optop */ }33 } catch (e) { /* invalid JSON → fall back to top */ } 34 34 35 35 let tracks; … … 41 41 ).all(site.id, ...chosenIds); 42 42 const byId = new Map(rows.map((r) => [r.id, r])); 43 tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean); // behoud gekozen volgorde43 tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean); // preserve chosen order 44 44 } else { 45 45 tracks = db.prepare( … … 60 60 ).all(site.id); 61 61 62 // P ers-contact: per-site instelling (epk_contact_<siteId>) als die er is, anders63 // de globale epk_contact. NOOIT automatisch de login-mail tonen.62 // Press contact: per-site setting (epk_contact_<siteId>) if present, otherwise 63 // the global epk_contact. NEVER auto-expose the login email. 64 64 const contact = (getSetting('epk_contact_' + site.id, '') || getSetting('epk_contact', '') || '').trim(); 65 // Korte pers-bio: per-site instelling, anders de tagline van de site.65 // Short press bio: per-site setting, otherwise the site's tagline. 66 66 const bio = (getSetting('epk_bio_' + site.id, '') || site.tagline || '').trim(); 67 67 -
src/routes/federation.js
rbb42dfb r834bcc3 1 // routes/federation.js — publi eke Cirkels-endpoints (v1, publicatie-kant).1 // routes/federation.js — public Cirkels endpoints (v1, publication side). 2 2 // 3 // GET /.klonkt/actor.json — ActivityStreams -actor + Ed25519-pubkey4 // GET /.klonkt/outbox.json — publi eke posts als AS Create-objecten,5 // getekend via de Klonkt-Signature-header3 // GET /.klonkt/actor.json — ActivityStreams actor + Ed25519 public key 4 // GET /.klonkt/outbox.json — public posts as AS Create objects, 5 // signed via the Klonkt-Signature header 6 6 // 7 // Site-agnosti sch en zonder auth — alleen lezen. Zie docs/cirkels-v1-spec.md.7 // Site-agnostic and unauthenticated — read-only. See docs/cirkels-v1-spec.md. 8 8 9 9 import express from 'express'; … … 18 18 } 19 19 20 // De proto die de consument zegt te draaien (uit z'n request-header), of0.20 // The proto the consumer claims to be running (from their request header), or 0. 21 21 function consumerProto(req) { 22 22 return parseInt(req.get('Klonkt-Proto') || '0', 10) || 0; … … 24 24 25 25 router.get('/.klonkt/actor.json', (req, res) => { 26 // Cir kels = solo-naar-solo; hubs publiceren geen federatie-actor.26 // Circles = solo-to-solo; hubs do not publish a federation actor. 27 27 if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus'); 28 // De actor serveren we ALTIJD (ook aan oudere consumenten) zodat zij onze proto29 // kunnen lezen en een nette "update vereist"-melding kunnen tonen.28 // We ALWAYS serve the actor (including to older consumers) so they can read our 29 // proto and show a clean "update required" message. 30 30 const body = JSON.stringify(buildActor(baseUrl(req)), null, 2); 31 31 res.type('application/activity+json; charset=utf-8'); … … 38 38 if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus'); 39 39 res.set('Klonkt-Proto', String(KLONKT_PROTO)); 40 // Te-oude consument? Weiger met 426 Upgrade Required (de crypto-binding sluit 'm41 // sowieso al uit; dit geeft een expliciet, leesbaar signaal). proto 0 = geen42 // header (bv. een browser/curl) → toestaan, die verifieert toch niet.40 // Consumer too old? Reject with 426 Upgrade Required (the crypto binding already 41 // excludes them; this gives an explicit, readable signal). proto 0 = no header 42 // (e.g. a browser/curl) → allow, they won't verify anyway. 43 43 const cp = consumerProto(req); 44 44 if (cp && cp < MIN_PROTO) { -
src/routes/hub.js
rbb42dfb r834bcc3 1 1 /** 2 * Hub -hoofdpagina — alleen in hub-modus. In plaats van de primaire Klonkt-site te3 * tonen, rendert '/' hier een bedrijfs-overview: de laatste posts van ALLE4 * gebruikers samengevat + een lijst van de Klonkt-site's.2 * Hub home page — hub mode only. Instead of rendering the primary Klonkt site, 3 * '/' renders a company overview here: the latest posts from ALL users combined 4 * + a list of the Klonkt sites. 5 5 * 6 * In solo -modus doet dit niets (next()) en rendert posts.js de enige site.6 * In solo mode this does nothing (next()) and posts.js renders the single site. 7 7 */ 8 8 … … 16 16 router.get('/', (req, res, next) => { 17 17 if (getTenancy() !== 'hub') return next(); 18 // Als resolveSite een specifieke site adresseerde (/user/:slug of/sites/:slug),19 // is req.url naar '/' herschreven — dan NIET de overview tonen maar de site zelf20 // laten renderen door posts.js. siteUrlBase is dan gezet.18 // If resolveSite addressed a specific site (/user/:slug or /sites/:slug), 19 // req.url was rewritten to '/' — do NOT show the overview but let posts.js 20 // render the site itself. siteUrlBase is set in that case. 21 21 if (res.locals.siteUrlBase) return next(); 22 22 23 // La atste gepubliceerde posts over álle sites heen.23 // Latest published posts across all sites. 24 24 const posts = db.prepare(` 25 25 SELECT p.title, p.slug, p.excerpt, p.published_at, p.created_at, … … 35 35 `).all(); 36 36 37 // De hoofd-/labelsite (de expliciet primaire = de bedrijfs-/hoofdaccount) is38 // GEEN artiest; die tonen we apart bovenaan, niet in de Artiesten-roster.37 // The main/label site (the explicitly primary = the company/main account) is 38 // NOT an artist; we display it separately at the top, not in the Artists roster. 39 39 const mainSite = db.prepare(` 40 40 SELECT s.id, s.slug, s.title, s.tagline, s.profile_photo, s.accent, … … 48 48 const mainId = mainSite ? mainSite.id : ''; 49 49 50 // Uitgelichte Klonkt-site's voor de home-roster: meest-actief eerst (aantal51 // gepubliceerde posts), dan nieuwste. Excl. de hoofdsite. Beperkt tot52 // HOME_ROSTER_LIMIT zodat de home schaalt — volledige lijst staat op/leden.50 // Featured Klonkt sites for the home roster: most active first (number of 51 // published posts), then newest. Excl. the main site. Capped at 52 // HOME_ROSTER_LIMIT so the home scales — full list is at /leden. 53 53 const HOME_ROSTER_LIMIT = 24; 54 54 const artists = db.prepare(` … … 64 64 const totalArtists = db.prepare('SELECT COUNT(*) AS c FROM sites WHERE id != ?').get(mainId).c; 65 65 66 // De hub-pagina is GENERIEK (van geen enkele user) — branding komt uit globale67 // instellingen die de admin in Beheer beheert, niet uit eensite.66 // The hub page is GENERIC (not belonging to any user) — branding comes from global 67 // settings managed by the admin in the admin panel, not from a site. 68 68 const hub = { 69 69 title: getSetting('hub_title') || 'Overzicht', -
src/routes/lang.js
rbb42dfb r834bcc3 1 // Taalkeuze van de bezoeker: /lang/:code zet de interface-taal in de sessie en2 // stuurt terug naar waar je vandaan kwam. (Content blijft in de taal van de auteur.)1 // Visitor language choice: /lang/:code sets the interface language in the session 2 // and redirects back to where you came from. (Content stays in the author's language.) 3 3 import express from 'express'; 4 4 import { SUPPORTED } from '../services/i18n.js'; … … 10 10 const code = SUPPORTED.includes(req.params.code) ? req.params.code : 'nl'; 11 11 if (req.session) req.session.lang = code; 12 // Ingelogd? Bewaar de keuze ook op het account zodat 'ie meereist over13 // a pparaten/sessies (niet alleen deze sessie-cookie).12 // Logged in? Also save the choice on the account so it follows the user 13 // across devices/sessions (not just this session cookie). 14 14 if (req.session && req.session.user && req.session.user.id) { 15 15 try { 16 16 db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id); 17 17 req.session.user.lang = code; 18 } catch { /* lang -kolom ontbreekt op een oude DB → sessie-only, geen breuk*/ }18 } catch { /* lang column missing on an old DB → session-only, no breakage */ } 19 19 } 20 // Veilige terug-URL: alleen een intern pad (geenopen redirect).20 // Safe back URL: internal path only (no open redirect). 21 21 let back = (typeof req.query.r === 'string') ? req.query.r : ''; 22 22 if (!back.startsWith('/') || back.startsWith('//')) { -
src/routes/linkbio.js
rbb42dfb r834bcc3 1 1 /** 2 * Link-in-bio + klikstats (premium feature #6).2 * Link-in-bio + click stats (premium feature #6). 3 3 * 4 * GET /links -> Linktree- achtige pagina met de profile_links van de site5 * GET /links/go/:i -> telt de klik (per url) en stuurt door naar de externeURL4 * GET /links -> Linktree-style page with the site's profile_links 5 * GET /links/go/:i -> counts the click (per url) and redirects to the external URL 6 6 * 7 * Hergebruikt de bestaande sites.profile_links (JSON [{platform,url}]) + de8 * PLATFORMS -iconen/labels. Klikken landen in link_clicks (zie /admin/stats).9 * Open-redirect -veilig: /links/go/:i stuurt ALLEEN door naar een url die in de10 * eigen profile_links staat. Hub: via /user/:slug/links.7 * Reuses the existing sites.profile_links (JSON [{platform,url}]) + the 8 * PLATFORMS icons/labels. Clicks are stored in link_clicks (see /admin/stats). 9 * Open-redirect safe: /links/go/:i ONLY redirects to a url present in the 10 * site's own profile_links. Hub: via /user/:slug/links. 11 11 */ 12 12 … … 48 48 if (!link || !link.url) return next(); 49 49 const url = String(link.url); 50 // Alleen externe http(s)- of mailto-links (geenopen redirect / javascript:).50 // Only external http(s) or mailto links (no open redirect / javascript:). 51 51 if (!/^https?:\/\//i.test(url) && !/^mailto:/i.test(url)) return res.status(400).send('Bad link'); 52 52 try { … … 55 55 ON CONFLICT(site_id, url) DO UPDATE SET clicks = clicks + 1, updated_at = CURRENT_TIMESTAMP` 56 56 ).run(site.id, url); 57 } catch { /* telling mag de redirect nooit breken*/ }57 } catch { /* counting must never break the redirect */ } 58 58 res.redirect(302, url); 59 59 }); -
src/routes/newsletter.js
rbb42dfb r834bcc3 1 1 /** 2 * N ieuwsbrief — publieke kant(premium feature #1).2 * Newsletter — public side (premium feature #1). 3 3 * 4 * GET /nieuwsbrief -> aanmeldformulier (premium; anders 404)5 * POST /nieuwsbrief -> aanmelden (double opt-in als SMTP er is)6 * GET /nieuwsbrief/bevestigen/:token -> opt-in bevestigen7 * GET /nieuwsbrief/uitschrijven/:token -> u itschrijven (ALTIJD toegestaan)4 * GET /nieuwsbrief -> sign-up form (premium; 404 otherwise) 5 * POST /nieuwsbrief -> subscribe (double opt-in if SMTP configured) 6 * GET /nieuwsbrief/bevestigen/:token -> confirm opt-in 7 * GET /nieuwsbrief/uitschrijven/:token -> unsubscribe (ALWAYS allowed) 8 8 * 9 * In hub -modus loopt dit via /user/:slug/nieuwsbrief (resolveSite zetsiteUrlBase).10 * Confirm -/unsub-links in de mail zijn absoluut(PUBLIC_BASE_URL + siteUrlBase).9 * In hub mode this runs via /user/:slug/nieuwsbrief (resolveSite sets siteUrlBase). 10 * Confirm/unsub links in the mail are absolute (PUBLIC_BASE_URL + siteUrlBase). 11 11 */ 12 12 … … 53 53 54 54 if (r.status === 'pending') { 55 // Double opt-in: s tuur de bevestigingsmail.55 // Double opt-in: send the confirmation email. 56 56 const link = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/bevestigen/' + r.token); 57 57 const unsub = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/uitschrijven/' + r.token); … … 81 81 }); 82 82 83 // U itschrijven mag altijd (ook als de premium-laag later uit zou gaan): een abonnee84 // moet zich altijd kunnen afmelden. Niet premium-gated.83 // Unsubscribe is always allowed (even if the premium layer is later disabled): a 84 // subscriber must always be able to opt out. Not premium-gated. 85 85 router.get('/nieuwsbrief/uitschrijven/:token', (req, res) => { 86 86 const ok = unsubscribe(req.params.token); -
src/routes/notifications.js
rbb42dfb r834bcc3 1 1 /** 2 * GET /notifications — meldingenpagina voor de ingelogde gebruiker.3 * Open en = alles als gelezen markeren (de teller in de header valt dan weg).2 * GET /notifications — notifications page for the logged-in user. 3 * Opening it marks everything as read (the counter in the header disappears). 4 4 */ 5 5 import express from 'express'; -
src/routes/posts.js
rbb42dfb r834bcc3 49 49 }); 50 50 51 // Maakt een unieke slug binnen de site: 'titel', 'titel-2', 'titel-3', …52 // Zo wordt een tweede post met dezelfde titel NIET geweigerd ("bestaat al"),53 // maar krijgt 'ie automatisch een vrij achtervoegsel. exceptId = de post die54 // we bijwerken (mag z'n eigen slug houden).51 // Generates a unique slug within the site: 'title', 'title-2', 'title-3', … 52 // A second post with the same title is NOT rejected ("already exists"), 53 // but automatically gets a free suffix. exceptId = the post being updated 54 // (allowed to keep its own slug). 55 55 function uniqueSlug(siteId, base, exceptId = null) { 56 56 let candidate = base; … … 190 190 if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`; 191 191 192 // Du bbele titel/slug? Automatisch uniek maken (titel-2, titel-3, …) i.p.v. weigeren.192 // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting. 193 193 finalSlug = uniqueSlug(site.id, finalSlug); 194 194 … … 199 199 let finalStatus = status || 'draft'; 200 200 let publishedAt = finalStatus === 'published' ? now : null; 201 // Release -planning: gepubliceerd + een toekomstige publish_at -> 'scheduled'202 // ( de Scheduler zet 'm live op het moment zelf). Verleden/leeg -> meteen live.201 // Release planning: published + a future publish_at -> 'scheduled' 202 // (the Scheduler makes it live at that moment). Past/empty -> live immediately. 203 203 let publishAt = null; 204 204 const pa = Date.parse(req.body.publish_at || ''); … … 297 297 const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); 298 298 const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned; 299 // Du bbele slug? Automatisch uniek maken i.p.v. weigeren (eigen post mag z'n slug houden).299 // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug). 300 300 finalSlug = uniqueSlug(site.id, safe, post.id); 301 301 } … … 310 310 } 311 311 312 // Release -planning: gepubliceerd + toekomstige publish_at -> 'scheduled'.312 // Release planning: published + future publish_at -> 'scheduled'. 313 313 let publishAt = null; 314 314 const pa = Date.parse(req.body.publish_at || ''); … … 412 412 }); 413 413 414 // Pa d naar de like-knop-partial (voor de htmx-toggle re-render).414 // Path to the like button partial (for the htmx toggle re-render). 415 415 const LIKE_PARTIAL = path.join(__dirname, '..', 'views', 'partials', 'like-button.ejs'); 416 416 417 // ==================== LIKE / FAVORIET ==================== 418 // Een ingelogde gebruiker (geen kijker — de globale guard blokkeert non-GET voor 419 // kijkers) togglet een like op een gepubliceerde post. Geeft de her-gerenderde 420 // knop terug (htmx outerHTML-swap). 417 // ==================== LIKE / FAVOURITE ==================== 418 // A logged-in user (not a viewer — the global guard blocks non-GET for viewers) 419 // toggles a like on a published post. Returns the re-rendered button (htmx outerHTML swap). 421 420 router.post('/posts/:id/like', requireAuth, (req, res) => { 422 421 const userId = req.session.user.id; … … 430 429 } else { 431 430 db.prepare('INSERT OR IGNORE INTO post_likes (post_id, user_id) VALUES (?, ?)').run(post.id, userId); 432 // Melding voor de post-auteur (notify slaat jezelf-liken over).431 // Notification for the post author (notify skips self-likes). 433 432 notify({ 434 433 userId: post.author_id, actorId: userId, actorName: req.session.user.username, type: 'like', … … 444 443 }); 445 444 446 // Favo rieten = de posts die de ingelogde gebruiker likete. Solo: binnen de447 // huidige site. Hub: over alle sites (met juiste /user/<slug>-links).445 // Favourites = posts the logged-in user has liked. Solo: within the current 446 // site. Hub: across all sites (with correct /user/<slug> links). 448 447 router.get('/favorieten', requireAuth, (req, res) => { 449 448 const userId = req.session.user.id; … … 469 468 }); 470 469 471 // Newer/Older -buren over ALLE posts in feed-volgorde. Gedeeld door de volledige472 // post -render én de fan-gate (premium fan_only), zodat de navigatie overal gelijk473 // is. Solo: binnen de site (pinned eerst, dan datum). Hub: globaal op datum.470 // Newer/Older neighbours across ALL posts in feed order. Shared by the full 471 // post render and the fan gate (premium fan_only) so navigation is consistent 472 // everywhere. Solo: within the site (pinned first, then date). Hub: globally by date. 474 473 function postNeighbors(site, post, isHub) { 475 474 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : ''; … … 507 506 `).get(site.id, req.params.slug); 508 507 509 if (!post) return next(); // onbekende slug -> nette404 catch-all508 if (!post) return next(); // unknown slug -> clean 404 catch-all 510 509 511 510 // Permission to view: published OR (logged in + can edit) … … 515 514 } 516 515 517 // Fan-only preview (premium #3): volledige inhoud alleen voor ingelogdefans.518 // Anon ieme bezoekers krijgen een nette login-gate i.p.v. de inhoud (de titel/519 // teaser ma g elders wel als lokkertje verschijnen).516 // Fan-only preview (premium #3): full content only for logged-in fans. 517 // Anonymous visitors get a clean login gate instead of the content (the title/ 518 // teaser may still appear elsewhere as a teaser). 520 519 if (post.fan_only && !(req.session && req.session.user)) { 521 // Zelfde Newer/Older-navigatie als op een gewone post, zodat de bezoeker op522 // de fan-gate niet vastloopt maar verder kan bladeren.520 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get 521 // stuck on the fan gate but can keep browsing. 523 522 const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub'); 524 523 return renderPage(req, res, 'pages/fan-gate', { … … 532 531 } 533 532 534 // Statisti eken: tel de weergave (skipt beheerders + niet-gepubliceerd-eigen-preview).533 // Statistics: count the view (skips admins + unpublished own-preview). 535 534 if (post.status === 'published') recordPostView(post, req); 536 535 … … 588 587 const byAlbum = new Map(); 589 588 for (const r of albumRows) { 590 // Link-only tracks ( geen bestand) blijven in het album-overzicht(url '').589 // Link-only tracks (no file) remain in the album overview (url ''). 591 590 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []); 592 591 byAlbum.get(r.album).push({ … … 625 624 } 626 625 } else { 627 // LITE -modus (KLONKT_AUDIO=off): geen eigen audio (geen ffmpeg/stream-route).628 // Extern e embeds (YouTube/SoundCloud/Spotify) blijven wel; de eigen-audio-629 // shortcodes ([[track]]/[[album]]/[[playlist]]) verwijderen we netjes.626 // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route). 627 // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio 628 // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped. 630 629 html = AudioEmbedService.autoembed(html); 631 630 html = AudioEmbedService.embedMediaShortcodes(html); … … 665 664 // Prev / next chronological (kept for back-compat — "post-nav" feature 666 665 // below the article still uses these as a simple linear navigation). 667 // Hub -modus: Gerelateerde posts + Newer/Older trekken uit ALLE users (alle668 // sites), nieuwste->oudste. Solo-modus: binnen de huidige site (oud gedrag).666 // Hub mode: Related posts + Newer/Older pull from ALL users (all sites), 667 // newest first. Solo mode: within the current site (old behaviour). 669 668 const isHub = res.locals.tenancy === 'hub'; 670 // Per-post URL -basis: in hub wijst een link naar/user/<site-slug>/<post-slug>.669 // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>. 671 670 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : ''; 672 671 673 // Newer/Older over ALLE posts (gedeelde helper — ook door de fan-gate gebruikt).672 // Newer/Older across ALL posts (shared helper — also used by the fan gate). 674 673 const { newerPost, olderPost } = postNeighbors(site, post, isHub); 675 674 … … 727 726 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) })); 728 727 729 // Likes / favo rieten: aantal + of de ingelogde gebruiker deze post likete.728 // Likes / favourites: count + whether the logged-in user liked this post. 730 729 const likeCount = db.prepare('SELECT COUNT(*) AS c FROM post_likes WHERE post_id = ?').get(post.id).c; 731 730 const likedByMe = !!(req.session?.user && -
src/routes/search.js
rbb42dfb r834bcc3 1 1 /** 2 * GET /search?q=... -> volledige resultatenpagina3 * GET /search/suggest?q=... -> compact e JSON voor live resultaten in de overlay2 * GET /search?q=... -> full results page 3 * GET /search/suggest?q=... -> compact JSON for live results in the overlay 4 4 * 5 * Doorzoekt de huidige site op:5 * Searches the current site across: 6 6 * 1. Posts via posts_fts (FTS5, prefix-matching) — published only. 7 * 2. Nummers (audio_tracks) op titel / artiest / album.8 * 3. Even ementen (shows) op plaats / locatie / land / notitie — als de agenda aan staat.9 * 4. Pag ina's (Agenda / Downloads / Links / Perskit / Archief) op naam — alleen10 * de beschikbare.7 * 2. Tracks (audio_tracks) on title / artist / album. 8 * 3. Events (shows) on city / venue / country / notes — when the agenda is enabled. 9 * 4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only 10 * the available ones. 11 11 * 12 * FTS5: user -input wordt getokeniseerd op niet-letter/cijfer en elk token tussen13 * dubbele quotes + `*` gezet → prefix-match, geenoperator-soup/syntax-errors.12 * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped 13 * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors. 14 14 */ 15 15 … … 47 47 } 48 48 49 // ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep50 // ( klein voor de live-suggesties, ruim voor de volle pagina).──────────────49 // ── Core: search all sources for one site. `lim` caps results per group 50 // (small for live suggestions, large for the full page). ────────────────── 51 51 function searchSite(req, res, rawQ, lim) { 52 52 const site = res.locals.site; … … 79 79 } 80 80 81 // 2. Nummers81 // 2. Tracks 82 82 try { 83 83 const trackRows = db.prepare(` … … 108 108 } catch (err) { if (!out.queryError) out.queryError = err.message; } 109 109 110 // 3. Even ementen (agenda) — alleen als de agenda publiek aan staat.110 // 3. Events (agenda) — only when the agenda is publicly enabled. 111 111 if (premiumUnlocked() && getSetting('agenda_enabled') === '1') { 112 112 try { … … 125 125 } 126 126 127 // 4. Pag ina's — curated, alleen de beschikbare; match op de (vertaalde) naam.127 // 4. Pages — curated, available ones only; matched against the (translated) name. 128 128 const ql = rawQ.toLowerCase(); 129 129 const candidates = [ … … 143 143 } 144 144 145 // ── Volledige resultatenpagina───────────────────────────────────────────────145 // ── Full results page ──────────────────────────────────────────────────────── 146 146 router.get('/', (req, res) => { 147 147 const site = res.locals.site; … … 165 165 }); 166 166 167 // ── Live suggesti es (JSON) ───────────────────────────────────────────────────167 // ── Live suggestions (JSON) ────────────────────────────────────────────────── 168 168 router.get('/suggest', (req, res) => { 169 169 const site = res.locals.site; -
src/routes/shows.js
rbb42dfb r834bcc3 1 1 /** 2 * Show -agenda + notify-me (premium feature #8) — publieke kant.2 * Show agenda + notify-me (premium feature #8) — public side. 3 3 * 4 * GET /shows -> komende optredens + "houd me op de hoogte"-formulier5 * POST /shows/notify -> aanmelden voor show-aankondigingen(subscribers, source6 * 'notify'; double opt-in als SMTP er is)4 * GET /shows -> upcoming gigs + "keep me posted" form 5 * POST /shows/notify -> subscribe to show announcements (subscribers, source 6 * 'notify'; double opt-in if SMTP configured) 7 7 * 8 * De notify-bevestiging/uitschrijving hergebruikt de generieke subscriber-links8 * The notify confirm/unsubscribe reuses the generic subscriber links 9 9 * (/nieuwsbrief/bevestigen|uitschrijven/:token). Hub: /user/:slug/shows. 10 10 */ … … 20 20 const router = express.Router(); 21 21 22 // Agenda is opt-in: pas bereikbaar als de beheerder 'm heeft ingeschakeld.22 // Agenda is opt-in: only accessible once the admin has enabled it. 23 23 function agendaOn() { return getSetting('agenda_enabled') === '1'; } 24 24
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)