Changeset 834bcc3 in Klonkt for src/middleware
- Timestamp:
- 06/23/2026 06:14:27 PM (3 months ago)
- Branches:
- main
- Children:
- d774679
- Parents:
- bb42dfb
- Location:
- src/middleware
- Files:
-
- 4 edited
Legend:
- Unmodified
- Added
- Removed
-
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
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)