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

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

Debug: show why a patron is rejected on the "Nog geen supporter" page

We are debugging boiert.eu blind (it's a self-hosted instance we can't see
the logs of), so surface the non-identifying diagnosis on the result page
itself. verifyPatron now always returns { status, cents, diag } after it
reaches Patreon, where diag is a breadcrumb: the configured campaign_id, the
memberships Patreon returned (campaign:status:cents each), and what got
picked. The paid-result page prints it small under the buttons so the tester
can read it and report back. No identity (name/email) is included or stored.

Once the cause is known this can be removed again.

Changed files:
src/services/PaidPatreonService.js

  • verifyPatron returns {status,cents,diag}; null only on hard misconfig

src/routes/paid.js

  • pass diag as debug to the notpatron/tier result page

src/views/pages/paid-result.ejs

  • small "diagnose:" line when debug is present

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

  • Property mode set to 100644
File size: 9.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) {
52 if (!cryptoBoxReady()) throw new Error('PAID_SECRET is not set: cannot store Patreon secrets');
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//
141// Memberships returned via a creator's OWN OAuth client are already scoped to
142// that creator's campaign(s), so in practice there is one. We still prefer an
143// exact campaign_id match (belt and suspenders for multi-campaign creators),
144// but fall back to the sole membership when the configured campaign_id doesn't
145// match: a wrong/typo'd campaign_id in the admin must not lock out real patrons.
[9e9e6f9]146export function pickCampaignMembership(identity, campaignId) {
147 const inc = (identity && identity.included) || [];
[c7a211d]148 const members = [];
[9e9e6f9]149 for (const it of inc) {
150 if (it.type !== 'member') continue;
151 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
152 const a = it.attributes || {};
[c7a211d]153 members.push({
154 status: a.patron_status || null,
155 cents: a.currently_entitled_amount_cents || 0,
156 campaignId: camp ? String(camp.id) : null,
157 });
[9e9e6f9]158 }
[c7a211d]159 if (!members.length) return null;
160 const exact = members.find((m) => m.campaignId && String(m.campaignId) === String(campaignId));
161 const pick = exact || (members.length === 1 ? members[0] : null);
162 return pick ? { status: pick.status, cents: pick.cents } : null;
[9e9e6f9]163}
164
165// Exchange a patron's auth code and read their membership of the owner's
[ec288dc]166// campaign. Returns { status, cents, diag } (status null = not a patron); the
167// `diag` string is a NON-identifying breadcrumb (campaign ids + status + cents)
168// so a stuck owner can see why. Returns null only on hard misconfig. The patron
169// token is used once and discarded here: nothing identifying is stored.
[9e9e6f9]170export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
171 const c = getOwnerConfig(siteId);
172 if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;
[ec288dc]173 const none = (diag) => ({ status: null, cents: 0, diag });
174 let tokenRes;
175 try {
176 tokenRes = await fetchImpl(TOKEN_URL, {
177 method: 'POST',
178 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
179 body: new URLSearchParams({
180 grant_type: 'authorization_code', code,
181 client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
182 }).toString(),
183 });
184 } catch { return none('token_fetch_error'); }
185 if (!tokenRes.ok) return none(`token_http_${tokenRes.status}`);
[9e9e6f9]186 const tok = await tokenRes.json();
[ec288dc]187 if (!tok || !tok.access_token) return none('no_access_token');
[9e9e6f9]188 const url = 'https://www.patreon.com/api/oauth2/v2/identity'
189 + '?include=memberships.campaign'
190 + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
191 const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
[ec288dc]192 if (!idRes.ok) return none(`identity_http_${idRes.status}`);
[9e9e6f9]193 const identity = await idRes.json();
[c7a211d]194 const membership = pickCampaignMembership(identity, c.campaignId); // token goes out of scope, discarded
[ec288dc]195 const seen = ((identity && identity.included) || [])
196 .filter((it) => it.type === 'member')
197 .map((it) => {
198 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
199 const a = it.attributes || {};
200 return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`;
201 });
202 const diag = `campaign=${c.campaignId} seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`;
203 if (!membership || membership.status !== 'active_patron') console.warn(`[paid] verifyPatron: ${diag}`);
204 return { status: membership ? membership.status : null, cents: membership ? membership.cents : 0, diag };
[9e9e6f9]205}
206
[61e3daf]207function safeDecrypt(blob) {
208 try { return decrypt(blob); } catch { return null; }
209}
210
211export default {
212 getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
[c3d12a6]213 defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
[9e9e6f9]214 pickCampaignMembership, verifyPatron,
[61e3daf]215};
Note: See TracBrowser for help on using the repository browser.