Changeset 834bcc3 in Klonkt for src/routes


Ignore:
Timestamp:
06/23/2026 06:14:27 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
d774679
Parents:
bb42dfb
Message:

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

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

Location:
src/routes
Files:
33 edited

Legend:

Unmodified
Added
Removed
  • src/routes/account.js

    rbb42dfb r834bcc3  
    6666  const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
    6767  const googleLinked = !!(account && account.google_sub);
    68   if (account) { delete account.password_hash; delete account.google_sub; } // niet naar de view lekken
     68  if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view
    6969
    7070  renderPage(req, res, 'pages/account', {
     
    8181});
    8282
    83 // ==================== PERSOONLIJKE INTERFACE-TAAL ====================
    84 // Slaat de taalkeuze op het account op (reist mee over apparaten/sessies) én
    85 // 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.
    8686router.post('/lang', requireAuth, (req, res) => {
    8787  const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null;
     
    9494});
    9595
    96 // De site die deze gebruiker mag bewerken vanuit z'n account: z'n eigen site
    97 // (owner_id), of 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.
    9898function ownedSite(user) {
    9999  if (!user) return null;
    100100  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);
    101101  if (!site && user.role === 'god') {
    102     site = getPrimarySite(); // primaire/hoofd-site als fallback
     102    site = getPrimarySite(); // primary/main site as fallback
    103103  }
    104104  return site || null;
     
    124124  const bio = (req.body.bio || '').toString().slice(0, 500).trim();
    125125
    126   // E-mail (optioneel mee te wijzigen). Validatie: geldig formaat + niet al door
    127   // 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.
    128128  const email = (req.body.email || '').toString().trim();
    129129  if (email) {
     
    138138    db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
    139139      .run(email, req.session.user.id);
    140     req.session.user.email = email; // sessie bijwerken zodat de UI klopt
     140    req.session.user.email = email; // update session so the UI reflects the change
    141141  }
    142142
     
    166166
    167167  const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
    168   // Google-only accounts (luisteraars) hebben geen echt wachtwoord.
     168  // Google-only accounts (listeners) have no real password.
    169169  if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
    170170    return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
     
    181181});
    182182
    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).
    185185router.post('/google/unlink', requireAuth, (req, res) => {
    186186  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  
    3838const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
    3939const 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 ruimer
     40const MAX_AUDIO_BYTES = 50 * 1024 * 1024;   // 50 MB — compressed formats (mp3/m4a/ogg/…)
     41const MAX_WAV_BYTES   = 100 * 1024 * 1024;  // 100 MB — WAV is uncompressed, so a higher limit
    4242const MAX_COVER_BYTES = 5 * 1024 * 1024;    // 5 MB
    4343
    44 // Per-bestand bovengrens op basis van extensie. multer's globale limiet is de
    45 // hoogste (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.
    4646const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES);
    4747
     
    5959const upload = multer({
    6060  storage,
    61   limits: { fileSize: MAX_WAV_BYTES }, // hoogste bovengrens (WAV) — per-type check in de handler
     61  limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler
    6262  fileFilter: (req, file, cb) => {
    6363    const ext = path.extname(file.originalname).toLowerCase();
     
    7373const router = express.Router();
    7474
    75 // "Open in"-platformlinks per track: alleen https + de juiste host accepteren
    76 // (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).
    7777const LINK_DOMAINS = {
    7878  spotify: ['spotify.com'],
     
    8686    const h = new URL(u).hostname.toLowerCase();
    8787    if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
    88   } catch (e) { /* ongeldige URL */ }
     88  } catch (e) { /* invalid URL */ }
    8989  return null;
    9090}
     
    148148    }
    149149
    150     // Per-type audio size check. multer's globale limiet was de WAV-bovengrens
    151     // (100MB); gecomprimeerde formaten blijven op 50MB.
     150    // Per-type audio size check. multer's global limit was the WAV upper bound
     151    // (100MB); compressed formats stay at 50MB.
    152152    const audioExt = path.extname(audioFile.originalname).toLowerCase();
    153153    const audioLimit = audioByteLimitFor(audioExt);
     
    185185    const finalArtist = artist?.trim() || null;
    186186    const finalAlbum  = album?.trim() || null;
    187     // Eigenaarschap/licentie. credit valt terug op de artiest; deze gaan zowel de
    188     // 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).
    189189    const finalCredit  = (req.body.credit  || '').trim() || finalArtist || null;
    190190    const finalLicense = (req.body.license || '').trim() || null;
     
    231231      `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
    232232
    233       // Duur automatisch: primair uit de transcode (ffmpeg codecData), anders een
    234       // 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).
    236236      const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
    237237      const finalDuration =
     
    275275});
    276276
    277 // Download-voor-email per track aan/uit (premium #2). Zonder-JS toggle vanaf de
    278 // 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.
    279279router.post('/:id/downloadable', requireGod, (req, res) => {
    280280  const site = res.locals.site;
     
    394394
    395395/** GET /admin/audio/api/:id — single track with all metadata */
    396 // Maak een track ZONDER audiobestand (alleen titel + open-in links). Verschijnt
    397 // 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.
    398398router.post('/create-link', requireGod, express.json(), (req, res) => {
    399399  const site = res.locals.site;
     
    507507  }
    508508
    509   // Verse rij + (als tag-velden wijzigden) de mp3 her-taggen, zodat de eigenaar/
    510   // licentie 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.
    511511  const fresh = db.prepare(`
    512512    SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
     
    527527      } });
    528528    } 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);
    530530    }
    531531  }
  • src/routes/admin-circle.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: Cirkel-beheer (god-only).
    3  *   GET  /admin/circle           -> lijst van cirkel-links + status
    4  *   POST /admin/circle/add       -> Klonkt-URL toevoegen
     2 * Admin: Circle management (god-only).
     3 *   GET  /admin/circle           -> list of circle links + status
     4 *   POST /admin/circle/add       -> add a Klonkt URL
    55 *   POST /admin/circle/:id/remove
    6  *   POST /admin/circle/:id/sync  -> nu verversen (pull + verifieer)
    7  *   POST /admin/circle/allow     -> toggle "mag 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"
    88 *
    9  * Zie docs/cirkels-v1-spec.md §5d.
     9 * See docs/cirkels-v1-spec.md §5d.
    1010 */
    1111
     
    5151  const site = primarySite();
    5252  if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden'));
    53   // Schema automatisch aanvullen: een kale domeinnaam → https://, een getypte
    54   // http:// → https:// (federatie is bewust https-only, getekende feeds). Zo hoeft
    55   // 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.
    5656  let url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, '');
    5757  if (url && !/^[a-z]+:\/\//i.test(url)) url = 'https://' + url;
     
    6868    return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel'));
    6969  }
    70   // Meteen ophalen i.p.v. wachten op de 15-min-loop.
     70  // Fetch immediately instead of waiting for the 15-minute loop.
    7171  try {
    7272    const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(id);
     
    7575  } catch (e) {
    7676    const msg = String((e && e.message) || e);
    77     // 404 = geen cirkel-endpoint. Hubs federeren bewust NIET (hun /.klonkt/actor.json
    78     // geeft 404), net als losse niet-Klonkt-sites. Niet toevoegen: rol de insert terug
    79     // 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.
    8080    if (/\b404\b/.test(msg)) {
    8181      db.prepare('DELETE FROM circle_links WHERE id = ?').run(id);
    8282      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).'));
    8383    }
    84     // Andere (mogelijk tijdelijke) fout → link blijft staan; later "Verversen".
     84    // Other (possibly temporary) error → link stays; use "Refresh" later to retry.
    8585    return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — synchroniseren mislukte (klik "Verversen" om opnieuw te proberen)'));
    8686  }
     
    9191  if (link) {
    9292    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.
    9494    if (link.remote_actor_id) {
    9595      const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id);
     
    117117});
    118118
    119 // Alles in één keer verversen (handig "voor de zekerheid").
     119// Refresh everything at once (handy "just to be sure").
    120120router.post('/sync-all', requireGod, async (req, res) => {
    121121  try {
  • src/routes/admin-comments.js

    rbb42dfb r834bcc3  
    6161  const site = res.locals.site;
    6262  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 ''
    6464
    6565  const row = db.prepare(`
  • src/routes/admin-epk.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: Perskit (EPK) bewerken — per-site bio + pers-contact.
     2 * Admin: Edit press kit (EPK) — per-site bio + press contact.
    33 *
    4  * GET  /admin/epk   -> formulier met huidige bio + contact
    5  * 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>)
    66 *
    7  * De perskit-pagina zelf (/pers) leest deze waarden; tracks + recente posts komen
    8  * automatisch. 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).
    99 */
    1010
     
    4949  setSetting('epk_bio_' + site.id, (req.body.epk_bio || '').toString().slice(0, 1000).trim());
    5050  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.
    5252  let ids = req.body.epk_tracks;
    5353  if (!Array.isArray(ids)) ids = ids ? [ids] : [];
  • src/routes/admin-newsletter.js

    rbb42dfb r834bcc3  
    11/**
    2  * Nieuwsbrief — beheerkant (premium feature #1).
     2 * Newsletter — admin side (premium feature #1).
    33 *
    4  *   GET  /admin/newsletter        -> opstellen + abonnee-aantallen + historie
    5  *   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)
    66 *
    7  * Premium-gated + site-beheerder. Versturen vereist ingestelde SMTP; zonder SMTP
    8  * 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.
    99 */
    1010
     
    8686      });
    8787      sent++;
    88     } catch (e) { /* sla deze ontvanger over, ga door */ }
     88    } catch (e) { /* skip this recipient, continue */ }
    8989  }
    9090  db.prepare('INSERT INTO newsletters (id, site_id, subject, body, recipient_count) VALUES (?,?,?,?,?)')
  • src/routes/admin-patreon.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: Patreon koppelen voor de premium-laag (god-only).
     2 * Admin: Link Patreon for the premium layer (god-only).
    33 *
    4  * GET /admin/patreon/connect    -> stuur de beheerder naar de license-server
    5  *                                  (oauth/start) met onze callback als return.
    6  * GET /admin/patreon/callback   -> license-server keert terug met ?klonkt_token
    7  *                                  (of ?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.
    99 *
    10  * Het echte verdienmodel-slot zit in het ondertekende token (alleen de
    11  * 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.
    1212 */
    1313
  • src/routes/admin-seo.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: geavanceerde SEO-instellingen van de primaire site.
     2 * Admin: advanced SEO settings for the primary site.
    33 *
    4  * GET  /admin/seo   -> formulier met alle SEO/social-velden van de hoofdsite
    5  * 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)
    66 *
    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  * titel-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.
    1212 *
    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).
    1414 */
    1515
  • src/routes/admin-settings.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: globale 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)
    55 *
    6  * GET  /admin/settings   -> toon huidige instellingen
    7  * POST /admin/settings   -> sla op (god-only). Accepteert nu ook een geuploade
    8  *                           hero-afbeelding (multipart); een upload wint van het
    9  *                           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.
    1010 *
    11  * De hub-pagina is generiek (van geen enkele user); deze branding leeft in
    12  * globale settings, niet in een site.
     11 * The hub page is generic (belonging to no user); this branding lives in
     12 * global settings, not in a site.
    1313 */
    1414
     
    3030const router = express.Router();
    3131
    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.
    3434function clampOverlay(raw) {
    3535  const v = parseInt(raw, 10);
     
    3838
    3939const __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.
    4242const HERO_DIR = path.resolve(
    4343  process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero')
     
    4545fs.mkdirSync(HERO_DIR, { recursive: true });
    4646
    47 // Alleen raster-formaten voor de upload. SVG mag bewust NIET via upload (raw
    48 // SVG kan script bevatten → opgeslagen-XSS bij direct openen); een SVG-hero kan
    49 // 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).
    5050const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
    5151const MAX_HERO_BYTES = 5 * 1024 * 1024;
     
    9595
    9696router.post('/', requireGod, (req, res) => {
    97   // multer.single verwerkt multipart (hub-branding form). Bij een gewone
    98   // urlencoded POST (tenancy-form) doet multer niets en blijft req.body intact.
     97  // multer.single processes multipart (hub branding form). For a plain
     98  // urlencoded POST (tenancy form) multer does nothing and req.body stays intact.
    9999  heroUpload.single('hub_hero_file')(req, res, (err) => {
    100100    if (err) {
     
    103103
    104104    if (typeof req.body.tenancy !== 'undefined') {
    105       // Hub-modus is een premium-feature: alleen naar hub schakelen als premium
    106       // ontgrendeld is (premium-laag uit = vrij; aan = Patreon vereist). Al-hub
    107       // 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.
    108108      if (req.body.tenancy === 'hub' && !premiumUnlocked() && getTenancy() !== 'hub') {
    109109        return res.redirect('/admin/settings?error=' + encodeURIComponent('Hub-modus is een premium-functie — koppel Patreon in Beheer → Instellingen.'));
     
    112112    }
    113113    if (typeof req.body.default_lang !== 'undefined') {
    114       // Standaardtaal voor bezoekers (leeg = volg env/browser). Valideert tegen NL/EN/DE.
     114      // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
    115115      const dl = (req.body.default_lang || '').toString().toLowerCase();
    116116      setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
    117117    }
    118118    if (typeof req.body.timezone !== 'undefined') {
    119       // Site-tijdzone (IANA, bv. Europe/Amsterdam). Leeg = server-default (UTC).
    120       // Valideer 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.
    121121      const tz = (req.body.timezone || '').toString().trim();
    122122      let valid = '';
     
    134134    }
    135135
    136     // Hero: een geüploade afbeelding wint; anders het URL-tekstveld.
     136    // Hero: an uploaded image wins; otherwise the URL text field.
    137137    if (req.file) {
    138138      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).
    140140      const old = getSetting('hub_hero_image') || '';
    141141      if (old.startsWith('/media/hero/')) {
     
    155155});
    156156
    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).
    158158router.get('/google', requireGod, (req, res) => {
    159159  renderPage(req, res, 'pages/admin-google', {
     
    171171});
    172172
    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).
    175175router.post('/google', requireGod, (req, res) => {
    176176  if (req.body.clear === '1') {
     
    180180  }
    181181  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).
    183183  const secret = (req.body.google_client_secret || '').toString().trim();
    184184  if (secret) setSetting('google_client_secret', secret);
     
    197197  setSetting('smtp_user', (b.smtp_user || '').toString().trim());
    198198  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.
    200200  const pass = (b.smtp_pass || '').toString();
    201201  if (pass) setSetting('smtp_pass', pass);
     
    203203});
    204204
    205 // Nieuwsbrief-aanmelding in de footer aan/uit.
     205// Newsletter sign-up in the footer on/off.
    206206router.post('/footer', requireGod, (req, res) => {
    207207  setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0');
     
    209209});
    210210
    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).
    212212router.post('/smtp/test', requireGod, async (req, res) => {
    213213  const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim();
  • src/routes/admin-shows.js

    rbb42dfb r834bcc3  
    11/**
    2  * Show-agenda (premium feature #8) — beheerkant.
     2 * Show agenda (premium feature #8) — admin side.
    33 *
    4  *   GET  /admin/shows           -> lijst + toevoeg-formulier
    5  *   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)
    66 *   POST /admin/shows/:id/delete
    77 *
    8  * Premium + site-beheerder. Notify-mail vereist SMTP; zonder SMTP wordt de show
    9  * gewoon opgeslagen (geen mail).
     8 * Premium + site manager. Notify email requires SMTP; without SMTP the show is
     9 * simply saved (no email sent).
    1010 */
    1111
     
    7979        });
    8080        sent++;
    81       } catch { /* sla over */ }
     81      } catch { /* skip */ }
    8282    }
    8383  }
     
    8686
    8787router.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).
    8989  setSetting('agenda_enabled', req.body.enabled ? '1' : '0');
    9090  res.redirect((res.locals.siteUrlBase || '') + '/admin/shows');
  • src/routes/admin-sites.js

    rbb42dfb r834bcc3  
    137137}
    138138
    139 /** Geldige user-id voor owner-toewijzing, of null bij leeg/onbekend. */
     139/** Valid user-id for owner assignment, or null if empty/unknown. */
    140140function validOwnerId(raw) {
    141141  const id = (raw || '').toString().trim();
     
    144144}
    145145
    146 /** Geef een user admin-rechten op een site (idempotent upsert). */
     146/** Grant a user admin rights on a site (idempotent upsert). */
    147147function grantSiteAdmin(siteId, userId) {
    148148  db.prepare(`
     
    152152}
    153153
    154 /** Kandidaat-owners voor het owner-keuzeveld (god-only). */
     154/** Candidate owners for the owner selector field (god-only). */
    155155function listOwnerCandidates() {
    156156  return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
     
    183183    bodyClass: 'on-admin',
    184184    isNew: true,
    185     // ?owner=<id> (vanaf de gebruikers-pagina: "geef deze user een Klonkt") wordt
    186     // voorgeselecteerd; anders de aanmakende god.
     185    // ?owner=<id> (from the users page: "give this user a Klonkt") is
     186    // pre-selected; otherwise defaults to the creating god.
    187187    site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
    188188    users: listOwnerCandidates(),
     
    211211  const f = { ...siteEditableFields(), ...req.body };
    212212
    213   // Owner: god mag de site aan een ANDERE gebruiker toewijzen — dit is de kern
    214   // van hub-modus (elke gebruiker z'n eigen, zelf te beheren Klonkt). Leeg of
    215   // 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.
    216216  const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
    217217
     
    242242  );
    243243
    244   // De OWNER (niet per se de aanmaker) krijgt een site_members-admin-rij → zo komt
    245   // 'ie door canAdminSite + de requireSiteManager-gates en beheert 'ie z'n site.
     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.
    246246  grantSiteAdmin(siteId, ownerId);
    247247
     
    332332  );
    333333
    334   // Owner (her)toewijzen — ALLEEN god. Een site-owner die z'n eigen site bewerkt
    335   // 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).
    336336  if (req.session.user.role === 'god') {
    337337    const newOwner = validOwnerId(req.body.owner_id);
     
    345345});
    346346
    347 // ==================== MAAK 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.
    350350router.post('/:slug/make-primary', requireGod, (req, res) => {
    351351  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
  • src/routes/admin-stats.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: Statistieken (premium-module, god-only).
     2 * Admin: Statistics (premium module, god-only).
    33 *
    4  * GET /admin/stats -> cookievrije statistieken: bezoekers/weergaven per dag,
    5  *                     plays, en de populairste posts/tracks.
     4 * GET /admin/stats -> cookie-free statistics: visitors/views per day,
     5 *                     plays, and the most popular posts/tracks.
    66 *
    7  * Premium-gated via premiumUnlocked() (premium-laag uit = gewoon beschikbaar;
    8  * aan = Patreon vereist). Tracking zit in StatsService (geen cookies).
     7 * Premium-gated via premiumUnlocked() (premium layer off = freely available;
     8 * on = Patreon required). Tracking is in StatsService (no cookies).
    99 */
    1010
     
    2222    return res.status(403).send('Statistieken is een premium-functie — koppel Patreon in Beheer → Instellingen.');
    2323  }
    24   // Link-in-bio klikken (premium #6) voor de huidige site.
     24  // Link-in-bio clicks (premium #6) for the current site.
    2525  let linkClicks = [];
    2626  if (res.locals.site) {
  • src/routes/admin-updates.js

    rbb42dfb r834bcc3  
    11/**
    22 * Admin: Updates (god-only).
    3  * Git-gebaseerde v1 voor instances die via de bare-repo draaien.
    4  *   GET  /admin/updates      -> huidige vs. nieuwste versie + status
    5  *   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)
    66 *
    7  * De instance kent z'n "huidige" commit uit .klonkt-version (door het script
    8  * geschreven) en de "nieuwste" uit de bare repo (KLONKT_GIT_DIR). Voor externe
    9  * 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.
    1111 */
    1212
     
    3737}
    3838
    39 // Laatste 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.
    4040function recentChanges() {
    4141  const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', 'main']);
     
    7272  }
    7373  try {
    74     // Detached + losgekoppeld: overleeft de pm2-reload die deze app herstart.
     74    // Detached + unlinked: survives the pm2-reload that restarts this app.
    7575    const child = spawn('bash', [UPDATE_SCRIPT, process.cwd()], { detached: true, stdio: 'ignore' });
    7676    child.unref();
  • src/routes/admin-users.js

    rbb42dfb r834bcc3  
    2020const router = express.Router();
    2121
    22 // 'kijker' = alleen-lezen demonstratie/audit-account: mag alles bekijken (incl.
    23 // Beheer), maar de globale guard blokkeert elke wijziging. Vervangt de oude
    24 // 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.
    2525const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
    2626
     
    6969  }
    7070
    71   // readonly=0: de alleen-lezen-status zit nu volledig in de 'kijker'-rol, dus
    72   // 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).
    7373  db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
    7474    .run(newRole, userId);
     
    8989  }
    9090
    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   // Atomisch 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.
    9494  const del = db.transaction(() => {
    9595    const sites = db.prepare('SELECT id FROM sites WHERE owner_id = ?').all(userId).map((s) => s.id);
     
    102102      db.prepare('DELETE FROM sites WHERE id = ?').run(sid);
    103103    }
    104     // Eigen content op andere sites + losse koppelingen.
     104    // Own content on other sites + loose associations.
    105105    db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)').run(userId);
    106106    db.prepare('DELETE FROM posts WHERE author_id = ?').run(userId);
  • src/routes/admin.js

    rbb42dfb r834bcc3  
    1414const router = express.Router();
    1515
    16 // Recente posts van één site, CONCEPTEN BOVENAAN, met mode-bewuste edit/view-URLs.
    17 // Lost op dat drafts (status != published) nergens terug te vinden waren: de
    18 // tijdlijn toont alleen gepubliceerde posts.
     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.
    1919function sitePosts(siteId, siteSlug, tenancy, limit = 60) {
    2020  const base = tenancy === 'hub' ? `/user/${siteSlug}` : '';
     
    3535  const user = req.session.user;
    3636
    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.
    4140  if (user.role !== 'god' && user.role !== 'kijker') {
    4241    const mySite = db.prepare(
     
    6059  const tenancy = getTenancy();
    6160
    62   // De primaire/hoofd-site — in solo dé site, in hub de hoofdsite. Geeft de
    63   // "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.
    6463  const primarySite = getPrimarySite();
    6564
     
    7372  };
    7473
    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.
    7675  const sites = tenancy === 'hub' ? db.prepare(`
    7776    SELECT s.slug, s.title, s.created_at, u.username AS owner_username
     
    8988  `).all() : [];
    9089
    91   // Posts/concepten van de primaire site (in solo = de site; in hub = de
    92   // 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.
    9392  const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug, tenancy) : [];
    9493
     
    105104});
    106105
    107 // Handleiding — doorzoekbare uitleg van alle Beheer-functies. Zichtbaar voor wie
    108 // 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.
    109108router.get('/handleiding', requireAuth, (req, res) => {
    110109  renderPage(req, res, 'pages/admin-help', {
  • src/routes/artists.js

    rbb42dfb r834bcc3  
    11/**
    2  * Artiesten-directory — alleen in hub-modus.
     2 * Artists directory — hub mode only.
    33 *
    4  * GET /leden?q=&page=  -> doorzoekbare, gepagineerde lijst van ALLE
    5  * Klonkt-site's. De hub-home toont maar een beperkte selectie; deze pagina
    6  * schaalt 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.
    77 *
    8  * In solo-modus bestaat er maar één site -> next() (valt door naar postsRoutes,
    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).
    1010 */
    1111
     
    2626  if (!Number.isFinite(page) || page < 1) page = 1;
    2727
    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.
    3030  const mainRow = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
    3131  const mainId = mainRow ? mainRow.id : '';
    3232
    33   // Zoekterm tegen titel/slug/tagline (case-insensitive via LIKE; SQLite LIKE is
    34   // 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).
    3636  const like = '%' + q.replace(/[\\%_]/g, (m) => '\\' + m) + '%';
    3737  const conds = ['s.id != @mainId'];
  • src/routes/audio.js

    rbb42dfb r834bcc3  
    9292  const range = req.headers.range;
    9393
    94   // Statistieken: tel één play bij de initiële player-fetch (niet bij scrub/
    95   // range-continuaties; replays binnen 24u komen uit de browsercache → geen
    96   // dubbeltelling). 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.
    9797  if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) {
    9898    try {
     
    141141});
    142142
    143 // Welke 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>]].
    145145router.get('/track/:id/post', (req, res) => {
    146146  const id = String(req.params.id || '');
  • src/routes/auth.js

    rbb42dfb r834bcc3  
    1010import { premiumUnlocked } from '../services/PatreonService.js';
    1111
    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 is
    14 // (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).
    1515function fanLoginReady() {
    1616  return googleConfigured() && premiumUnlocked();
     
    2222const router = express.Router();
    2323
    24 // Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de
    25 // 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.
    2626const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
    2727
    28 // Canonieke basis-URL voor links in e-mails (reset). Uit headers bouwen is
    29 // spoofbaar (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.
    3030function publicBaseUrl(req) {
    3131  const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
    3232  if (cfg) return cfg;
    33   // Fallback (dev): trust-proxy-gesaneerde protocol + Host-header (NIET de rauwe
     33  // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw
    3434  // X-Forwarded-Host).
    3535  return `${req.protocol}://${req.get('host')}`;
     
    4040}
    4141
    42 // Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een
    43 // beheerder aanmaken. Daarna is registratie dicht (luisteraars komen via 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).
    4444function isSetupMode() {
    4545  return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
     
    4747
    4848// ==================== 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.
    5352router.get('/login', (req, res) => {
    5453  const next = safeNext(req.query.next) || '';
     
    6867});
    6968
    70 // Verborgen beheerders-login (gebruikersnaam + wachtwoord). Nergens in de UI
    71 // 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).
    7271router.get('/admin', (req, res) => {
    7372  const next = safeNext(req.query.next) || '';
     
    9190  const next = safeNext(req.body.next) || '';
    9291
    93   // Foutweergave op de (verborgen) beheerders-loginpagina: toon het wachtwoord-
    94   // formulier 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.
    9594  const renderErr = (error, status = 400) => {
    9695    res.status(status);
     
    105104
    106105  const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
    107   // Altijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord
    108   // 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.
    109108  const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
    110109  const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
     
    119118});
    120119
    121 // ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ====================
     120// ==================== FIRST-TIME SETUP (create admin account) ====================
    122121router.get('/register', (req, res) => {
    123122  const next = safeNext(req.query.next) || '';
    124123  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.
    126125  if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
    127126  renderPage(req, res, 'pages/auth-register', {
     
    139138  });
    140139
    141   // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via deze route.
     140  // Hard-closed once a user exists — prevents a second "admin" via this route.
    142141  if (!isSetupMode()) return res.redirect('/auth/login');
    143142
     
    150149  const userId = uuid();
    151150  const hash = bcrypt.hashSync(password, 10);
    152   // De allereerste gebruiker is de beheerder (god).
     151  // The very first user is the administrator (god).
    153152  db.prepare(`
    154153    INSERT INTO users (id, username, email, password_hash, role, theme, palette)
     
    156155  `).run(userId, username, email, hash);
    157156
    158   // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgt later).
    159   // Setup-wizard: sitenaam + taal komen uit het formulier; taal = de taal waarin
    160   // 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.
    161160  if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
    162161    const siteId = uuid();
     
    168167    `).run(siteId, username.toLowerCase(), title, '', userId, lang);
    169168    db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
    170     try { setSetting('default_lang', lang); } catch (e) { /* niet fataal */ }
     169    try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ }
    171170  }
    172171
     
    175174});
    176175
    177 // ==================== WACHTWOORD VERGETEN (aanvraag) ====================
     176// ==================== FORGOT PASSWORD (request) ====================
    178177router.get('/reset-request', (req, res) => {
    179178  if (req.session.user) return res.redirect('/');
     
    191190    const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
    192191    if (user) {
    193       const token = crypto.randomBytes(32).toString('hex'); // ruw: gaat alleen de mail/link in
     192      const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link
    194193      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.
    196195      db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
    197196        .run(hashToken(token), expires, user.id);
     
    211210        }
    212211      } 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.
    214213        console.log(`[password-reset] ${user.email} -> ${url}`);
    215214        devResetUrl = url;
    216215      } else {
    217         // Productie 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.
    218217        console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
    219218      }
     
    221220  }
    222221
    223   // Anti-enumeratie: zelfde antwoord ongeacht of het adres bestaat.
     222  // Anti-enumeration: same response regardless of whether the address exists.
    224223  renderPage(req, res, 'pages/auth-reset-request', {
    225224    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
     
    228227});
    229228
    230 // ==================== WACHTWOORD RESETTEN (toepassen) ====================
     229// ==================== RESET PASSWORD (apply) ====================
    231230router.get('/reset/:token', (req, res) => {
    232231  const row = db.prepare(`
     
    266265});
    267266
    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.
    270269router.get('/google', (req, res) => {
    271270  if (!fanLoginReady()) {
     
    279278});
    280279
    281 // Google KOPPELEN aan het huidige (ingelogde) account — bv. een beheerder die
    282 // voortaan óók met Google wil inloggen. Vereist dat je al ingelogd bent (met
    283 // 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).
    285284router.get('/google/link', requireAuth, (req, res) => {
    286285  if (!googleConfigured()) {
     
    289288  const state = crypto.randomBytes(16).toString('hex');
    290289  req.session.oauthState = state;
    291   req.session.oauthLink = true; // koppel-modus i.p.v. login-modus
     290  req.session.oauthLink = true; // link mode instead of login mode
    292291  res.redirect(authorizeUrl(state));
    293292});
     
    320319    const email = (info.email || '').trim().toLowerCase();
    321320
    322     // ── KOPPEL-MODUS: Google aan het huidige (ingelogde) account hangen ──
     321    // ── LINK MODE: attach Google to the current (logged-in) account ──
    323322    if (linking) {
    324323      delete req.session.oauthState; delete req.session.oauthLink;
     
    326325      if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.');
    327326      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.
    329328      const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id);
    330329      if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.');
     
    336335    }
    337336
    338     // ── LOGIN-MODUS (luisteraars/fans + gekoppelde beheerder) ──
     337    // ── LOGIN MODE (listeners/fans + linked admin) ──
    339338    if (!fanLoginReady()) return failLogin('unavailable');
    340339    const next = safeNext(req.session.oauthNext) || '';
     
    342341    if (!email || info.email_verified === false) return failLogin('email');
    343342
    344     // Zoek EERST op de gekoppelde Google-account (google_sub). Een sub-match is het
    345     // 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 ander
    347     // 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.
    348347    let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null;
    349348
    350349    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.
    352351      db.prepare(`
    353352        UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?
     
    356355      user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
    357356      if (user && (user.role === 'god' || user.role === 'admin')) {
    358         // Beheerder gevonden op e-mail maar ZONDER gekoppelde sub → Google geeft
    359         // nooit beheer. Eerst koppelen via Account → Inloggen met Google.
     357        // Admin found by email but WITHOUT a linked sub → Google never grants admin.
     358        // Must first link via Account → Sign in with Google.
    360359        return failLogin('admin');
    361360      } else if (user) {
    362         // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken.
     361        // Existing listener: link google_sub/avatar if missing.
    363362        db.prepare(`
    364363          UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),
     
    366365        `).run(info.sub || null, info.picture || null, user.id);
    367366      } else {
    368         // Nieuwe luisteraar — altijd member.
     367        // New listener — always member.
    369368        const userId = uuid();
    370369        const username = uniqueUsername(info.name || email.split('@')[0]);
  • src/routes/changelog.js

    rbb42dfb r834bcc3  
    11/**
    2  * Publieke wijzigingen-/release-pagina.
     2 * Public changelog / release page.
    33 *
    4  * GET /changelog  -> rendert CHANGELOG.md (de bron van waarheid voor releases).
     4 * GET /changelog  -> renders CHANGELOG.md (the source of truth for releases).
    55 *
    6  * De app-versie (footer, package.json) is bewust losgekoppeld van de
    7  * cirkel-federatie-proto (KLONKT_PROTO): een versie-bump is cosmetisch en raakt
    8  * de federatie niet. We tonen de proto hier expliciet zodat per release zichtbaar
    9  * 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).
    1010 */
    1111
  • src/routes/circle.js

    rbb42dfb r834bcc3  
    11/**
    2  * Cirkel-feed + lokale lees-pagina.
    3  *   GET /cirkel        -> overzicht (zelfde timeline/grid-view als de home)
    4  *   GET /cirkel/:id    -> losse remote-post in eigen chrome (blijf op je site)
    5  * Alleen actief als tenancy === 'circle' (anders next() -> 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.
    77 */
    88
     
    2525}
    2626
    27 // ── Overzicht ────────────────────────────────────────────────
     27// ── Overview ─────────────────────────────────────────────────
    2828router.get('/cirkel', (req, res, next) => {
    2929  if (getTenancy() !== 'circle') return next();
     
    4242    return {
    4343      id: r.id,
    44       // Lokale lees-pagina -> de kaart blijft op de eigen site (post-card linkt
    45       // lokaal + htmx, GEEN external_url).
     44      // Local reading page -> the card stays on the own site (post-card links
     45      // locally + htmx, NO external_url).
    4646      slug: 'cirkel/' + encodeURIComponent(r.id),
    4747      title: r.title || '(zonder titel)',
     
    5858  });
    5959
    60   // Sites in de cirkel — alleen actieve links (outdated/error vallen weg, net als
    61   // hun posts). Voor de grafische header met avatars.
     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.
    6262  const sites = db.prepare(`
    6363    SELECT a.name, a.url, a.avatar
     
    7676});
    7777
    78 // ── Losse remote-post (lokaal lezen) ─────────────────────────
     78// ── Individual remote post (local reading) ────────────────────
    7979router.get('/cirkel/:id', (req, res, next) => {
    8080  if (getTenancy() !== 'circle') return next();
  • src/routes/comments.js

    rbb42dfb r834bcc3  
    5858    if (!parent) return res.status(400).send('Invalid parent comment');
    5959    resolvedParent = parent.parent_comment_id || parent.id;
    60     parentAuthorId = parent.author_id; // ontvanger van de "antwoord"-melding
     60    parentAuthorId = parent.author_id; // recipient of the "reply" notification
    6161  }
    6262
     
    7777  `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status);
    7878
    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.
    8281  if (status === 'approved') {
    8382    const url = `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`;
  • src/routes/download.js

    rbb42dfb r834bcc3  
    11/**
    2  * Download-voor-email (premium feature #2).
     2 * Download-for-email (premium feature #2).
    33 *
    4  *   GET  /downloads                 -> lijst van downloadbare tracks (premium; anders 404)
    5  *   GET  /download/:id              -> e-mail-capture-pagina voor één track
    6  *   POST /download/:id              -> e-mail opslaan (-> mailinglijst) + download vrijgeven
    7  *   GET  /download/:id/bestand      -> serveert het bestand (sessie-gated na capture)
     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)
    88 *
    9  * De fan laat z'n e-mail achter en krijgt het bestand; het adres komt in de
    10  * subscribers-lijst (source 'download', single opt-in — geen confirm-drempel vóór de
     9 * 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
    1111 * download). Hub: via /user/:slug/... (resolveSite + siteUrlBase).
    1212 */
     
    2424const router = express.Router();
    2525
    26 // Als er een echte (gepinde) post met slug 'downloads' bestaat, hangt de
    27 // 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.
    2929function downloadsPostNav(req, res) {
    3030  const site = res.locals.site;
     
    4040
    4141const 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 na capture
     42const GRACE_MS = 15 * 60 * 1000; // download window after capture
    4343
    4444function dlTrack(siteId, id) {
     
    5555}
    5656
    57 // Lijst van downloadbare tracks.
     57// List of downloadable tracks.
    5858router.get('/downloads', (req, res, next) => {
    5959  if (!premiumUnlocked()) return next();
     
    6767  renderPage(req, res, 'pages/downloads', {
    6868    pageTitle: 'Downloads — ' + (site.title || ''),
    69     // on-special = compacte profielkop (zoals op een post); on-downloads = pill grijs
    70     // + feature-route-gedrag. Samen → downloads ziet er net zo uit als een post.
     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.
    7171    bodyClass: 'on-downloads on-special',
    7272    dlTracks: tracks,
     
    7676});
    7777
    78 // Capture-pagina voor één track.
     78// Capture page for a single track.
    7979router.get('/download/:id', (req, res, next) => {
    8080  if (!premiumUnlocked()) return next();
     
    9393});
    9494
    95 // E-mail opslaan + download vrijgeven.
     95// Save email + unlock download.
    9696router.post('/download/:id', (req, res, next) => {
    9797  if (!premiumUnlocked()) return next();
     
    109109    });
    110110  }
    111   // Download vrijgeven in de sessie (kort venster).
     111  // Unlock download in the session (short window).
    112112  if (!req.session.dl) req.session.dl = {};
    113113  req.session.dl[track.id] = Date.now();
     
    118118});
    119119
    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).
    121121router.get('/download/:id/bestand', (req, res, next) => {
    122122  if (!premiumUnlocked()) return next();
     
    129129    return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.');
    130130  }
    131   // De speelbare/te-downloaden file = de KALE filename (storage_path is een
    132   // absoluut 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.
    133133  const sp = track.filename;
    134134  if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path');
  • src/routes/embed.js

    rbb42dfb r834bcc3  
    11/**
    2  * Embedbare player (premium feature #7).
     2 * Embeddable player (premium feature #7).
    33 *
    4  *   GET /embed   -> een zelfstandige, compacte audiospeler-pagina (geen shell),
    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.
    66 *
    7  * De pagina wordt door ons (klonkt-origin) geserveerd, dus de audio-requests vanuit
    8  * het iframe blijven same-origin → de /audio/stream-gate laat ze door, ook al staat
    9  * 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.
    1111 */
    1212
     
    2121  if (!site) return next();
    2222
    23   // Inbedden op externe sites toestaan (overrule de globale frameguard/CSP).
     23  // Allow embedding on external sites (override the global frameguard/CSP).
    2424  res.removeHeader('X-Frame-Options');
    2525  res.setHeader(
  • src/routes/epk.js

    rbb42dfb r834bcc3  
    11/**
    2  * EPK / perskit (premium) — een deelbare perspagina per Klonkt-site.
     2 * EPK / press kit (premium) — a shareable press page per Klonkt site.
    33 *
    4  * GET /pers  (solo) of /user/:slug/pers (hub, via resolveSite + siteUrlBase)
    5  *   -> nette, openbare perskit: hero (foto/titel/tagline), korte bio, topnummers,
    6  *      recente 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.
    77 *
    8  * Premium-gated: niet-premium instances hebben GEEN /pers (next() -> 404 via de
    9  * 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 via
    11  * een 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.
    1212 */
    1313
     
    2121
    2222router.get('/pers', (req, res, next) => {
    23   if (!premiumUnlocked()) return next();      // geen premium -> geen perskit
     23  if (!premiumUnlocked()) return next();      // no premium -> no press kit
    2424  const site = res.locals.site;
    2525  if (!site) return next();
    2626
    27   // Nummers op de perskit: een door de admin GEKOZEN selectie (max 5, in eigen
    28   // 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.
    2929  let chosenIds = [];
    3030  try {
    3131    const raw = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]');
    3232    if (Array.isArray(raw)) chosenIds = raw.filter((x) => typeof x === 'string').slice(0, 5);
    33   } catch (e) { /* ongeldige JSON → val terug op top */ }
     33  } catch (e) { /* invalid JSON → fall back to top */ }
    3434
    3535  let tracks;
     
    4141    ).all(site.id, ...chosenIds);
    4242    const byId = new Map(rows.map((r) => [r.id, r]));
    43     tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean);  // behoud gekozen volgorde
     43    tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean);  // preserve chosen order
    4444  } else {
    4545    tracks = db.prepare(
     
    6060  ).all(site.id);
    6161
    62   // Pers-contact: per-site instelling (epk_contact_<siteId>) als die er is, anders
    63   // 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.
    6464  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.
    6666  const bio = (getSetting('epk_bio_' + site.id, '') || site.tagline || '').trim();
    6767
  • src/routes/federation.js

    rbb42dfb r834bcc3  
    1 // routes/federation.js — publieke Cirkels-endpoints (v1, publicatie-kant).
     1// routes/federation.js — public Cirkels endpoints (v1, publication side).
    22//
    3 //   GET /.klonkt/actor.json   — ActivityStreams-actor + Ed25519-pubkey
    4 //   GET /.klonkt/outbox.json  — publieke posts als AS Create-objecten,
    5 //                               getekend via de Klonkt-Signature-header
     3//   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
    66//
    7 // Site-agnostisch en zonder auth — alleen lezen. Zie docs/cirkels-v1-spec.md.
     7// Site-agnostic and unauthenticated — read-only. See docs/cirkels-v1-spec.md.
    88
    99import express from 'express';
     
    1818}
    1919
    20 // De proto die de consument zegt te draaien (uit z'n request-header), of 0.
     20// The proto the consumer claims to be running (from their request header), or 0.
    2121function consumerProto(req) {
    2222  return parseInt(req.get('Klonkt-Proto') || '0', 10) || 0;
     
    2424
    2525router.get('/.klonkt/actor.json', (req, res) => {
    26   // Cirkels = solo-naar-solo; hubs publiceren geen federatie-actor.
     26  // Circles = solo-to-solo; hubs do not publish a federation actor.
    2727  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 proto
    29   // 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.
    3030  const body = JSON.stringify(buildActor(baseUrl(req)), null, 2);
    3131  res.type('application/activity+json; charset=utf-8');
     
    3838  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
    3939  res.set('Klonkt-Proto', String(KLONKT_PROTO));
    40   // Te-oude consument? Weiger met 426 Upgrade Required (de crypto-binding sluit 'm
    41   // sowieso al uit; dit geeft een expliciet, leesbaar signaal). proto 0 = geen
    42   // 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.
    4343  const cp = consumerProto(req);
    4444  if (cp && cp < MIN_PROTO) {
  • src/routes/hub.js

    rbb42dfb r834bcc3  
    11/**
    2  * Hub-hoofdpagina — alleen in hub-modus. In plaats van de primaire Klonkt-site te
    3  * tonen, rendert '/' hier een bedrijfs-overview: de laatste posts van ALLE
    4  * 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.
    55 *
    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.
    77 */
    88
     
    1616router.get('/', (req, res, next) => {
    1717  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 zelf
    20   // 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.
    2121  if (res.locals.siteUrlBase) return next();
    2222
    23   // Laatste gepubliceerde posts over álle sites heen.
     23  // Latest published posts across all sites.
    2424  const posts = db.prepare(`
    2525    SELECT p.title, p.slug, p.excerpt, p.published_at, p.created_at,
     
    3535  `).all();
    3636
    37   // De hoofd-/labelsite (de expliciet primaire = de bedrijfs-/hoofdaccount) is
    38   // 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.
    3939  const mainSite = db.prepare(`
    4040    SELECT s.id, s.slug, s.title, s.tagline, s.profile_photo, s.accent,
     
    4848  const mainId = mainSite ? mainSite.id : '';
    4949
    50   // Uitgelichte Klonkt-site's voor de home-roster: meest-actief eerst (aantal
    51   // gepubliceerde posts), dan nieuwste. Excl. de hoofdsite. Beperkt tot
    52   // 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.
    5353  const HOME_ROSTER_LIMIT = 24;
    5454  const artists = db.prepare(`
     
    6464  const totalArtists = db.prepare('SELECT COUNT(*) AS c FROM sites WHERE id != ?').get(mainId).c;
    6565
    66   // De hub-pagina is GENERIEK (van geen enkele user) — branding komt uit globale
    67   // instellingen die de admin in Beheer beheert, niet uit een site.
     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.
    6868  const hub = {
    6969    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 en
    2 // 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.)
    33import express from 'express';
    44import { SUPPORTED } from '../services/i18n.js';
     
    1010  const code = SUPPORTED.includes(req.params.code) ? req.params.code : 'nl';
    1111  if (req.session) req.session.lang = code;
    12   // Ingelogd? Bewaar de keuze ook op het account zodat 'ie meereist over
    13   // apparaten/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).
    1414  if (req.session && req.session.user && req.session.user.id) {
    1515    try {
    1616      db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
    1717      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 */ }
    1919  }
    20   // Veilige terug-URL: alleen een intern pad (geen open redirect).
     20  // Safe back URL: internal path only (no open redirect).
    2121  let back = (typeof req.query.r === 'string') ? req.query.r : '';
    2222  if (!back.startsWith('/') || back.startsWith('//')) {
  • src/routes/linkbio.js

    rbb42dfb r834bcc3  
    11/**
    2  * Link-in-bio + klikstats (premium feature #6).
     2 * Link-in-bio + click stats (premium feature #6).
    33 *
    4  *   GET /links          -> Linktree-achtige pagina met de profile_links van de site
    5  *   GET /links/go/:i     -> telt de klik (per url) en stuurt door naar de externe URL
     4 *   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
    66 *
    7  * Hergebruikt de bestaande sites.profile_links (JSON [{platform,url}]) + de
    8  * 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 de
    10  * 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.
    1111 */
    1212
     
    4848  if (!link || !link.url) return next();
    4949  const url = String(link.url);
    50   // Alleen externe http(s)- of mailto-links (geen open redirect / javascript:).
     50  // Only external http(s) or mailto links (no open redirect / javascript:).
    5151  if (!/^https?:\/\//i.test(url) && !/^mailto:/i.test(url)) return res.status(400).send('Bad link');
    5252  try {
     
    5555       ON CONFLICT(site_id, url) DO UPDATE SET clicks = clicks + 1, updated_at = CURRENT_TIMESTAMP`
    5656    ).run(site.id, url);
    57   } catch { /* telling mag de redirect nooit breken */ }
     57  } catch { /* counting must never break the redirect */ }
    5858  res.redirect(302, url);
    5959});
  • src/routes/newsletter.js

    rbb42dfb r834bcc3  
    11/**
    2  * Nieuwsbrief — publieke kant (premium feature #1).
     2 * Newsletter — public side (premium feature #1).
    33 *
    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 bevestigen
    7  *   GET  /nieuwsbrief/uitschrijven/:token  -> uitschrijven (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)
    88 *
    9  * In hub-modus loopt dit via /user/:slug/nieuwsbrief (resolveSite zet siteUrlBase).
    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).
    1111 */
    1212
     
    5353
    5454  if (r.status === 'pending') {
    55     // Double opt-in: stuur de bevestigingsmail.
     55    // Double opt-in: send the confirmation email.
    5656    const link = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/bevestigen/' + r.token);
    5757    const unsub = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/uitschrijven/' + r.token);
     
    8181});
    8282
    83 // Uitschrijven mag altijd (ook als de premium-laag later uit zou gaan): een abonnee
    84 // 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.
    8585router.get('/nieuwsbrief/uitschrijven/:token', (req, res) => {
    8686  const ok = unsubscribe(req.params.token);
  • src/routes/notifications.js

    rbb42dfb r834bcc3  
    11/**
    2  * GET /notifications — meldingenpagina voor de ingelogde gebruiker.
    3  * Openen = 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).
    44 */
    55import express from 'express';
  • src/routes/posts.js

    rbb42dfb r834bcc3  
    4949});
    5050
    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 die
    54 // 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).
    5555function uniqueSlug(siteId, base, exceptId = null) {
    5656  let candidate = base;
     
    190190  if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
    191191
    192   // Dubbele 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.
    193193  finalSlug = uniqueSlug(site.id, finalSlug);
    194194
     
    199199  let finalStatus = status || 'draft';
    200200  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.
    203203  let publishAt = null;
    204204  const pa = Date.parse(req.body.publish_at || '');
     
    297297    const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
    298298    const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
    299     // Dubbele 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).
    300300    finalSlug = uniqueSlug(site.id, safe, post.id);
    301301  }
     
    310310  }
    311311
    312   // Release-planning: gepubliceerd + toekomstige publish_at -> 'scheduled'.
     312  // Release planning: published + future publish_at -> 'scheduled'.
    313313  let publishAt = null;
    314314  const pa = Date.parse(req.body.publish_at || '');
     
    412412});
    413413
    414 // Pad naar de like-knop-partial (voor de htmx-toggle re-render).
     414// Path to the like button partial (for the htmx toggle re-render).
    415415const LIKE_PARTIAL = path.join(__dirname, '..', 'views', 'partials', 'like-button.ejs');
    416416
    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).
    421420router.post('/posts/:id/like', requireAuth, (req, res) => {
    422421  const userId = req.session.user.id;
     
    430429  } else {
    431430    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).
    433432    notify({
    434433      userId: post.author_id, actorId: userId, actorName: req.session.user.username, type: 'like',
     
    444443});
    445444
    446 // Favorieten = de posts die de ingelogde gebruiker likete. Solo: binnen de
    447 // 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).
    448447router.get('/favorieten', requireAuth, (req, res) => {
    449448  const userId = req.session.user.id;
     
    469468});
    470469
    471 // Newer/Older-buren over ALLE posts in feed-volgorde. Gedeeld door de volledige
    472 // post-render én de fan-gate (premium fan_only), zodat de navigatie overal gelijk
    473 // 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.
    474473function postNeighbors(site, post, isHub) {
    475474  const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
     
    507506  `).get(site.id, req.params.slug);
    508507
    509   if (!post) return next(); // onbekende slug -> nette 404 catch-all
     508  if (!post) return next(); // unknown slug -> clean 404 catch-all
    510509
    511510  // Permission to view: published OR (logged in + can edit)
     
    515514  }
    516515
    517   // Fan-only preview (premium #3): volledige inhoud alleen voor ingelogde fans.
    518   // Anonieme bezoekers krijgen een nette login-gate i.p.v. de inhoud (de titel/
    519   // teaser mag 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).
    520519  if (post.fan_only && !(req.session && req.session.user)) {
    521     // Zelfde Newer/Older-navigatie als op een gewone post, zodat de bezoeker op
    522     // 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.
    523522    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
    524523    return renderPage(req, res, 'pages/fan-gate', {
     
    532531  }
    533532
    534   // Statistieken: tel de weergave (skipt beheerders + niet-gepubliceerd-eigen-preview).
     533  // Statistics: count the view (skips admins + unpublished own-preview).
    535534  if (post.status === 'published') recordPostView(post, req);
    536535
     
    588587      const byAlbum = new Map();
    589588      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 '').
    591590        if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
    592591        byAlbum.get(r.album).push({
     
    625624  }
    626625  } else {
    627     // LITE-modus (KLONKT_AUDIO=off): geen eigen audio (geen ffmpeg/stream-route).
    628     // Externe 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.
    630629    html = AudioEmbedService.autoembed(html);
    631630    html = AudioEmbedService.embedMediaShortcodes(html);
     
    665664  // Prev / next chronological (kept for back-compat — "post-nav" feature
    666665  // below the article still uses these as a simple linear navigation).
    667   // Hub-modus: Gerelateerde posts + Newer/Older trekken uit ALLE users (alle
    668   // 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).
    669668  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>.
    671670  const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
    672671
    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).
    674673  const { newerPost, olderPost } = postNeighbors(site, post, isHub);
    675674
     
    727726  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
    728727
    729   // Likes / favorieten: aantal + of de ingelogde gebruiker deze post likete.
     728  // Likes / favourites: count + whether the logged-in user liked this post.
    730729  const likeCount = db.prepare('SELECT COUNT(*) AS c FROM post_likes WHERE post_id = ?').get(post.id).c;
    731730  const likedByMe = !!(req.session?.user &&
  • src/routes/search.js

    rbb42dfb r834bcc3  
    11/**
    2  * GET /search?q=...          -> volledige resultatenpagina
    3  * GET /search/suggest?q=...  -> compacte JSON voor live resultaten in de overlay
     2 * GET /search?q=...          -> full results page
     3 * GET /search/suggest?q=...  -> compact JSON for live results in the overlay
    44 *
    5  * Doorzoekt de huidige site op:
     5 * Searches the current site across:
    66 *   1. Posts via posts_fts (FTS5, prefix-matching) — published only.
    7  *   2. Nummers (audio_tracks) op titel / artiest / album.
    8  *   3. Evenementen (shows) op plaats / locatie / land / notitie — als de agenda aan staat.
    9  *   4. Pagina's (Agenda / Downloads / Links / Perskit / Archief) op naam — alleen
    10  *      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.
    1111 *
    12  * FTS5: user-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
    13  * dubbele quotes + `*` gezet → prefix-match, geen operator-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.
    1414 */
    1515
     
    4747}
    4848
    49 // ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep
    50 //    (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). ──────────────────
    5151function searchSite(req, res, rawQ, lim) {
    5252  const site = res.locals.site;
     
    7979  }
    8080
    81   // 2. Nummers
     81  // 2. Tracks
    8282  try {
    8383    const trackRows = db.prepare(`
     
    108108  } catch (err) { if (!out.queryError) out.queryError = err.message; }
    109109
    110   // 3. Evenementen (agenda) — alleen als de agenda publiek aan staat.
     110  // 3. Events (agenda) — only when the agenda is publicly enabled.
    111111  if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
    112112    try {
     
    125125  }
    126126
    127   // 4. Pagina's — curated, alleen de beschikbare; match op de (vertaalde) naam.
     127  // 4. Pages — curated, available ones only; matched against the (translated) name.
    128128  const ql = rawQ.toLowerCase();
    129129  const candidates = [
     
    143143}
    144144
    145 // ── Volledige resultatenpagina ───────────────────────────────────────────────
     145// ── Full results page ────────────────────────────────────────────────────────
    146146router.get('/', (req, res) => {
    147147  const site = res.locals.site;
     
    165165});
    166166
    167 // ── Live suggesties (JSON) ───────────────────────────────────────────────────
     167// ── Live suggestions (JSON) ──────────────────────────────────────────────────
    168168router.get('/suggest', (req, res) => {
    169169  const site = res.locals.site;
  • src/routes/shows.js

    rbb42dfb r834bcc3  
    11/**
    2  * Show-agenda + notify-me (premium feature #8) — publieke kant.
     2 * Show agenda + notify-me (premium feature #8) — public side.
    33 *
    4  *   GET  /shows         -> komende optredens + "houd me op de hoogte"-formulier
    5  *   POST /shows/notify  -> aanmelden voor show-aankondigingen (subscribers, source
    6  *                          '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)
    77 *
    8  * De notify-bevestiging/uitschrijving hergebruikt de generieke subscriber-links
     8 * The notify confirm/unsubscribe reuses the generic subscriber links
    99 * (/nieuwsbrief/bevestigen|uitschrijven/:token). Hub: /user/:slug/shows.
    1010 */
     
    2020const router = express.Router();
    2121
    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.
    2323function agendaOn() { return getSetting('agenda_enabled') === '1'; }
    2424
Note: See TracChangeset for help on using the changeset viewer.