Changeset 6cbd014 in Klonkt


Ignore:
Timestamp:
07/21/2026 01:32:23 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
d48ea02
Parents:
d43230f
git-author:
Robin <roboburr@…> (07/21/2026 01:30:40 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 01:32:23 AM)
Message:

Feature: paid posts slice 4, cookie-less per-post unlock

The unlock leg of the paid-posts flow (klonkt-demo-3lz). A supporter who
already made a passkey (slice 3) opens a paid post and unlocks it with a
WebAuthn assertion, no account and no cookie.

  • Cookie-less: GET /paid/challenge hands out authentication options plus a short-lived (300s) signed blob carrying the challenge, the post slug and the post's required cents. The client returns both to POST /paid/unlock; nothing is kept between the two requests.
  • Discoverable credentials: allowCredentials is empty, so the browser offers the site's passkeys and the visitor stays pseudonymous.
  • Gate checks, in order: valid+unexpired entitlement for this passkey and site (else 403 -> the page sends the visitor to /paid/link to register), tier (entitlement cents >= post cents, else 403), then the assertion is verified and the signature counter bumped (clone detection).
  • The full post body is returned in that SAME response (renderPostBodyHtml, extracted from the page pipeline so unlocked HTML matches the normal render exactly). No unlock token becomes state.

Note: injected content covers text, images and external embeds; the
own-hosted audio player binds on load and is not re-initialised in
injected HTML yet (follow-up).

Changed files:
src/routes/posts.js

  • export renderPostBodyHtml (shared by the page and the unlock route)

src/services/PasskeyService.js

  • authenticationOptions, verifyAssertion, bumpCounter

src/routes/paid.js

  • GET /paid/challenge, POST /paid/unlock (cookie-less)

src/views/pages/paid-gate.ejs

  • Ontgrendel button + vendored SimpleWebAuthnBrowser assertion script; swaps the gate for the post on success, links to Patreon on 403

test/paid-unlock.test.js

  • auth options challenge + empty allowCredentials, counter bump, tier gate, expired entitlement not served

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Files:
1 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/routes/paid.js

    rd43230f r6cbd014  
    1515import PaidPatreon from '../services/PaidPatreonService.js';
    1616import Passkey from '../services/PasskeyService.js';
     17import { renderPostBodyHtml } from './posts.js';
    1718
    1819const router = express.Router();
     
    9596});
    9697
     98// Step 4 (unlock): hand out authentication options for a passkey assertion.
     99router.get('/challenge', async (req, res) => {
     100  const r = ready(req, res); if (!r) return;
     101  const slug = String(req.query.post || '').trim();
     102  const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
     103  if (!post || !post.paid) return res.status(404).json({ error: 'not_paid' });
     104  const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
     105  const options = await Passkey.authenticationOptions(baseUrl(req));
     106  const blob = signBlob({ purpose: 'auth', siteId: r.site.id, cents, post: post.slug, challenge: options.challenge }, 300);
     107  res.json({ options, blob });
     108});
     109
     110// Verify the assertion, check the entitlement, and return the full post body in
     111// the SAME response. No unlock token becomes state (design decision).
     112router.post('/unlock', express.json({ limit: '64kb' }), async (req, res) => {
     113  const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
     114  const { response, blob } = req.body || {};
     115  const payload = verifyBlob(String(blob || ''));
     116  if (!payload || payload.purpose !== 'auth' || payload.siteId !== r.site.id) return res.status(400).json({ error: 'bad_challenge' });
     117  const credId = response && response.id;
     118  const ent = credId ? Passkey.getEntitlement(credId, r.site.id) : null;
     119  if (!ent) return res.status(403).json({ error: 'no_entitlement' });      // unknown/expired passkey
     120  if ((ent.min_cents || 0) < payload.cents) return res.status(403).json({ error: 'tier' });
     121  const vr = await Passkey.verifyAssertion(baseUrl(req), response, payload.challenge, ent);
     122  if (!vr) return res.status(400).json({ error: 'verify_failed' });
     123  Passkey.bumpCounter(credId, vr.newCounter);
     124  const post = db.prepare("SELECT * FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(r.site.id, String(payload.post || ''));
     125  if (!post || !post.paid) return res.status(404).json({ error: 'gone' });
     126  res.json({ ok: true, title: post.title || '', html: renderPostBodyHtml(r.site, post, req) });
     127});
     128
    97129export default router;
  • src/routes/posts.js

    rd43230f r6cbd014  
    648648// post render and the fan gate (premium fan_only) so navigation is consistent
    649649// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
    650 // A short public teaser for a paid post: its excerpt, else the first ~280 chars
    651 // of the (stripped) content. Shared by the web gate and federation.
    652 function paidTeaser(post, max = 280) {
    653   if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
    654   // Only the FIRST paragraph: a paid teaser must never spill later content.
    655   const html = String((post && post.content) || '');
    656   const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
    657   const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
    658   return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
    659 }
    660 
    661 function postNeighbors(site, post, isHub) {
    662   const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
    663   const ordered = isHub
    664     ? db.prepare(`
    665         SELECT p.id, p.slug, p.title, p.pinned, s.slug AS site_slug
    666         FROM posts p JOIN sites s ON s.id = p.site_id
    667         WHERE p.status = 'published'
    668         ORDER BY p.published_at DESC
    669       `).all()
    670     : db.prepare(`
    671         SELECT id, slug, title, pinned FROM posts
    672         WHERE site_id = ? AND status = 'published'
    673         ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
    674       `).all(site.id);
    675   const idx = ordered.findIndex((p) => p.id === post.id);
    676   const newerPost = idx > 0 ? ordered[idx - 1] : null;
    677   const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
    678   if (newerPost) newerPost._urlBase = urlBaseFor(newerPost);
    679   if (olderPost) olderPost._urlBase = urlBaseFor(olderPost);
    680   return { newerPost, olderPost };
    681 }
    682 
    683 // ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
    684 // Standard fediverse "reply from your own server" landing endpoint. A post page
    685 // elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
    686 // composes a reply that federates back to that post.
    687 router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
    688   const site = res.locals.site;
    689   const uri = (req.query.uri || '').toString();
    690   const sent = !!req.query.sent;
    691   const followed = !!req.query.followed;
    692   const voted = !!req.query.voted;
    693   const reported = !!req.query.reported;
    694   let target = null, followTarget = null;
    695   if (!sent && !followed && !voted && !reported && uri) {
    696     try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
    697     // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
    698     if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
    699   }
    700   renderPage(req, res, 'pages/authorize-interaction', {
    701     pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
    702     bodyClass: 'on-special',
    703     uri,
    704     target,
    705     followTarget,
    706     sent,
    707     followed,
    708     voted: !!req.query.voted,
    709     reported: !!req.query.reported,
    710     liked: !!req.query.liked,
    711     boosted: !!req.query.boosted,
    712     reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
    713     siteTitle: site ? site.title : '',
    714   });
    715 });
    716 
    717 // 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
    718 // followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
    719 router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
    720   const site = res.locals.site;
    721   const uri = (req.body.uri || '').toString();
    722   let choice = req.body.choice;
    723   if (choice == null) choice = [];
    724   if (!Array.isArray(choice)) choice = [choice];
    725   if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
    726   res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
    727 });
    728 
    729 // 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
    730 router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
    731   const site = res.locals.site;
    732   const uri = (req.body.uri || '').toString();
    733   const actorUri = (req.body.actor_uri || '').toString();
    734   const reason = (req.body.reason || '').toString();
    735   if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
    736   res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
    737 });
    738 
    739 // ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
    740 router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
    741   const site = res.locals.site;
    742   const uri = (req.body.uri || '').toString();
    743   let on = false;
    744   if (site && uri) {
    745     on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
    746     ActivityPubService.resolveRemoteNote(uri)
    747       .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
    748       .catch((e) => console.warn('[AP] remote like failed:', e.message));
    749     ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
    750   }
    751   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
    752   res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
    753 });
    754 
    755 // 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
    756 // Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
    757 router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
    758   const site = res.locals.site;
    759   const uri = (req.body.uri || '').toString();
    760   let on = false;
    761   if (site && uri) {
    762     on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
    763     ActivityPubService.resolveRemoteNote(uri)
    764       .then((note) => {
    765         if (!note) return;
    766         const id = note.object_uri || uri;
    767         return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
    768           // Boost → store the post in the timeline (even if you don't follow the author) so it
    769           // surfaces in the Cirkel; unboost → just clear the flag.
    770           .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
    771       })
    772       .catch((e) => console.warn('[AP] remote boost failed:', e.message));
    773     ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
    774   }
    775   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
    776   res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
    777 });
    778 
    779 // Follow a remote actor from your own site (when the target is a profile, not a post).
    780 router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
    781   const site = res.locals.site;
    782   const uri = (req.body.uri || '').toString();
    783   if (site && uri) {
    784     ActivityPubService.followActor(site, uri)
    785       .catch((e) => console.warn('[AP] remote follow failed:', e.message));
    786   }
    787   res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
    788 });
    789 
    790 router.post('/authorize_interaction', requireSiteManager, (req, res) => {
    791   const site = res.locals.site;
    792   const uri = (req.body.uri || '').toString();
    793   const text = (req.body.text || '').toString();
    794   const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
    795   const language = (req.body.language || '').toString();
    796   let attachments = [];
    797   try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
    798   let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
    799   try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
    800   if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
    801     // Resolve + deliver in the background so Send responds instantly.
    802     ActivityPubService.resolveRemoteNote(uri)
    803       .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
    804       .catch((e) => console.warn('[AP] remote reply failed:', e.message));
    805   }
    806   res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
    807 });
    808 
    809 // Manage / delete your own outbound fediverse replies (site owner only).
    810 // Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
    811 // The old /fediverse (manage) and /notifications pages redirect here.
    812 router.get('/messages', requireSiteManager, (req, res) => {
    813   const site = res.locals.site;
    814   const append = req.query.append === '1';
    815   const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
    816   const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : [];
    817   const hasMore = page.length > FEED_PAGE;
    818   const items = page.slice(0, FEED_PAGE);
    819   // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
    820   const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
    821   // Only stamp "seen" on the first page load (not on Load-more appends).
    822   if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
    823   const moreBase = res.locals.siteUrlBase || '';
    824   if (append) {
    825     return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
    826   }
    827   renderPage(req, res, 'pages/messages', {
    828     pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
    829     hasMore, nextOffset: offset + FEED_PAGE, moreBase,
    830     success: req.query.success || null, error: req.query.error || null,
    831   });
    832 });
    833 router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
    834 
    835 router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
    836   const site = res.locals.site;
    837   if (site) {
    838     try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
    839     catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
    840   }
    841   res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
    842 });
    843 
    844 // Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
    845 // object URI so re-delivery and thread-crawling never bring it back. Works for private
    846 // notes too (acts on the local copy; no remote fetch involved).
    847 router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
    848   const site = res.locals.site;
    849   if (site) {
    850     const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
    851     if (r.error) console.warn('[AP] interaction remove failed:', r.error);
    852   }
    853   res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
    854 });
    855 
    856 // Moderation: report an INCOMING reply to its home instance (owner only). Uses the
    857 // locally stored object/actor URIs, so it also works for private notes that
    858 // authorize_interaction cannot fetch (401/404).
    859 router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
    860   const site = res.locals.site;
    861   if (site) {
    862     const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
    863     if (tgt && (tgt.objectUri || tgt.actorUri)) {
    864       try {
    865         const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
    866         if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
    867       } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
    868     }
    869   }
    870   res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
    871 });
    872 
    873 // Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
    874 router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
    875   const site = res.locals.site;
    876   const text = String(req.body.text || '');
    877   const html = String(req.body.content || '');   // rich reply editor HTML (sanitized in deliverOutboxUpdate)
    878   if (site && (text.trim() || html.trim())) {
    879     try {
    880       await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
    881         html, language: String(req.body.language || ''),
    882       });
    883     } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
    884   }
    885   res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
    886 });
    887 
    888 // ==================== FEDIVERSE CLIENT: home timeline + following ====================
    889 // Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
    890 // SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
    891 function timelineEmbedHtml(html) {
    892   if (!html) return null;
    893   const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
    894   while ((m = re.exec(html))) {
    895     const u = m[1]; if (seen.has(u)) continue; seen.add(u);
    896     let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
    897     if (!p) {
    898       // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
    899       // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
    900       // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
    901       const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
    902       if (pt) return `<iframe class="tl-embed-frame" src="https://${pt[1]}/videos/embed/${pt[2]}" title="PeerTube" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
    903       continue;
    904     }
    905     if (p.provider === 'youtube') return `<iframe class="tl-embed-frame" src="https://www.youtube-nocookie.com/embed/${p.id}" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
    906     if (p.provider === 'spotify') return `<iframe class="tl-embed-frame tl-embed-spotify" src="https://open.spotify.com/embed/${p.type}/${p.id}" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
    907     if (p.provider === 'soundcloud') return `<iframe class="tl-embed-frame tl-embed-sc" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(p.url)}&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>`;
    908     if (p.provider === 'vimeo') return `<iframe class="tl-embed-frame" src="https://player.vimeo.com/video/${p.id}" title="Vimeo" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
    909     if (p.provider === 'bandcamp') return `<iframe class="tl-embed-frame tl-embed-bandcamp" src="https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(u)}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/" title="Bandcamp" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
    910     if (p.provider === 'applemusic') { const am = u.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i); if (am) return `<iframe class="tl-embed-frame tl-embed-apple" src="https://embed.music.apple.com/${am[1]}" title="Apple Music" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>`; }
    911   }
    912   return null;
    913 }
    914 
    915 // A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
    916 // Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
    917 // (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
    918 function klonktAudioEmbed(html, url) {
    919   if (!html || !url || html.indexOf('🎵') < 0) return null;
    920   let u; try { u = new URL(url); } catch { return null; }
    921   if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
    922   const slug = u.pathname.replace(/^\/+|\/+$/g, '');
    923   if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
    924   const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
    925   // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
    926   const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
    927   return { origin: u.origin, embedUrl: src, content, html: `<iframe class="tl-embed-frame tl-embed-klonkt" src="${src}" title="Audio" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>` };
    928 }
    929 
    930 router.get('/news', requireSiteManager, (req, res) => {
    931   const site = res.locals.site;
    932   const append = req.query.append === '1';
    933   const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
    934   const cspOrigins = new Set();
    935   // Fetch one extra to know whether a "Load more" button belongs on this page.
    936   const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];
    937   const hasMore = rows.length > FEED_PAGE;
    938   const timeline = rows.slice(0, FEED_PAGE).map((p) => {
    939     let embedHtml = timelineEmbedHtml(p.content);
    940     let content = p.content;
    941     let embedUrl = null;
    942     if (!embedHtml) {
    943       const k = klonktAudioEmbed(p.content, p.url);
    944       if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
    945     }
    946     // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
    947     // top-level "open the player" link that works even when a browser shield/CSP blocks
    948     // the cross-site iframe (a full-page navigation is not a cross-site frame).
    949     let poll = null;
    950     if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
    951     return { ...p, content, embedHtml, embedUrl, poll };
    952   });
    953   // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
    954   // extending ONLY this response's CSP frame-src. The global policy stays locked down.
    955   if (cspOrigins.size) {
    956     const csp = res.getHeader('Content-Security-Policy');
    957     if (csp) {
    958       const extra = [...cspOrigins].join(' ');
    959       res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
    960     }
    961   }
    962   const moreBase = res.locals.siteUrlBase || '';
    963   if (append) {
    964     return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
    965   }
    966   renderPage(req, res, 'pages/news', {
    967     pageTitle: 'News', bodyClass: 'on-special',
    968     timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
    969     success: req.query.success || null, error: req.query.error || null,
    970   });
    971 });
    972 
    973 // Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
    974 // Connect = who you follow + who follows you, merged into one page with direction
    975 // (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
    976 // separate Following/Followers pages, which redirect here so old links keep working.
    977 router.get('/connect', requireSiteManager, (req, res) => {
    978   const site = res.locals.site;
    979   const connections = site ? ActivityPubService.listConnections(site.slug) : [];
    980   renderPage(req, res, 'pages/connect', {
    981     pageTitle: 'Connect', bodyClass: 'on-special',
    982     connections,
    983     success: req.query.success || null, error: req.query.error || null,
    984   });
    985 });
    986 router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
    987 router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
    988 
    989 router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
    990   const site = res.locals.site;
    991   const base = res.locals.siteUrlBase || '';
    992   if (!site) return res.redirect(`${base}/connect`);
    993   const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
    994   return res.redirect(`${base}/connect?` + (ok
    995     ? 'success=' + encodeURIComponent('Volger verwijderd')
    996     : 'error=' + encodeURIComponent('Volger niet gevonden')));
    997 });
    998 
    999 router.post('/news/follow', requireSiteManager, async (req, res) => {
    1000   const site = res.locals.site;
    1001   const handle = (req.body.handle || '').toString();
    1002   let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
    1003   if (site && handle.trim()) {
    1004     try {
    1005       const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
    1006       if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
    1007       else {
    1008         q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
    1009       }
    1010     } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
    1011   }
    1012   res.redirect('/following?' + q);
    1013 });
    1014 
    1015 router.post('/news/unfollow', requireSiteManager, async (req, res) => {
    1016   const site = res.locals.site;
    1017   const actorUri = (req.body.actor_uri || '').toString();
    1018   if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
    1019   res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
    1020 });
    1021 
    1022 // Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
    1023 router.post('/news/autoboost', requireSiteManager, (req, res) => {
    1024   const site = res.locals.site;
    1025   const actorUri = (req.body.actor_uri || '').toString();
    1026   if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
    1027   res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
    1028 });
    1029 
    1030 // Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
    1031 // no banner); no-JS → redirect back.
    1032 router.post('/news/like', requireSiteManager, async (req, res) => {
    1033   const site = res.locals.site;
    1034   const note = (req.body.note || '').toString();
    1035   let on = false;
    1036   if (site && note) {
    1037     on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
    1038     try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
    1039     if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
    1040   }
    1041   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
    1042   res.redirect('/news');
    1043 });
    1044 
    1045 // Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
    1046 router.post('/news/boost', requireSiteManager, async (req, res) => {
    1047   const site = res.locals.site;
    1048   const note = (req.body.note || '').toString();
    1049   let on = false;
    1050   if (site && note) {
    1051     on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
    1052     try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
    1053     if (on) {
    1054       ActivityPubService.markBoosted(site.slug, note); // instant UI state
    1055       // Fire-and-forget: re-resolve the note so the cached row is refreshed
    1056       // (cover/content) — boosting again heals a stale copy from EVERY boost
    1057       // path, not just the interact page.
    1058       ActivityPubService.resolveRemoteNote(note)
    1059         .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
    1060         .catch(() => { /* best-effort */ });
    1061     } else {
    1062       ActivityPubService.unmarkBoosted(site.slug, note);
    1063     }
    1064   }
    1065   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
    1066   res.redirect('/news');
    1067 });
    1068 
    1069 // Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
    1070 router.post('/news/vote', requireSiteManager, async (req, res) => {
    1071   const site = res.locals.site;
    1072   const note = (req.body.note || '').toString();
    1073   let choice = req.body.choice;
    1074   if (choice == null) choice = [];
    1075   if (!Array.isArray(choice)) choice = [choice];
    1076   if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
    1077   res.redirect('/news');
    1078 });
    1079 
    1080 // Notifications inbox (new followers + replies/likes/boosts on your posts).
    1081 router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
    1082 
    1083 // Blocking / defederation (owner-only).
    1084 router.get('/blocking', requireSiteManager, (req, res) => {
    1085   const site = res.locals.site;
    1086   const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
    1087   renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
    1088 });
    1089 
    1090 router.post('/blocking/add', requireSiteManager, async (req, res) => {
    1091   const site = res.locals.site;
    1092   let q = 'success=' + encodeURIComponent('Geblokkeerd');
    1093   if (site) {
    1094     try {
    1095       const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
    1096       if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
    1097       else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
    1098     } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
    1099   }
    1100   const ref = req.get('Referer') || '';
    1101   res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
    1102 });
    1103 
    1104 router.post('/blocking/remove', requireSiteManager, (req, res) => {
    1105   const site = res.locals.site;
    1106   if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
    1107   res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
    1108 });
    1109 
    1110 // ==================== VIEW POST (last route — catches /:slug) ====================
    1111 router.get('/:slug', (req, res, next) => {
    1112   if (RESERVED_SLUGS.has(req.params.slug)) return next();
    1113 
    1114   const site = res.locals.site;
    1115   if (!site) return next(); // -> nette 404 catch-all
    1116 
    1117   const post = db.prepare(`
    1118     SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
    1119     FROM posts p JOIN users u ON p.author_id = u.id
    1120     WHERE p.site_id = ? AND p.slug = ?
    1121   `).get(site.id, req.params.slug);
    1122 
    1123   if (!post) return next(); // unknown slug -> clean 404 catch-all
    1124 
    1125   // Permission to view: published OR (logged in + can edit)
    1126   if (post.status !== 'published') {
    1127     const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
    1128     if (!canEdit) return res.status(403).send('Not published');
    1129   }
    1130 
    1131   // Fan-only preview (premium #3): full content only for logged-in fans.
    1132   // Anonymous visitors get a clean login gate instead of the content (the title/
    1133   // teaser may still appear elsewhere as a teaser).
    1134   if (post.fan_only && !(req.session && req.session.user)) {
    1135     // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
    1136     // stuck on the fan gate but can keep browsing.
    1137     const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
    1138     return renderPage(req, res, 'pages/fan-gate', {
    1139       pageTitle: post.title || 'Alleen voor fans',
    1140       bodyClass: 'on-special',
    1141       fgTitle: post.title || '',
    1142       fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
    1143       newerPost,
    1144       olderPost,
    1145     });
    1146   }
    1147 
    1148   // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
    1149   // is not the owner/editor. The passkey unlock arrives in slices 3-4; for now
    1150   // the owner previews the full post, everyone else sees the teaser + notice.
    1151   const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
    1152   if (post.paid && !canEditThis) {
    1153     const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
    1154     return renderPage(req, res, 'pages/paid-gate', {
    1155       pageTitle: post.title || 'Voor supporters',
    1156       bodyClass: 'on-special',
    1157       pgTitle: post.title || '',
    1158       pgTeaser: paidTeaser(post),
    1159       pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
    1160       pgSlug: post.slug,
    1161       newerPost,
    1162       olderPost,
    1163     });
    1164   }
    1165 
    1166   // Statistics: count the view (skips admins + unpublished own-preview).
    1167   if (post.status === 'published') recordPostView(post, req);
    1168 
    1169   // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
    1170   // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
    1171   // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
    1172   // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
    1173   // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
     650// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
     651// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
     652export function renderPostBodyHtml(site, post, req) {
    1174653  let html = (post.content_rendered != null && post.content_rendered !== '')
    1175654    ? post.content_rendered
     
    1269748    html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
    1270749  }
    1271   // (linkify is baked into content_rendered at save now, not re-run here.)
    1272   post.content_html = html;
     750  return html;
     751}
     752
     753// A short public teaser for a paid post: its excerpt, else the first ~280 chars
     754// of the (stripped) content. Shared by the web gate and federation.
     755function paidTeaser(post, max = 280) {
     756  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
     757  // Only the FIRST paragraph: a paid teaser must never spill later content.
     758  const html = String((post && post.content) || '');
     759  const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
     760  const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
     761  return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
     762}
     763
     764function postNeighbors(site, post, isHub) {
     765  const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
     766  const ordered = isHub
     767    ? db.prepare(`
     768        SELECT p.id, p.slug, p.title, p.pinned, s.slug AS site_slug
     769        FROM posts p JOIN sites s ON s.id = p.site_id
     770        WHERE p.status = 'published'
     771        ORDER BY p.published_at DESC
     772      `).all()
     773    : db.prepare(`
     774        SELECT id, slug, title, pinned FROM posts
     775        WHERE site_id = ? AND status = 'published'
     776        ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
     777      `).all(site.id);
     778  const idx = ordered.findIndex((p) => p.id === post.id);
     779  const newerPost = idx > 0 ? ordered[idx - 1] : null;
     780  const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
     781  if (newerPost) newerPost._urlBase = urlBaseFor(newerPost);
     782  if (olderPost) olderPost._urlBase = urlBaseFor(olderPost);
     783  return { newerPost, olderPost };
     784}
     785
     786// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
     787// Standard fediverse "reply from your own server" landing endpoint. A post page
     788// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
     789// composes a reply that federates back to that post.
     790router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
     791  const site = res.locals.site;
     792  const uri = (req.query.uri || '').toString();
     793  const sent = !!req.query.sent;
     794  const followed = !!req.query.followed;
     795  const voted = !!req.query.voted;
     796  const reported = !!req.query.reported;
     797  let target = null, followTarget = null;
     798  if (!sent && !followed && !voted && !reported && uri) {
     799    try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
     800    // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
     801    if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
     802  }
     803  renderPage(req, res, 'pages/authorize-interaction', {
     804    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
     805    bodyClass: 'on-special',
     806    uri,
     807    target,
     808    followTarget,
     809    sent,
     810    followed,
     811    voted: !!req.query.voted,
     812    reported: !!req.query.reported,
     813    liked: !!req.query.liked,
     814    boosted: !!req.query.boosted,
     815    reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
     816    siteTitle: site ? site.title : '',
     817  });
     818});
     819
     820// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
     821// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
     822router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
     823  const site = res.locals.site;
     824  const uri = (req.body.uri || '').toString();
     825  let choice = req.body.choice;
     826  if (choice == null) choice = [];
     827  if (!Array.isArray(choice)) choice = [choice];
     828  if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
     829  res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
     830});
     831
     832// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
     833router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
     834  const site = res.locals.site;
     835  const uri = (req.body.uri || '').toString();
     836  const actorUri = (req.body.actor_uri || '').toString();
     837  const reason = (req.body.reason || '').toString();
     838  if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
     839  res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
     840});
     841
     842// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
     843router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
     844  const site = res.locals.site;
     845  const uri = (req.body.uri || '').toString();
     846  let on = false;
     847  if (site && uri) {
     848    on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
     849    ActivityPubService.resolveRemoteNote(uri)
     850      .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
     851      .catch((e) => console.warn('[AP] remote like failed:', e.message));
     852    ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
     853  }
     854  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     855  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
     856});
     857
     858// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
     859// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
     860router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
     861  const site = res.locals.site;
     862  const uri = (req.body.uri || '').toString();
     863  let on = false;
     864  if (site && uri) {
     865    on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
     866    ActivityPubService.resolveRemoteNote(uri)
     867      .then((note) => {
     868        if (!note) return;
     869        const id = note.object_uri || uri;
     870        return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
     871          // Boost → store the post in the timeline (even if you don't follow the author) so it
     872          // surfaces in the Cirkel; unboost → just clear the flag.
     873          .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
     874      })
     875      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
     876    ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
     877  }
     878  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     879  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
     880});
     881
     882// Follow a remote actor from your own site (when the target is a profile, not a post).
     883router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
     884  const site = res.locals.site;
     885  const uri = (req.body.uri || '').toString();
     886  if (site && uri) {
     887    ActivityPubService.followActor(site, uri)
     888      .catch((e) => console.warn('[AP] remote follow failed:', e.message));
     889  }
     890  res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
     891});
     892
     893router.post('/authorize_interaction', requireSiteManager, (req, res) => {
     894  const site = res.locals.site;
     895  const uri = (req.body.uri || '').toString();
     896  const text = (req.body.text || '').toString();
     897  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
     898  const language = (req.body.language || '').toString();
     899  let attachments = [];
     900  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
     901  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
     902  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
     903  if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
     904    // Resolve + deliver in the background so Send responds instantly.
     905    ActivityPubService.resolveRemoteNote(uri)
     906      .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
     907      .catch((e) => console.warn('[AP] remote reply failed:', e.message));
     908  }
     909  res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
     910});
     911
     912// Manage / delete your own outbound fediverse replies (site owner only).
     913// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
     914// The old /fediverse (manage) and /notifications pages redirect here.
     915router.get('/messages', requireSiteManager, (req, res) => {
     916  const site = res.locals.site;
     917  const append = req.query.append === '1';
     918  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
     919  const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : [];
     920  const hasMore = page.length > FEED_PAGE;
     921  const items = page.slice(0, FEED_PAGE);
     922  // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
     923  const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
     924  // Only stamp "seen" on the first page load (not on Load-more appends).
     925  if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
     926  const moreBase = res.locals.siteUrlBase || '';
     927  if (append) {
     928    return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
     929  }
     930  renderPage(req, res, 'pages/messages', {
     931    pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
     932    hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     933    success: req.query.success || null, error: req.query.error || null,
     934  });
     935});
     936router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
     937
     938router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
     939  const site = res.locals.site;
     940  if (site) {
     941    try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
     942    catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
     943  }
     944  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
     945});
     946
     947// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
     948// object URI so re-delivery and thread-crawling never bring it back. Works for private
     949// notes too (acts on the local copy; no remote fetch involved).
     950router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
     951  const site = res.locals.site;
     952  if (site) {
     953    const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
     954    if (r.error) console.warn('[AP] interaction remove failed:', r.error);
     955  }
     956  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
     957});
     958
     959// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
     960// locally stored object/actor URIs, so it also works for private notes that
     961// authorize_interaction cannot fetch (401/404).
     962router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
     963  const site = res.locals.site;
     964  if (site) {
     965    const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
     966    if (tgt && (tgt.objectUri || tgt.actorUri)) {
     967      try {
     968        const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
     969        if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
     970      } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
     971    }
     972  }
     973  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
     974});
     975
     976// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
     977router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
     978  const site = res.locals.site;
     979  const text = String(req.body.text || '');
     980  const html = String(req.body.content || '');   // rich reply editor HTML (sanitized in deliverOutboxUpdate)
     981  if (site && (text.trim() || html.trim())) {
     982    try {
     983      await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
     984        html, language: String(req.body.language || ''),
     985      });
     986    } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
     987  }
     988  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
     989});
     990
     991// ==================== FEDIVERSE CLIENT: home timeline + following ====================
     992// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
     993// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
     994function timelineEmbedHtml(html) {
     995  if (!html) return null;
     996  const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
     997  while ((m = re.exec(html))) {
     998    const u = m[1]; if (seen.has(u)) continue; seen.add(u);
     999    let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
     1000    if (!p) {
     1001      // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
     1002      // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
     1003      // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
     1004      const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
     1005      if (pt) return `<iframe class="tl-embed-frame" src="https://${pt[1]}/videos/embed/${pt[2]}" title="PeerTube" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
     1006      continue;
     1007    }
     1008    if (p.provider === 'youtube') return `<iframe class="tl-embed-frame" src="https://www.youtube-nocookie.com/embed/${p.id}" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
     1009    if (p.provider === 'spotify') return `<iframe class="tl-embed-frame tl-embed-spotify" src="https://open.spotify.com/embed/${p.type}/${p.id}" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
     1010    if (p.provider === 'soundcloud') return `<iframe class="tl-embed-frame tl-embed-sc" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(p.url)}&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>`;
     1011    if (p.provider === 'vimeo') return `<iframe class="tl-embed-frame" src="https://player.vimeo.com/video/${p.id}" title="Vimeo" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
     1012    if (p.provider === 'bandcamp') return `<iframe class="tl-embed-frame tl-embed-bandcamp" src="https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(u)}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/" title="Bandcamp" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
     1013    if (p.provider === 'applemusic') { const am = u.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i); if (am) return `<iframe class="tl-embed-frame tl-embed-apple" src="https://embed.music.apple.com/${am[1]}" title="Apple Music" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>`; }
     1014  }
     1015  return null;
     1016}
     1017
     1018// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
     1019// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
     1020// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
     1021function klonktAudioEmbed(html, url) {
     1022  if (!html || !url || html.indexOf('🎵') < 0) return null;
     1023  let u; try { u = new URL(url); } catch { return null; }
     1024  if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
     1025  const slug = u.pathname.replace(/^\/+|\/+$/g, '');
     1026  if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
     1027  const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
     1028  // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
     1029  const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
     1030  return { origin: u.origin, embedUrl: src, content, html: `<iframe class="tl-embed-frame tl-embed-klonkt" src="${src}" title="Audio" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>` };
     1031}
     1032
     1033router.get('/news', requireSiteManager, (req, res) => {
     1034  const site = res.locals.site;
     1035  const append = req.query.append === '1';
     1036  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
     1037  const cspOrigins = new Set();
     1038  // Fetch one extra to know whether a "Load more" button belongs on this page.
     1039  const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];
     1040  const hasMore = rows.length > FEED_PAGE;
     1041  const timeline = rows.slice(0, FEED_PAGE).map((p) => {
     1042    let embedHtml = timelineEmbedHtml(p.content);
     1043    let content = p.content;
     1044    let embedUrl = null;
     1045    if (!embedHtml) {
     1046      const k = klonktAudioEmbed(p.content, p.url);
     1047      if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
     1048    }
     1049    // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
     1050    // top-level "open the player" link that works even when a browser shield/CSP blocks
     1051    // the cross-site iframe (a full-page navigation is not a cross-site frame).
     1052    let poll = null;
     1053    if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
     1054    return { ...p, content, embedHtml, embedUrl, poll };
     1055  });
     1056  // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
     1057  // extending ONLY this response's CSP frame-src. The global policy stays locked down.
     1058  if (cspOrigins.size) {
     1059    const csp = res.getHeader('Content-Security-Policy');
     1060    if (csp) {
     1061      const extra = [...cspOrigins].join(' ');
     1062      res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
     1063    }
     1064  }
     1065  const moreBase = res.locals.siteUrlBase || '';
     1066  if (append) {
     1067    return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
     1068  }
     1069  renderPage(req, res, 'pages/news', {
     1070    pageTitle: 'News', bodyClass: 'on-special',
     1071    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     1072    success: req.query.success || null, error: req.query.error || null,
     1073  });
     1074});
     1075
     1076// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
     1077// Connect = who you follow + who follows you, merged into one page with direction
     1078// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
     1079// separate Following/Followers pages, which redirect here so old links keep working.
     1080router.get('/connect', requireSiteManager, (req, res) => {
     1081  const site = res.locals.site;
     1082  const connections = site ? ActivityPubService.listConnections(site.slug) : [];
     1083  renderPage(req, res, 'pages/connect', {
     1084    pageTitle: 'Connect', bodyClass: 'on-special',
     1085    connections,
     1086    success: req.query.success || null, error: req.query.error || null,
     1087  });
     1088});
     1089router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
     1090router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
     1091
     1092router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
     1093  const site = res.locals.site;
     1094  const base = res.locals.siteUrlBase || '';
     1095  if (!site) return res.redirect(`${base}/connect`);
     1096  const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
     1097  return res.redirect(`${base}/connect?` + (ok
     1098    ? 'success=' + encodeURIComponent('Volger verwijderd')
     1099    : 'error=' + encodeURIComponent('Volger niet gevonden')));
     1100});
     1101
     1102router.post('/news/follow', requireSiteManager, async (req, res) => {
     1103  const site = res.locals.site;
     1104  const handle = (req.body.handle || '').toString();
     1105  let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
     1106  if (site && handle.trim()) {
     1107    try {
     1108      const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
     1109      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
     1110      else {
     1111        q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
     1112      }
     1113    } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
     1114  }
     1115  res.redirect('/following?' + q);
     1116});
     1117
     1118router.post('/news/unfollow', requireSiteManager, async (req, res) => {
     1119  const site = res.locals.site;
     1120  const actorUri = (req.body.actor_uri || '').toString();
     1121  if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
     1122  res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
     1123});
     1124
     1125// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
     1126router.post('/news/autoboost', requireSiteManager, (req, res) => {
     1127  const site = res.locals.site;
     1128  const actorUri = (req.body.actor_uri || '').toString();
     1129  if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
     1130  res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
     1131});
     1132
     1133// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
     1134// no banner); no-JS → redirect back.
     1135router.post('/news/like', requireSiteManager, async (req, res) => {
     1136  const site = res.locals.site;
     1137  const note = (req.body.note || '').toString();
     1138  let on = false;
     1139  if (site && note) {
     1140    on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
     1141    try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
     1142    if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
     1143  }
     1144  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     1145  res.redirect('/news');
     1146});
     1147
     1148// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
     1149router.post('/news/boost', requireSiteManager, async (req, res) => {
     1150  const site = res.locals.site;
     1151  const note = (req.body.note || '').toString();
     1152  let on = false;
     1153  if (site && note) {
     1154    on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
     1155    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
     1156    if (on) {
     1157      ActivityPubService.markBoosted(site.slug, note); // instant UI state
     1158      // Fire-and-forget: re-resolve the note so the cached row is refreshed
     1159      // (cover/content) — boosting again heals a stale copy from EVERY boost
     1160      // path, not just the interact page.
     1161      ActivityPubService.resolveRemoteNote(note)
     1162        .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
     1163        .catch(() => { /* best-effort */ });
     1164    } else {
     1165      ActivityPubService.unmarkBoosted(site.slug, note);
     1166    }
     1167  }
     1168  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     1169  res.redirect('/news');
     1170});
     1171
     1172// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
     1173router.post('/news/vote', requireSiteManager, async (req, res) => {
     1174  const site = res.locals.site;
     1175  const note = (req.body.note || '').toString();
     1176  let choice = req.body.choice;
     1177  if (choice == null) choice = [];
     1178  if (!Array.isArray(choice)) choice = [choice];
     1179  if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
     1180  res.redirect('/news');
     1181});
     1182
     1183// Notifications inbox (new followers + replies/likes/boosts on your posts).
     1184router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
     1185
     1186// Blocking / defederation (owner-only).
     1187router.get('/blocking', requireSiteManager, (req, res) => {
     1188  const site = res.locals.site;
     1189  const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
     1190  renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
     1191});
     1192
     1193router.post('/blocking/add', requireSiteManager, async (req, res) => {
     1194  const site = res.locals.site;
     1195  let q = 'success=' + encodeURIComponent('Geblokkeerd');
     1196  if (site) {
     1197    try {
     1198      const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
     1199      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
     1200      else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
     1201    } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
     1202  }
     1203  const ref = req.get('Referer') || '';
     1204  res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
     1205});
     1206
     1207router.post('/blocking/remove', requireSiteManager, (req, res) => {
     1208  const site = res.locals.site;
     1209  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
     1210  res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
     1211});
     1212
     1213// ==================== VIEW POST (last route — catches /:slug) ====================
     1214router.get('/:slug', (req, res, next) => {
     1215  if (RESERVED_SLUGS.has(req.params.slug)) return next();
     1216
     1217  const site = res.locals.site;
     1218  if (!site) return next(); // -> nette 404 catch-all
     1219
     1220  const post = db.prepare(`
     1221    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
     1222    FROM posts p JOIN users u ON p.author_id = u.id
     1223    WHERE p.site_id = ? AND p.slug = ?
     1224  `).get(site.id, req.params.slug);
     1225
     1226  if (!post) return next(); // unknown slug -> clean 404 catch-all
     1227
     1228  // Permission to view: published OR (logged in + can edit)
     1229  if (post.status !== 'published') {
     1230    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
     1231    if (!canEdit) return res.status(403).send('Not published');
     1232  }
     1233
     1234  // Fan-only preview (premium #3): full content only for logged-in fans.
     1235  // Anonymous visitors get a clean login gate instead of the content (the title/
     1236  // teaser may still appear elsewhere as a teaser).
     1237  if (post.fan_only && !(req.session && req.session.user)) {
     1238    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
     1239    // stuck on the fan gate but can keep browsing.
     1240    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
     1241    return renderPage(req, res, 'pages/fan-gate', {
     1242      pageTitle: post.title || 'Alleen voor fans',
     1243      bodyClass: 'on-special',
     1244      fgTitle: post.title || '',
     1245      fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
     1246      newerPost,
     1247      olderPost,
     1248    });
     1249  }
     1250
     1251  // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
     1252  // is not the owner/editor. The passkey unlock arrives in slices 3-4; for now
     1253  // the owner previews the full post, everyone else sees the teaser + notice.
     1254  const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
     1255  if (post.paid && !canEditThis) {
     1256    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
     1257    return renderPage(req, res, 'pages/paid-gate', {
     1258      pageTitle: post.title || 'Voor supporters',
     1259      bodyClass: 'on-special',
     1260      pgTitle: post.title || '',
     1261      pgTeaser: paidTeaser(post),
     1262      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
     1263      pgSlug: post.slug,
     1264      newerPost,
     1265      olderPost,
     1266    });
     1267  }
     1268
     1269  // Statistics: count the view (skips admins + unpublished own-preview).
     1270  if (post.status === 'published') recordPostView(post, req);
     1271
     1272  // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
     1273  // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
     1274  // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
     1275  // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
     1276  // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
     1277  post.content_html = renderPostBodyHtml(site, post, req);
    12731278
    12741279  if (post.tags) {
  • src/services/PasskeyService.js

    rd43230f r6cbd014  
    6363}
    6464
     65// Authentication (assertion) options for the unlock. Discoverable credentials,
     66// so allowCredentials is empty and the browser offers the site's passkeys.
     67export async function authenticationOptions(base) {
     68  const { rpID } = rpFor(base);
     69  const { generateAuthenticationOptions } = await lib();
     70  return generateAuthenticationOptions({ rpID, userVerification: 'preferred', allowCredentials: [] });
     71}
     72
     73// Verify an assertion against a stored entitlement row. Returns { newCounter }
     74// or null. Challenge is read from the signed blob by the caller.
     75export async function verifyAssertion(base, response, expectedChallenge, ent) {
     76  const { rpID, origin } = rpFor(base);
     77  let v;
     78  try {
     79    const { verifyAuthenticationResponse } = await lib();
     80    v = await verifyAuthenticationResponse({
     81      response,
     82      expectedChallenge,
     83      expectedOrigin: origin,
     84      expectedRPID: rpID,
     85      requireUserVerification: false,
     86      credential: {
     87        id: ent.credential_id,
     88        publicKey: Buffer.from(ent.public_key, 'base64url'),
     89        counter: ent.counter || 0,
     90        transports: ent.transports ? JSON.parse(ent.transports) : undefined,
     91      },
     92    });
     93  } catch { return null; }
     94  if (!v || !v.verified) return null;
     95  return { newCounter: v.authenticationInfo.newCounter };
     96}
     97
     98// Bump the signature counter after a successful assertion (clone detection).
     99export function bumpCounter(credentialId, newCounter) {
     100  db.prepare('UPDATE paid_entitlements SET counter = ? WHERE credential_id = ?').run(newCounter || 0, credentialId);
     101}
     102
    65103// Store (or refresh) a pseudonymous entitlement for this passkey.
    66104export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
     
    96134  rpFor, registrationOptions, verifyRegistration, storeEntitlement,
    97135  getEntitlement, deleteEntitlement, pruneExpired,
     136  authenticationOptions, verifyAssertion, bumpCounter,
    98137};
  • src/views/pages/paid-gate.ejs

    rd43230f r6cbd014  
    33</div>
    44
    5 <article class="pg-page">
     5<article class="pg-page" id="pg-page">
    66  <% if (typeof pgTitle !== 'undefined' && pgTitle) { %><h1 class="pg-title"><%= pgTitle %></h1><% } %>
    77
     
    1818      dan ontgrendel je 'm met je passkey. Geen account, geen cookie.
    1919    </p>
    20     <p class="pg-soon">Ontgrendelen met je Patreon-passkey komt eraan.</p>
     20    <button type="button" id="pg-unlock" class="pg-btn">Ontgrendelen</button>
     21    <p id="pg-status" class="pg-status" hidden></p>
    2122  </section>
    2223</article>
     24
     25<script src="/assets/vendor/simplewebauthn-browser.umd.min.js" nonce="<%= cspNonce %>"></script>
     26<script nonce="<%= cspNonce %>">
     27(function () {
     28  var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>";
     29  var slug = "<%= pgSlug %>";
     30  var btn = document.getElementById('pg-unlock');
     31  var status = document.getElementById('pg-status');
     32  var page = document.getElementById('pg-page');
     33  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
     34  function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); }
     35
     36  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { btn.textContent = 'Word supporter'; btn.addEventListener('click', toLink); return; }
     37
     38  btn.addEventListener('click', function () {
     39    btn.disabled = true;
     40    say('Bevestig met je passkey…');
     41    fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug))
     42      .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); })
     43      .then(function (data) {
     44        return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options })
     45          .then(function (response) {
     46            return fetch(base + '/paid/unlock', {
     47              method: 'POST', headers: { 'Content-Type': 'application/json' },
     48              body: JSON.stringify({ response: response, blob: data.blob }),
     49            });
     50          });
     51      })
     52      .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
     53      .then(function (res) {
     54        if (res.j && res.j.ok) {
     55          // Swap the gate for the full post, client-side (no cookie kept).
     56          var h = document.createElement('div');
     57          h.innerHTML = (res.j.title ? '<h1 class="post-title">' + res.j.title + '</h1>' : '') +
     58            '<div class="post-content">' + res.j.html + '</div>';
     59          page.replaceWith(h);
     60        } else if (res.status === 403) {
     61          toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
     62        } else {
     63          btn.disabled = false; say('Ontgrendelen mislukt. Probeer opnieuw.', true);
     64        }
     65      })
     66      .catch(function (e) {
     67        if (e && e.link) { toLink(); return; }
     68        if (e && e.name === 'NotAllowedError') { toLink(); return; }   // cancelled / no passkey -> link
     69        btn.disabled = false; say('Er ging iets mis. Probeer opnieuw.', true);
     70      });
     71  });
     72})();
     73</script>
    2374
    2475<style>
     
    3283  .pg-h2 { font-size: 22px; margin: 0 0 8px; }
    3384  .pg-sub { opacity: .85; line-height: 1.6; margin: 0 auto 12px; max-width: 34em; }
    34   .pg-soon { display: inline-block; padding: 10px 18px; border-radius: 10px; font-weight: 600;
    35     background: color-mix(in srgb, var(--accent, #6b8f71) 14%, transparent); color: var(--ink, inherit); }
     85  .pg-btn { padding: 12px 24px; border: none; border-radius: 10px; font: inherit; font-weight: 600; cursor: pointer;
     86    background: var(--accent, #6b8f71); color: #fff; }
     87  .pg-btn:disabled { opacity: .6; cursor: default; }
     88  .pg-status { margin: 12px 0 0; opacity: .9; }
     89  .pg-status.is-err { color: #c0392b; }
    3690</style>
Note: See TracChangeset for help on using the changeset viewer.