Changeset af2cc73 in Klonkt
- Timestamp:
- 08/18/2026 06:50:08 PM (3 weeks ago)
- Branches:
- main
- Children:
- d85b66f
- Parents:
- 9da8027
- git-author:
- Bart <bart@…> (08/18/2026 06:05:22 PM)
- git-committer:
- Bart <bart@…> (08/18/2026 06:50:08 PM)
- Files:
-
- 4 added
- 8 edited
-
src/config/database.js (modified) (1 diff)
-
src/routes/activitypub.js (modified) (1 diff)
-
src/routes/openwebauth.js (added)
-
src/routes/posts.js (modified) (4 diffs)
-
src/server.js (modified) (2 diffs)
-
src/services/OpenWebAuthService.js (added)
-
src/services/PostAccessService.js (modified) (2 diffs)
-
src/services/i18n.js (modified) (3 diffs)
-
src/views/pages/fan-gate.ejs (modified) (2 diffs)
-
test/openwebauth-http.test.js (added)
-
test/openwebauth.test.js (added)
-
test/post-access.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r9da8027 raf2cc73 427 427 ); 428 428 429 -- OpenWebAuth (FEP-61cf): eenmalige tokens waarmee een BEZOEKER van elders 430 -- bewijst wie hij is. Klonkt is hier de 'target instance': we hebben nooit 431 -- iemands prive-sleutel nodig, alleen zijn publieke -- dus staat hier ook 432 -- geen geheim van een ander in. 433 -- 434 -- Het token is kort houdbaar (minuten, zie OpenWebAuthService) en gaat na 435 -- inwisselen meteen weg: eenmalig is de hele bedoeling. De FEP noemt de 436 -- opruiming expliciet als DoS-verdediging -- zonder vervaltijd vult iemand 437 -- deze tabel met tokens die hij nooit inwisselt. 438 CREATE TABLE IF NOT EXISTS owa_tokens ( 439 token TEXT PRIMARY KEY, 440 actor_uri TEXT NOT NULL, 441 created_at INTEGER NOT NULL -- ms sinds epoch 442 ); 443 CREATE INDEX IF NOT EXISTS idx_owa_tokens_created ON owa_tokens(created_at); 444 429 445 CREATE TABLE IF NOT EXISTS ap_followers ( 430 446 id INTEGER PRIMARY KEY AUTOINCREMENT, -
src/routes/activitypub.js
r9da8027 raf2cc73 130 130 } 131 131 132 /** 133 * Is dit een vraag naar de WORTEL van deze server? 134 * 135 * FEP-61cf laat de home instance webfingeren op "the root URL of the destination 136 * site" om ons token-endpoint te vinden. Dat is een URL en geen `acct:`, dus hij 137 * strandde hierboven op de 400 -- terwijl een ACTOR-URI met een pad wel degelijk 138 * een 400 hoort te blijven (dat legt test/webfinger-bare-host.test.js vast, en 139 * dat is een uitgesproken keuze van eerder). Vandaar: alleen origin + '/' telt, 140 * alles met een pad niet. 141 */ 142 function isEigenWortel(resource, req) { 143 const r = String(resource || '').trim(); 144 if (!/^https?:\/\//i.test(r)) return false; 145 try { 146 const u = new URL(r); 147 if (u.pathname && u.pathname !== '/') return false; 148 if (u.search || u.hash) return false; 149 return asciiHost(u.host) === asciiHost(hostOf(req)); 150 } catch { return false; } 151 } 152 132 153 router.get('/.well-known/webfinger', (req, res) => { 154 if (isEigenWortel(req.query.resource, req)) { 155 res.type('application/jrd+json; charset=utf-8'); 156 res.set('Cache-Control', 'public, max-age=300'); 157 return res.send(JSON.stringify({ 158 subject: baseUrl(req) + '/', 159 links: [ 160 // Waar een home instance ondertekend een token mag ophalen (FEP-61cf 161 // stap 2/3). Host-niveau en niet per site: het token zegt WIE er binnen 162 // is, niet waar hij binnen mag -- dat besluit valt bij de poort. 163 { rel: 'http://purl.org/openwebauth/v1', href: baseUrl(req) + '/owa/token' }, 164 ], 165 })); 166 } 133 167 const user = webfingerGebruiker(req.query.resource); 134 168 if (!user) return res.status(400).type('text/plain').send('bad resource'); -
src/routes/posts.js
r9da8027 raf2cc73 25 25 import { verifyBlob } from '../services/CryptoBox.js'; 26 26 import { postEntry } from '../services/PostAccessService.js'; 27 import * as OWA from '../services/OpenWebAuthService.js'; 27 28 import MusicMeta from '../services/MusicMeta.js'; 28 29 import { mediaDir } from '../config/paths.js'; … … 702 703 // tekst op, want wat niet gerenderd wordt kan ook niet lekken. 703 704 function readerItems(site, rows, req) { 704 const viewer = { user: req.session?.user || null, site, unlockedSlug: null };705 const viewer = OWA.viewerFor(req, site, { unlockedSlug: null }); 705 706 return rows.map((post) => ({ 706 707 post, … … 1589 1590 // Anonymous visitors get a clean login gate instead of the content (the title/ 1590 1591 // teaser may still appear elsewhere as a teaser). 1591 if (post.fan_only && !(req.session && req.session.user)) { 1592 // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site 1593 // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien. 1594 const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req)); 1595 if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) { 1592 1596 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get 1593 1597 // stuck on the fan gate but can keep browsing. … … 1598 1602 fgTitle: post.title || '', 1599 1603 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug, 1604 owaError: !!(req.query && req.query.owa_error), 1600 1605 newerPost, 1601 1606 olderPost, -
src/server.js
r9da8027 raf2cc73 66 66 import ogRoutes from './routes/og.js'; 67 67 import apRoutes from './routes/activitypub.js'; 68 import owaRoutes, { owaMiddleware } from './routes/openwebauth.js'; 68 69 import oauthRoutes from './routes/oauth.js'; 69 70 import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js'; … … 311 312 // ActivityPub: WebFinger + /ap/* (site-agnostic, resolves the site by slug). 312 313 app.use(apRoutes); 314 // OpenWebAuth (FEP-61cf): het token-endpoint en het inlogformulier. 315 app.use(owaRoutes); 316 // En op elk GET-verzoek kijken of er een token wordt ingewisseld (?owt=) of een 317 // stroom gestart (?zid=). Na de sessie, want het resultaat gaat IN de sessie; 318 // voor de pagina's, want een poort verderop moet de uitkomst al kunnen zien. 319 app.use(owaMiddleware); 313 320 // ActivityPub C2S: OAuth 2.0 (native/web clients). Site-agnostic; the consent 314 321 // screen picks which site the token can post as. -
src/services/PostAccessService.js
r9da8027 raf2cc73 44 44 * 45 45 * @param post de rij uit `posts` 46 * @param viewer { user, site, unlockedSlug } -- unlockedSlug is de slug uit 47 * een vers ?u=-bewijs van /paid/unlock, al geverifieerd door de 48 * aanroeper. Deze module doet geen crypto. 46 * @param viewer { user, site, unlockedSlug, isFollower } -- unlockedSlug is de 47 * slug uit een vers ?u=-bewijs van /paid/unlock, al geverifieerd 48 * door de aanroeper; isFollower komt uit OpenWebAuthService en 49 * zegt dat een bewezen fedi-actor deze site volgt. Deze module 50 * doet geen crypto en raakt de database niet aan: ophalen doet 51 * de aanroeper, beslissen doet deze. 49 52 */ 50 export function postAccess(post, { user = null, site = null, unlockedSlug = null } = {}) {53 export function postAccess(post, { user = null, site = null, unlockedSlug = null, isFollower = false } = {}) { 51 54 if (!post) return 'forbidden'; 52 55 … … 62 65 63 66 if (post.paid && String(unlockedSlug || '') !== String(post.slug)) return 'paid'; 64 if (post.fan_only && !user) return 'fan'; 67 // `fan_only` betekende altijd al "mijn volgers op de fediverse", maar de poort 68 // vroeg om een lokaal account -- de verkeerde vraag, die juist de mensen 69 // buitensloot voor wie hij openstond. `isFollower` is het antwoord op de 70 // ECHTE vraag: een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn 71 // en die deze site volgt. Wie dat bewijs levert, komt binnen. 72 // 73 // Het lokale account blijft er ook door, zoals het altijd deed: dat is een 74 // andere manier om te weten wie iemand is, niet een mindere. 75 if (post.fan_only && !user && !isFollower) return 'fan'; 65 76 return 'full'; 66 77 } -
src/services/i18n.js
r9da8027 raf2cc73 890 890 'epk.recent': 'Recent', 891 891 'fgate.title': 'Alleen voor vrienden', 892 'fgate.sub': 'Dit is exclusief voor ingelogde vrienden. Log in om het te bekijken.',893 'fgate. login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',892 'fgate.sub': 'Dit bericht is voor wie deze site volgt.', 893 'fgate.owa_label': 'Log in met je eigen fediverse-adres', 'fgate.owa_go': 'Ga verder', 'fgate.owa_hint': 'Je server bevestigt wie je bent. Hier heb je geen account en geen wachtwoord nodig — volg je deze site, dan ben je binnen.', 'fgate.owa_failed': 'Dat adres kon ik niet bereiken. Klopt de spelling?', 'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen', 894 894 'lbio.empty': 'Nog geen links ingesteld.', 895 895 'lbio.back_to_site': 'naar de site', … … 1896 1896 'epk.recent': 'Recent', 1897 1897 'fgate.title': 'Friends only', 1898 'fgate.sub': 'This is exclusive to logged-in friends. Log in to view it.',1899 'fgate. login': 'Log in / sign up', 'read.open': 'Replies and reactions',1898 'fgate.sub': 'This post is for people who follow this site.', 1899 'fgate.owa_label': 'Sign in with your own fediverse address', 'fgate.owa_go': 'Continue', 'fgate.owa_hint': 'Your own server confirms who you are. No account and no password here — if you follow this site, you are in.', 'fgate.owa_failed': 'I could not reach that address. Is the spelling right?', 'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions', 1900 1900 'lbio.empty': 'No links set up yet.', 1901 1901 'lbio.back_to_site': 'back to the site', … … 2902 2902 'epk.recent': 'Aktuell', 2903 2903 'fgate.title': 'Nur für Freunde', 2904 'fgate.sub': 'Dies ist exklusiv für eingeloggte Freunde. Melde dich an, um es anzusehen.',2905 'fgate. login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',2904 'fgate.sub': 'Dieser Beitrag ist für alle, die dieser Seite folgen.', 2905 'fgate.owa_label': 'Mit deiner eigenen Fediverse-Adresse anmelden', 'fgate.owa_go': 'Weiter', 'fgate.owa_hint': 'Dein eigener Server bestätigt, wer du bist. Kein Konto und kein Passwort hier — wenn du dieser Seite folgst, bist du drin.', 'fgate.owa_failed': 'Diese Adresse war nicht erreichbar. Stimmt die Schreibweise?', 'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen', 2906 2906 'lbio.empty': 'Noch keine Links eingerichtet.', 2907 2907 'lbio.back_to_site': 'zurück zur Seite', -
src/views/pages/fan-gate.ejs
r9da8027 raf2cc73 12 12 <% if (typeof fgTitle !== 'undefined' && fgTitle) { %><p class="fg-which">"<%= fgTitle %>"</p><% } %> 13 13 <p class="fg-sub"><%= t('fgate.sub') %></p> 14 <p><a class="fg-btn" href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p> 14 15 <%# De hoofdweg: bewijs bij je EIGEN server wie je bent (OpenWebAuth, 16 FEP-61cf). Dit is de vraag die fan_only altijd al stelde -- volg je deze 17 site? -- in plaats van "heb je hier een account", wat juist de mensen 18 buitensloot voor wie de poort openstond. Geen account hier, geen 19 wachtwoord hier, geen cookie van een derde. %> 20 <form class="fg-owa" method="post" action="/owa/login"> 21 <input type="hidden" name="next" value="<%= fgNext || '/' %>"> 22 <label class="fg-label" for="fg-handle"><%= t('fgate.owa_label') %></label> 23 <div class="fg-row"> 24 <input class="fg-input" id="fg-handle" name="handle" type="text" inputmode="email" 25 autocomplete="username" spellcheck="false" placeholder="@jij@jouwserver.nl" required> 26 <button class="fg-btn" type="submit"><%= t('fgate.owa_go') %></button> 27 </div> 28 <p class="fg-hint"><%= t('fgate.owa_hint') %></p> 29 <% if (typeof owaError !== 'undefined' && owaError) { %> 30 <p class="fg-error"><%= t('fgate.owa_failed') %></p> 31 <% } %> 32 </form> 33 34 <%# De lokale inlog blijft, maar als tweede: die is voor wie hier echt een 35 account heeft, en dat is de uitzondering. %> 36 <p class="fg-alt"><a href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p> 15 37 </div> 16 38 </section> … … 26 48 .fg-which { font-style: italic; opacity: .8; margin: 0 0 12px; } 27 49 .fg-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; } 28 .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; } 50 .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; border: 0; font-family: inherit; font-size: 1rem; cursor: pointer; } 51 .fg-owa { margin: 0 0 18px; text-align: left; } 52 .fg-label { display: block; font-size: .85rem; opacity: .8; margin-bottom: 6px; } 53 .fg-row { display: flex; gap: 8px; flex-wrap: wrap; } 54 .fg-input { flex: 1 1 12rem; min-width: 0; padding: 12px 14px; border-radius: 10px; 55 border: 1px solid rgba(128,128,128,.35); background: transparent; color: inherit; 56 font-family: inherit; font-size: 1rem; } 57 .fg-hint { font-size: .8rem; opacity: .65; margin: 8px 0 0; line-height: 1.5; } 58 .fg-error { font-size: .85rem; margin: 8px 0 0; color: #d66; } 59 .fg-alt { margin: 0; font-size: .85rem; opacity: .7; } 29 60 </style> -
test/post-access.test.js
r9da8027 raf2cc73 77 77 }); 78 78 79 // ── de fanpoort stelt eindelijk de juiste vraag ─────────────────────────── 80 // fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort 81 // vroeg om een lokaal account. isFollower komt uit OpenWebAuthService: een 82 // bezoeker die via FEP-61cf bewees @iemand@ergens te zijn en die deze site volgt. 83 84 test('een bewezen volger komt door de fanpoort', () => { 85 const f = post({ fan_only: 1 }); 86 assert.equal(postAccess(f, { user: VREEMDE, site: SITE }), 'fan', 'zonder bewijs nog steeds dicht'); 87 assert.equal(postAccess(f, { user: VREEMDE, site: SITE, isFollower: true }), 'full', 88 'mét bewijs open -- geen account, geen wachtwoord, wel een volger'); 89 }); 90 91 test('maar volgen koopt geen betaald bericht', () => { 92 // De volgorde blijft: betaald wint van fan-only, en volgerschap is geen geld. 93 const b = post({ paid: 1, fan_only: 1 }); 94 assert.equal(postAccess(b, { user: VREEMDE, site: SITE, isFollower: true }), 'paid'); 95 assert.equal(postAccess(b, { user: VREEMDE, site: SITE, isFollower: true, unlockedSlug: 'hallo' }), 'full', 96 'betaald EN volger komt er wel doorheen'); 97 }); 98 99 test('volgen doet niets bij een concept', () => { 100 assert.equal(postAccess(post({ status: 'draft' }), { user: VREEMDE, site: SITE, isFollower: true }), 'forbidden'); 101 }); 102 79 103 test('de eigenaar loopt niet tegen zijn eigen muur op', () => { 80 104 assert.equal(postAccess(post({ paid: 1, fan_only: 1 }), { user: OWNER, site: SITE }), 'full');
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)