Changeset 834bcc3 in Klonkt for src/services


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/services
Files:
15 edited

Legend:

Unmodified
Added
Removed
  • src/services/AudioEmbedService.js

    rbb42dfb r834bcc3  
    1111 */
    1212
    13 // "Open in"-iconen (brand-gekleurd via CSS .pat-link--*).
     13// "Open in" icons (brand-colored via CSS .pat-link--).
    1414const OPEN_IN_SVG = {
    1515  spotify: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.6 14.42a.62.62 0 01-.86.21c-2.35-1.44-5.3-1.76-8.79-.96a.62.62 0 11-.28-1.21c3.8-.87 7.07-.5 9.71 1.11.3.18.39.57.22.85zm1.23-2.73a.78.78 0 01-1.07.26c-2.69-1.66-6.79-2.14-9.97-1.17a.78.78 0 11-.45-1.49c3.63-1.1 8.15-.56 11.24 1.33.36.22.48.7.25 1.07zm.1-2.85C14.66 8.95 9.4 8.78 6.3 9.72a.93.93 0 11-.54-1.79c3.56-1.08 9.37-.87 13.07 1.33a.94.94 0 01-.96 1.61z"/></svg>',
     
    1919
    2020class AudioEmbedService {
    21   // Kleine "open in"-links voor een track (Spotify/YouTube/SoundCloud). De hrefs
    22   // zijn server-side al gevalideerd (alleen https + juiste host). Geeft '' als er
    23   // geen links zijn. Wordt naast de play-knop gezet (buiten de knop → geen
    24   // conflict met afspelen).
     21  // Small "open in" links for a track (Spotify/YouTube/SoundCloud). The hrefs
     22  // are already validated server-side (https + correct host only). Returns ''
     23  // when no links exist. Placed next to the play button (outside the button →
     24  // no conflict with playback).
    2525  static openInLinks(t) {
    2626    if (!t) return '';
     
    4040    url = url.trim();
    4141
    42     // Alleen http(s)-URL's embedden. De provider-regexes hieronder zijn NIET
    43     // verankerd, dus zonder deze check zou bv. `javascript:alert(1)//youtu.be/x`
    44     // matchen en als embed-URL belanden (stored XSS via een [[embed:...]]-
    45     // shortcode — die tekst gaat niet langs de HTML-sanitizer omdat 'ie in een
    46     // text-node zit). De scheme-guard sluit javascript:/data:/vbscript: enz. uit.
     42    // Only embed http(s) URLs. The provider regexes below are NOT anchored,
     43    // so without this check e.g. `javascript:alert(1)//youtu.be/x` would match
     44    // and land as an embed URL (stored XSS via an [[embed:...]] shortcode —
     45    // that text never passes through the HTML sanitizer because it lives in a
     46    // text node). The scheme guard excludes javascript:/data:/vbscript: etc.
    4747    if (!/^https?:\/\//i.test(url)) return null;
    4848
     
    6868    }
    6969
    70     // YouTube — video-id is altijd exact 11 tekens (lijnt uit met de client-side
    71     // ytId() in embed-player.js, die ook {11} verwacht).
     70    // YouTube — video id is always exactly 11 characters (aligns with the client-side
     71    // ytId() in embed-player.js, which also expects {11}).
    7272    if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
    7373      const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
     
    8686  static generateIframe(provider, config) {
    8787    switch (provider) {
    88       // Eigen custom-spelers (client-side via embed-player.js + de echte
    89       // platform-API's). We renderen een placeholder met data-attributen i.p.v.
    90       // het kale platform-iframe, zodat de embed in ÓNZE huisstijl verschijnt.
     88      // Custom players (client-side via embed-player.js + the real platform APIs).
     89      // We render a placeholder with data attributes instead of the bare platform
     90      // iframe, so the embed appears in OUR brand style.
    9191      case 'youtube':
    9292        return this.embedPlaceholder('youtube', config.id, 'video',
     
    9797        return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
    9898          config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
    99       // Geen JS-API (Bandcamp/Apple) of niet-prioritair (Vimeo): blijven een
    100       // iframe; mutual-exclusion loopt voor deze via de blur-fallback.
     99      // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
     100      // mutual exclusion for these runs via the blur fallback.
    101101      case 'bandcamp':
    102102        return this.bandcampIframe(config);
     
    111111
    112112  /**
    113    * Placeholder voor een eigen custom-speler. embed-player.js pikt
    114    * .folio-embed[data-embed-provider] op en bouwt de kaart + speler client-side.
    115    * ALLE waarden via escape() — post.content_html wordt ongeescaped uitgevoerd.
     113   * Placeholder for a custom player. embed-player.js picks up
     114   * .folio-embed[data-embed-provider] and builds the card + player client-side.
     115   * ALL values go through escape() — post.content_html is executed unescaped.
    116116   */
    117117  static embedPlaceholder(provider, ref, type, url) {
     
    244244
    245245  /**
    246    * Replace [[embed:<url>]] shortcodes met de platform-iframe (YouTube, Spotify,
    247    * SoundCloud, Apple Music, Bandcamp, Vimeo). De editor-knop voegt deze
    248    * shortcode in; losse URL-regels embedden ook automatisch via autoembed().
    249    * Niet-ondersteunde/ongeldige URLs krijgen een nette inline-melding.
     246   * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
     247   * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
     248   * shortcode; bare URL lines also embed automatically via autoembed().
     249   * Unsupported/invalid URLs get a clean inline notice.
    250250   */
    251251  static embedMediaShortcodes(html) {
     
    274274      const artistH0 = this.escape(t.artist || '');
    275275      const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
    276       // Link-only track (geen audiobestand): geen afspeelknop, wel info + open-in.
     276      // Link-only track (no audio file): no play button, but info + open-in links.
    277277      if (!t.url) {
    278278        const coverH0 = this.escape(t.cover || '');
     
    302302      const artistH = this.escape(t.artist || '');
    303303      const urlH = this.escape(t.url);
    304       // Zichtbare eigenaar/licentie-regel onder de track.
     304      // Visible owner/license line below the track.
    305305      const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
    306306      const dataAttr = trackJson
    307307        .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
    308       // id="track-<id>" = anker zodat de mini-speler hierheen kan scrollen.
     308      // id="track-<id>" = anchor so the mini-player can scroll to this element.
    309309      return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
    310310  <button type="button" class="pat-play" aria-label="Play ${titleH}">
     
    337337      // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
    338338      const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
    339       // Alleen afspeelbare tracks (met url) in de queue; link-only tracks staan
    340       // wel in de lijst maar niet in de afspeel-JSON.
     339      // Only playable tracks (with url) in the queue; link-only tracks appear
     340      // in the list but not in the playback JSON.
    341341      const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
    342342        .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
     
    348348        const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
    349349        const tArtist = this.escape(t.artist || '');
    350         // Link-only track: geen afspeelknop, wel nummer + info + open-in.
     350        // Link-only track: no play button, but track number + info + open-in links.
    351351        if (!t.url) {
    352352          return `    <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
     
    436436      // Audio-player.js reads data-pcms-album for queue. Same shape as
    437437      // embedAlbumShortcodes — keep both in sync.
    438       // Alleen afspeelbare tracks in de queue; link-only tracks staan wel in de
    439       // lijst maar niet in de afspeel-JSON.
     438      // Only playable tracks in the queue; link-only tracks appear in the list
     439      // but not in the playback JSON.
    440440      const tracksData = pl.tracks.filter(t => t.url).map(t => ({
    441441        id:     t.id,
     
    479479          : `<span class="pat-num">${i + 1}</span>`;
    480480
    481         // Link-only track: geen klikbare afspeel-rij (statische div), wel open-in.
     481        // Link-only track: no clickable play row (static div), but open-in links.
    482482        if (!t.url) {
    483483          return `    <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
     
    594594   * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
    595595   * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
    596    * Per Robin's v9: "Externe link, klik = open platform (target _blank)".
     596   * Per Robin's v9: "External link, click = open platform (target _blank)".
    597597   */
    598598  static embedExternalLinkShortcodes(html) {
  • src/services/AudioTranscoder.js

    rbb42dfb r834bcc3  
    126126      size: outStat.size,
    127127      mimeType: 'audio/mpeg',
    128       durationSec,   // hele seconden uit ffmpeg's codecData (null als onbekend)
     128      durationSec,   // whole seconds from ffmpeg's codecData (null if unknown)
    129129    };
    130130
     
    138138
    139139/**
    140  * Herschrijf de ID3-tags van een BESTAANDE mp3 zonder her-encoden (`-c copy`).
    141  * Gebruikt bij het bewerken van track-metadata (titel/artiest/album/credit/licentie)
    142  * zodat de eigendomsinfo in het bestand zelf meereist bij een download.
    143  * ffmpeg kan niet in-place editen → schrijf naar tmp en hernoem atomisch terug.
     140 * Rewrite the ID3 tags of an EXISTING mp3 without re-encoding (`-c copy`).
     141 * Used when editing track metadata (title/artist/album/credit/license) so that
     142 * ownership info travels with the file on download.
     143 * ffmpeg cannot edit in-place → write to tmp and atomically rename back.
    144144 */
    145145export async function retagMp3({ filePath, tags = {} }) {
    146146  if (!filePath) throw new Error('retagMp3: filePath required');
    147   await stat(filePath); // throws als 't bestand mist
     147  await stat(filePath); // throws if file is missing
    148148  const dir = path.dirname(filePath);
    149149  const base = path.basename(filePath, path.extname(filePath));
     
    152152    await new Promise((resolve, reject) => {
    153153      const cmd = ffmpeg(filePath)
    154         .audioCodec('copy')        // geen her-encode → snel, geen kwaliteitsverlies
     154        .audioCodec('copy')        // no re-encode → fast, no quality loss
    155155        .format('mp3')
    156156        .outputOptions('-id3v2_version', '3')
     
    167167    });
    168168    const s = await stat(tmpPath);
    169     if (s.size === 0) throw new Error('retag output is leeg');
     169    if (s.size === 0) throw new Error('retag output is empty');
    170170    await rename(tmpPath, filePath);
    171171    return { filePath, size: s.size };
    172172  } catch (err) {
    173     try { await unlink(tmpPath); } catch { /* tmp bestaat mogelijk niet */ }
     173    try { await unlink(tmpPath); } catch { /* tmp may not exist */ }
    174174    throw err;
    175175  }
     
    209209    if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
    210210    if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
    211     if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — eigenaar/credit
    212     if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — licentie
     211    if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — owner/credit
     212    if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — license
    213213
    214214    cmd
    215       // codecData geeft de duur van de INPUT als "HH:MM:SS.xx" — zo bepalen we
    216       // de tracklengte automatisch zonder aparte ffprobe-binary.
     215      // codecData gives the INPUT duration as "HH:MM:SS.xx" — this lets us
     216      // determine the track length automatically without a separate ffprobe binary.
    217217      .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); })
    218218      .on('error', (err, stdout, stderr) => {
     
    230230
    231231/**
    232  * Parse een ffmpeg-duurstring "HH:MM:SS.xx" naar hele seconden. Geeft null bij
    233  * "N/A" of een onverwacht formaat.
     232 * Parse an ffmpeg duration string "HH:MM:SS.xx" to whole seconds. Returns null
     233 * for "N/A" or an unexpected format.
    234234 */
    235235function parseHmsToSeconds(hms) {
     
    242242
    243243/**
    244  * Lees de duur (hele seconden) van een audiobestand ZONDER te transcoderen.
    245  * Start een ffmpeg-pass en leest enkel het codecData-event (duur), waarna we het
    246  * proces direct stoppen — snel en zonder aparte ffprobe-binary (ffmpeg-static
    247  * levert alleen ffmpeg). Bedoeld voor het backfill-script.
     244 * Read the duration (whole seconds) of an audio file WITHOUT transcoding.
     245 * Starts an ffmpeg pass and reads only the codecData event (duration), then
     246 * kills the process immediately — fast and without a separate ffprobe binary
     247 * (ffmpeg-static ships only ffmpeg). Intended for the backfill script.
    248248 * @returns {Promise<number|null>}
    249249 */
     
    255255      .on('codecData', (data) => {
    256256        durationSec = parseHmsToSeconds(data && data.duration);
    257         try { cmd.kill('SIGKILL'); } catch { /* al klaar */ }
     257        try { cmd.kill('SIGKILL'); } catch { /* already done */ }
    258258        finish();
    259259      })
  • src/services/CircleFederation.js

    rbb42dfb r834bcc3  
    1 // CircleFederation.js — eigen publicatie-kant van "Cirkels" (v1).
     1// CircleFederation.js — publication side of "Circles" (v1).
    22//
    3 // Publiceert deze instance als een ActivityStreams-actor met een Ed25519-
    4 // sleutel, plus een outbox van publieke posts. De outbox wordt getekend zodat
    5 // consumenten (andere Klonkt-instances) de herkomst kunnen verifiëren.
     3// Publishes this instance as an ActivityStreams actor with an Ed25519 key,
     4// plus an outbox of public posts. The outbox is signed so that consumers
     5// (other Klonkt instances) can verify the origin.
    66//
    7 // v1 = alleen PUBLICEREN + tekenen. Het pullen/verifiëren van remote cirkels
    8 // (CircleService.sync) komt in een volgende stap. Zie docs/cirkels-v1-spec.md.
     7// v1 = PUBLISH + sign only. Pulling/verifying remote circles
     8// (CircleService.sync) comes in a later step. See docs/cirkels-v1-spec.md.
    99//
    10 // (Het idee om je netjes aan de bestaande standaarden te houden fluisterde een
    11 //  zekere Bart ons in. Wie hij is, waar hij vandaan komt — niemand die het zeker
    12 //  weet. Hij verscheen, sprak van ActivityStreams, en was weer weg.)
     10// (The idea of sticking neatly to existing standards was whispered to us by
     11//  a certain Bart. Who he is, where he came from — nobody knows for sure.
     12//  He appeared, spoke of ActivityStreams, and was gone.)
    1313
    1414import crypto from 'crypto';
     
    1616import { getSetting, setSetting } from './SettingsService.js';
    1717
    18 // ── Protocol-versie (federatie) ───────────────────────────────
    19 // KLONKT_PROTO zit IN de ondertekende grondslag (zie signingInput): een instance
    20 // die niet op deze proto draait kan onze getekende outbox NIET verifiëren, en wij
    21 // de hare niet. Bijblijven is dus geen beleefde check die je wegpatcht, maar
    22 // cryptografisch afgedwongen — de enige manier om mee te doen is dezelfde proto
    23 // draaien (= de update). Bump KLONKT_PROTO bij elke release die federatie/security
    24 // raakt, en koppel een securityfix aan elke bump → outdated = buiten + onveilig.
    25 // MIN_PROTO = de laagste proto waarmee we nog federeren.
     18// ── Protocol version (federation) ────────────────────────────
     19// KLONKT_PROTO is embedded IN the signed input (see signingInput): an instance
     20// not running this proto CANNOT verify our signed outbox, and we cannot verify
     21// theirs. Staying current is therefore not a polite check you can patch away,
     22// but cryptographically enforced — the only way to participate is to run the
     23// same proto (= apply the update). Bump KLONKT_PROTO for every release that
     24// touches federation/security, and attach a security fix to each bump →
     25// outdated = excluded + insecure.
     26// MIN_PROTO = the lowest proto we still federate with.
    2627export const KLONKT_PROTO = 2;
    2728export const MIN_PROTO = 2;
     
    3132}
    3233
    33 // ── Sleutelbeheer ─────────────────────────────────────────────
    34 // Per-instance Ed25519-keypair, eenmalig gegenereerd en in app_settings
    35 // bewaard. Privé = PKCS8-PEM (nooit serveren). Publiek = SPKI-DER base64
    36 // (gepubliceerd in de actor; round-trip via createPublicKey).
     34// ── Key management ────────────────────────────────────────────
     35// Per-instance Ed25519 keypair, generated once and stored in app_settings.
     36// Private = PKCS8 PEM (never served). Public = SPKI DER base64
     37// (published in the actor; round-tripped via createPublicKey).
    3738function getKeys() {
    3839  let priv = getSetting('circle_privkey_pem', null);
     
    5253}
    5354
    54 /** Tekent een body-string, gebonden aan de protocol-versie (Ed25519). */
     55/** Signs a body string, bound to the protocol version (Ed25519). */
    5556export function signBody(rawString, proto = KLONKT_PROTO) {
    5657  const key = crypto.createPrivateKey(getKeys().priv);
     
    5859}
    5960
    60 /** Verifieert een body tegen een SPKI-DER-base64 publieke sleutel, voor de gegeven
    61  *  proto. Een mismatch in proto = mismatch in grondslag = ongeldige handtekening. */
     61/** Verifies a body against an SPKI-DER-base64 public key for the given proto.
     62 *  A proto mismatch = a signing-input mismatch = invalid signature. */
    6263export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
    6364  try {
     
    7374// ── Helpers ───────────────────────────────────────────────────
    7475function primarySite() {
    75   // Solo: de primaire/owner-site (eerst aangemaakt) — zelfde keuze als resolveSite.
     76  // Solo: the primary/owner site (oldest) — same choice as resolveSite.
    7677  return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
    7778}
     
    8081  return String(s || '')
    8182    .replace(/<[^>]+>/g, ' ')
    82     .replace(/\[\[[^\]]*\]\]/g, ' ')   // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg
     83    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
    8384    .replace(/\s+/g, ' ')
    8485    .trim();
    8586}
    8687
    87 // Tags-kolom (JSON-array of comma-separated) -> nette string-array.
     88// Tags column (JSON array or comma-separated) -> clean string array.
    8889function parseTags(raw) {
    8990  if (!raw) return [];
    9091  if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
    91   try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* geen JSON */ }
     92  try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* not JSON */ }
    9293  return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
    9394}
     
    103104}
    104105
    105 // allow_circle: een site mag in cirkels van anderen verschijnen. v1 koppelt dit
    106 // aan is_public (aparte expliciete flag volgt in de Beheer-UX-stap).
     106// allow_circle: a site may appear in other instances' circles. v1 ties this
     107// to is_public (a separate explicit flag follows in the admin UX step).
    107108function allowsCircle(site) {
    108109  return !!site && site.is_public !== 0 && site.allow_circle !== 0;
     
    170171        published,
    171172        ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
    172         // ActivityStreams: tags als Hashtag-objecten (href naar de bron-tagpagina).
     173        // ActivityStreams: tags as Hashtag objects (href points to the source tag page).
    173174        ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}),
    174175      },
  • src/services/CircleService.js

    rbb42dfb r834bcc3  
    1 // CircleService.js — pull-kant van Cirkels (v1).
     1// CircleService.js — pull side of Circles (v1).
    22//
    3 // Haalt per circle_link de remote actor + outbox op, verifieert de Ed25519-
    4 // handtekening, sanitiseert en cachet publieke posts in remote_actors/remote_posts.
    5 // Alleen LEZEN van remote; nooit schrijven. Zie docs/cirkels-v1-spec.md §5b.
     3// Per circle_link: fetches the remote actor + outbox, verifies the Ed25519
     4// signature, sanitizes, and caches public posts in remote_actors/remote_posts.
     5// READ ONLY from remote; never write. See docs/cirkels-v1-spec.md §5b.
    66
    77import db from '../config/database.js';
     
    1616  return String(s || '')
    1717    .replace(/<[^>]+>/g, ' ')
    18     .replace(/\[\[[^\]]*\]\]/g, ' ')   // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg
     18    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
    1919    .replace(/\s+/g, ' ')
    2020    .trim();
     
    3131}
    3232
    33 // AS Hashtag-array -> comma-separated tagnamen (zonder #), gesanitized.
     33// AS Hashtag array -> comma-separated tag names (without #), sanitized.
    3434function extractTags(tag) {
    3535  if (!Array.isArray(tag)) return null;
     
    4141}
    4242
    43 // Bron buiten de cirkel zetten met een leesbare reden (geen stille mislukking).
    44 // Aparte status 'outdated' zodat de Beheer-UI er een nette "update vereist"-
    45 // melding van kan maken i.p.v. een generieke fout.
     43// Mark a source as outside the circle with a readable reason (no silent failure).
     44// Separate 'outdated' status so the admin UI can show a clean "update required"
     45// notice instead of a generic error.
    4646function markOutdated(link, msg) {
    47   // Gecachte posts van deze bron weghalen: we kunnen ze niet meer verifiëren of
    48   // verversen (proto-mismatch), dus ze horen niet meer in de cirkel-feed.
     47  // Remove cached posts from this source: we can no longer verify or refresh
     48  // them (proto mismatch), so they no longer belong in the circle feed.
    4949  if (link.remote_actor_id) {
    5050    try { db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id); } catch {}
     
    5555}
    5656
    57 // Robuuste, defensieve fetch: alleen https, timeout, body-cap, redirect-follow.
     57// Robust, defensive fetch: https only, timeout, body cap, redirect follow.
    5858async function fetchText(url) {
    5959  if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan');
     
    6666      headers: {
    6767        Accept: 'application/activity+json, application/json',
    68         // Vertel de publisher onze proto → die kan ons met 426 weren als we te oud zijn.
     68        // Tell the publisher our proto → they can reject us with 426 if we are too old.
    6969        'Klonkt-Proto': String(KLONKT_PROTO),
    7070      },
     
    7272    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    7373    const buf = Buffer.from(await res.arrayBuffer());
    74     if (buf.length > MAX_BODY_BYTES) throw new Error('body te groot');
     74    if (buf.length > MAX_BODY_BYTES) throw new Error('body too large');
    7575    return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url };
    7676  } finally {
     
    7979}
    8080
    81 // Lazy prepares — de tabellen bestaan pas ná initializeDatabase(); dit module
    82 // wordt geïmporteerd vóór die call, dus niet op module-niveau prepare'n.
     81// Lazy prepares — tables only exist after initializeDatabase(); this module is
     82// imported before that call, so do not prepare at module level.
    8383let _stmts = null;
    8484function stmts() {
     
    107107  const base = baseOf(link.remote_url);
    108108
    109   // 1. Actor ophalen + valideren
     109  // 1. Fetch + validate actor
    110110  const actorUrl = `${base}/.klonkt/actor.json`;
    111111  const a = await fetchText(actorUrl);
     
    117117  if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
    118118
    119   // Protocol-versie-gate. De proto zit óók in de outbox-handtekening-grondslag,
    120   // dus liegen in de (ongetekende) actor helpt niet: bij een echte mismatch faalt
    121   // de verificatie verderop alsnog. Hier vooral voor een DUIDELIJKE melding +
    122   // buitensluiten zonder stille mislukking.
     119  // Protocol version gate. The proto is also embedded in the outbox signing
     120  // input, so lying in the (unsigned) actor does not help: a real mismatch
     121  // will still fail verification later. This check is mainly for a CLEAR
     122  // message + exclusion without silent failure.
    123123  const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1;
    124124  if (remoteProto > KLONKT_PROTO) {
     
    131131  }
    132132
    133   // TOFU: een sleutelwissel vereist expliciete herbevestiging (anti-hijack)
     133  // TOFU: a key change requires explicit re-confirmation (anti-hijack)
    134134  const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
    135135  if (existing && existing.public_key !== pubKey) {
     
    146146  });
    147147
    148   // 2. Outbox ophalen + handtekening verifiëren
     148  // 2. Fetch outbox + verify signature
    149149  const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`;
    150150  const o = await fetchText(outboxUrl);
     
    158158  const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : [];
    159159
    160   // 3. Objecten sanitizen + cachen (same-origin als de actor = anti-impersonatie)
     160  // 3. Sanitize + cache objects (same origin as actor = anti-impersonation)
    161161  const actorOrigin = originOf(actorId);
    162162  const seen = new Set();
     
    186186  }
    187187
    188   // 4. Pruning: posts die niet meer in de outbox staan opruimen
     188  // 4. Pruning: remove posts that are no longer in the outbox
    189189  const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id);
    190190  const stale = known.filter((id) => !seen.has(id));
     
    194194  }
    195195
    196   // Naam automatisch overnemen van de remote actor (geen handmatige invoer nodig).
    197   // COALESCE: heeft de actor geen naam, dan blijft een evt. bestaand label staan.
     196  // Automatically adopt the name from the remote actor (no manual entry needed).
     197  // COALESCE: if the actor has no name, any existing label is preserved.
    198198  db.prepare(
    199199    "UPDATE circle_links SET remote_actor_id=?, label=COALESCE(?, label), last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?"
     
    221221
    222222let _timer = null;
    223 /** Periodieke achtergrond-sync (gated op tenancy='circle' binnen sync()). */
     223/** Periodic background sync (gated on tenancy='circle' inside sync()). */
    224224export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) {
    225225  if (_timer) return;
    226226  const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); };
    227   setTimeout(run, 30 * 1000); // korte delay na boot
     227  setTimeout(run, 30 * 1000); // short delay after boot
    228228  _timer = setInterval(run, intervalMs);
    229229  if (_timer.unref) _timer.unref();
  • src/services/ImageWebpService.js

    rbb42dfb r834bcc3  
    11/**
    2  * Zet een zojuist-geüploade afbeelding om naar WebP (kleiner, modern).
     2 * Convert a freshly uploaded image to WebP (smaller, modern format).
    33 *
    4  * Gebruikt het systeem-`cwebp` (libwebp). Aanwezig → converteer + verwijder het
    5  * origineel, geef de nieuwe .webp-bestandsnaam terug. Niet aanwezig of fout →
    6  * geef de originele bestandsnaam terug (graceful fallback, niks breekt).
     4 * Uses the system `cwebp` (libwebp). Present → convert + delete the original,
     5 * return the new .webp filename. Not present or error → return the original
     6 * filename (graceful fallback, nothing breaks).
    77 *
    8  * GIF blijft GIF (cwebp maakt geen geanimeerde webp van een gif); reeds-webp
    9  * wordt overgeslagen.
     8 * GIF stays GIF (cwebp cannot produce animated WebP from a GIF); already-WebP
     9 * files are skipped.
    1010 */
    1111import { execFileSync } from 'child_process';
     
    1717/**
    1818 * @param {{path:string, filename:string, destination?:string}} file  multer file
    19  * @returns {string} de definitieve bestandsnaam (basename) — .webp of het origineel
     19 * @returns {string} the final filename (basename) — .webp or the original
    2020 */
    2121export function toWebp(file) {
     
    2828  try {
    2929    execFileSync('cwebp', ['-quiet', '-q', QUALITY, file.path, '-o', outPath], { stdio: 'ignore' });
    30     if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error('lege output');
    31     try { fs.unlinkSync(file.path); } catch { /* origineel weg, niet kritisch */ }
     30    if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error('empty output');
     31    try { fs.unlinkSync(file.path); } catch { /* original gone, not critical */ }
    3232    return outName;
    3333  } catch (e) {
    34     console.warn('[webp] conversie overgeslagen (cwebp niet beschikbaar/fout):', e.message);
    35     try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // ruim halve output op
    36     return file.filename; // behoud origineel
     34    console.warn('[webp] conversion skipped (cwebp not available/error):', e.message);
     35    try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // clean up partial output
     36    return file.filename; // keep original
    3737  }
    3838}
  • src/services/NotificationService.js

    rbb42dfb r834bcc3  
    11/**
    2  * Meldingen — antwoord op je reactie, reactie op je post, like op je post.
    3  * Voor élke ingelogde gebruiker (Google-bezoekers/fans én admin). Snapshots van
    4  * actor-naam + post-titel zodat de lijst zonder joins te tonen is.
     2 * Notifications — reply to your comment, comment on your post, like on your post.
     3 * For every logged-in user (Google visitors/fans and admins). Snapshots of
     4 * actor name + post title so the list can be rendered without joins.
    55 */
    66import { randomUUID } from 'crypto';
    77import db from '../config/database.js';
    88
    9 // Maakt een melding aan. Doet niets als er geen ontvanger is of als je jezelf
    10 // zou notificeren (eigen reactie/like op eigen post/reactie).
     9// Creates a notification. Does nothing if there is no recipient or if you
     10// would notify yourself (your own comment/like on your own post/comment).
    1111export function notify({ userId, actorId, actorName, type, postSlug, postTitle, url }) {
    1212  if (!userId || userId === actorId) return;
     
    1616      VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
    1717    `).run(randomUUID(), userId, type, actorId || null, actorName || null, postSlug || null, postTitle || null, url || null);
    18   } catch { /* meldingen zijn niet-fataal */ }
     18  } catch { /* notifications are non-fatal */ }
    1919}
    2020
     
    3333export function markAllRead(userId) {
    3434  if (!userId) return;
    35   try { db.prepare('UPDATE user_notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId); } catch { /* noop */ }
     35  try { db.prepare('UPDATE user_notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId); } catch { /* no-op */ }
    3636}
    3737
  • src/services/PatreonService.js

    rbb42dfb r834bcc3  
    1 // Patreon-entitlement (premium-laag).
     1// Patreon entitlement (premium layer).
    22//
    3 // Model (Klonkt, 2026-06): de app + alle updates zijn gratis. Een paar premium-
    4 // modules (Hub-modus, Statistieken, Fan-login) zitten achter een $10-lifetime
    5 // Patreon-supporter-status. De centrale license-server (license.klonkt.com)
    6 // checkt Patreon en tekent een Ed25519-JWT "entitlement-token". DEZE instance
    7 // verifieert dat token OFFLINE met de publieke sleutel van de server — een
    8 // gekraakte/geforkte self-host kan dus geen geldig token verzinnen (alleen de
    9 // license-server kan tekenen). Dat is het echte slot; de feature-flags zelf zijn
    10 // op self-host wel te patchen (bewust geaccepteerd: $10 < moeite om te kraken).
     3// Model (Klonkt, 2026-06): the app + all updates are free. A few premium
     4// modules (Hub mode, Statistics, Fan login) are gated behind a $10 lifetime
     5// Patreon supporter status. The central license server (license.klonkt.com)
     6// checks Patreon and signs an Ed25519 JWT "entitlement token". THIS instance
     7// verifies that token OFFLINE using the server's public key — a cracked/forked
     8// self-host cannot forge a valid token (only the license server can sign).
     9// That is the real lock; feature flags themselves can be patched on self-host
     10// (deliberately accepted: $10 < effort to crack).
    1111//
    12 // Premium staat STANDAARD UIT (KLONKT_PREMIUM_ENABLED != 'on'): dan is er geen
    13 // premium-UI en wordt er niets gegate. De self-hoster zet 'm aan zodra Patreon
    14 // geregeld is.
     12// Premium is OFF by default (KLONKT_PREMIUM_ENABLED != 'on'): no premium UI
     13// is shown and nothing is gated. Self-hosters enable it once Patreon is set up.
    1514
    1615import crypto from 'node:crypto';
     
    2524export function licenseBase() { return LICENSE_URL; }
    2625
    27 // --- Publieke sleutel van de license-server cachen (voor offline verificatie) ---
     26// --- Cache the license-server public key (for offline verification) ---
    2827let _pubKey = null;
    2928async function licensePublicKey() {
    3029  if (_pubKey) return _pubKey;
    3130  const res = await fetch(`${LICENSE_URL}/pubkey`);
    32   if (!res.ok) throw new Error('pubkey fetch faalde: ' + res.status);
     31  if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status);
    3332  const pem = await res.text();
    3433  _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key
     
    4039}
    4140
    42 // Verifieer een entitlement-token (EdDSA-JWT van de license-server). Gooit bij
    43 // ongeldige handtekening/issuer/verlooptijd. Geeft de claims terug.
     41// Verify an entitlement token (EdDSA JWT from the license server). Throws on
     42// invalid signature, issuer, or expiry. Returns the claims on success.
    4443export async function verifyEntitlementToken(token) {
    4544  const parts = String(token || '').split('.');
     
    4746  const [h, p, s] = parts;
    4847  const header = JSON.parse(b64urlToBuf(h).toString('utf8'));
    49   if (header.alg !== 'EdDSA') throw new Error('onverwacht alg');
     48  if (header.alg !== 'EdDSA') throw new Error('unexpected alg');
    5049  const key = await licensePublicKey();
    5150  const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
    52   if (!ok) throw new Error('ongeldige handtekening');
     51  if (!ok) throw new Error('invalid signature');
    5352  const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
    54   if (payload.iss !== ISSUER) throw new Error('onverwachte issuer');
    55   if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('verlopen token');
     53  if (payload.iss !== ISSUER) throw new Error('unexpected issuer');
     54  if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token');
    5655  return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... }
    5756}
     
    7170}
    7271
    73 // Is deze instance premium? Premium-laag aan + een geldig, niet-verlopen,
    74 // entitled opgeslagen token. Patreon-lifetime daalt nooit, dus opnieuw koppelen
    75 // na verloop slaagt altijd.
     72// Is this instance premium? Premium layer enabled + a valid, non-expired,
     73// entitled stored token. Patreon lifetime never decreases, so re-linking
     74// after expiry always succeeds.
    7675export function isPremium() {
    7776  if (!premiumEnabled()) return false;
     
    8281}
    8382
    84 // Is een premium-feature beschikbaar? True als de premium-laag UIT staat (dan is
    85 // niets gegate — huidige gedrag), of AAN én deze instance is entitled. False alleen
    86 // als premium aan staat maar er geen geldige Patreon-koppeling is (= betaalmuur).
     83// Is a premium feature available? True if the premium layer is OFF (nothing is
     84// gated — current behavior), or ON and this instance is entitled. False only
     85// if premium is on but there is no valid Patreon connection (= paywall).
    8786export function premiumUnlocked() {
    8887  return !premiumEnabled() || isPremium();
  • src/services/PermissionsService.js

    rbb42dfb r834bcc3  
    4646    if (!user) return false;
    4747    if (user.role === 'god') return true;
    48     if (!site) return false; // geen site-context (bv. hub-landing) -> niets te posten
     48    if (!site) return false; // no site context (e.g. hub landing) -> nothing to post to
    4949    if (user.id === site.owner_id) return true; // Site owner
    5050    if (this.canAdminSite(user, site)) return true;
     
    5959    if (user.role === 'god') return true;
    6060    if (user.id === site.owner_id) return true;
    61     // Toegewezen mede-beheerder (collaborator) via site_members. Dit werd
    62     // voorheen via een nooit-gevulde user.siteRoles gelezen → dode code; nu
    63     // direct op de tabel (paar checks per pagina, indexed = goedkoop).
     61    // Assigned co-admin (collaborator) via site_members. Previously read from
     62    // a never-populated user.siteRoles → dead code; now queried directly on
     63    // the table (a few checks per page, indexed = cheap).
    6464    return !!db.prepare(
    6565      "SELECT 1 FROM site_members WHERE site_id = ? AND user_id = ? AND role = 'admin' LIMIT 1"
  • src/services/PlaylistService.js

    rbb42dfb r834bcc3  
    104104
    105105    const mappedTracks = tracks
    106       // Link-only tracks (geen media-bestand) blijven in de lijst staan met url ''.
     106      // Link-only tracks (no media file) remain in the list with url ''.
    107107      .map(t => ({
    108108        id: t.id,
     
    116116        url: (t.filename && urlFor) ? urlFor(t.filename) : '',
    117117      }));
    118     // Geen eigen cover? Val terug op de eerste track-cover, zodat de kaart niet leeg is.
     118    // No playlist cover? Fall back to the first track cover so the card isn't empty.
    119119    const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
    120120    return {
     
    220220   * Delete a playlist. Track references in playlist_tracks are removed
    221221   * automatically via ON DELETE CASCADE. Posts that embed this playlist
    222    * will render a "playlist niet gevonden" placeholder.
     222   * will render a "playlist not found" placeholder.
    223223   */
    224224  static delete(siteId, id) {
  • src/services/Scheduler.js

    rbb42dfb r834bcc3  
    11/**
    2  * Scheduler — release-planning (premium #3).
     2 * Scheduler — release planning (premium #3).
    33 *
    4  * Geplande posts hebben status 'scheduled' + publish_at (toekomst). Een lichte
    5  * timer zet ze op 'published' zodra publish_at bereikt is. Zo hoeven de publieke
    6  * queries (status='published') NIET aangepast te worden — een geplande post is
    7  * gewoon nog niet 'published' en dus nergens publiek zichtbaar tot het moment.
     4 * Scheduled posts have status 'scheduled' + publish_at (future). A lightweight
     5 * timer flips them to 'published' once publish_at is reached. This means public
     6 * queries (status='published') need NO changes — a scheduled post simply isn't
     7 * 'published' yet and therefore invisible until that moment.
    88 */
    99
     
    2525    for (const p of due) {
    2626      upd.run(p.id);
    27       try { fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS niet-fataal */ }
     27      try { fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ }
    2828    }
    2929    return due.length;
     
    3333let _timer = null;
    3434export function startScheduler() {
    35   flipScheduledPosts();                 // direct bij boot
     35  flipScheduledPosts();                 // run immediately on boot
    3636  if (_timer) return;
    37   _timer = setInterval(flipScheduledPosts, 60 * 1000); // elke minuut
     37  _timer = setInterval(flipScheduledPosts, 60 * 1000); // every minute
    3838  if (_timer.unref) _timer.unref();
    3939}
  • src/services/SettingsService.js

    rbb42dfb r834bcc3  
    1 // Globale app-instellingen (key/value, gecached). Nu vooral de tenancy-modus.
     1// Global app settings (key/value, cached). Primarily used for the tenancy mode.
    22//
    3 //   tenancy = 'solo'  -> precies één site (de primaire/owner-site)
    4 //   tenancy = 'hub'   -> hoofdsite (bedrijf) + /user/, admin wijst Klonkt-site's toe
     3//   tenancy = 'solo'  -> exactly one site (the primary/owner site)
     4//   tenancy = 'hub'   -> main site (company) + /user/, admin assigns Klonkt sites
    55//
    6 // De cache wordt bij setSetting meteen ververst, dus een toggle in Beheer werkt
    7 // live zonder herstart.
     6// The cache is updated immediately on setSetting, so a toggle in admin
     7// takes effect live without a restart.
    88
    99import db from '../config/database.js';
  • src/services/StatsService.js

    rbb42dfb r834bcc3  
    1 // StatsService — cookievrije statistieken (premium-module).
     1// StatsService — cookie-free statistics (premium module).
    22//
    3 // Tellers: posts.view_count, audio_tracks.play_count, en per dag/site het aantal
    4 // pageviews (stat_daily) + unieke bezoekers (stat_visitor_day).
     3// Counters: posts.view_count, audio_tracks.play_count, and per day/site the number
     4// of pageviews (stat_daily) + unique visitors (stat_visitor_day).
    55//
    6 // Unieke bezoekers ZONDER cookie: een sha256 van IP+UA+dag-salt. De salt roteert
    7 // elke dag en wordt nooit langer bewaard → je kunt iemand niet over dagen heen
    8 // volgen, het ruwe IP wordt niet opgeslagen. Geen persistente identifier, geen
    9 // toestemmingsbanner nodig (Plausible/Fathom-aanpak).
     6// Unique visitors WITHOUT cookies: a sha256 of IP+UA+daily-salt. The salt rotates
     7// every day and is never stored longer → you cannot track someone across days,
     8// and the raw IP is never persisted. No persistent identifier, no consent
     9// banner required (Plausible/Fathom approach).
    1010
    1111import crypto from 'node:crypto';
     
    1717}
    1818
    19 // Dagelijks roterende salt (gecachet in proces, persistent in app_settings zodat
    20 // een herstart binnen dezelfde dag dezelfde salt houdt).
     19// Daily rotating salt (cached in process, persisted in app_settings so that
     20// a restart within the same day reuses the same salt).
    2121let _salt = null, _saltDay = null;
    2222function dailySalt() {
     
    3939}
    4040
    41 // De eigenaar/beheerder niet meetellen — anders inflate je je eigen cijfers.
     41// Don't count the owner/admin — otherwise you inflate your own numbers.
    4242function isOperator(req) {
    4343  const u = req && req.session && req.session.user;
     
    4545}
    4646
    47 // Bekende bots/crawlers + link-preview-fetchers + scripts overslaan, zodat ze de
    48 // weergaven/bezoeker-dagen niet opblazen. Geen UA = vrijwel altijd geautomatiseerd.
     47// Skip known bots/crawlers + link-preview fetchers + scripts so they don't inflate
     48// view/visitor-day counts. Empty UA = almost always automated.
    4949const BOT_RE = /bot|crawl|spider|slurp|mediapartners|bingpreview|facebookexternalhit|whatsapp|telegram|discord|twitter|linkedin|embedly|pinterest|redditbot|applebot|petalbot|yandex|baidu|duckduckbot|semrush|ahrefs|mj12|dotbot|uptimerobot|pingdom|statuscake|headless|lighthouse|gptbot|claude|ccbot|perplexity|bytespider|amazonbot|googleother|google-read-aloud|python-requests|scrapy|curl|wget|axios|node-fetch|go-http|java\/|okhttp|libwww|httpclient/i;
    5050function isBot(req) {
    5151  const ua = (req && req.headers && req.headers['user-agent']) || '';
    52   if (!ua) return true;          // lege UA = script/bot
     52  if (!ua) return true;          // empty UA = script/bot
    5353  return BOT_RE.test(ua);
    5454}
    5555
    56 // Lazy prepares — tabellen bestaan pas ná initializeDatabase(); dit module wordt
    57 // geïmporteerd vóór die call.
     56// Lazy prepares — tables only exist after initializeDatabase(); this module is
     57// imported before that call.
    5858let _s = null;
    5959function stmts() {
     
    7575}
    7676
    77 // Externe referrer-host uit de Referer-header (pro-stats #5). Lege/eigen-site/
    78 // ongeldige referrers worden overgeslagen → alleen echte externe bronnen tellen.
     77// External referrer host from the Referer header (pro stats #5). Empty/own-site/
     78// invalid referrers are skipped → only genuine external sources are counted.
    7979function recordReferrer(siteId, req) {
    8080  try {
     
    8484    if (!host) return;
    8585    const own = ((req.headers && req.headers.host) || '').replace(/^www\./, '').toLowerCase();
    86     if (host === own) return; // interne navigatie telt niet als bron
     86    if (host === own) return; // internal navigation does not count as a source
    8787    stmts().bumpReferrer.run(siteId, host.slice(0, 120));
    88   } catch { /* geen geldige referrer-URL → overslaan */ }
     88  } catch { /* not a valid referrer URL → skip */ }
    8989}
    9090
     
    9696    stmts().addVisitor.run(siteId, d, visitorHash(req));
    9797    recordReferrer(siteId, req);
    98   } catch { /* statistieken mogen nooit een request breken */ }
     98  } catch { /* stats must never break a request */ }
    9999}
    100100
     
    112112}
    113113
    114 // Instance-brede statistieken (solo = de site, hub = alle sites samen).
     114// Instance-wide statistics (solo = the site, hub = all sites combined).
    115115export function getStats(days = 14) {
    116116  days = [7, 14, 30, 90].includes(Number(days)) ? Number(days) : 14;
     
    129129  }
    130130  const totals = {
    131     pageviews: series.reduce((s, r) => s + r.pageviews, 0), // laatste N dagen
    132     visitors: series.reduce((s, r) => s + r.visitors, 0),   // som van dag-uniques (cookieless kan niet anders)
     131    pageviews: series.reduce((s, r) => s + r.pageviews, 0), // last N days
     132    visitors: series.reduce((s, r) => s + r.visitors, 0),   // sum of daily uniques (cookieless has no alternative)
    133133    plays: db.prepare('SELECT COALESCE(SUM(play_count), 0) AS n FROM audio_tracks').get().n,
    134134    postViews: db.prepare('SELECT COALESCE(SUM(view_count), 0) AS n FROM posts').get().n,
     
    142142    ORDER BY play_count DESC LIMIT 5
    143143  `).all();
    144   // Top externe bronnen (pro #5) — instance-breed geaggregeerd per host.
     144  // Top external sources (pro #5) — aggregated instance-wide per host.
    145145  let referrers = [];
    146146  try {
     
    149149    ).all();
    150150  } catch { referrers = []; }
    151   // All-time totalen (cookieloze unieke bezoekers = som van dag-uniques).
     151  // All-time totals (cookieless unique visitors = sum of daily uniques).
    152152  const allTime = {
    153153    pageviews: db.prepare('SELECT COALESCE(SUM(pageviews),0) AS n FROM stat_daily').get().n,
  • src/services/SubscriberService.js

    rbb42dfb r834bcc3  
    11/**
    2  * SubscriberService — nieuwsbrief-abonnees per site (premium feature #1).
     2 * SubscriberService — newsletter subscribers per site (premium feature #1).
    33 *
    4  * Double opt-in als SMTP er is (status 'pending' → 'confirmed' via confirm-link),
    5  * anders single opt-in ('confirmed' meteen). Elke abonnee heeft een token dat zowel
    6  * de confirm- als de unsubscribe-link draagt. Hergebruikt door #2 (download-voor-
    7  * email) en #8 (notify-me) als gedeelde abonnee-opslag.
     4 * Double opt-in when SMTP is configured (status 'pending' → 'confirmed' via
     5 * confirm link), otherwise single opt-in ('confirmed' immediately). Each
     6 * subscriber has a token used for both the confirm and unsubscribe links.
     7 * Reused by #2 (download-for-email) and #8 (notify-me) as shared subscriber storage.
    88 */
    99
     
    2323
    2424/**
    25  * Voeg een abonnee toe (of heractiveer een uitgeschreven/bestaande).
     25 * Add a subscriber (or reactivate an unsubscribed/existing one).
    2626 * @returns {{ok:boolean, status?:string, token?:string, created?:boolean, error?:string}}
    27  *   status 'pending'  → er moet nog bevestigd worden (stuur confirm-mail)
    28  *   status 'confirmed'→ direct actief (single opt-in)
     27 *   status 'pending'  → confirmation still required (send confirm email)
     28 *   status 'confirmed'→ immediately active (single opt-in)
    2929 */
    3030export function addSubscriber(siteId, email, source = 'widget', { doubleOptin = false } = {}) {
     
    3737
    3838  if (existing) {
    39     // Al actief → niets te doen (idempotent, geen dubbele mail).
     39    // Already confirmed → nothing to do (idempotent, no duplicate email).
    4040    if (existing.status === 'confirmed') return { ok: true, status: 'confirmed', token: existing.token, created: false };
    41     // Pending of uitgeschreven → opnieuw uitnodigen/activeren met een verse token.
     41    // Pending or unsubscribed → re-invite/reactivate with a fresh token.
    4242    const token = newToken();
    4343    db.prepare("UPDATE subscribers SET status = ?, token = ?, source = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END WHERE id = ?")
     
    6969}
    7070
    71 /** Bevestigde abonnees (email + token) voor een site — voor het versturen.
    72  * Optioneel filteren op bron (bv. 'notify' voor show-aankondigingen). */
     71/** Confirmed subscribers (email + token) for a site — for sending newsletters.
     72 * Optionally filter by source (e.g. 'notify' for show announcements). */
    7373export function confirmedFor(siteId, source) {
    7474  if (source) {
  • src/services/ThemeService.js

    rbb42dfb r834bcc3  
    1616
    1717class ThemeService {
    18   // Paper/ink komen 1-op-1 uit de [data-palette]-CSS in style.css (= wat ECHT
    19   // wordt toegepast); de accent-stip is een representatieve kleur per palet.
     18  // Paper/ink values map 1-to-1 from the [data-palette] CSS in style.css (= what
     19  // is ACTUALLY applied); the accent dot is a representative color per palette.
    2020  static PALETTES = {
    21     // Merk-standaard — komt overeen met klonkt.com (donkerblauw + geel).
     21    // Brand default — matches klonkt.com (dark blue + gold).
    2222    klonkt: {
    2323      name: 'Klonkt',
     
    6565      dark: { paper: '#1f0a0f', ink: '#fce4ea', accent: '#f06b9a' }
    6666    },
    67     // key blijft 'mint' (DB-veilig), maar omgekleurd naar warm Terracotta — minder groen.
     67    // key stays 'mint' (DB-safe), but recolored to warm Terracotta — less green.
    6868    mint: {
    6969      name: 'Terracotta',
     
    8383   * Each color works against both light and dark themes.
    8484   */
    85   // Evenwichtig over het kleurenwiel — minder groen/blauw (4 van de 12),
    86   // meer warme + paars/roze variatie. Allemaal leesbaar op licht én donker.
     85  // Balanced across the color wheel — fewer greens/blues (4 of 12),
     86  // more warm + purple/pink variation. All readable on both light and dark.
    8787  static ACCENTS = [
    8888    { key: 'klonkt',  name: 'Klonkt-geel', color: '#e8b04b' },
  • src/services/ensurePrimarySite.js

    rbb42dfb r834bcc3  
    22import db from '../config/database.js';
    33
    4 // Een Klonkt-instance hoort ALTIJD een primaire site te hebben — die draagt de
    5 // identiteit (titel, thema, profiel) en is het ankerpunt in solo/hub/circle.
    6 // De register-flow maakt er al één aan, maar een via een script aangemaakte
    7 // beheerder (of een om wat voor reden dan ook lege sites-tabel) liet de
    8 // instance zonder site achter: geen instellingen, dashboard liep dood.
     4// A Klonkt instance should ALWAYS have a primary site — it carries the identity
     5// (title, theme, profile) and is the anchor point in solo/hub/circle mode.
     6// The register flow already creates one, but an admin created via a script
     7// (or an empty sites table for any reason) left the instance without a site:
     8// no settings, dashboard would crash.
    99//
    10 // Deze helper draait bij boot (en is idempotent): zodra er een beheerder is
    11 // maar nog geen enkele site, maakt 'ie een standaard-site aan, eigendom van de
    12 // eerste god/admin. Tenancy-onafhankelijk — geldt voor solo, hub én circle.
     10// This helper runs at boot (and is idempotent): as soon as there is an admin
     11// but no site yet, it creates a default site owned by the first god/admin.
     12// Tenancy-agnostic — applies to solo, hub, and circle.
    1313
    1414function defaultTitle() {
     
    2020      if (label) return label.charAt(0).toUpperCase() + label.slice(1);
    2121    }
    22   } catch { /* val terug op generiek */ }
     22  } catch { /* fall back to generic */ }
    2323  return 'Mijn site';
    2424}
     
    2626export function ensurePrimarySite() {
    2727  const count = db.prepare('SELECT COUNT(*) AS c FROM sites').get().c;
    28   if (count > 0) return null; // er is al een site — niets te doen
     28  if (count > 0) return null; // a site already exists — nothing to do
    2929
    3030  const owner = db.prepare(
    3131    "SELECT id FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1"
    3232  ).get();
    33   if (!owner) return null; // nog geen beheerder -> geen eigenaar, niets aanmaken
     33  if (!owner) return null; // no admin yet -> no owner, nothing to create
    3434
    3535  const siteId = uuid();
    36   const slug = 'main'; // niet gereserveerd; in solo wordt de primaire site sowieso gepind
     36  const slug = 'main'; // not reserved; in solo mode the primary site is always pinned anyway
    3737  db.prepare(`
    3838    INSERT INTO sites (
Note: See TracChangeset for help on using the changeset viewer.