source: Klonkt/src/services/PaidPatreonService.js@ 928d1c7

main
Last change on this file since 928d1c7 was 61e3daf, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: paid posts slice 1, owner Patreon config (encrypted)

The site owner can connect their OWN Patreon campaign for paid posts
(klonkt-demo-aki), premium-gated in Beheer. Client id/secret, campaign
id and the creator access/refresh token are stored ENCRYPTED at rest
(new CryptoBox AES-256-GCM helper, key from PAID_SECRET), so a database
dump leaks nothing usable; the token auto-refreshes. Separate from
Klonkt Premium's license flow, which is untouched. Degrades gracefully:
without PAID_SECRET the admin page refuses to save rather than storing
plaintext. Nothing patron-facing yet (posts.paid + unlock come in
slices 2 to 4), so no changelog entry.

CryptoBox also carries the cookie-less signed-blob helper (signBlob/
verifyBlob) that slices 3 and 4 reuse for the OAuth state and the
WebAuthn challenge.

Changed files:
src/config/database.js

  • paid_patreon table (site_id PK, secrets encrypted)

src/server.js

  • mount /admin/paid

src/views/pages/admin.ejs

  • "Betaalde posts" button in Beheer

New file:
src/services/CryptoBox.js

  • aes-256-gcm encrypt/decrypt + HMAC signBlob/verifyBlob

src/services/PaidPatreonService.js

  • owner config CRUD (encrypted), token refresh, creatorAccessToken

src/routes/admin-paid.js

  • premium-gated config form (GET/POST/disconnect)

src/views/pages/admin-paid.ejs

  • the form + status

test/paid-patreon.test.js

  • crypto roundtrip, no-plaintext-in-DB, refresh, blob signing

docs/paid-posts-design.md, docs/privacy-betaalde-posts.md

  • concurrency property documented

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

  • Property mode set to 100644
File size: 5.6 KB
Line 
1// Paid posts (klonkt-demo-aki) slice 1: the site owner's own Patreon campaign.
2// Stores client id/secret + the creator access/refresh token (encrypted) and a
3// default price. Separate from PatreonService, which is Klonkt Premium's
4// instance-level license flow and stays untouched.
5import db from '../config/database.js';
6import { encrypt, decrypt, cryptoBoxReady } from './CryptoBox.js';
7
8const TOKEN_URL = 'https://www.patreon.com/api/oauth2/token';
9
10// The owner's config, secrets decrypted. Returns null when unconfigured.
11export function getOwnerConfig(siteId) {
12 const row = db.prepare('SELECT * FROM paid_patreon WHERE site_id = ?').get(siteId);
13 if (!row) return null;
14 return {
15 siteId: row.site_id,
16 clientId: row.client_id || null,
17 clientSecret: row.client_secret_enc ? safeDecrypt(row.client_secret_enc) : null,
18 campaignId: row.campaign_id || null,
19 accessToken: row.access_token_enc ? safeDecrypt(row.access_token_enc) : null,
20 refreshToken: row.refresh_token_enc ? safeDecrypt(row.refresh_token_enc) : null,
21 tokenExp: row.token_exp || 0,
22 defaultMinCents: row.default_min_cents || 0,
23 };
24}
25
26// Non-secret status for the admin screen (never returns tokens/secret).
27export function ownerStatus(siteId) {
28 const c = getOwnerConfig(siteId);
29 if (!c) return { configured: false, connected: false, defaultMinCents: 0 };
30 return {
31 configured: !!(c.clientId && c.clientSecret),
32 connected: !!(c.accessToken && c.campaignId),
33 clientId: c.clientId || null,
34 campaignId: c.campaignId || null,
35 defaultMinCents: c.defaultMinCents || 0,
36 tokenExp: c.tokenExp || 0,
37 hasSecret: !!c.clientSecret,
38 };
39}
40
41// Upsert. Only overwrites secret/token fields when a new value is provided, so
42// the admin form can be re-saved without re-pasting the secret.
43export function saveOwnerConfig(siteId, patch) {
44 if (!cryptoBoxReady()) throw new Error('PAID_SECRET is not set: cannot store Patreon secrets');
45 const cur = getOwnerConfig(siteId) || {};
46 const merged = {
47 clientId: patch.clientId ?? cur.clientId ?? null,
48 clientSecret: patch.clientSecret ?? cur.clientSecret ?? null,
49 campaignId: patch.campaignId ?? cur.campaignId ?? null,
50 accessToken: patch.accessToken ?? cur.accessToken ?? null,
51 refreshToken: patch.refreshToken ?? cur.refreshToken ?? null,
52 tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
53 defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
54 };
55 db.prepare(`INSERT INTO paid_patreon
56 (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, updated_at)
57 VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
58 ON CONFLICT(site_id) DO UPDATE SET
59 client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
60 campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
61 refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
62 default_min_cents=excluded.default_min_cents, updated_at=CURRENT_TIMESTAMP`)
63 .run(
64 siteId,
65 merged.clientId,
66 merged.clientSecret != null ? encrypt(merged.clientSecret) : null,
67 merged.campaignId,
68 merged.accessToken != null ? encrypt(merged.accessToken) : null,
69 merged.refreshToken != null ? encrypt(merged.refreshToken) : null,
70 merged.tokenExp || 0,
71 Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
72 );
73}
74
75export function disconnect(siteId) {
76 db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
77}
78
79export function defaultMinCents(siteId) {
80 const row = db.prepare('SELECT default_min_cents FROM paid_patreon WHERE site_id = ?').get(siteId);
81 return row ? (row.default_min_cents || 0) : 0;
82}
83
84// True when the stored creator token is missing or within `skewSeconds` of exp.
85export function needsRefresh(siteId, skewSeconds = 3600) {
86 const c = getOwnerConfig(siteId);
87 if (!c || !c.refreshToken) return false;
88 return !c.accessToken || (c.tokenExp || 0) <= (Math.floor(Date.now() / 1000) + skewSeconds);
89}
90
91// Refresh the creator token via Patreon. Returns true on success. `fetchImpl`
92// is injectable for tests; defaults to global fetch.
93export async function refreshCreatorToken(siteId, fetchImpl = fetch) {
94 const c = getOwnerConfig(siteId);
95 if (!c || !c.clientId || !c.clientSecret || !c.refreshToken) return false;
96 const body = new URLSearchParams({
97 grant_type: 'refresh_token',
98 refresh_token: c.refreshToken,
99 client_id: c.clientId,
100 client_secret: c.clientSecret,
101 });
102 const res = await fetchImpl(TOKEN_URL, {
103 method: 'POST',
104 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
105 body: body.toString(),
106 });
107 if (!res.ok) return false;
108 const j = await res.json();
109 if (!j || !j.access_token) return false;
110 saveOwnerConfig(siteId, {
111 accessToken: j.access_token,
112 refreshToken: j.refresh_token || c.refreshToken,
113 tokenExp: Math.floor(Date.now() / 1000) + (parseInt(j.expires_in, 10) || 0),
114 });
115 return true;
116}
117
118// A valid creator access token, refreshing first if it is stale. Null when the
119// owner has not connected. Used by the patron verify path (slice 3).
120export async function creatorAccessToken(siteId, fetchImpl = fetch) {
121 if (needsRefresh(siteId)) { try { await refreshCreatorToken(siteId, fetchImpl); } catch { /* fall through */ } }
122 const c = getOwnerConfig(siteId);
123 return c && c.accessToken ? c.accessToken : null;
124}
125
126function safeDecrypt(blob) {
127 try { return decrypt(blob); } catch { return null; }
128}
129
130export default {
131 getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
132 defaultMinCents, needsRefresh, refreshCreatorToken, creatorAccessToken,
133};
Note: See TracBrowser for help on using the repository browser.