| [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,
|
|---|
| 23 | };
|
|---|
| 24 | }
|
|---|
| 25 |
|
|---|
| 26 | // Non-secret status for the admin screen (never returns tokens/secret).
|
|---|
| 27 | export function ownerStatus(siteId) {
|
|---|
| 28 | const c = getOwnerConfig(siteId);
|
|---|
| 29 | if (!c) return { configured: false, connected: false, defaultMinCents: 0 };
|
|---|
| 30 | return {
|
|---|
| 31 | configured: !!(c.clientId && c.clientSecret),
|
|---|
| 32 | connected: !!(c.accessToken && c.campaignId),
|
|---|
| 33 | clientId: c.clientId || null,
|
|---|
| 34 | campaignId: c.campaignId || null,
|
|---|
| 35 | defaultMinCents: c.defaultMinCents || 0,
|
|---|
| 36 | tokenExp: c.tokenExp || 0,
|
|---|
| 37 | hasSecret: !!c.clientSecret,
|
|---|
| 38 | };
|
|---|
| 39 | }
|
|---|
| 40 |
|
|---|
| 41 | // Upsert. Only overwrites secret/token fields when a new value is provided, so
|
|---|
| 42 | // the admin form can be re-saved without re-pasting the secret.
|
|---|
| 43 | export function saveOwnerConfig(siteId, patch) {
|
|---|
| 44 | if (!cryptoBoxReady()) throw new Error('PAID_SECRET is not set: cannot store Patreon secrets');
|
|---|
| 45 | const cur = getOwnerConfig(siteId) || {};
|
|---|
| 46 | const merged = {
|
|---|
| 47 | clientId: patch.clientId ?? cur.clientId ?? null,
|
|---|
| 48 | clientSecret: patch.clientSecret ?? cur.clientSecret ?? null,
|
|---|
| 49 | campaignId: patch.campaignId ?? cur.campaignId ?? null,
|
|---|
| 50 | accessToken: patch.accessToken ?? cur.accessToken ?? null,
|
|---|
| 51 | refreshToken: patch.refreshToken ?? cur.refreshToken ?? null,
|
|---|
| 52 | tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
|
|---|
| 53 | defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
|
|---|
| 54 | };
|
|---|
| 55 | db.prepare(`INSERT INTO paid_patreon
|
|---|
| 56 | (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, updated_at)
|
|---|
| 57 | VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
|
|---|
| 58 | ON CONFLICT(site_id) DO UPDATE SET
|
|---|
| 59 | client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
|
|---|
| 60 | campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
|
|---|
| 61 | refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
|
|---|
| 62 | default_min_cents=excluded.default_min_cents, updated_at=CURRENT_TIMESTAMP`)
|
|---|
| 63 | .run(
|
|---|
| 64 | siteId,
|
|---|
| 65 | merged.clientId,
|
|---|
| 66 | merged.clientSecret != null ? encrypt(merged.clientSecret) : null,
|
|---|
| 67 | merged.campaignId,
|
|---|
| 68 | merged.accessToken != null ? encrypt(merged.accessToken) : null,
|
|---|
| 69 | merged.refreshToken != null ? encrypt(merged.refreshToken) : null,
|
|---|
| 70 | merged.tokenExp || 0,
|
|---|
| 71 | Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
|
|---|
| 72 | );
|
|---|
| 73 | }
|
|---|
| 74 |
|
|---|
| 75 | export function disconnect(siteId) {
|
|---|
| 76 | db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
|
|---|
| 77 | }
|
|---|
| 78 |
|
|---|
| 79 | export function defaultMinCents(siteId) {
|
|---|
| 80 | const row = db.prepare('SELECT default_min_cents FROM paid_patreon WHERE site_id = ?').get(siteId);
|
|---|
| 81 | return row ? (row.default_min_cents || 0) : 0;
|
|---|
| 82 | }
|
|---|
| 83 |
|
|---|
| 84 | // True when the stored creator token is missing or within `skewSeconds` of exp.
|
|---|
| 85 | export function needsRefresh(siteId, skewSeconds = 3600) {
|
|---|
| 86 | const c = getOwnerConfig(siteId);
|
|---|
| 87 | if (!c || !c.refreshToken) return false;
|
|---|
| 88 | return !c.accessToken || (c.tokenExp || 0) <= (Math.floor(Date.now() / 1000) + skewSeconds);
|
|---|
| 89 | }
|
|---|
| 90 |
|
|---|
| 91 | // Refresh the creator token via Patreon. Returns true on success. `fetchImpl`
|
|---|
| 92 | // is injectable for tests; defaults to global fetch.
|
|---|
| 93 | export async function refreshCreatorToken(siteId, fetchImpl = fetch) {
|
|---|
| 94 | const c = getOwnerConfig(siteId);
|
|---|
| 95 | if (!c || !c.clientId || !c.clientSecret || !c.refreshToken) return false;
|
|---|
| 96 | const body = new URLSearchParams({
|
|---|
| 97 | grant_type: 'refresh_token',
|
|---|
| 98 | refresh_token: c.refreshToken,
|
|---|
| 99 | client_id: c.clientId,
|
|---|
| 100 | client_secret: c.clientSecret,
|
|---|
| 101 | });
|
|---|
| 102 | const res = await fetchImpl(TOKEN_URL, {
|
|---|
| 103 | method: 'POST',
|
|---|
| 104 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|---|
| 105 | body: body.toString(),
|
|---|
| 106 | });
|
|---|
| 107 | if (!res.ok) return false;
|
|---|
| 108 | const j = await res.json();
|
|---|
| 109 | if (!j || !j.access_token) return false;
|
|---|
| 110 | saveOwnerConfig(siteId, {
|
|---|
| 111 | accessToken: j.access_token,
|
|---|
| 112 | refreshToken: j.refresh_token || c.refreshToken,
|
|---|
| 113 | tokenExp: Math.floor(Date.now() / 1000) + (parseInt(j.expires_in, 10) || 0),
|
|---|
| 114 | });
|
|---|
| 115 | return true;
|
|---|
| 116 | }
|
|---|
| 117 |
|
|---|
| 118 | // A valid creator access token, refreshing first if it is stale. Null when the
|
|---|
| 119 | // owner has not connected. Used by the patron verify path (slice 3).
|
|---|
| 120 | export async function creatorAccessToken(siteId, fetchImpl = fetch) {
|
|---|
| 121 | if (needsRefresh(siteId)) { try { await refreshCreatorToken(siteId, fetchImpl); } catch { /* fall through */ } }
|
|---|
| 122 | const c = getOwnerConfig(siteId);
|
|---|
| 123 | return c && c.accessToken ? c.accessToken : null;
|
|---|
| 124 | }
|
|---|
| 125 |
|
|---|
| [9e9e6f9] | 126 | // Pure: pick the membership for the owner's campaign out of a Patreon
|
|---|
| 127 | // identity?include=memberships.campaign response (JSON:API). Returns
|
|---|
| 128 | // { status, cents } or null.
|
|---|
| 129 | export function pickCampaignMembership(identity, campaignId) {
|
|---|
| 130 | const inc = (identity && identity.included) || [];
|
|---|
| 131 | for (const it of inc) {
|
|---|
| 132 | if (it.type !== 'member') continue;
|
|---|
| 133 | const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
|
|---|
| 134 | if (!camp || String(camp.id) !== String(campaignId)) continue;
|
|---|
| 135 | const a = it.attributes || {};
|
|---|
| 136 | return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
|
|---|
| 137 | }
|
|---|
| 138 | return null;
|
|---|
| 139 | }
|
|---|
| 140 |
|
|---|
| 141 | // Exchange a patron's auth code and read their membership of the owner's
|
|---|
| 142 | // campaign. Returns { status, cents } or null. The patron token is used once
|
|---|
| 143 | // and discarded here: nothing identifying is stored (design decision).
|
|---|
| 144 | export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
|
|---|
| 145 | const c = getOwnerConfig(siteId);
|
|---|
| 146 | if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;
|
|---|
| 147 | const tokenRes = await fetchImpl(TOKEN_URL, {
|
|---|
| 148 | method: 'POST',
|
|---|
| 149 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|---|
| 150 | body: new URLSearchParams({
|
|---|
| 151 | grant_type: 'authorization_code', code,
|
|---|
| 152 | client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
|
|---|
| 153 | }).toString(),
|
|---|
| 154 | });
|
|---|
| 155 | if (!tokenRes.ok) return null;
|
|---|
| 156 | const tok = await tokenRes.json();
|
|---|
| 157 | if (!tok || !tok.access_token) return null;
|
|---|
| 158 | const url = 'https://www.patreon.com/api/oauth2/v2/identity'
|
|---|
| 159 | + '?include=memberships.campaign'
|
|---|
| 160 | + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
|
|---|
| 161 | const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
|
|---|
| 162 | if (!idRes.ok) return null;
|
|---|
| 163 | const identity = await idRes.json();
|
|---|
| 164 | return pickCampaignMembership(identity, c.campaignId); // token goes out of scope, discarded
|
|---|
| 165 | }
|
|---|
| 166 |
|
|---|
| [61e3daf] | 167 | function safeDecrypt(blob) {
|
|---|
| 168 | try { return decrypt(blob); } catch { return null; }
|
|---|
| 169 | }
|
|---|
| 170 |
|
|---|
| 171 | export default {
|
|---|
| 172 | getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
|
|---|
| 173 | defaultMinCents, needsRefresh, refreshCreatorToken, creatorAccessToken,
|
|---|
| [9e9e6f9] | 174 | pickCampaignMembership, verifyPatron,
|
|---|
| [61e3daf] | 175 | };
|
|---|