openvibers/openvibe.tips Commits
Other ways to support: a creator links Ko-fi, PayPal.me, Patreon and eight more services (Tips builds each link from a handle); the tip page shows them as paid outside OpenVibe (#67)
Changes
@@@ -351,6 +351,14 @@ goals, overlay links shown once, alert settings, simulation, totals, recent tipsSigned-in forms carry an HMAC anti-forgery token and a per-render nonce. OpenVibe Frame: Network`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.++**Other ways to support** (since 2026-10-17, openvibe-contracts 0.202.0 `support_links`; owner 10-08: Billing is provider++agnostic, creators bring their own links): a creator lists where else people pay them (Ko-fi, PayPal.me, Patreon, Buy Me++a Coffee, Cash App, Venmo, GitHub Sponsors, Liberapay, Open Collective, Streamlabs and StreamElements tip pages; at most++8) from the dashboard or `PATCH /api/v1/profiles/:creator {support_links: [{provider, handle}]}`. The tip page shows them++as paid outside OpenVibe (never held, never counted toward goals), `rel="nofollow noopener ugc"`. A link is a known++service plus a handle, and Tips builds the URL ([server/domain/support-links.js](server/domain/support-links.js)): a++pasted link counts only on that service's own host, so a profile cannot carry a look-alike payment page.++## SecurityReporting a vulnerability: [SECURITY.md](SECURITY.md). Tips never moves money (ADR-012): it holdsnnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- Other ways to support a creator, paid outside OpenVibe (openvibe-contracts 0.202.0 support_links;++-- server/domain/support-links.js): [{ provider, handle }], one per known service. Tips builds each URL; none is stored.++ALTER TABLE creator_tip_profiles ADD COLUMN support_links jsonb NOT NULL DEFAULT '[]'::jsonb;@@@ -63,6 +63,9 @@ dl.receipt dd { margin: 0; overflow-wrap: anywhere; }.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media, .js .tip-form[data-kind=sound] .when-sound { display: block; }.sounds .sound-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }.sounds audio { height: 32px; max-width: 100%; }++.support-list { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 8px; }++.support-form { display: grid; gap: 4px 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); align-items: end; }++.support-form button { justify-self: start; }.js .tip-form:not([data-kind=tip]) .when-tip { display: none; }ol.leaderboard { padding-left: 1.6em; }ol.leaderboard li, ul.supporters li { margin: 4px 0; }@@@ -22,6 +22,7 @@const { sql } = require('openvibe-sdk/db');const { fail, iso, text } = require('../util');const filterLib = require('./filter');++const supportLinks = require('./support-links');const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;// Paths the site itself uses; no creator page can take them.@@@ -165,6 +166,7 @@ function createProfiles(ctx) {}if (f.links !== undefined) set.filter_links = on(f.links);}++ if (patch.support_links !== undefined) set.support_links = sql.json(supportLinks.normalize(patch.support_links));if (patch.display_name !== undefined) {const n = text(patch.display_name, 'display_name', 80);if (!n) fail(422, 'tips.invalid_input', 'display_name cannot be empty');@@@ -205,6 +207,8 @@ function createProfiles(ctx) {media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },// The sounds themselves: GET /api/v1/profiles/:creator/sounds (and the public page).sounds: { enabled: !!p.sounds_enabled },++ // Other ways to support, paid outside OpenVibe (support-links.js): Tips builds each link.++ support_links: supportLinks.present(p.support_links),page: p.page,supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,currency: 'vibes-bits',nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++++/**++ * Other ways to support a creator, paid outside OpenVibe (owner 2026-10-08: "our billing system shouldnt be so powerchat++ * dependent… the user can setup powerchat and such on those sites or other platforms… externally"; openvibe-contracts++ * 0.202.0 `support_links`). OpenVibe never sees or holds these payments: the tip page only links to them.++ *++ * A link is a known service plus the creator's handle there, and Tips builds the URL itself. A free-form URL is never++ * stored, so a profile cannot carry a look-alike payment page. A creator may paste their link instead of the handle++ * (https://ko-fi.com/name): the handle is read from it, and only from that service's own host.++ */++const { fail } = require('../util');++++const PROVIDERS = {++ kofi: { label: 'Ko-fi', hosts: ['ko-fi.com'], path: /^\/([A-Za-z0-9_]{2,40})\/?$/, handle: /^[A-Za-z0-9_]{2,40}$/, url: (h) => `https://ko-fi.com/${h}` },++ paypal: { label: 'PayPal.me', hosts: ['paypal.me', 'paypal.com'], path: /^\/(?:paypalme\/)?([A-Za-z0-9]{1,20})\/?$/, handle: /^[A-Za-z0-9]{1,20}$/, url: (h) => `https://paypal.me/${h}` },++ patreon: { label: 'Patreon', hosts: ['patreon.com'], path: /^\/(?:c\/)?([A-Za-z0-9_-]{1,64})\/?$/, handle: /^[A-Za-z0-9_-]{1,64}$/, url: (h) => `https://www.patreon.com/${h}` },++ buymeacoffee: { label: 'Buy Me a Coffee', hosts: ['buymeacoffee.com'], path: /^\/([A-Za-z0-9_-]{1,64})\/?$/, handle: /^[A-Za-z0-9_-]{1,64}$/, url: (h) => `https://buymeacoffee.com/${h}` },++ cashapp: { label: 'Cash App', hosts: ['cash.app'], path: /^\/\$([A-Za-z][A-Za-z0-9_-]{0,19})\/?$/, handle: /^[A-Za-z][A-Za-z0-9_-]{0,19}$/, url: (h) => `https://cash.app/$${h}` },++ venmo: { label: 'Venmo', hosts: ['venmo.com', 'account.venmo.com'], path: /^\/(?:u\/)?([A-Za-z0-9_-]{5,30})\/?$/, handle: /^[A-Za-z0-9_-]{5,30}$/, url: (h) => `https://venmo.com/u/${h}` },++ github: { label: 'GitHub Sponsors', hosts: ['github.com'], path: /^\/sponsors\/([A-Za-z0-9-]{1,39})\/?$/, handle: /^[A-Za-z0-9-]{1,39}$/, url: (h) => `https://github.com/sponsors/${h}` },++ liberapay: { label: 'Liberapay', hosts: ['liberapay.com'], path: /^\/([A-Za-z0-9_.-]{1,32})\/?$/, handle: /^[A-Za-z0-9_.-]{1,32}$/, url: (h) => `https://liberapay.com/${h}` },++ opencollective: { label: 'Open Collective', hosts: ['opencollective.com'], path: /^\/([a-z0-9-]{1,64})\/?$/, handle: /^[a-z0-9-]{1,64}$/, url: (h) => `https://opencollective.com/${h}` },++ streamlabs: { label: 'Streamlabs', hosts: ['streamlabs.com'], path: /^\/([A-Za-z0-9_]{1,40})\/tip\/?$/, handle: /^[A-Za-z0-9_]{1,40}$/, url: (h) => `https://streamlabs.com/${h}/tip` },++ streamelements: { label: 'StreamElements', hosts: ['streamelements.com'], path: /^\/([A-Za-z0-9_]{1,40})\/tip\/?$/, handle: /^[A-Za-z0-9_]{1,40}$/, url: (h) => `https://streamelements.com/${h}/tip` },++};++const MAX_LINKS = 8;++++/** The handle from what the creator typed: a bare handle (an @ or $ in front is dropped) or their link on that service. */++function handleOf(provider, value) {++ const p = PROVIDERS[provider];++ const raw = String(value == null ? '' : value).trim();++ if (!raw) return null;++ if (/^https?:\/\//i.test(raw) || /^(?:www\.)?[a-z0-9.-]+\.[a-z]{2,}\//i.test(raw)) {++ let u;++ try { u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return undefined; }++ const host = u.hostname.toLowerCase().replace(/^www\./, '');++ if (!p.hosts.includes(host)) return undefined;++ const m = p.path.exec(u.pathname);++ return m ? m[1] : undefined;++ }++ const h = raw.replace(/^[@$]/, '');++ return p.handle.test(h) ? h : undefined;++}++++/** A request's support_links → the stored list ([{ provider, handle }], one per provider, in the order given). */++function normalize(input) {++ if (!Array.isArray(input)) fail(422, 'tips.invalid_input', 'support_links must be a list of { provider, handle }');++ const out = [];++ for (const item of input) {++ if (!item || typeof item !== 'object') fail(422, 'tips.invalid_input', 'each support link is { provider, handle }');++ const provider = String(item.provider || '');++ if (!PROVIDERS[provider]) fail(422, 'tips.invalid_input', `support link provider must be one of ${Object.keys(PROVIDERS).join(', ')}`);++ const handle = handleOf(provider, item.handle);++ if (handle === null) continue; // left blank: no link for that service++ if (handle === undefined) fail(422, 'tips.invalid_input', `that is not a ${PROVIDERS[provider].label} name or link`);++ if (out.some((l) => l.provider === provider)) fail(422, 'tips.invalid_input', `one ${PROVIDERS[provider].label} link only`);++ out.push({ provider, handle });++ }++ if (out.length > MAX_LINKS) fail(422, 'tips.invalid_input', `at most ${MAX_LINKS} other ways to support you`);++ return out;++}++++/** Stored list → what pages and the API show (unknown or invalid rows are skipped, never shown). */++function present(stored) {++ return (Array.isArray(stored) ? stored : []).map((l) => {++ const p = l && PROVIDERS[l.provider];++ if (!p || !p.handle.test(String(l.handle || ''))) return null;++ return { provider: l.provider, label: p.label, handle: l.handle, url: p.url(l.handle) };++ }).filter(Boolean).slice(0, MAX_LINKS);++}++++module.exports = { PROVIDERS, MAX_LINKS, handleOf, normalize, present };@@@ -8,6 +8,7 @@const showcase = require('openvibe-shared/showcase');const { esc } = require('./layout');const { VOICES } = require('../domain/profiles');++const supportLinks = require('../domain/support-links');const n = (v) => Number(v || 0).toLocaleString('en-US');const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');@@@ -128,6 +129,16 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e</form>`;}++/** Other ways to support, paid outside OpenVibe (domain/support-links.js builds every link; none is user-written). */++function supportLinksSection(profile) {++ const links = supportLinks.present(profile.support_links);++ if (!links.length) return '';++ return `<section class="card support-links"><h2>Other ways to support ${esc(profile.display_name)}</h2>++ <ul class="support-list">${links.map((l) => `<li><a class="btn ghost" href="${esc(l.url)}" rel="nofollow noopener ugc" target="_blank">${esc(l.label)}</a></li>`).join('')}</ul>++ <p class="fine">These are paid outside OpenVibe: OpenVibe never sees or holds them, and they do not count toward goals.</p>++</section>`;++}++function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview, sounds = [] }) {const active = goals.filter((g) => g.status === 'active');return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}@@@ -137,7 +148,8 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er<p class="links"><a href="https://openvibe.live/@${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div></section>${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}--${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}`;++${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}++${supportLinksSection(profile)}`;}function goalsPage({ profile, goals }) {@@@ -236,6 +248,19 @@ function soundsCard({ profile: p, sounds, csrf, idem }) {</section>`;}++/** The dashboard's other ways to support: one field per known service (a name, or the creator's link there). */++function supportLinksCard({ profile: p, csrf, idem }) {++ const have = new Map(supportLinks.present(p.support_links).map((l) => [l.provider, l.handle]));++ const hint = { kofi: 'ko-fi.com/…', paypal: 'paypal.me/…', patreon: 'patreon.com/…', buymeacoffee: 'buymeacoffee.com/…', cashapp: 'cash.app/$…', venmo: 'venmo.com/u/…', github: 'github.com/sponsors/…', liberapay: 'liberapay.com/…', opencollective: 'opencollective.com/…', streamlabs: 'streamlabs.com/…/tip', streamelements: 'streamelements.com/…/tip' };++ return `<section class="card" id="support-links"><h2>Other ways to support you</h2>++ <p>Your tip page can link to where else people pay you. Type your name on each service you use, or paste your link there; leave the rest blank. Up to ${supportLinks.MAX_LINKS}. These payments happen outside OpenVibe and do not count toward goals.</p>++ <form method="post" action="/dashboard/support-links" class="support-form">${hidden({ csrf, idem: `${idem}-sl` })}++ ${Object.entries(supportLinks.PROVIDERS).map(([k, v]) => `<label>${esc(v.label)} <input type="text" name="link_${k}" maxlength="200" autocomplete="off" spellcheck="false" placeholder="${esc(hint[k] || '')}" value="${esc(have.get(k) || '')}"></label>`).join('\n ')}++ <button class="btn" type="submit">Save links</button>++ </form>++</section>`;++}++function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {}, sounds = [] }) {const p = profile;const f = (k) => (p[k] ? ' checked' : '');@@@ -247,7 +272,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}<div class="card"><h2>Totals</h2><p class="big">${n(totals.settled_via_billing)} <small>Vibes settled through Billing</small></p><p>${n(totals.external)} Vibes-equivalent tipped on your own PowerChat · ${n(totals.interactions)} tips · ${n(totals.pending)} pending</p>-- <p class="fine">Simulations (${n(totals.simulations)}) are never counted. Your balance and cash-outs are on OpenVibe.Live until Billing takes over.</p>++ <p class="fine">Simulations (${n(totals.simulations)}) are never counted. OpenVibe.Billing keeps your Vibes: your balance and cash-outs are in <a href="https://openvibe.live/#vibes">your Vibes</a>.</p></div><div class="card"><h2>Your page</h2><p><a href="/${esc(p.handle)}">openvibe.tips/${esc(p.handle)}</a> — ${p.page_enabled ? 'on and indexable' : 'off (only you can see it)'}</p>@@@ -280,6 +305,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}</div></section>${soundsCard({ profile: p, sounds, csrf, idem })}++${supportLinksCard({ profile: p, csrf, idem })}<section class="card"><h2>Goals</h2>${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}<label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>@@@ -36,6 +36,7 @@ const pages = require('./pages');const { asset } = require('./layout');const { PAGE_DEFAULTS } = require('../domain/profiles');const multipart = require('./multipart');++const supportLinks = require('../domain/support-links');// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.const BOOT_AT = new Date().toISOString();@@@ -259,6 +260,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {return { done: 'Page settings saved' };});action('/dashboard/goals', async (req, p) => { await goals.create(p.creator_subject, req.body); return { done: 'Goal added' }; });++ action('/dashboard/support-links', async (req, p) => {++ const links = Object.keys(supportLinks.PROVIDERS).map((provider) => ({ provider, handle: req.body[`link_${provider}`] }));++ await profiles.update(p.creator_subject, { support_links: links });++ return { done: 'Links saved' };++ });const ownGoal = async (req, p) => {const g = await goals.get(db, req.params.id);if (!g || g.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.goal_not_found', 'no such goal');nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++// Other ways to support a creator, paid outside OpenVibe (owner 10-08: Billing is provider agnostic; creators bring++// Ko-fi, PayPal.me, Patreon and other links). A link is a known service plus the creator's handle, and Tips builds the++// URL: a pasted link counts only on that service's own host, so no look-alike page can be stored.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const links = require('../server/domain/support-links');++++(async () => {++ await check('a handle or the link on that service; any other host or a bad name is refused', async () => {++ assert.strictEqual(links.handleOf('kofi', 'tierra_tv'), 'tierra_tv');++ assert.strictEqual(links.handleOf('kofi', 'https://ko-fi.com/tierra_tv'), 'tierra_tv');++ assert.strictEqual(links.handleOf('kofi', 'ko-fi.com/tierra_tv/'), 'tierra_tv');++ assert.strictEqual(links.handleOf('kofi', 'https://ko-fi.com.evil.example/tierra'), undefined, 'a look-alike host');++ assert.strictEqual(links.handleOf('kofi', 'https://evil.example/ko-fi.com/tierra'), undefined);++ assert.strictEqual(links.handleOf('paypal', 'https://www.paypal.com/paypalme/Tierra'), 'Tierra');++ assert.strictEqual(links.handleOf('cashapp', '$tierra'), 'tierra');++ assert.strictEqual(links.handleOf('cashapp', 'https://cash.app/$tierra'), 'tierra');++ assert.strictEqual(links.handleOf('github', 'https://github.com/sponsors/tierra'), 'tierra');++ assert.strictEqual(links.handleOf('github', 'https://github.com/tierra'), undefined, 'only the Sponsors page');++ assert.strictEqual(links.handleOf('streamlabs', 'streamlabs.com/tierra/tip'), 'tierra');++ assert.strictEqual(links.handleOf('patreon', 'javascript:alert(1)'), undefined);++ assert.strictEqual(links.handleOf('kofi', ' '), null, 'blank means no link');++ assert.deepStrictEqual(links.present([{ provider: 'kofi', handle: 'x"><script>' }, { provider: 'myspace', handle: 'a' }, { provider: 'liberapay', handle: 'tierra' }]),++ [{ provider: 'liberapay', label: 'Liberapay', handle: 'tierra', url: 'https://liberapay.com/tierra' }], 'stored rows that are not valid are never shown');++ });++++ const t = await boot();++ const tierra = await t.creator('tierra');++ const viewer = t.network.newUser('viewer');++ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;++ const get = async (p, u) => { const r = await fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }); return { status: r.status, text: await r.text() }; };++ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };++++ await check('the API sets them (a pasted link becomes the handle); the public profile carries the links Tips built', async () => {++ const r = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'https://ko-fi.com/tierra_tv' }, { provider: 'paypal', handle: 'TierraTV' }] } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json.profile.support_links, [++ { provider: 'kofi', label: 'Ko-fi', handle: 'tierra_tv', url: 'https://ko-fi.com/tierra_tv' },++ { provider: 'paypal', label: 'PayPal.me', handle: 'TierraTV', url: 'https://paypal.me/TierraTV' },++ ]);++ const pub = await t.call('GET', '/api/v1/profiles/tierra', { token: null });++ assert.strictEqual(pub.json.profile.support_links.length, 2);++ const bad = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'https://k0-fi.com/tierra' }] } });++ assert.strictEqual(bad.status, 422);++ const twice = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'a1' }, { provider: 'kofi', handle: 'b2' }] } });++ assert.strictEqual(twice.status, 422);++ const other = await t.call('PATCH', '/api/v1/profiles/tierra', { user: viewer, body: { support_links: [] } });++ assert.strictEqual(other.status, 403, 'only the creator (or a granted service) changes them');++ });++++ await check('the tip page lists them as paid outside OpenVibe, nofollow and in a new tab', async () => {++ const page = await get('/tierra');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /Other ways to support/);++ assert.match(page.text, /<a class="btn ghost" href="https:\/\/ko-fi\.com\/tierra_tv" rel="nofollow noopener ugc" target="_blank">Ko-fi<\/a>/);++ assert.match(page.text, /paid outside OpenVibe: OpenVibe never sees or holds them/);++ });++++ await check('the dashboard form saves one field per service; blank ones are removed', async () => {++ const d = await get('/dashboard', tierra);++ assert.match(d.text, /id="support-links"/);++ assert.match(d.text, /name="link_kofi"[^>]*value="tierra_tv"/);++ const body = new URLSearchParams({ csrf: field(d.text, 'csrf'), idem: `sl-${Date.now()}`, link_kofi: '', link_patreon: 'https://www.patreon.com/tierra', link_cashapp: '$tierra' });++ const r = await fetch(`${t.base}/dashboard/support-links`, { method: 'POST', body, redirect: 'manual', headers: { Cookie: cookie(tierra), 'Content-Type': 'application/x-www-form-urlencoded' } });++ const txt = await r.text();++ assert.strictEqual(r.status, 303, (/class="error[^"]*"[^>]*>([\s\S]{0,200})/.exec(txt) || [])[1] || String(r.status));++ const pub = await t.call('GET', '/api/v1/profiles/tierra', { token: null });++ assert.deepStrictEqual(pub.json.profile.support_links.map((l) => l.url), ['https://www.patreon.com/tierra', 'https://cash.app/$tierra']);++ });++++ await t.close();++ done();++})();
@@ -351,6 +351,14 @@ goals, overlay links shown once, alert settings, simulation, totals, recent tips
@@ -351,6 +351,14 @@ goals, overlay links shown once, alert settings, simulation, totals, recent tips
Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. OpenVibe Frame: Network
Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. OpenVibe Frame: Network
`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.
`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.
+**Other ways to support** (since 2026-10-17, openvibe-contracts 0.202.0 `support_links`; owner 10-08: Billing is provider
+agnostic, creators bring their own links): a creator lists where else people pay them (Ko-fi, PayPal.me, Patreon, Buy Me
+a Coffee, Cash App, Venmo, GitHub Sponsors, Liberapay, Open Collective, Streamlabs and StreamElements tip pages; at most
+8) from the dashboard or `PATCH /api/v1/profiles/:creator {support_links: [{provider, handle}]}`. The tip page shows them
+as paid outside OpenVibe (never held, never counted toward goals), `rel="nofollow noopener ugc"`. A link is a known
+service plus a handle, and Tips builds the URL ([server/domain/support-links.js](server/domain/support-links.js)): a
+pasted link counts only on that service's own host, so a profile cannot carry a look-alike payment page.
+
## Security
## Security
Reporting a vulnerability: [SECURITY.md](SECURITY.md). Tips never moves money (ADR-012): it holds
Reporting a vulnerability: [SECURITY.md](SECURITY.md). Tips never moves money (ADR-012): it holds
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- Other ways to support a creator, paid outside OpenVibe (openvibe-contracts 0.202.0 support_links;
+-- server/domain/support-links.js): [{ provider, handle }], one per known service. Tips builds each URL; none is stored.
+ALTER TABLE creator_tip_profiles ADD COLUMN support_links jsonb NOT NULL DEFAULT '[]'::jsonb;
@@ -63,6 +63,9 @@ dl.receipt dd { margin: 0; overflow-wrap: anywhere; }
@@ -63,6 +63,9 @@ dl.receipt dd { margin: 0; overflow-wrap: anywhere; }
.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media, .js .tip-form[data-kind=sound] .when-sound { display: block; }
.js .tip-form[data-kind=tts] .when-tts, .js .tip-form[data-kind=media_request] .when-media, .js .tip-form[data-kind=sound] .when-sound { display: block; }
.sounds .sound-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.sounds .sound-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.sounds audio { height: 32px; max-width: 100%; }
.sounds audio { height: 32px; max-width: 100%; }
+.support-list { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.support-form { display: grid; gap: 4px 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); align-items: end; }
+.support-form button { justify-self: start; }
.js .tip-form:not([data-kind=tip]) .when-tip { display: none; }
.js .tip-form:not([data-kind=tip]) .when-tip { display: none; }
ol.leaderboard { padding-left: 1.6em; }
ol.leaderboard { padding-left: 1.6em; }
ol.leaderboard li, ul.supporters li { margin: 4px 0; }
ol.leaderboard li, ul.supporters li { margin: 4px 0; }
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
const { sql } = require('openvibe-sdk/db');
const { sql } = require('openvibe-sdk/db');
const { fail, iso, text } = require('../util');
const { fail, iso, text } = require('../util');
const filterLib = require('./filter');
const filterLib = require('./filter');
+const supportLinks = require('./support-links');
const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
// Paths the site itself uses; no creator page can take them.
// Paths the site itself uses; no creator page can take them.
@@ -165,6 +166,7 @@ function createProfiles(ctx) {
@@ -165,6 +166,7 @@ function createProfiles(ctx) {
}
}
if (f.links !== undefined) set.filter_links = on(f.links);
if (f.links !== undefined) set.filter_links = on(f.links);
}
}
+ if (patch.support_links !== undefined) set.support_links = sql.json(supportLinks.normalize(patch.support_links));
if (patch.display_name !== undefined) {
if (patch.display_name !== undefined) {
const n = text(patch.display_name, 'display_name', 80);
const n = text(patch.display_name, 'display_name', 80);
if (!n) fail(422, 'tips.invalid_input', 'display_name cannot be empty');
if (!n) fail(422, 'tips.invalid_input', 'display_name cannot be empty');
@@ -205,6 +207,8 @@ function createProfiles(ctx) {
@@ -205,6 +207,8 @@ function createProfiles(ctx) {
media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },
media_requests: { enabled: p.media_requests_enabled, max_seconds: p.media_max_seconds },
// The sounds themselves: GET /api/v1/profiles/:creator/sounds (and the public page).
// The sounds themselves: GET /api/v1/profiles/:creator/sounds (and the public page).
sounds: { enabled: !!p.sounds_enabled },
sounds: { enabled: !!p.sounds_enabled },
+ // Other ways to support, paid outside OpenVibe (support-links.js): Tips builds each link.
+ support_links: supportLinks.present(p.support_links),
page: p.page,
page: p.page,
supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,
supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/${p.handle}/supporters` : null,
currency: 'vibes-bits',
currency: 'vibes-bits',
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+
+/**
+ * Other ways to support a creator, paid outside OpenVibe (owner 2026-10-08: "our billing system shouldnt be so powerchat
+ * dependent… the user can setup powerchat and such on those sites or other platforms… externally"; openvibe-contracts
+ * 0.202.0 `support_links`). OpenVibe never sees or holds these payments: the tip page only links to them.
+ *
+ * A link is a known service plus the creator's handle there, and Tips builds the URL itself. A free-form URL is never
+ * stored, so a profile cannot carry a look-alike payment page. A creator may paste their link instead of the handle
+ * (https://ko-fi.com/name): the handle is read from it, and only from that service's own host.
+ */
+const { fail } = require('../util');
+
+const PROVIDERS = {
+ kofi: { label: 'Ko-fi', hosts: ['ko-fi.com'], path: /^\/([A-Za-z0-9_]{2,40})\/?$/, handle: /^[A-Za-z0-9_]{2,40}$/, url: (h) => `https://ko-fi.com/${h}` },
+ paypal: { label: 'PayPal.me', hosts: ['paypal.me', 'paypal.com'], path: /^\/(?:paypalme\/)?([A-Za-z0-9]{1,20})\/?$/, handle: /^[A-Za-z0-9]{1,20}$/, url: (h) => `https://paypal.me/${h}` },
+ patreon: { label: 'Patreon', hosts: ['patreon.com'], path: /^\/(?:c\/)?([A-Za-z0-9_-]{1,64})\/?$/, handle: /^[A-Za-z0-9_-]{1,64}$/, url: (h) => `https://www.patreon.com/${h}` },
+ buymeacoffee: { label: 'Buy Me a Coffee', hosts: ['buymeacoffee.com'], path: /^\/([A-Za-z0-9_-]{1,64})\/?$/, handle: /^[A-Za-z0-9_-]{1,64}$/, url: (h) => `https://buymeacoffee.com/${h}` },
+ cashapp: { label: 'Cash App', hosts: ['cash.app'], path: /^\/\$([A-Za-z][A-Za-z0-9_-]{0,19})\/?$/, handle: /^[A-Za-z][A-Za-z0-9_-]{0,19}$/, url: (h) => `https://cash.app/$${h}` },
+ venmo: { label: 'Venmo', hosts: ['venmo.com', 'account.venmo.com'], path: /^\/(?:u\/)?([A-Za-z0-9_-]{5,30})\/?$/, handle: /^[A-Za-z0-9_-]{5,30}$/, url: (h) => `https://venmo.com/u/${h}` },
+ github: { label: 'GitHub Sponsors', hosts: ['github.com'], path: /^\/sponsors\/([A-Za-z0-9-]{1,39})\/?$/, handle: /^[A-Za-z0-9-]{1,39}$/, url: (h) => `https://github.com/sponsors/${h}` },
+ liberapay: { label: 'Liberapay', hosts: ['liberapay.com'], path: /^\/([A-Za-z0-9_.-]{1,32})\/?$/, handle: /^[A-Za-z0-9_.-]{1,32}$/, url: (h) => `https://liberapay.com/${h}` },
+ opencollective: { label: 'Open Collective', hosts: ['opencollective.com'], path: /^\/([a-z0-9-]{1,64})\/?$/, handle: /^[a-z0-9-]{1,64}$/, url: (h) => `https://opencollective.com/${h}` },
+ streamlabs: { label: 'Streamlabs', hosts: ['streamlabs.com'], path: /^\/([A-Za-z0-9_]{1,40})\/tip\/?$/, handle: /^[A-Za-z0-9_]{1,40}$/, url: (h) => `https://streamlabs.com/${h}/tip` },
+ streamelements: { label: 'StreamElements', hosts: ['streamelements.com'], path: /^\/([A-Za-z0-9_]{1,40})\/tip\/?$/, handle: /^[A-Za-z0-9_]{1,40}$/, url: (h) => `https://streamelements.com/${h}/tip` },
+};
+const MAX_LINKS = 8;
+
+/** The handle from what the creator typed: a bare handle (an @ or $ in front is dropped) or their link on that service. */
+function handleOf(provider, value) {
+ const p = PROVIDERS[provider];
+ const raw = String(value == null ? '' : value).trim();
+ if (!raw) return null;
+ if (/^https?:\/\//i.test(raw) || /^(?:www\.)?[a-z0-9.-]+\.[a-z]{2,}\//i.test(raw)) {
+ let u;
+ try { u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return undefined; }
+ const host = u.hostname.toLowerCase().replace(/^www\./, '');
+ if (!p.hosts.includes(host)) return undefined;
+ const m = p.path.exec(u.pathname);
+ return m ? m[1] : undefined;
+ }
+ const h = raw.replace(/^[@$]/, '');
+ return p.handle.test(h) ? h : undefined;
+}
+
+/** A request's support_links → the stored list ([{ provider, handle }], one per provider, in the order given). */
+function normalize(input) {
+ if (!Array.isArray(input)) fail(422, 'tips.invalid_input', 'support_links must be a list of { provider, handle }');
+ const out = [];
+ for (const item of input) {
+ if (!item || typeof item !== 'object') fail(422, 'tips.invalid_input', 'each support link is { provider, handle }');
+ const provider = String(item.provider || '');
+ if (!PROVIDERS[provider]) fail(422, 'tips.invalid_input', `support link provider must be one of ${Object.keys(PROVIDERS).join(', ')}`);
+ const handle = handleOf(provider, item.handle);
+ if (handle === null) continue; // left blank: no link for that service
+ if (handle === undefined) fail(422, 'tips.invalid_input', `that is not a ${PROVIDERS[provider].label} name or link`);
+ if (out.some((l) => l.provider === provider)) fail(422, 'tips.invalid_input', `one ${PROVIDERS[provider].label} link only`);
+ out.push({ provider, handle });
+ }
+ if (out.length > MAX_LINKS) fail(422, 'tips.invalid_input', `at most ${MAX_LINKS} other ways to support you`);
+ return out;
+}
+
+/** Stored list → what pages and the API show (unknown or invalid rows are skipped, never shown). */
+function present(stored) {
+ return (Array.isArray(stored) ? stored : []).map((l) => {
+ const p = l && PROVIDERS[l.provider];
+ if (!p || !p.handle.test(String(l.handle || ''))) return null;
+ return { provider: l.provider, label: p.label, handle: l.handle, url: p.url(l.handle) };
+ }).filter(Boolean).slice(0, MAX_LINKS);
+}
+
+module.exports = { PROVIDERS, MAX_LINKS, handleOf, normalize, present };
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { esc } = require('./layout');
const { esc } = require('./layout');
const { VOICES } = require('../domain/profiles');
const { VOICES } = require('../domain/profiles');
+const supportLinks = require('../domain/support-links');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
@@ -128,6 +129,16 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
@@ -128,6 +129,16 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
</form>`;
</form>`;
}
}
+/** Other ways to support, paid outside OpenVibe (domain/support-links.js builds every link; none is user-written). */
+function supportLinksSection(profile) {
+ const links = supportLinks.present(profile.support_links);
+ if (!links.length) return '';
+ return `<section class="card support-links"><h2>Other ways to support ${esc(profile.display_name)}</h2>
+ <ul class="support-list">${links.map((l) => `<li><a class="btn ghost" href="${esc(l.url)}" rel="nofollow noopener ugc" target="_blank">${esc(l.label)}</a></li>`).join('')}</ul>
+ <p class="fine">These are paid outside OpenVibe: OpenVibe never sees or holds them, and they do not count toward goals.</p>
+</section>`;
+}
+
function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview, sounds = [] }) {
function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview, sounds = [] }) {
const active = goals.filter((g) => g.status === 'active');
const active = goals.filter((g) => g.status === 'active');
return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}
return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}
@@ -137,7 +148,8 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
@@ -137,7 +148,8 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
<p class="links"><a href="https://openvibe.live/@${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div>
<p class="links"><a href="https://openvibe.live/@${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div>
</section>
</section>
${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
-${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}`;
+${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}
+${supportLinksSection(profile)}`;
}
}
function goalsPage({ profile, goals }) {
function goalsPage({ profile, goals }) {
@@ -236,6 +248,19 @@ function soundsCard({ profile: p, sounds, csrf, idem }) {
@@ -236,6 +248,19 @@ function soundsCard({ profile: p, sounds, csrf, idem }) {
</section>`;
</section>`;
}
}
+/** The dashboard's other ways to support: one field per known service (a name, or the creator's link there). */
+function supportLinksCard({ profile: p, csrf, idem }) {
+ const have = new Map(supportLinks.present(p.support_links).map((l) => [l.provider, l.handle]));
+ const hint = { kofi: 'ko-fi.com/…', paypal: 'paypal.me/…', patreon: 'patreon.com/…', buymeacoffee: 'buymeacoffee.com/…', cashapp: 'cash.app/$…', venmo: 'venmo.com/u/…', github: 'github.com/sponsors/…', liberapay: 'liberapay.com/…', opencollective: 'opencollective.com/…', streamlabs: 'streamlabs.com/…/tip', streamelements: 'streamelements.com/…/tip' };
+ return `<section class="card" id="support-links"><h2>Other ways to support you</h2>
+ <p>Your tip page can link to where else people pay you. Type your name on each service you use, or paste your link there; leave the rest blank. Up to ${supportLinks.MAX_LINKS}. These payments happen outside OpenVibe and do not count toward goals.</p>
+ <form method="post" action="/dashboard/support-links" class="support-form">${hidden({ csrf, idem: `${idem}-sl` })}
+ ${Object.entries(supportLinks.PROVIDERS).map(([k, v]) => `<label>${esc(v.label)} <input type="text" name="link_${k}" maxlength="200" autocomplete="off" spellcheck="false" placeholder="${esc(hint[k] || '')}" value="${esc(have.get(k) || '')}"></label>`).join('\n ')}
+ <button class="btn" type="submit">Save links</button>
+ </form>
+</section>`;
+}
+
function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {}, sounds = [] }) {
function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {}, sounds = [] }) {
const p = profile;
const p = profile;
const f = (k) => (p[k] ? ' checked' : '');
const f = (k) => (p[k] ? ' checked' : '');
@@ -247,7 +272,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
@@ -247,7 +272,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
<div class="card"><h2>Totals</h2>
<div class="card"><h2>Totals</h2>
<p class="big">${n(totals.settled_via_billing)} <small>Vibes settled through Billing</small></p>
<p class="big">${n(totals.settled_via_billing)} <small>Vibes settled through Billing</small></p>
<p>${n(totals.external)} Vibes-equivalent tipped on your own PowerChat · ${n(totals.interactions)} tips · ${n(totals.pending)} pending</p>
<p>${n(totals.external)} Vibes-equivalent tipped on your own PowerChat · ${n(totals.interactions)} tips · ${n(totals.pending)} pending</p>
- <p class="fine">Simulations (${n(totals.simulations)}) are never counted. Your balance and cash-outs are on OpenVibe.Live until Billing takes over.</p>
+ <p class="fine">Simulations (${n(totals.simulations)}) are never counted. OpenVibe.Billing keeps your Vibes: your balance and cash-outs are in <a href="https://openvibe.live/#vibes">your Vibes</a>.</p>
</div>
</div>
<div class="card"><h2>Your page</h2>
<div class="card"><h2>Your page</h2>
<p><a href="/${esc(p.handle)}">openvibe.tips/${esc(p.handle)}</a> — ${p.page_enabled ? 'on and indexable' : 'off (only you can see it)'}</p>
<p><a href="/${esc(p.handle)}">openvibe.tips/${esc(p.handle)}</a> — ${p.page_enabled ? 'on and indexable' : 'off (only you can see it)'}</p>
@@ -280,6 +305,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
@@ -280,6 +305,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
</div>
</div>
</section>
</section>
${soundsCard({ profile: p, sounds, csrf, idem })}
${soundsCard({ profile: p, sounds, csrf, idem })}
+${supportLinksCard({ profile: p, csrf, idem })}
<section class="card"><h2>Goals</h2>
<section class="card"><h2>Goals</h2>
${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}
${goals.length ? goals.map((g) => `${goalCard(g)}${g.status === 'active' ? `<form class="inline" method="post" action="/dashboard/goals/${esc(g.id)}">${hidden({ csrf, idem: `${idem}-g-${g.id}`, revision: g.revision })}
<label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>
<label>Title <input name="title" maxlength="120" value="${esc(g.title)}"></label><label>Target <input type="number" name="target_amount" min="1" value="${g.target_amount}"></label>
@@ -36,6 +36,7 @@ const pages = require('./pages');
@@ -36,6 +36,7 @@ const pages = require('./pages');
const { asset } = require('./layout');
const { asset } = require('./layout');
const { PAGE_DEFAULTS } = require('../domain/profiles');
const { PAGE_DEFAULTS } = require('../domain/profiles');
const multipart = require('./multipart');
const multipart = require('./multipart');
+const supportLinks = require('../domain/support-links');
// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.
// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.
const BOOT_AT = new Date().toISOString();
const BOOT_AT = new Date().toISOString();
@@ -259,6 +260,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -259,6 +260,11 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
return { done: 'Page settings saved' };
return { done: 'Page settings saved' };
});
});
action('/dashboard/goals', async (req, p) => { await goals.create(p.creator_subject, req.body); return { done: 'Goal added' }; });
action('/dashboard/goals', async (req, p) => { await goals.create(p.creator_subject, req.body); return { done: 'Goal added' }; });
+ action('/dashboard/support-links', async (req, p) => {
+ const links = Object.keys(supportLinks.PROVIDERS).map((provider) => ({ provider, handle: req.body[`link_${provider}`] }));
+ await profiles.update(p.creator_subject, { support_links: links });
+ return { done: 'Links saved' };
+ });
const ownGoal = async (req, p) => {
const ownGoal = async (req, p) => {
const g = await goals.get(db, req.params.id);
const g = await goals.get(db, req.params.id);
if (!g || g.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.goal_not_found', 'no such goal');
if (!g || g.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.goal_not_found', 'no such goal');
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+// Other ways to support a creator, paid outside OpenVibe (owner 10-08: Billing is provider agnostic; creators bring
+// Ko-fi, PayPal.me, Patreon and other links). A link is a known service plus the creator's handle, and Tips builds the
+// URL: a pasted link counts only on that service's own host, so no look-alike page can be stored.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const links = require('../server/domain/support-links');
+
+(async () => {
+ await check('a handle or the link on that service; any other host or a bad name is refused', async () => {
+ assert.strictEqual(links.handleOf('kofi', 'tierra_tv'), 'tierra_tv');
+ assert.strictEqual(links.handleOf('kofi', 'https://ko-fi.com/tierra_tv'), 'tierra_tv');
+ assert.strictEqual(links.handleOf('kofi', 'ko-fi.com/tierra_tv/'), 'tierra_tv');
+ assert.strictEqual(links.handleOf('kofi', 'https://ko-fi.com.evil.example/tierra'), undefined, 'a look-alike host');
+ assert.strictEqual(links.handleOf('kofi', 'https://evil.example/ko-fi.com/tierra'), undefined);
+ assert.strictEqual(links.handleOf('paypal', 'https://www.paypal.com/paypalme/Tierra'), 'Tierra');
+ assert.strictEqual(links.handleOf('cashapp', '$tierra'), 'tierra');
+ assert.strictEqual(links.handleOf('cashapp', 'https://cash.app/$tierra'), 'tierra');
+ assert.strictEqual(links.handleOf('github', 'https://github.com/sponsors/tierra'), 'tierra');
+ assert.strictEqual(links.handleOf('github', 'https://github.com/tierra'), undefined, 'only the Sponsors page');
+ assert.strictEqual(links.handleOf('streamlabs', 'streamlabs.com/tierra/tip'), 'tierra');
+ assert.strictEqual(links.handleOf('patreon', 'javascript:alert(1)'), undefined);
+ assert.strictEqual(links.handleOf('kofi', ' '), null, 'blank means no link');
+ assert.deepStrictEqual(links.present([{ provider: 'kofi', handle: 'x"><script>' }, { provider: 'myspace', handle: 'a' }, { provider: 'liberapay', handle: 'tierra' }]),
+ [{ provider: 'liberapay', label: 'Liberapay', handle: 'tierra', url: 'https://liberapay.com/tierra' }], 'stored rows that are not valid are never shown');
+ });
+
+ const t = await boot();
+ const tierra = await t.creator('tierra');
+ const viewer = t.network.newUser('viewer');
+ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;
+ const get = async (p, u) => { const r = await fetch(t.base + p, { headers: u ? { Cookie: cookie(u) } : {}, redirect: 'manual' }); return { status: r.status, text: await r.text() }; };
+ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };
+
+ await check('the API sets them (a pasted link becomes the handle); the public profile carries the links Tips built', async () => {
+ const r = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'https://ko-fi.com/tierra_tv' }, { provider: 'paypal', handle: 'TierraTV' }] } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json.profile.support_links, [
+ { provider: 'kofi', label: 'Ko-fi', handle: 'tierra_tv', url: 'https://ko-fi.com/tierra_tv' },
+ { provider: 'paypal', label: 'PayPal.me', handle: 'TierraTV', url: 'https://paypal.me/TierraTV' },
+ ]);
+ const pub = await t.call('GET', '/api/v1/profiles/tierra', { token: null });
+ assert.strictEqual(pub.json.profile.support_links.length, 2);
+ const bad = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'https://k0-fi.com/tierra' }] } });
+ assert.strictEqual(bad.status, 422);
+ const twice = await t.call('PATCH', '/api/v1/profiles/me', { user: tierra, body: { support_links: [{ provider: 'kofi', handle: 'a1' }, { provider: 'kofi', handle: 'b2' }] } });
+ assert.strictEqual(twice.status, 422);
+ const other = await t.call('PATCH', '/api/v1/profiles/tierra', { user: viewer, body: { support_links: [] } });
+ assert.strictEqual(other.status, 403, 'only the creator (or a granted service) changes them');
+ });
+
+ await check('the tip page lists them as paid outside OpenVibe, nofollow and in a new tab', async () => {
+ const page = await get('/tierra');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /Other ways to support/);
+ assert.match(page.text, /<a class="btn ghost" href="https:\/\/ko-fi\.com\/tierra_tv" rel="nofollow noopener ugc" target="_blank">Ko-fi<\/a>/);
+ assert.match(page.text, /paid outside OpenVibe: OpenVibe never sees or holds them/);
+ });
+
+ await check('the dashboard form saves one field per service; blank ones are removed', async () => {
+ const d = await get('/dashboard', tierra);
+ assert.match(d.text, /id="support-links"/);
+ assert.match(d.text, /name="link_kofi"[^>]*value="tierra_tv"/);
+ const body = new URLSearchParams({ csrf: field(d.text, 'csrf'), idem: `sl-${Date.now()}`, link_kofi: '', link_patreon: 'https://www.patreon.com/tierra', link_cashapp: '$tierra' });
+ const r = await fetch(`${t.base}/dashboard/support-links`, { method: 'POST', body, redirect: 'manual', headers: { Cookie: cookie(tierra), 'Content-Type': 'application/x-www-form-urlencoded' } });
+ const txt = await r.text();
+ assert.strictEqual(r.status, 303, (/class="error[^"]*"[^>]*>([\s\S]{0,200})/.exec(txt) || [])[1] || String(r.status));
+ const pub = await t.call('GET', '/api/v1/profiles/tierra', { token: null });
+ assert.deepStrictEqual(pub.json.profile.support_links.map((l) => l.url), ['https://www.patreon.com/tierra', 'https://cash.app/$tierra']);
+ });
+
+ await t.close();
+ done();
+})();