Changeset 834bcc3 in Klonkt for src/services
- Timestamp:
- 06/23/2026 06:14:27 PM (3 months ago)
- Branches:
- main
- Children:
- d774679
- Parents:
- bb42dfb
- Location:
- src/services
- Files:
-
- 15 edited
-
AudioEmbedService.js (modified) (15 diffs)
-
AudioTranscoder.js (modified) (8 diffs)
-
CircleFederation.js (modified) (9 diffs)
-
CircleService.js (modified) (16 diffs)
-
ImageWebpService.js (modified) (3 diffs)
-
NotificationService.js (modified) (3 diffs)
-
PatreonService.js (modified) (6 diffs)
-
PermissionsService.js (modified) (2 diffs)
-
PlaylistService.js (modified) (3 diffs)
-
Scheduler.js (modified) (3 diffs)
-
SettingsService.js (modified) (1 diff)
-
StatsService.js (modified) (11 diffs)
-
SubscriberService.js (modified) (4 diffs)
-
ThemeService.js (modified) (3 diffs)
-
ensurePrimarySite.js (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/AudioEmbedService.js
rbb42dfb r834bcc3 11 11 */ 12 12 13 // "Open in" -iconen (brand-gekleurd via CSS .pat-link--*).13 // "Open in" icons (brand-colored via CSS .pat-link--). 14 14 const OPEN_IN_SVG = { 15 15 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>', … … 19 19 20 20 class AudioEmbedService { 21 // Kleine "open in"-links voor een track (Spotify/YouTube/SoundCloud). De hrefs22 // zijn server-side al gevalideerd (alleen https + juiste host). Geeft '' als er23 // geen links zijn. Wordt naast de play-knop gezet (buiten de knop → geen24 // 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). 25 25 static openInLinks(t) { 26 26 if (!t) return ''; … … 40 40 url = url.trim(); 41 41 42 // Alleen http(s)-URL's embedden. De provider-regexes hieronder zijn NIET43 // 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 een46 // 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. 47 47 if (!/^https?:\/\//i.test(url)) return null; 48 48 … … 68 68 } 69 69 70 // YouTube — video -id is altijd exact 11 tekens (lijnt uit met de client-side71 // 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}). 72 72 if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) { 73 73 const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i); … … 86 86 static generateIframe(provider, config) { 87 87 switch (provider) { 88 // Eigen custom-spelers (client-side via embed-player.js + de echte89 // 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. 91 91 case 'youtube': 92 92 return this.embedPlaceholder('youtube', config.id, 'video', … … 97 97 return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`, 98 98 config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`); 99 // Geen JS-API (Bandcamp/Apple) of niet-prioritair (Vimeo): blijven een100 // 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. 101 101 case 'bandcamp': 102 102 return this.bandcampIframe(config); … … 111 111 112 112 /** 113 * Placeholder voor een eigen custom-speler. embed-player.js pikt114 * .folio-embed[data-embed-provider] op en bouwt de kaart + speler client-side.115 * ALL E 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. 116 116 */ 117 117 static embedPlaceholder(provider, ref, type, url) { … … 244 244 245 245 /** 246 * Replace [[embed:<url>]] shortcodes met de platform-iframe (YouTube, Spotify,247 * SoundCloud, Apple Music, Bandcamp, Vimeo). De editor-knop voegt deze248 * shortcode in; losse URL-regels embedden ook automatischvia 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. 250 250 */ 251 251 static embedMediaShortcodes(html) { … … 274 274 const artistH0 = this.escape(t.artist || ''); 275 275 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. 277 277 if (!t.url) { 278 278 const coverH0 = this.escape(t.cover || ''); … … 302 302 const artistH = this.escape(t.artist || ''); 303 303 const urlH = this.escape(t.url); 304 // Zichtbare eigenaar/licentie-regel onder de track.304 // Visible owner/license line below the track. 305 305 const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · '); 306 306 const dataAttr = trackJson 307 307 .replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<'); 308 // id="track-<id>" = an ker zodat de mini-speler hierheen kan scrollen.308 // id="track-<id>" = anchor so the mini-player can scroll to this element. 309 309 return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'> 310 310 <button type="button" class="pat-play" aria-label="Play ${titleH}"> … … 337 337 // Stable DOM id for this rendering — used as data-pcms-album-id on tracks 338 338 const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10); 339 // Alleen afspeelbare tracks (met url) in de queue; link-only tracks staan340 // 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. 341 341 const albumJson = JSON.stringify(album.tracks.filter((t) => t.url)) 342 342 .replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<'); … … 348 348 const tTitle = this.escape(t.title || ('Track ' + (i + 1))); 349 349 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. 351 351 if (!t.url) { 352 352 return ` <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}> … … 436 436 // Audio-player.js reads data-pcms-album for queue. Same shape as 437 437 // embedAlbumShortcodes — keep both in sync. 438 // Alleen afspeelbare tracks in de queue; link-only tracks staan wel in de439 // 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. 440 440 const tracksData = pl.tracks.filter(t => t.url).map(t => ({ 441 441 id: t.id, … … 479 479 : `<span class="pat-num">${i + 1}</span>`; 480 480 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. 482 482 if (!t.url) { 483 483 return ` <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}> … … 594 594 * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a 595 595 * branded "Open in <Platform>" anchor (no iframe). Opens in new tab. 596 * Per Robin's v9: "Extern e link, klik = open platform (target _blank)".596 * Per Robin's v9: "External link, click = open platform (target _blank)". 597 597 */ 598 598 static embedExternalLinkShortcodes(html) { -
src/services/AudioTranscoder.js
rbb42dfb r834bcc3 126 126 size: outStat.size, 127 127 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) 129 129 }; 130 130 … … 138 138 139 139 /** 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. 144 144 */ 145 145 export async function retagMp3({ filePath, tags = {} }) { 146 146 if (!filePath) throw new Error('retagMp3: filePath required'); 147 await stat(filePath); // throws als 't bestand mist147 await stat(filePath); // throws if file is missing 148 148 const dir = path.dirname(filePath); 149 149 const base = path.basename(filePath, path.extname(filePath)); … … 152 152 await new Promise((resolve, reject) => { 153 153 const cmd = ffmpeg(filePath) 154 .audioCodec('copy') // geen her-encode → snel, geen kwaliteitsverlies154 .audioCodec('copy') // no re-encode → fast, no quality loss 155 155 .format('mp3') 156 156 .outputOptions('-id3v2_version', '3') … … 167 167 }); 168 168 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'); 170 170 await rename(tmpPath, filePath); 171 171 return { filePath, size: s.size }; 172 172 } catch (err) { 173 try { await unlink(tmpPath); } catch { /* tmp bestaat mogelijk niet */ }173 try { await unlink(tmpPath); } catch { /* tmp may not exist */ } 174 174 throw err; 175 175 } … … 209 209 if (tags.artist) cmd.outputOptions('-metadata', `artist=${tags.artist}`); 210 210 if (tags.album) cmd.outputOptions('-metadata', `album=${tags.album}`); 211 if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — eigenaar/credit212 if (tags.comment) cmd.outputOptions('-metadata', `comment=${tags.comment}`); // ID3 COMM — licen tie211 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 213 213 214 214 cmd 215 // codecData g eeft de duur van de INPUT als "HH:MM:SS.xx" — zo bepalen we216 // 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. 217 217 .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); }) 218 218 .on('error', (err, stdout, stderr) => { … … 230 230 231 231 /** 232 * Parse een ffmpeg-duurstring "HH:MM:SS.xx" naar hele seconden. Geeft null bij233 * "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. 234 234 */ 235 235 function parseHmsToSeconds(hms) { … … 242 242 243 243 /** 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 het246 * proces direct stoppen — snel en zonder aparte ffprobe-binary (ffmpeg-static247 * 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. 248 248 * @returns {Promise<number|null>} 249 249 */ … … 255 255 .on('codecData', (data) => { 256 256 durationSec = parseHmsToSeconds(data && data.duration); 257 try { cmd.kill('SIGKILL'); } catch { /* al klaar*/ }257 try { cmd.kill('SIGKILL'); } catch { /* already done */ } 258 258 finish(); 259 259 }) -
src/services/CircleFederation.js
rbb42dfb r834bcc3 1 // CircleFederation.js — eigen publicatie-kant van "Cirkels" (v1).1 // CircleFederation.js — publication side of "Circles" (v1). 2 2 // 3 // Publi ceert deze instance als een ActivityStreams-actor met een Ed25519-4 // sleutel, plus een outbox van publieke posts. De outbox wordt getekend zodat5 // 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. 6 6 // 7 // v1 = alleen PUBLICEREN + tekenen. Het pullen/verifiëren van remote cirkels8 // (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. 9 9 // 10 // ( Het idee om je netjes aan de bestaande standaarden te houden fluisterde een11 // zekere Bart ons in. Wie hij is, waar hij vandaan komt — niemand die het zeker12 // 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.) 13 13 14 14 import crypto from 'crypto'; … … 16 16 import { getSetting, setSetting } from './SettingsService.js'; 17 17 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. 26 27 export const KLONKT_PROTO = 2; 27 28 export const MIN_PROTO = 2; … … 31 32 } 32 33 33 // ── Sleutelbeheer ─────────────────────────────────────────────34 // Per-instance Ed25519 -keypair, eenmalig gegenereerd en in app_settings35 // bewaard. Privé = PKCS8-PEM (nooit serveren). Publiek = SPKI-DER base6436 // ( gepubliceerd in de actor; round-tripvia 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). 37 38 function getKeys() { 38 39 let priv = getSetting('circle_privkey_pem', null); … … 52 53 } 53 54 54 /** Tekent een body-string, gebonden aan de protocol-versie(Ed25519). */55 /** Signs a body string, bound to the protocol version (Ed25519). */ 55 56 export function signBody(rawString, proto = KLONKT_PROTO) { 56 57 const key = crypto.createPrivateKey(getKeys().priv); … … 58 59 } 59 60 60 /** Verifie ert een body tegen een SPKI-DER-base64 publieke sleutel, voor de gegeven61 * 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. */ 62 63 export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) { 63 64 try { … … 73 74 // ── Helpers ─────────────────────────────────────────────────── 74 75 function primarySite() { 75 // Solo: de primaire/owner-site (eerst aangemaakt) — zelfde keuze als resolveSite.76 // Solo: the primary/owner site (oldest) — same choice as resolveSite. 76 77 return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get(); 77 78 } … … 80 81 return String(s || '') 81 82 .replace(/<[^>]+>/g, ' ') 82 .replace(/\[\[[^\]]*\]\]/g, ' ') // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg83 .replace(/\[\[[^\]]*\]\]/g, ' ') // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes 83 84 .replace(/\s+/g, ' ') 84 85 .trim(); 85 86 } 86 87 87 // Tags -kolom (JSON-array of comma-separated) -> nette string-array.88 // Tags column (JSON array or comma-separated) -> clean string array. 88 89 function parseTags(raw) { 89 90 if (!raw) return []; 90 91 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 { /* geenJSON */ }92 try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* not JSON */ } 92 93 return String(raw).split(',').map((t) => t.trim()).filter(Boolean); 93 94 } … … 103 104 } 104 105 105 // allow_circle: een site mag in cirkels van anderen verschijnen. v1 koppelt dit106 // 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). 107 108 function allowsCircle(site) { 108 109 return !!site && site.is_public !== 0 && site.allow_circle !== 0; … … 170 171 published, 171 172 ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}), 172 // ActivityStreams: tags a ls Hashtag-objecten (href naar de bron-tagpagina).173 // ActivityStreams: tags as Hashtag objects (href points to the source tag page). 173 174 ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}), 174 175 }, -
src/services/CircleService.js
rbb42dfb r834bcc3 1 // CircleService.js — pull -kant van Cirkels (v1).1 // CircleService.js — pull side of Circles (v1). 2 2 // 3 // Haalt per circle_link de remote actor + outbox op, verifieert de Ed25519-4 // handtekening, sanitiseert en cachet publiekeposts 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. 6 6 7 7 import db from '../config/database.js'; … … 16 16 return String(s || '') 17 17 .replace(/<[^>]+>/g, ' ') 18 .replace(/\[\[[^\]]*\]\]/g, ' ') // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg18 .replace(/\[\[[^\]]*\]\]/g, ' ') // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes 19 19 .replace(/\s+/g, ' ') 20 20 .trim(); … … 31 31 } 32 32 33 // AS Hashtag -array -> comma-separated tagnamen (zonder #), gesanitized.33 // AS Hashtag array -> comma-separated tag names (without #), sanitized. 34 34 function extractTags(tag) { 35 35 if (!Array.isArray(tag)) return null; … … 41 41 } 42 42 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. 46 46 function markOutdated(link, msg) { 47 // Gecachte posts van deze bron weghalen: we kunnen ze niet meer verifiëren of48 // 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. 49 49 if (link.remote_actor_id) { 50 50 try { db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id); } catch {} … … 55 55 } 56 56 57 // Robu uste, defensieve fetch: alleen https, timeout, body-cap, redirect-follow.57 // Robust, defensive fetch: https only, timeout, body cap, redirect follow. 58 58 async function fetchText(url) { 59 59 if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan'); … … 66 66 headers: { 67 67 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. 69 69 'Klonkt-Proto': String(KLONKT_PROTO), 70 70 }, … … 72 72 if (!res.ok) throw new Error(`HTTP ${res.status}`); 73 73 const buf = Buffer.from(await res.arrayBuffer()); 74 if (buf.length > MAX_BODY_BYTES) throw new Error('body t e groot');74 if (buf.length > MAX_BODY_BYTES) throw new Error('body too large'); 75 75 return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url }; 76 76 } finally { … … 79 79 } 80 80 81 // Lazy prepares — de tabellen bestaan pas ná initializeDatabase(); dit module82 // 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. 83 83 let _stmts = null; 84 84 function stmts() { … … 107 107 const base = baseOf(link.remote_url); 108 108 109 // 1. Actor ophalen + valideren109 // 1. Fetch + validate actor 110 110 const actorUrl = `${base}/.klonkt/actor.json`; 111 111 const a = await fetchText(actorUrl); … … 117 117 if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL'); 118 118 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 faalt121 // 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. 123 123 const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1; 124 124 if (remoteProto > KLONKT_PROTO) { … … 131 131 } 132 132 133 // TOFU: een sleutelwissel vereist expliciete herbevestiging(anti-hijack)133 // TOFU: a key change requires explicit re-confirmation (anti-hijack) 134 134 const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId); 135 135 if (existing && existing.public_key !== pubKey) { … … 146 146 }); 147 147 148 // 2. Outbox ophalen + handtekening verifiëren148 // 2. Fetch outbox + verify signature 149 149 const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`; 150 150 const o = await fetchText(outboxUrl); … … 158 158 const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : []; 159 159 160 // 3. Objecten sanitizen + cachen (same-origin als de actor = anti-impersonatie)160 // 3. Sanitize + cache objects (same origin as actor = anti-impersonation) 161 161 const actorOrigin = originOf(actorId); 162 162 const seen = new Set(); … … 186 186 } 187 187 188 // 4. Pruning: posts die niet meer in de outbox staan opruimen188 // 4. Pruning: remove posts that are no longer in the outbox 189 189 const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id); 190 190 const stale = known.filter((id) => !seen.has(id)); … … 194 194 } 195 195 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. 198 198 db.prepare( 199 199 "UPDATE circle_links SET remote_actor_id=?, label=COALESCE(?, label), last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?" … … 221 221 222 222 let _timer = null; 223 /** Periodi eke achtergrond-sync (gated op tenancy='circle' binnensync()). */223 /** Periodic background sync (gated on tenancy='circle' inside sync()). */ 224 224 export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) { 225 225 if (_timer) return; 226 226 const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); }; 227 setTimeout(run, 30 * 1000); // korte delay naboot227 setTimeout(run, 30 * 1000); // short delay after boot 228 228 _timer = setInterval(run, intervalMs); 229 229 if (_timer.unref) _timer.unref(); -
src/services/ImageWebpService.js
rbb42dfb r834bcc3 1 1 /** 2 * Zet een zojuist-geüploade afbeelding om naar WebP (kleiner, modern).2 * Convert a freshly uploaded image to WebP (smaller, modern format). 3 3 * 4 * Gebruikt het systeem-`cwebp` (libwebp). Aanwezig → converteer + verwijder het5 * 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). 7 7 * 8 * GIF blijft GIF (cwebp maakt geen geanimeerde webp van een gif); reeds-webp9 * wordt overgeslagen.8 * GIF stays GIF (cwebp cannot produce animated WebP from a GIF); already-WebP 9 * files are skipped. 10 10 */ 11 11 import { execFileSync } from 'child_process'; … … 17 17 /** 18 18 * @param {{path:string, filename:string, destination?:string}} file multer file 19 * @returns {string} de definitieve bestandsnaam (basename) — .webp of het origineel19 * @returns {string} the final filename (basename) — .webp or the original 20 20 */ 21 21 export function toWebp(file) { … … 28 28 try { 29 29 execFileSync('cwebp', ['-quiet', '-q', QUALITY, file.path, '-o', outPath], { stdio: 'ignore' }); 30 if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error(' legeoutput');31 try { fs.unlinkSync(file.path); } catch { /* origin eel 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 */ } 32 32 return outName; 33 33 } catch (e) { 34 console.warn('[webp] conversi e overgeslagen (cwebp niet beschikbaar/fout):', e.message);35 try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // ruim halve output op36 return file.filename; // behoud origineel34 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 37 37 } 38 38 } -
src/services/NotificationService.js
rbb42dfb r834bcc3 1 1 /** 2 * Meldingen — antwoord op je reactie, reactie op je post, like op jepost.3 * Voor élke ingelogde gebruiker (Google-bezoekers/fans én admin). Snapshots van4 * 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. 5 5 */ 6 6 import { randomUUID } from 'crypto'; 7 7 import db from '../config/database.js'; 8 8 9 // Maakt een melding aan. Doet niets als er geen ontvanger is of als je jezelf10 // 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). 11 11 export function notify({ userId, actorId, actorName, type, postSlug, postTitle, url }) { 12 12 if (!userId || userId === actorId) return; … … 16 16 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) 17 17 `).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 */ } 19 19 } 20 20 … … 33 33 export function markAllRead(userId) { 34 34 if (!userId) return; 35 try { db.prepare('UPDATE user_notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId); } catch { /* no op */ }35 try { db.prepare('UPDATE user_notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId); } catch { /* no-op */ } 36 36 } 37 37 -
src/services/PatreonService.js
rbb42dfb r834bcc3 1 // Patreon -entitlement (premium-laag).1 // Patreon entitlement (premium layer). 2 2 // 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-lifetime5 // Patreon -supporter-status. De centrale license-server (license.klonkt.com)6 // check t Patreon en tekent een Ed25519-JWT "entitlement-token". DEZEinstance7 // verifie ert dat token OFFLINE met de publieke sleutel van de server — een8 // gekraakte/geforkte self-host kan dus geen geldig token verzinnen (alleen de9 // license-server kan tekenen). Dat is het echte slot; de feature-flags zelf zijn10 // 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). 11 11 // 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. 15 14 16 15 import crypto from 'node:crypto'; … … 25 24 export function licenseBase() { return LICENSE_URL; } 26 25 27 // --- Publieke sleutel van de license-server cachen (voor offline verificatie) ---26 // --- Cache the license-server public key (for offline verification) --- 28 27 let _pubKey = null; 29 28 async function licensePublicKey() { 30 29 if (_pubKey) return _pubKey; 31 30 const res = await fetch(`${LICENSE_URL}/pubkey`); 32 if (!res.ok) throw new Error('pubkey fetch fa alde: ' + res.status);31 if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status); 33 32 const pem = await res.text(); 34 33 _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key … … 40 39 } 41 40 42 // Verif ieer een entitlement-token (EdDSA-JWT van de license-server). Gooit bij43 // 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. 44 43 export async function verifyEntitlementToken(token) { 45 44 const parts = String(token || '').split('.'); … … 47 46 const [h, p, s] = parts; 48 47 const header = JSON.parse(b64urlToBuf(h).toString('utf8')); 49 if (header.alg !== 'EdDSA') throw new Error(' onverwachtalg');48 if (header.alg !== 'EdDSA') throw new Error('unexpected alg'); 50 49 const key = await licensePublicKey(); 51 50 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'); 53 52 const payload = JSON.parse(b64urlToBuf(p).toString('utf8')); 54 if (payload.iss !== ISSUER) throw new Error(' onverwachteissuer');55 if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error(' verlopentoken');53 if (payload.iss !== ISSUER) throw new Error('unexpected issuer'); 54 if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token'); 56 55 return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... } 57 56 } … … 71 70 } 72 71 73 // Is deze instance premium? Premium-laag aan + een geldig, niet-verlopen,74 // entitled opgeslagen token. Patreon-lifetime daalt nooit, dus opnieuw koppelen75 // 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. 76 75 export function isPremium() { 77 76 if (!premiumEnabled()) return false; … … 82 81 } 83 82 84 // Is een premium-feature beschikbaar? True als de premium-laag UIT staat (danis85 // niets gegate — huidige gedrag), of AAN én deze instance is entitled. False alleen86 // 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). 87 86 export function premiumUnlocked() { 88 87 return !premiumEnabled() || isPremium(); -
src/services/PermissionsService.js
rbb42dfb r834bcc3 46 46 if (!user) return false; 47 47 if (user.role === 'god') return true; 48 if (!site) return false; // geen site-context (bv. hub-landing) -> niets te posten48 if (!site) return false; // no site context (e.g. hub landing) -> nothing to post to 49 49 if (user.id === site.owner_id) return true; // Site owner 50 50 if (this.canAdminSite(user, site)) return true; … … 59 59 if (user.role === 'god') return true; 60 60 if (user.id === site.owner_id) return true; 61 // Toegewezen mede-beheerder (collaborator) via site_members. Dit werd62 // voorheen via een nooit-gevulde user.siteRoles gelezen → dode code; nu63 // 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). 64 64 return !!db.prepare( 65 65 "SELECT 1 FROM site_members WHERE site_id = ? AND user_id = ? AND role = 'admin' LIMIT 1" -
src/services/PlaylistService.js
rbb42dfb r834bcc3 104 104 105 105 const mappedTracks = tracks 106 // Link-only tracks ( geen media-bestand) blijven in de lijst staan meturl ''.106 // Link-only tracks (no media file) remain in the list with url ''. 107 107 .map(t => ({ 108 108 id: t.id, … … 116 116 url: (t.filename && urlFor) ? urlFor(t.filename) : '', 117 117 })); 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. 119 119 const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || ''; 120 120 return { … … 220 220 * Delete a playlist. Track references in playlist_tracks are removed 221 221 * automatically via ON DELETE CASCADE. Posts that embed this playlist 222 * will render a "playlist n iet gevonden" placeholder.222 * will render a "playlist not found" placeholder. 223 223 */ 224 224 static delete(siteId, id) { -
src/services/Scheduler.js
rbb42dfb r834bcc3 1 1 /** 2 * Scheduler — release -planning (premium #3).2 * Scheduler — release planning (premium #3). 3 3 * 4 * Geplande posts hebben status 'scheduled' + publish_at (toekomst). Een lichte5 * timer zet ze op 'published' zodra publish_at bereikt is. Zo hoeven de publieke6 * queries (status='published') NIET aangepast te worden — een geplande post is7 * 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. 8 8 */ 9 9 … … 25 25 for (const p of due) { 26 26 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 */ } 28 28 } 29 29 return due.length; … … 33 33 let _timer = null; 34 34 export function startScheduler() { 35 flipScheduledPosts(); // direct bijboot35 flipScheduledPosts(); // run immediately on boot 36 36 if (_timer) return; 37 _timer = setInterval(flipScheduledPosts, 60 * 1000); // e lke minuut37 _timer = setInterval(flipScheduledPosts, 60 * 1000); // every minute 38 38 if (_timer.unref) _timer.unref(); 39 39 } -
src/services/SettingsService.js
rbb42dfb r834bcc3 1 // Global e app-instellingen (key/value, gecached). Nu vooral de tenancy-modus.1 // Global app settings (key/value, cached). Primarily used for the tenancy mode. 2 2 // 3 // tenancy = 'solo' -> precies één site (de primaire/owner-site)4 // tenancy = 'hub' -> hoofdsite (bedrijf) + /user/, admin wijst Klonkt-site's toe3 // tenancy = 'solo' -> exactly one site (the primary/owner site) 4 // tenancy = 'hub' -> main site (company) + /user/, admin assigns Klonkt sites 5 5 // 6 // De cache wordt bij setSetting meteen ververst, dus een toggle in Beheer werkt7 // live zonder herstart.6 // The cache is updated immediately on setSetting, so a toggle in admin 7 // takes effect live without a restart. 8 8 9 9 import db from '../config/database.js'; -
src/services/StatsService.js
rbb42dfb r834bcc3 1 // StatsService — cookie vrije statistieken (premium-module).1 // StatsService — cookie-free statistics (premium module). 2 2 // 3 // Tellers: posts.view_count, audio_tracks.play_count, en per dag/site het aantal4 // 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). 5 5 // 6 // Uni eke bezoekers ZONDER cookie: een sha256 van IP+UA+dag-salt. De salt roteert7 // e lke dag en wordt nooit langer bewaard → je kunt iemand niet over dagen heen8 // volgen, het ruwe IP wordt niet opgeslagen. Geen persistente identifier, geen9 // 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). 10 10 11 11 import crypto from 'node:crypto'; … … 17 17 } 18 18 19 // Da gelijks roterende salt (gecachet in proces, persistent in app_settings zodat20 // 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). 21 21 let _salt = null, _saltDay = null; 22 22 function dailySalt() { … … 39 39 } 40 40 41 // D e eigenaar/beheerder niet meetellen — anders inflate je je eigen cijfers.41 // Don't count the owner/admin — otherwise you inflate your own numbers. 42 42 function isOperator(req) { 43 43 const u = req && req.session && req.session.user; … … 45 45 } 46 46 47 // Bekende bots/crawlers + link-preview-fetchers + scripts overslaan, zodat ze de48 // 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. 49 49 const 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; 50 50 function isBot(req) { 51 51 const ua = (req && req.headers && req.headers['user-agent']) || ''; 52 if (!ua) return true; // legeUA = script/bot52 if (!ua) return true; // empty UA = script/bot 53 53 return BOT_RE.test(ua); 54 54 } 55 55 56 // Lazy prepares — tab ellen bestaan pas ná initializeDatabase(); dit module wordt57 // geïmporteerd vóór diecall.56 // Lazy prepares — tables only exist after initializeDatabase(); this module is 57 // imported before that call. 58 58 let _s = null; 59 59 function stmts() { … … 75 75 } 76 76 77 // Extern e 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. 79 79 function recordReferrer(siteId, req) { 80 80 try { … … 84 84 if (!host) return; 85 85 const own = ((req.headers && req.headers.host) || '').replace(/^www\./, '').toLowerCase(); 86 if (host === own) return; // intern e navigatie telt niet als bron86 if (host === own) return; // internal navigation does not count as a source 87 87 stmts().bumpReferrer.run(siteId, host.slice(0, 120)); 88 } catch { /* geen geldige referrer-URL → overslaan*/ }88 } catch { /* not a valid referrer URL → skip */ } 89 89 } 90 90 … … 96 96 stmts().addVisitor.run(siteId, d, visitorHash(req)); 97 97 recordReferrer(siteId, req); 98 } catch { /* stat istieken mogen nooit een request breken*/ }98 } catch { /* stats must never break a request */ } 99 99 } 100 100 … … 112 112 } 113 113 114 // Instance- brede statistieken (solo = de site, hub = alle sites samen).114 // Instance-wide statistics (solo = the site, hub = all sites combined). 115 115 export function getStats(days = 14) { 116 116 days = [7, 14, 30, 90].includes(Number(days)) ? Number(days) : 14; … … 129 129 } 130 130 const totals = { 131 pageviews: series.reduce((s, r) => s + r.pageviews, 0), // la atste N dagen132 visitors: series.reduce((s, r) => s + r.visitors, 0), // s om 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) 133 133 plays: db.prepare('SELECT COALESCE(SUM(play_count), 0) AS n FROM audio_tracks').get().n, 134 134 postViews: db.prepare('SELECT COALESCE(SUM(view_count), 0) AS n FROM posts').get().n, … … 142 142 ORDER BY play_count DESC LIMIT 5 143 143 `).all(); 144 // Top extern e bronnen (pro #5) — instance-breed geaggregeerdper host.144 // Top external sources (pro #5) — aggregated instance-wide per host. 145 145 let referrers = []; 146 146 try { … … 149 149 ).all(); 150 150 } catch { referrers = []; } 151 // All-time total en (cookieloze unieke bezoekers = som van dag-uniques).151 // All-time totals (cookieless unique visitors = sum of daily uniques). 152 152 const allTime = { 153 153 pageviews: db.prepare('SELECT COALESCE(SUM(pageviews),0) AS n FROM stat_daily').get().n, -
src/services/SubscriberService.js
rbb42dfb r834bcc3 1 1 /** 2 * SubscriberService — n ieuwsbrief-abonnees per site (premium feature #1).2 * SubscriberService — newsletter subscribers per site (premium feature #1). 3 3 * 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 zowel6 * 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. 8 8 */ 9 9 … … 23 23 24 24 /** 25 * Voeg een abonnee toe (of heractiveer een uitgeschreven/bestaande).25 * Add a subscriber (or reactivate an unsubscribed/existing one). 26 26 * @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) 29 29 */ 30 30 export function addSubscriber(siteId, email, source = 'widget', { doubleOptin = false } = {}) { … … 37 37 38 38 if (existing) { 39 // Al actief → niets te doen (idempotent, geen dubbelemail).39 // Already confirmed → nothing to do (idempotent, no duplicate email). 40 40 if (existing.status === 'confirmed') return { ok: true, status: 'confirmed', token: existing.token, created: false }; 41 // Pending o f uitgeschreven → opnieuw uitnodigen/activeren met een versetoken.41 // Pending or unsubscribed → re-invite/reactivate with a fresh token. 42 42 const token = newToken(); 43 43 db.prepare("UPDATE subscribers SET status = ?, token = ?, source = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END WHERE id = ?") … … 69 69 } 70 70 71 /** Bevestigde abonnees (email + token) voor een site — voor het versturen.72 * Option eel 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). */ 73 73 export function confirmedFor(siteId, source) { 74 74 if (source) { -
src/services/ThemeService.js
rbb42dfb r834bcc3 16 16 17 17 class ThemeService { 18 // Paper/ink komen 1-op-1 uit de [data-palette]-CSS in style.css (= wat ECHT19 // 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. 20 20 static PALETTES = { 21 // Merk-standaard — komt overeen met klonkt.com (donkerblauw + geel).21 // Brand default — matches klonkt.com (dark blue + gold). 22 22 klonkt: { 23 23 name: 'Klonkt', … … 65 65 dark: { paper: '#1f0a0f', ink: '#fce4ea', accent: '#f06b9a' } 66 66 }, 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. 68 68 mint: { 69 69 name: 'Terracotta', … … 83 83 * Each color works against both light and dark themes. 84 84 */ 85 // Evenwichtig over het kleurenwiel — minder groen/blauw (4 van de12),86 // m eer 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. 87 87 static ACCENTS = [ 88 88 { key: 'klonkt', name: 'Klonkt-geel', color: '#e8b04b' }, -
src/services/ensurePrimarySite.js
rbb42dfb r834bcc3 2 2 import db from '../config/database.js'; 3 3 4 // Een Klonkt-instance hoort ALTIJD een primaire site te hebben — die draagt de5 // 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 aangemaakte7 // beheerder (of een om wat voor reden dan ook lege sites-tabel) liet de8 // 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. 9 9 // 10 // Deze helper draait bij boot (en is idempotent): zodra er een beheerder is11 // maar nog geen enkele site, maakt 'ie een standaard-site aan, eigendom van de12 // eerste god/admin. Tenancy-onafhankelijk — geldt voor solo, hub éncircle.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. 13 13 14 14 function defaultTitle() { … … 20 20 if (label) return label.charAt(0).toUpperCase() + label.slice(1); 21 21 } 22 } catch { /* val terug op generiek*/ }22 } catch { /* fall back to generic */ } 23 23 return 'Mijn site'; 24 24 } … … 26 26 export function ensurePrimarySite() { 27 27 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 doen28 if (count > 0) return null; // a site already exists — nothing to do 29 29 30 30 const owner = db.prepare( 31 31 "SELECT id FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1" 32 32 ).get(); 33 if (!owner) return null; // no g geen beheerder -> geen eigenaar, niets aanmaken33 if (!owner) return null; // no admin yet -> no owner, nothing to create 34 34 35 35 const siteId = uuid(); 36 const slug = 'main'; // n iet gereserveerd; in solo wordt de primaire site sowieso gepind36 const slug = 'main'; // not reserved; in solo mode the primary site is always pinned anyway 37 37 db.prepare(` 38 38 INSERT INTO sites (
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)