openvibers/openvibe.tips Commits

openvibe.tips/start: a creator's tip page in one step, made from the account (Live's dashboard links here) (#76)

OP OpenVibers committed 22 days ago · 697ff9591d · 4 files, +100 −3

Changes

ddiff --git a/server/domain/profiles.js b/server/domain/profiles.js
iindex fac49fb..5da8c0b 100644
---- a/server/domain/profiles.js
++++ b/server/domain/profiles.js
@@@ -28,7 +28,7 @@ const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
// Paths the site itself uses; no creator page can take them.
const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',
'robots.txt', 'sitemap.xml', 'llms.txt', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www',
-- 'moderate', 'supporters', 'metrics']);
++ 'moderate', 'supporters', 'metrics', 'start']);
const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];
function normalizeHandle(v) {
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 3a8a697..e170d3a 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -451,6 +451,22 @@ ${rows.length ? `<ul class="mod-list">${rows.map(item).join('')}</ul>` : '<p>Not
${next ? `<p><a href="/moderate/${esc(profile.handle)}?state=${esc(state)}&cursor=${esc(next)}">Older</a></p>` : ''}`;
}
++/** /start: what the creator's page will be, and the one button that switches it on. */
++function startPage({ profile: p, csrf, idem }) {
++ return `<h1>Your tip page</h1>
++<section class="card">
++ <p>Your page will be <b>openvibe.tips/${esc(p.handle)}</b>, as ${esc(p.display_name)}, from your OpenVibe account.</p>
++ <ul>
++ <li>Viewers tip you in Vibes, with a message if they like. OpenVibe.Billing settles every tip and keeps your Vibes.</li>
++ <li>Tips count toward your goals, and appear in your OpenVibe.Live chat and on your channel's goal.</li>
++ <li>Text-to-speech, media requests and paid sounds stay off until you switch them on in the dashboard.</li>
++ <li>The page is public and search engines may index it. You can switch it off at any time.</li>
++ </ul>
++ <form method="post" action="/start">${hidden({ csrf, idem })}<button class="btn" type="submit">Turn on my tip page</button></form>
++ <p class="fine"><a href="/dashboard">Set it up in the dashboard first</a></p>
++</section>`;
++}
++
function invitePage({ profile, csrf, idem, secret, isSelf }) {
return `<h1>Moderate ${esc(profile.display_name)}</h1>
<section class="card">
@@@ -477,4 +493,4 @@ function errorPage({ status, title, message }) {
return `<section class="card"><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.Tips home</a></p></section><!-- ${status} -->`;
}
--module.exports = { NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
++module.exports = { NEW_PAGE_MINIMUMS, HOME_FAQ, SUPPORT_SERVICES, home, creatorPage, goalsPage, supportersPage, receiptsPage, erasePage, receiptPage, dashboard, moderatePage, startPage, invitePage, inviteCreated, tokenCreated, errorPage, tipForm };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 144255d..8432396 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -47,7 +47,7 @@ const SITE_NAME = 'OpenVibe.Tips';
const SITE_SUMMARY = 'OpenVibe.Tips: creator support — tips, goals, paid messages, TTS and media requests, and overlays.';
const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay',
-- 'Filter saved', 'Moderator removed', 'Invitation revoked']);
++ 'Filter saved', 'Moderator removed', 'Invitation revoked', 'Your tip page is on']);
const MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);
const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
@@@ -242,6 +242,23 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
}),
}), status);
}
++ // ── Start: a creator's tip page in one step (OpenVibe.Live's dashboard links here) ──
++ // Signed in, the profile is made from the Network account (the username is the handle, as on the dashboard); one
++ // button switches the public page on, accepting tips. A page that is on already goes straight to the dashboard.
++ r.get('/start', withViewer, async (req, res) => {
++ if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/start')}`);
++ const p = await ensureProfile(req);
++ if (p.page_enabled) return res.redirect(303, '/dashboard');
++ return html(res, pageFor(req, { title: 'Your tip page', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/start', body: pages.startPage({ profile: p, csrf: csrfFor(req.viewer.subject), idem: idem() }) }));
++ });
++ r.post('/start', withViewer, form, async (req, res) => {
++ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/start')}`);
++ if (!csrfOk(req)) return html(res, pageFor(req, { title: 'Your tip page', robots: 'noindex,nofollow', body: pages.errorPage({ status: 403, title: 'That form expired', message: 'Open the start page again.' }) }), 403);
++ const p = await ensureProfile(req);
++ if (!p.page_enabled || !p.accepting) await profiles.update(p.creator_subject, { page_enabled: true, accepting: true });
++ return res.redirect(303, `/dashboard?done=${encodeURIComponent('Your tip page is on')}`);
++ });
++
r.get('/dashboard', withViewer, async (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
// Only our own confirmations are shown: a crafted ?done= link cannot put words on the page.
ddiff --git a/test/start.test.js b/test/start.test.js
nnew file mode 100644
iindex 0000000..ee7123b
---- /dev/null
++++ b/test/start.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++// A creator's tip page in one step (plan C4, 10-22: production had one profile because Live's streamers never came
++// here). OpenVibe.Live's dashboard links to /start: signed in, the page is made from the account and one button turns
++// it on; a page that is on already goes to the dashboard; the form needs this site's anti-forgery field.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const sam = t.network.newUser('sam');
++ const cookie = (u) => `ov_token=${t.network.signUser(u)}`;
++ const req = async (method, p, u, body) => {
++ const headers = u ? { Cookie: cookie(u) } : {};
++ if (body) headers['Content-Type'] = 'application/x-www-form-urlencoded';
++ const r = await fetch(t.base + p, { method, headers, body: body ? new URLSearchParams(body).toString() : undefined, redirect: 'manual' });
++ return { status: r.status, location: r.headers.get('location'), text: await r.text() };
++ };
++ const field = (html, name) => { const m = html.match(new RegExp(`name="${name}" value="([^"]*)"`)); return m ? m[1] : null; };
++
++ await check('signed out, /start asks for sign-in and comes back to it', async () => {
++ const r = await req('GET', '/start');
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.location, '/auth/login?next=%2Fstart');
++ });
++
++ await check('signed in, /start says what the page will be; nothing is public until the button', async () => {
++ const r = await req('GET', '/start', sam);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /openvibe\.tips\/sam/);
++ assert.match(r.text, /Turn on my tip page/);
++ assert.match(r.text, /noindex/);
++ assert.strictEqual((await req('GET', '/sam')).status, 404, 'the page is off until the creator turns it on');
++ });
++
++ await check('a form without this site\'s anti-forgery field changes nothing', async () => {
++ const r = await req('POST', '/start', sam, { csrf: 'forged' });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual((await req('GET', '/sam')).status, 404);
++ });
++
++ await check('the button turns the page on, accepting tips, and opens the dashboard', async () => {
++ const page = await req('GET', '/start', sam);
++ const r = await req('POST', '/start', sam, { csrf: field(page.text, 'csrf'), idem: field(page.text, 'idem') });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(r.location, `/dashboard?done=${encodeURIComponent('Your tip page is on')}`);
++ const dash = await req('GET', r.location, sam);
++ assert.match(dash.text, /Your tip page is on/);
++ const pub = await req('GET', '/sam');
++ assert.strictEqual(pub.status, 200);
++ const api = await t.call('GET', '/api/v1/profiles/sam', { token: null });
++ assert.deepStrictEqual([api.json.profile.page_enabled, api.json.profile.accepting], [true, true]);
++ });
++
++ await check('once the page is on, /start goes to the dashboard; "start" is never a creator\'s handle', async () => {
++ const r = await req('GET', '/start', sam);
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(r.location, '/dashboard');
++ const { normalizeHandle } = require('../server/domain/profiles');
++ assert.strictEqual(normalizeHandle('start'), null);
++ });
++
++ await t.close();
++ done();
++})();