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

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

Feature: a way to actually become a supporter, and a styled expired page

Bart's test surfaced two gaps in the visitor flow:

  • After "Allow" on Patreon, a non-supporter landed on "Nog geen supporter" with no way to actually pledge. The page now shows a primary "Word supporter op Patreon" button. The site owner sets their public Patreon page in Beheer -> Betaalde posts (new field); it's also linked from the gate itself ("Nog geen supporter? Word het op Patreon").
  • The invalid/expired callback replied with a raw res.send() plain-text line. It now renders the normal paid-result page (reason 'expired').

Changed files:
src/config/database.js

  • paid_patreon.patreon_url column (additive)

src/services/PaidPatreonService.js

  • patreonUrl in config/status/save (undefined keeps, empty clears) + patreonUrl(siteId) helper

src/routes/admin-paid.js

  • save patreon_url from the form

src/views/pages/admin-paid.ejs

  • "Openbare Patreon-pagina" field

src/routes/paid.js

  • expired callback renders paid-result; pass patronUrl to the result pages

src/routes/posts.js

  • pass pgPatronUrl to the gate

src/views/pages/paid-gate.ejs

  • "Word supporter" join line under the unlock button

src/views/pages/paid-result.ejs

  • "Word supporter op Patreon" primary button, 'expired' reason, ghost back button always has a label

test/paid-patron.test.js

  • patreonUrl set/keep/clear semantics

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

  • Property mode set to 100644
File size: 8.1 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 patreonUrl: row.patreon_url || null,
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,
39 patreonUrl: c.patreonUrl || null,
40 };
41}
42
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
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,
62 // undefined = keep (e.g. token refresh doesn't touch it); null/'' = clear.
63 patreonUrl: patch.patreonUrl !== undefined ? (patch.patreonUrl || null) : (cur.patreonUrl ?? null),
64 };
65 db.prepare(`INSERT INTO paid_patreon
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)
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,
72 default_min_cents=excluded.default_min_cents, patreon_url=excluded.patreon_url, updated_at=CURRENT_TIMESTAMP`)
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),
82 merged.patreonUrl || null,
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
137// Pure: pick the membership for the owner's campaign out of a Patreon
138// identity?include=memberships.campaign response (JSON:API). Returns
139// { status, cents } or null.
140export function pickCampaignMembership(identity, campaignId) {
141 const inc = (identity && identity.included) || [];
142 for (const it of inc) {
143 if (it.type !== 'member') continue;
144 const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
145 if (!camp || String(camp.id) !== String(campaignId)) continue;
146 const a = it.attributes || {};
147 return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
148 }
149 return null;
150}
151
152// Exchange a patron's auth code and read their membership of the owner's
153// campaign. Returns { status, cents } or null. The patron token is used once
154// and discarded here: nothing identifying is stored (design decision).
155export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
156 const c = getOwnerConfig(siteId);
157 if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;
158 const tokenRes = await fetchImpl(TOKEN_URL, {
159 method: 'POST',
160 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
161 body: new URLSearchParams({
162 grant_type: 'authorization_code', code,
163 client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
164 }).toString(),
165 });
166 if (!tokenRes.ok) return null;
167 const tok = await tokenRes.json();
168 if (!tok || !tok.access_token) return null;
169 const url = 'https://www.patreon.com/api/oauth2/v2/identity'
170 + '?include=memberships.campaign'
171 + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
172 const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
173 if (!idRes.ok) return null;
174 const identity = await idRes.json();
175 return pickCampaignMembership(identity, c.campaignId); // token goes out of scope, discarded
176}
177
178function safeDecrypt(blob) {
179 try { return decrypt(blob); } catch { return null; }
180}
181
182export default {
183 getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
184 defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
185 pickCampaignMembership, verifyPatron,
186};
Note: See TracBrowser for help on using the repository browser.