| 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.
|
|---|
| 5 | import db from '../config/database.js';
|
|---|
| 6 | import { encrypt, decrypt, cryptoBoxReady } from './CryptoBox.js';
|
|---|
| 7 |
|
|---|
| 8 | const TOKEN_URL = 'https://www.patreon.com/api/oauth2/token';
|
|---|
| 9 |
|
|---|
| 10 | // The owner's config, secrets decrypted. Returns null when unconfigured.
|
|---|
| 11 | export 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).
|
|---|
| 28 | export 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.
|
|---|
| 44 | export 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.
|
|---|
| 51 | export 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 |
|
|---|
| 86 | export function disconnect(siteId) {
|
|---|
| 87 | db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
|
|---|
| 88 | }
|
|---|
| 89 |
|
|---|
| 90 | export 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.
|
|---|
| 96 | export 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.
|
|---|
| 104 | export 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).
|
|---|
| 131 | export 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 owner's-campaign membership out of a Patreon
|
|---|
| 138 | // identity?include=memberships.campaign response (JSON:API). Returns
|
|---|
| 139 | // { status, cents } or null.
|
|---|
| 140 | //
|
|---|
| 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.
|
|---|
| 147 | export function pickCampaignMembership(identity, campaignId) {
|
|---|
| 148 | if (!campaignId) return null;
|
|---|
| 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;
|
|---|
| 153 | if (!camp || String(camp.id) !== String(campaignId)) continue;
|
|---|
| 154 | const a = it.attributes || {};
|
|---|
| 155 | return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
|
|---|
| 156 | }
|
|---|
| 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.
|
|---|
| 163 | export 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;
|
|---|
| 173 | }
|
|---|
| 174 |
|
|---|
| 175 | // Exchange a patron's auth code and read their membership of the owner's
|
|---|
| 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.
|
|---|
| 180 | export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
|
|---|
| 181 | const c = getOwnerConfig(siteId);
|
|---|
| 182 | if (!c || !c.clientId || !c.clientSecret) return null;
|
|---|
| 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}`);
|
|---|
| 196 | const tok = await tokenRes.json();
|
|---|
| 197 | if (!tok || !tok.access_token) return none('no_access_token');
|
|---|
| 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}` } });
|
|---|
| 202 | if (!idRes.ok) return none(`identity_http_${idRes.status}`);
|
|---|
| 203 | const identity = await idRes.json();
|
|---|
| 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
|
|---|
| 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 | });
|
|---|
| 219 | const diag = `owner=${ownerCampaign || 'unknown'} config=${c.campaignId || 'none'} seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`;
|
|---|
| 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 };
|
|---|
| 222 | }
|
|---|
| 223 |
|
|---|
| 224 | function safeDecrypt(blob) {
|
|---|
| 225 | try { return decrypt(blob); } catch { return null; }
|
|---|
| 226 | }
|
|---|
| 227 |
|
|---|
| 228 | export default {
|
|---|
| 229 | getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
|
|---|
| 230 | defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
|
|---|
| 231 | pickCampaignMembership, fetchOwnerCampaignId, verifyPatron,
|
|---|
| 232 | };
|
|---|