source: Klonkt/src/routes/paid.js@ 156baa3

main
Last change on this file since 156baa3 was 156baa3, checked in by roboburr <roboburr@…>, 5 weeks ago

De chrome en vier schermen met servergegevens (shaer-bqr, stap 3e)

topnav en profile-sheet naar mod/chrome.js; admin-media, admin-videos, paid-gate,
paid-passkey en admin-playlists naar eigen modules.

TWEE MANIEREN om servergegevens bij een module te krijgen, en de keuze is niet
willekeurig:

OP HET ELEMENT waar de tekst bij EEN ding hoort. De zoekteksten staan nu op

#search-overlay, de twee themalabels op het label zelf. Dat is
ook robuuster: bij een htmx-navigatie wordt de chrome
vervangen, en dan komt de tekst gewoon mee.

VIA pageData() waar het om paginabrede gegevens gaat (csrf, slug, een blob,

een setje meldingen).

De chrome kon niet via pageData: die leeft langer dan een pagina.

DRIE FOUTEN ONDERWEG, alle drie gevangen voordat er iets stuk ging:

  1. Ik zocht de eerste </script> in het HELE bestand in plaats van die na het openende tag. paid-gate en paid-passkey hebben een <script src> erboven, dus het "blok" werd leeg en er werd een lege module geschreven. Teruggezet uit git en de zoekfunctie kreeg een offset plus een controle dat het blok niet leeg is.
  2. Een zoekterm sloot te vroeg af: de tekst van delete_failed loopt door in dezelfde JS-string (": " + err.message), dus het patroon met de sluitquote matchte niet.
  3. Een eerdere poging verving nul regels en schreef toch. Nu telt de helper de vervangingen en weigert als het er niet exact zoveel zijn als opgegeven.

Die controle op "geen enkele module bevat nog EJS" draait mee, en er is er nu een
bij die een verdacht lege module afvangt -- precies wat fout 1 opleverde.

Templates compileren, suite 565/565.

  • 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 pageJs: 'paid-passkey',
79 pageTitleKey: 'ppk.t', bodyClass: 'on-post',
80 optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
81 });
82});
83
84// Step 3: verify the passkey and store the pseudonymous entitlement.
85router.post('/register', express.json({ limit: '64kb' }), async (req, res) => {
86 const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
87 const { response, blob } = req.body || {};
88 const payload = verifyBlob(String(blob || ''));
89 if (!payload || payload.purpose !== 'reg' || payload.siteId !== r.site.id) {
90 return res.status(400).json({ error: 'bad_challenge' });
91 }
92 const cred = await Passkey.verifyRegistration(baseUrl(req), response, payload.challenge);
93 if (!cred) return res.status(400).json({ error: 'verify_failed' });
94 Passkey.storeEntitlement({
95 credentialId: cred.credentialId, siteId: r.site.id, publicKey: cred.publicKey,
96 counter: cred.counter, transports: cred.transports, minCents: payload.cents,
97 });
98 res.json({ ok: true });
99});
100
101// Step 4 (unlock): hand out authentication options for a passkey assertion.
102router.get('/challenge', async (req, res) => {
103 const r = ready(req, res); if (!r) return;
104 const slug = String(req.query.post || '').trim();
105 const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
106 if (!post || !post.paid) return res.status(404).json({ error: 'not_paid' });
107 const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
108 const options = await Passkey.authenticationOptions(baseUrl(req));
109 const blob = signBlob({ purpose: 'auth', siteId: r.site.id, cents, post: post.slug, challenge: options.challenge }, 300);
110 res.json({ options, blob });
111});
112
113// Verify the assertion, check the entitlement, and return the full post body in
114// the SAME response. No unlock token becomes state (design decision).
115router.post('/unlock', express.json({ limit: '64kb' }), async (req, res) => {
116 const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
117 const { response, blob } = req.body || {};
118 const payload = verifyBlob(String(blob || ''));
119 if (!payload || payload.purpose !== 'auth' || payload.siteId !== r.site.id) return res.status(400).json({ error: 'bad_challenge' });
120 const credId = response && response.id;
121 const ent = credId ? Passkey.getEntitlement(credId, r.site.id) : null;
122 if (!ent) return res.status(403).json({ error: 'no_entitlement' }); // unknown/expired passkey
123 if ((ent.min_cents || 0) < payload.cents) return res.status(403).json({ error: 'tier' });
124 const vr = await Passkey.verifyAssertion(baseUrl(req), response, payload.challenge, ent);
125 if (!vr) return res.status(400).json({ error: 'verify_failed' });
126 Passkey.bumpCounter(credId, vr.newCounter);
127 const post = db.prepare("SELECT * FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(r.site.id, String(payload.post || ''));
128 if (!post || !post.paid) return res.status(404).json({ error: 'gone' });
129 // Hand back a short-lived, single-post unlock capability. The client reloads
130 // the real post page with it (?u=), so the post renders through its normal
131 // template: correct layout, scoped styles, working audio. Not a cookie and
132 // not stored: a 120s signed blob that lives only in that one URL.
133 const token = signBlob({ purpose: 'unlocked', siteId: r.site.id, post: post.slug }, 120);
134 res.json({ ok: true, redirect: `${res.locals.siteUrlBase || ''}/${encodeURIComponent(post.slug)}?u=${encodeURIComponent(token)}` });
135});
136
137export default router;
Note: See TracBrowser for help on using the repository browser.