Changeset 6cbd014 in Klonkt
- Timestamp:
- 07/21/2026 01:32:23 AM (7 weeks ago)
- 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)
- Files:
-
- 1 added
- 4 edited
-
src/routes/paid.js (modified) (2 diffs)
-
src/routes/posts.js (modified) (2 diffs)
-
src/services/PasskeyService.js (modified) (2 diffs)
-
src/views/pages/paid-gate.ejs (modified) (3 diffs)
-
test/paid-unlock.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/paid.js
rd43230f r6cbd014 15 15 import PaidPatreon from '../services/PaidPatreonService.js'; 16 16 import Passkey from '../services/PasskeyService.js'; 17 import { renderPostBodyHtml } from './posts.js'; 17 18 18 19 const router = express.Router(); … … 95 96 }); 96 97 98 // Step 4 (unlock): hand out authentication options for a passkey assertion. 99 router.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). 112 router.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 97 129 export default router; -
src/routes/posts.js
rd43230f r6cbd014 648 648 // post render and the fan gate (premium fan_only) so navigation is consistent 649 649 // 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. 652 export function renderPostBodyHtml(site, post, req) { 1174 653 let html = (post.content_rendered != null && post.content_rendered !== '') 1175 654 ? post.content_rendered … … 1269 748 html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, ''); 1270 749 } 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. 755 function 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 764 function 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. 790 router.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. 822 router.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). 833 router.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). 843 router.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). 860 router.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). 883 router.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 893 router.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. 915 router.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 }); 936 router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`)); 937 938 router.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). 950 router.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). 962 router.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). 977 router.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. 994 function 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. 1021 function 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 1033 router.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. 1080 router.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 }); 1089 router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`)); 1090 router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`)); 1091 1092 router.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 1102 router.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 1118 router.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. 1126 router.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. 1135 router.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. 1149 router.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. 1173 router.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). 1184 router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`)); 1185 1186 // Blocking / defederation (owner-only). 1187 router.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 1193 router.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 1207 router.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) ==================== 1214 router.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); 1273 1278 1274 1279 if (post.tags) { -
src/services/PasskeyService.js
rd43230f r6cbd014 63 63 } 64 64 65 // Authentication (assertion) options for the unlock. Discoverable credentials, 66 // so allowCredentials is empty and the browser offers the site's passkeys. 67 export 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. 75 export 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). 99 export function bumpCounter(credentialId, newCounter) { 100 db.prepare('UPDATE paid_entitlements SET counter = ? WHERE credential_id = ?').run(newCounter || 0, credentialId); 101 } 102 65 103 // Store (or refresh) a pseudonymous entitlement for this passkey. 66 104 export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) { … … 96 134 rpFor, registrationOptions, verifyRegistration, storeEntitlement, 97 135 getEntitlement, deleteEntitlement, pruneExpired, 136 authenticationOptions, verifyAssertion, bumpCounter, 98 137 }; -
src/views/pages/paid-gate.ejs
rd43230f r6cbd014 3 3 </div> 4 4 5 <article class="pg-page" >5 <article class="pg-page" id="pg-page"> 6 6 <% if (typeof pgTitle !== 'undefined' && pgTitle) { %><h1 class="pg-title"><%= pgTitle %></h1><% } %> 7 7 … … 18 18 dan ontgrendel je 'm met je passkey. Geen account, geen cookie. 19 19 </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> 21 22 </section> 22 23 </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> 23 74 24 75 <style> … … 32 83 .pg-h2 { font-size: 22px; margin: 0 0 8px; } 33 84 .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; } 36 90 </style>
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)