source: Klonkt/src/routes/paid.js@ c628dcd4

main
Last change on this file since c628dcd4 was c628dcd4, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Paid-post pagina krijgt de normale header, en Release a Ward vraagt eerst

Twee kleine dingen uit de backlog.

shaer-eaf: de pagina-s in de unlock-flow (paid-result en paid-passkey) stonden op
bodyClass on-special. Die klasse is bedoeld voor de fediverse/beheer-achtige
pagina-s en zet in de topnav dezelfde behandeling aan als bij admin: een kale
terug-naar-site link i.p.v. de site-header. Maar dit zijn gewoon bezoekerspagina-s
in het midden van een aankoop. Nu on-post, wat de normale (compacte) header geeft.

shaer-o4a: een ward loslaten ging met een enkele tik, zonder vraag. Dat is een
zware en slecht terug te draaien actie: je stopt als guardian, ziet hun berichten
niet meer, krijgt geen hulpverzoeken meer en beslist niet meer over volgverzoeken,
en terugkomen kan alleen met een nieuwe aanvraag die de ward accepteert. Er komt
nu een bevestiging die precies dat opsomt, in alle drie de talen.

Changed files:
src/routes/paid.js

  • vier renders van on-special naar on-post (paid-result en paid-passkey)

src/services/i18n.js

  • guardian.release_confirm toegevoegd in nl, en, de

src/routes/guardian.js

  • release_confirm meegegeven aan de PWA-strings

src/assets/js/guardian.js

  • Loslaten vraagt eerst om bevestiging, met de handle in de tekst

remarks: 190 tests groen. De paid-flow is hier niet live te reproduceren (die
routes 404-en zonder Patreon-config), dus dat deel is via de topnav-logica
geverifieerd en niet in de browser.

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

  • Property mode set to 100644
File size: 7.6 KB
Line 
1/**
2 * Paid posts (klonkt-demo-aki) slice 3: the patron link + passkey flow.
3 * Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in
4 * signed blobs (CryptoBox), never a session.
5 *
6 * GET /paid/link?post=<slug> -> redirect to Patreon authorize
7 * GET /paid/callback -> verify patron, render the passkey page
8 * POST /paid/register -> verify the passkey, store the entitlement
9 */
10import express from 'express';
11import db from '../config/database.js';
12import { renderPage } from '../middleware/render.js';
13import { premiumUnlocked } from '../services/PatreonService.js';
14import { signBlob, verifyBlob, cryptoBoxReady } from '../services/CryptoBox.js';
15import PaidPatreon from '../services/PaidPatreonService.js';
16import Passkey from '../services/PasskeyService.js';
17
18const router = express.Router();
19const AUTHORIZE = 'https://www.patreon.com/oauth2/authorize';
20
21const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
22
23// The feature is only live when premium is on, secrets can be encrypted, and the
24// owner has connected a campaign.
25function ready(req, res) {
26 const site = res.locals.site;
27 if (!site) { res.status(404).end(); return null; }
28 if (!premiumUnlocked() || !cryptoBoxReady()) { res.status(404).end(); return null; }
29 const cfg = PaidPatreon.getOwnerConfig(site.id);
30 if (!cfg || !cfg.clientId || !cfg.campaignId) { res.status(404).end(); return null; }
31 return { site, cfg };
32}
33
34// Step 1: send the visitor to Patreon.
35router.get('/link', (req, res) => {
36 const r = ready(req, res); if (!r) return;
37 const slug = String(req.query.post || '').trim();
38 const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
39 if (!post || !post.paid) return res.redirect((res.locals.siteUrlBase || '') + '/' + (slug || ''));
40 const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
41 const state = signBlob({ purpose: 'patron', siteId: r.site.id, cents, post: post.slug }, 900);
42 const url = `${AUTHORIZE}?response_type=code&client_id=${encodeURIComponent(r.cfg.clientId)}`
43 + `&redirect_uri=${encodeURIComponent(baseUrl(req) + '/paid/callback')}`
44 + `&scope=${encodeURIComponent('identity identity.memberships')}`
45 + `&state=${encodeURIComponent(state)}`;
46 res.redirect(url);
47});
48
49// Step 2: Patreon returns. Verify the patron; if a supporter at the right tier,
50// render the passkey-creation page.
51router.get('/callback', async (req, res) => {
52 const r = ready(req, res); if (!r) return;
53 const payload = verifyBlob(String(req.query.state || ''));
54 if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
55 return renderPage(req, res, 'pages/paid-result', {
56 pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false, reason: 'expired',
57 });
58 }
59 const patronUrl = PaidPatreon.patreonUrl(r.site.id);
60 const code = String(req.query.code || '');
61 if (req.query.error || !code) {
62 return renderPage(req, res, 'pages/paid-result', { pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false, reason: 'declined', postSlug: payload.post, patronUrl });
63 }
64 const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
65 const cents = membership ? (membership.cents || 0) : 0;
66 const active = membership && membership.status === 'active_patron';
67 if (!active || cents < payload.cents) {
68 return renderPage(req, res, 'pages/paid-result', {
69 pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false,
70 reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post, patronUrl,
71 });
72 }
73 // Supporter at the right tier. Hand out registration options + a signed blob
74 // carrying the challenge and the proven cents; the passkey page returns both.
75 const options = await Passkey.registrationOptions(baseUrl(req), r.site.slug);
76 const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
77 renderPage(req, res, 'pages/paid-passkey', {
78 pageTitleKey: 'ppk.t', bodyClass: 'on-post',
79 optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
80 });
81});
82
83// Step 3: verify the passkey and store the pseudonymous entitlement.
84router.post('/register', express.json({ limit: '64kb' }), async (req, res) => {
85 const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
86 const { response, blob } = req.body || {};
87 const payload = verifyBlob(String(blob || ''));
88 if (!payload || payload.purpose !== 'reg' || payload.siteId !== r.site.id) {
89 return res.status(400).json({ error: 'bad_challenge' });
90 }
91 const cred = await Passkey.verifyRegistration(baseUrl(req), response, payload.challenge);
92 if (!cred) return res.status(400).json({ error: 'verify_failed' });
93 Passkey.storeEntitlement({
94 credentialId: cred.credentialId, siteId: r.site.id, publicKey: cred.publicKey,
95 counter: cred.counter, transports: cred.transports, minCents: payload.cents,
96 });
97 res.json({ ok: true });
98});
99
100// Step 4 (unlock): hand out authentication options for a passkey assertion.
101router.get('/challenge', async (req, res) => {
102 const r = ready(req, res); if (!r) return;
103 const slug = String(req.query.post || '').trim();
104 const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
105 if (!post || !post.paid) return res.status(404).json({ error: 'not_paid' });
106 const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
107 const options = await Passkey.authenticationOptions(baseUrl(req));
108 const blob = signBlob({ purpose: 'auth', siteId: r.site.id, cents, post: post.slug, challenge: options.challenge }, 300);
109 res.json({ options, blob });
110});
111
112// Verify the assertion, check the entitlement, and return the full post body in
113// the SAME response. No unlock token becomes state (design decision).
114router.post('/unlock', express.json({ limit: '64kb' }), async (req, res) => {
115 const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
116 const { response, blob } = req.body || {};
117 const payload = verifyBlob(String(blob || ''));
118 if (!payload || payload.purpose !== 'auth' || payload.siteId !== r.site.id) return res.status(400).json({ error: 'bad_challenge' });
119 const credId = response && response.id;
120 const ent = credId ? Passkey.getEntitlement(credId, r.site.id) : null;
121 if (!ent) return res.status(403).json({ error: 'no_entitlement' }); // unknown/expired passkey
122 if ((ent.min_cents || 0) < payload.cents) return res.status(403).json({ error: 'tier' });
123 const vr = await Passkey.verifyAssertion(baseUrl(req), response, payload.challenge, ent);
124 if (!vr) return res.status(400).json({ error: 'verify_failed' });
125 Passkey.bumpCounter(credId, vr.newCounter);
126 const post = db.prepare("SELECT * FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(r.site.id, String(payload.post || ''));
127 if (!post || !post.paid) return res.status(404).json({ error: 'gone' });
128 // Hand back a short-lived, single-post unlock capability. The client reloads
129 // the real post page with it (?u=), so the post renders through its normal
130 // template: correct layout, scoped styles, working audio. Not a cookie and
131 // not stored: a 120s signed blob that lives only in that one URL.
132 const token = signBlob({ purpose: 'unlocked', siteId: r.site.id, post: post.slug }, 120);
133 res.json({ ok: true, redirect: `${res.locals.siteUrlBase || ''}/${encodeURIComponent(post.slug)}?u=${encodeURIComponent(token)}` });
134});
135
136export default router;
Note: See TracBrowser for help on using the repository browser.