| [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.
|
|---|
| 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,
|
|---|
| [c3d12a6] | 23 | patreonUrl: row.patreon_url || null,
|
|---|
| [61e3daf] | 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,
|
|---|
| [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.
|
|---|
| 44 | export 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.
|
|---|
| 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,
|
|---|
| [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 |
|
|---|
| 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 |
|
|---|
| [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] | 146 | export 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] | 170 | export 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] | 207 | function safeDecrypt(blob) {
|
|---|
| 208 | try { return decrypt(blob); } catch { return null; }
|
|---|
| 209 | }
|
|---|
| 210 |
|
|---|
| 211 | export default {
|
|---|
| 212 | getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
|
|---|
| [c3d12a6] | 213 | defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
|
|---|
| [9e9e6f9] | 214 | pickCampaignMembership, verifyPatron,
|
|---|
| [61e3daf] | 215 | };
|
|---|