Changeset 834bcc3 in Klonkt
- Timestamp:
- 06/23/2026 06:14:27 PM (3 months ago)
- Branches:
- main
- Children:
- d774679
- Parents:
- bb42dfb
- Files:
-
- 83 edited
-
scripts/backfill-durations.mjs (modified) (2 diffs)
-
scripts/reset-admin.mjs (modified) (2 diffs)
-
src/assets/js/audio-player.js (modified) (23 diffs)
-
src/assets/js/embed-player.js (modified) (27 diffs)
-
src/assets/js/smooth-scroll.js (modified) (1 diff)
-
src/config/database.js (modified) (12 diffs)
-
src/config/features.js (modified) (1 diff)
-
src/config/google.js (modified) (5 diffs)
-
src/config/mailer.js (modified) (5 diffs)
-
src/middleware/auth.js (modified) (3 diffs)
-
src/middleware/rate-limit.js (modified) (1 diff)
-
src/middleware/render.js (modified) (9 diffs)
-
src/middleware/site.js (modified) (4 diffs)
-
src/routes/account.js (modified) (7 diffs)
-
src/routes/admin-audio.js (modified) (11 diffs)
-
src/routes/admin-circle.js (modified) (6 diffs)
-
src/routes/admin-comments.js (modified) (1 diff)
-
src/routes/admin-epk.js (modified) (2 diffs)
-
src/routes/admin-newsletter.js (modified) (2 diffs)
-
src/routes/admin-patreon.js (modified) (1 diff)
-
src/routes/admin-seo.js (modified) (1 diff)
-
src/routes/admin-settings.js (modified) (14 diffs)
-
src/routes/admin-shows.js (modified) (3 diffs)
-
src/routes/admin-sites.js (modified) (8 diffs)
-
src/routes/admin-stats.js (modified) (2 diffs)
-
src/routes/admin-updates.js (modified) (3 diffs)
-
src/routes/admin-users.js (modified) (4 diffs)
-
src/routes/admin.js (modified) (6 diffs)
-
src/routes/artists.js (modified) (2 diffs)
-
src/routes/audio.js (modified) (2 diffs)
-
src/routes/auth.js (modified) (26 diffs)
-
src/routes/changelog.js (modified) (1 diff)
-
src/routes/circle.js (modified) (5 diffs)
-
src/routes/comments.js (modified) (2 diffs)
-
src/routes/download.js (modified) (10 diffs)
-
src/routes/embed.js (modified) (2 diffs)
-
src/routes/epk.js (modified) (4 diffs)
-
src/routes/federation.js (modified) (4 diffs)
-
src/routes/hub.js (modified) (5 diffs)
-
src/routes/lang.js (modified) (2 diffs)
-
src/routes/linkbio.js (modified) (3 diffs)
-
src/routes/newsletter.js (modified) (3 diffs)
-
src/routes/notifications.js (modified) (1 diff)
-
src/routes/posts.js (modified) (16 diffs)
-
src/routes/search.js (modified) (7 diffs)
-
src/routes/shows.js (modified) (2 diffs)
-
src/server.js (modified) (12 diffs)
-
src/services/AudioEmbedService.js (modified) (15 diffs)
-
src/services/AudioTranscoder.js (modified) (8 diffs)
-
src/services/CircleFederation.js (modified) (9 diffs)
-
src/services/CircleService.js (modified) (16 diffs)
-
src/services/ImageWebpService.js (modified) (3 diffs)
-
src/services/NotificationService.js (modified) (3 diffs)
-
src/services/PatreonService.js (modified) (6 diffs)
-
src/services/PermissionsService.js (modified) (2 diffs)
-
src/services/PlaylistService.js (modified) (3 diffs)
-
src/services/Scheduler.js (modified) (3 diffs)
-
src/services/SettingsService.js (modified) (1 diff)
-
src/services/StatsService.js (modified) (11 diffs)
-
src/services/SubscriberService.js (modified) (4 diffs)
-
src/services/ThemeService.js (modified) (3 diffs)
-
src/services/ensurePrimarySite.js (modified) (3 diffs)
-
src/views/pages/account.ejs (modified) (3 diffs)
-
src/views/pages/admin-circle.ejs (modified) (2 diffs)
-
src/views/pages/admin-google.ejs (modified) (1 diff)
-
src/views/pages/admin-seo.ejs (modified) (1 diff)
-
src/views/pages/admin-settings.ejs (modified) (10 diffs)
-
src/views/pages/admin-shows.ejs (modified) (1 diff)
-
src/views/pages/admin-site-edit.ejs (modified) (6 diffs)
-
src/views/pages/admin-users.ejs (modified) (2 diffs)
-
src/views/pages/auth-login.ejs (modified) (5 diffs)
-
src/views/pages/circle-feed.ejs (modified) (2 diffs)
-
src/views/pages/fan-gate.ejs (modified) (2 diffs)
-
src/views/pages/hub-home.ejs (modified) (8 diffs)
-
src/views/pages/post-edit.ejs (modified) (44 diffs)
-
src/views/pages/post.ejs (modified) (4 diffs)
-
src/views/partials/bottom-tab.ejs (modified) (4 diffs)
-
src/views/partials/footer.ejs (modified) (4 diffs)
-
src/views/partials/like-button.ejs (modified) (1 diff)
-
src/views/partials/post-nav.ejs (modified) (2 diffs)
-
src/views/partials/shared-styles.ejs (modified) (4 diffs)
-
src/views/partials/topnav.ejs (modified) (8 diffs)
-
src/views/partials/track-editor.ejs (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
scripts/backfill-durations.mjs
rbb42dfb r834bcc3 1 1 #!/usr/bin/env node 2 2 /** 3 * Backfill audio_tracks.duration voor bestaande tracks die nog géén duur hebben.4 * Leest de duur uit het mp3-bestand via ffmpeg (probeDuration — geen aparte5 * ffprobe -binary nodig). Idempotent: pakt alleen rijen met duration NULL of0,6 * dus veilig herhaalbaar.3 * Backfill audio_tracks.duration for existing tracks that have no duration yet. 4 * Reads the duration from the mp3 file via ffmpeg (probeDuration — no separate 5 * ffprobe binary required). Idempotent: only touches rows with duration NULL or 0, 6 * so it is safe to run repeatedly. 7 7 * 8 8 * npm run backfill:durations 9 9 * 10 * Respect eert AUDIO_PATH (env) net als de upload-route.10 * Respects AUDIO_PATH (env) just like the upload route. 11 11 */ 12 12 import path from 'path'; … … 21 21 ); 22 22 23 // storage_path is absolu ut (opgeslagen bij upload); val terug opAUDIO_DIR/filename.23 // storage_path is absolute (stored at upload time); fall back to AUDIO_DIR/filename. 24 24 function resolveFile(t) { 25 25 const candidates = [t.storage_path, t.filename ? path.join(AUDIO_DIR, t.filename) : null].filter(Boolean); 26 26 for (const c of candidates) { 27 try { if (fs.statSync(c).isFile()) return c; } catch { /* volgende kandidaat*/ }27 try { if (fs.statSync(c).isFile()) return c; } catch { /* try next candidate */ } 28 28 } 29 29 return null; -
scripts/reset-admin.mjs
rbb42dfb r834bcc3 1 1 #!/usr/bin/env node 2 // Break-glass: reset (o f zet) het wachtwoord van een beheerder. Werkt altijd,3 // zonder e-mail — de self-hoster heeft immers shell-/servertoegang.2 // Break-glass: reset (or set) the password of an admin account. Always works, 3 // no email required — the self-hoster has shell/server access. 4 4 // 5 // Gebruik:6 // npm run reset-admin # reset de (eerste) god-user, print nieuw wachtwoord7 // npm run reset-admin -- <user|email> # reset specifieke user, print nieuw wachtwoord8 // npm run reset-admin -- <user|email> <pw> # zet een gekozen wachtwoord5 // Usage: 6 // npm run reset-admin # reset the (first) god user, print new password 7 // npm run reset-admin -- <user|email> # reset a specific user, print new password 8 // npm run reset-admin -- <user|email> <pw> # set a chosen password 9 9 // 10 // Draai dit vanuit de projectroot zodat DATABASE_PATH/.env correct geladen wordt.10 // Run from the project root so DATABASE_PATH/.env is loaded correctly. 11 11 12 12 import 'dotenv/config'; … … 22 22 user = db.prepare('SELECT * FROM users WHERE username = ? OR LOWER(email) = LOWER(?)').get(arg, arg); 23 23 } else { 24 // Geen arg: pak de beheerder (god of admin), anders de allereersteuser.24 // No arg: pick the admin (god or admin role), otherwise the very first user. 25 25 user = 26 26 db.prepare("SELECT * FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1").get() || -
src/assets/js/audio-player.js
rbb42dfb r834bcc3 230 230 } 231 231 currentObjectUrl = objUrl; 232 // Schone overgang: pause + load forceert reset van internal state na233 // m eerdere src-changes (voorkomt state-corruption van het audio-element).232 // Clean transition: pause + load forces a reset of internal state after 233 // multiple src changes (prevents state corruption of the audio element). 234 234 try { audio.pause(); } catch (e) {} 235 235 audio.src = objUrl; … … 276 276 // Used by the site pre-seed so opening a page doesn't auto-download audio; 277 277 // the blob is fetched lazily on the first play(). 278 // Markeer de huidige track persistent (blijvende highlight zolang 'ie actief is).278 // Persistently mark the current track (stays highlighted as long as it's active). 279 279 function markPlaying(trackId) { 280 280 document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing')); … … 283 283 if (el) el.classList.add('pat-playing'); 284 284 } 285 // Na een htmx-navigatie is de post-DOM vervangen → highlight opnieuw zetten.285 // After an htmx navigation the post DOM is replaced → reapply the highlight. 286 286 document.body.addEventListener('htmx:afterSettle', () => { 287 287 const t = queue[currentIndex]; … … 341 341 // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered 342 342 // panel, so we do NOT auto-open it there. 343 // Drie weergaves (Robin 2026-06-15): telefoon(<768) = fullscreen sheet;344 // tablet/ auto (768–1199) = grote landscape full-player (tablet + tablet-in-de-345 // auto); desktop (≥1200) = alleen mini-speler, GEEN full player. matchMedia346 // zodat dit exact de CSS-breekpunten volgt.343 // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet; 344 // tablet/car (768–1199) = large landscape full-player (tablet + car-mode); 345 // desktop (≥1200) = mini-player only, NO full player. matchMedia so this 346 // exactly follows the CSS breakpoints. 347 347 function playerTier() { 348 348 if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop'; … … 359 359 loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true); 360 360 // Mobile: a track press from an album/playlist auto-opens the full 361 // now-playing sheet (Spotify-style) i .p.v. alleen de dunnemini-strip.362 // Alleen hier (setQueue = een verse, door-de-gebruiker-gestartequeue) —363 // n iet bij next/prev of de site-pre-seed — zodat een sheet die de gebruiker364 // bewust sloot niet vanzelf terugkomt.361 // now-playing sheet (Spotify-style) instead of just the thin mini-strip. 362 // Only here (setQueue = a fresh, user-initiated queue) — 363 // not on next/prev or the site pre-seed — so a sheet the user 364 // deliberately closed doesn't reappear by itself. 365 365 if (hasFullPlayer()) openSheet(); 366 366 } … … 377 377 p.catch((err) => { 378 378 console.warn('[pcms-audio] play() rejected:', err.name, err.message); 379 // Browser -autoplay-policy heeft 't gestopt (typisch na3-4380 // auto matische plays op iOS Safari, of als tab tijdelijk inactive381 // was). Visuele hint dat user op play moet tappen.379 // Browser autoplay policy blocked it (typically after 3-4 380 // auto-plays on iOS Safari, or when the tab was temporarily inactive). 381 // Show a visual hint for the user to tap play. 382 382 if (err && err.name === 'NotAllowedError') { 383 383 root.classList.add('audio-needs-tap'); … … 440 440 441 441 // ============================================================ 442 // 4b. Mutual exclusion — gedeelde media-registry (zie embed-player.js).443 // ============================================================ 444 // All e spelers (deze site-speler + de YouTube/SoundCloud/Spotify-embeds)445 // regist reren zich in window.pcmsMediaRegistry. Start er één, dan pauzeert de446 // vorige. Dit is de precieze vervanger van de oude focus/blur-heuristiek voor447 // de embeds met een echte JS-API. (De blur-fallback hieronder blijft staan448 // voor iframe-only embeds zonderAPI: Bandcamp/Apple Music/Vimeo.)442 // 4b. Mutual exclusion — shared media registry (see embed-player.js). 443 // ============================================================ 444 // All players (this site player + YouTube/SoundCloud/Spotify embeds) 445 // register themselves in window.pcmsMediaRegistry. Starting one pauses 446 // the previous. This is the precise replacement for the old focus/blur 447 // heuristic for embeds with a real JS API. (The blur fallback below stays 448 // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.) 449 449 function mediaRegistry() { 450 450 if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry; … … 467 467 // 5. Audio element events → UI sync 468 468 // ============================================================ 469 // Error -counter voorkomt infinite-loop als ALLE tracks broken zijn.469 // Error counter prevents an infinite loop when ALL tracks are broken. 470 470 let consecutiveErrors = 0; 471 471 … … 473 473 isPlaying = true; 474 474 root.classList.add('is-playing'); 475 root.classList.remove('audio-needs-tap'); // verstop tap-hint 476 mediaRegistry().setActive(registrySelf); // pauzeer eventueel spelende embeds 477 }); 478 // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij 479 // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode- 480 // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij 481 // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er 482 // daadwerkelijk audio speelt. 475 root.classList.remove('audio-needs-tap'); // hide tap hint 476 mediaRegistry().setActive(registrySelf); // pause any currently playing embeds 477 }); 478 // Reset the error counter only on a REAL playback start (`playing`), not the 479 // eager `play` event. `play` fires before any network/decode error, so resetting 480 // there would prevent the 3-strikes stop from ever triggering on a broken 481 // track → infinite "next" loop. `playing` only fires when audio is actually playing. 483 482 audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); }); 484 483 audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); }); … … 488 487 console.error('[pcms-audio] playback error', code, audio.src, e); 489 488 consecutiveErrors++; 490 // Bij netwerk/decode-fout: skip naar volgende track ipv stilstaan.491 // Max 3 fouten op rij voordat we opgeven (andersinfinite loop).489 // On network/decode error: skip to next track instead of stalling. 490 // Max 3 consecutive errors before giving up (otherwise infinite loop). 492 491 if (consecutiveErrors < 3 && queue.length > 1) { 493 console.warn('[pcms-audio] auto-skip naar volgende naerror', consecutiveErrors);492 console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors); 494 493 setTimeout(next, 400); 495 494 } … … 545 544 // 7. Sheet expand/close + drag-down-to-close 546 545 // ============================================================ 547 // Back -knop sluit de sheet op mobiel: bij openen pushen we een history-entry,548 // zodat de telefoon-terugknop (popstate) eerst de sheet sluit i.p.v. de pagina549 // te verlaten. We balanceren 'm bij een UI-sluitingvia history.back().546 // Back button closes the sheet on mobile: on open we push a history entry 547 // so the phone back button (popstate) closes the sheet first instead of 548 // leaving the page. We balance it on a UI-initiated close via history.back(). 550 549 let sheetHistoryPushed = false; 551 550 552 551 function openSheet() { 553 if (!hasFullPlayer()) return; // desktop (≥1200): geen full player, alleen mini-speler552 if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only 554 553 if (sheet.classList.contains('is-open')) return; 555 554 sheet.classList.add('is-open'); 556 555 sheet.setAttribute('aria-hidden', 'false'); 557 556 document.body.classList.add('audio-sheet-locked'); 558 // Telefoon + tablet: history-entry zodat de terug-knop eerst de sheet sluit.557 // Phone + tablet: push a history entry so the back button closes the sheet first. 559 558 try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {} 560 559 } … … 566 565 sheetPanel.style.removeProperty('--pcms-drag-y'); 567 566 sheetBackdrop.style.removeProperty('--pcms-sheet-progress'); 568 // UI -sluiting (X / swipe / backdrop / Esc): pop onze eigen history-entry zodat569 // de volgende terug-knop weer normaal navigeert. Bij een popstate-sluiting570 // (de terug-knop zelf) is de entry al gepopt.567 // UI close (X / swipe / backdrop / Esc): pop our own history entry so the 568 // next back button navigates normally. On a popstate close (back button itself) 569 // the entry is already popped. 571 570 const wasPushed = sheetHistoryPushed; 572 571 sheetHistoryPushed = false; … … 576 575 if (sheet.classList.contains('is-open')) closeSheet(true); 577 576 }); 578 // Mini-player-info aanklikken:579 // - DESKTOP (≥ 768px): spring naar de post waar de track vandaan komt (als bekend),580 // via htmx zodat de audio blijft spelen. Geen post bekend → val terug op de sheet.581 // - MOBI EL: altijd de uitgebreide speler-sheet openen (huidig gedrag).577 // Clicking the mini-player track info: 578 // - DESKTOP (≥1200px): jump to the post the track came from (if known), 579 // via htmx so audio keeps playing. No post known → fall back to the sheet. 580 // - MOBILE/TABLET: always open the full now-playing sheet. 582 581 function scrollToTrack(trackId) { 583 582 if (!trackId) { window.scrollTo(0, 0); return; } … … 594 593 const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' }); 595 594 history.pushState({}, '', url + hash); 596 // Na de swap naar de track scrollen (klein uitstel zodat de globale597 // afterSwap -top-scroll eerst is geweest); val terug op top als niet gevonden.595 // Scroll to the track after the swap (small delay so the global 596 // afterSwap scroll-to-top runs first); fall back to top if not found. 598 597 const go = () => setTimeout(() => scrollToTrack(trackId), 60); 599 598 if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150); 600 599 return; 601 } catch (e) { /* val terug op volledige navigatie*/ }600 } catch (e) { /* fall back to full navigation */ } 602 601 } 603 602 location.href = url + hash; … … 605 604 expandTrigger.addEventListener('click', () => { 606 605 const t = queue[currentIndex]; 607 // Alleen op échte desktop (≥1200, geen full-player) springen we naar de post.608 // Tablet + telefoon hebben een full-player → open die (zoals mobiel).606 // Only on true desktop (≥1200, no full-player) do we jump to the post. 607 // Tablet + phone have a full-player → open it (same as mobile behaviour). 609 608 const jumpToPost = !hasFullPlayer(); 610 609 if (jumpToPost && t) { 611 // 1) Track speelde vanuit een post → die URL kennen we al.610 // 1) Track played from a post → we already know that URL. 612 611 if (t.postUrl) { goToPost(t.postUrl, t.id); return; } 613 // 2) Site- brede track (geen postUrl) → zoek de post op via de track-id.612 // 2) Site-wide track (no postUrl) → look up the post via the track id. 614 613 if (t.id) { 615 614 fetch('/audio/track/' + encodeURIComponent(t.id) + '/post') … … 627 626 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet(); 628 627 }); 629 // Naar desktop-breedte (≥1200) gesleept terwijl de full player open is? Sluiten—630 // op desktop bestaat de full player niet.628 // Resized to desktop width (≥1200) while the full player is open? Close it — 629 // the full player doesn't exist on desktop. 631 630 window.addEventListener('resize', () => { 632 631 if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet(); 633 632 }); 634 633 635 // Vangnet voor mutual exclusion. Voor YouTube/SoundCloud/Spotify-embeds doet de636 // registry dit al precies (echte play-events). Maar voor iframe-only embeds637 // ZONDER JS-API (Bandcamp/Apple/Vimeo) én voor de iframe-FALLBACK (als een638 // ad-blocker de player-API blokkeert) is er geen play-event: daar vangen we het639 // af via focus. Klikt de gebruiker zo'n iframe aan → window 'blur' → pauzeer640 // onze speler. (Voor de API-embeds is dit hooguit een onschadelijke dubbele641 // pauze.)634 // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the 635 // registry already handles this precisely (real play events). But for 636 // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the 637 // iframe FALLBACK (when an ad-blocker blocks the player API) there is no 638 // play event: we catch those via focus. User clicks such an iframe → 639 // window 'blur' → pause our player. (For API embeds this is at worst a 640 // harmless double-pause.) 642 641 window.addEventListener('blur', () => { 643 642 setTimeout(() => { 644 643 const el = document.activeElement; 645 // Alleen embed-iframes (binnen .folio-embed) pauzeren de speler — niet een646 // willekeurig iframe (captcha/reclame/kaart) dat per ongeluk focus krijgt.644 // Only embed iframes (inside .folio-embed) pause the player — not a 645 // random iframe (captcha/ad/map) that happens to receive focus. 647 646 if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) { 648 647 pause(); … … 710 709 // wrapper. For the other three the data is on the button itself. The 711 710 // handler reads from button-first, falls back to wrapper. 712 // Event -delegation op document.body i.p.v. per-knop listeners. Dit overleeft713 // HTMX history-restores: de mobiele terug-knop (popstate) laat HTMX #pcms-main714 // terugzetten uit z'n snapshot; een per-element `data-pcms-attached`-vlag zou715 // dan dode knoppen geven (vlag ingebakken in de snapshot, listener weg). Eén716 // gedelegeerde listener werkt ongeacht hoe vaak de DOM ge(her)swapt wordt.711 // Event delegation on document.body instead of per-button listeners. This 712 // survives HTMX history-restores: the mobile back button (popstate) lets HTMX 713 // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag 714 // would leave dead buttons (flag baked into the snapshot, listener gone). One 715 // delegated listener works regardless of how many times the DOM is (re)swapped. 717 716 const PLAY_SELECTOR = 718 717 '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall'; … … 722 721 e.preventDefault(); 723 722 e.stopPropagation(); 724 // De post waar je VANAF afspeelt = de pagina waar je nu bent (de embeds staan725 // in de post-content). Bewaar 'm op de track(s) zodat de mini-player er op726 // desktop naartoe kan springen — overleeft ook de sessionStorage-resume.723 // The post you're playing FROM = the current page (embeds live in post content). 724 // Store it on the track(s) so the desktop mini-player can jump back to it — 725 // survives the sessionStorage resume as well. 727 726 const postUrl = location.pathname + location.search; 728 727 // Resolve metadata: button-first, then closest .post-audio-track wrapper … … 804 803 805 804 // ============================================================ 806 // 10. Sessi e-persistentie — speler "blijft" over page-navigaties heen807 // ============================================================ 808 // Een <audio> overleeft geen volledige page-load (en cross-context navigatie809 // — bv. naar de headerloze hub-overview — is bewust full-nav). We bewaren de810 // sessie in sessionStorage en herstellen + hervatten 'm op de volgende pagina:811 // de speler staat er weer met dezelfde track op dezelfde positie. In Chrome812 // (hoge media-engagement) speelt 'ie meteen door; staat de browser autoplay813 // niet toe, dan staat 'ie klaar op die plek (één tik = verder).805 // 10. Session persistence — player "survives" across page navigations 806 // ============================================================ 807 // An <audio> element doesn't survive a full page load (and cross-context 808 // navigation — e.g. to the headerless hub overview — is intentionally a 809 // full-nav). We save the session to sessionStorage and restore + resume it 810 // on the next page: the player comes back with the same track at the same 811 // position. In Chrome (high media engagement) it resumes immediately; 812 // if the browser blocks autoplay it waits at that position (one tap = play). 814 813 const PLAYER_STATE_KEY = 'pcms-player-state'; 815 814 let pendingSeek = 0; … … 830 829 audio.addEventListener('ended', savePlayerState); 831 830 setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000); 832 // Herstelde positie toepassen zodra de track-metadata binnen is.831 // Apply the restored position once track metadata is available. 833 832 audio.addEventListener('loadedmetadata', () => { 834 833 if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) { … … 845 844 pendingSeek = s.time || 0; 846 845 const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1)); 847 // playing -> fetch + (poging tot) hervatten; gepauzeerd -> alleen metadata.846 // playing → fetch + (attempt to) resume; paused → meta-only. 848 847 loadTrack(idx, !!s.playing, !s.playing); 849 848 return true; … … 853 852 // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS) 854 853 // ============================================================ 855 // Een actieve sessie (restore) wint van de pagina-seed, zodat lopende muziek856 // doorgaat i.p.v. vervangen te worden door de tracks van de nieuwe pagina.854 // An active session (restore) wins over the page seed, so music that is 855 // already playing continues instead of being replaced by the new page's tracks. 857 856 if (!restorePlayerState()) { 858 857 if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) { … … 864 863 cover: t.cover_url || t.cover || '', 865 864 })); 866 // Queue alleen klaarzetten — de speler-balk verschijnt PAS bij de eerste867 // audio-klik (een .post-audio-track of de mini-speler-play roept setQueue/868 // loadTrack → die toont de balk). Geen pre-seed-balk meer op page-load.865 // Only prime the queue — the player bar appears only on the first audio click 866 // (.post-audio-track or the mini-player play button calls setQueue/loadTrack, 867 // which shows the bar). No more pre-seed bar on page load. 869 868 currentIndex = 0; 870 869 } -
src/assets/js/embed-player.js
rbb42dfb r834bcc3 1 1 /** 2 * Klonkt Embed Player — eigen, in-huisstijl media-embeds bovenop de ÉCHTE3 * player -API's (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).2 * Klonkt Embed Player — custom, on-brand media embeds on top of the REAL 3 * player APIs (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API). 4 4 * 5 * De server (AudioEmbedService) rendert per embed een placeholder:5 * The server (AudioEmbedService) renders a placeholder per embed: 6 6 * <div class="folio-embed folio-embed--<provider> pcms-embed-card" 7 7 * data-embed-provider data-embed-ref data-embed-type data-embed-url></div> 8 * Dit script bouwt daar onze eigen kaart omheen (cover/poster + onze play-knop+9 * voortgangsbalk in huisstijl) en bestuurt de onderliggende speler via de10 * platform -API, zodat play/pause/voortgang in ÓNZE handen liggen.8 * This script wraps it in our own card (cover/poster + our play button + 9 * progress bar in brand style) and controls the underlying player via the 10 * platform API, so play/pause/progress are in OUR hands. 11 11 * 12 * Capability -eerlijkheid:13 * - youtube/soundcloud → volledig eigen controls (native chrome verborgen).14 * - spotify → besturing + onze frame eromheen; Spotify's eigen UI15 * blijft binnenin (kan niet anders zonderPremium+OAuth).12 * Capability honesty: 13 * - youtube/soundcloud → fully custom controls (native chrome hidden). 14 * - spotify → controls + our frame around it; Spotify's own UI 15 * stays inside (no alternative without Premium+OAuth). 16 16 * 17 * Mutual exclusion: e lke speler (incl. de site-audiospeler in audio-player.js)18 * regist reert zich in window.pcmsMediaRegistry. Start er één → de vorige pauzeert.19 * Dit vervangt de oude focus/blur-heuristiek door echte play-events.17 * Mutual exclusion: every player (incl. the site audio player in audio-player.js) 18 * registers itself in window.pcmsMediaRegistry. Starting one pauses the previous. 19 * This replaces the old focus/blur heuristic with real play events. 20 20 * 21 * Singleton — guard tegen dubbel-init (HTMX laadt scripts soms opnieuw).21 * Singleton — guard against double-init (HTMX can reload scripts). 22 22 */ 23 23 (function () { … … 25 25 26 26 // ============================================================ 27 // 0. Gedeelde playback-registry (ook door audio-player.js gebruikt)27 // 0. Shared playback registry (also used by audio-player.js) 28 28 // ============================================================ 29 29 function registry() { … … 31 31 const r = { 32 32 _active: null, 33 // Mark eer `player` als de enige actieve; pauzeer de vorige.33 // Mark `player` as the sole active one; pause the previous. 34 34 setActive(player) { 35 35 if (this._active && this._active !== player && this._active.pause) { … … 45 45 46 46 // ============================================================ 47 // 1. Lazy script -loaders — 1 promise per platform, gedeeld overN embeds.48 // We laden een platform-script PAS als er een embed van dat platform op de49 // pagina daadwerkelijk wordt gestart.47 // 1. Lazy script loaders — 1 promise per platform, shared across N embeds. 48 // A platform script is only loaded when an embed from that platform 49 // is actually started on the page. 50 50 // ============================================================ 51 51 const scripts = {}; … … 55 55 s.src = src; s.async = true; 56 56 s.onload = () => resolve(); 57 s.onerror = () => reject(new Error('embed -script faalde: ' + src));57 s.onerror = () => reject(new Error('embed script failed: ' + src)); 58 58 document.head.appendChild(s); 59 59 }); 60 60 } 61 // Belangrijk: alle loaders REJECTEN bij een script-fout (bv. een ad-blocker die62 // het API-script blokkeert) én na een time-out — zodat de aanroeper netjes kan63 // t erugvallen op het kale platform-iframe i.p.v. eeuwig te hangen.61 // Important: all loaders REJECT on script errors (e.g. an ad-blocker blocking 62 // the API script) and on timeout — so the caller can gracefully fall back to 63 // the plain platform iframe instead of hanging indefinitely. 64 64 const API_TIMEOUT = 8000; 65 65 66 // YouTube: global e callback onYouTubeIframeAPIReady (1×) → in promise wikkelen.66 // YouTube: global callback onYouTubeIframeAPIReady (once) → wrap in a promise. 67 67 function ytApi() { 68 68 if (scripts.yt) return scripts.yt; … … 79 79 return scripts.yt; 80 80 } 81 // SoundCloud: geen globale ready-callback; resolve op script-onload, daarna82 // wacht elke widget op z'n eigen SC.Widget.Events.READY.81 // SoundCloud: no global ready callback; resolve on script onload, then 82 // each widget waits for its own SC.Widget.Events.READY. 83 83 function scApi() { 84 84 if (scripts.sc) return scripts.sc; … … 91 91 return scripts.sc; 92 92 } 93 // Spotify: global e callback onSpotifyIframeApiReady(IFrameAPI) (1×) → wrappen.93 // Spotify: global callback onSpotifyIframeApiReady(IFrameAPI) (once) → wrap in a promise. 94 94 function spotifyApi() { 95 95 if (scripts.sp) return scripts.sp; … … 101 101 }; 102 102 loadScript('https://open.spotify.com/embed/iframe-api/v1').catch(reject); 103 // Korter dan API_TIMEOUT: Spotify's bundle initialiseert snel óf helemaal104 // niet (CDN 503 / origin-gating). Niet 8s wachten vóór de iframe-fallback.103 // Shorter than API_TIMEOUT: Spotify's bundle initialises quickly or not at all 104 // (CDN 503 / origin-gating). No need to wait 8 s before the iframe fallback. 105 105 setTimeout(() => reject(new Error('Spotify API timeout')), 4000); 106 106 }); … … 108 108 } 109 109 110 // Kaal platform-iframe als fallback wanneer de JS-API geblokkeerd/onbereikbaar111 // is. Ad-blockers laten de embed-iframes doorgaans wél door. Autoplay-param112 // omdat we hier altijd in een user-gesture-context zitten (klik op play).110 // Plain platform iframe as fallback when the JS API is blocked/unreachable. 111 // Ad-blockers generally let embed iframes through. Autoplay parameter 112 // is safe here because we're always in a user-gesture context (play click). 113 113 function fallbackIframe(provider, ref, url) { 114 114 if (provider === 'youtube') { … … 138 138 const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/); 139 139 if (m) return m[1]; 140 return null; // geen blinde slice — een ongeldige ref geeft liever niets dan een kapotteid141 } 142 // Alleen http(s) als href toelaten (defense-in-depth tegen javascript:/data:).140 return null; // no blind slice — an invalid ref returns nothing rather than a broken id 141 } 142 // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs). 143 143 function safeHref(u) { 144 144 try { const p = new URL(u, location.href); return (p.protocol === 'http:' || p.protocol === 'https:') ? u : '#'; } … … 154 154 155 155 // ============================================================ 156 // 3. Card controller — b ouwt de huisstijl-kaart + delegeert naar een adapter156 // 3. Card controller — builds the on-brand card + delegates to an adapter 157 157 // ============================================================ 158 158 function buildCard(el) { … … 165 165 el.classList.remove('pcms-embed-loading'); 166 166 167 // O nze speler-zijde (registry-peer). pause() wijst naar de adapter zodra die168 // gemount is; ervoor is 't eenno-op.167 // Our player side (registry peer). pause() points to the adapter once 168 // mounted; before that it's a no-op. 169 169 let adapter = null; 170 170 const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } }; 171 171 172 // Teardown -hook: aangeroepen door de MutationObserver als deze kaart uit de173 // DOM verdwijnt (HTMX-swap) → adapter opruimen (timers/iframes) + registry174 // vrijgeven, zodat er geen poll-timers of spelers blijven lekken.172 // Teardown hook: called by the MutationObserver when this card leaves the 173 // DOM (HTMX swap) → clean up the adapter (timers/iframes) + release the registry, 174 // so no poll timers or players keep leaking. 175 175 el._pcmsDestroy = function () { 176 176 try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {} … … 182 182 let dur = 0; 183 183 184 // --- UI ophangen (verschilt per provider-type) ---184 // --- Mount the UI (differs per provider type) --- 185 185 const isVideo = provider === 'youtube'; 186 186 const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls … … 220 220 const volEl = el.querySelector('.pcms-embed-vol'); 221 221 const muteBtn = el.querySelector('.pcms-embed-mute'); 222 let vol = 100; // huidige volume 0-100 (wordt op de adapter toegepast)222 let vol = 100; // current volume 0-100 (applied to the adapter) 223 223 let preMuteVol = 100; 224 224 … … 251 251 }; 252 252 253 // API geblokkeerd/onbereikbaar → kaal platform-iframe (graceful degradation).254 // De mutual-exclusion loopt voor deze fallback via de blur-heuristiek255 // (audio-player.js), want de kaart krijgt geen.is-mounted.253 // API blocked/unreachable → plain platform iframe (graceful degradation). 254 // Mutual exclusion for this fallback runs via the blur heuristic 255 // (audio-player.js), because the card never gets .is-mounted. 256 256 function renderFallback() { 257 257 const fb = fallbackIframe(provider, ref, url); … … 271 271 el.appendChild(iframe); 272 272 273 // Mutual exclusion óók voor de fallback-iframe. Eencross-origin iframe274 // kunnen we niet via een API pauzeren, dus 'pauze' = herladen ZONDER275 // autoplay (= stop t het geluid, speler blijft zichtbaar/herstartbaar).276 // We regist reren 'm als actief: nu (= gebruiker start de embed) pauzeert de277 // site -speler/andere embeds; en als de site-speler later start, pauzeert de278 // registry dezefallback.273 // Mutual exclusion also for the fallback iframe. A cross-origin iframe 274 // cannot be paused via an API, so 'pause' = reload WITHOUT 275 // autoplay (= stops the audio, player stays visible/restartable). 276 // We register it as active: now (= user starts the embed) the 277 // site player/other embeds pause; and when the site player starts later, 278 // the registry pauses this fallback. 279 279 self.pause = function () { 280 280 try { … … 294 294 } 295 295 296 // Eerste interactie → adapter mounten + spelen. Daarna toggelt de knop.296 // First interaction → mount the adapter + play. The button toggles after that. 297 297 async function ensureMountedAndPlay() { 298 298 if (mounted) { if (adapter) adapter.play(); return; } 299 299 mounted = true; 300 // Spotify: hun speler is toch niet te skinnen (besturing-only) én de301 // iFrame -API-bundle initialiseert in de praktijk vaak niet(CDN-gating/503)302 // → niet op de API wachten, meteen het kale Spotify-iframe tonen. Instant.300 // Spotify: their player cannot be skinned (controls-only) and the 301 // iFrame API bundle often fails to initialise in practice (CDN-gating/503) 302 // → don't wait for the API, show the plain Spotify iframe immediately. 303 303 if (provider === 'spotify') { renderFallback(); return; } 304 304 el.classList.add('pcms-embed-busy'); … … 306 306 adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks); 307 307 if (el._pcmsApplyVol) el._pcmsApplyVol(); // onthouden volume toepassen 308 // is-mounted: CSS verbergt de poster (video) of de Spotify-facade en309 // toont de echte speler. Voor SoundCloud blijft onze kaart+balk staan en310 // blijft het (functionele) iframe off-screen verborgen.308 // is-mounted: CSS hides the poster (video) or Spotify facade and 309 // shows the real player. For SoundCloud our card+bar stays visible and 310 // the (functional) iframe remains hidden off-screen. 311 311 el.classList.add('is-mounted'); 312 312 adapter.play(); 313 313 } catch (err) { 314 console.warn('[pcms-embed] API niet beschikbaar, val terug op kaaliframe', provider, err);314 console.warn('[pcms-embed] API unavailable, falling back to plain iframe', provider, err); 315 315 renderFallback(); 316 316 } … … 328 328 adapter.seek(ratio * dur); 329 329 }); 330 // Volume: s chuif zet vol (0-100) en past 'm toe op de adapter (YT/SC hebben331 // setVolume). De waarde wordt onthouden en na het mounten opnieuw toegepast.330 // Volume: slider sets vol (0-100) and applies it to the adapter (YT/SC support 331 // setVolume). The value is remembered and reapplied after mounting. 332 332 function applyVol() { 333 333 if (adapter && adapter.setVolume) { try { adapter.setVolume(vol); } catch (e) {} } … … 341 341 applyVol(); 342 342 }); 343 el._pcmsApplyVol = applyVol; // door ensureMountedAndPlay aangeroepen namount343 el._pcmsApplyVol = applyVol; // called by ensureMountedAndPlay after mount 344 344 } 345 345 … … 349 349 350 350 // ============================================================ 351 // 4. Adapters — normalise ren de 3 zeer verschillende API's naar 1 vorm.352 // E lke mounter geeft { play, pause, seek } terug en roept hooks aan met353 // second en (units worden hier rechtgetrokken).351 // 4. Adapters — normalise the 3 very different APIs to one common shape. 352 // Each mounter returns { play, pause, seek } and calls hooks with 353 // seconds (units are normalised here). 354 354 // ============================================================ 355 355 const MOUNTERS = { … … 403 403 }, 404 404 405 // ---- SoundCloud: Widget API, visual=false, eigencontrols ----405 // ---- SoundCloud: Widget API, visual=false, custom controls ---- 406 406 async soundcloud(mountEl, { ref, url }, hooks) { 407 407 const SC = await scApi(); 408 // Iframe zelf bouwen (kale balk) en daarna SC.Widget eraan hangen.408 // Build the iframe ourselves (bare bar) and then attach SC.Widget to it. 409 409 const iframe = document.createElement('iframe'); 410 410 iframe.allow = 'autoplay'; … … 453 453 }, 454 454 455 // ---- Spotify: iFrame API — besturing + onze frame; geen eigenskin ----455 // ---- Spotify: iFrame API — controls + our frame; no custom skin ---- 456 456 async spotify(mountEl, { ref, url }, hooks) { 457 457 const IFrameAPI = await spotifyApi(); … … 473 473 const d = e && e.data ? e.data : {}; 474 474 hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000); 475 // Geen betrouwbare 'ended'-event bij Spotify; we behandelen elke476 // isPaused -overgang als play/pause. Einde = gewoon een pauze (de balk477 // blijft op de eindpositie i.p.v. misleidend naar 0 te springen).475 // No reliable 'ended' event from Spotify; we treat every 476 // isPaused transition as play/pause. End = just a pause (the bar 477 // stays at the end position rather than misleadingly jumping to 0). 478 478 if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); } 479 479 else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); } … … 486 486 487 487 // ============================================================ 488 // 5. Scan + HTMX -/DOM-mutatie-aware (de site navigeert deels via HTMX-swaps)488 // 5. Scan + HTMX/DOM-mutation-aware (the site partially navigates via HTMX swaps) 489 489 // ============================================================ 490 490 function scan(root) { … … 492 492 if (el.dataset.embedInit) return; 493 493 el.dataset.embedInit = '1'; 494 try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard fa alde', e); }494 try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard failed', e); } 495 495 }); 496 496 } … … 501 501 scan(document); 502 502 } 503 // HTMX vervangt #pcms-main bij interne navigatie → opnieuw scannen.503 // HTMX replaces #pcms-main on internal navigation → rescan. 504 504 document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document)); 505 505 document.body.addEventListener('htmx:load', (e) => scan(e.target || document)); 506 506 507 // Teardown bij verwijdering uit de DOM (HTMX vervangt #pcms-main innerHTML, of 508 // een SPA-achtige swap). Zonder dit blijven YouTube-poll-timers + adapters/ 509 // iframes hangen als je wegnavigeert terwijl een embed speelt → CPU/geheugenlek 510 // dat per navigatie opstapelt. We roepen el._pcmsDestroy() aan voor elke kaart 511 // die echt uit het document verdwijnt. 507 // Teardown on DOM removal (HTMX replaces #pcms-main innerHTML, or an SPA-like 508 // swap). Without this, YouTube poll timers + adapters/iframes linger when 509 // navigating away while an embed is playing → CPU/memory leak that accumulates 510 // per navigation. We call el._pcmsDestroy() for every card that truly leaves the document. 512 511 const teardownObserver = new MutationObserver((muts) => { 513 512 for (const m of muts) { -
src/assets/js/smooth-scroll.js
rbb42dfb r834bcc3 2 2 (function () { 3 3 if (typeof Lenis === "undefined") return; 4 // Skip o p touch/mobile — native momentum is beter danJS lib4 // Skip on touch/mobile — native momentum scroll is better than a JS lib 5 5 var isMobile = window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 1024; 6 6 if (isMobile) return; -
src/config/database.js
rbb42dfb r834bcc3 39 39 ensureColumn('users', 'reset_token', 'TEXT'); 40 40 ensureColumn('users', 'reset_token_expires', 'DATETIME'); 41 // Google OAuth: koppel een Google-account aan eenuser (login via Google).41 // Google OAuth: link a Google account to a user (login via Google). 42 42 ensureColumn('users', 'google_sub', 'TEXT'); 43 // Read-only/ kijk-account: kan alles bekijken maar geen wijzigingen doen.43 // Read-only/viewer account: can view everything but make no changes. 44 44 ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0'); 45 // Perso onlijke interface-taal (nl|en|de). Null = volg de standaard(site/env/browser).45 // Personal interface language (nl|en|de). Null = follow the default (site/env/browser). 46 46 ensureColumn('users', 'lang', 'TEXT'); 47 47 // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed. 48 48 ensureColumn('sites', 'comments_moderation_mode', "TEXT DEFAULT 'moderate'"); 49 // Cir kels: mag deze site in cirkels van anderen verschijnen(surfacing opt-out).49 // Circles: whether this site may appear in other sites' circles (surfacing opt-out). 50 50 ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1'); 51 51 52 // Eén EXPLICIETE primaire/hoofd-site (= de bedrijfs-/labelsite in hub-modus,53 // de enige site in solo) i.p.v. de fragiele "oudste = hoofd"-conventie die op54 // 4 plekken gedupliceerd stond. Backfill: markeer de oudste als er nog geen55 // primaire site is, zodat bestaand gedrag exact behouden blijft.52 // One EXPLICIT primary/main site (= the company/label site in hub mode, 53 // the only site in solo) instead of the fragile "oldest = main" convention 54 // that was duplicated in 4 places. Backfill: mark the oldest if no primary 55 // site exists yet, so existing behaviour is preserved exactly. 56 56 ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0'); 57 57 try { … … 61 61 if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id); 62 62 } 63 } catch (e) { /* sites -tabel nog leeg/afwezig bij verse init — ensurePrimarySite regelt 't */ }63 } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ } 64 64 65 65 // v9 audit additions ————————————————————————————————————————— … … 82 82 // Per-post noindex + type 83 83 ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0'); 84 ensureColumn('posts', 'publish_at', 'DATETIME'); // release -planning (premium #3): geplandego-live84 ensureColumn('posts', 'publish_at', 'DATETIME'); // release planning (premium #3): scheduled go-live 85 85 ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3) 86 86 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio 87 87 88 // Statisti eken (premium-module) — kale tellers, cookievrij.89 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // weergavenper post88 // Statistics (premium module) — bare counters, cookie-free. 89 ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // views per post 90 90 ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track 91 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download- voor-email (premium #2)92 ensureColumn('audio_tracks', 'credit', 'TEXT'); // eigenaar/credit (copyright-houder)93 ensureColumn('audio_tracks', 'license', 'TEXT'); // licen tie (bv. "CC BY 4.0", "Alle rechten voorbehouden")94 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" -links per track91 ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2) 92 ensureColumn('audio_tracks', 'credit', 'TEXT'); // owner/credit (copyright holder) 93 ensureColumn('audio_tracks', 'license', 'TEXT'); // license (e.g. "CC BY 4.0", "All rights reserved") 94 ensureColumn('audio_tracks', 'link_spotify', 'TEXT'); // "open in" links per track 95 95 ensureColumn('audio_tracks', 'link_youtube', 'TEXT'); 96 96 ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT'); … … 123 123 `); 124 124 125 // Global e app-instellingen (key/value singleton). O.a. de tenancy-modus126 // (solo = één site, hub = bedrijfssite + /user/). Default = solo.125 // Global app settings (key/value singleton). Includes the tenancy mode 126 // (solo = one site, hub = company site + /user/). Default = solo. 127 127 db.exec(` 128 128 CREATE TABLE IF NOT EXISTS app_settings ( … … 134 134 db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run(); 135 135 136 // ── Statisti eken (premium) — cookievrij─────────────────────137 // stat_daily: p er dag per site het aantal pageviews (kale teller).138 // stat_visitor_day: per dag per site een rij per UNIEKE bezoeker-hash139 // (sha256 van IP+UA+dag-salt; de salt roteert dagelijks en wordt nooit140 // bewaard → geen persistente identifier, geen cookie, geen toestemming nodig).136 // ── Statistics (premium) — cookie-free ────────────────────── 137 // stat_daily: pageview count per day per site (bare counter). 138 // stat_visitor_day: one row per UNIQUE visitor hash per day per site 139 // (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored 140 // → no persistent identifier, no cookie, no consent required). 141 141 db.exec(` 142 142 CREATE TABLE IF NOT EXISTS stat_daily ( … … 161 161 `); 162 162 163 // ── Cir kels (federatie) ─────────────────────────────────────164 // Decentral e, asymmetrische verbindingen tussen solo-instances.163 // ── Circles (federation) ──────────────────────────────────── 164 // Decentralised, asymmetric connections between solo instances. 165 165 db.exec(` 166 166 CREATE TABLE IF NOT EXISTS circle_links ( … … 200 200 `); 201 201 202 // Tags van de originele post — getoond in de cirkel(comma-separated string).202 // Tags from the original post — shown in the circle feed (comma-separated string). 203 203 ensureColumn('remote_posts', 'tags', 'TEXT'); 204 204 205 // N ieuwsbrief / mailinglijst (premium). Abonnees per site; double opt-in alsSMTP206 // er is (status 'pending' tot bevestigd), anderssingle opt-in ('confirmed').207 // 'unsub' = u itgeschreven. token = confirm/unsubscribe-sleutel (in de e-maillinks).205 // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP 206 // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed'). 207 // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links). 208 208 db.exec(` 209 209 CREATE TABLE IF NOT EXISTS subscribers ( … … 221 221 `); 222 222 223 // Verstuurde nieuwsbrieven (historie + aantallen).223 // Sent newsletters (history + counts). 224 224 db.exec(` 225 225 CREATE TABLE IF NOT EXISTS newsletters ( … … 233 233 `); 234 234 235 // Show -agenda (premium #8): tourdata/optredens per site.235 // Show agenda (premium #8): tour dates / gigs per site. 236 236 db.exec(` 237 237 CREATE TABLE IF NOT EXISTS shows ( … … 250 250 `); 251 251 252 // Meldingen: iemand reageert op je reactie / post, of liket jepost. Snapshots253 // van naam/titel zodat de lijst goedkoop te tonen is zonderjoins.254 // NB: bewust 'user_notifications' — sommige oudere DBs hebben nog eenstale,255 // ongebruikte 'notifications'-tabel met een ander schema (geen read-kolom).252 // Notifications: someone replies to your comment / post, or likes your post. Snapshots 253 // of name/title so the list can be shown cheaply without joins. 254 // NB: deliberately named 'user_notifications' — some older DBs still have a stale, 255 // unused 'notifications' table with a different schema (no read column). 256 256 db.exec(` 257 257 CREATE TABLE IF NOT EXISTS user_notifications ( … … 270 270 `); 271 271 272 // Link-in-bio klikstatistiek (premium #6). Per (site, url) een teller; de273 // link-in-bio -pagina linkt via /links/go/:i dat de klik telt en doorstuurt.272 // Link-in-bio click statistics (premium #6). One counter per (site, url); the 273 // link-in-bio page links via /links/go/:i which counts the click and redirects. 274 274 db.exec(` 275 275 CREATE TABLE IF NOT EXISTS link_clicks ( … … 282 282 `); 283 283 284 // Likes / favo rieten: een ingelogde gebruiker kan een post liken. De set van285 // posts die een gebruiker likte = z'n favorieten (/favorieten-pagina). Eén rij286 // per (post, user); uni ek zodat liken idempotent is.284 // Likes / favourites: a logged-in user can like a post. The set of 285 // posts a user liked = their favourites (/favorieten page). One row 286 // per (post, user); unique so that liking is idempotent. 287 287 db.exec(` 288 288 CREATE TABLE IF NOT EXISTS post_likes ( -
src/config/features.js
rbb42dfb r834bcc3 1 // Feature -flags (boot-tijd, via env).1 // Feature flags (boot-time, via env). 2 2 // 3 // Lite -modus: zet KLONKT_AUDIO=off in .env om de HELE audio-feature uit te4 // schakelen — geen audio-/playlist-/download-/embed-routes, geen ffmpeg-aanroep,5 // geen speler en geen [[track]]/[[playlist]]-shortcodes. Zo draait Klonkt als6 // lichte blog/foto/EPK-site op een omgeving zónder ffmpeg/exec. Hub én Cirkels7 // blijven gewoon werken (die hangen niet van audio af).3 // Lite mode: set KLONKT_AUDIO=off in .env to disable the ENTIRE audio feature — 4 // no audio/playlist/download/embed routes, no ffmpeg calls, no player, and no 5 // [[track]]/[[playlist]] shortcodes. This lets Klonkt run as a lightweight 6 // blog/photo/EPK site on environments without ffmpeg/exec. Hub and Circles 7 // keep working (they have no audio dependency). 8 8 // 9 // Default = aan (volledige versie). Alleen de letterlijke waarde 'off' schakelt uit.9 // Default = on (full version). Only the literal value 'off' disables it. 10 10 export function audioEnabled() { 11 11 return String(process.env.KLONKT_AUDIO ?? 'on').toLowerCase() !== 'off'; -
src/config/google.js
rbb42dfb r834bcc3 1 // Google OAuth2 voor LUISTERAARS (reageren). Per-instance: de self-hoster zet2 // z'n EIGEN Google-client. Zo hangt elke site aan z'n eigen Google Cloud project3 // — geen centrale afhankelijkheid, geen gedeelde aansprakelijkheid.1 // Google OAuth2 for LISTENERS (commenting). Per-instance: each self-hoster sets 2 // their OWN Google client. This way every site is tied to its own Google Cloud 3 // project — no central dependency, no shared liability. 4 4 // 5 // Config -bron (in deze volgorde): app_settings (ingesteld via Beheer → Instellingen),6 // anders de env-vars. Niet ingesteld → geen "Login met Google"-knop; de rest van7 // de site werkt door. Google-login geeft NOOIT beheerrechten.5 // Config source (in this order): app_settings (set via Admin → Settings), 6 // otherwise env vars. Not configured → no "Login with Google" button; the rest 7 // of the site keeps working. Google login NEVER grants admin rights. 8 8 // 9 // De redirect-URI wordt afgeleid vanPUBLIC_BASE_URL (<base>/auth/google/callback),10 // o f expliciet via GOOGLE_REDIRECT_URI. Die exacte URL moet in Google Cloud staan.9 // The redirect URI is derived from PUBLIC_BASE_URL (<base>/auth/google/callback), 10 // or explicitly via GOOGLE_REDIRECT_URI. That exact URL must be listed in Google Cloud. 11 11 12 12 import { getSetting } from '../services/SettingsService.js'; … … 16 16 const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo'; 17 17 18 // Dynamisch lezen (UI-wijziging werkt zonder herstart). app_settings wint, env = fallback.18 // Read dynamically (UI changes take effect without a restart). app_settings wins, env = fallback. 19 19 function clientId() { 20 20 return getSetting('google_client_id', '') || process.env.GOOGLE_CLIENT_ID || ''; … … 29 29 } 30 30 31 export function currentClientId() { return clientId(); } // n iet-geheim, voor het formulier31 export function currentClientId() { return clientId(); } // not secret, used for the settings form 32 32 export function clientSecretSet() { return !!clientSecret(); } 33 33 export function googleConfigured() { … … 61 61 body, 62 62 }); 63 if (!r.ok) throw new Error(`Google token -exchange faalde: ${r.status}`);63 if (!r.ok) throw new Error(`Google token exchange failed: ${r.status}`); 64 64 return r.json(); // { access_token, id_token, ... } 65 65 } … … 68 68 export async function fetchUserinfo(accessToken) { 69 69 const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } }); 70 if (!r.ok) throw new Error(`Google userinfo fa alde: ${r.status}`);70 if (!r.ok) throw new Error(`Google userinfo failed: ${r.status}`); 71 71 return r.json(); 72 72 } -
src/config/mailer.js
rbb42dfb r834bcc3 1 // E-mail versturen (wachtwoord-reset, nieuwsbrief, notify). Optioneel: alleen actief2 // als SMTP is ingesteld — via Beheer → Instellingen (app_settings) OF env-vars.1 // Send email (password reset, newsletter, notify). Optional: only active 2 // when SMTP is configured — via Admin → Settings (app_settings) OR env vars. 3 3 // 4 // Config -bron (in deze volgorde): app_settings (ingesteld in de UI), andersenv:4 // Config source (in this order): app_settings (set via the UI), otherwise env: 5 5 // SMTP_HOST, SMTP_PORT (default 587), SMTP_USER, SMTP_PASS, SMTP_FROM (default = USER) 6 // N iet ingesteld → versturen valt terug op CLI (reset-admin) / wordt overgeslagen.6 // Not configured → sending falls back to CLI (reset-admin) / is skipped. 7 7 8 8 import nodemailer from 'nodemailer'; … … 23 23 } 24 24 25 // Status voor de UI (zonder het wachtwoord te lekken).25 // Status for the UI (without leaking the password). 26 26 export function mailerStatus() { 27 27 const c = cfg(); … … 33 33 from: c.from, 34 34 passSet: !!c.pass, 35 // bron: handig om te tonen dat env nog actief is35 // source: useful to show that env vars are still active 36 36 fromEnv: !getSetting('smtp_host', '') && !!process.env.SMTP_HOST, 37 37 }; 38 38 } 39 39 40 // Transport cachen, maar herbouwen zodra de config wijzigt (UI-edit zonder herstart).40 // Cache the transport, but rebuild it whenever the config changes (UI edit without restart). 41 41 let _transport = null, _key = null; 42 42 function transport() { … … 47 47 host: c.host, 48 48 port: c.port, 49 secure: c.port === 465, // 465 = implici eteTLS; 587 = STARTTLS49 secure: c.port === 465, // 465 = implicit TLS; 587 = STARTTLS 50 50 auth: { user: c.user, pass: c.pass }, 51 51 }); … … 56 56 57 57 export async function sendMail({ to, subject, text, html }) { 58 if (!mailerConfigured()) throw new Error('SMTP n iet geconfigureerd');58 if (!mailerConfigured()) throw new Error('SMTP not configured'); 59 59 const c = cfg(); 60 60 return transport().sendMail({ from: c.from, to, subject, text, html }); -
src/middleware/auth.js
rbb42dfb r834bcc3 38 38 } 39 39 40 // Een 'kijker' mag ALLES bekijken (incl. Beheer) maar NIETS wijzigen. De41 // schrijf-blokkade zit in de globale guard in server.js; deze helper bepaalt42 // alleen "is dit een alleen-lezen account?". `readonly` is de legacy-vlag die43 // we nog meenemen zodat niet-gemigreerde demo-accounts geblokkeerd blijven.40 // A 'kijker' (viewer) may VIEW everything (incl. Admin) but CHANGE nothing. The 41 // write block lives in the global guard in server.js; this helper only determines 42 // "is this a read-only account?". `readonly` is the legacy flag we still include 43 // so unmigrated demo accounts remain blocked. 44 44 export function isViewer(user) { 45 45 return !!user && (user.role === 'kijker' || !!user.readonly); … … 49 49 if (!req.session?.user) return loginRedirect(req, res); 50 50 const role = req.session.user.role; 51 // god beheert; een kijker mág het Beheer-paneel zien (alleen-lezen) — de52 // global e guard 403't elke write, dus dit geeft enkel kijk-toegang.51 // god manages; a viewer MAY see the Admin panel (read-only) — the 52 // global guard 403s every write, so this only grants view access. 53 53 if (role !== 'god' && role !== 'kijker') { 54 54 return res.status(403).send('God role required'); … … 57 57 } 58 58 59 // Mag de ingelogde user de HUIDIGE site (res.locals.site) beheren? god altijd;60 // anders alleen de owner van die site. Gebruikt voor site-gescopete beheerroutes61 // die een artiest via /user/<eigen-slug>/admin/... bereikt (res.locals.site is dan62 // z'n eigen site; een vreemde slug levert een anderesite -> 403).59 // Can the logged-in user manage the CURRENT site (res.locals.site)? god always; 60 // otherwise only the owner of that site. Used for site-scoped admin routes 61 // that an artist reaches via /user/<own-slug>/admin/... (res.locals.site is then 62 // their own site; a foreign slug yields a different site -> 403). 63 63 export function requireSiteManager(req, res, next) { 64 64 if (!req.session?.user) return loginRedirect(req, res); 65 65 const u = req.session.user; 66 if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang66 if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access 67 67 const site = res.locals.site; 68 // owner O F toegewezen mede-beheerder (site_members) — canAdminSite dekt beide.68 // owner OR assigned co-admin (site_members) — canAdminSite covers both. 69 69 if (site && PermissionsService.canAdminSite(u, site)) return next(); 70 70 return res.status(403).send('Geen toegang tot deze site.'); 71 71 } 72 72 73 // Idem, maar de site wordt bepaald door de :slug-parameter (bv. site-edit).73 // Same, but the site is determined by the :slug parameter (e.g. site-edit). 74 74 export function requireSiteManagerBySlug(req, res, next) { 75 75 if (!req.session?.user) return loginRedirect(req, res); 76 76 const u = req.session.user; 77 if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang77 if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access 78 78 const site = db.prepare('SELECT id, owner_id FROM sites WHERE slug = ?').get(req.params.slug); 79 79 if (site && PermissionsService.canAdminSite(u, site)) return next(); -
src/middleware/rate-limit.js
rbb42dfb r834bcc3 14 14 import { renderPage } from './render.js'; 15 15 16 // Achter Cloudflare/Caddy kan req.ip binnenkomen als "1.2.3.4:11046" (IPv4 met17 // po ort). express-rate-limit v7 valideert het IP en gooit anders18 // ERR_ERL_INVALID_IP_ADDRESS — onafgevangen async → het proces crasht (enpm219 // loopt in een restart-loop). Strip een trailing IPv4-poort, val terug op de20 // socket , en laat IPv6 (meerdere dubbele punten) ongemoeid.16 // Behind Cloudflare/Caddy, req.ip can arrive as "1.2.3.4:11046" (IPv4 with 17 // port). express-rate-limit v7 validates the IP and otherwise throws 18 // ERR_ERL_INVALID_IP_ADDRESS — uncaught async → the process crashes (and pm2 19 // enters a restart loop). Strip a trailing IPv4 port, fall back to the 20 // socket address, and leave IPv6 (multiple colons) untouched. 21 21 function clientKey(req) { 22 22 let ip = req.ip || req.socket?.remoteAddress || ''; -
src/middleware/render.js
rbb42dfb r834bcc3 27 27 const VIEWS_DIR = path.join(__dirname, '..', 'views'); 28 28 29 // App -versie (uit package.json) + korte commit-hash (uit .klonkt-version, door de30 // deploy geschreven) — getoond in de footer naast "Klonkt Beta". De hash loopt31 // automati sch mee bij élke deploy, dus de versie is nooit meer stale.29 // App version (from package.json) + short commit hash (from .klonkt-version, written by 30 // the deploy script) — shown in the footer next to "Klonkt Beta". The hash is updated 31 // automatically on every deploy, so the displayed version is never stale. 32 32 let APP_VERSION = ''; 33 33 try { … … 36 36 const sha = fs.readFileSync(path.join(__dirname, '..', '..', '.klonkt-version'), 'utf8').trim().slice(0, 7); 37 37 if (sha) APP_VERSION += ' · ' + sha; 38 } catch { /* geen .klonkt-version (lokale dev) */ } 39 } catch { /* geen versie beschikbaar */ } 40 41 // Site-tijdzone (Beheer → Instellingen). Leeg = server-default (UTC). Wordt 42 // toegepast op álle server-side geformatteerde datums zodat ze in de zone van 43 // de site staan i.p.v. UTC. 38 } catch { /* no .klonkt-version (local dev) */ } 39 } catch { /* no version available */ } 40 41 // Site timezone (Admin → Settings). Empty = server default (UTC). Applied to 42 // all server-side formatted dates so they display in the site's timezone instead of UTC. 44 43 const siteTimezone = () => getSetting('timezone') || undefined; 45 44 … … 58 57 const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1'; 59 58 60 // Voorkom dat de browser een htmx-PARTIAL (alleen #pcms-main, zónder<head>/CSS)61 // onder dezelfde URL cachet en bij "terug" als volledige pagina serveert →62 // ongestylede HTML. Vary: HX-Request scheidt partial- en full-responses in de63 // cache; no-store op de partial zelf laat "terug" altijd de volledige pagina64 // opnieuw ophalen. (Vary geldt ook voor tussenliggende caches/Cloudflare.)59 // Prevent the browser from caching an htmx PARTIAL (only #pcms-main, without <head>/CSS) 60 // under the same URL and serving it as a full page on "back" → unstyled HTML. 61 // Vary: HX-Request separates partial and full responses in the cache; 62 // no-store on the partial forces "back" to always re-fetch the full page. 63 // (Vary also applies to intermediate caches / Cloudflare.) 65 64 res.setHeader('Vary', 'HX-Request'); 66 65 if (isPartial) res.setHeader('Cache-Control', 'no-store'); 67 66 68 // Bezit deze (niet-god) user een eigen site? Bepaalt of 'ie een "Beheer"-69 // ingang ziet (artiest-zelfbeheer). god ziet beheer sowieso (op rol).67 // Does this (non-god) user own a site? Determines whether they see an "Admin" 68 // entry (artist self-manage). god always sees admin (by role). 70 69 let _u = req.session?.user || null; 71 // Ververs avatar + rol uit de DB zodat een stale sessie (bv. na een72 // avatar -wijziging of rolwissel) zichzelf herstelt zonder opnieuw inloggen.70 // Refresh avatar + role from the DB so a stale session (e.g. after an 71 // avatar change or role switch) heals itself without a new login. 73 72 if (_u && _u.id) { 74 73 const _fresh = db.prepare('SELECT avatar_url, role, lang FROM users WHERE id = ?').get(_u.id); … … 78 77 db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id)); 79 78 80 // De avatar van de SITE-EIGENAAR (niet de kijker!) — voor de Klonkt-site-kop,81 // zodat de artiest z'n eigen account-foto als sitefoto kan gebruiken.79 // The avatar of the SITE OWNER (not the viewer!) — for the Klonkt site header, 80 // so the artist can use their own account photo as the site photo. 82 81 const _site = data.site || res.locals.site || null; 83 82 const siteOwnerAvatar = (_site && _site.owner_id) … … 85 84 : null; 86 85 87 // Kijker-modus: alles bekijken mag, niets wijzigen. Views gebruikencanMutate88 // om schrijf-knoppen (posten, opslaan, verwijderen) te verbergen/uit te zetten.86 // Viewer mode: may view everything, change nothing. Views use canMutate 87 // to hide/disable write buttons (post, save, delete). 89 88 const _isViewer = isViewer(_u); 90 89 91 // W ie ziet de "Beheer"-link? god/admin, een site-eigenaar (artiest-zelfbeheer),92 // én een kijker (mag het Beheer alleen-lezen inzien). Eén bron van waarheid,93 // gespiegeld in topnav/hub-nav/profielsheet — anders raakt de link verborgen94 // voor wie 'm wél mag zien (kijker zag 'm eerst nergens).90 // Who sees the "Admin" link? god/admin, a site owner (artist self-manage), 91 // and a viewer (may view Admin read-only). One source of truth, 92 // mirrored in topnav/hub-nav/profile sheet — otherwise the link gets hidden 93 // for those who should see it (viewer didn't see it anywhere before). 95 94 const _role = _u ? _u.role : null; 96 95 const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite)); 97 96 98 // Interface -taal: sessie-keuze (deze sessie) → eigen voorkeur van de ingelogde99 // gebruiker (users.lang) → admin-ingestelde standaard (Beheer) → env → browser → nl.97 // Interface language: session choice (this session) → logged-in user's own preference 98 // (users.lang) → admin-set default (Admin → Settings) → env → browser → nl. 100 99 const _lang = resolveLang(req, { 101 100 userLang: _u && _u.lang, … … 125 124 tenancy: res.locals.tenancy || 'solo', 126 125 hubTitle: getSetting('hub_title') || '', 127 footerNewsletter: getSetting('footer_newsletter') === '1', // n ieuwsbrief-aanmeldingin footer (premium)128 agendaEnabled: getSetting('agenda_enabled') === '1', // Agenda/evenementen tonen in de pill (premium, opt-in)126 footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium) 127 agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in) 129 128 platforms_catalog: PLATFORMS_CATALOG, 130 129 permissions: PermissionsService, … … 153 152 // looks "unclickable". Escape any non-ASCII to \uXXXX: the header stays 154 153 // ASCII-safe and remains valid JSON that htmx parses back unchanged. 155 // Per-site accent + palette zitten in de shell-<head> (style#pcms-site-accent156 // + html[data-palette]) en worden NIET mee-geswapt bij htmx-nav. Stuur ze mee157 // zodat de client ze bijwerkt — anders erft een artiest de kleuren van de158 // vorige pagina (bv. hub-paars i.p.v. eigen groen). Zelfde afleiding als shell.ejs.154 // Per-site accent + palette live in the shell <head> (style#pcms-site-accent 155 // + html[data-palette]) and are NOT swapped during htmx navigation. Send them along 156 // so the client updates them — otherwise an artist inherits the previous page's 157 // colours (e.g. hub-purple instead of their own green). Same derivation as shell.ejs. 159 158 const _navAccent = (_site && _site.accent && /^#[0-9a-fA-F]{6}$/.test(_site.accent)) 160 159 ? _site.accent : '#e8b04b'; … … 169 168 }).replace(/[-]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0')); 170 169 res.setHeader('HX-Trigger-After-Settle', triggerJson); 171 // Site-chrome out-of-band mee-renderen, zodat de kop (topnav/profielkop/172 // view-switcher) bij navigatie ALTIJD bij de nieuwe pagina/artiest hoort—173 // terwijl de audioplayer (los in document.body) blijft leven (geen174 // verspringen). htmx vervangt #pcms-chrome via hx-swap-oob. Niet kritisch:175 // faalt 't, dan blijft de oude chrome staan (geencrash).170 // Render the site chrome out-of-band so the header (topnav/profile header/ 171 // view-switcher) ALWAYS matches the new page/artist on navigation — 172 // while the audio player (separate in document.body) keeps playing (no 173 // interruption). htmx replaces #pcms-chrome via hx-swap-oob. Non-critical: 174 // if it fails, the old chrome remains (no crash). 176 175 let oobChrome = ''; 177 176 try { … … 181 180 { async: false }, 182 181 ); 183 } catch (e) { /* chrome-OOB overslaan*/ }182 } catch (e) { /* skip chrome OOB */ } 184 183 return res.send(pageContent + oobChrome); 185 184 } -
src/middleware/site.js
rbb42dfb r834bcc3 15 15 16 16 /** 17 * De primaire/hoofd-site — ÉÉN bron van waarheid (vervangt de "oudstesite ="18 * hoofd"-aanname die voorheen los in resolveSite/hub/account/admin stond).19 * Leest de expliciete is_primary-vlag; valt terug op de oudste als die (nog)20 * nergens staat, zodat bestaand gedrag exact behouden blijft.17 * The primary/main site — ONE source of truth (replaces the "oldest site =" 18 * main" assumption that was previously scattered across resolveSite/hub/account/admin). 19 * Reads the explicit is_primary flag; falls back to the oldest if it isn't set 20 * anywhere yet, so existing behaviour is preserved exactly. 21 21 */ 22 22 export function getPrimarySite() { … … 28 28 export function resolveSite(req, res, next) { 29 29 const tenancy = getTenancy(); 30 res.locals.tenancy = tenancy; // ook beschikbaar voorviews30 res.locals.tenancy = tenancy; // also available in views 31 31 32 // In HUB -mode mapt /user/:slug naar een specifieke site. In SOLO-mode bestaat33 // er maar één site: we slaan die routing over en pinnen op de primairesite.32 // In HUB mode /user/:slug maps to a specific site. In SOLO mode there is only 33 // one site: we skip that routing and pin to the primary site. 34 34 if (tenancy === 'hub') { 35 // Een Klonkt-site is canoniek bereikbaar via /user/:slug. /sites/:slug is een36 // legacy -alias → 301 naar de canonieke vorm zodat er één URL-schema overblijft37 // ( behoudt pad + querystring; raakt /admin/sites NIET, dat begint met/admin/).35 // A Klonkt site is canonically reachable via /user/:slug. /sites/:slug is a 36 // legacy alias → 301 to the canonical form so one URL scheme remains 37 // (preserves path + query string; does NOT touch /admin/sites, which starts with /admin/). 38 38 const m = req.path.match(/^\/(sites|user)\/([a-zA-Z0-9_-]+)(\/.*)?$/); 39 39 if (m) { … … 49 49 } 50 50 } 51 // ( Verwijderd: een dode "slug == hostname"-subdomein-hack. Slugs mogen geen52 // punten bevatten, dus die kon nooit matchen. Echte subdomein-routing zou de53 // subdom ein-LABEL tegen de slug matchen — een aparte feature, niet dit.)51 // (Removed: a dead "slug == hostname" subdomain hack. Slugs may not contain 52 // dots, so it could never match. Real subdomain routing would match the 53 // subdomain LABEL against the slug — a separate feature, not this.) 54 54 } 55 55 56 // Solo (o f hub zonder match): pin op de primaire/hoofd-site.56 // Solo (or hub without a match): pin to the primary/main site. 57 57 const defaultSite = getPrimarySite(); 58 58 if (defaultSite) { … … 79 79 80 80 try { 81 // m.filename = de kale bestandsnaam; de speelbare URL is de gated stream-route82 // (audioUrl). De media-tabel heeft GEEN url-kolom — de oude query selecteerde83 // m.url en faalde dus altijd stil (lege speler). Nu bouwen we de URL uitfilename.81 // m.filename = the bare filename; the playable URL is the gated stream route 82 // (audioUrl). The media table has NO url column — the old query selected 83 // m.url and always failed silently (empty player). Now we build the URL from filename. 84 84 const rows = db.prepare(` 85 85 SELECT t.id, t.title, t.artist, t.duration, t.position, m.filename -
src/routes/account.js
rbb42dfb r834bcc3 66 66 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth'); 67 67 const googleLinked = !!(account && account.google_sub); 68 if (account) { delete account.password_hash; delete account.google_sub; } // niet naar de view lekken68 if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view 69 69 70 70 renderPage(req, res, 'pages/account', { … … 81 81 }); 82 82 83 // ==================== PERSO ONLIJKE INTERFACE-TAAL====================84 // S laat de taalkeuze op het account op (reist mee over apparaten/sessies) én85 // zet 'm meteen in de sessie zodat 't direct effect heeft.83 // ==================== PERSONAL INTERFACE LANGUAGE ==================== 84 // Saves the language choice on the account (persists across devices/sessions) and 85 // also sets it in the session immediately so it takes effect right away. 86 86 router.post('/lang', requireAuth, (req, res) => { 87 87 const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null; … … 94 94 }); 95 95 96 // De site die deze gebruiker mag bewerken vanuit z'n account: z'n eigen site97 // (owner_id), o f voor een god de primaire site. Null als er niets is.96 // The site this user may edit from their account: their own site 97 // (owner_id), or for a god the primary site. Null if nothing found. 98 98 function ownedSite(user) { 99 99 if (!user) return null; 100 100 let site = db.prepare('SELECT id, title, tagline, slug, owner_id FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id); 101 101 if (!site && user.role === 'god') { 102 site = getPrimarySite(); // prima ire/hoofd-site als fallback102 site = getPrimarySite(); // primary/main site as fallback 103 103 } 104 104 return site || null; … … 124 124 const bio = (req.body.bio || '').toString().slice(0, 500).trim(); 125 125 126 // E -mail (optioneel mee te wijzigen). Validatie: geldig formaat + niet al door127 // een ander account in gebruik. E-mail is het login-/reset-anker, dus uniek.126 // Email (optionally also changed). Validation: valid format + not already in use 127 // by another account. Email is the login/reset anchor, so it must be unique. 128 128 const email = (req.body.email || '').toString().trim(); 129 129 if (email) { … … 138 138 db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') 139 139 .run(email, req.session.user.id); 140 req.session.user.email = email; // sessie bijwerken zodat de UI klopt140 req.session.user.email = email; // update session so the UI reflects the change 141 141 } 142 142 … … 166 166 167 167 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id); 168 // Google-only accounts (l uisteraars) hebben geen echt wachtwoord.168 // Google-only accounts (listeners) have no real password. 169 169 if (!row || !row.password_hash || row.password_hash === '!google-oauth') { 170 170 return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)')); … … 181 181 }); 182 182 183 // Google-account ontkoppelen. Alleen toegestaan als er nog een wachtwoord is,184 // anders zou je jezelf buitensluiten (geen login-methode meer over).183 // Unlink Google account. Only allowed if a password is set, 184 // otherwise the user would lock themselves out (no login method left). 185 185 router.post('/google/unlink', requireAuth, (req, res) => { 186 186 const row = db.prepare('SELECT password_hash, google_sub FROM users WHERE id = ?').get(req.session.user.id); -
src/routes/admin-audio.js
rbb42dfb r834bcc3 38 38 const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']); 39 39 const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); 40 const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — gecomprimeerde formaten(mp3/m4a/ogg/…)41 const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is ongecomprimeerd, dus ruimer40 const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — compressed formats (mp3/m4a/ogg/…) 41 const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is uncompressed, so a higher limit 42 42 const MAX_COVER_BYTES = 5 * 1024 * 1024; // 5 MB 43 43 44 // Per- bestand bovengrens op basis van extensie. multer's globale limiet is de45 // h oogste (WAV); de echte controle per type gebeurt in de upload-handler.44 // Per-file upper limit based on extension. multer's global limit is the 45 // highest (WAV); the real per-type check happens in the upload handler. 46 46 const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES); 47 47 … … 59 59 const upload = multer({ 60 60 storage, 61 limits: { fileSize: MAX_WAV_BYTES }, // h oogste bovengrens (WAV) — per-type check in de handler61 limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler 62 62 fileFilter: (req, file, cb) => { 63 63 const ext = path.extname(file.originalname).toLowerCase(); … … 73 73 const router = express.Router(); 74 74 75 // "Open in" -platformlinks per track: alleen https + de juiste host accepteren76 // (href komt ongeescaped in de view → scheme/host-guard tegen misbruik).75 // "Open in" platform links per track: only https + the correct host accepted 76 // (href arrives unescaped in the view → scheme/host guard against abuse). 77 77 const LINK_DOMAINS = { 78 78 spotify: ['spotify.com'], … … 86 86 const h = new URL(u).hostname.toLowerCase(); 87 87 if (domains.some((d) => h === d || h.endsWith('.' + d))) return u; 88 } catch (e) { /* ongeldigeURL */ }88 } catch (e) { /* invalid URL */ } 89 89 return null; 90 90 } … … 148 148 } 149 149 150 // Per-type audio size check. multer's global e limiet was de WAV-bovengrens151 // (100MB); gecomprimeerde formaten blijven op50MB.150 // Per-type audio size check. multer's global limit was the WAV upper bound 151 // (100MB); compressed formats stay at 50MB. 152 152 const audioExt = path.extname(audioFile.originalname).toLowerCase(); 153 153 const audioLimit = audioByteLimitFor(audioExt); … … 185 185 const finalArtist = artist?.trim() || null; 186 186 const finalAlbum = album?.trim() || null; 187 // Eigenaarschap/licentie. credit valt terug op de artiest; deze gaan zowel de188 // DB in als de ID3-tags van de mp3 (copyright + comment).187 // Ownership/licence. credit falls back to the artist; these go both into the 188 // DB and into the ID3 tags of the mp3 (copyright + comment). 189 189 const finalCredit = (req.body.credit || '').trim() || finalArtist || null; 190 190 const finalLicense = (req.body.license || '').trim() || null; … … 231 231 `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path); 232 232 233 // Du ur automatisch: primair uit de transcode (ffmpeg codecData), anders een234 // optionele client-side waarde (bulk-uploader leest <audio>.duration uit),235 // anders NULL (UI toont dan '—:—', handmatig bij te werken in de editor).233 // Duration automatically: primarily from the transcode (ffmpeg codecData), then 234 // an optional client-side value (bulk uploader reads <audio>.duration), 235 // otherwise NULL (UI then shows '—:—', editable manually in the editor). 236 236 const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN; 237 237 const finalDuration = … … 275 275 }); 276 276 277 // Download- voor-email per track aan/uit (premium #2). Zonder-JS toggle vanaf de278 // audio -beheerlijst → flip + terug.277 // Download-for-email per track on/off (premium #2). No-JS toggle from the 278 // audio admin list → flip + back. 279 279 router.post('/:id/downloadable', requireGod, (req, res) => { 280 280 const site = res.locals.site; … … 394 394 395 395 /** GET /admin/audio/api/:id — single track with all metadata */ 396 // Maak een track ZONDER audiobestand (alleen titel + open-in links). Verschijnt397 // in albums/playlists in de lijst, met open-in-iconen maar zonder afspeelknop.396 // Create a track WITHOUT an audio file (title + open-in links only). Appears 397 // in albums/playlists in the list, with open-in icons but no play button. 398 398 router.post('/create-link', requireGod, express.json(), (req, res) => { 399 399 const site = res.locals.site; … … 507 507 } 508 508 509 // Verse rij + (als tag-velden wijzigden) de mp3 her-taggen, zodat de eigenaar/510 // licen tie ook IN het bestand staat (ID3) en meereist bij een download.509 // Fresh row + (if tag fields changed) retag the mp3, so that the owner/ 510 // licence is also IN the file (ID3) and travels with it on download. 511 511 const fresh = db.prepare(` 512 512 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path … … 527 527 } }); 528 528 } catch (e) { 529 console.warn('[admin-audio] ID3 her-taggen mislukt (DB is wel bijgewerkt):', e.message);529 console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message); 530 530 } 531 531 } -
src/routes/admin-circle.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: Cir kel-beheer(god-only).3 * GET /admin/circle -> li jst van cirkel-links + status4 * POST /admin/circle/add -> Klonkt-URL toevoegen2 * Admin: Circle management (god-only). 3 * GET /admin/circle -> list of circle links + status 4 * POST /admin/circle/add -> add a Klonkt URL 5 5 * POST /admin/circle/:id/remove 6 * POST /admin/circle/:id/sync -> nu verversen (pull + verifieer)7 * POST /admin/circle/allow -> toggle "ma g in cirkels van anderen verschijnen"6 * POST /admin/circle/:id/sync -> refresh now (pull + verify) 7 * POST /admin/circle/allow -> toggle "may appear in others' circles" 8 8 * 9 * Zie docs/cirkels-v1-spec.md §5d.9 * See docs/cirkels-v1-spec.md §5d. 10 10 */ 11 11 … … 51 51 const site = primarySite(); 52 52 if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden')); 53 // Schema automatisch aanvullen: een kale domeinnaam → https://, een getypte54 // http:// → https:// (federati e is bewust https-only, getekende feeds). Zo hoeft55 // de gebruiker nooit zelf http(s):// te typen.53 // Auto-complete the scheme: a bare domain name → https://, a typed 54 // http:// → https:// (federation is intentionally https-only, signed feeds). So the 55 // user never has to type http(s):// themselves. 56 56 let url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, ''); 57 57 if (url && !/^[a-z]+:\/\//i.test(url)) url = 'https://' + url; … … 68 68 return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel')); 69 69 } 70 // Meteen ophalen i.p.v. wachten op de 15-min-loop.70 // Fetch immediately instead of waiting for the 15-minute loop. 71 71 try { 72 72 const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(id); … … 75 75 } catch (e) { 76 76 const msg = String((e && e.message) || e); 77 // 404 = geen cirkel-endpoint. Hubs federeren bewust NIET (hun/.klonkt/actor.json78 // geeft 404), net als losse niet-Klonkt-sites. Niet toevoegen: rol de insert terug79 // zodat er geen dode "fout"-rij in de cirkel blijft staan.77 // 404 = no circle endpoint. Hubs intentionally do NOT federate (their /.klonkt/actor.json 78 // returns 404), same as standalone non-Klonkt sites. Don't add: roll back the insert 79 // so no dead "error" row is left in the circle. 80 80 if (/\b404\b/.test(msg)) { 81 81 db.prepare('DELETE FROM circle_links WHERE id = ?').run(id); 82 82 return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet toegevoegd: deze site doet niet mee aan cirkels. Een hub kan geen cirkel-partner zijn (en losse/niet-Klonkt-sites ook niet).')); 83 83 } 84 // Andere (mogelijk tijdelijke) fout → link blijft staan; later "Verversen".84 // Other (possibly temporary) error → link stays; use "Refresh" later to retry. 85 85 return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — synchroniseren mislukte (klik "Verversen" om opnieuw te proberen)')); 86 86 } … … 91 91 if (link) { 92 92 db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id); 93 // Gecachte content opruimen als geen andere link nog naar deze actor wijst.93 // Clean up cached content if no other link still points to this actor. 94 94 if (link.remote_actor_id) { 95 95 const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id); … … 117 117 }); 118 118 119 // Alles in één keer verversen (handig "voor de zekerheid").119 // Refresh everything at once (handy "just to be sure"). 120 120 router.post('/sync-all', requireGod, async (req, res) => { 121 121 try { -
src/routes/admin-comments.js
rbb42dfb r834bcc3 61 61 const site = res.locals.site; 62 62 if (!site) return res.status(404).send('No site'); 63 const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub -artiestcontext, anders''63 const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub artist context, otherwise '' 64 64 65 65 const row = db.prepare(` -
src/routes/admin-epk.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: Perskit (EPK) bewerken — per-site bio + pers-contact.2 * Admin: Edit press kit (EPK) — per-site bio + press contact. 3 3 * 4 * GET /admin/epk -> form ulier met huidigebio + contact5 * POST /admin/epk -> opslaan(app_settings: epk_bio_<siteId> / epk_contact_<siteId>)4 * GET /admin/epk -> form with current bio + contact 5 * POST /admin/epk -> save (app_settings: epk_bio_<siteId> / epk_contact_<siteId>) 6 6 * 7 * De perskit-pagina zelf (/pers) leest deze waarden; tracks + recente posts komen8 * automati sch. Perskit is premium + solo (zie routes/epk.js).7 * The press kit page itself (/pers) reads these values; tracks + recent posts come 8 * automatically. Press kit is premium + solo (see routes/epk.js). 9 9 */ 10 10 … … 49 49 setSetting('epk_bio_' + site.id, (req.body.epk_bio || '').toString().slice(0, 1000).trim()); 50 50 setSetting('epk_contact_' + site.id, (req.body.epk_contact || '').toString().slice(0, 300).trim()); 51 // Gekozen nummers: alleen ids van DEZE site, max 5, in de aangeleverde volgorde.51 // Chosen tracks: only ids belonging to THIS site, max 5, in the supplied order. 52 52 let ids = req.body.epk_tracks; 53 53 if (!Array.isArray(ids)) ids = ids ? [ids] : []; -
src/routes/admin-newsletter.js
rbb42dfb r834bcc3 1 1 /** 2 * N ieuwsbrief — beheerkant(premium feature #1).2 * Newsletter — admin side (premium feature #1). 3 3 * 4 * GET /admin/newsletter -> opstellen + abonnee-aantallen + historie5 * POST /admin/newsletter/send -> verstuur naar alle BEVESTIGDE abonnees (SMTP)4 * GET /admin/newsletter -> compose + subscriber counts + history 5 * POST /admin/newsletter/send -> send to all CONFIRMED subscribers (SMTP) 6 6 * 7 * Premium-gated + site -beheerder. Versturen vereist ingestelde SMTP; zonderSMTP8 * worden aanmeldingen wél verzameld (single opt-in), alleen versturen kan dan niet.7 * Premium-gated + site manager. Sending requires configured SMTP; without SMTP 8 * sign-ups are still collected (single opt-in), only sending is unavailable. 9 9 */ 10 10 … … 86 86 }); 87 87 sent++; 88 } catch (e) { /* s la deze ontvanger over, ga door*/ }88 } catch (e) { /* skip this recipient, continue */ } 89 89 } 90 90 db.prepare('INSERT INTO newsletters (id, site_id, subject, body, recipient_count) VALUES (?,?,?,?,?)') -
src/routes/admin-patreon.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: Patreon koppelen voor de premium-laag(god-only).2 * Admin: Link Patreon for the premium layer (god-only). 3 3 * 4 * GET /admin/patreon/connect -> stuur de beheerder naar de license-server5 * (oauth/start) met onze callback als return.6 * GET /admin/patreon/callback -> license -server keert terug met?klonkt_token7 * (o f ?klonkt_error). Verifieer + sla op.8 * GET /admin/patreon/disconnect -> entitlement wissen.4 * GET /admin/patreon/connect -> redirect the admin to the license server 5 * (oauth/start) with our callback as return URL. 6 * GET /admin/patreon/callback -> license server returns with ?klonkt_token 7 * (or ?klonkt_error). Verify + store. 8 * GET /admin/patreon/disconnect -> clear entitlement. 9 9 * 10 * Het echte verdienmodel-slot zit in het ondertekende token (alleen de11 * license -server kan tekenen). Zie PatreonService.js.10 * The real monetisation lock is in the signed token (only the 11 * license server can sign). See PatreonService.js. 12 12 */ 13 13 -
src/routes/admin-seo.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: geavanceerde SEO-instellingen van de primairesite.2 * Admin: advanced SEO settings for the primary site. 3 3 * 4 * GET /admin/seo -> form ulier met alle SEO/social-velden van de hoofdsite5 * POST /admin/seo -> opslaan(god-only)4 * GET /admin/seo -> form with all SEO/social fields for the main site 5 * POST /admin/seo -> save (god-only) 6 6 * 7 * Deze velden worden al door de <head> (shell.ejs) en de JSON-LD/OpenGraph-8 * tags geconsumeerd, maar waren tot nu toe nergens te bewerken. De basis-9 * velden (titel/bio/robots) blijven in Uiterlijk; dit is de geavanceerde laag:10 * tit el-sjabloon, canonical, social-share-afbeelding, verificatie-metas,11 * publisher/JSON-LD en OpenGraph-locale.7 * These fields are already consumed by the <head> (shell.ejs) and the JSON-LD/ 8 * OpenGraph tags, but were previously not editable anywhere. The basic 9 * fields (title/bio/robots) remain in Appearance; this is the advanced layer: 10 * title template, canonical, social share image, verification metas, 11 * publisher/JSON-LD and OpenGraph locale. 12 12 * 13 * Werkt op de PRIMAIRE site (solo = de enige site; hub = de bedrijfssite).13 * Operates on the PRIMARY site (solo = the only site; hub = the company site). 14 14 */ 15 15 -
src/routes/admin-settings.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: global e instellingen.3 * - tenancy -modus(Solo/Hub)4 * - hub -branding (naam/tagline/intro/hero van de generieke hub-hoofdpagina)2 * Admin: global settings. 3 * - tenancy mode (Solo/Hub) 4 * - hub branding (name/tagline/intro/hero of the generic hub home page) 5 5 * 6 * GET /admin/settings -> toon huidige instellingen7 * POST /admin/settings -> s la op (god-only). Accepteert nu ook een geuploade8 * hero -afbeelding (multipart); een upload wint van het9 * URL -tekstveld. Zonder upload blijft het URL-veld leidend.6 * GET /admin/settings -> show current settings 7 * POST /admin/settings -> save (god-only). Also accepts an uploaded 8 * hero image (multipart); an upload wins over the 9 * URL text field. Without an upload the URL field is leading. 10 10 * 11 * De hub-pagina is generiek (van geen enkele user); deze branding leeftin12 * global e settings, niet in eensite.11 * The hub page is generic (belonging to no user); this branding lives in 12 * global settings, not in a site. 13 13 */ 14 14 … … 30 30 const router = express.Router(); 31 31 32 // Hero dark -overlay: percentage 0-100 (0 = geen overlay, 100 = volledig zwart).33 // Default 45 = de oude hardgecodeerde waarde, zodat bestaande hubs niet wijzigen.32 // Hero dark overlay: percentage 0-100 (0 = no overlay, 100 = fully black). 33 // Default 45 = the old hard-coded value, so existing hubs don't change appearance. 34 34 function clampOverlay(raw) { 35 35 const v = parseInt(raw, 10); … … 38 38 39 39 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 40 // Hero -uploads landen in storage/media/hero → bereikbaar als /media/hero/<file>41 // ( de /media static handler serveert storage/media). Zelfde model als avatars.40 // Hero uploads land in storage/media/hero → accessible as /media/hero/<file> 41 // (the /media static handler serves storage/media). Same model as avatars. 42 42 const HERO_DIR = path.resolve( 43 43 process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero') … … 45 45 fs.mkdirSync(HERO_DIR, { recursive: true }); 46 46 47 // Alleen raster-formaten voor de upload. SVG mag bewust NIET via upload (raw48 // SVG kan script bevatten → opgeslagen-XSS bij direct openen); een SVG-hero kan49 // nog steeds via het URL-veld (zoals de meegeleverde demo-placeholder).47 // Only raster formats for upload. SVG is intentionally NOT allowed via upload 48 // (raw SVG can contain scripts → stored-XSS when opened directly); an SVG hero 49 // can still be set via the URL field (like the bundled demo placeholder). 50 50 const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); 51 51 const MAX_HERO_BYTES = 5 * 1024 * 1024; … … 95 95 96 96 router.post('/', requireGod, (req, res) => { 97 // multer.single verwerkt multipart (hub-branding form). Bij een gewone98 // urlencoded POST (tenancy -form) doet multer niets en blijft req.bodyintact.97 // multer.single processes multipart (hub branding form). For a plain 98 // urlencoded POST (tenancy form) multer does nothing and req.body stays intact. 99 99 heroUpload.single('hub_hero_file')(req, res, (err) => { 100 100 if (err) { … … 103 103 104 104 if (typeof req.body.tenancy !== 'undefined') { 105 // Hub -modus is een premium-feature: alleen naar hub schakelen als premium106 // ontgrendeld is (premium-laag uit = vrij; aan = Patreon vereist). Al-hub107 // blijven mag altijd, zodat een instance nooit vastloopt.105 // Hub mode is a premium feature: only switch to hub if premium is 106 // unlocked (premium layer off = free; on = Patreon required). Staying on 107 // hub is always allowed, so an instance can never get stuck. 108 108 if (req.body.tenancy === 'hub' && !premiumUnlocked() && getTenancy() !== 'hub') { 109 109 return res.redirect('/admin/settings?error=' + encodeURIComponent('Hub-modus is een premium-functie — koppel Patreon in Beheer → Instellingen.')); … … 112 112 } 113 113 if (typeof req.body.default_lang !== 'undefined') { 114 // Standaardtaal voor bezoekers (leeg = volg env/browser). Valideert tegenNL/EN/DE.114 // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE. 115 115 const dl = (req.body.default_lang || '').toString().toLowerCase(); 116 116 setSetting('default_lang', SUPPORTED.includes(dl) ? dl : ''); 117 117 } 118 118 if (typeof req.body.timezone !== 'undefined') { 119 // Site -tijdzone (IANA, bv. Europe/Amsterdam). Leeg = server-default (UTC).120 // Valid eer met Intl zodat een onzin-waarde nooit de datum-rendering breekt.119 // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC). 120 // Validate with Intl so a nonsense value never breaks date rendering. 121 121 const tz = (req.body.timezone || '').toString().trim(); 122 122 let valid = ''; … … 134 134 } 135 135 136 // Hero: een geüploade afbeelding wint; anders het URL-tekstveld.136 // Hero: an uploaded image wins; otherwise the URL text field. 137 137 if (req.file) { 138 138 const newUrl = `/media/hero/${toWebp(req.file)}`; 139 // Ruim een vorige geüploade hero op (alleen als die uit onze hero-map kwam).139 // Clean up a previously uploaded hero (only if it came from our hero dir). 140 140 const old = getSetting('hub_hero_image') || ''; 141 141 if (old.startsWith('/media/hero/')) { … … 155 155 }); 156 156 157 // Google -login op een eigen Beheer-pagina (los van de algemene instellingen).157 // Google login on its own admin page (separate from the general settings). 158 158 router.get('/google', requireGod, (req, res) => { 159 159 renderPage(req, res, 'pages/admin-google', { … … 171 171 }); 172 172 173 // Google-login (luisteraars) configureren— Client ID + Secret in app_settings.174 // De redirect-URI leiden we af van PUBLIC_BASE_URL (zie config/google.js).173 // Configure Google login (listeners) — Client ID + Secret in app_settings. 174 // The redirect URI is derived from PUBLIC_BASE_URL (see config/google.js). 175 175 router.post('/google', requireGod, (req, res) => { 176 176 if (req.body.clear === '1') { … … 180 180 } 181 181 setSetting('google_client_id', (req.body.google_client_id || '').toString().trim()); 182 // Secret alleen overschrijven als er een nieuwe waarde is ingevoerd (leeg = laat staan).182 // Only overwrite the secret if a new value was entered (empty = leave as-is). 183 183 const secret = (req.body.google_client_secret || '').toString().trim(); 184 184 if (secret) setSetting('google_client_secret', secret); … … 197 197 setSetting('smtp_user', (b.smtp_user || '').toString().trim()); 198 198 setSetting('smtp_from', (b.smtp_from || '').toString().trim()); 199 // Wachtwoord alleen overschrijven als er een nieuwe waarde is ingevoerd.199 // Only overwrite the password if a new value was entered. 200 200 const pass = (b.smtp_pass || '').toString(); 201 201 if (pass) setSetting('smtp_pass', pass); … … 203 203 }); 204 204 205 // N ieuwsbrief-aanmelding in de footer aan/uit.205 // Newsletter sign-up in the footer on/off. 206 206 router.post('/footer', requireGod, (req, res) => { 207 207 setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0'); … … 209 209 }); 210 210 211 // Testmail sturen naar een opgegeven adres (of de ingelogde gebruiker).211 // Send a test email to a specified address (or the logged-in user). 212 212 router.post('/smtp/test', requireGod, async (req, res) => { 213 213 const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim(); -
src/routes/admin-shows.js
rbb42dfb r834bcc3 1 1 /** 2 * Show -agenda (premium feature #8) — beheerkant.2 * Show agenda (premium feature #8) — admin side. 3 3 * 4 * GET /admin/shows -> li jst + toevoeg-formulier5 * POST /admin/shows -> show toevoegen (optioneel notify-mail naar abonnees)4 * GET /admin/shows -> list + add form 5 * POST /admin/shows -> add show (optional notify email to subscribers) 6 6 * POST /admin/shows/:id/delete 7 7 * 8 * Premium + site -beheerder. Notify-mail vereist SMTP; zonder SMTP wordt de show9 * gewoon opgeslagen (geen mail).8 * Premium + site manager. Notify email requires SMTP; without SMTP the show is 9 * simply saved (no email sent). 10 10 */ 11 11 … … 79 79 }); 80 80 sent++; 81 } catch { /* s la over*/ }81 } catch { /* skip */ } 82 82 } 83 83 } … … 86 86 87 87 router.post('/toggle', requireSiteManager, premiumGate, (req, res) => { 88 // Agenda tonen op de site (Agenda-knop in de pill + /shows-pagina).88 // Show the agenda on the site (Agenda button in the pill + /shows page). 89 89 setSetting('agenda_enabled', req.body.enabled ? '1' : '0'); 90 90 res.redirect((res.locals.siteUrlBase || '') + '/admin/shows'); -
src/routes/admin-sites.js
rbb42dfb r834bcc3 137 137 } 138 138 139 /** Geldige user-id voor owner-toewijzing, of null bij leeg/onbekend. */139 /** Valid user-id for owner assignment, or null if empty/unknown. */ 140 140 function validOwnerId(raw) { 141 141 const id = (raw || '').toString().trim(); … … 144 144 } 145 145 146 /** G eef een user admin-rechten op eensite (idempotent upsert). */146 /** Grant a user admin rights on a site (idempotent upsert). */ 147 147 function grantSiteAdmin(siteId, userId) { 148 148 db.prepare(` … … 152 152 } 153 153 154 /** Kandidaat-owners voor het owner-keuzeveld (god-only). */154 /** Candidate owners for the owner selector field (god-only). */ 155 155 function listOwnerCandidates() { 156 156 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all(); … … 183 183 bodyClass: 'on-admin', 184 184 isNew: true, 185 // ?owner=<id> ( vanaf de gebruikers-pagina: "geef deze user een Klonkt") wordt186 // voorgeselecteerd; anders de aanmakendegod.185 // ?owner=<id> (from the users page: "give this user a Klonkt") is 186 // pre-selected; otherwise defaults to the creating god. 187 187 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() }, 188 188 users: listOwnerCandidates(), … … 211 211 const f = { ...siteEditableFields(), ...req.body }; 212 212 213 // Owner: god ma g de site aan een ANDERE gebruiker toewijzen — dit is de kern214 // van hub-modus (elke gebruiker z'n eigen, zelf te beheren Klonkt). Leeg of215 // ongeldig → de aanmakende god zelf.213 // Owner: god may assign the site to a DIFFERENT user — this is the core of 214 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the 215 // creating god themselves. 216 216 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id; 217 217 … … 242 242 ); 243 243 244 // De OWNER (niet per se de aanmaker) krijgt een site_members-admin-rij → zo komt245 // 'ie door canAdminSite + de requireSiteManager-gates en beheert 'ie z'nsite.244 // The OWNER (not necessarily the creator) gets a site_members admin row → this 245 // lets them pass canAdminSite + requireSiteManager gates to manage their site. 246 246 grantSiteAdmin(siteId, ownerId); 247 247 … … 332 332 ); 333 333 334 // Owner (her)toewijzen — ALLEEN god. Een site-owner die z'n eigen site bewerkt335 // kan de eigenaar niet wijzigen (het veld wordt voor niet-god ook niet getoond).334 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot 335 // change the owner (the field is not shown to non-god users either). 336 336 if (req.session.user.role === 'god') { 337 337 const newOwner = validOwnerId(req.body.owner_id); … … 345 345 }); 346 346 347 // ==================== MA AK PRIMAIR====================348 // God kiest welke site de primaire/hoofd-site is (de label-/bedrijfssite in hub;349 // in solo dé site). Precies één site is primair → eerst alles uit, dan deze aan.347 // ==================== MAKE PRIMARY ==================== 348 // God chooses which site is the primary/main site (the label/company site in hub; 349 // in solo mode: the one site). Exactly one site is primary → clear all, then set this one. 350 350 router.post('/:slug/make-primary', requireGod, (req, res) => { 351 351 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug); -
src/routes/admin-stats.js
rbb42dfb r834bcc3 1 1 /** 2 * Admin: Statisti eken (premium-module, god-only).2 * Admin: Statistics (premium module, god-only). 3 3 * 4 * GET /admin/stats -> cookie vrije statistieken: bezoekers/weergaven per dag,5 * plays, en de populairsteposts/tracks.4 * GET /admin/stats -> cookie-free statistics: visitors/views per day, 5 * plays, and the most popular posts/tracks. 6 6 * 7 * Premium-gated via premiumUnlocked() (premium -laag uit = gewoon beschikbaar;8 * aan = Patreon vereist). Tracking zit in StatsService (geencookies).7 * Premium-gated via premiumUnlocked() (premium layer off = freely available; 8 * on = Patreon required). Tracking is in StatsService (no cookies). 9 9 */ 10 10 … … 22 22 return res.status(403).send('Statistieken is een premium-functie — koppel Patreon in Beheer → Instellingen.'); 23 23 } 24 // Link-in-bio klikken (premium #6) voor de huidigesite.24 // Link-in-bio clicks (premium #6) for the current site. 25 25 let linkClicks = []; 26 26 if (res.locals.site) { -
src/routes/admin-updates.js
rbb42dfb r834bcc3 1 1 /** 2 2 * Admin: Updates (god-only). 3 * Git- gebaseerde v1 voor instances die via de bare-repo draaien.4 * GET /admin/updates -> huidige vs. nieuwste versie+ status5 * POST /admin/updates/run -> haal nieuwste main op + herstart (detached script)3 * Git-based v1 for instances running from a bare repo. 4 * GET /admin/updates -> current vs. latest version + status 5 * POST /admin/updates/run -> fetch latest main + restart (detached script) 6 6 * 7 * De instance kent z'n "huidige" commit uit .klonkt-version (door het script8 * geschreven) en de "nieuwste" uit de bare repo (KLONKT_GIT_DIR). Voor externe9 * self-hosters komt later een GESIGNEERDE release-feed (zie monetization-plan);10 * deze v1 is bewust simpel en alleen voor Robins eigen VPS-instances.7 * The instance knows its "current" commit from .klonkt-version (written by the 8 * script) and the "latest" from the bare repo (KLONKT_GIT_DIR). For external 9 * self-hosters a SIGNED release feed will follow later (see monetization plan); 10 * this v1 is intentionally simple and only for Robin's own VPS instances. 11 11 */ 12 12 … … 37 37 } 38 38 39 // La atste 5 commits op main = de "laatste wijzigingen" die je bij bijwerken krijgt.39 // Last 5 commits on main = the "recent changes" you'll get when updating. 40 40 function recentChanges() { 41 41 const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', 'main']); … … 72 72 } 73 73 try { 74 // Detached + losgekoppeld: overleeft de pm2-reload die deze app herstart.74 // Detached + unlinked: survives the pm2-reload that restarts this app. 75 75 const child = spawn('bash', [UPDATE_SCRIPT, process.cwd()], { detached: true, stdio: 'ignore' }); 76 76 child.unref(); -
src/routes/admin-users.js
rbb42dfb r834bcc3 20 20 const router = express.Router(); 21 21 22 // 'kijker' = alleen-lezen demonstratie/audit-account: mag alles bekijken (incl.23 // Beheer), maar de globale guard blokkeert elke wijziging. Vervangt de oude24 // losse 'kijk-modus'-vlag (readonly), die nu door deze rol wordt afgedekt.22 // 'kijker' = read-only demo/audit account: may view everything (incl. admin panel), 23 // but the global guard blocks all mutations. Replaces the old separate 24 // 'kijk-modus' flag (readonly), which is now covered by this role. 25 25 const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']); 26 26 … … 69 69 } 70 70 71 // readonly=0: de alleen-lezen-status zit nu volledig in de 'kijker'-rol, dus72 // bij elke rolwijziging ruimen we de legacy-vlag op (geen dubbele bron).71 // readonly=0: read-only status now lives entirely in the 'kijker' role, so 72 // on every role change we clear the legacy flag (no dual source of truth). 73 73 db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?') 74 74 .run(newRole, userId); … … 89 89 } 90 90 91 // Cascade -verwijderen: de sites van deze user (+ posts/playlists/audio/leden/92 // comments daaronder), z'n eigen content elders, en daarna de user zelf.93 // Atomi sch in een transactie — faalt er een FK, dan rolt alles terug.91 // Cascade delete: this user's sites (+ posts/playlists/audio/members/ 92 // comments under them), their own content elsewhere, then the user themselves. 93 // Atomic in a transaction — if any FK fails, everything rolls back. 94 94 const del = db.transaction(() => { 95 95 const sites = db.prepare('SELECT id FROM sites WHERE owner_id = ?').all(userId).map((s) => s.id); … … 102 102 db.prepare('DELETE FROM sites WHERE id = ?').run(sid); 103 103 } 104 // Eigen content op andere sites + losse koppelingen.104 // Own content on other sites + loose associations. 105 105 db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)').run(userId); 106 106 db.prepare('DELETE FROM posts WHERE author_id = ?').run(userId); -
src/routes/admin.js
rbb42dfb r834bcc3 14 14 const router = express.Router(); 15 15 16 // Recent e posts van één site, CONCEPTEN BOVENAAN, met mode-bewuste edit/view-URLs.17 // Lost op dat drafts (status != published) nergens terug te vinden waren: de18 // t ijdlijn toont alleen gepubliceerdeposts.16 // Recent posts from one site, DRAFTS ON TOP, with mode-aware edit/view URLs. 17 // Solves the problem that drafts (status != published) were not findable anywhere: 18 // the timeline shows only published posts. 19 19 function sitePosts(siteId, siteSlug, tenancy, limit = 60) { 20 20 const base = tenancy === 'hub' ? `/user/${siteSlug}` : ''; … … 35 35 const user = req.session.user; 36 36 37 // Een kijker mag het volledige (god-)Beheer alleen-lezen inzien — net als god 38 // dus, alleen schrijven is globaal geblokkeerd. Een gewone artiest die een 39 // eigen site bezit krijgt een "Mijn Klonkt Hub"-dashboard, gescopet op z'n 40 // eigen site. Bezit 'ie geen site -> geen beheer. 37 // A kijker may view the full (god) admin panel read-only — same as god, 38 // but writing is globally blocked. A regular artist who owns a site gets 39 // a "My Klonkt Hub" dashboard, scoped to their own site. No site -> no admin. 41 40 if (user.role !== 'god' && user.role !== 'kijker') { 42 41 const mySite = db.prepare( … … 60 59 const tenancy = getTenancy(); 61 60 62 // De primaire/hoofd-site — in solo dé site, in hub de hoofdsite. Geeft de63 // " Uiterlijk"-tegel z'n edit-link + de posts/concepten-lijst.61 // The primary/main site — in solo THE site, in hub the main site. Provides the 62 // "Appearance" tile with its edit link + the posts/drafts list. 64 63 const primarySite = getPrimarySite(); 65 64 … … 73 72 }; 74 73 75 // Sites/users -tabellen zijn alleen in hub relevant; in solo besparen we de query.74 // Sites/users tables are only relevant in hub mode; in solo we skip the query. 76 75 const sites = tenancy === 'hub' ? db.prepare(` 77 76 SELECT s.slug, s.title, s.created_at, u.username AS owner_username … … 89 88 `).all() : []; 90 89 91 // Posts/ concepten van de primaire site (in solo = de site; in hub = de92 // hoofdsite van de admin). Concepten staan bovenaan zodat ze vindbaar zijn.90 // Posts/drafts of the primary site (in solo = the site; in hub = the admin's 91 // main site). Drafts are listed first so they are easy to find. 93 92 const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug, tenancy) : []; 94 93 … … 105 104 }); 106 105 107 // Handleiding — doorzoekbare uitleg van alle Beheer-functies. Zichtbaar voor wie108 // het Beheer mag zien (ingelogd); puur statische hulptekst, niets gevoeligs.106 // Handleiding — searchable explanation of all admin features. Visible to anyone 107 // who may view the admin panel (logged in); purely static help text, nothing sensitive. 109 108 router.get('/handleiding', requireAuth, (req, res) => { 110 109 renderPage(req, res, 'pages/admin-help', { -
src/routes/artists.js
rbb42dfb r834bcc3 1 1 /** 2 * Arti esten-directory — alleen in hub-modus.2 * Artists directory — hub mode only. 3 3 * 4 * GET /leden?q=&page= -> doorzoekbare, gepagineerde lijst van ALLE5 * Klonkt -site's. De hub-home toont maar een beperkte selectie; deze pagina6 * sc haalt naar honderden/duizenden artiesten via zoeken + paginering.4 * GET /leden?q=&page= -> searchable, paginated list of ALL 5 * Klonkt sites. The hub home shows only a limited selection; this page 6 * scales to hundreds/thousands of artists via search + pagination. 7 7 * 8 * In solo -modus bestaat er maar één site -> next() (valt door naarpostsRoutes,9 * die 'artiesten' als onbekende slug afhandelt).8 * In solo mode there is only one site -> next() (falls through to postsRoutes, 9 * which handles 'artiesten' as an unknown slug). 10 10 */ 11 11 … … 26 26 if (!Number.isFinite(page) || page < 1) page = 1; 27 27 28 // De hoofd-/labelsite (oudste) is geen artiest -> uit de directory weren,29 // consistent met de hub-home die 'm apart toont.28 // The main/label site (oldest) is not an artist -> exclude from the directory, 29 // consistent with the hub home which displays it separately. 30 30 const mainRow = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get(); 31 31 const mainId = mainRow ? mainRow.id : ''; 32 32 33 // Zoekterm tegen titel/slug/tagline (case-insensitive via LIKE; SQLite LIKE is34 // standaard ongevoelig voor ASCII-hoofdletters). De ESCAPE '\' maakt %, _ en\35 // in de zoekterm letterlijk (anders zouden ze als wildcards werken).33 // Search term against title/slug/tagline (case-insensitive via LIKE; SQLite LIKE is 34 // case-insensitive for ASCII by default). ESCAPE '\' makes %, _, and \ 35 // in the search term literal (otherwise they would act as wildcards). 36 36 const like = '%' + q.replace(/[\\%_]/g, (m) => '\\' + m) + '%'; 37 37 const conds = ['s.id != @mainId']; -
src/routes/audio.js
rbb42dfb r834bcc3 92 92 const range = req.headers.range; 93 93 94 // Statisti eken: tel één play bij de initiële player-fetch (niet bijscrub/95 // range -continuaties; replays binnen 24u komen uit de browsercache → geen96 // d ubbeltelling). Best-effort, mag nooit de stream breken.94 // Statistics: count one play on the initial player fetch (not on scrub/ 95 // range continuations; replays within 24h come from the browser cache → no 96 // double counting). Best-effort, must never break the stream. 97 97 if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) { 98 98 try { … … 141 141 }); 142 142 143 // W elke post bevat deze track? (voor de mini-speler → "spring naar de post +144 // scroll naar de track".) Pakt de nieuwste gepubliceerde post met[[track:<id>]].143 // Which post contains this track? (for the mini-player → "jump to the post + 144 // scroll to the track".) Fetches the newest published post with [[track:<id>]]. 145 145 router.get('/track/:id/post', (req, res) => { 146 146 const id = String(req.params.id || ''); -
src/routes/auth.js
rbb42dfb r834bcc3 10 10 import { premiumUnlocked } from '../services/PatreonService.js'; 11 11 12 // Fan -login (luisteraars inloggen met Google om te reageren) is een premium-13 // feature: beschikbaar als Google is ingesteld ÉN de premium-laag ontgrendeld is14 // (premium uit = vrij; aan = Patreon vereist).12 // Fan login (listeners signing in with Google to comment) is a premium feature: 13 // available when Google is configured AND the premium layer is unlocked 14 // (premium off = open to all; on = Patreon required). 15 15 function fanLoginReady() { 16 16 return googleConfigured() && premiumUnlocked(); … … 22 22 const router = express.Router(); 23 23 24 // Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de25 // user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie.24 // Fixed dummy hash: ensures login always runs one bcrypt comparison, even when the 25 // user doesn't exist or has no password — no timing oracle for enumeration. 26 26 const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10); 27 27 28 // Canoni eke basis-URL voor links in e-mails (reset). Uit headers bouwenis29 // spoof baar (X-Forwarded-Host); een vaste config sluit dat uit.28 // Canonical base URL for links in emails (reset). Building it from headers is 29 // spoofable (X-Forwarded-Host); a fixed config eliminates that risk. 30 30 function publicBaseUrl(req) { 31 31 const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, ''); 32 32 if (cfg) return cfg; 33 // Fallback (dev): trust-proxy- gesaneerde protocol + Host-header (NIET de rauwe33 // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw 34 34 // X-Forwarded-Host). 35 35 return `${req.protocol}://${req.get('host')}`; … … 40 40 } 41 41 42 // Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een43 // beheerder aanmaken. Daarna is registratie dicht (luisteraars komenvia Google).42 // First-time setup? Only while there are no users yet may /register create an 43 // admin account. Afterwards registration is closed (listeners come via Google). 44 44 function isSetupMode() { 45 45 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0; … … 47 47 48 48 // ==================== LOGIN ==================== 49 // Publieke loginpagina: voor BEZOEKERS alleen Google-login (luisteraars/fans). 50 // De beheerders-login (wachtwoord) staat hier bewust NIET — die zit verborgen op 51 // /auth/admin (zie hieronder), zodat de admin-login niet zichtbaar is op de plek 52 // waar bezoekers heen worden gestuurd. 49 // Public login page: for VISITORS only Google-login (listeners/fans). 50 // The admin login (password) is intentionally NOT here — it lives hidden at 51 // /auth/admin (see below), so the admin login is not visible where visitors land. 53 52 router.get('/login', (req, res) => { 54 53 const next = safeNext(req.query.next) || ''; … … 68 67 }); 69 68 70 // Verborgen beheerders-login (gebruikersnaam + wachtwoord). Nergens in de UI71 // gelinkt — de beheerder navigeert hier rechtstreeks naartoe(/auth/admin).69 // Hidden admin login (username + password). Not linked anywhere in the UI — 70 // the admin navigates here directly (/auth/admin). 72 71 router.get('/admin', (req, res) => { 73 72 const next = safeNext(req.query.next) || ''; … … 91 90 const next = safeNext(req.body.next) || ''; 92 91 93 // Foutweergave op de (verborgen) beheerders-loginpagina: toon het wachtwoord-94 // form ulier opnieuw (adminLogin:true), niet de Google-only publieke pagina.92 // Error display on the (hidden) admin login page: re-show the password 93 // form (adminLogin:true), not the Google-only public page. 95 94 const renderErr = (error, status = 400) => { 96 95 res.status(status); … … 105 104 106 105 const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username); 107 // Al tijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord108 // heeft) zodat de responstijd niets over het bestaan van een account verraadt.106 // Always one bcrypt comparison (dummy if the user has no usable password) 107 // so response time reveals nothing about whether the account exists. 109 108 const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth'); 110 109 const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH); … … 119 118 }); 120 119 121 // ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ====================120 // ==================== FIRST-TIME SETUP (create admin account) ==================== 122 121 router.get('/register', (req, res) => { 123 122 const next = safeNext(req.query.next) || ''; 124 123 if (req.session.user) return res.redirect(next || '/'); 125 // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt.124 // No public registration: only the very first admin may be created here. 126 125 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : '')); 127 126 renderPage(req, res, 'pages/auth-register', { … … 139 138 }); 140 139 141 // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via dezeroute.140 // Hard-closed once a user exists — prevents a second "admin" via this route. 142 141 if (!isSetupMode()) return res.redirect('/auth/login'); 143 142 … … 150 149 const userId = uuid(); 151 150 const hash = bcrypt.hashSync(password, 10); 152 // De allereerste gebruiker is de beheerder (god).151 // The very first user is the administrator (god). 153 152 db.prepare(` 154 153 INSERT INTO users (id, username, email, password_hash, role, theme, palette) … … 156 155 `).run(userId, username, email, hash); 157 156 158 // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgtlater).159 // Setup -wizard: sitenaam + taal komen uit het formulier; taal = de taal waarin160 // de bezoeker de wizard invulde (resolveLang) en wordt meteen de site-standaard.157 // Auto-create a personal site (single-tenant restructure follows later). 158 // Setup wizard: site name + language come from the form; language = the language 159 // the visitor used to fill in the wizard (resolveLang) and becomes the site default. 161 160 if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 162 161 const siteId = uuid(); … … 168 167 `).run(siteId, username.toLowerCase(), title, '', userId, lang); 169 168 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 170 try { setSetting('default_lang', lang); } catch (e) { /* n iet fataal */ }169 try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ } 171 170 } 172 171 … … 175 174 }); 176 175 177 // ==================== WACHTWOORD VERGETEN (aanvraag) ====================176 // ==================== FORGOT PASSWORD (request) ==================== 178 177 router.get('/reset-request', (req, res) => { 179 178 if (req.session.user) return res.redirect('/'); … … 191 190 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email); 192 191 if (user) { 193 const token = crypto.randomBytes(32).toString('hex'); // r uw: gaat alleen de mail/link in192 const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link 194 193 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min 195 // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op.194 // Store only the HASH: so DB read access yields no usable token. 196 195 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?') 197 196 .run(hashToken(token), expires, user.id); … … 211 210 } 212 211 } else if (process.env.NODE_ENV !== 'production') { 213 // Dev zonder SMTP: link in log + op de pagina tonen.212 // Dev without SMTP: show the link in the log + on the page. 214 213 console.log(`[password-reset] ${user.email} -> ${url}`); 215 214 devResetUrl = url; 216 215 } else { 217 // Producti e zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass.216 // Production without SMTP: NEVER log the token. Refer to the CLI break-glass. 218 217 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`); 219 218 } … … 221 220 } 222 221 223 // Anti-enumerati e: zelfde antwoord ongeacht of het adres bestaat.222 // Anti-enumeration: same response regardless of whether the address exists. 224 223 renderPage(req, res, 'pages/auth-reset-request', { 225 224 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', … … 228 227 }); 229 228 230 // ==================== WACHTWOORD RESETTEN (toepassen) ====================229 // ==================== RESET PASSWORD (apply) ==================== 231 230 router.get('/reset/:token', (req, res) => { 232 231 const row = db.prepare(` … … 266 265 }); 267 266 268 // ==================== GOOGLE -LOGIN (luisteraars/reageerders) ====================269 // Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer.267 // ==================== GOOGLE LOGIN (listeners/commenters) ==================== 268 // Per-instance, own Google client. ALWAYS grants role member — never admin. 270 269 router.get('/google', (req, res) => { 271 270 if (!fanLoginReady()) { … … 279 278 }); 280 279 281 // Google KOPPELEN aan het huidige (ingelogde) account — bv. een beheerder die282 // voortaan óók met Google wil inloggen. Vereist dat je al ingelogd bent (met283 // wachtwoord); de koppeling slaat de google_sub op het eigen account op.284 // Alleen googleConfigured() nodig (geen premium-gate — dit is geen fan-login).280 // LINK Google to the current (logged-in) account — e.g. an admin who also wants 281 // to log in with Google. Requires being already logged in (with password); the 282 // link stores the google_sub on their own account. 283 // Only googleConfigured() needed (no premium gate — this is not fan login). 285 284 router.get('/google/link', requireAuth, (req, res) => { 286 285 if (!googleConfigured()) { … … 289 288 const state = crypto.randomBytes(16).toString('hex'); 290 289 req.session.oauthState = state; 291 req.session.oauthLink = true; // koppel-modus i.p.v. login-modus290 req.session.oauthLink = true; // link mode instead of login mode 292 291 res.redirect(authorizeUrl(state)); 293 292 }); … … 320 319 const email = (info.email || '').trim().toLowerCase(); 321 320 322 // ── KOPPEL-MODUS: Google aan het huidige (ingelogde) account hangen──321 // ── LINK MODE: attach Google to the current (logged-in) account ── 323 322 if (linking) { 324 323 delete req.session.oauthState; delete req.session.oauthLink; … … 326 325 if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.'); 327 326 if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.'); 328 // Dit Google-account mag niet al aan een ANDER account hangen.327 // This Google account must not already be linked to a DIFFERENT account. 329 328 const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id); 330 329 if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.'); … … 336 335 } 337 336 338 // ── LOGIN -MODUS (luisteraars/fans + gekoppelde beheerder) ──337 // ── LOGIN MODE (listeners/fans + linked admin) ── 339 338 if (!fanLoginReady()) return failLogin('unavailable'); 340 339 const next = safeNext(req.session.oauthNext) || ''; … … 342 341 if (!email || info.email_verified === false) return failLogin('email'); 343 342 344 // Zoek EERST op de gekoppelde Google-account (google_sub). Een sub-match is het345 // expliciete koppel-bewijs → log in met de eigen rol, OOK als het Google-346 // mailadres afwijkt van het account-mailadres (bv. een beheerder die een ander347 // Gmail koppelt). Daarna pas op e-mail.343 // Look FIRST by linked Google account (google_sub). A sub-match is explicit 344 // proof of the link → log in with their own role, EVEN IF the Google email 345 // differs from the account email (e.g. an admin who linked a different Gmail). 346 // Only then fall back to email lookup. 348 347 let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null; 349 348 350 349 if (user) { 351 // Gekoppeld account gevonden → eigen rol behouden. Avatar bijwerken indien leeg.350 // Linked account found → keep their own role. Update avatar if empty. 352 351 db.prepare(` 353 352 UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? … … 356 355 user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email); 357 356 if (user && (user.role === 'god' || user.role === 'admin')) { 358 // Beheerder gevonden op e-mail maar ZONDER gekoppelde sub → Google geeft359 // nooit beheer. Eerst koppelen via Account → Inloggen metGoogle.357 // Admin found by email but WITHOUT a linked sub → Google never grants admin. 358 // Must first link via Account → Sign in with Google. 360 359 return failLogin('admin'); 361 360 } else if (user) { 362 // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken.361 // Existing listener: link google_sub/avatar if missing. 363 362 db.prepare(` 364 363 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?), … … 366 365 `).run(info.sub || null, info.picture || null, user.id); 367 366 } else { 368 // N ieuwe luisteraar — altijdmember.367 // New listener — always member. 369 368 const userId = uuid(); 370 369 const username = uniqueUsername(info.name || email.split('@')[0]); -
src/routes/changelog.js
rbb42dfb r834bcc3 1 1 /** 2 * Publi eke wijzigingen-/release-pagina.2 * Public changelog / release page. 3 3 * 4 * GET /changelog -> render t CHANGELOG.md (de bron van waarheid voor releases).4 * GET /changelog -> renders CHANGELOG.md (the source of truth for releases). 5 5 * 6 * De app-versie (footer, package.json) is bewust losgekoppeld van de7 * cir kel-federatie-proto (KLONKT_PROTO): een versie-bump is cosmetisch en raakt8 * de federatie niet. We tonen de proto hier expliciet zodat per release zichtbaar9 * is met welke federatie-versie deze instance praat (cirkels = lockstep per proto).6 * The app version (footer, package.json) is intentionally decoupled from the 7 * circle federation proto (KLONKT_PROTO): a version bump is cosmetic and does not 8 * affect federation. We show the proto here explicitly so that each release 9 * makes visible which federation version this instance speaks (circles = lockstep per proto). 10 10 */ 11 11 -
src/routes/circle.js
rbb42dfb r834bcc3 1 1 /** 2 * Cir kel-feed + lokale lees-pagina.3 * GET /cirkel -> over zicht (zelfde timeline/grid-view als de home)4 * GET /cirkel/:id -> losse remote-post in eigen chrome (blijf op jesite)5 * Alleen actief als tenancy === 'circle' (andersnext() -> postsRoutes/404).6 * Zie docs/cirkels-v1-spec.md §5c.2 * Circle feed + local reading page. 3 * GET /cirkel -> overview (same timeline/grid view as the home) 4 * GET /cirkel/:id -> individual remote post in own chrome (stay on your site) 5 * Only active when tenancy === 'circle' (otherwise next() -> postsRoutes/404). 6 * See docs/cirkels-v1-spec.md §5c. 7 7 */ 8 8 … … 25 25 } 26 26 27 // ── Over zicht────────────────────────────────────────────────27 // ── Overview ───────────────────────────────────────────────── 28 28 router.get('/cirkel', (req, res, next) => { 29 29 if (getTenancy() !== 'circle') return next(); … … 42 42 return { 43 43 id: r.id, 44 // Lo kale lees-pagina -> de kaart blijft op de eigen site (post-card linkt45 // lo kaal + htmx, GEENexternal_url).44 // Local reading page -> the card stays on the own site (post-card links 45 // locally + htmx, NO external_url). 46 46 slug: 'cirkel/' + encodeURIComponent(r.id), 47 47 title: r.title || '(zonder titel)', … … 58 58 }); 59 59 60 // Sites in de cirkel — alleen actieve links (outdated/error vallen weg, net als61 // hun posts). Voor de grafische header metavatars.60 // Sites in the circle — active links only (outdated/error ones are excluded, along 61 // with their posts). Used for the graphic header with avatars. 62 62 const sites = db.prepare(` 63 63 SELECT a.name, a.url, a.avatar … … 76 76 }); 77 77 78 // ── Losse remote-post (lokaal lezen) ─────────────────────────78 // ── Individual remote post (local reading) ──────────────────── 79 79 router.get('/cirkel/:id', (req, res, next) => { 80 80 if (getTenancy() !== 'circle') return next(); -
src/routes/comments.js
rbb42dfb r834bcc3 58 58 if (!parent) return res.status(400).send('Invalid parent comment'); 59 59 resolvedParent = parent.parent_comment_id || parent.id; 60 parentAuthorId = parent.author_id; // ontvanger van de "antwoord"-melding60 parentAuthorId = parent.author_id; // recipient of the "reply" notification 61 61 } 62 62 … … 77 77 `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status); 78 78 79 // Melding (alleen bij een zichtbare reactie): antwoord → de auteur van de reactie 80 // waarop gereageerd is; top-level reactie → de auteur van de post. notify() slaat 81 // jezelf-notificeren over. 79 // Notification (only for visible comments): reply → author of the parent comment; 80 // top-level comment → author of the post. notify() skips self-notifications. 82 81 if (status === 'approved') { 83 82 const url = `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`; -
src/routes/download.js
rbb42dfb r834bcc3 1 1 /** 2 * Download- voor-email (premium feature #2).2 * Download-for-email (premium feature #2). 3 3 * 4 * GET /downloads -> li jst van downloadbare tracks (premium; anders 404)5 * GET /download/:id -> e -mail-capture-pagina voor ééntrack6 * POST /download/:id -> e-mail opslaan (-> mailinglijst) + download vrijgeven7 * GET /download/:id/bestand -> serve ert het bestand (sessie-gated nacapture)4 * GET /downloads -> list of downloadable tracks (premium; 404 otherwise) 5 * GET /download/:id -> email capture page for a single track 6 * POST /download/:id -> save email (-> mailing list) + unlock download 7 * GET /download/:id/bestand -> serves the file (session-gated after capture) 8 8 * 9 * De fan laat z'n e-mail achter en krijgt het bestand; het adres komt in de10 * subscribers -lijst (source 'download', single opt-in — geen confirm-drempel vóór de9 * The fan leaves their email and receives the file; the address is added to the 10 * subscribers list (source 'download', single opt-in — no confirm step before the 11 11 * download). Hub: via /user/:slug/... (resolveSite + siteUrlBase). 12 12 */ … … 24 24 const router = express.Router(); 25 25 26 // Als er een echte (gepinde) post met slug 'downloads' bestaat, hangt de27 // downloads-lijst feitelijk aan die post. Dan tonen we óók de Newer/Older-postnav,28 // zodat de bezoeker net als bij een post verder kan bladeren.26 // If a real (pinned) post with slug 'downloads' exists, the downloads list is 27 // effectively attached to that post. We then also show the Newer/Older post nav 28 // so the visitor can browse just like on a regular post. 29 29 function downloadsPostNav(req, res) { 30 30 const site = res.locals.site; … … 40 40 41 41 const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' }; 42 const GRACE_MS = 15 * 60 * 1000; // download -venster nacapture42 const GRACE_MS = 15 * 60 * 1000; // download window after capture 43 43 44 44 function dlTrack(siteId, id) { … … 55 55 } 56 56 57 // Li jst van downloadbare tracks.57 // List of downloadable tracks. 58 58 router.get('/downloads', (req, res, next) => { 59 59 if (!premiumUnlocked()) return next(); … … 67 67 renderPage(req, res, 'pages/downloads', { 68 68 pageTitle: 'Downloads — ' + (site.title || ''), 69 // on-special = compact e profielkop (zoals op een post); on-downloads = pill grijs70 // + feature-route -gedrag. Samen → downloads ziet er net zo uit als eenpost.69 // on-special = compact profile header (like on a post); on-downloads = grey pill 70 // + feature-route behaviour. Together → downloads looks just like a post. 71 71 bodyClass: 'on-downloads on-special', 72 72 dlTracks: tracks, … … 76 76 }); 77 77 78 // Capture -pagina voor ééntrack.78 // Capture page for a single track. 79 79 router.get('/download/:id', (req, res, next) => { 80 80 if (!premiumUnlocked()) return next(); … … 93 93 }); 94 94 95 // E-mail opslaan + download vrijgeven.95 // Save email + unlock download. 96 96 router.post('/download/:id', (req, res, next) => { 97 97 if (!premiumUnlocked()) return next(); … … 109 109 }); 110 110 } 111 // Download vrijgeven in de sessie (kort venster).111 // Unlock download in the session (short window). 112 112 if (!req.session.dl) req.session.dl = {}; 113 113 req.session.dl[track.id] = Date.now(); … … 118 118 }); 119 119 120 // Het bestand serveren — alleen als er net een e-mail is achtergelaten (sessie).120 // Serve the file — only if an email was just submitted (session-gated). 121 121 router.get('/download/:id/bestand', (req, res, next) => { 122 122 if (!premiumUnlocked()) return next(); … … 129 129 return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.'); 130 130 } 131 // De speelbare/te-downloaden file = de KALE filename (storage_path is een132 // absolu ut pad → faalt de slash-guard). Zelfde aanpak als /audio/stream.131 // The playable/downloadable file = the BARE filename (storage_path is an 132 // absolute path → fails the slash-guard). Same approach as /audio/stream. 133 133 const sp = track.filename; 134 134 if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path'); -
src/routes/embed.js
rbb42dfb r834bcc3 1 1 /** 2 * Embed bare player (premium feature #7).2 * Embeddable player (premium feature #7). 3 3 * 4 * GET /embed -> een zelfstandige, compacte audiospeler-pagina (geenshell),5 * bedoeld om op EXTERNE sites in een <iframe> te zetten.4 * GET /embed -> a standalone, compact audio player page (no shell), 5 * intended to be placed in an <iframe> on EXTERNAL sites. 6 6 * 7 * De pagina wordt door ons (klonkt-origin) geserveerd, dus de audio-requests vanuit8 * het iframe blijven same-origin → de /audio/stream-gate laat ze door, ook al staat9 * het iframe op een vreemde site. We overrulen alleen Helmet's frameguard +10 * frame-ancestors zodat externe sites mógen inbedden. Hub: /user/:slug/embed.7 * The page is served by us (klonkt-origin), so audio requests from within 8 * the iframe remain same-origin → the /audio/stream gate lets them through, 9 * even when the iframe is on a foreign site. We only override Helmet's frameguard 10 * + frame-ancestors so that external sites are allowed to embed us. Hub: /user/:slug/embed. 11 11 */ 12 12 … … 21 21 if (!site) return next(); 22 22 23 // Inbedden op externe sites toestaan (overrule de globaleframeguard/CSP).23 // Allow embedding on external sites (override the global frameguard/CSP). 24 24 res.removeHeader('X-Frame-Options'); 25 25 res.setHeader( -
src/routes/epk.js
rbb42dfb r834bcc3 1 1 /** 2 * EPK / p erskit (premium) — een deelbare perspagina per Klonkt-site.2 * EPK / press kit (premium) — a shareable press page per Klonkt site. 3 3 * 4 * GET /pers (solo) o f/user/:slug/pers (hub, via resolveSite + siteUrlBase)5 * -> nette, openbare perskit: hero (foto/titel/tagline), korte bio, topnummers,6 * recent e posts en een contact-knop. Bedoeld om naar boekers/pers te sturen.4 * GET /pers (solo) or /user/:slug/pers (hub, via resolveSite + siteUrlBase) 5 * -> clean, public press kit: hero (photo/title/tagline), short bio, top tracks, 6 * recent posts and a contact button. Intended to share with bookers/press. 7 7 * 8 * Premium-gated: n iet-premium instances hebben GEEN /pers (next() -> 404 via de9 * catch-all). De PAGINA zelf is openbaar (geen login) zodat pers 'm kan bekijken;10 * alleen het BESTAAN ervan is premium. Geen login-e-mail lekken: contact loopt via11 * e en expliciet ingesteld pers-adres (epk_contact, per site) of anders de site zelf.8 * Premium-gated: non-premium instances have NO /pers (next() -> 404 via the 9 * catch-all). The PAGE itself is public (no login) so press can view it; 10 * only its EXISTENCE is premium. No login email leak: contact goes via an 11 * explicitly configured press address (epk_contact, per site) or the site itself. 12 12 */ 13 13 … … 21 21 22 22 router.get('/pers', (req, res, next) => { 23 if (!premiumUnlocked()) return next(); // geen premium -> geen perskit23 if (!premiumUnlocked()) return next(); // no premium -> no press kit 24 24 const site = res.locals.site; 25 25 if (!site) return next(); 26 26 27 // Nummers op de perskit: een door de admin GEKOZEN selectie (max 5, in eigen28 // volgorde) als die is ingesteld; anders automatisch de top 5 meest beluisterde.27 // Tracks on the press kit: an admin-CHOSEN selection (max 5, in custom order) 28 // if configured; otherwise automatically the top 5 most-listened. 29 29 let chosenIds = []; 30 30 try { 31 31 const raw = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); 32 32 if (Array.isArray(raw)) chosenIds = raw.filter((x) => typeof x === 'string').slice(0, 5); 33 } catch (e) { /* ongeldige JSON → val terug optop */ }33 } catch (e) { /* invalid JSON → fall back to top */ } 34 34 35 35 let tracks; … … 41 41 ).all(site.id, ...chosenIds); 42 42 const byId = new Map(rows.map((r) => [r.id, r])); 43 tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean); // behoud gekozen volgorde43 tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean); // preserve chosen order 44 44 } else { 45 45 tracks = db.prepare( … … 60 60 ).all(site.id); 61 61 62 // P ers-contact: per-site instelling (epk_contact_<siteId>) als die er is, anders63 // de globale epk_contact. NOOIT automatisch de login-mail tonen.62 // Press contact: per-site setting (epk_contact_<siteId>) if present, otherwise 63 // the global epk_contact. NEVER auto-expose the login email. 64 64 const contact = (getSetting('epk_contact_' + site.id, '') || getSetting('epk_contact', '') || '').trim(); 65 // Korte pers-bio: per-site instelling, anders de tagline van de site.65 // Short press bio: per-site setting, otherwise the site's tagline. 66 66 const bio = (getSetting('epk_bio_' + site.id, '') || site.tagline || '').trim(); 67 67 -
src/routes/federation.js
rbb42dfb r834bcc3 1 // routes/federation.js — publi eke Cirkels-endpoints (v1, publicatie-kant).1 // routes/federation.js — public Cirkels endpoints (v1, publication side). 2 2 // 3 // GET /.klonkt/actor.json — ActivityStreams -actor + Ed25519-pubkey4 // GET /.klonkt/outbox.json — publi eke posts als AS Create-objecten,5 // getekend via de Klonkt-Signature-header3 // GET /.klonkt/actor.json — ActivityStreams actor + Ed25519 public key 4 // GET /.klonkt/outbox.json — public posts as AS Create objects, 5 // signed via the Klonkt-Signature header 6 6 // 7 // Site-agnosti sch en zonder auth — alleen lezen. Zie docs/cirkels-v1-spec.md.7 // Site-agnostic and unauthenticated — read-only. See docs/cirkels-v1-spec.md. 8 8 9 9 import express from 'express'; … … 18 18 } 19 19 20 // De proto die de consument zegt te draaien (uit z'n request-header), of0.20 // The proto the consumer claims to be running (from their request header), or 0. 21 21 function consumerProto(req) { 22 22 return parseInt(req.get('Klonkt-Proto') || '0', 10) || 0; … … 24 24 25 25 router.get('/.klonkt/actor.json', (req, res) => { 26 // Cir kels = solo-naar-solo; hubs publiceren geen federatie-actor.26 // Circles = solo-to-solo; hubs do not publish a federation actor. 27 27 if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus'); 28 // De actor serveren we ALTIJD (ook aan oudere consumenten) zodat zij onze proto29 // kunnen lezen en een nette "update vereist"-melding kunnen tonen.28 // We ALWAYS serve the actor (including to older consumers) so they can read our 29 // proto and show a clean "update required" message. 30 30 const body = JSON.stringify(buildActor(baseUrl(req)), null, 2); 31 31 res.type('application/activity+json; charset=utf-8'); … … 38 38 if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus'); 39 39 res.set('Klonkt-Proto', String(KLONKT_PROTO)); 40 // Te-oude consument? Weiger met 426 Upgrade Required (de crypto-binding sluit 'm41 // sowieso al uit; dit geeft een expliciet, leesbaar signaal). proto 0 = geen42 // header (bv. een browser/curl) → toestaan, die verifieert toch niet.40 // Consumer too old? Reject with 426 Upgrade Required (the crypto binding already 41 // excludes them; this gives an explicit, readable signal). proto 0 = no header 42 // (e.g. a browser/curl) → allow, they won't verify anyway. 43 43 const cp = consumerProto(req); 44 44 if (cp && cp < MIN_PROTO) { -
src/routes/hub.js
rbb42dfb r834bcc3 1 1 /** 2 * Hub -hoofdpagina — alleen in hub-modus. In plaats van de primaire Klonkt-site te3 * tonen, rendert '/' hier een bedrijfs-overview: de laatste posts van ALLE4 * gebruikers samengevat + een lijst van de Klonkt-site's.2 * Hub home page — hub mode only. Instead of rendering the primary Klonkt site, 3 * '/' renders a company overview here: the latest posts from ALL users combined 4 * + a list of the Klonkt sites. 5 5 * 6 * In solo -modus doet dit niets (next()) en rendert posts.js de enige site.6 * In solo mode this does nothing (next()) and posts.js renders the single site. 7 7 */ 8 8 … … 16 16 router.get('/', (req, res, next) => { 17 17 if (getTenancy() !== 'hub') return next(); 18 // Als resolveSite een specifieke site adresseerde (/user/:slug of/sites/:slug),19 // is req.url naar '/' herschreven — dan NIET de overview tonen maar de site zelf20 // laten renderen door posts.js. siteUrlBase is dan gezet.18 // If resolveSite addressed a specific site (/user/:slug or /sites/:slug), 19 // req.url was rewritten to '/' — do NOT show the overview but let posts.js 20 // render the site itself. siteUrlBase is set in that case. 21 21 if (res.locals.siteUrlBase) return next(); 22 22 23 // La atste gepubliceerde posts over álle sites heen.23 // Latest published posts across all sites. 24 24 const posts = db.prepare(` 25 25 SELECT p.title, p.slug, p.excerpt, p.published_at, p.created_at, … … 35 35 `).all(); 36 36 37 // De hoofd-/labelsite (de expliciet primaire = de bedrijfs-/hoofdaccount) is38 // GEEN artiest; die tonen we apart bovenaan, niet in de Artiesten-roster.37 // The main/label site (the explicitly primary = the company/main account) is 38 // NOT an artist; we display it separately at the top, not in the Artists roster. 39 39 const mainSite = db.prepare(` 40 40 SELECT s.id, s.slug, s.title, s.tagline, s.profile_photo, s.accent, … … 48 48 const mainId = mainSite ? mainSite.id : ''; 49 49 50 // Uitgelichte Klonkt-site's voor de home-roster: meest-actief eerst (aantal51 // gepubliceerde posts), dan nieuwste. Excl. de hoofdsite. Beperkt tot52 // HOME_ROSTER_LIMIT zodat de home schaalt — volledige lijst staat op/leden.50 // Featured Klonkt sites for the home roster: most active first (number of 51 // published posts), then newest. Excl. the main site. Capped at 52 // HOME_ROSTER_LIMIT so the home scales — full list is at /leden. 53 53 const HOME_ROSTER_LIMIT = 24; 54 54 const artists = db.prepare(` … … 64 64 const totalArtists = db.prepare('SELECT COUNT(*) AS c FROM sites WHERE id != ?').get(mainId).c; 65 65 66 // De hub-pagina is GENERIEK (van geen enkele user) — branding komt uit globale67 // instellingen die de admin in Beheer beheert, niet uit eensite.66 // The hub page is GENERIC (not belonging to any user) — branding comes from global 67 // settings managed by the admin in the admin panel, not from a site. 68 68 const hub = { 69 69 title: getSetting('hub_title') || 'Overzicht', -
src/routes/lang.js
rbb42dfb r834bcc3 1 // Taalkeuze van de bezoeker: /lang/:code zet de interface-taal in de sessie en2 // stuurt terug naar waar je vandaan kwam. (Content blijft in de taal van de auteur.)1 // Visitor language choice: /lang/:code sets the interface language in the session 2 // and redirects back to where you came from. (Content stays in the author's language.) 3 3 import express from 'express'; 4 4 import { SUPPORTED } from '../services/i18n.js'; … … 10 10 const code = SUPPORTED.includes(req.params.code) ? req.params.code : 'nl'; 11 11 if (req.session) req.session.lang = code; 12 // Ingelogd? Bewaar de keuze ook op het account zodat 'ie meereist over13 // a pparaten/sessies (niet alleen deze sessie-cookie).12 // Logged in? Also save the choice on the account so it follows the user 13 // across devices/sessions (not just this session cookie). 14 14 if (req.session && req.session.user && req.session.user.id) { 15 15 try { 16 16 db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id); 17 17 req.session.user.lang = code; 18 } catch { /* lang -kolom ontbreekt op een oude DB → sessie-only, geen breuk*/ }18 } catch { /* lang column missing on an old DB → session-only, no breakage */ } 19 19 } 20 // Veilige terug-URL: alleen een intern pad (geenopen redirect).20 // Safe back URL: internal path only (no open redirect). 21 21 let back = (typeof req.query.r === 'string') ? req.query.r : ''; 22 22 if (!back.startsWith('/') || back.startsWith('//')) { -
src/routes/linkbio.js
rbb42dfb r834bcc3 1 1 /** 2 * Link-in-bio + klikstats (premium feature #6).2 * Link-in-bio + click stats (premium feature #6). 3 3 * 4 * GET /links -> Linktree- achtige pagina met de profile_links van de site5 * GET /links/go/:i -> telt de klik (per url) en stuurt door naar de externeURL4 * GET /links -> Linktree-style page with the site's profile_links 5 * GET /links/go/:i -> counts the click (per url) and redirects to the external URL 6 6 * 7 * Hergebruikt de bestaande sites.profile_links (JSON [{platform,url}]) + de8 * PLATFORMS -iconen/labels. Klikken landen in link_clicks (zie /admin/stats).9 * Open-redirect -veilig: /links/go/:i stuurt ALLEEN door naar een url die in de10 * eigen profile_links staat. Hub: via /user/:slug/links.7 * Reuses the existing sites.profile_links (JSON [{platform,url}]) + the 8 * PLATFORMS icons/labels. Clicks are stored in link_clicks (see /admin/stats). 9 * Open-redirect safe: /links/go/:i ONLY redirects to a url present in the 10 * site's own profile_links. Hub: via /user/:slug/links. 11 11 */ 12 12 … … 48 48 if (!link || !link.url) return next(); 49 49 const url = String(link.url); 50 // Alleen externe http(s)- of mailto-links (geenopen redirect / javascript:).50 // Only external http(s) or mailto links (no open redirect / javascript:). 51 51 if (!/^https?:\/\//i.test(url) && !/^mailto:/i.test(url)) return res.status(400).send('Bad link'); 52 52 try { … … 55 55 ON CONFLICT(site_id, url) DO UPDATE SET clicks = clicks + 1, updated_at = CURRENT_TIMESTAMP` 56 56 ).run(site.id, url); 57 } catch { /* telling mag de redirect nooit breken*/ }57 } catch { /* counting must never break the redirect */ } 58 58 res.redirect(302, url); 59 59 }); -
src/routes/newsletter.js
rbb42dfb r834bcc3 1 1 /** 2 * N ieuwsbrief — publieke kant(premium feature #1).2 * Newsletter — public side (premium feature #1). 3 3 * 4 * GET /nieuwsbrief -> aanmeldformulier (premium; anders 404)5 * POST /nieuwsbrief -> aanmelden (double opt-in als SMTP er is)6 * GET /nieuwsbrief/bevestigen/:token -> opt-in bevestigen7 * GET /nieuwsbrief/uitschrijven/:token -> u itschrijven (ALTIJD toegestaan)4 * GET /nieuwsbrief -> sign-up form (premium; 404 otherwise) 5 * POST /nieuwsbrief -> subscribe (double opt-in if SMTP configured) 6 * GET /nieuwsbrief/bevestigen/:token -> confirm opt-in 7 * GET /nieuwsbrief/uitschrijven/:token -> unsubscribe (ALWAYS allowed) 8 8 * 9 * In hub -modus loopt dit via /user/:slug/nieuwsbrief (resolveSite zetsiteUrlBase).10 * Confirm -/unsub-links in de mail zijn absoluut(PUBLIC_BASE_URL + siteUrlBase).9 * In hub mode this runs via /user/:slug/nieuwsbrief (resolveSite sets siteUrlBase). 10 * Confirm/unsub links in the mail are absolute (PUBLIC_BASE_URL + siteUrlBase). 11 11 */ 12 12 … … 53 53 54 54 if (r.status === 'pending') { 55 // Double opt-in: s tuur de bevestigingsmail.55 // Double opt-in: send the confirmation email. 56 56 const link = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/bevestigen/' + r.token); 57 57 const unsub = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/uitschrijven/' + r.token); … … 81 81 }); 82 82 83 // U itschrijven mag altijd (ook als de premium-laag later uit zou gaan): een abonnee84 // moet zich altijd kunnen afmelden. Niet premium-gated.83 // Unsubscribe is always allowed (even if the premium layer is later disabled): a 84 // subscriber must always be able to opt out. Not premium-gated. 85 85 router.get('/nieuwsbrief/uitschrijven/:token', (req, res) => { 86 86 const ok = unsubscribe(req.params.token); -
src/routes/notifications.js
rbb42dfb r834bcc3 1 1 /** 2 * GET /notifications — meldingenpagina voor de ingelogde gebruiker.3 * Open en = alles als gelezen markeren (de teller in de header valt dan weg).2 * GET /notifications — notifications page for the logged-in user. 3 * Opening it marks everything as read (the counter in the header disappears). 4 4 */ 5 5 import express from 'express'; -
src/routes/posts.js
rbb42dfb r834bcc3 49 49 }); 50 50 51 // Maakt een unieke slug binnen de site: 'titel', 'titel-2', 'titel-3', …52 // Zo wordt een tweede post met dezelfde titel NIET geweigerd ("bestaat al"),53 // maar krijgt 'ie automatisch een vrij achtervoegsel. exceptId = de post die54 // we bijwerken (mag z'n eigen slug houden).51 // Generates a unique slug within the site: 'title', 'title-2', 'title-3', … 52 // A second post with the same title is NOT rejected ("already exists"), 53 // but automatically gets a free suffix. exceptId = the post being updated 54 // (allowed to keep its own slug). 55 55 function uniqueSlug(siteId, base, exceptId = null) { 56 56 let candidate = base; … … 190 190 if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`; 191 191 192 // Du bbele titel/slug? Automatisch uniek maken (titel-2, titel-3, …) i.p.v. weigeren.192 // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting. 193 193 finalSlug = uniqueSlug(site.id, finalSlug); 194 194 … … 199 199 let finalStatus = status || 'draft'; 200 200 let publishedAt = finalStatus === 'published' ? now : null; 201 // Release -planning: gepubliceerd + een toekomstige publish_at -> 'scheduled'202 // ( de Scheduler zet 'm live op het moment zelf). Verleden/leeg -> meteen live.201 // Release planning: published + a future publish_at -> 'scheduled' 202 // (the Scheduler makes it live at that moment). Past/empty -> live immediately. 203 203 let publishAt = null; 204 204 const pa = Date.parse(req.body.publish_at || ''); … … 297 297 const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); 298 298 const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned; 299 // Du bbele slug? Automatisch uniek maken i.p.v. weigeren (eigen post mag z'n slug houden).299 // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug). 300 300 finalSlug = uniqueSlug(site.id, safe, post.id); 301 301 } … … 310 310 } 311 311 312 // Release -planning: gepubliceerd + toekomstige publish_at -> 'scheduled'.312 // Release planning: published + future publish_at -> 'scheduled'. 313 313 let publishAt = null; 314 314 const pa = Date.parse(req.body.publish_at || ''); … … 412 412 }); 413 413 414 // Pa d naar de like-knop-partial (voor de htmx-toggle re-render).414 // Path to the like button partial (for the htmx toggle re-render). 415 415 const LIKE_PARTIAL = path.join(__dirname, '..', 'views', 'partials', 'like-button.ejs'); 416 416 417 // ==================== LIKE / FAVORIET ==================== 418 // Een ingelogde gebruiker (geen kijker — de globale guard blokkeert non-GET voor 419 // kijkers) togglet een like op een gepubliceerde post. Geeft de her-gerenderde 420 // knop terug (htmx outerHTML-swap). 417 // ==================== LIKE / FAVOURITE ==================== 418 // A logged-in user (not a viewer — the global guard blocks non-GET for viewers) 419 // toggles a like on a published post. Returns the re-rendered button (htmx outerHTML swap). 421 420 router.post('/posts/:id/like', requireAuth, (req, res) => { 422 421 const userId = req.session.user.id; … … 430 429 } else { 431 430 db.prepare('INSERT OR IGNORE INTO post_likes (post_id, user_id) VALUES (?, ?)').run(post.id, userId); 432 // Melding voor de post-auteur (notify slaat jezelf-liken over).431 // Notification for the post author (notify skips self-likes). 433 432 notify({ 434 433 userId: post.author_id, actorId: userId, actorName: req.session.user.username, type: 'like', … … 444 443 }); 445 444 446 // Favo rieten = de posts die de ingelogde gebruiker likete. Solo: binnen de447 // huidige site. Hub: over alle sites (met juiste /user/<slug>-links).445 // Favourites = posts the logged-in user has liked. Solo: within the current 446 // site. Hub: across all sites (with correct /user/<slug> links). 448 447 router.get('/favorieten', requireAuth, (req, res) => { 449 448 const userId = req.session.user.id; … … 469 468 }); 470 469 471 // Newer/Older -buren over ALLE posts in feed-volgorde. Gedeeld door de volledige472 // post -render én de fan-gate (premium fan_only), zodat de navigatie overal gelijk473 // is. Solo: binnen de site (pinned eerst, dan datum). Hub: globaal op datum.470 // Newer/Older neighbours across ALL posts in feed order. Shared by the full 471 // post render and the fan gate (premium fan_only) so navigation is consistent 472 // everywhere. Solo: within the site (pinned first, then date). Hub: globally by date. 474 473 function postNeighbors(site, post, isHub) { 475 474 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : ''; … … 507 506 `).get(site.id, req.params.slug); 508 507 509 if (!post) return next(); // onbekende slug -> nette404 catch-all508 if (!post) return next(); // unknown slug -> clean 404 catch-all 510 509 511 510 // Permission to view: published OR (logged in + can edit) … … 515 514 } 516 515 517 // Fan-only preview (premium #3): volledige inhoud alleen voor ingelogdefans.518 // Anon ieme bezoekers krijgen een nette login-gate i.p.v. de inhoud (de titel/519 // teaser ma g elders wel als lokkertje verschijnen).516 // Fan-only preview (premium #3): full content only for logged-in fans. 517 // Anonymous visitors get a clean login gate instead of the content (the title/ 518 // teaser may still appear elsewhere as a teaser). 520 519 if (post.fan_only && !(req.session && req.session.user)) { 521 // Zelfde Newer/Older-navigatie als op een gewone post, zodat de bezoeker op522 // de fan-gate niet vastloopt maar verder kan bladeren.520 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get 521 // stuck on the fan gate but can keep browsing. 523 522 const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub'); 524 523 return renderPage(req, res, 'pages/fan-gate', { … … 532 531 } 533 532 534 // Statisti eken: tel de weergave (skipt beheerders + niet-gepubliceerd-eigen-preview).533 // Statistics: count the view (skips admins + unpublished own-preview). 535 534 if (post.status === 'published') recordPostView(post, req); 536 535 … … 588 587 const byAlbum = new Map(); 589 588 for (const r of albumRows) { 590 // Link-only tracks ( geen bestand) blijven in het album-overzicht(url '').589 // Link-only tracks (no file) remain in the album overview (url ''). 591 590 if (!byAlbum.has(r.album)) byAlbum.set(r.album, []); 592 591 byAlbum.get(r.album).push({ … … 625 624 } 626 625 } else { 627 // LITE -modus (KLONKT_AUDIO=off): geen eigen audio (geen ffmpeg/stream-route).628 // Extern e embeds (YouTube/SoundCloud/Spotify) blijven wel; de eigen-audio-629 // shortcodes ([[track]]/[[album]]/[[playlist]]) verwijderen we netjes.626 // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route). 627 // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio 628 // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped. 630 629 html = AudioEmbedService.autoembed(html); 631 630 html = AudioEmbedService.embedMediaShortcodes(html); … … 665 664 // Prev / next chronological (kept for back-compat — "post-nav" feature 666 665 // below the article still uses these as a simple linear navigation). 667 // Hub -modus: Gerelateerde posts + Newer/Older trekken uit ALLE users (alle668 // sites), nieuwste->oudste. Solo-modus: binnen de huidige site (oud gedrag).666 // Hub mode: Related posts + Newer/Older pull from ALL users (all sites), 667 // newest first. Solo mode: within the current site (old behaviour). 669 668 const isHub = res.locals.tenancy === 'hub'; 670 // Per-post URL -basis: in hub wijst een link naar/user/<site-slug>/<post-slug>.669 // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>. 671 670 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : ''; 672 671 673 // Newer/Older over ALLE posts (gedeelde helper — ook door de fan-gate gebruikt).672 // Newer/Older across ALL posts (shared helper — also used by the fan gate). 674 673 const { newerPost, olderPost } = postNeighbors(site, post, isHub); 675 674 … … 727 726 relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) })); 728 727 729 // Likes / favo rieten: aantal + of de ingelogde gebruiker deze post likete.728 // Likes / favourites: count + whether the logged-in user liked this post. 730 729 const likeCount = db.prepare('SELECT COUNT(*) AS c FROM post_likes WHERE post_id = ?').get(post.id).c; 731 730 const likedByMe = !!(req.session?.user && -
src/routes/search.js
rbb42dfb r834bcc3 1 1 /** 2 * GET /search?q=... -> volledige resultatenpagina3 * GET /search/suggest?q=... -> compact e JSON voor live resultaten in de overlay2 * GET /search?q=... -> full results page 3 * GET /search/suggest?q=... -> compact JSON for live results in the overlay 4 4 * 5 * Doorzoekt de huidige site op:5 * Searches the current site across: 6 6 * 1. Posts via posts_fts (FTS5, prefix-matching) — published only. 7 * 2. Nummers (audio_tracks) op titel / artiest / album.8 * 3. Even ementen (shows) op plaats / locatie / land / notitie — als de agenda aan staat.9 * 4. Pag ina's (Agenda / Downloads / Links / Perskit / Archief) op naam — alleen10 * de beschikbare.7 * 2. Tracks (audio_tracks) on title / artist / album. 8 * 3. Events (shows) on city / venue / country / notes — when the agenda is enabled. 9 * 4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only 10 * the available ones. 11 11 * 12 * FTS5: user -input wordt getokeniseerd op niet-letter/cijfer en elk token tussen13 * dubbele quotes + `*` gezet → prefix-match, geenoperator-soup/syntax-errors.12 * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped 13 * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors. 14 14 */ 15 15 … … 47 47 } 48 48 49 // ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep50 // ( klein voor de live-suggesties, ruim voor de volle pagina).──────────────49 // ── Core: search all sources for one site. `lim` caps results per group 50 // (small for live suggestions, large for the full page). ────────────────── 51 51 function searchSite(req, res, rawQ, lim) { 52 52 const site = res.locals.site; … … 79 79 } 80 80 81 // 2. Nummers81 // 2. Tracks 82 82 try { 83 83 const trackRows = db.prepare(` … … 108 108 } catch (err) { if (!out.queryError) out.queryError = err.message; } 109 109 110 // 3. Even ementen (agenda) — alleen als de agenda publiek aan staat.110 // 3. Events (agenda) — only when the agenda is publicly enabled. 111 111 if (premiumUnlocked() && getSetting('agenda_enabled') === '1') { 112 112 try { … … 125 125 } 126 126 127 // 4. Pag ina's — curated, alleen de beschikbare; match op de (vertaalde) naam.127 // 4. Pages — curated, available ones only; matched against the (translated) name. 128 128 const ql = rawQ.toLowerCase(); 129 129 const candidates = [ … … 143 143 } 144 144 145 // ── Volledige resultatenpagina───────────────────────────────────────────────145 // ── Full results page ──────────────────────────────────────────────────────── 146 146 router.get('/', (req, res) => { 147 147 const site = res.locals.site; … … 165 165 }); 166 166 167 // ── Live suggesti es (JSON) ───────────────────────────────────────────────────167 // ── Live suggestions (JSON) ────────────────────────────────────────────────── 168 168 router.get('/suggest', (req, res) => { 169 169 const site = res.locals.site; -
src/routes/shows.js
rbb42dfb r834bcc3 1 1 /** 2 * Show -agenda + notify-me (premium feature #8) — publieke kant.2 * Show agenda + notify-me (premium feature #8) — public side. 3 3 * 4 * GET /shows -> komende optredens + "houd me op de hoogte"-formulier5 * POST /shows/notify -> aanmelden voor show-aankondigingen(subscribers, source6 * 'notify'; double opt-in als SMTP er is)4 * GET /shows -> upcoming gigs + "keep me posted" form 5 * POST /shows/notify -> subscribe to show announcements (subscribers, source 6 * 'notify'; double opt-in if SMTP configured) 7 7 * 8 * De notify-bevestiging/uitschrijving hergebruikt de generieke subscriber-links8 * The notify confirm/unsubscribe reuses the generic subscriber links 9 9 * (/nieuwsbrief/bevestigen|uitschrijven/:token). Hub: /user/:slug/shows. 10 10 */ … … 20 20 const router = express.Router(); 21 21 22 // Agenda is opt-in: pas bereikbaar als de beheerder 'm heeft ingeschakeld.22 // Agenda is opt-in: only accessible once the admin has enabled it. 23 23 function agendaOn() { return getSetting('agenda_enabled') === '1'; } 24 24 -
src/server.js
rbb42dfb r834bcc3 2 2 * Klonkt Beta — server bootstrap 3 3 * 4 * Perso onlijkmulti-site platform — Node + SQLite + htmx.4 * Personal multi-site platform — Node + SQLite + htmx. 5 5 * Stack: Express + better-sqlite3 + EJS + htmx + ws. 6 6 */ … … 87 87 "'self'", 88 88 "'unsafe-inline'", 89 // Eigen custom-embeds (embed-player.js) laden de OFFICIELE player-API's90 // van deze hosts. Zonder deze whitelist blokkeert de CSP ze stil (alleen91 // een console-fout) en faalt de embed-speler.89 // Our custom embeds (embed-player.js) load the OFFICIAL player APIs 90 // from these hosts. Without this whitelist the CSP silently blocks them 91 // (only a console error) and the embed player fails. 92 92 "https://www.youtube.com", // YouTube IFrame Player API (+ www-widgetapi.js) 93 "https://s.ytimg.com", // YouTube player -assets93 "https://s.ytimg.com", // YouTube player assets 94 94 "https://w.soundcloud.com", // SoundCloud Widget API (api.js) 95 95 "https://open.spotify.com", // Spotify iFrame API (loader) 96 "https://*.spotifycdn.com", // Spotify iFrame API ( echtebundle: embed-cdn.spotifycdn.com)96 "https://*.spotifycdn.com", // Spotify iFrame API (real bundle: embed-cdn.spotifycdn.com) 97 97 ], 98 // Helmet's default zet script-src-attr op 'none', wat ALLE inline event-99 // handlers (onchange/onclick/onsubmit) blokkeert — daardoor deed o.a. de100 // avatar-upload (<input onchange="this.form.submit()">) en de rol-dropdown101 // niets. We staan inline handlers expliciet toe, consistent met de al102 // t oegestane inline <script> hierboven.98 // Helmet's default sets script-src-attr to 'none', which blocks ALL inline 99 // event handlers (onchange/onclick/onsubmit) — causing e.g. the avatar 100 // upload (<input onchange="this.form.submit()">) and the role dropdown to 101 // silently do nothing. We explicitly allow inline handlers, consistent with 102 // the already-allowed inline <script> above. 103 103 scriptSrcAttr: ["'unsafe-inline'"], 104 104 styleSrc: ["'self'", "'unsafe-inline'"], 105 // blob: nodig voor de afbeeldings-editor (Cropper) — die toont de gekozen106 // foto via een URL.createObjectURL(blob:…). Zonder blob: blokkeert de CSP107 // de <img> stil → leeg bewerk-venster. (media-src heeft blob: al voor audio.)105 // blob: required for the image editor (Cropper) — it displays the chosen 106 // photo via URL.createObjectURL(blob:…). Without blob: the CSP silently 107 // blocks the <img> → empty edit window. (media-src already has blob: for audio.) 108 108 imgSrc: ["'self'", "data:", "https:", "blob:"], 109 109 connectSrc: ["'self'", "wss:", "ws:", "https://*.spotifycdn.com", "https://*.scdn.co"], … … 121 121 "https://embed.music.apple.com", 122 122 "https://www.youtube-nocookie.com", 123 "https://www.youtube.com", // YouTube IFrame API maakt soms een www.youtube.com-iframe123 "https://www.youtube.com", // YouTube IFrame API sometimes creates a www.youtube.com iframe 124 124 "https://player.vimeo.com", 125 125 ], … … 143 143 if (!isDev) app.set('trust proxy', 1); 144 144 145 // Schema aanmaken/bijwerken VÓÓR iets de DB aanraakt: de session-store doet 146 // bij constructie al een query op de `sessions`-tabel, dus bij een verse 147 // install moeten de tabellen eerst bestaan (anders: "no such table: sessions" 148 // → crash-loop op de allereerste boot). 145 // Create/migrate the schema BEFORE anything touches the DB: the session store 146 // queries the `sessions` table on construction, so on a fresh install the tables 147 // must exist first (otherwise: "no such table: sessions" → crash loop on first boot). 149 148 initializeDatabase(); 150 startScheduler(); // release -planning: zet geplande posts live zodra publish_at bereikt is151 152 // Vangnet: garandeer dat er altijd een primaire site is(solo/hub/circle).153 // Idempotent — doe t niets als er al een site is of nog geen beheerder.149 startScheduler(); // release planning: publish scheduled posts when publish_at is reached 150 151 // Safety net: guarantee that there is always a primary site (solo/hub/circle). 152 // Idempotent — does nothing if a site already exists or there is no admin yet. 154 153 ensurePrimarySite(); 155 154 … … 173 172 app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' })); 174 173 app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', { 175 // Publi eke media (post-covers, avatars) moet door andere Klonkt-sites in hun176 // CIRKEL cross-origin embedbaar zijn. Helmet zet standaard CORP=same-origin,177 // wat die afbeeldingen in de browser blokkeert (bestand komt wél binnen, maar178 // de browser weigert 'm te tonen). Voor /media dus expliciet cross-origin.174 // Public media (post covers, avatars) must be cross-origin embeddable by other 175 // Klonkt sites in their CIRCLE. Helmet sets CORP=same-origin by default, which 176 // causes the browser to block those images (the file arrives, but the browser 177 // refuses to render it). Set cross-origin explicitly for /media. 179 178 setHeaders: (res) => res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'), 180 179 })); 181 180 182 // ( Verwijderd) TWA / digital-asset-links — alleen nodig voor de APK/TWA-variant.183 // Klonkt is PWA-only; geen assetlinks.json meer.184 185 // Cir kels: periodieke achtergrond-sync van remote instances (no-op tenzijtenancy='circle').181 // (Removed) TWA / digital-asset-links — only needed for the APK/TWA variant. 182 // Klonkt is PWA-only; assetlinks.json is no longer served. 183 184 // Circles: periodic background sync of remote instances (no-op unless tenancy='circle'). 186 185 startCircleSyncLoop(); 187 186 … … 203 202 })(); 204 203 205 // Cir kels-federatie: publieke, site-agnostischeendpoints (/.klonkt/*).206 // Vóór resolveSite/theme — ze hebben geen site-context nodig.204 // Circle federation: public, site-agnostic endpoints (/.klonkt/*). 205 // Before resolveSite/theme — they don't need a site context. 207 206 app.use(federationRoutes); 208 207 … … 211 210 app.use(loadTheme); 212 211 213 // Li chtgewicht CSRF-defense: weiger cross-origin state-wijzigenderequests.214 // Same-origin forms + HTMX s turen een matchende Origin; ontbreekt Origin dan215 // laten we door (non-browser clients). sameSite:'lax' op de sessiecookie is de216 // tweede laag. (Geldt niet voorGET/HEAD/OPTIONS.)212 // Lightweight CSRF defense: reject cross-origin state-mutating requests. 213 // Same-origin forms + HTMX send a matching Origin; missing Origin is allowed 214 // through (non-browser clients). sameSite:'lax' on the session cookie is the 215 // second layer. (Does not apply to GET/HEAD/OPTIONS.) 217 216 app.use((req, res, next) => { 218 217 if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next(); 219 218 const origin = req.get('origin'); 220 if (!origin) return next(); // geen Origin -> geen browser-CSRF-vector219 if (!origin) return next(); // no Origin → no browser CSRF vector 221 220 let originHost; 222 221 try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); } … … 225 224 }); 226 225 227 // Kijker-accounts: alles bekijken mag (incl. Beheer), niets wijzigen. Dit is de228 // ENIGE schrijf-blokkade — fail-closed, vóór alle route-handlers. Elke state-229 // wijzigende methode wordt geweigerd (de login-POST zet de sessie pas ná deze230 // guard, dus die valt er niet onder). I.p.v. rauwe 403-tekst tonen we een nette231 // pagina (of, bij HTMX, een ingeswapte melding).226 // Viewer accounts: may view everything (including Admin), change nothing. This is 227 // the ONLY write gate — fail-closed, before all route handlers. Every state-mutating 228 // method is rejected (the login POST sets the session after this guard, so it is 229 // not affected). Instead of raw 403 text we render a clean page (or, for HTMX, 230 // a swapped-in message). 232 231 app.use((req, res, next) => { 233 232 const mutating = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS'; 234 233 if (mutating && isViewer(req.session?.user)) { 235 234 if (req.headers['hx-request'] === 'true') { 236 // htmx swapt niet op 4xx; stuur 200 + retarget zodat de melding in #pcms-main verschijnt.235 // htmx doesn't swap on 4xx; send 200 + retarget so the message appears in #pcms-main. 237 236 res.setHeader('HX-Retarget', '#pcms-main'); 238 237 res.setHeader('HX-Reswap', 'innerHTML'); … … 277 276 // Feed/sitemap routes are mounted at root because they're at well-known paths 278 277 app.use('/', feedRoutes); 279 app.use('/leden', artistsRoutes); // doorzoekbare leden-directory (alleen hub; solo: next())278 app.use('/leden', artistsRoutes); // searchable member directory (hub only; solo: next()) 280 279 app.get('/artiesten', (req, res) => res.redirect(301, req.originalUrl.replace(/^\/artiesten/, '/leden'))); // oude URL -> /leden 281 280 app.use('/', hubRoutes); // hub-overview op '/' (solo: next() -> postsRoutes) … … 306 305 307 306 // A stable identity per site so installs don't collide (Chromium uses `id`). 308 // NB: een id-wissel orphant bestaande PWA-installs (er is geen migratie die een309 // install over een id-verandering heen tilt) — wie een site al als PWA had,310 // moet 'm één keer opnieuw installeren. Data blijft server-side, dus niets kwijt.307 // NB: changing the id orphans existing PWA installs (no migration carries an 308 // install across an id change) — anyone who already installed the site as a 309 // PWA will need to reinstall once. Data stays server-side, so nothing is lost. 311 310 const idBase = site?.slug ? `klonkt-${site.slug}` : 'klonkt'; 312 311 … … 376 375 self.clients.claim(); 377 376 }); 378 // ALLEEN navigaties (HTML-pagina's) onderscheppen, voor een offline-fallback.379 // Afbeeldingen, CSS, JS en /media NIET aanraken — laat de browser die native380 // afhandelen. Anders kon een mislukte netwerk-fetch terugvallen op een lege381 // cache-match (undefined) en zo een afbeelding "kapot" maken bij een gewone382 // refresh (hard reload omzeilt de SW en werkte daarom wél).377 // ONLY intercept navigations (HTML pages) for an offline fallback. 378 // Do NOT touch images, CSS, JS or /media — let the browser handle those natively. 379 // Otherwise a failed network fetch could fall back to an empty cache match 380 // (undefined) and "break" an image on a normal refresh (hard reload bypasses 381 // the SW, which is why that case worked fine). 383 382 self.addEventListener('fetch', e => { 384 383 if (e.request.method !== 'GET') return; 385 if (e.request.mode !== 'navigate') return; // alleen page-loads384 if (e.request.mode !== 'navigate') return; // page loads only 386 385 e.respondWith( 387 386 fetch(e.request).catch(() => caches.match('/').then(r => r || Response.error())) … … 404 403 app.use((req, res) => { 405 404 res.status(404); 406 // Nette, mobielvriendelijke 404 via de shell (viewport + nav + huisstijl).407 // Valt terug op kale HTML als het renderen onverhoopt faalt.405 // Clean, mobile-friendly 404 via the shell (viewport + nav + site theme). 406 // Falls back to bare HTML if rendering unexpectedly fails. 408 407 try { 409 408 return renderPage(req, res, 'pages/404', { -
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 ( -
src/views/pages/account.ejs
rbb42dfb r834bcc3 90 90 </section> 91 91 92 <%# ── PERSO ONLIJKE TAAL ───────────────────────────────────── %>92 <%# ── PERSONAL LANGUAGE ──────────────────────────────────── %> 93 93 <% if (canMutate) { %> 94 94 <section class="ax-card"> … … 110 110 <% } %> 111 111 112 <%# ── SITE ( eigenaar mag de naam van z'n site aanpassen)── %>112 <%# ── SITE (owner may update their site name) ─────────── %> 113 113 <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %> 114 114 <section class="ax-card"> … … 130 130 <% } %> 131 131 132 <%# ── WACHTWOORD────────────────────────────────────────── %>133 <%# In kijker-modus geen wachtwoord-sectie (niets te wijzigen). %>132 <%# ── PASSWORD ──────────────────────────────────────────── %> 133 <%# In viewer mode there is no password section (nothing to change). %> 134 134 <% if (canMutate) { %> 135 135 <% if (hasPassword) { %> -
src/views/pages/admin-circle.ejs
rbb42dfb r834bcc3 79 79 <span class="circ-badge">⏸ <%= _cPaused %></span> 80 80 <% } else { %> 81 <%# Onbekende status: toon 'm rauw + eventuele reden i.p.v. stil "gepauzeerd". %>81 <%# Unknown status: show it raw + any reason instead of silently showing "paused". %> 82 82 <span class="circ-badge"><%= l.status %></span> 83 83 <% if (l.last_error) { %><div class="circ-reason"><%= l.last_error %></div><% } %> … … 123 123 .circ-status { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem .5rem; margin-top: .25rem; font-size: .78rem; color: var(--ink-muted); } 124 124 .circ-meta { color: var(--ink-muted); } 125 /* Re den op een eigen regel onder de badge; normale woordafbreking (geenbreak-all126 die hele zinnen midden in woorden knipt — dat zit op de URL-small, niet hier). */125 /* Reason on its own line below the badge; normal word breaking (no break-all 126 that cuts whole sentences mid-word — that is applied to the URL small, not here). */ 127 127 .circ-reason { flex-basis: 100%; line-height: 1.4; color: var(--ink-muted); word-break: normal; overflow-wrap: anywhere; } 128 128 .btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; } -
src/views/pages/admin-google.ejs
rbb42dfb r834bcc3 103 103 .set-redirect-warn { margin: 0.85rem 0 0; font-size: 0.85rem; color: var(--ink-muted); } 104 104 .set-redirect-warn code { background: var(--paper-2); padding: 0.05rem 0.35rem; border-radius: 4px; } 105 /* Genummerde, altijd-zichtbare stap-voor-stap-uitleg (beginnervriendelijk) */105 /* Numbered, always-visible step-by-step instructions (beginner-friendly) */ 106 106 .gg-steps { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-direction: column; gap: 0.85rem; } 107 107 .gg-steps > li { display: flex; gap: 0.7rem; align-items: flex-start; } -
src/views/pages/admin-seo.ejs
rbb42dfb r834bcc3 110 110 111 111 .site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; } 112 /* <fieldset> krimpt door de UA min-inline-size:min-content niet mee → mobiel-overflow.113 min-width:0 dwingt meekrimpen. */112 /* <fieldset> does not shrink because the UA sets min-inline-size:min-content → mobile overflow. 113 min-width:0 forces it to shrink. */ 114 114 .site-form fieldset { min-width: 0; } 115 115 .site-form input, .site-form textarea, .site-form select { min-width: 0; } -
src/views/pages/admin-settings.ejs
rbb42dfb r834bcc3 40 40 </section> 41 41 42 <%# ── Standaardtaal voor bezoekers ── %>42 <%# ── Default language for visitors ── %> 43 43 <section class="set-card" style="margin-top:1rem"> 44 44 <h2><%= t('aset.default_lang') %></h2> … … 58 58 </section> 59 59 60 <%# ── Ti jdzone ── %>60 <%# ── Timezone ── %> 61 61 <section class="set-card" style="margin-top:1rem"> 62 62 <h2><%= t('aset.timezone') %></h2> … … 78 78 </section> 79 79 80 <%# Mod us-specifieke config — staat direct onder de Modus-kaart zodat 'ie bij81 de gekozen modus hoort (Hub bij hub, Cirkel bijcircle). %>80 <%# Mode-specific config — placed directly below the Mode card so it visually 81 belongs to the selected mode (Hub under hub, Circle under circle). %> 82 82 <% if (tenancy === 'hub') { %> 83 83 <section class="set-card set-card--mode" style="margin-top:1rem"> … … 141 141 <% } %> 142 142 143 <%# Premium (Patreon) — alleen zichtbaar als de self-hoster de premium-laag aanzet144 (env KLONKT_PREMIUM_ENABLED=on). Standaard onzichtbaar; niets gegate. %>143 <%# Premium (Patreon) — only visible when the self-hoster enables the premium layer 144 (env KLONKT_PREMIUM_ENABLED=on). Hidden by default; nothing gated. %> 145 145 <% if (premium && premium.enabled) { %> 146 146 <section class="set-card" style="margin-top:1rem"> … … 162 162 <% } %> 163 163 164 <%# Google -login staat op een eigen Beheer-pagina (compacte link hier). %>164 <%# Google login has its own admin page (compact link shown here). %> 165 165 <section class="set-card" style="margin-top:1rem"> 166 166 <h2><%= t('aset.google_login') %></h2> … … 170 170 </section> 171 171 172 <%# N ieuwsbrief-aanmelding in de footer (premium). %>172 <%# Newsletter signup in the footer (premium). %> 173 173 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %> 174 174 <section class="set-card" style="margin-top:1rem"> … … 185 185 <% } %> 186 186 187 <%# E-mail / SMTP — nodig voor nieuwsbrief versturen, notify-mails en wachtwoord-reset. %>187 <%# E-mail / SMTP — required for sending newsletters, notify-mails and password resets. %> 188 188 <section class="set-card" style="margin-top:1rem"> 189 189 <h2><%= t('aset.email_smtp') %></h2> … … 233 233 .admin-tagline a { color: var(--accent); } 234 234 .set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; } 235 /* Mod us-specifieke kaart (Hub/Cirkel): accent-rand links zodat 'ie zichtbaar bij236 de gekozen modus hoort, direct onder de Modus-kaart. */235 /* Mode-specific card (Hub/Circle): accent border on the left so it visually 236 belongs to the selected mode, directly below the Mode card. */ 237 237 .set-card--mode { border-left: 3px solid var(--accent); } 238 238 .set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 0.5rem; } 239 239 .set-help { color: var(--ink-muted); font-size: 0.9rem; margin: 0 0 1.25rem; } 240 /* Google -setup-gids (uitklapbaar) */240 /* Google setup guide (collapsible) */ 241 241 .set-google-guide { margin: 0 0 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); } 242 242 .set-google-guide > summary { cursor: pointer; padding: 0.6rem 0.85rem; font-size: 0.9rem; font-weight: 600; color: var(--accent); list-style: none; } … … 249 249 .set-google-guide .set-help { padding: 0 0.85rem; } 250 250 .set-form { display: flex; flex-direction: column; gap: 1rem; max-width: 460px; } 251 /* Kale <label>'s in een set-form krijgen dezelfde nette stacked-stijl als .set-field:252 label -tekst boven, input full-width. (.set-toggle = checkbox-rij, uitgezonderd.) */251 /* Bare <label>s inside a set-form inherit the same neat stacked style as .set-field: 252 label text on top, input full-width. (.set-toggle = checkbox row, excluded.) */ 253 253 .set-form > label:not(.set-toggle):not(.set-opt) { display: flex; flex-direction: column; gap: 0.3rem; } 254 254 .set-form > label:not(.set-toggle):not(.set-opt) > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); } … … 277 277 .set-field textarea { resize: vertical; } 278 278 .set-field input:focus, .set-field textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } 279 /* Selects dezelfde nette stijl als de inputs i.p.v. de kale native dropdown:280 eigen chevron, accent-focus, volle breedte. */279 /* Selects styled the same as inputs instead of the bare native dropdown: 280 custom chevron, accent focus ring, full width. */ 281 281 .set-form select { 282 282 appearance: none; -webkit-appearance: none; -
src/views/pages/admin-shows.ejs
rbb42dfb r834bcc3 63 63 .ash-note { font-size: 13px; opacity: .8; margin: 0 0 18px; } 64 64 .ash-form { border: 1px solid rgba(128,128,128,.2); border-radius: 14px; padding: 16px; margin-bottom: 24px; } 65 /* Mobi el-eerst: één kolom (twee 1fr-kolommen met niet-krimpbare date/text-inputs66 liepen samen ~374px > telefoonbreedte). Twee kolommen pas vanaf 520px. */65 /* Mobile-first: single column (two 1fr columns with non-shrinkable date/text inputs 66 combined to ~374px > phone width). Two columns only from 520px onwards. */ 67 67 .ash-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } 68 68 @media (min-width: 520px) { .ash-grid { grid-template-columns: 1fr 1fr; } } -
src/views/pages/admin-site-edit.ejs
rbb42dfb r834bcc3 24 24 <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200"> 25 25 </label> 26 <%# Eigenaar (her)toewijzen — ALLEEN god ÉN alleen in hub-modus. In hub geef27 je zo elke gebruiker z'n eigen, zelf te beheren Klonkt. In solo (éénsite,28 één eigenaar) is dit zinloos/verwarrend, dus verborgen. %>26 <%# Assign (or reassign) owner — ONLY god AND only in hub mode. In hub this 27 gives each user their own self-managed Klonkt. In solo (one site, 28 one owner) this is pointless/confusing, so it's hidden. %> 29 29 <% if (user && user.role === 'god' && (typeof tenancy !== 'undefined' && tenancy === 'hub')) { %> 30 30 <label> … … 250 250 <script> 251 251 (function() { 252 // Slug -URL-veld: toon de echte host als gedimde prefix (de slug zelf is253 // gekleurd via CSS). Hub → host/user/<slug>, andershost/<slug>.252 // Slug URL field: show the real host as a dimmed prefix (the slug itself is 253 // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>. 254 254 var sh = document.getElementById('slug-host'); 255 255 if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/'); … … 354 354 355 355 .site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; } 356 /* Mobi el: een <fieldset> heeft een browser-default min-inline-size:min-content en357 krimpt daardoor NIET mee → het hele formulier werd breder dan het scherm. min-width:0358 force ert meekrimpen; de form-velden krijgen max-width:100% + border-box zodat ze359 n ooit buiten hun fieldset lopen. */356 /* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and 357 therefore does NOT shrink → the entire form became wider than the screen. min-width:0 358 forces it to shrink; form fields get max-width:100% + border-box so they 359 never overflow their fieldset. */ 360 360 .site-form fieldset { min-width: 0; } 361 361 .site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; } … … 398 398 .site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); } 399 399 400 /* Slug a ls URL-veld: gedimde host-prefix + de slug zelf in de accentkleur. */400 /* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */ 401 401 .slug-url { 402 402 display: flex; align-items: stretch; … … 425 425 } 426 426 427 /* Selects: zelfde look als de inputs, met een eigen chevron i.p.v. de kale428 native pijl. De geopende optielijst blijft door de browser gerenderd. */427 /* Selects: same look as the inputs, with a custom chevron instead of the bare 428 native arrow. The open option list is still rendered by the browser. */ 429 429 .site-form select { 430 430 appearance: none; … … 562 562 .photo-controls { gap: .55rem; } 563 563 } 564 /* Mobi el-eerst: platform-keuze op een eigen regel (volle breedte), daaronder de565 URL-input + verwijderknop. Voorkomt horizontale overflow op telefoons (de vaste566 160px-kolom + niet-krimpbare input liepen samen ~447px > schermbreedte). */564 /* Mobile-first: platform selector on its own line (full width), URL input + remove 565 button below. Prevents horizontal overflow on phones (fixed 160px column + 566 non-shrinkable input together ~447px > screen width). */ 567 567 .profile-link-row { 568 568 display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem; -
src/views/pages/admin-users.ejs
rbb42dfb r834bcc3 71 71 72 72 <div class="ax-user-controls"> 73 <%# Hub: g eef deze gebruiker een eigen, zelf te beheren Klonkt — opent het74 n ieuw-site-formulier met deze gebruiker al als eigenaar voorgeselecteerd. %>73 <%# Hub: give this user their own self-managed Klonkt — opens the 74 new-site form with this user already pre-selected as owner. %> 75 75 <% if (typeof tenancy !== 'undefined' && tenancy === 'hub' && canMutate) { %> 76 76 <a href="/admin/sites/new?owner=<%= u.id %>" class="ax-icon-btn" … … 98 98 </form> 99 99 <% } else { %> 100 <%# Lege plek zodat de rol-kolom op elke rij uitlijnt. %>100 <%# Empty placeholder so the role column aligns on every row. %> 101 101 <span class="ax-icon-btn-spacer" aria-hidden="true"></span> 102 102 <% } %> -
src/views/pages/auth-login.ejs
rbb42dfb r834bcc3 1 1 <% 2 // adminLogin = de verborgen beheerders-loginpagina (/auth/admin): toont het3 // gebruikersnaam/wachtwoord-formulier. De publieke /auth/login toont voor4 // bezoekers ALLEEN Google-login (luisteraars/fans). Zo staat de admin-login5 // niet zichtbaar op de plek waar bezoekers heen gestuurd worden.2 // adminLogin = the hidden admin login page (/auth/admin): shows the 3 // username/password form. The public /auth/login shows visitors 4 // ONLY Google login (listeners/fans). This keeps the admin login 5 // off the page where visitors are directed. 6 6 var _admin = (typeof adminLogin !== 'undefined' && adminLogin); 7 7 %> … … 32 32 33 33 <% if (_admin) { %> 34 <%# Verborgen beheerders-login (wachtwoord). %>34 <%# Hidden admin login (password). %> 35 35 <form method="post" action="/auth/login" class="auth-form"> 36 36 <% if (typeof next !== 'undefined' && next) { %> … … 49 49 </form> 50 50 <% } else if (googleReady) { %> 51 <%# Publi eke login: alleen Google, voor luisteraars/fans. %>51 <%# Public login: Google only, for listeners/fans. %> 52 52 <p class="auth-sub"><%= t('auth.public_sub') %></p> 53 53 <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>"> … … 65 65 66 66 <% if (!_admin) { %> 67 <%# Klikbare box voor de beheerder → naar de verborgen wachtwoord-login. %>67 <%# Clickable box for the admin → navigates to the hidden password login. %> 68 68 <a class="auth-admin-box" href="/auth/admin<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>"> 69 69 <span class="auth-admin-box-icon" aria-hidden="true">🔑</span> … … 85 85 .auth-notice-list li { margin: 4px 0; } 86 86 .auth-sub { color: var(--ink-muted); margin: 0 0 1rem; text-align: center; } 87 /* Google -knop is wit → tekst altijd donker houden, ook op hover (de globale88 a.btn:hover -regel maakt 'm anders bijna-wit op wit = onleesbaar). */87 /* Google button is white → keep text dark at all times, including hover (the global 88 a.btn:hover rule would otherwise make it near-white on white = unreadable). */ 89 89 a.btn-google, a.btn-google:hover { color: #1f1f1f; } 90 90 .auth-admin-box { -
src/views/pages/circle-feed.ejs
rbb42dfb r834bcc3 26 26 </div> 27 27 <% } else { %> 28 <%# M eer dan 5: geen chips (zou een muur worden) — alleen het aantal. %>28 <%# More than 5: no chips (would become a wall) — show the count only. %> 29 29 <span class="cirkel-count"><%= t('cfeed.count', { n: sites.length }) %></span> 30 30 <% } %> … … 38 38 <% } else { %> 39 39 40 <%# Dezelfde view als de hoofdpagina: timeline (post-card) + grid (post-tile),41 gewisseld door de view-switcher via body[data-feed-view]. De remote-posts42 zijn in circle.js naar het lokale post-shape gevormd (metexternal_url,43 zodat de kaarten naar de bron-site linken). %>40 <%# Same view as the home page: timeline (post-card) + grid (post-tile), 41 switched by the view-switcher via body[data-feed-view]. The remote posts 42 are shaped to the local post format in circle.js (with external_url, 43 so cards link to the source site). %> 44 44 45 45 <!-- ===== TIMELINE (standaard) ===== --> -
src/views/pages/fan-gate.ejs
rbb42dfb r834bcc3 1 1 <div class="container fg-navwrap"> 2 <%# Zelfde post-navigatie (Newer/Older) als op een gewone post, zodat een3 bezoeker op de fan-gate niet vastloopt maar verder kan bladeren. Zelfde4 maten als .post-page (720px / padding 0 1rem) zodat de nav pixel-gelijk is. %>2 <%# Same post navigation (Newer/Older) as on a regular post, so a visitor 3 on the fan gate can still browse rather than getting stuck. Same 4 dimensions as .post-page (720px / padding 0 1rem) for pixel-perfect alignment. %> 5 5 <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %> 6 6 </div> … … 17 17 18 18 <style> 19 /* EXACT dezelfde class-combinatie (.container + override) én waarden als20 .post-page in post.ejs, zodat de post-nav cascade-identiek uitlijnt. */19 /* EXACT same class combination (.container + override) and values as 20 .post-page in post.ejs, so the post-nav aligns identically in the cascade. */ 21 21 .fg-navwrap { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; } 22 22 .fg { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; } -
src/views/pages/hub-home.ejs
rbb42dfb r834bcc3 1 1 <div class="hub<%= hub.heroImage ? ' hub--img' : '' %>"> 2 2 3 <%# ── HERO (label) — de mini topnav-balk staat erboven(chrome.ejs);4 de hero zelf is de "grote" header van de hub-home ──────────────── %>3 <%# ── HERO (label) — the mini topnav bar sits above it (chrome.ejs); 4 the hero itself is the "large" header of the hub home ──────────────── %> 5 5 <header class="hub-hero<%= hub.heroImage ? ' has-img' : '' %>"<% if (hub.heroImage) { %> style="--hero-img:url('<%= hub.heroImage %>'); --hero-ov:<%= ((hub.heroOverlay == null ? 45 : hub.heroOverlay)/100).toFixed(2) %>"<% } %>> 6 6 <div class="hub-hero-inner container"> … … 17 17 <div class="container hub-body"> 18 18 19 <%# ── HOOFDPAGINA (label-/hoofdaccount — geen artiest) ──────────── %>19 <%# ── MAIN PAGE (label-/primary account — not an artist) ──────────── %> 20 20 <% if (mainSite) { %> 21 21 <section class="hub-sec hub-sec-main"> … … 37 37 <% } %> 38 38 39 <%# ── ARTI ESTEN-ROSTER ─────────────────────────────────────────── %>39 <%# ── ARTIST ROSTER ─────────────────────────────────────────── %> 40 40 <% if (artists && artists.length) { %> 41 41 <% var _total = (typeof totalArtists !== 'undefined') ? totalArtists : artists.length; %> … … 69 69 <% } %> 70 70 71 <%# ── LA ATSTEPOSTS ────────────────────────────────────────────── %>71 <%# ── LATEST POSTS ────────────────────────────────────────────── %> 72 72 <% if (posts && posts.length) { %> 73 73 <section class="hub-sec"> … … 99 99 .hub { width: 100%; position: relative; } 100 100 101 /* Hero sluit direct aan op de mini topnav-balk — geen tussenruimte boven de102 hub -home (override van de globalemain-padding-top). */101 /* Hero connects directly to the mini topnav bar — no gap above the 102 hub home (overrides the global main-padding-top). */ 103 103 .pcms-main { padding-top: 0; } 104 104 .hub-hero { margin-top: 0; } … … 160 160 .hub-more-link:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--paper-2)); } 161 161 162 /* ── Hoofdpagina-kaart (label-/hoofdaccount, apart van de artiesten) ── */162 /* ── Main page card (label-/primary account, separate from the artists) ── */ 163 163 .hub-sec-main { margin-top: 2.25rem; } 164 164 .hub-main-card { … … 199 199 } 200 200 201 /* ── Roster (arti esten) ───────────────────────────────────────────── */201 /* ── Roster (artists) ───────────────────────────────────────────── */ 202 202 .roster { 203 203 display: grid; … … 231 231 } 232 232 233 /* ── Feed (la atsteposts) ─────────────────────────────────────────── */233 /* ── Feed (latest posts) ─────────────────────────────────────────── */ 234 234 .hub-feed { display: flex; flex-direction: column; gap: 0.6rem; } 235 235 .feed-item { -
src/views/pages/post-edit.ejs
rbb42dfb r834bcc3 9 9 // and styling changed. 10 10 const _hasCover = !!post.cover_image_url; 11 // In hub -modus moeten save/create/cancel de /user/<slug>/-prefix dragen, anders12 // valt de request terug op de primaire site (siteUrlBase leeg) en rendert de13 // post na opslaan headerless (bareChrome). In solo is _base leeg.11 // In hub mode, save/create/cancel must carry the /user/<slug>/ prefix, otherwise 12 // the request falls back to the primary site (siteUrlBase empty) and the post 13 // renders headerless (bareChrome) after saving. In solo mode _base is empty. 14 14 const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; 15 15 %> … … 138 138 data-placeholder="<%= t('pedit.editor_placeholder') %>"></div> 139 139 140 <%# Sticky "t ik om te bewerken"-hint (alleen zichtbaar op touch + niet-fullscreen141 via CSS). Pu ur visueel (pointer-events:none); de tik op het veld opentfullscreen. %>140 <%# Sticky "tap to edit" hint (only visible on touch + non-fullscreen 141 via CSS). Purely visual (pointer-events:none); tapping the field opens fullscreen. %> 142 142 <div class="pe-edit-hint" aria-hidden="true">✎ <%= t('pedit.tap_to_edit') %></div> 143 143 … … 225 225 if (!cb || !box) return; 226 226 var inp = document.getElementById('pe-publish-at'); 227 var SITE_TZ = '<%= timezone || '' %>'; // ingestelde site-tijdzone; leeg = browser-lokaal227 var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local 228 228 var pad = function (n) { return String(n).padStart(2, '0'); }; 229 // Offset (ms) tussen een tijdzone en UTC op een bepaaldmoment.229 // Offset (ms) between a timezone and UTC at a given moment. 230 230 function tzOffset(date, tz) { 231 231 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); … … 233 233 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime(); 234 234 } 235 // datetime-local "wa ndtijd" (in de site-zone) → UTC Date.235 // datetime-local "wall time" (in the site zone) → UTC Date. 236 236 function wallToUtc(wall) { 237 237 if (!SITE_TZ) return new Date(wall); … … 239 239 return new Date(guess - tzOffset(new Date(guess), SITE_TZ)); 240 240 } 241 // UTC-ISO → "YYYY-MM-DDTHH:MM" wa ndtijd in de site-zone.241 // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone. 242 242 function utcToWall(iso) { 243 243 var d = new Date(iso); if (isNaN(d)) return ''; … … 247 247 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute; 248 248 } 249 // Prefill: opgeslagen UTC → wandtijd in de site-zone.249 // Prefill: stored UTC → wall time in the site zone. 250 250 if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso); 251 // " Ingepland voor" in leesbare tijd van de site-zone.251 // "Scheduled for" in human-readable time in the site zone. 252 252 var when = document.getElementById('pe-sched-when'); 253 253 if (when && when.dataset.iso) { … … 257 257 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; } 258 258 cb.addEventListener('change', sync); sync(); 259 // Bij opslaan: wandtijd in de site-zone → UTC-ISO via een hidden veld.259 // On save: wall time in the site zone → UTC-ISO via a hidden field. 260 260 var form = cb.closest('form'); 261 261 if (form) { … … 466 466 width: 32px; height: 32px; 467 467 padding: 0; 468 touch-action: manipulation; /* snappy taps o p mobiel, geen dubbeltik-zoom */468 touch-action: manipulation; /* snappy taps on mobile, no double-tap zoom */ 469 469 -webkit-user-select: none; user-select: none; 470 470 background: transparent; … … 478 478 -webkit-tap-highlight-color: transparent; 479 479 } 480 /* Hover alleen op echte hover-apparaten — op touch blijft :hover anders "plakken"481 na een tik, waardoor de actief/inactief-staat onleesbaar wordt. */480 /* Hover only on real hover-capable devices — on touch :hover otherwise "sticks" 481 after a tap, making the active/inactive state unreadable. */ 482 482 @media (hover: hover) { 483 483 .pe-toolbar button:hover { … … 487 487 } 488 488 .pe-toolbar button:active { transform: scale(.94); } 489 /* Acti eve opmaak = onmiskenbaar gevuld met de accentkleur. Zo is op mobiel490 meteen duidelijk dat bv. vet AAN staat (tik nogmaals = uit). */489 /* Active formatting = unmistakably filled with the accent colour. On mobile 490 it's immediately clear when e.g. bold is ON (tap again = off). */ 491 491 .pe-toolbar button.is-active { 492 492 background: var(--accent); … … 505 505 .pe-toolbar-spacer { flex: 1; } 506 506 507 /* ─── Volledig-scherm schrijfmodus─── */507 /* ─── Full-screen writing mode ─── */ 508 508 .fs-icon-compress { display: none; } 509 509 .pe-editor-frame.pe-fs .fs-icon-expand { display: none; } … … 514 514 height: 100dvh; 515 515 background: var(--paper); 516 overflow: hidden; /* alléén het tekstveld scrollt, niet het frame zelf */517 } 518 /* In fullscreen vult het schrijfveld de resterende ruimte en scrollt het zelf519 (max-height: none over schrijft de mobiele box-begrenzing hieronder). */516 overflow: hidden; /* only the text field scrolls, not the frame itself */ 517 } 518 /* In fullscreen the writing field fills the remaining space and scrolls itself 519 (max-height: none overrides the mobile box limit below). */ 520 520 .pe-editor-frame.pe-fs .pe-editor { 521 521 flex: 1 1 auto; min-height: 0; height: auto; max-height: none; 522 522 } 523 /* NB: bewust GEEN overflow:hidden op html/body in fullscreen — dat kon blijven524 hangen (bv. fullscreen verlaten via navigatie) en blokkeerde dan het scrollen van525 de hele pagina. Het fullscreen-frame (position:fixed, inset:0) dekt de pagina al526 a f, en op touch zijn de scrollbalken verborgen → geen dubbele balk nodig. */527 528 /* " Klaar"-knop: alleen in fullscreen zichtbaar (links in de toolbar), duidelijke529 accent-pill i .p.v. een vierkant icoontje. */530 /* Verberg-regel specifieker dan ".pe-toolbar button" (anders wint die en zie je531 de Klaar-knop ook inline als kleine knop). Alleen in fullscreen tonen → groot. */523 /* NB: deliberately NO overflow:hidden on html/body in fullscreen — that could get 524 stuck (e.g. leaving fullscreen via navigation) and would block scrolling on the 525 whole page. The fullscreen frame (position:fixed, inset:0) already covers the page, 526 and on touch scrollbars are hidden → no double bar needed. */ 527 528 /* "Done" button: only visible in fullscreen (left side of toolbar), clear 529 accent-pill instead of a square icon. */ 530 /* Hide rule more specific than ".pe-toolbar button" (otherwise that wins and the 531 Done button also shows inline as a small button). Only shown in fullscreen → large. */ 532 532 .pe-toolbar button.pe-fs-done { display: none; } 533 533 .pe-editor-frame.pe-fs .pe-fs-done { … … 556 556 } 557 557 .pe-editor:focus { outline: none; } 558 /* Inline (n iet-fullscreen) groeit het schrijfveld gewoon mee met de inhoud — geen559 intern e scroll-box (scroll-binnen-scroll is verwarrend). Op mobiel/tablet gaat560 typen sowieso fullscreen (zie JS), waar het veld de pagina vult en als enige561 scroll t. */558 /* Inline (non-fullscreen) the writing field simply grows with the content — no 559 internal scroll-box (scroll-within-scroll is confusing). On mobile/tablet typing 560 always goes fullscreen (see JS), where the field fills the page and is the sole 561 scroller. */ 562 562 .pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; } 563 563 564 /* Touch: het inline content-veld is geen tekstveld maar een tap-vlak→ fullscreen565 bewerken. Een "✎ Tik om te bewerken"-pill plakt sticky onderaan de container,566 zodat de hint altijd in beeld is zonder midden in de tekst te staan. */564 /* Touch: the inline content field is not a text field but a tap target → fullscreen 565 editing. A "✎ Tap to edit" pill sticks to the bottom of the container, 566 so the hint is always visible without sitting in the middle of the text. */ 567 567 .pe-editor.pe-tap-to-edit { cursor: pointer; } 568 568 .pe-edit-hint { display: none; } … … 571 571 display: block; 572 572 position: sticky; 573 bottom: 4.5rem; /* boven de sticky Opslaan/Annuleren-balk*/573 bottom: 4.5rem; /* above the sticky Save/Cancel bar */ 574 574 width: max-content; 575 575 max-width: calc(100% - 2rem); … … 580 580 pointer-events: none; text-align: center; white-space: nowrap; 581 581 } 582 /* N iet relevant op touch (slepen/selecteren hoort bij inline-bewerken opdesktop). */582 /* Not relevant on touch (drag/select belongs to inline editing on desktop). */ 583 583 .pe-content-hint { display: none; } 584 /* Inline (n iet-fullscreen) op touch: simpel houden — je bewerkt hier toch niet,585 dus geen opmaak-toolbar en geen losse rand. Alleen de content-preview + de586 "t ik om te bewerken"-pill. De toolbar verschijnt pas in fullscreen. */584 /* Inline (non-fullscreen) on touch: keep it simple — you don't edit here anyway, 585 so no formatting toolbar and no border. Just the content preview + the 586 "tap to edit" pill. The toolbar only appears in fullscreen. */ 587 587 .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; } 588 588 .pe-editor-frame:not(.pe-fs) { border-top: 0; } … … 955 955 } 956 956 957 /* Vastpinnen: checkbox + ▲▼-stepper met beschrijving (i.p.v. een ruw rang-getal). */957 /* Pin: checkbox + ▲▼-stepper with description (instead of a raw rank number). */ 958 958 .pe-pin { display: flex; flex-direction: column; gap: 0.45rem; } 959 959 .pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; } … … 978 978 gap: 0.5rem; 979 979 padding: 0.85rem 1rem; 980 margin: 0.5rem -1rem 0; /* extend to viewport edges on tightcontainer */980 margin: 0.5rem -1rem 0; /* extend to viewport edges on a narrow container */ 981 981 background: color-mix(in srgb, var(--paper) 94%, transparent); 982 982 -webkit-backdrop-filter: blur(8px); … … 987 987 .pe-actions-spacer { flex: 1; } 988 988 989 /* De editor is een focus-scherm: verberg de mobiele site-tabbalk (Home/Zoek/…)990 zodat er niet twee balken onderaan stapelen (Opslaan-balk + tabbalk). De991 Opslaan/Annuleren-balk wordt dan de enige onderbalk → gewoon opbottom:0,992 geen tab-offset meer nodig. Op desktop is de tabbalk toch al verborgen. */989 /* The editor is a focus screen: hide the mobile site tab bar (Home/Search/…) 990 so two bars don't stack at the bottom (Save bar + tab bar). The 991 Save/Cancel bar then becomes the only bottom bar → plain bottom:0, 992 no tab offset needed. On desktop the tab bar is already hidden. */ 993 993 body:has(.post-edit-page) .bottom-tab { display: none; } 994 /* Audio speler (indien aan het spelen) niet meer 56px optillen voor de nu-verborgen995 tab balk, anders zou die zweven boven de actiebalk. */994 /* Audio player (if playing) no longer lifted 56px for the now-hidden 995 tab bar, otherwise it would float above the action bar. */ 996 996 body:has(.post-edit-page) .audio-player { bottom: 0; } 997 997 998 /* ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ── */998 /* ── Image editor (rotate / crop / mirror) ── */ 999 999 .imed-backdrop { 1000 1000 position: fixed; inset: 0; z-index: 9999; … … 1015 1015 } 1016 1016 .imed-stage img { display: block; max-width: 100%; } 1017 /* Cropper z'n container moet de volle stage-hoogte vullen — anders klapt 'ie dicht1018 in een flex-kolom zonder expliciete hoogte → leeg bewerk-venster (geen afbeelding). */1017 /* Cropper's container must fill the full stage height — otherwise it collapses 1018 in a flex column without an explicit height → empty edit window (no image). */ 1019 1019 .imed-stage .cropper-container { width: 100% !important; height: 100% !important; } 1020 1020 .imed-tools { … … 1052 1052 } 1053 1053 1054 // ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ──────────1055 // Lazy-load Cropper.js (lo kaal gevendord) pas bij het eerste gebruik.1054 // ── Image editor (rotate / crop / mirror) ────────── 1055 // Lazy-load Cropper.js (locally vendored) on first use. 1056 1056 let _cropperReady = null; 1057 1057 function ensureCropper() { … … 1073 1073 } 1074 1074 1075 // Open t de editor voor een gekozen bestand; resolved met een bewerktFile,1076 // o f null als de gebruiker annuleert. Animatie-GIF's worden niet door de1077 // canvas -editor gehaald (zouden statisch worden) — die uploaden direct.1075 // Opens the editor for a chosen file; resolves with an edited File, 1076 // or null if the user cancels. Animated GIFs are not sent through the 1077 // canvas editor (they would become static) — those upload directly. 1078 1078 async function openImageEditor(file) { 1079 1079 if (!file || !file.type || !file.type.startsWith('image/')) return file; 1080 if (file.type === 'image/gif') return file; // behoud animatie1081 try { await ensureCropper(); } catch (_) { return file; } // editor onbeschikbaar → direct uploaden1080 if (file.type === 'image/gif') return file; // preserve animation 1081 try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly 1082 1082 1083 1083 return new Promise((resolve) => { … … 1118 1118 cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true }); 1119 1119 }; 1120 img.onerror = () => { cleanup(); resolve(file); }; // kon niet laden → upload het origineel1120 img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original 1121 1121 img.src = url; 1122 1122 … … 1179 1179 const edited = await openImageEditor(coverField.files[0]); 1180 1180 coverField.value = ''; 1181 if (!edited) return; // geannuleerd1181 if (!edited) return; // cancelled 1182 1182 coverStatus.classList.remove('is-error'); 1183 1183 coverStatus.textContent = '<%= t('pedit.js_uploading') %>'; … … 1224 1224 if (!editor) return; 1225 1225 1226 // Titel alleen automatisch focussen op desktop (muis/trackpad). Op touch zou1227 // dit meteen het toetsenbord openen bij het openen van de editor — niet gewenst.1226 // Auto-focus the title only on desktop (mouse/trackpad). On touch this would 1227 // immediately open the keyboard when the editor opens — not desired. 1228 1228 try { 1229 1229 const titleInput = form && form.querySelector('input[name="title"]'); … … 1329 1329 1330 1330 // ── Toolbar wiring 1331 // Houd de scrollpositie vast rond een edit-commando. execCommand/insert scrollt1332 // standaard de caret in beeld → het beeld "verspringt" bij het aanklikken van een1333 // opmaakknop. We leggen ALLE scrollbare voorouders (editor, frame, #pcms-main, …)1334 // + de pagina vast en zetten ze terug — sync én over een paar frames, wantChrome1335 // s crollt soms pas een frame later. De gebruiker scrollt zo zelf.1331 // Lock the scroll position around an edit command. execCommand/insert scrolls 1332 // the caret into view by default → the view "jumps" when clicking a formatting 1333 // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …) 1334 // + the page and restore them — sync and over a few frames, because Chrome 1335 // sometimes scrolls a frame later. The user scrolls themselves. 1336 1336 function scrollableAncestors(el) { 1337 1337 const list = []; … … 1345 1345 } 1346 1346 function keepScroll(fn) { 1347 // In fullscreen staat de pagina vast (body overflow:hidden) en mag het veld1348 // gewoon naar de caret scrollen — geen pagina-sprong mogelijk, dus niets fixen.1347 // In fullscreen the page is locked (body overflow:hidden) and the field may 1348 // scroll to the caret freely — no page jump possible, so nothing to fix. 1349 1349 const frame = document.querySelector('.pe-editor-frame'); 1350 1350 if (frame && frame.classList.contains('pe-fs')) { fn(); return; } … … 1389 1389 execCmd('createLink', url); 1390 1390 } 1391 // De huidige selectie zit in een <blockquote> binnen de editor? Geef 'm terug.1391 // Is the current selection inside a <blockquote> within the editor? Return it. 1392 1392 function blockquoteAncestor() { 1393 1393 const sel = window.getSelection(); … … 1400 1400 return null; 1401 1401 } 1402 // Echte toggle: execCommand('formatBlock','blockquote') zet 'm wél AAN maar1403 // krijgt 'm nooit meer UIT (browser-quirk). Staat de caret al in eenquote →1404 // pak de wrapper uit; anders pas blockquote toe.1402 // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but 1403 // can never turn it OFF (browser quirk). If the caret is already in a quote → 1404 // unwrap it; otherwise apply blockquote. 1405 1405 function toggleBlockquote() { 1406 1406 keepScroll(function () { … … 1409 1409 if (bq) { 1410 1410 const parent = bq.parentNode; 1411 // Inhoud uit de quote halen, op z'n plek, en de lege wrapper verwijderen.1411 // Extract content from the quote in place, then remove the empty wrapper. 1412 1412 const ref = bq; 1413 1413 let firstMoved = null; … … 1418 1418 } 1419 1419 parent.removeChild(bq); 1420 // Caret terugzetten in de uitgepakte inhoud.1420 // Restore the caret inside the unwrapped content. 1421 1421 if (firstMoved) { 1422 1422 const sel = window.getSelection(); … … 1436 1436 1437 1437 if (toolbar) { 1438 // CRUCIA AL (mobiel + desktop): voorkom dat een toolbar-knop de focus/selectie1439 // uit het editor-veld steelt. Zonder dit raakt de selectie kwijt bij het tikken1440 // → execCommand werkt op een lege selectie (vet kan niet meer UIT) én de browser1441 // scrollt de caret opnieuw in beeld (de "sprong naar beneden"). preventDefault op1442 // mousedown houdt de focus in de editor; de click blijft gewoon vuren.1438 // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection 1439 // from the editor field. Without this the selection is lost on tap 1440 // → execCommand operates on an empty selection (bold can no longer be toggled OFF) 1441 // and the browser scrolls the caret back into view (the "jump down"). preventDefault 1442 // on mousedown keeps focus in the editor; the click still fires normally. 1443 1443 toolbar.addEventListener('mousedown', (e) => { 1444 1444 if (e.target.closest('button')) e.preventDefault(); … … 1457 1457 } 1458 1458 1459 // ── Volledig-scherm schrijfmodus: het schrijfveld vult de hele pagina.1459 // ── Full-screen writing mode: the writing field fills the whole page. 1460 1460 const fsBtn = document.getElementById('pe-fullscreen-btn'); 1461 1461 const editorFrame = document.querySelector('.pe-editor-frame'); 1462 1462 const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); 1463 1463 1464 // O p mobiel duwt het toetsenbord de zichtbare (visual) viewport omhoog terwijl1465 // een position:fixed-frame aan de LAYOUT-viewport blijft hangen → de toolbar1466 // s chuift uit beeld. Houd het fullscreen-frame daarom gelijk aan de visual1467 // viewport (top + h oogte), zodat de toolbar altijd bovenaan zichtbaar blijft.1464 // On mobile the keyboard pushes the visible (visual) viewport up while 1465 // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar 1466 // slides out of view. Keep the fullscreen frame aligned to the visual 1467 // viewport (top + height) so the toolbar stays visible at the top. 1468 1468 function syncFsViewport() { 1469 1469 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return; … … 1499 1499 } 1500 1500 } 1501 // O p touch is het veld inline NIET bewerkbaar; alleen in fullscreen wel.1501 // On touch the field is NOT editable inline; only in fullscreen. 1502 1502 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false'); 1503 1503 if (on) { … … 1505 1505 } else { 1506 1506 if (isTouch) editor.blur(); 1507 // Bij sluiten: naar de TOP van de content scrollen i.p.v. ergens onderaan1508 // (footer) te blijven hangen.1507 // On close: scroll to the TOP of the content instead of staying 1508 // somewhere at the bottom (footer). 1509 1509 requestAnimationFrame(function () { 1510 1510 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {} … … 1512 1512 } 1513 1513 } 1514 // De fullscreen schrijf-"pagina": openen pusht een history-state zodat de browser-1515 // back -knop (en de Klaar-knop) 'm sluit en je terug bent in het formulier — voelt1516 // als een aparte pagina, maar álle formuliervelden blijven intact (zelfde DOM).1514 // The fullscreen writing "page": opening pushes a history state so the browser 1515 // back button (and the Done button) closes it and returns you to the form — feels 1516 // like a separate page, but all form fields remain intact (same DOM). 1517 1517 function openFs() { 1518 1518 if (isFs()) return; … … 1522 1522 function closeFs() { 1523 1523 if (!isFs()) return; 1524 if (history.state && history.state.peFs) history.back(); // → popstate sluit af1524 if (history.state && history.state.peFs) history.back(); // → popstate closes it 1525 1525 else applyFs(false); 1526 1526 } … … 1534 1534 }); 1535 1535 1536 // O p mobiel/tablet (touch): het content-veld is INLINE NIET bewerkbaar — het is1537 // dan geen tekstveld. Eén tik → fullscreen, waar het wél bewerkbaar wordt1538 // (toggleFullscreen zet contenteditable aan/uit). Zo kun je nooit inline typen.1536 // On mobile/tablet (touch): the content field is NOT editable inline — it is 1537 // not a text field there. One tap → fullscreen, where it becomes editable 1538 // (toggleFullscreen toggles contenteditable). This prevents inline typing. 1539 1539 if (isTouch) { 1540 1540 editor.setAttribute('contenteditable', 'false'); … … 1555 1555 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {} 1556 1556 } 1557 // Quote -knop: actief als de caret in een <blockquote> staat (toggle-feedback).1557 // Quote button: active when the caret is inside a <blockquote> (toggle feedback). 1558 1558 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]'); 1559 1559 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor()); … … 1588 1588 async function uploadAndInsertImage(file) { 1589 1589 const edited = await openImageEditor(file); 1590 if (!edited) return; // geannuleerd1590 if (!edited) return; // cancelled 1591 1591 contentStatus.classList.remove('is-error'); 1592 1592 contentStatus.textContent = '<%= t('pedit.js_uploading') %>'; … … 1654 1654 } 1655 1655 1656 // ── Embed insert: p lak een platform-URL -> [[embed:url]]-chip die server-side1657 // een iframe wordt(YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).1656 // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes 1657 // an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp). 1658 1658 const embedBtn = document.getElementById('insert-embed-btn'); 1659 1659 if (embedBtn) { … … 1674 1674 const tpEmpty = document.getElementById('tp-empty'); 1675 1675 const tpSearch = document.getElementById('tp-search'); 1676 let tpCache = null; // cached track slist (fetched once per page load)1676 let tpCache = null; // cached track list (fetched once per page load) 1677 1677 let tpLastFocus = null; // element to restore focus to on close 1678 1678 … … 1870 1870 <script> 1871 1871 (function () { 1872 // Vastpinnen: checkbox toggelt het verborgen rang-veld (0 = niet gepind),1873 // ▲▼ verschuift de plek, met een leesbare beschrijving i.p.v. een ruw getal.1872 // Pin: checkbox toggles the hidden rank field (0 = not pinned), 1873 // ▲▼ shifts the position, with a readable description instead of a raw number. 1874 1874 var toggle = document.getElementById('pin-toggle'); 1875 1875 var rank = document.getElementById('pin-rank'); 1876 1876 var pos = document.getElementById('pin-pos'); 1877 1877 var label = document.getElementById('pin-label'); 1878 var up = document.getElementById('pin-up'); // h oger = lager getal (richting 1/bovenaan)1878 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top) 1879 1879 var down = document.getElementById('pin-down'); 1880 1880 if (!toggle || !rank || !pos) return; … … 1900 1900 1901 1901 (function () { 1902 // De Opslaan/Annuleren-balk (position: sticky; bottom:0) net boven twee mogelijke1903 // obsta kels houden door een dynamische bottom-offset te zetten = het grootste van:1904 // 1) de hoogte van het toetsenbord-deel dat de layout-viewport NIET dekt1905 // (o p iOS verschuift de visual viewport; op Android verkleint de layout-1906 // viewport doorinteractive-widget=resizes-content → offset ≈ 0);1907 // 2) de hoogte van de spelende audiospeler (fixed, z-index 1000).1908 // We blijven bij sticky (geen fixed/top-gegoochel → geen balk midden in beeld).1902 // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible 1903 // obstacles by setting a dynamic bottom offset = the greater of: 1904 // 1) the height of the keyboard area NOT covered by the layout viewport 1905 // (on iOS the visual viewport shifts; on Android the layout viewport shrinks 1906 // due to interactive-widget=resizes-content → offset ≈ 0); 1907 // 2) the height of the playing audio player (fixed, z-index 1000). 1908 // We stick with sticky (no fixed/top tricks → no bar floating in the middle). 1909 1909 var bar = document.querySelector('.pe-actions'); 1910 1910 if (!bar) return; … … 1922 1922 window.addEventListener('resize', position); 1923 1923 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); } 1924 // has-audio-player wisselt via een body-class → daarop reageren.1924 // has-audio-player is toggled via a body class → observe it. 1925 1925 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {} 1926 1926 })(); -
src/views/pages/post.ejs
rbb42dfb r834bcc3 1 1 <% 2 // In hub -mode is de site bereikbaar onder /user/<slug>; allesite-scoped links3 // (tag, edit, delete, comments) m oeten met die prefix anders vallen ze terug op4 // de primaire site → 404 / verkeerde site. In solo is_base = ''.2 // In hub mode the site is accessible under /user/<slug>; all site-scoped links 3 // (tag, edit, delete, comments) must carry that prefix otherwise they fall back 4 // to the primary site → 404 / wrong site. In solo mode _base = ''. 5 5 const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; 6 6 %> 7 7 <article class="container post-page"> 8 8 9 <%# Navigati e over ALLE posts — BOVEN de post (gedeeld partial). %>9 <%# Navigation across ALL posts — ABOVE the post (shared partial). %> 10 10 <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %> 11 11 … … 79 79 80 80 <% if (typeof currentPath === 'string' && currentPath && currentPath.indexOf('/' + post.slug) === 0) { %> 81 <% /* If the URL has ?pending=1, the previous submit landed in the queue */ %>81 <% /* If the URL has ?pending=1, the previous submit landed in the moderation queue */ %> 82 82 <% } %> 83 83 <script> … … 164 164 </div> 165 165 166 <%# Re ageren op een antwoord — landt (via resolvedParent op de167 server) in dezelfde thread, dus 1 niveau diep blijft behouden. %>166 <%# Replying to a reply — lands (via resolvedParent on the 167 server) in the same thread, so the 1-level depth is preserved. %> 168 168 <% if (user && canMutate) { %> 169 169 <form method="post" action="<%= _base %>/comments" class="comment-reply-form" hidden data-reply-form-for="<%= r.id %>"> … … 431 431 .comment-form button { align-self: flex-start; } 432 432 .comment-reply-form-actions { display: flex; gap: 0.5rem; } 433 /* Reply-formulier pas tonen NA klik op REPLY. De display:flex hierboven overschreef434 anders het [hidden]-attribuut → formulier stond altijdopen. */433 /* Show the reply form only AFTER clicking REPLY. The display:flex above would 434 otherwise override the [hidden] attribute → form was always open. */ 435 435 .comment-reply-form[hidden] { display: none; } 436 /* DELETE -knop zit in een <form>; display:contents laat 'm als directeflex-sibling437 van REPLY meedoen → pixel-perfecte uitlijning + gelijkegap. */436 /* DELETE button is inside a <form>; display:contents makes it a direct flex-sibling 437 of REPLY → pixel-perfect alignment + equal gap. */ 438 438 .comment-actions form { display: contents; } 439 439 -
src/views/partials/bottom-tab.ejs
rbb42dfb r834bcc3 15 15 const _ownProfile = user ? ('/users/' + user.username) : null; 16 16 const _isHub = (typeof tenancy !== 'undefined' && tenancy === 'hub'); 17 // Home gaat in hub-modus ALTIJD naar de hub-root (/), niet naar de sub-home van18 // een artiest. In solo/circle is _siteUrlBase leeg, dus dat blijft'/'.17 // Home in hub mode ALWAYS goes to the hub root (/), not to an artist's sub-home. 18 // In solo/circle _siteUrlBase is empty, so it stays '/'. 19 19 const _homeHref = (_isHub ? '' : _siteUrlBase) + '/'; 20 20 … … 27 27 28 28 let _active = null; 29 // In hub is Home alleen actief op de échte hub-root, niet op een artiest-sub-home.29 // In hub, Home is only active on the real hub root, not on an artist sub-home. 30 30 if (_isHub ? (_path === '/') : (_p === '/' || _p === '')) _active = 'home'; 31 31 else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search'; … … 38 38 39 39 <!-- Home --> 40 <%# htmx -navigatie (geen full reload): de persistente audioplayer in document.body41 overleeft dan, dus de muziek verspringt niet bij het navigeren. Op mobiel is de42 top-balk toch verborgen, dus een context-reset via full-load is hier niet nodig. %>40 <%# htmx navigation (no full reload): the persistent audio player in document.body 41 survives, so music does not skip when navigating. On mobile the top bar is hidden 42 anyway, so a context reset via full-load is not needed here. %> 43 43 <a class="bottom-tab-item<%= _active === 'home' ? ' is-active' : '' %>" 44 44 href="<%= _homeHref %>" … … 308 308 309 309 <script> 310 // Bottom-tab zoekknop → opent #search-overlay. Via event-delegatie + de overlay311 // ELKE klik opnieuw opzoeken: de overlay zit in de chrome die bij htmx-navigatie312 // OOB vervangen wordt, dus een één-keer-opgevangen referentie verschaalt313 // ( knop deed daarna niets). Guard tegen dubbel-wiren.310 // Bottom-tab search button → opens #search-overlay. Using event delegation + looking 311 // up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced 312 // during htmx navigation, so a once-captured reference goes stale 313 // (button did nothing afterwards). Guard against double-wiring. 314 314 (function() { 315 315 if (window.__btSearchWired) return; -
src/views/partials/footer.ejs
rbb42dfb r834bcc3 13 13 <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'Klonkt' %></span> 14 14 </div> 15 <%# Install-app knop — in de footer-rij (midden), bespaart een hele extra rij.16 JS detect eert platform & opent een modal met PWA-install-stappen. %>15 <%# Install-app button — placed in the footer row (centre), saves a whole extra row. 16 JS detects the platform & opens a modal with PWA install steps. %> 17 17 <button type="button" class="install-app-btn install-app-btn--footer" data-pcms-install-app> 18 18 <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.84v6h5.66V9h3.84L12 2z" fill="currentColor"/></svg> … … 114 114 } 115 115 .footer-lang:hover { border-color: var(--accent); } 116 /* Open dropdown-opties leesbaar (anders wit-op-wit opdark mode). */116 /* Make open dropdown options readable (otherwise white-on-white in dark mode). */ 117 117 .footer-lang option { background: var(--paper); color: var(--ink); } 118 /* O p desktop staat de taalkeuze in de header-nav → footer-versie verbergen. */118 /* On desktop the language picker lives in the header nav → hide the footer version. */ 119 119 @media (min-width: 768px) { .footer-lang { display: none; } } 120 120 .footer-version { color: inherit; text-decoration: none; } … … 144 144 } 145 145 146 /* Compact e install-knop ín de footer-rij (midden) i.p.v. een grote losse CTA147 op een eigen regel — bespaart verticale ruimte. */146 /* Compact install button inside the footer row (centre) instead of a large standalone 147 CTA on its own line — saves vertical space. */ 148 148 .install-app-btn--footer { 149 149 flex: 0 0 auto; 150 width: auto !important; /* override de mobile .install-app-btn width:100% */150 width: auto !important; /* override the mobile .install-app-btn width:100% */ 151 151 padding: 0.42rem 0.95rem; 152 152 font-size: 0.82rem; … … 154 154 } 155 155 .install-app-btn--footer svg { width: 15px; height: 15px; } 156 /* O p smal scherm: laat de drie items netjes centreren als ze wrappen. */156 /* On narrow screens: let the three items centre neatly when they wrap. */ 157 157 @media (max-width: 600px) { 158 158 .site-footer-inner { justify-content: center; text-align: center; } -
src/views/partials/like-button.ejs
rbb42dfb r834bcc3 1 <%# Like -/favorietknop voor een post. Ingelogd → htmx-toggle (POST /posts/:id/like,2 swap outerHTML met deze partial terug). Niet ingelogd → link naarlogin.3 Verwacht: post (met.id), likedByMe, likeCount, loggedIn, loginNext. %>1 <%# Like/favourite button for a post. Logged in → htmx toggle (POST /posts/:id/like, 2 swaps outerHTML back with this partial). Not logged in → link to login. 3 Expects: post (with .id), likedByMe, likeCount, loggedIn, loginNext. %> 4 4 <% 5 5 var _liked = (typeof likedByMe !== 'undefined' && likedByMe); -
src/views/partials/post-nav.ejs
rbb42dfb r834bcc3 1 <%# Post -navigatie (Newer links / Older rechts) — gedeeld door post.ejs én2 fan-gate.ejs. Verwacht newerPost + olderPost in scope. Ontbreekt een buur,3 dan een uitgegrijsde placeholder zodat elke post dezelfde structuur houdt. %>1 <%# Post navigation (Newer link left / Older right) — shared by post.ejs and 2 fan-gate.ejs. Expects newerPost + olderPost in scope. If a neighbour is missing, 3 a greyed-out placeholder keeps every post in the same two-card structure. %> 4 4 <% if ((typeof newerPost !== 'undefined' && newerPost) || (typeof olderPost !== 'undefined' && olderPost)) { %> 5 5 <nav class="post-nav post-nav--top"> … … 35 35 <style> 36 36 .post-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--rule); } 37 /* Boven de post: geen lijn/gap erboven, maar een scheidingslijn ERONDER (naar de content). */37 /* Above the post: no line/gap above it, but a dividing line BELOW (toward the content). */ 38 38 .post-nav--top { margin-top: 0; padding-top: 0; border-top: 0; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rule); } 39 39 .post-nav-prev, .post-nav-next { display: flex; flex-direction: column; padding: 1rem; background: var(--paper-2); border-radius: 6px; text-decoration: none; color: var(--ink); } 40 /* Lege plek (geen nieuwere/oudere post): zelfde kaart, uitgegrijsd + niet klikbaar,41 zodat elke post dezelfde twee-kaarten-structuur houdt. */40 /* Empty slot (no newer/older post): same card, greyed out + not clickable, 41 so every post keeps the same two-card structure. */ 42 42 .post-nav-prev.is-empty, .post-nav-next.is-empty { opacity: 0.4; cursor: default; } 43 43 .post-nav-prev.is-empty .post-nav-title, .post-nav-next.is-empty .post-nav-title { font-weight: 400; font-style: italic; color: var(--ink-muted); } -
src/views/partials/shared-styles.ejs
rbb42dfb r834bcc3 6 6 border-bottom: 1px solid var(--rule); 7 7 background: var(--paper); 8 /* Geen extra margin-top: de pill wordt zo gecentreerd tussen de profielkop-9 streep (boven) en de eigen border (onder). De gelijke lucht boven/onder de10 pill komt uit de symmetrische container-padding hieronder. De margin-bottom11 (1.5rem in style.css) blijft lucht naar de feed. */8 /* No extra margin-top: this centres the pill between the profile-header 9 rule (above) and the own border (below). Equal space above/below the 10 pill comes from the symmetric container padding below. The margin-bottom 11 (1.5rem in style.css) keeps breathing room toward the feed. */ 12 12 margin-top: 0; 13 13 } 14 14 .view-switcher-wrap .container { 15 15 display: flex; align-items: center; justify-content: center; gap: 0.75rem; 16 /* Symmetri sche padding → even veel lucht boven als onder de pill (≈8px),17 gecentreerd tussen de twee lijnen. */16 /* Symmetric padding → equal space above and below the pill (≈8px), 17 centred between the two dividing lines. */ 18 18 padding: 0.5rem 1rem; 19 19 max-width: 1200px; margin: 0 auto; … … 36 36 } 37 37 .view-switch-btn:hover { color: var(--ink); } 38 /* Acti ef = wit (lichte achtergrond, donkere tekst). Via aria-selected (element-39 attribu ut, door syncAria gezet + op htmx:afterSettle) → htmx-robuust. Op niet-40 feed -pagina's zet syncAria aria-selected=false → beide grijs. */38 /* Active = white (light background, dark text). Via aria-selected (element 39 attribute, set by syncAria + on htmx:afterSettle) → htmx-robust. On non- 40 feed pages syncAria sets aria-selected=false → both grey. */ 41 41 .view-switch-btn[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); } 42 42 43 /* Agenda = een EIGEN pill (los van Tijdlijn/Grid, zodat de feed-toggle nooit44 verstoord raakt). Zelfde look als de view-switcher-pill. Zichtbaar in alle modi. */45 .agenda-pill { display: inline-flex; align-items: center; } /* geen box — losse icoon+tekst link */43 /* Agenda = its OWN pill (separate from Timeline/Grid, so the feed-toggle is never 44 disturbed). Same look as the view-switcher pill. Visible in all modes. */ 45 .agenda-pill { display: inline-flex; align-items: center; } /* no box — standalone icon+text link */ 46 46 .vs-agenda { 47 47 display: inline-flex; align-items: center; gap: 0.4rem; … … 53 53 } 54 54 .vs-agenda:hover { color: var(--ink); } 55 /* Acti ef (op de agendapagina) = helder/wit. Element-class (in de OOB-gerenderde56 chrome) i.p.v. body-class-CSS → werkt betrouwbaar ook na htmx-navigatie. */55 /* Active (on the agenda page) = bright/white. Element-class (in the OOB-rendered 56 chrome) rather than body-class CSS → stays reliable after htmx navigation. */ 57 57 .vs-agenda.is-active { color: var(--ink); font-weight: 600; } 58 58 59 /* Mobi el: Agenda op een eigen rij bovenaan, en Solo/Cirkel + Tijdlijn/Grid samen60 o p de rij eronder (samen ~330px → past binnen een telefoon, i.p.v. de oude61 onevenwichtige split Agenda+Solo / Tijdlijn). */59 /* Mobile: Agenda on its own row at the top, Solo/Circle + Timeline/Grid together 60 on the row below (together ~330px → fits within a phone, instead of the old 61 unbalanced split Agenda+Solo / Timeline). */ 62 62 @media (max-width: 520px) { 63 63 .view-switcher-wrap .agenda-pill { flex: 0 0 100%; justify-content: center; } … … 92 92 .on-admin .view-switcher-wrap { display: none; } 93 93 94 /* O p een losse post is er geen feed om te schakelen, dus geen knop hoort "actief"95 ( gekleurd) te ogen. De actieve staat zit in style.css opbody[data-feed-view]96 / body[data-grid-cols] (background:var(--ink)); we overr ulen die SPECIFIEKER97 via body.on-post zodat de knoppen neutraal/grijs worden — maar wél klikbaar98 blijven (de klik-handler navigeert dan naar de feed in die weergave). De99 body-class wisselt client-side bij HTMX-nav, dus dit werkt ook zonder reload. */94 /* On a standalone post there is no feed to switch, so no button should look "active" 95 (coloured). The active state lives in style.css on body[data-feed-view] 96 / body[data-grid-cols] (background:var(--ink)); we override that MORE SPECIFICALLY 97 via body.on-post so the buttons become neutral/grey — but remain clickable 98 (the click-handler then navigates to the feed in that view). The 99 body-class switches client-side on HTMX-nav, so this works without a reload too. */ 100 100 body.on-post[data-feed-view="timeline"] .view-switch-btn[data-view="timeline"], 101 101 body.on-post[data-feed-view="grid"] .view-switch-btn[data-view="grid"], -
src/views/partials/topnav.ejs
rbb42dfb r834bcc3 27 27 <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a> 28 28 <% } else if (typeof tenancy !== 'undefined' && tenancy === 'hub') { %> 29 <%# Hub: de brand gaat naar de hub-home via HTMX. De chrome wordt in hub-modus30 altijd gerenderd en via body.on-hub (CSS) verborgen op de landing, dushtmx31 kan schakelen zonder full-reload → de audioplayer blijft leven. %>29 <%# Hub: the brand navigates to the hub home via HTMX. The chrome is always 30 rendered in hub mode and hidden on the landing via body.on-hub (CSS), so htmx 31 can switch without a full reload → the audio player stays alive. %> 32 32 <a class="nav-hub-home" href="/" aria-label="Home" title="Home" 33 33 hx-get="/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/" hx-indicator="#pcms-loading"> … … 207 207 <script> 208 208 (function() { 209 // EÉN keer bedraden. Deze chrome (topnav) wordt bij htmx-navigatie out-of-band210 // opnieuw ingevoegd → zonder deze guard zou dit script bij elke navigatie EXTRA211 // listeners stapelen, waardoor de thema-toggle 2× (of vaker) vuurde = geen netto212 // wisseling ("toggle werkt niet meer"). Alles hieronder is event-delegatie op213 // b ody/document, dus het werkt ook voor knoppen die ná deze run (OOB) verschijnen.209 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation 210 // → without this guard the script would stack EXTRA listeners on every navigation, 211 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working"). 212 // Everything below uses event delegation on body/document, so it also works for 213 // buttons that appear after this run (OOB). 214 214 if (window.__pcmsChromeWired) return; 215 215 window.__pcmsChromeWired = true; … … 229 229 if (e.target.closest('#search-toggle')) { openSearch(); return; } 230 230 if (e.target.closest('#search-close')) { closeSearch(); return; } 231 // Open dropdowns (gebruikersmenu + taalkeuze) sluiten bij klik erbuiten.231 // Close open dropdowns (user menu + language picker) on click outside. 232 232 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) { 233 233 if (!d.contains(e.target)) d.removeAttribute('open'); … … 239 239 }); 240 240 241 // ── Live result aten tijdens typen───────────────────────────────241 // ── Live results while typing ─────────────────────────────────── 242 242 var _ST = { 243 243 posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>', … … 280 280 }, 200); 281 281 }); 282 // Klik op een suggestie → overlay sluiten (de link/boost doet de navigatie).282 // Click on a suggestion → close the overlay (the link/boost handles navigation). 283 283 document.addEventListener('click', function(e) { 284 284 if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; } … … 313 313 } 314 314 315 /* Hub-home brand is WÉL zichtbaar — de thuisknop op artiest-/hub-pagina's. */315 /* Hub-home brand IS visible — the home button on artist/hub pages. */ 316 316 .masthead-brand .nav-hub-home { 317 317 display: inline-flex; align-items: center; justify-content: center; … … 436 436 min-width: 220px; 437 437 padding: .35rem; 438 /* Frosted glass: transpar ant + zware blur zodat het frosted effect duidelijk is. */438 /* Frosted glass: transparent + heavy blur so the frosted effect is clearly visible. */ 439 439 background: color-mix(in srgb, var(--paper) 60%, transparent); 440 440 backdrop-filter: blur(34px) saturate(190%); … … 565 565 .ss-all { display: block; margin-top: .35rem; padding: .55rem .6rem; border-top: 1px solid var(--rule); color: var(--accent); font-weight: 600; text-decoration: none; font-size: .9rem; } 566 566 .ss-all:hover { text-decoration: underline; } 567 /* Meldingen-bel + ongelezen-badge */567 /* Notification bell + unread badge */ 568 568 .nav-notif { position: relative; } 569 569 .notif-badge { -
src/views/partials/track-editor.ejs
rbb42dfb r834bcc3 195 195 } 196 196 197 /* ── Credit -veld met ©-invoegknop──────────────────────────── */197 /* ── Credit field with © insert button ──────────────────────────── */ 198 198 .te-credit-row { display: flex; gap: 0.4rem; align-items: stretch; } 199 199 .te-credit-row input { flex: 1; min-width: 0; } … … 638 638 } 639 639 640 // ── ©-teken invoegen in het credit-veld ─────────────────640 // ── Insert © symbol into the credit field ───────────────── 641 641 const copyrBtn = $('#te-credit-copyr'); 642 642 if (copyrBtn) { … … 693 693 }); 694 694 695 // ── Auto-du ur───────────────────────────────────────────696 // De server bepaalt de duur al automatisch bij upload. Dit is de vangnet/697 // UX-laag: opent een admin een bestaande track zónder duur, dan lezen we 'm698 // hier uit de audio-metadata en vullen het veld — zodat je nooit seconden699 // hoeft te typen. Bestaande waarde wordt nooit overschreven. We halen de700 // bytes via dezelfde header-gate als de speler (X-Audio-Player).695 // ── Auto-duration ─────────────────────────────────────────── 696 // The server already determines duration automatically on upload. This is the 697 // fallback/UX layer: if an admin opens an existing track without a duration, 698 // we read it from the audio metadata and fill the field — so you never need 699 // to type seconds manually. An existing value is never overwritten. We fetch 700 // the bytes via the same header gate as the player (X-Audio-Player). 701 701 (async function autoDuration() { 702 702 const durEl = $('#te-duration'); 703 703 if (!durEl || !track.stream_url) return; 704 if (durEl.value && Number(durEl.value) > 0) return; // al ingevuld → met rust laten704 if (durEl.value && Number(durEl.value) > 0) return; // already filled → leave it alone 705 705 let objUrl = null; 706 706 try {
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)