| 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 | */
|
|---|
| 10 | import express from 'express';
|
|---|
| 11 | import db from '../config/database.js';
|
|---|
| 12 | import { renderPage } from '../middleware/render.js';
|
|---|
| 13 | import { premiumUnlocked } from '../services/PatreonService.js';
|
|---|
| 14 | import { signBlob, verifyBlob, cryptoBoxReady } from '../services/CryptoBox.js';
|
|---|
| 15 | import PaidPatreon from '../services/PaidPatreonService.js';
|
|---|
| 16 | import Passkey from '../services/PasskeyService.js';
|
|---|
| 17 |
|
|---|
| 18 | const router = express.Router();
|
|---|
| 19 | const AUTHORIZE = 'https://www.patreon.com/oauth2/authorize';
|
|---|
| 20 |
|
|---|
| 21 | const 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.
|
|---|
| 25 | function 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.
|
|---|
| 35 | router.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.
|
|---|
| 51 | router.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-special', 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-special', 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-special', 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-special',
|
|---|
| 79 | optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
|
|---|
| 80 | });
|
|---|
| 81 | });
|
|---|
| 82 |
|
|---|
| 83 | // Step 3: verify the passkey and store the pseudonymous entitlement.
|
|---|
| 84 | router.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.
|
|---|
| 101 | router.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).
|
|---|
| 114 | router.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 |
|
|---|
| 136 | export default router;
|
|---|