source: Klonkt/src/services/PaidPatreonService.js@ d7e72b8

main
Last change on this file since d7e72b8 was 4590e66, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: auto-generate the paid-posts encryption key (no env needed)

Nobody should have to hand-edit the env to use paid posts. CryptoBox now
resolves its key as: PAID_SECRET (env) if set, else a persisted key file
next to the database, auto-generated (0600) on first use. New installs and
existing users after an update get a key with zero config; a self-hoster
who set PAID_SECRET (Bart already did) keeps working unchanged, env wins.

The key lives OUTSIDE the sqlite DB on purpose: encrypting the Patreon
secrets is pointless if the key sits in the same file a DB dump would leak.
If the file can't be persisted (read-only fs) the box stays "not ready"
rather than using an ephemeral key that a restart would lose, so ciphertext
never becomes undecryptable.

Admin copy that referenced PAID_SECRET is updated: the warning now describes
the real remaining failure (key can't be created/read), not a missing env.

Changed files:
src/services/CryptoBox.js

  • resolve secret: env, else auto-generated 0600 key file by the database

src/services/PaidPatreonService.js

  • save error no longer names PAID_SECRET

src/routes/admin-paid.js, src/views/pages/admin-paid.ejs

  • not-ready copy describes the key file, not a missing env var

New file:
test/paid-secret.test.js

  • without PAID_SECRET: key file generated (0600), encrypt/decrypt roundtrips, key persists

remarks: the generated storage/.paid-secret must be backed up alongside the
DB, or the stored Patreon secrets can't be decrypted after a restore.

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

  • Property mode set to 100644
File size: 10.9 KB
RevLine 
[61e3daf]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,
[c3d12a6]23 patreonUrl: row.patreon_url || null,
[61e3daf]24 };
25}
26
27// Non-secret status for the admin screen (never returns tokens/secret).
28export function ownerStatus(siteId) {
29 const c = getOwnerConfig(siteId);
30 if (!c) return { configured: false, connected: false, defaultMinCents: 0 };
31 return {
32 configured: !!(c.clientId && c.clientSecret),
33 connected: !!(c.accessToken && c.campaignId),
34 clientId: c.clientId || null,
35 campaignId: c.campaignId || null,
36 defaultMinCents: c.defaultMinCents || 0,
37 tokenExp: c.tokenExp || 0,
38 hasSecret: !!c.clientSecret,
[c3d12a6]39 patreonUrl: c.patreonUrl || null,
[61e3daf]40 };
41}
42
[c3d12a6]43// The owner's public Patreon page, for the "Word supporter" link. Null when unset.
44export function patreonUrl(siteId) {
45 const c = getOwnerConfig(siteId);
46 return c && c.patreonUrl ? c.patreonUrl : null;
47}
48
[61e3daf]49// Upsert. Only overwrites secret/token fields when a new value is provided, so
50// the admin form can be re-saved without re-pasting the secret.
51export function saveOwnerConfig(siteId, patch) {
[4590e66]52 if (!cryptoBoxReady()) throw new Error('encryption key unavailable: cannot store Patreon secrets');
[61e3daf]53 const cur = getOwnerConfig(siteId) || {};
54 const merged = {
55 clientId: patch.clientId ?? cur.clientId ?? null,
56 clientSecret: patch.clientSecret ?? cur.clientSecret ?? null,
57 campaignId: patch.campaignId ?? cur.campaignId ?? null,
58 accessToken: patch.accessToken ?? cur.accessToken ?? null,
59 refreshToken: patch.refreshToken ?? cur.refreshToken ?? null,
60 tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
61 defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
[c3d12a6]62 // undefined = keep (e.g. token refresh doesn't touch it); null/'' = clear.
63 patreonUrl: patch.patreonUrl !== undefined ? (patch.patreonUrl || null) : (cur.patreonUrl ?? null),
[61e3daf]64 };
65 db.prepare(`INSERT INTO paid_patreon
[c3d12a6]66 (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, patreon_url, updated_at)
67 VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
[61e3daf]68 ON CONFLICT(site_id) DO UPDATE SET
69 client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
70 campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
71 refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
[c3d12a6]72 default_min_cents=excluded.default_min_cents, patreon_url=excluded.patreon_url, updated_at=CURRENT_TIMESTAMP`)
[61e3daf]73 .run(
74 siteId,
75 merged.clientId,
76 merged.clientSecret != null ? encrypt(merged.clientSecret) : null,
77 merged.campaignId,
78 merged.accessToken != null ? encrypt(merged.accessToken) : null,
79 merged.refreshToken != null ? encrypt(merged.refreshToken) : null,
80 merged.tokenExp || 0,
81 Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
[c3d12a6]82 merged.patreonUrl || null,
[61e3daf]83 );
84}
85
86export function disconnect(siteId) {
87 db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
88}
89
90export function defaultMinCents(siteId) {
91 const row = db.prepare('SELECT default_min_cents FROM paid_patreon WHERE site_id = ?').get(siteId);
92 return row ? (row.default_min_cents || 0) : 0;
93}
94
95// True when the stored creator token is missing or within `skewSeconds` of exp.
96export function needsRefresh(siteId, skewSeconds = 3600) {
97 const c = getOwnerConfig(siteId);
98 if (!c || !c.refreshToken) return false;
99 return !c.accessToken || (c.tokenExp || 0) <= (Math.floor(Date.now() / 1000) + skewSeconds);
100}
101
102// Refresh the creator token via Patreon. Returns true on success. `fetchImpl`
103// is injectable for tests; defaults to global fetch.
104export async function refreshCreatorToken(siteId, fetchImpl = fetch) {
105 const c = getOwnerConfig(siteId);
106 if (!c || !c.clientId || !c.clientSecret || !c.refreshToken) return false;
107 const body = new URLSearchParams({
108 grant_type: 'refresh_token',
109 refresh_token: c.refreshToken,
110 client_id: c.clientId,
111 client_secret: c.clientSecret,
112 });
113 const res = await fetchImpl(TOKEN_URL, {
114 method: 'POST',
115 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
116 body: body.toString(),
117 });
118 if (!res.ok) return false;
119 const j = await res.json();
120 if (!j || !j.access_token) return false;
121 saveOwnerConfig(siteId, {
122 accessToken: j.access_token,
123 refreshToken: j.refresh_token || c.refreshToken,
124 tokenExp: Math.floor(Date.now() / 1000) + (parseInt(j.expires_in, 10) || 0),
125 });
126 return true;
127}
128
129// A valid creator access token, refreshing first if it is stale. Null when the
130// owner has not connected. Used by the patron verify path (slice 3).
131export async function creatorAccessToken(siteId, fetchImpl = fetch) {
132 if (needsRefresh(siteId)) { try { await refreshCreatorToken(siteId, fetchImpl); } catch { /* fall through */ } }
133 const c = getOwnerConfig(siteId);
134 return c && c.accessToken ? c.accessToken : null;
135}
136
[c7a211d]137// Pure: pick the owner's-campaign membership out of a Patreon
[9e9e6f9]138// identity?include=memberships.campaign response (JSON:API). Returns
139// { status, cents } or null.
[c7a211d]140//
[dede82e]141// STRICT match on campaignId only. Patreon's /identity returns ALL of the
142// visitor's memberships across every creator they back (verified: a tester had
143// 12), NOT just this creator's, so any fallback would grant access to someone
144// who backs a DIFFERENT creator. The campaignId must therefore be the owner's
145// real campaign; verifyPatron auto-derives it from the creator token so a
146// mistyped admin value can't lock real patrons out.
[9e9e6f9]147export function pickCampaignMembership(identity, campaignId) {
[dede82e]148 if (!campaignId) return null;
[9e9e6f9]149 const inc = (identity && identity.included) || [];
150 for (const it of inc) {
151 if (it.type !== 'member') continue;
152 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
[dede82e]153 if (!camp || String(camp.id) !== String(campaignId)) continue;
[9e9e6f9]154 const a = it.attributes || {};
[dede82e]155 return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
[9e9e6f9]156 }
[dede82e]157 return null;
158}
159
160// The campaign id owned by the creator token (i.e. the site owner's OWN
161// campaign). This is authoritative: it removes the "typed the wrong campaign_id"
162// failure mode. Null if there's no valid creator token or the call fails.
163export async function fetchOwnerCampaignId(siteId, fetchImpl = fetch) {
164 const token = await creatorAccessToken(siteId, fetchImpl).catch(() => null);
165 if (!token) return null;
166 const res = await fetchImpl('https://www.patreon.com/api/oauth2/v2/campaigns', {
167 headers: { Authorization: `Bearer ${token}` },
168 }).catch(() => null);
169 if (!res || !res.ok) return null;
170 const j = await res.json().catch(() => null);
171 const id = j && j.data && j.data[0] && j.data[0].id;
172 return id ? String(id) : null;
[9e9e6f9]173}
174
175// Exchange a patron's auth code and read their membership of the owner's
[ec288dc]176// campaign. Returns { status, cents, diag } (status null = not a patron); the
177// `diag` string is a NON-identifying breadcrumb (campaign ids + status + cents)
178// so a stuck owner can see why. Returns null only on hard misconfig. The patron
179// token is used once and discarded here: nothing identifying is stored.
[9e9e6f9]180export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
181 const c = getOwnerConfig(siteId);
[dede82e]182 if (!c || !c.clientId || !c.clientSecret) return null;
[ec288dc]183 const none = (diag) => ({ status: null, cents: 0, diag });
184 let tokenRes;
185 try {
186 tokenRes = await fetchImpl(TOKEN_URL, {
187 method: 'POST',
188 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
189 body: new URLSearchParams({
190 grant_type: 'authorization_code', code,
191 client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
192 }).toString(),
193 });
194 } catch { return none('token_fetch_error'); }
195 if (!tokenRes.ok) return none(`token_http_${tokenRes.status}`);
[9e9e6f9]196 const tok = await tokenRes.json();
[ec288dc]197 if (!tok || !tok.access_token) return none('no_access_token');
[9e9e6f9]198 const url = 'https://www.patreon.com/api/oauth2/v2/identity'
199 + '?include=memberships.campaign'
200 + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
201 const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
[ec288dc]202 if (!idRes.ok) return none(`identity_http_${idRes.status}`);
[9e9e6f9]203 const identity = await idRes.json();
[dede82e]204 // Authoritative campaign id: the one owned by the creator token. Beats a
205 // mistyped admin value. Self-heal the stored config when they differ.
206 const ownerCampaign = await fetchOwnerCampaignId(siteId, fetchImpl).catch(() => null);
207 const campaignId = ownerCampaign || c.campaignId;
208 if (ownerCampaign && String(ownerCampaign) !== String(c.campaignId)) {
209 try { saveOwnerConfig(siteId, { campaignId: ownerCampaign }); } catch { /* non-fatal */ }
210 }
211 const membership = pickCampaignMembership(identity, campaignId); // token goes out of scope, discarded
[ec288dc]212 const seen = ((identity && identity.included) || [])
213 .filter((it) => it.type === 'member')
214 .map((it) => {
215 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
216 const a = it.attributes || {};
217 return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`;
218 });
[dede82e]219 const diag = `owner=${ownerCampaign || 'unknown'} config=${c.campaignId || 'none'} seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`;
[ec288dc]220 if (!membership || membership.status !== 'active_patron') console.warn(`[paid] verifyPatron: ${diag}`);
221 return { status: membership ? membership.status : null, cents: membership ? membership.cents : 0, diag };
[9e9e6f9]222}
223
[61e3daf]224function safeDecrypt(blob) {
225 try { return decrypt(blob); } catch { return null; }
226}
227
228export default {
229 getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
[c3d12a6]230 defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
[dede82e]231 pickCampaignMembership, fetchOwnerCampaignId, verifyPatron,
[61e3daf]232};
Note: See TracBrowser for help on using the repository browser.