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)
Changes
@@@ -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) {@@@ -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 };@@@ -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.nnew file mode 100644@@@ -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();++})();
@@ -28,7 +28,7 @@ const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
@@ -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.
// 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',
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',
'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'];
const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];
function normalizeHandle(v) {
function normalizeHandle(v) {
@@ -451,6 +451,22 @@ ${rows.length ? `<ul class="mod-list">${rows.map(item).join('')}</ul>` : '<p>Not
@@ -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>` : ''}`;
${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 }) {
function invitePage({ profile, csrf, idem, secret, isSelf }) {
return `<h1>Moderate ${esc(profile.display_name)}</h1>
return `<h1>Moderate ${esc(profile.display_name)}</h1>
<section class="card">
<section class="card">
@@ -477,4 +493,4 @@ function errorPage({ status, title, message }) {
@@ -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} -->`;
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 };
@@ -47,7 +47,7 @@ const SITE_NAME = 'OpenVibe.Tips';
@@ -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 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',
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 MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);
const RECEIPT_FLASH = new Set(['Your data was erased from your tips']);
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
@@ -242,6 +242,23 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
}),
}),
}), status);
}), 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) => {
r.get('/dashboard', withViewer, async (req, res) => {
if (!req.viewer) return res.redirect(`/auth/login?next=${encodeURIComponent('/dashboard')}`);
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.
// Only our own confirmations are shown: a crafted ?done= link cannot put words on the page.
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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();
+})();