openvibers/openvibe.tips Commits
Creator pages at /@name; the root /name 301s there for the 301 window (#210)
Changes
@@@ -41,7 +41,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);const publicSounds = p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [];return twin({-- title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,++ title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/@${p.handle}`,description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,robots: 'index,follow', ogImage: p.avatar_url || undefined,body: pages.creatorPage({@@@ -55,7 +55,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =const p = await profiles.byHandle(db, handle);if (!p || !p.page_enabled) return null;return twin({-- title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: 'index,follow',++ title: `${p.display_name} — goals`, canonicalPath: `/@${p.handle}/goals`, robots: 'index,follow',body: pages.goalsPage({ profile: p, goals: await goals.publicGoals(db, await goals.list(db, p.creator_subject), p) }),});}@@@ -67,7 +67,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =const leaderboard = top.map((row) => (p.page.supporters_amounts ? row : { ...row, total: null }));const recent = recentRows ? recentRows.map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;return twin({-- title: `${p.display_name} — supporters`, canonicalPath: `/${p.handle}/supporters`, robots: 'index,follow',++ title: `${p.display_name} — supporters`, canonicalPath: `/@${p.handle}/supporters`, robots: 'index,follow',body: pages.supportersPage({ profile: p, leaderboard, recent }),});}@@@ -76,7 +76,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =const p = String(path || '/').replace(/\/+$/, '') || '/';if (p === '/') return home();if (p === '/updates') return updates();-- const m = /^\/([^/]+)(\/goals|\/supporters)?$/.exec(p);++ const m = /^\/@([^/]+)(\/goals|\/supporters)?$/.exec(p);if (!m) return null;const handle = profiles.normalizeHandle(m[1]);if (!handle) return null;@@@ -167,6 +167,21 @@ function createApp(opts = {}) {},}));const liveNow = opts.liveNow || require('./clients/live-now').createLiveNow({ config, fetchImpl: opts.fetchImpl, log });++ // Old root creator links (and their .md twins) move to /@name with one 301. This sits before the agent++ // layer, which otherwise would sign /name.md with the old path as its canonical. Reserved names and++ // unknown handles fall through (404), and only GET/HEAD move.++ app.use(async (req, res, next) => {++ if (!['GET', 'HEAD'].includes(req.method)) return next();++ const m = /^\/([^/@][^/]*)(\/goals|\/supporters)?(\.md)?$/.exec(req.path);++ if (!m) return next();++ const handle = domain.profiles.normalizeHandle(m[1]);++ if (!handle) return next();++ let p;++ try { p = await domain.profiles.byHandle(db, handle); } catch { return next(); }++ if (!p) return next();++ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';++ return res.redirect(301, `/@${encodeURIComponent(p.handle)}${m[2] || ''}${m[3] || ''}${q}`);++ });// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the// public pages (server/agent-pages.js). The pages keep their own routes — a signed-in viewer's tip form,// account bar and anti-forgery token are theirs — so only the manifest and twin requests reach this@@@ -190,7 +190,7 @@ function createProfiles(ctx) {const visible = (before && before.page_enabled) || (after && after.page_enabled);const handle = (after && after.handle) || (before && before.handle);if (!visible || !handle) return;-- inx.pingSoon([`${ctx.config.baseUrl}/${handle}`, `${ctx.config.baseUrl}/sitemap.xml`]);++ inx.pingSoon([`${ctx.config.baseUrl}/@${handle}`, `${ctx.config.baseUrl}/sitemap.xml`]);}/** Public shape; `owner` adds the settings only the creator sees. */@@@ -198,7 +198,7 @@ function createProfiles(ctx) {if (!p) return null;const out = {creator: { type: 'user', id: p.creator_subject }, handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url || null,-- headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/${p.handle}`,++ headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/@${p.handle}`,minimums: {tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null,media_request: p.media_requests_enabled ? p.media_request_min : null, sound: p.sounds_enabled ? Number(p.sound_min) : null,@@@ -210,7 +210,7 @@ function createProfiles(ctx) {// 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,++ supporters_url: p.page.supporters_page ? `${ctx.config.baseUrl}/@${p.handle}/supporters` : null,currency: 'vibes-bits',};if (owner) Object.assign(out, { filter: p.filter, revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });@@@ -94,7 +94,7 @@ const HOME_PANE_IDS = ['send', 'for-creators', 'services', 'how', 'costs'];function home({ creators, live = [] }) {const m = NEW_PAGE_MINIMUMS;const liveCards = live.length ? `<ul class="tp-cards">${live.map((c) => `<li class="tp-card"><p class="tp-card-top"><span class="tp-live">Live</span> <a href="${esc(c.url)}"><strong>${esc(c.name)}</strong></a></p>${c.title ? `<p>${esc(c.title)}</p>` : ''}<p class="meta">${[c.category, watching(c.viewers)].filter(Boolean).map(esc).join(' · ')}</p></li>`).join('')}</ul>` : '';-- const creatorCards = creators.length ? `<ul class="tp-cards">${creators.map((c) => `<li class="tp-card"><p class="tp-card-top">${c.avatar_url ? `<img src="${esc(mediaSrc(c.avatar_url, 96))}" alt="" width="40" height="40" loading="lazy">` : ''}<a href="/${esc(c.handle)}"><strong>${esc(c.display_name)}</strong></a></p>${c.headline ? `<p>${esc(c.headline)}</p>` : ''}${c.goal ? `<p class="meta">Goal: ${esc(c.goal.title)}</p><div class="bar" role="progressbar" aria-label="${esc(c.goal.title)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${c.goal.percent}"><span style="width:${c.goal.percent}%"></span></div>` : ''}</li>`).join('')}</ul>` : '';++ const creatorCards = creators.length ? `<ul class="tp-cards">${creators.map((c) => `<li class="tp-card"><p class="tp-card-top">${c.avatar_url ? `<img src="${esc(mediaSrc(c.avatar_url, 96))}" alt="" width="40" height="40" loading="lazy">` : ''}<a href="/@${esc(c.handle)}"><strong>${esc(c.display_name)}</strong></a></p>${c.headline ? `<p>${esc(c.headline)}</p>` : ''}${c.goal ? `<p class="meta">Goal: ${esc(c.goal.title)}</p><div class="bar" role="progressbar" aria-label="${esc(c.goal.title)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${c.goal.percent}"><span style="width:${c.goal.percent}%"></span></div>` : ''}</li>`).join('')}</ul>` : '';return showcase.hero({eyebrow: 'OpenVibe.Tips',title: 'Support the creators', accent: 'you watch',@@@ -191,7 +191,7 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, econst kind = values.kind || 'tip';const active = goals.filter((g) => g.status === 'active');if (!profile.accepting) return `<section class="card"><p>${esc(profile.display_name)} is not accepting tips right now.</p></section>`;-- return `<form class="card tip-form" method="post" action="/${esc(profile.handle)}/tip">++ return `<form class="card tip-form" method="post" action="/@${esc(profile.handle)}/tip"><h2>Send ${esc(profile.display_name)} something</h2>${errorBox(error)}${hidden({ csrf: csrf || '', idem })}@@@ -303,7 +303,7 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er<section class="creator-head">${profile.avatar_url ? `<img class="avatar" src="${esc(mediaSrc(profile.avatar_url, 320))}" alt="" width="96" height="96">` : ''}<div><h1>${esc(profile.display_name)}</h1>${profile.headline ? `<p class="headline">${esc(profile.headline)}</p>` : ''}-- <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>${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}@@@ -313,7 +313,7 @@ ${supportLinksSection(profile)}`;function goalsPage({ profile, goals }) {return `<h1>${esc(profile.display_name)} — goals</h1>--<p><a href="/${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>++<p><a href="/@${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>${goals.length ? goals.map(goalCard).join('') : '<p>No goals yet.</p>'}<p class="fine">Goal totals count settled payments only.${profile.page.goal_supporters ? ' Supporters are listed as they chose to be shown.' : ''}</p>`;}@@@ -327,14 +327,14 @@ function supportersPage({ profile, leaderboard, recent }) {const wall = recent && recent.length? `<section><h2>Recent messages</h2><ul class="wall">${recent.map((v) => `<li><b>${esc(v.supporter_name)}</b>${v.amount != null ? ` <small>${n(v.amount)} Vibes</small>` : ''}<p>${esc(v.message)}</p><small>${when(v.at)}</small></li>`).join('')}</ul></section>` : '';return `<h1>${esc(profile.display_name)} — supporters</h1>--<p><a href="/${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>++<p><a href="/@${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p><section><h2>Top supporters</h2>${board}</section>${page.supporters_messages ? wall || '<p>No public messages yet.</p>' : ''}<p class="fine">Anonymous tips and tips with a hidden amount are not on this list.</p>`;}function receiptRow(i, as) {-- const who = as === 'creator' ? esc(i.supporter_name || 'Someone') : `<a href="/${esc(i.creator_handle || '')}">${esc(i.creator_name || 'creator')}</a>`;++ const who = as === 'creator' ? esc(i.supporter_name || 'Someone') : `<a href="/@${esc(i.creator_handle || '')}">${esc(i.creator_name || 'creator')}</a>`;return `<tr${i.test ? ' class="test"' : ''}><td><a href="/receipts/${esc(i.id)}">${when(i.created_at)}</a></td><td>${who}</td><td>${esc(KIND_LABEL[i.kind])}${i.test ? ' <small>(simulation)</small>' : ''}</td><td class="num">${n(i.amount)}</td><td>${esc(PAY_LABEL[i.payment.state])}</td><td>${esc(DELIVERY_LABEL[i.delivery.state])}</td></tr>`;}@@@ -373,7 +373,7 @@ function receiptPage({ i, profile, as, cancelled }) {return `<h1>Receipt</h1>${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was cancelled. Nothing was charged; you can start again from the creator\'s page.</p>' : ''}<dl class="receipt">-- <dt>Creator</dt><dd>${profile ? `<a href="/${esc(profile.handle)}">${esc(profile.display_name)}</a>` : 'unknown'}</dd>++ <dt>Creator</dt><dd>${profile ? `<a href="/@${esc(profile.handle)}">${esc(profile.display_name)}</a>` : 'unknown'}</dd><dt>From</dt><dd>${esc(i.supporter_name || 'Someone')}</dd><dt>What</dt><dd>${esc(KIND_LABEL[i.kind])}${i.test ? ' (simulation, not charged, not counted)' : ''}</dd>${privacyLine(i.privacy) ? `<dt>Privacy</dt><dd>${privacyLine(i.privacy)}</dd>` : ''}@@@ -434,7 +434,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}<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>++ <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><form method="post" action="/dashboard/profile">${hidden({ csrf, idem: `${idem}-p` })}<label><input type="checkbox" name="page_enabled" value="1"${f('page_enabled')}> Public page on (search engines may index it)</label><label><input type="checkbox" name="accepting" value="1"${f('accepting')}> Accepting tips</label>@@@ -129,7 +129,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a})));r.get('/sitemap.xml', async (req, res) => {const rows = await db.many(sql`SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled ORDER BY handle`);-- const urls = [{ loc: `${config.baseUrl}/`, lastmod: BOOT_AT }, ...rows.map((p) => ({ loc: `${config.baseUrl}/${p.handle}`, lastmod: p.updated_at }))];++ const urls = [{ loc: `${config.baseUrl}/`, lastmod: BOOT_AT }, ...rows.map((p) => ({ loc: `${config.baseUrl}/@${p.handle}`, lastmod: p.updated_at }))];res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 900 })).send(seo.sitemapXml(urls));});// /llms.txt (llmstxt.org): a plain-markdown map of the site for language-model crawlers.@@@ -172,7 +172,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a] },{ title: 'Creator pages', pages: rows.map((c) => ({title: `${c.display_name} on OpenVibe.Tips`,-- url: `/${c.handle}`,++ url: `/@${c.handle}`,text: `The public support page of ${c.display_name} (@${c.handle}): their goals and a tip form that works without JavaScript. Tips settle through OpenVibe Billing and the overlay alerts belong to the creator. The goals page and, only when the creator switches it on, the supporters page are public too.`,})) },{ title: 'Legal', pages: legal.PATHS.map((p) => ({@@@ -538,7 +538,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: aconst p = await profiles.byHandle(db, req.params.handle);const owner = !!(p && req.viewer && req.viewer.subject === p.creator_subject);if (!p || (!p.page_enabled && !owner)) return notFound(req, res);-- if (p.handle !== String(req.params.handle)) return res.redirect(301, `/${p.handle}`); // @name, Name → the canonical path++ if (p.handle !== String(req.params.handle)) return res.redirect(301, `/@${encodeURIComponent(p.handle)}`); // Name → the one canonical /@name// The page shows what the public sees, for the creator too (a preview); the dashboard has the rest.const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);// The members-only posts: the title and the tier are public, the text only for a member of a@@@ -547,26 +547,29 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: aconst posts = await domain.posts.view({ creatorSubject: p.creator_subject, memberSubject: req.viewer ? req.viewer.subject : null, plans: vipCreator ? vipCreator.plans : [], owner });for (const post of posts) post.join_url = post.unlocked ? null : await domain.vip.joinUrl(p.creator_subject, post.tiers);const csrf = req.viewer ? csrfFor(req.viewer.subject) : '';-- const jsonLd = p.page_enabled ? [{ '@context': 'https://schema.org', '@type': 'ProfilePage', name: p.display_name, url: `${config.baseUrl}/${p.handle}`, mainEntity: { '@type': 'Person', name: p.display_name, alternateName: p.handle, image: p.avatar_url || undefined } }] : [];++ const jsonLd = p.page_enabled ? [{ '@context': 'https://schema.org', '@type': 'ProfilePage', name: p.display_name, url: `${config.baseUrl}/@${p.handle}`, mainEntity: { '@type': 'Person', name: p.display_name, alternateName: p.handle, image: p.avatar_url || undefined } }] : [];return html(res, pageFor(req, {-- title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,++ title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/@${p.handle}`,description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled, sounds: p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [], posts }),}), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });}-- r.get('/:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));-- r.get('/:handle/goals', withViewer, async (req, res, next) => {++ // The canonical creator addresses: /@<handle>. The root /<handle> forms are kept only as 301s from the old++ // links (and the reserved list only guards that old space); new links, canonicals, the sitemap and IndexNow++ // all use /@.++ r.get('/@:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));++ r.get('/@:handle/goals', withViewer, async (req, res, next) => {const p = await profiles.byHandle(db, req.params.handle);if (!p) return next();const owner = req.viewer && req.viewer.subject === p.creator_subject;if (!p.page_enabled && !owner) return notFound(req, res);return html(res, pageFor(req, {-- title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow',++ title: `${p.display_name} — goals`, canonicalPath: `/@${p.handle}/goals`, robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow',body: pages.goalsPage({ profile: p, goals: await goals.publicGoals(db, await goals.list(db, p.creator_subject), p) }),}));});-- r.get('/:handle/supporters', withViewer, async (req, res, next) => {++ r.get('/@:handle/supporters', withViewer, async (req, res, next) => {const p = await profiles.byHandle(db, req.params.handle);if (!p) return next();const owner = req.viewer && req.viewer.subject === p.creator_subject;@@@ -577,15 +580,38 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: aconst recent = recentRows ? recentRows.map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;const indexable = p.page_enabled && p.page.supporters_page;return html(res, pageFor(req, {-- title: `${p.display_name} — supporters`, canonicalPath: `/${p.handle}/supporters`, robots: indexable ? 'index,follow' : 'noindex,nofollow',++ title: `${p.display_name} — supporters`, canonicalPath: `/@${p.handle}/supporters`, robots: indexable ? 'index,follow' : 'noindex,nofollow',body: pages.supportersPage({ profile: p, leaderboard, recent }),}), 200, indexable ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });});-- r.post('/:handle/tip', withViewer, form, async (req, res, next) => {++ // Old root links, for the 301 window: /name → /@name (query kept). A name that is not a creator is still a++ // 404; the reserved names never redirect because normalizeHandle refuses them.++ const legacy = (suffix) => async (req, res, next) => {++ const handle = profiles.normalizeHandle(req.params.handle);++ if (!handle) return next();++ const p = await profiles.byHandle(db, handle);++ if (!p) return next();++ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';++ return res.redirect(301, `/@${encodeURIComponent(p.handle)}${suffix}${q}`);++ };++ r.get('/:handle', withViewer, legacy(''));++ r.get('/:handle/goals', withViewer, legacy('/goals'));++ r.get('/:handle/supporters', withViewer, legacy('/supporters'));++ // Old in-flight tip forms: 308 keeps the method and body while moving them to the canonical address.++ r.post('/:handle/tip', withViewer, async (req, res, next) => {++ if (String(req.params.handle).startsWith('@')) return next(); // the canonical /@:handle/tip route below++ const handle = profiles.normalizeHandle(req.params.handle);++ if (!handle) return next();++ const p = await profiles.byHandle(db, handle);++ if (!p) return next();++ return res.redirect(308, `/@${encodeURIComponent(p.handle)}/tip`);++ });++++ r.post('/@:handle/tip', withViewer, form, async (req, res, next) => {const p = await profiles.byHandle(db, req.params.handle);if (!p) return next();-- if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/${p.handle}`)}`);++ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/@${p.handle}`)}`);const b = req.body || {};const values = {kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, sound_id: b.sound_id, goal_id: b.goal_id, pay_with: b.pay_with,@@@ -45,7 +45,7 @@ const KEY = 'k'.repeat(32);await check('switching a creator page on pings its URL and the sitemap', async () => {alex = await t.creator('alex', { settings: { headline: 'Late-night synth streams' } });-- assert.ok(pings.includes('http://tips.test/alex'), JSON.stringify(pings));++ assert.ok(pings.includes('http://tips.test/@alex'), JSON.stringify(pings));assert.ok(pings.includes('http://tips.test/sitemap.xml'), JSON.stringify(pings));});@@@ -53,7 +53,7 @@ const KEY = 'k'.repeat(32);pings.length = 0;const r = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { headline: 'Now with text-to-speech' } });assert.strictEqual(r.status, 200, r.text);-- assert.ok(pings.includes('http://tips.test/alex'), JSON.stringify(pings));++ assert.ok(pings.includes('http://tips.test/@alex'), JSON.stringify(pings));assert.ok(pings.includes('http://tips.test/sitemap.xml'), JSON.stringify(pings));});@@@ -67,7 +67,7 @@ const validModerated = payloadValidator('tips.interaction.moderated');const tts = await pay('tts', { amount: 150, tts: { text: 'say badword now please' } });await domain.effects.drain();assert.strictEqual(jobsOf(tts.json.interaction.id).find((j) => j.effect === 'tts').tts.text, 'say now please');-- const page = await get('/alex/supporters');++ const page = await get('/@alex/supporters');assert.ok(!/badword/i.test(page.text.replace(/<style[\s\S]*?<\/style>/g, '')), 'the supporters page shows no blocked word');});@@@ -120,7 +120,7 @@ const validModerated = payloadValidator('tips.interaction.moderated');const ok = await post(path, mod, { csrf: field(page.text, 'csrf'), idem: 'x' });assert.strictEqual(ok.status, 303);assert.match(ok.headers.get('location'), /^\/moderate\/alex\?done=/);-- assert.strictEqual((await post(path, stranger, { csrf: field((await get('/alex', stranger)).text, 'csrf'), idem: 'x' })).status, 404, 'used once');++ assert.strictEqual((await post(path, stranger, { csrf: field((await get('/@alex', stranger)).text, 'csrf'), idem: 'x' })).status, 404, 'used once');assert.strictEqual((await get('/api/v1/moderators?creator=alex', null)).status, 401);const list = await t.call('GET', '/api/v1/moderators', { user: alex });assert.deepStrictEqual(list.json.moderators.map((m) => m.moderator.id), [mod.subject]);@@@ -30,22 +30,22 @@ const { boot, check, done } = require('./helpers/app');assert.match(r.text, /<meta name="ov-release"/);assert.match(r.text, /id="ov-footer"|class="ovf/);assert.match(r.text, /<script type="application\/ld\+json">[^<]*"@type":"WebSite"[^<]*"name":"OpenVibe.Tips"/, 'the home says what the site is (JSON-LD)');-- assert.match(r.text, /href="\/alex"/);-- assert.ok(!r.text.includes('href="/bob"'), 'switched-off pages are not listed');++ assert.match(r.text, /href="\/@alex"/);++ assert.ok(!r.text.includes('href="/@bob"'), 'switched-off pages are not listed');// The home opens with the showcase kit (openvibe-shared/showcase): one h1 (the hero), the kit's sheet, the// pricing from the real defaults, and the creators list still below it.assert.ok(r.text.includes('class="sc-hero') && r.text.includes('class="sc-tiers"'), 'hero and pricing');assert.match(r.text, /<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[^"]+">/);assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');assert.ok(r.text.indexOf('class="sc-hero') < r.text.indexOf('id="creators"'), 'the creators follow the showcase');-- assert.ok(!(await get('/alex')).text.includes('showcase.css'), 'only the home links the kit');++ assert.ok(!(await get('/@alex')).text.includes('showcase.css'), 'only the home links the kit');});await check('a Media avatar is asked for the width the page draws; an already sized URL is left alone', async () => {const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';await t.db.exec(`UPDATE creator_tip_profiles SET avatar_url = '${obj}' WHERE handle = 'alex'`);assert.ok((await get('/')).text.includes(`<img src="${obj}?w=96" alt="" width="40" height="40" loading="lazy">`), 'the 40 px creator card asks for 96');-- assert.ok((await get('/alex')).text.includes(`<img class="avatar" src="${obj}?w=320" alt="" width="96" height="96">`), 'the 96 px creator page head asks for 320');++ assert.ok((await get('/@alex')).text.includes(`<img class="avatar" src="${obj}?w=320" alt="" width="96" height="96">`), 'the 96 px creator page head asks for 320');await t.db.exec(`UPDATE creator_tip_profiles SET avatar_url = '${obj}?w=64' WHERE handle = 'alex'`);const sized = await get('/');assert.ok(sized.text.includes(`src="${obj}?w=64"`), 'the URL is used as stored');@@@ -62,7 +62,7 @@ const { boot, check, done } = require('./helpers/app');});await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {-- for (const [p, u] of [['/', null], ['/alex', viewer], ['/dashboard', alex], ['/receipts', viewer], ['/updates', alex]]) {++ for (const [p, u] of [['/', null], ['/@alex', viewer], ['/dashboard', alex], ['/receipts', viewer], ['/updates', alex]]) {const r = await get(p, u);assert.strictEqual(r.status, 200, p);assert.match(r.text, /<meta name="ov-boost" content="tips@[^"]+">/, p);@@@ -72,16 +72,19 @@ const { boot, check, done } = require('./helpers/app');});await check('creator page: indexable when on, goals and a no-JS tip form', async () => {-- const r = await get('/alex');++ const r = await get('/@alex');assert.strictEqual(r.status, 200);assert.match(r.text, /<meta name="robots" content="index,follow">/);-- assert.match(r.text, /<link rel="canonical" href="http:\/\/tips\.test\/alex">/);++ assert.match(r.text, /<link rel="canonical" href="http:\/\/tips\.test\/@alex">/);assert.match(r.text, /New synth/);-- assert.match(r.text, /<form class="card tip-form" method="post" action="\/alex\/tip">/);++ assert.match(r.text, /<form class="card tip-form" method="post" action="\/@alex\/tip">/);assert.match(r.text, /Sign in to send/);assert.match(r.text, /Late-night synth streams/);assert.match(r.text, /"@type":"ProfilePage"/);-- assert.match((await get('/@alex')).headers.get('location') || '', /\/alex$/);++ const legacy = await get('/alex?from=old');++ assert.deepStrictEqual([legacy.status, legacy.headers.get('location')], [301, '/@alex?from=old']);++ assert.deepStrictEqual([(await get('/ALEX')).status, (await get('/ALEX')).headers.get('location')], [301, '/@alex']);++ assert.strictEqual((await get('/nobody-here')).status, 404);});await check('the head comes from openvibe-shared/shell and keeps every tag and script of the site', async () => {@@@ -105,19 +108,19 @@ const { boot, check, done } = require('./helpers/app');assert.match(home.text, /<main id="main" class="page page-wide">/, "the home is the wide page");assert.match(home.text, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);-- const creator = await get('/alex');++ const creator = await get('/@alex');const creatorHead = creator.text.slice(0, creator.text.indexOf('</head>'));-- assert.match(creatorHead, /<link rel="canonical" href="http:\/\/tips\.test\/alex">/);++ assert.match(creatorHead, /<link rel="canonical" href="http:\/\/tips\.test\/@alex">/);assert.match(creatorHead, /<script type="application\/ld\+json">\{[^<]*"@type":"ProfilePage"/);});await check('a switched-off page: 404 + noindex for everyone else; a noindex preview for its creator', async () => {-- const anon = await get('/bob');++ const anon = await get('/@bob');assert.strictEqual(anon.status, 404);assert.match(anon.text, /noindex/);-- const other = await get('/bob', viewer);++ const other = await get('/@bob', viewer);assert.strictEqual(other.status, 404);-- const own = await get('/bob', bob);++ const own = await get('/@bob', bob);assert.strictEqual(own.status, 200);assert.match(own.text, /<meta name="robots" content="noindex,nofollow">/);assert.match(own.headers.get('x-robots-tag'), /noindex/);@@@ -137,9 +140,10 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(sm.status, 200);assert.match(sm.headers.get('content-type'), /^application\/xml/);assert.match(sm.text, /<loc>http:\/\/tips\.test\/<\/loc>/, 'the sitemap lists the home URL');-- assert.match(sm.text, /<loc>http:\/\/tips\.test\/alex<\/loc>/);++ assert.match(sm.text, /<loc>http:\/\/tips\.test\/@alex<\/loc>/);++ assert.ok(!sm.text.includes('<loc>http://tips.test/alex</loc>'), 'the old root form is not a sitemap page');assert.match(sm.text, /<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/, 'every url carries a real lastmod');-- assert.ok(!sm.text.includes('/bob<'));++ assert.ok(!sm.text.includes('/@bob<'));const llms = await get('/llms.txt');assert.strictEqual(llms.status, 200);assert.match(llms.headers.get('content-type'), /^text\/plain/);@@@ -161,22 +165,22 @@ const { boot, check, done } = require('./helpers/app');assert.match(full.headers.get('content-type'), /^text\/plain/);assert.match(full.text, /^# /, 'llms-full.txt starts with the same markdown heading');assert.match(full.text, /URL: http:\/\/tips\.test\/\n/, 'llms-full.txt contains the home URL');-- assert.match(full.text, /URL: http:\/\/tips\.test\/alex\n/, 'llms-full.txt contains the switched-on creator page');-- assert.ok(!full.text.includes('/bob'), 'llms-full.txt never lists a switched-off page');++ assert.match(full.text, /URL: http:\/\/tips\.test\/@alex\n/, 'llms-full.txt contains the switched-on creator page');++ assert.ok(!full.text.includes('/@bob'), 'llms-full.txt never lists a switched-off page');assert.ok(!/(?:URL: http:\/\/tips\.test\/)(?:dashboard|receipts|overlay|moderate|auth|api)/.test(full.text), 'llms-full.txt lists no signed-in page');assert.ok(Buffer.byteLength(full.text) < 512 * 1024, 'llms-full.txt stays under 512 KiB');});await check('tipping without JavaScript: sign-in first, then a form post settles and lands on the receipt', async () => {-- const anon = await post('/alex/tip', null, { amount: '10' });++ const anon = await post('/@alex/tip', null, { amount: '10' });assert.strictEqual(anon.status, 303);-- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Falex/);-- const page = await get('/alex', viewer);++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2F%40alex/);++ const page = await get('/@alex', viewer);const csrf = field(page.text, 'csrf');const idem = field(page.text, 'idem');assert.ok(csrf && idem);const form = { csrf, idem, kind: 'tip', amount: '25', message: 'no js needed', pay_with: 'credit', supporter_name: 'Viewer' };-- const r = await post('/alex/tip', viewer, form);++ const r = await post('/@alex/tip', viewer, form);assert.strictEqual(r.status, 303, r.text.slice(0, 300));const loc = r.headers.get('location');assert.match(loc, /^\/receipts\/tint_/);@@@ -184,7 +188,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await domain.interactions.get(t.db, id)).payment_state, 'settled');// Double submit (same form nonce): the same interaction, no second transfer.const before = billing.transfers().length;-- const again = await post('/alex/tip', viewer, form);++ const again = await post('/@alex/tip', viewer, form);assert.strictEqual(again.headers.get('location'), loc);assert.strictEqual(billing.transfers().length, before);const receipt = await get(loc, viewer);@@@ -198,10 +202,10 @@ const { boot, check, done } = require('./helpers/app');});await check('a forged cross-site form (no valid token) is refused', async () => {-- const r = await post('/alex/tip', viewer, { csrf: 'forged', idem: 'form-abcdefghijkl', amount: '10' });++ const r = await post('/@alex/tip', viewer, { csrf: 'forged', idem: 'form-abcdefghijkl', amount: '10' });assert.strictEqual(r.status, 403);-- const other = await get('/alex', bob);-- const r2 = await post('/alex/tip', viewer, { csrf: field(other.text, 'csrf'), idem: 'form-abcdefghijkm', amount: '10' });++ const other = await get('/@alex', bob);++ const r2 = await post('/@alex/tip', viewer, { csrf: field(other.text, 'csrf'), idem: 'form-abcdefghijkm', amount: '10' });assert.strictEqual(r2.status, 403, 'a token minted for someone else does not work');const d = await post('/dashboard/profile', alex, { csrf: 'nope', page_enabled: '0' });assert.strictEqual(d.status, 403);@@@ -209,11 +213,11 @@ const { boot, check, done } = require('./helpers/app');});await check('the form reports Billing\'s refusal in words; checkout redirects to the provider', async () => {-- const page = await get('/alex', viewer);-- const low = await post('/alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-low-balance-1', amount: '99999', pay_with: 'credit' });++ const page = await get('/@alex', viewer);++ const low = await post('/@alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-low-balance-1', amount: '99999', pay_with: 'credit' });assert.strictEqual(low.status, 409);assert.match(low.text, /balance is too low/);-- const co = await post('/alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-checkout-0001', amount: '200', pay_with: 'checkout', provider: 'stripe' });++ const co = await post('/@alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-checkout-0001', amount: '200', pay_with: 'checkout', provider: 'stripe' });assert.strictEqual(co.status, 303);assert.match(co.headers.get('location'), /^https:\/\/checkout\.stripe\.test\//);});@@@ -241,12 +245,13 @@ const { boot, check, done } = require('./helpers/app');const off = await post('/dashboard/profile', alex, { csrf, idem: 'x', accepting: '1', revision: String((await domain.profiles.byHandle(t.db, 'alex')).revision) });assert.strictEqual(off.status, 303);assert.strictEqual((await domain.profiles.byHandle(t.db, 'alex')).page_enabled, false, 'an unchecked box switches the page off');-- assert.strictEqual((await get('/alex')).status, 404);++ assert.strictEqual((await get('/@alex')).status, 404);++ assert.strictEqual((await get('/alex')).status, 301, 'the switched-off page\'s old root link still moves to its one address');});await check('copy rules: no pricing claims ("free", "$0", "no ads") on any page', async () => {await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page_enabled: true } });-- for (const [p, u] of [['/', null], ['/alex', viewer], ['/alex/goals', null], ['/dashboard', alex], ['/receipts', viewer]]) {++ for (const [p, u] of [['/', null], ['/@alex', viewer], ['/@alex/goals', null], ['/dashboard', alex], ['/receipts', viewer]]) {const r = await get(p, u);const visible = r.text.replace(/<style[\s\S]*?<\/style>/g, '').replace(/<script[\s\S]*?<\/script>/g, '');assert.ok(!COPY.test(visible), `${p} has forbidden copy: ${(visible.match(COPY) || [])[0]}`);@@@ -135,7 +135,7 @@ function payloadValidator(type) {const list = (await t.call('GET', '/api/v1/goals?creator=alex', { token: null })).json.goals;assert.strictEqual(list[0].current_amount, null);assert.ok((await t.call('GET', `/api/v1/goals/${goal.id}`, { user: alex })).json.goal.current_amount > 0, 'the creator sees everything');-- const html = await fetch(`${t.base}/alex/goals`).then((x) => x.text());++ const html = await fetch(`${t.base}/@alex/goals`).then((x) => x.text());assert.ok(!/of 100,000 Vibes/.test(html), 'the page shows the percentage only');assert.match(html, /Other/);await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { goal_amounts: true } } });@@@ -149,7 +149,7 @@ function payloadValidator(type) {await check('the supporters page: off by default; the creator picks names, amounts and messages', async () => {assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).status, 404);-- assert.strictEqual((await fetch(`${t.base}/alex/supporters`)).status, 404);++ assert.strictEqual((await fetch(`${t.base}/@alex/supporters`)).status, 404);await tip(other, { amount: 1000, supporter_name: 'Other', message: 'big fan' });await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { supporters_page: true } } });let r = (await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json;@@@ -166,19 +166,19 @@ function payloadValidator(type) {assert.ok(!msgs.includes('just for you'), 'a private message never shows');assert.strictEqual(r.recent.find((v) => v.message === 'from nobody').supporter_name, 'Anonymous');assert.strictEqual(r.recent.find((v) => v.message === 'keep it quiet').amount, null);-- const html = await fetch(`${t.base}/alex/supporters`).then((x) => x.text());++ const html = await fetch(`${t.base}/@alex/supporters`).then((x) => x.text());assert.match(html, /big fan/);assert.match(html, /<meta name="robots" content="index,follow">/);assert.ok(!html.includes('just for you') && !html.includes('Viewer Real Name'));-- assert.match(await fetch(`${t.base}/alex`).then((x) => x.text()), /href="\/alex\/supporters"/);++ assert.match(await fetch(`${t.base}/@alex`).then((x) => x.text()), /href="\/@alex\/supporters"/);});await check('the no-JS tip form carries the privacy choices', async () => {const cookie = `ov_token=${t.network.signUser(other)}`;-- const page = await fetch(`${t.base}/alex`, { headers: { Cookie: cookie } }).then((x) => x.text());++ const page = await fetch(`${t.base}/@alex`, { headers: { Cookie: cookie } }).then((x) => x.text());assert.match(page, /name="anonymous"/);const csrf = page.match(/name="csrf" value="([^"]*)"/)[1];-- const res = await fetch(`${t.base}/alex/tip`, {++ const res = await fetch(`${t.base}/@alex/tip`, {method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie },body: new URLSearchParams({ csrf, idem: 'form-privacy-00001', kind: 'tip', amount: '15', message: 'shh', pay_with: 'credit', supporter_name: 'Other', anonymous: '1', private_message: '1' }).toString(),});@@@ -78,7 +78,7 @@ const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Bu});await check('switched off, nobody can buy a sound; switched on, the page offers them with a preview', async () => {-- assert.ok(!(await get('/alex', viewer)).text.includes('name="sound_id"'));++ assert.ok(!(await get('/@alex', viewer)).text.includes('name="sound_id"'));const off = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 100, sound: { id: horn.id } } });assert.strictEqual(off.status, 409);assert.strictEqual(off.json.code, 'tips.sounds_disabled');@@@ -86,7 +86,7 @@ const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Buassert.strictEqual(on.status, 200, on.text);assert.deepStrictEqual(on.json.profile.sounds, { enabled: true });assert.strictEqual(on.json.profile.minimums.sound, 60);-- const page = await get('/alex', viewer);++ const page = await get('/@alex', viewer);assert.match(page.text, /A sound \(played on stream\)/);assert.match(page.text, new RegExp(`name="sound_id" value="${horn.id}" checked`));assert.match(page.text, new RegExp(`<audio controls preload="none" src="/sounds/${horn.id}">`));@@@ -29,13 +29,13 @@ const { boot, check, done } = require('./helpers/app');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');++ 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);++ assert.strictEqual((await req('GET', '/@sam')).status, 404);});await check('the button turns the page on, accepting tips, and opens the dashboard', async () => {@@@ -45,7 +45,7 @@ const { boot, check, done } = require('./helpers/app');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');++ 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]);@@@ -50,7 +50,7 @@ const links = require('../server/domain/support-links');});await check('the tip page lists them as paid outside OpenVibe, nofollow and in a new tab', async () => {-- const page = await get('/tierra');++ 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>/);
@@ -41,7 +41,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =
@@ -41,7 +41,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =
const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);
const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);
const publicSounds = p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [];
const publicSounds = p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [];
return twin({
return twin({
- title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,
+ title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/@${p.handle}`,
description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
robots: 'index,follow', ogImage: p.avatar_url || undefined,
robots: 'index,follow', ogImage: p.avatar_url || undefined,
body: pages.creatorPage({
body: pages.creatorPage({
@@ -55,7 +55,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =
@@ -55,7 +55,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =
const p = await profiles.byHandle(db, handle);
const p = await profiles.byHandle(db, handle);
if (!p || !p.page_enabled) return null;
if (!p || !p.page_enabled) return null;
return twin({
return twin({
- title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: 'index,follow',
+ title: `${p.display_name} — goals`, canonicalPath: `/@${p.handle}/goals`, robots: 'index,follow',
body: pages.goalsPage({ profile: p, goals: await goals.publicGoals(db, await goals.list(db, p.creator_subject), p) }),
body: pages.goalsPage({ profile: p, goals: await goals.publicGoals(db, await goals.list(db, p.creator_subject), p) }),
});
});
}
}
@@ -67,7 +67,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =
@@ -67,7 +67,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =
const leaderboard = top.map((row) => (p.page.supporters_amounts ? row : { ...row, total: null }));
const leaderboard = top.map((row) => (p.page.supporters_amounts ? row : { ...row, total: null }));
const recent = recentRows ? recentRows.map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;
const recent = recentRows ? recentRows.map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;
return twin({
return twin({
- title: `${p.display_name} — supporters`, canonicalPath: `/${p.handle}/supporters`, robots: 'index,follow',
+ title: `${p.display_name} — supporters`, canonicalPath: `/@${p.handle}/supporters`, robots: 'index,follow',
body: pages.supportersPage({ profile: p, leaderboard, recent }),
body: pages.supportersPage({ profile: p, leaderboard, recent }),
});
});
}
}
@@ -76,7 +76,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =
@@ -76,7 +76,7 @@ function createAgentPages({ domain, config, layout, liveNow = { list: async () =
const p = String(path || '/').replace(/\/+$/, '') || '/';
const p = String(path || '/').replace(/\/+$/, '') || '/';
if (p === '/') return home();
if (p === '/') return home();
if (p === '/updates') return updates();
if (p === '/updates') return updates();
- const m = /^\/([^/]+)(\/goals|\/supporters)?$/.exec(p);
+ const m = /^\/@([^/]+)(\/goals|\/supporters)?$/.exec(p);
if (!m) return null;
if (!m) return null;
const handle = profiles.normalizeHandle(m[1]);
const handle = profiles.normalizeHandle(m[1]);
if (!handle) return null;
if (!handle) return null;
@@ -167,6 +167,21 @@ function createApp(opts = {}) {
@@ -167,6 +167,21 @@ function createApp(opts = {}) {
},
},
}));
}));
const liveNow = opts.liveNow || require('./clients/live-now').createLiveNow({ config, fetchImpl: opts.fetchImpl, log });
const liveNow = opts.liveNow || require('./clients/live-now').createLiveNow({ config, fetchImpl: opts.fetchImpl, log });
+ // Old root creator links (and their .md twins) move to /@name with one 301. This sits before the agent
+ // layer, which otherwise would sign /name.md with the old path as its canonical. Reserved names and
+ // unknown handles fall through (404), and only GET/HEAD move.
+ app.use(async (req, res, next) => {
+ if (!['GET', 'HEAD'].includes(req.method)) return next();
+ const m = /^\/([^/@][^/]*)(\/goals|\/supporters)?(\.md)?$/.exec(req.path);
+ if (!m) return next();
+ const handle = domain.profiles.normalizeHandle(m[1]);
+ if (!handle) return next();
+ let p;
+ try { p = await domain.profiles.byHandle(db, handle); } catch { return next(); }
+ if (!p) return next();
+ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
+ return res.redirect(301, `/@${encodeURIComponent(p.handle)}${m[2] || ''}${m[3] || ''}${q}`);
+ });
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the
// public pages (server/agent-pages.js). The pages keep their own routes — a signed-in viewer's tip form,
// public pages (server/agent-pages.js). The pages keep their own routes — a signed-in viewer's tip form,
// account bar and anti-forgery token are theirs — so only the manifest and twin requests reach this
// account bar and anti-forgery token are theirs — so only the manifest and twin requests reach this
@@ -190,7 +190,7 @@ function createProfiles(ctx) {
@@ -190,7 +190,7 @@ function createProfiles(ctx) {
const visible = (before && before.page_enabled) || (after && after.page_enabled);
const visible = (before && before.page_enabled) || (after && after.page_enabled);
const handle = (after && after.handle) || (before && before.handle);
const handle = (after && after.handle) || (before && before.handle);
if (!visible || !handle) return;
if (!visible || !handle) return;
- inx.pingSoon([`${ctx.config.baseUrl}/${handle}`, `${ctx.config.baseUrl}/sitemap.xml`]);
+ inx.pingSoon([`${ctx.config.baseUrl}/@${handle}`, `${ctx.config.baseUrl}/sitemap.xml`]);
}
}
/** Public shape; `owner` adds the settings only the creator sees. */
/** Public shape; `owner` adds the settings only the creator sees. */
@@ -198,7 +198,7 @@ function createProfiles(ctx) {
@@ -198,7 +198,7 @@ function createProfiles(ctx) {
if (!p) return null;
if (!p) return null;
const out = {
const out = {
creator: { type: 'user', id: p.creator_subject }, handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url || null,
creator: { type: 'user', id: p.creator_subject }, handle: p.handle, display_name: p.display_name, avatar_url: p.avatar_url || null,
- headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/${p.handle}`,
+ headline: p.headline || null, page_enabled: p.page_enabled, accepting: p.accepting, url: `${ctx.config.baseUrl}/@${p.handle}`,
minimums: {
minimums: {
tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null,
tip: p.min_amount, paid_message: p.paid_message_min, tts: p.tts_enabled ? p.tts_min_amount : null,
media_request: p.media_requests_enabled ? p.media_request_min : null, sound: p.sounds_enabled ? Number(p.sound_min) : null,
media_request: p.media_requests_enabled ? p.media_request_min : null, sound: p.sounds_enabled ? Number(p.sound_min) : null,
@@ -210,7 +210,7 @@ function createProfiles(ctx) {
@@ -210,7 +210,7 @@ function createProfiles(ctx) {
// Other ways to support, paid outside OpenVibe (support-links.js): Tips builds each link.
// Other ways to support, paid outside OpenVibe (support-links.js): Tips builds each link.
support_links: supportLinks.present(p.support_links),
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',
};
};
if (owner) Object.assign(out, { filter: p.filter, revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });
if (owner) Object.assign(out, { filter: p.filter, revision: p.revision, created_at: p.created_at, updated_at: p.updated_at });
@@ -94,7 +94,7 @@ const HOME_PANE_IDS = ['send', 'for-creators', 'services', 'how', 'costs'];
@@ -94,7 +94,7 @@ const HOME_PANE_IDS = ['send', 'for-creators', 'services', 'how', 'costs'];
function home({ creators, live = [] }) {
function home({ creators, live = [] }) {
const m = NEW_PAGE_MINIMUMS;
const m = NEW_PAGE_MINIMUMS;
const liveCards = live.length ? `<ul class="tp-cards">${live.map((c) => `<li class="tp-card"><p class="tp-card-top"><span class="tp-live">Live</span> <a href="${esc(c.url)}"><strong>${esc(c.name)}</strong></a></p>${c.title ? `<p>${esc(c.title)}</p>` : ''}<p class="meta">${[c.category, watching(c.viewers)].filter(Boolean).map(esc).join(' · ')}</p></li>`).join('')}</ul>` : '';
const liveCards = live.length ? `<ul class="tp-cards">${live.map((c) => `<li class="tp-card"><p class="tp-card-top"><span class="tp-live">Live</span> <a href="${esc(c.url)}"><strong>${esc(c.name)}</strong></a></p>${c.title ? `<p>${esc(c.title)}</p>` : ''}<p class="meta">${[c.category, watching(c.viewers)].filter(Boolean).map(esc).join(' · ')}</p></li>`).join('')}</ul>` : '';
- const creatorCards = creators.length ? `<ul class="tp-cards">${creators.map((c) => `<li class="tp-card"><p class="tp-card-top">${c.avatar_url ? `<img src="${esc(mediaSrc(c.avatar_url, 96))}" alt="" width="40" height="40" loading="lazy">` : ''}<a href="/${esc(c.handle)}"><strong>${esc(c.display_name)}</strong></a></p>${c.headline ? `<p>${esc(c.headline)}</p>` : ''}${c.goal ? `<p class="meta">Goal: ${esc(c.goal.title)}</p><div class="bar" role="progressbar" aria-label="${esc(c.goal.title)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${c.goal.percent}"><span style="width:${c.goal.percent}%"></span></div>` : ''}</li>`).join('')}</ul>` : '';
+ const creatorCards = creators.length ? `<ul class="tp-cards">${creators.map((c) => `<li class="tp-card"><p class="tp-card-top">${c.avatar_url ? `<img src="${esc(mediaSrc(c.avatar_url, 96))}" alt="" width="40" height="40" loading="lazy">` : ''}<a href="/@${esc(c.handle)}"><strong>${esc(c.display_name)}</strong></a></p>${c.headline ? `<p>${esc(c.headline)}</p>` : ''}${c.goal ? `<p class="meta">Goal: ${esc(c.goal.title)}</p><div class="bar" role="progressbar" aria-label="${esc(c.goal.title)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${c.goal.percent}"><span style="width:${c.goal.percent}%"></span></div>` : ''}</li>`).join('')}</ul>` : '';
return showcase.hero({
return showcase.hero({
eyebrow: 'OpenVibe.Tips',
eyebrow: 'OpenVibe.Tips',
title: 'Support the creators', accent: 'you watch',
title: 'Support the creators', accent: 'you watch',
@@ -191,7 +191,7 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
@@ -191,7 +191,7 @@ function tipForm({ profile, goals, viewer, csrf, idem, values = {}, providers, e
const kind = values.kind || 'tip';
const kind = values.kind || 'tip';
const active = goals.filter((g) => g.status === 'active');
const active = goals.filter((g) => g.status === 'active');
if (!profile.accepting) return `<section class="card"><p>${esc(profile.display_name)} is not accepting tips right now.</p></section>`;
if (!profile.accepting) return `<section class="card"><p>${esc(profile.display_name)} is not accepting tips right now.</p></section>`;
- return `<form class="card tip-form" method="post" action="/${esc(profile.handle)}/tip">
+ return `<form class="card tip-form" method="post" action="/@${esc(profile.handle)}/tip">
<h2>Send ${esc(profile.display_name)} something</h2>
<h2>Send ${esc(profile.display_name)} something</h2>
${errorBox(error)}
${errorBox(error)}
${hidden({ csrf: csrf || '', idem })}
${hidden({ csrf: csrf || '', idem })}
@@ -303,7 +303,7 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
@@ -303,7 +303,7 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
<section class="creator-head">
<section class="creator-head">
${profile.avatar_url ? `<img class="avatar" src="${esc(mediaSrc(profile.avatar_url, 320))}" alt="" width="96" height="96">` : ''}
${profile.avatar_url ? `<img class="avatar" src="${esc(mediaSrc(profile.avatar_url, 320))}" alt="" width="96" height="96">` : ''}
<div><h1>${esc(profile.display_name)}</h1>${profile.headline ? `<p class="headline">${esc(profile.headline)}</p>` : ''}
<div><h1>${esc(profile.display_name)}</h1>${profile.headline ? `<p class="headline">${esc(profile.headline)}</p>` : ''}
- <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>
${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}
${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}
${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
@@ -313,7 +313,7 @@ ${supportLinksSection(profile)}`;
@@ -313,7 +313,7 @@ ${supportLinksSection(profile)}`;
function goalsPage({ profile, goals }) {
function goalsPage({ profile, goals }) {
return `<h1>${esc(profile.display_name)} — goals</h1>
return `<h1>${esc(profile.display_name)} — goals</h1>
-<p><a href="/${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>
+<p><a href="/@${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>
${goals.length ? goals.map(goalCard).join('') : '<p>No goals yet.</p>'}
${goals.length ? goals.map(goalCard).join('') : '<p>No goals yet.</p>'}
<p class="fine">Goal totals count settled payments only.${profile.page.goal_supporters ? ' Supporters are listed as they chose to be shown.' : ''}</p>`;
<p class="fine">Goal totals count settled payments only.${profile.page.goal_supporters ? ' Supporters are listed as they chose to be shown.' : ''}</p>`;
}
}
@@ -327,14 +327,14 @@ function supportersPage({ profile, leaderboard, recent }) {
@@ -327,14 +327,14 @@ function supportersPage({ profile, leaderboard, recent }) {
const wall = recent && recent.length
const wall = recent && recent.length
? `<section><h2>Recent messages</h2><ul class="wall">${recent.map((v) => `<li><b>${esc(v.supporter_name)}</b>${v.amount != null ? ` <small>${n(v.amount)} Vibes</small>` : ''}<p>${esc(v.message)}</p><small>${when(v.at)}</small></li>`).join('')}</ul></section>` : '';
? `<section><h2>Recent messages</h2><ul class="wall">${recent.map((v) => `<li><b>${esc(v.supporter_name)}</b>${v.amount != null ? ` <small>${n(v.amount)} Vibes</small>` : ''}<p>${esc(v.message)}</p><small>${when(v.at)}</small></li>`).join('')}</ul></section>` : '';
return `<h1>${esc(profile.display_name)} — supporters</h1>
return `<h1>${esc(profile.display_name)} — supporters</h1>
-<p><a href="/${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>
+<p><a href="/@${esc(profile.handle)}">Back to ${esc(profile.display_name)}</a></p>
<section><h2>Top supporters</h2>${board}</section>
<section><h2>Top supporters</h2>${board}</section>
${page.supporters_messages ? wall || '<p>No public messages yet.</p>' : ''}
${page.supporters_messages ? wall || '<p>No public messages yet.</p>' : ''}
<p class="fine">Anonymous tips and tips with a hidden amount are not on this list.</p>`;
<p class="fine">Anonymous tips and tips with a hidden amount are not on this list.</p>`;
}
}
function receiptRow(i, as) {
function receiptRow(i, as) {
- const who = as === 'creator' ? esc(i.supporter_name || 'Someone') : `<a href="/${esc(i.creator_handle || '')}">${esc(i.creator_name || 'creator')}</a>`;
+ const who = as === 'creator' ? esc(i.supporter_name || 'Someone') : `<a href="/@${esc(i.creator_handle || '')}">${esc(i.creator_name || 'creator')}</a>`;
return `<tr${i.test ? ' class="test"' : ''}><td><a href="/receipts/${esc(i.id)}">${when(i.created_at)}</a></td><td>${who}</td><td>${esc(KIND_LABEL[i.kind])}${i.test ? ' <small>(simulation)</small>' : ''}</td><td class="num">${n(i.amount)}</td><td>${esc(PAY_LABEL[i.payment.state])}</td><td>${esc(DELIVERY_LABEL[i.delivery.state])}</td></tr>`;
return `<tr${i.test ? ' class="test"' : ''}><td><a href="/receipts/${esc(i.id)}">${when(i.created_at)}</a></td><td>${who}</td><td>${esc(KIND_LABEL[i.kind])}${i.test ? ' <small>(simulation)</small>' : ''}</td><td class="num">${n(i.amount)}</td><td>${esc(PAY_LABEL[i.payment.state])}</td><td>${esc(DELIVERY_LABEL[i.delivery.state])}</td></tr>`;
}
}
@@ -373,7 +373,7 @@ function receiptPage({ i, profile, as, cancelled }) {
@@ -373,7 +373,7 @@ function receiptPage({ i, profile, as, cancelled }) {
return `<h1>Receipt</h1>
return `<h1>Receipt</h1>
${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was cancelled. Nothing was charged; you can start again from the creator\'s page.</p>' : ''}
${cancelled && i.payment.state === 'pending' ? '<p class="notice">Checkout was cancelled. Nothing was charged; you can start again from the creator\'s page.</p>' : ''}
<dl class="receipt">
<dl class="receipt">
- <dt>Creator</dt><dd>${profile ? `<a href="/${esc(profile.handle)}">${esc(profile.display_name)}</a>` : 'unknown'}</dd>
+ <dt>Creator</dt><dd>${profile ? `<a href="/@${esc(profile.handle)}">${esc(profile.display_name)}</a>` : 'unknown'}</dd>
<dt>From</dt><dd>${esc(i.supporter_name || 'Someone')}</dd>
<dt>From</dt><dd>${esc(i.supporter_name || 'Someone')}</dd>
<dt>What</dt><dd>${esc(KIND_LABEL[i.kind])}${i.test ? ' (simulation, not charged, not counted)' : ''}</dd>
<dt>What</dt><dd>${esc(KIND_LABEL[i.kind])}${i.test ? ' (simulation, not charged, not counted)' : ''}</dd>
${privacyLine(i.privacy) ? `<dt>Privacy</dt><dd>${privacyLine(i.privacy)}</dd>` : ''}
${privacyLine(i.privacy) ? `<dt>Privacy</dt><dd>${privacyLine(i.privacy)}</dd>` : ''}
@@ -434,7 +434,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
@@ -434,7 +434,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
<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>
<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>
<form method="post" action="/dashboard/profile">${hidden({ csrf, idem: `${idem}-p` })}
<form method="post" action="/dashboard/profile">${hidden({ csrf, idem: `${idem}-p` })}
<label><input type="checkbox" name="page_enabled" value="1"${f('page_enabled')}> Public page on (search engines may index it)</label>
<label><input type="checkbox" name="page_enabled" value="1"${f('page_enabled')}> Public page on (search engines may index it)</label>
<label><input type="checkbox" name="accepting" value="1"${f('accepting')}> Accepting tips</label>
<label><input type="checkbox" name="accepting" value="1"${f('accepting')}> Accepting tips</label>
@@ -129,7 +129,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -129,7 +129,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
})));
})));
r.get('/sitemap.xml', async (req, res) => {
r.get('/sitemap.xml', async (req, res) => {
const rows = await db.many(sql`SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled ORDER BY handle`);
const rows = await db.many(sql`SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled ORDER BY handle`);
- const urls = [{ loc: `${config.baseUrl}/`, lastmod: BOOT_AT }, ...rows.map((p) => ({ loc: `${config.baseUrl}/${p.handle}`, lastmod: p.updated_at }))];
+ const urls = [{ loc: `${config.baseUrl}/`, lastmod: BOOT_AT }, ...rows.map((p) => ({ loc: `${config.baseUrl}/@${p.handle}`, lastmod: p.updated_at }))];
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 900 })).send(seo.sitemapXml(urls));
res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 900 })).send(seo.sitemapXml(urls));
});
});
// /llms.txt (llmstxt.org): a plain-markdown map of the site for language-model crawlers.
// /llms.txt (llmstxt.org): a plain-markdown map of the site for language-model crawlers.
@@ -172,7 +172,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -172,7 +172,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
] },
] },
{ title: 'Creator pages', pages: rows.map((c) => ({
{ title: 'Creator pages', pages: rows.map((c) => ({
title: `${c.display_name} on OpenVibe.Tips`,
title: `${c.display_name} on OpenVibe.Tips`,
- url: `/${c.handle}`,
+ url: `/@${c.handle}`,
text: `The public support page of ${c.display_name} (@${c.handle}): their goals and a tip form that works without JavaScript. Tips settle through OpenVibe Billing and the overlay alerts belong to the creator. The goals page and, only when the creator switches it on, the supporters page are public too.`,
text: `The public support page of ${c.display_name} (@${c.handle}): their goals and a tip form that works without JavaScript. Tips settle through OpenVibe Billing and the overlay alerts belong to the creator. The goals page and, only when the creator switches it on, the supporters page are public too.`,
})) },
})) },
{ title: 'Legal', pages: legal.PATHS.map((p) => ({
{ title: 'Legal', pages: legal.PATHS.map((p) => ({
@@ -538,7 +538,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -538,7 +538,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
const p = await profiles.byHandle(db, req.params.handle);
const p = await profiles.byHandle(db, req.params.handle);
const owner = !!(p && req.viewer && req.viewer.subject === p.creator_subject);
const owner = !!(p && req.viewer && req.viewer.subject === p.creator_subject);
if (!p || (!p.page_enabled && !owner)) return notFound(req, res);
if (!p || (!p.page_enabled && !owner)) return notFound(req, res);
- if (p.handle !== String(req.params.handle)) return res.redirect(301, `/${p.handle}`); // @name, Name → the canonical path
+ if (p.handle !== String(req.params.handle)) return res.redirect(301, `/@${encodeURIComponent(p.handle)}`); // Name → the one canonical /@name
// The page shows what the public sees, for the creator too (a preview); the dashboard has the rest.
// The page shows what the public sees, for the creator too (a preview); the dashboard has the rest.
const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);
const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);
// The members-only posts: the title and the tier are public, the text only for a member of a
// The members-only posts: the title and the tier are public, the text only for a member of a
@@ -547,26 +547,29 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -547,26 +547,29 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
const posts = await domain.posts.view({ creatorSubject: p.creator_subject, memberSubject: req.viewer ? req.viewer.subject : null, plans: vipCreator ? vipCreator.plans : [], owner });
const posts = await domain.posts.view({ creatorSubject: p.creator_subject, memberSubject: req.viewer ? req.viewer.subject : null, plans: vipCreator ? vipCreator.plans : [], owner });
for (const post of posts) post.join_url = post.unlocked ? null : await domain.vip.joinUrl(p.creator_subject, post.tiers);
for (const post of posts) post.join_url = post.unlocked ? null : await domain.vip.joinUrl(p.creator_subject, post.tiers);
const csrf = req.viewer ? csrfFor(req.viewer.subject) : '';
const csrf = req.viewer ? csrfFor(req.viewer.subject) : '';
- const jsonLd = p.page_enabled ? [{ '@context': 'https://schema.org', '@type': 'ProfilePage', name: p.display_name, url: `${config.baseUrl}/${p.handle}`, mainEntity: { '@type': 'Person', name: p.display_name, alternateName: p.handle, image: p.avatar_url || undefined } }] : [];
+ const jsonLd = p.page_enabled ? [{ '@context': 'https://schema.org', '@type': 'ProfilePage', name: p.display_name, url: `${config.baseUrl}/@${p.handle}`, mainEntity: { '@type': 'Person', name: p.display_name, alternateName: p.handle, image: p.avatar_url || undefined } }] : [];
return html(res, pageFor(req, {
return html(res, pageFor(req, {
- title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,
+ title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/@${p.handle}`,
description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,
robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,
body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled, sounds: p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [], posts }),
body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled, sounds: p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [], posts }),
}), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
}), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
}
}
- r.get('/:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));
- r.get('/:handle/goals', withViewer, async (req, res, next) => {
+ // The canonical creator addresses: /@<handle>. The root /<handle> forms are kept only as 301s from the old
+ // links (and the reserved list only guards that old space); new links, canonicals, the sitemap and IndexNow
+ // all use /@.
+ r.get('/@:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));
+ r.get('/@:handle/goals', withViewer, async (req, res, next) => {
const p = await profiles.byHandle(db, req.params.handle);
const p = await profiles.byHandle(db, req.params.handle);
if (!p) return next();
if (!p) return next();
const owner = req.viewer && req.viewer.subject === p.creator_subject;
const owner = req.viewer && req.viewer.subject === p.creator_subject;
if (!p.page_enabled && !owner) return notFound(req, res);
if (!p.page_enabled && !owner) return notFound(req, res);
return html(res, pageFor(req, {
return html(res, pageFor(req, {
- title: `${p.display_name} — goals`, canonicalPath: `/${p.handle}/goals`, robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow',
+ title: `${p.display_name} — goals`, canonicalPath: `/@${p.handle}/goals`, robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow',
body: pages.goalsPage({ profile: p, goals: await goals.publicGoals(db, await goals.list(db, p.creator_subject), p) }),
body: pages.goalsPage({ profile: p, goals: await goals.publicGoals(db, await goals.list(db, p.creator_subject), p) }),
}));
}));
});
});
- r.get('/:handle/supporters', withViewer, async (req, res, next) => {
+ r.get('/@:handle/supporters', withViewer, async (req, res, next) => {
const p = await profiles.byHandle(db, req.params.handle);
const p = await profiles.byHandle(db, req.params.handle);
if (!p) return next();
if (!p) return next();
const owner = req.viewer && req.viewer.subject === p.creator_subject;
const owner = req.viewer && req.viewer.subject === p.creator_subject;
@@ -577,15 +580,38 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -577,15 +580,38 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
const recent = recentRows ? recentRows.map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;
const recent = recentRows ? recentRows.map((v) => (p.page.supporters_amounts ? v : { ...v, amount: null })) : null;
const indexable = p.page_enabled && p.page.supporters_page;
const indexable = p.page_enabled && p.page.supporters_page;
return html(res, pageFor(req, {
return html(res, pageFor(req, {
- title: `${p.display_name} — supporters`, canonicalPath: `/${p.handle}/supporters`, robots: indexable ? 'index,follow' : 'noindex,nofollow',
+ title: `${p.display_name} — supporters`, canonicalPath: `/@${p.handle}/supporters`, robots: indexable ? 'index,follow' : 'noindex,nofollow',
body: pages.supportersPage({ profile: p, leaderboard, recent }),
body: pages.supportersPage({ profile: p, leaderboard, recent }),
}), 200, indexable ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
}), 200, indexable ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
});
});
- r.post('/:handle/tip', withViewer, form, async (req, res, next) => {
+ // Old root links, for the 301 window: /name → /@name (query kept). A name that is not a creator is still a
+ // 404; the reserved names never redirect because normalizeHandle refuses them.
+ const legacy = (suffix) => async (req, res, next) => {
+ const handle = profiles.normalizeHandle(req.params.handle);
+ if (!handle) return next();
+ const p = await profiles.byHandle(db, handle);
+ if (!p) return next();
+ const q = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
+ return res.redirect(301, `/@${encodeURIComponent(p.handle)}${suffix}${q}`);
+ };
+ r.get('/:handle', withViewer, legacy(''));
+ r.get('/:handle/goals', withViewer, legacy('/goals'));
+ r.get('/:handle/supporters', withViewer, legacy('/supporters'));
+ // Old in-flight tip forms: 308 keeps the method and body while moving them to the canonical address.
+ r.post('/:handle/tip', withViewer, async (req, res, next) => {
+ if (String(req.params.handle).startsWith('@')) return next(); // the canonical /@:handle/tip route below
+ const handle = profiles.normalizeHandle(req.params.handle);
+ if (!handle) return next();
+ const p = await profiles.byHandle(db, handle);
+ if (!p) return next();
+ return res.redirect(308, `/@${encodeURIComponent(p.handle)}/tip`);
+ });
+
+ r.post('/@:handle/tip', withViewer, form, async (req, res, next) => {
const p = await profiles.byHandle(db, req.params.handle);
const p = await profiles.byHandle(db, req.params.handle);
if (!p) return next();
if (!p) return next();
- if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/${p.handle}`)}`);
+ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/@${p.handle}`)}`);
const b = req.body || {};
const b = req.body || {};
const values = {
const values = {
kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, sound_id: b.sound_id, goal_id: b.goal_id, pay_with: b.pay_with,
kind: b.kind, amount: b.amount, message: b.message, tts_text: b.tts_text, tts_voice: b.tts_voice, media_url: b.media_url, sound_id: b.sound_id, goal_id: b.goal_id, pay_with: b.pay_with,
@@ -45,7 +45,7 @@ const KEY = 'k'.repeat(32);
@@ -45,7 +45,7 @@ const KEY = 'k'.repeat(32);
await check('switching a creator page on pings its URL and the sitemap', async () => {
await check('switching a creator page on pings its URL and the sitemap', async () => {
alex = await t.creator('alex', { settings: { headline: 'Late-night synth streams' } });
alex = await t.creator('alex', { settings: { headline: 'Late-night synth streams' } });
- assert.ok(pings.includes('http://tips.test/alex'), JSON.stringify(pings));
+ assert.ok(pings.includes('http://tips.test/@alex'), JSON.stringify(pings));
assert.ok(pings.includes('http://tips.test/sitemap.xml'), JSON.stringify(pings));
assert.ok(pings.includes('http://tips.test/sitemap.xml'), JSON.stringify(pings));
});
});
@@ -53,7 +53,7 @@ const KEY = 'k'.repeat(32);
@@ -53,7 +53,7 @@ const KEY = 'k'.repeat(32);
pings.length = 0;
pings.length = 0;
const r = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { headline: 'Now with text-to-speech' } });
const r = await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { headline: 'Now with text-to-speech' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.ok(pings.includes('http://tips.test/alex'), JSON.stringify(pings));
+ assert.ok(pings.includes('http://tips.test/@alex'), JSON.stringify(pings));
assert.ok(pings.includes('http://tips.test/sitemap.xml'), JSON.stringify(pings));
assert.ok(pings.includes('http://tips.test/sitemap.xml'), JSON.stringify(pings));
});
});
@@ -67,7 +67,7 @@ const validModerated = payloadValidator('tips.interaction.moderated');
@@ -67,7 +67,7 @@ const validModerated = payloadValidator('tips.interaction.moderated');
const tts = await pay('tts', { amount: 150, tts: { text: 'say badword now please' } });
const tts = await pay('tts', { amount: 150, tts: { text: 'say badword now please' } });
await domain.effects.drain();
await domain.effects.drain();
assert.strictEqual(jobsOf(tts.json.interaction.id).find((j) => j.effect === 'tts').tts.text, 'say now please');
assert.strictEqual(jobsOf(tts.json.interaction.id).find((j) => j.effect === 'tts').tts.text, 'say now please');
- const page = await get('/alex/supporters');
+ const page = await get('/@alex/supporters');
assert.ok(!/badword/i.test(page.text.replace(/<style[\s\S]*?<\/style>/g, '')), 'the supporters page shows no blocked word');
assert.ok(!/badword/i.test(page.text.replace(/<style[\s\S]*?<\/style>/g, '')), 'the supporters page shows no blocked word');
});
});
@@ -120,7 +120,7 @@ const validModerated = payloadValidator('tips.interaction.moderated');
@@ -120,7 +120,7 @@ const validModerated = payloadValidator('tips.interaction.moderated');
const ok = await post(path, mod, { csrf: field(page.text, 'csrf'), idem: 'x' });
const ok = await post(path, mod, { csrf: field(page.text, 'csrf'), idem: 'x' });
assert.strictEqual(ok.status, 303);
assert.strictEqual(ok.status, 303);
assert.match(ok.headers.get('location'), /^\/moderate\/alex\?done=/);
assert.match(ok.headers.get('location'), /^\/moderate\/alex\?done=/);
- assert.strictEqual((await post(path, stranger, { csrf: field((await get('/alex', stranger)).text, 'csrf'), idem: 'x' })).status, 404, 'used once');
+ assert.strictEqual((await post(path, stranger, { csrf: field((await get('/@alex', stranger)).text, 'csrf'), idem: 'x' })).status, 404, 'used once');
assert.strictEqual((await get('/api/v1/moderators?creator=alex', null)).status, 401);
assert.strictEqual((await get('/api/v1/moderators?creator=alex', null)).status, 401);
const list = await t.call('GET', '/api/v1/moderators', { user: alex });
const list = await t.call('GET', '/api/v1/moderators', { user: alex });
assert.deepStrictEqual(list.json.moderators.map((m) => m.moderator.id), [mod.subject]);
assert.deepStrictEqual(list.json.moderators.map((m) => m.moderator.id), [mod.subject]);
@@ -30,22 +30,22 @@ const { boot, check, done } = require('./helpers/app');
@@ -30,22 +30,22 @@ const { boot, check, done } = require('./helpers/app');
assert.match(r.text, /<meta name="ov-release"/);
assert.match(r.text, /<meta name="ov-release"/);
assert.match(r.text, /id="ov-footer"|class="ovf/);
assert.match(r.text, /id="ov-footer"|class="ovf/);
assert.match(r.text, /<script type="application\/ld\+json">[^<]*"@type":"WebSite"[^<]*"name":"OpenVibe.Tips"/, 'the home says what the site is (JSON-LD)');
assert.match(r.text, /<script type="application\/ld\+json">[^<]*"@type":"WebSite"[^<]*"name":"OpenVibe.Tips"/, 'the home says what the site is (JSON-LD)');
- assert.match(r.text, /href="\/alex"/);
- assert.ok(!r.text.includes('href="/bob"'), 'switched-off pages are not listed');
+ assert.match(r.text, /href="\/@alex"/);
+ assert.ok(!r.text.includes('href="/@bob"'), 'switched-off pages are not listed');
// The home opens with the showcase kit (openvibe-shared/showcase): one h1 (the hero), the kit's sheet, the
// The home opens with the showcase kit (openvibe-shared/showcase): one h1 (the hero), the kit's sheet, the
// pricing from the real defaults, and the creators list still below it.
// pricing from the real defaults, and the creators list still below it.
assert.ok(r.text.includes('class="sc-hero') && r.text.includes('class="sc-tiers"'), 'hero and pricing');
assert.ok(r.text.includes('class="sc-hero') && r.text.includes('class="sc-tiers"'), 'hero and pricing');
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[^"]+">/);
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[^"]+">/);
assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
assert.ok(r.text.indexOf('class="sc-hero') < r.text.indexOf('id="creators"'), 'the creators follow the showcase');
assert.ok(r.text.indexOf('class="sc-hero') < r.text.indexOf('id="creators"'), 'the creators follow the showcase');
- assert.ok(!(await get('/alex')).text.includes('showcase.css'), 'only the home links the kit');
+ assert.ok(!(await get('/@alex')).text.includes('showcase.css'), 'only the home links the kit');
});
});
await check('a Media avatar is asked for the width the page draws; an already sized URL is left alone', async () => {
await check('a Media avatar is asked for the width the page draws; an already sized URL is left alone', async () => {
const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';
const obj = 'https://openvibe.media/o/med_01M644DY8BFX4H7MTRKPGG052R';
await t.db.exec(`UPDATE creator_tip_profiles SET avatar_url = '${obj}' WHERE handle = 'alex'`);
await t.db.exec(`UPDATE creator_tip_profiles SET avatar_url = '${obj}' WHERE handle = 'alex'`);
assert.ok((await get('/')).text.includes(`<img src="${obj}?w=96" alt="" width="40" height="40" loading="lazy">`), 'the 40 px creator card asks for 96');
assert.ok((await get('/')).text.includes(`<img src="${obj}?w=96" alt="" width="40" height="40" loading="lazy">`), 'the 40 px creator card asks for 96');
- assert.ok((await get('/alex')).text.includes(`<img class="avatar" src="${obj}?w=320" alt="" width="96" height="96">`), 'the 96 px creator page head asks for 320');
+ assert.ok((await get('/@alex')).text.includes(`<img class="avatar" src="${obj}?w=320" alt="" width="96" height="96">`), 'the 96 px creator page head asks for 320');
await t.db.exec(`UPDATE creator_tip_profiles SET avatar_url = '${obj}?w=64' WHERE handle = 'alex'`);
await t.db.exec(`UPDATE creator_tip_profiles SET avatar_url = '${obj}?w=64' WHERE handle = 'alex'`);
const sized = await get('/');
const sized = await get('/');
assert.ok(sized.text.includes(`src="${obj}?w=64"`), 'the URL is used as stored');
assert.ok(sized.text.includes(`src="${obj}?w=64"`), 'the URL is used as stored');
@@ -62,7 +62,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -62,7 +62,7 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {
await check('every page carries the boost marker and script, and the navbar signs in back to the current page', async () => {
- for (const [p, u] of [['/', null], ['/alex', viewer], ['/dashboard', alex], ['/receipts', viewer], ['/updates', alex]]) {
+ for (const [p, u] of [['/', null], ['/@alex', viewer], ['/dashboard', alex], ['/receipts', viewer], ['/updates', alex]]) {
const r = await get(p, u);
const r = await get(p, u);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
assert.match(r.text, /<meta name="ov-boost" content="tips@[^"]+">/, p);
assert.match(r.text, /<meta name="ov-boost" content="tips@[^"]+">/, p);
@@ -72,16 +72,19 @@ const { boot, check, done } = require('./helpers/app');
@@ -72,16 +72,19 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('creator page: indexable when on, goals and a no-JS tip form', async () => {
await check('creator page: indexable when on, goals and a no-JS tip form', async () => {
- const r = await get('/alex');
+ const r = await get('/@alex');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /<meta name="robots" content="index,follow">/);
assert.match(r.text, /<meta name="robots" content="index,follow">/);
- assert.match(r.text, /<link rel="canonical" href="http:\/\/tips\.test\/alex">/);
+ assert.match(r.text, /<link rel="canonical" href="http:\/\/tips\.test\/@alex">/);
assert.match(r.text, /New synth/);
assert.match(r.text, /New synth/);
- assert.match(r.text, /<form class="card tip-form" method="post" action="\/alex\/tip">/);
+ assert.match(r.text, /<form class="card tip-form" method="post" action="\/@alex\/tip">/);
assert.match(r.text, /Sign in to send/);
assert.match(r.text, /Sign in to send/);
assert.match(r.text, /Late-night synth streams/);
assert.match(r.text, /Late-night synth streams/);
assert.match(r.text, /"@type":"ProfilePage"/);
assert.match(r.text, /"@type":"ProfilePage"/);
- assert.match((await get('/@alex')).headers.get('location') || '', /\/alex$/);
+ const legacy = await get('/alex?from=old');
+ assert.deepStrictEqual([legacy.status, legacy.headers.get('location')], [301, '/@alex?from=old']);
+ assert.deepStrictEqual([(await get('/ALEX')).status, (await get('/ALEX')).headers.get('location')], [301, '/@alex']);
+ assert.strictEqual((await get('/nobody-here')).status, 404);
});
});
await check('the head comes from openvibe-shared/shell and keeps every tag and script of the site', async () => {
await check('the head comes from openvibe-shared/shell and keeps every tag and script of the site', async () => {
@@ -105,19 +108,19 @@ const { boot, check, done } = require('./helpers/app');
@@ -105,19 +108,19 @@ const { boot, check, done } = require('./helpers/app');
assert.match(home.text, /<main id="main" class="page page-wide">/, "the home is the wide page");
assert.match(home.text, /<main id="main" class="page page-wide">/, "the home is the wide page");
assert.match(home.text, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);
assert.match(home.text, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);
- const creator = await get('/alex');
+ const creator = await get('/@alex');
const creatorHead = creator.text.slice(0, creator.text.indexOf('</head>'));
const creatorHead = creator.text.slice(0, creator.text.indexOf('</head>'));
- assert.match(creatorHead, /<link rel="canonical" href="http:\/\/tips\.test\/alex">/);
+ assert.match(creatorHead, /<link rel="canonical" href="http:\/\/tips\.test\/@alex">/);
assert.match(creatorHead, /<script type="application\/ld\+json">\{[^<]*"@type":"ProfilePage"/);
assert.match(creatorHead, /<script type="application\/ld\+json">\{[^<]*"@type":"ProfilePage"/);
});
});
await check('a switched-off page: 404 + noindex for everyone else; a noindex preview for its creator', async () => {
await check('a switched-off page: 404 + noindex for everyone else; a noindex preview for its creator', async () => {
- const anon = await get('/bob');
+ const anon = await get('/@bob');
assert.strictEqual(anon.status, 404);
assert.strictEqual(anon.status, 404);
assert.match(anon.text, /noindex/);
assert.match(anon.text, /noindex/);
- const other = await get('/bob', viewer);
+ const other = await get('/@bob', viewer);
assert.strictEqual(other.status, 404);
assert.strictEqual(other.status, 404);
- const own = await get('/bob', bob);
+ const own = await get('/@bob', bob);
assert.strictEqual(own.status, 200);
assert.strictEqual(own.status, 200);
assert.match(own.text, /<meta name="robots" content="noindex,nofollow">/);
assert.match(own.text, /<meta name="robots" content="noindex,nofollow">/);
assert.match(own.headers.get('x-robots-tag'), /noindex/);
assert.match(own.headers.get('x-robots-tag'), /noindex/);
@@ -137,9 +140,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -137,9 +140,10 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(sm.status, 200);
assert.strictEqual(sm.status, 200);
assert.match(sm.headers.get('content-type'), /^application\/xml/);
assert.match(sm.headers.get('content-type'), /^application\/xml/);
assert.match(sm.text, /<loc>http:\/\/tips\.test\/<\/loc>/, 'the sitemap lists the home URL');
assert.match(sm.text, /<loc>http:\/\/tips\.test\/<\/loc>/, 'the sitemap lists the home URL');
- assert.match(sm.text, /<loc>http:\/\/tips\.test\/alex<\/loc>/);
+ assert.match(sm.text, /<loc>http:\/\/tips\.test\/@alex<\/loc>/);
+ assert.ok(!sm.text.includes('<loc>http://tips.test/alex</loc>'), 'the old root form is not a sitemap page');
assert.match(sm.text, /<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/, 'every url carries a real lastmod');
assert.match(sm.text, /<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/, 'every url carries a real lastmod');
- assert.ok(!sm.text.includes('/bob<'));
+ assert.ok(!sm.text.includes('/@bob<'));
const llms = await get('/llms.txt');
const llms = await get('/llms.txt');
assert.strictEqual(llms.status, 200);
assert.strictEqual(llms.status, 200);
assert.match(llms.headers.get('content-type'), /^text\/plain/);
assert.match(llms.headers.get('content-type'), /^text\/plain/);
@@ -161,22 +165,22 @@ const { boot, check, done } = require('./helpers/app');
@@ -161,22 +165,22 @@ const { boot, check, done } = require('./helpers/app');
assert.match(full.headers.get('content-type'), /^text\/plain/);
assert.match(full.headers.get('content-type'), /^text\/plain/);
assert.match(full.text, /^# /, 'llms-full.txt starts with the same markdown heading');
assert.match(full.text, /^# /, 'llms-full.txt starts with the same markdown heading');
assert.match(full.text, /URL: http:\/\/tips\.test\/\n/, 'llms-full.txt contains the home URL');
assert.match(full.text, /URL: http:\/\/tips\.test\/\n/, 'llms-full.txt contains the home URL');
- assert.match(full.text, /URL: http:\/\/tips\.test\/alex\n/, 'llms-full.txt contains the switched-on creator page');
- assert.ok(!full.text.includes('/bob'), 'llms-full.txt never lists a switched-off page');
+ assert.match(full.text, /URL: http:\/\/tips\.test\/@alex\n/, 'llms-full.txt contains the switched-on creator page');
+ assert.ok(!full.text.includes('/@bob'), 'llms-full.txt never lists a switched-off page');
assert.ok(!/(?:URL: http:\/\/tips\.test\/)(?:dashboard|receipts|overlay|moderate|auth|api)/.test(full.text), 'llms-full.txt lists no signed-in page');
assert.ok(!/(?:URL: http:\/\/tips\.test\/)(?:dashboard|receipts|overlay|moderate|auth|api)/.test(full.text), 'llms-full.txt lists no signed-in page');
assert.ok(Buffer.byteLength(full.text) < 512 * 1024, 'llms-full.txt stays under 512 KiB');
assert.ok(Buffer.byteLength(full.text) < 512 * 1024, 'llms-full.txt stays under 512 KiB');
});
});
await check('tipping without JavaScript: sign-in first, then a form post settles and lands on the receipt', async () => {
await check('tipping without JavaScript: sign-in first, then a form post settles and lands on the receipt', async () => {
- const anon = await post('/alex/tip', null, { amount: '10' });
+ const anon = await post('/@alex/tip', null, { amount: '10' });
assert.strictEqual(anon.status, 303);
assert.strictEqual(anon.status, 303);
- assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2Falex/);
- const page = await get('/alex', viewer);
+ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=%2F%40alex/);
+ const page = await get('/@alex', viewer);
const csrf = field(page.text, 'csrf');
const csrf = field(page.text, 'csrf');
const idem = field(page.text, 'idem');
const idem = field(page.text, 'idem');
assert.ok(csrf && idem);
assert.ok(csrf && idem);
const form = { csrf, idem, kind: 'tip', amount: '25', message: 'no js needed', pay_with: 'credit', supporter_name: 'Viewer' };
const form = { csrf, idem, kind: 'tip', amount: '25', message: 'no js needed', pay_with: 'credit', supporter_name: 'Viewer' };
- const r = await post('/alex/tip', viewer, form);
+ const r = await post('/@alex/tip', viewer, form);
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
const loc = r.headers.get('location');
const loc = r.headers.get('location');
assert.match(loc, /^\/receipts\/tint_/);
assert.match(loc, /^\/receipts\/tint_/);
@@ -184,7 +188,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -184,7 +188,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await domain.interactions.get(t.db, id)).payment_state, 'settled');
assert.strictEqual((await domain.interactions.get(t.db, id)).payment_state, 'settled');
// Double submit (same form nonce): the same interaction, no second transfer.
// Double submit (same form nonce): the same interaction, no second transfer.
const before = billing.transfers().length;
const before = billing.transfers().length;
- const again = await post('/alex/tip', viewer, form);
+ const again = await post('/@alex/tip', viewer, form);
assert.strictEqual(again.headers.get('location'), loc);
assert.strictEqual(again.headers.get('location'), loc);
assert.strictEqual(billing.transfers().length, before);
assert.strictEqual(billing.transfers().length, before);
const receipt = await get(loc, viewer);
const receipt = await get(loc, viewer);
@@ -198,10 +202,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -198,10 +202,10 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('a forged cross-site form (no valid token) is refused', async () => {
await check('a forged cross-site form (no valid token) is refused', async () => {
- const r = await post('/alex/tip', viewer, { csrf: 'forged', idem: 'form-abcdefghijkl', amount: '10' });
+ const r = await post('/@alex/tip', viewer, { csrf: 'forged', idem: 'form-abcdefghijkl', amount: '10' });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
- const other = await get('/alex', bob);
- const r2 = await post('/alex/tip', viewer, { csrf: field(other.text, 'csrf'), idem: 'form-abcdefghijkm', amount: '10' });
+ const other = await get('/@alex', bob);
+ const r2 = await post('/@alex/tip', viewer, { csrf: field(other.text, 'csrf'), idem: 'form-abcdefghijkm', amount: '10' });
assert.strictEqual(r2.status, 403, 'a token minted for someone else does not work');
assert.strictEqual(r2.status, 403, 'a token minted for someone else does not work');
const d = await post('/dashboard/profile', alex, { csrf: 'nope', page_enabled: '0' });
const d = await post('/dashboard/profile', alex, { csrf: 'nope', page_enabled: '0' });
assert.strictEqual(d.status, 403);
assert.strictEqual(d.status, 403);
@@ -209,11 +213,11 @@ const { boot, check, done } = require('./helpers/app');
@@ -209,11 +213,11 @@ const { boot, check, done } = require('./helpers/app');
});
});
await check('the form reports Billing\'s refusal in words; checkout redirects to the provider', async () => {
await check('the form reports Billing\'s refusal in words; checkout redirects to the provider', async () => {
- const page = await get('/alex', viewer);
- const low = await post('/alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-low-balance-1', amount: '99999', pay_with: 'credit' });
+ const page = await get('/@alex', viewer);
+ const low = await post('/@alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-low-balance-1', amount: '99999', pay_with: 'credit' });
assert.strictEqual(low.status, 409);
assert.strictEqual(low.status, 409);
assert.match(low.text, /balance is too low/);
assert.match(low.text, /balance is too low/);
- const co = await post('/alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-checkout-0001', amount: '200', pay_with: 'checkout', provider: 'stripe' });
+ const co = await post('/@alex/tip', viewer, { csrf: field(page.text, 'csrf'), idem: 'form-checkout-0001', amount: '200', pay_with: 'checkout', provider: 'stripe' });
assert.strictEqual(co.status, 303);
assert.strictEqual(co.status, 303);
assert.match(co.headers.get('location'), /^https:\/\/checkout\.stripe\.test\//);
assert.match(co.headers.get('location'), /^https:\/\/checkout\.stripe\.test\//);
});
});
@@ -241,12 +245,13 @@ const { boot, check, done } = require('./helpers/app');
@@ -241,12 +245,13 @@ const { boot, check, done } = require('./helpers/app');
const off = await post('/dashboard/profile', alex, { csrf, idem: 'x', accepting: '1', revision: String((await domain.profiles.byHandle(t.db, 'alex')).revision) });
const off = await post('/dashboard/profile', alex, { csrf, idem: 'x', accepting: '1', revision: String((await domain.profiles.byHandle(t.db, 'alex')).revision) });
assert.strictEqual(off.status, 303);
assert.strictEqual(off.status, 303);
assert.strictEqual((await domain.profiles.byHandle(t.db, 'alex')).page_enabled, false, 'an unchecked box switches the page off');
assert.strictEqual((await domain.profiles.byHandle(t.db, 'alex')).page_enabled, false, 'an unchecked box switches the page off');
- assert.strictEqual((await get('/alex')).status, 404);
+ assert.strictEqual((await get('/@alex')).status, 404);
+ assert.strictEqual((await get('/alex')).status, 301, 'the switched-off page\'s old root link still moves to its one address');
});
});
await check('copy rules: no pricing claims ("free", "$0", "no ads") on any page', async () => {
await check('copy rules: no pricing claims ("free", "$0", "no ads") on any page', async () => {
await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page_enabled: true } });
await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page_enabled: true } });
- for (const [p, u] of [['/', null], ['/alex', viewer], ['/alex/goals', null], ['/dashboard', alex], ['/receipts', viewer]]) {
+ for (const [p, u] of [['/', null], ['/@alex', viewer], ['/@alex/goals', null], ['/dashboard', alex], ['/receipts', viewer]]) {
const r = await get(p, u);
const r = await get(p, u);
const visible = r.text.replace(/<style[\s\S]*?<\/style>/g, '').replace(/<script[\s\S]*?<\/script>/g, '');
const visible = r.text.replace(/<style[\s\S]*?<\/style>/g, '').replace(/<script[\s\S]*?<\/script>/g, '');
assert.ok(!COPY.test(visible), `${p} has forbidden copy: ${(visible.match(COPY) || [])[0]}`);
assert.ok(!COPY.test(visible), `${p} has forbidden copy: ${(visible.match(COPY) || [])[0]}`);
@@ -135,7 +135,7 @@ function payloadValidator(type) {
@@ -135,7 +135,7 @@ function payloadValidator(type) {
const list = (await t.call('GET', '/api/v1/goals?creator=alex', { token: null })).json.goals;
const list = (await t.call('GET', '/api/v1/goals?creator=alex', { token: null })).json.goals;
assert.strictEqual(list[0].current_amount, null);
assert.strictEqual(list[0].current_amount, null);
assert.ok((await t.call('GET', `/api/v1/goals/${goal.id}`, { user: alex })).json.goal.current_amount > 0, 'the creator sees everything');
assert.ok((await t.call('GET', `/api/v1/goals/${goal.id}`, { user: alex })).json.goal.current_amount > 0, 'the creator sees everything');
- const html = await fetch(`${t.base}/alex/goals`).then((x) => x.text());
+ const html = await fetch(`${t.base}/@alex/goals`).then((x) => x.text());
assert.ok(!/of 100,000 Vibes/.test(html), 'the page shows the percentage only');
assert.ok(!/of 100,000 Vibes/.test(html), 'the page shows the percentage only');
assert.match(html, /Other/);
assert.match(html, /Other/);
await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { goal_amounts: true } } });
await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { goal_amounts: true } } });
@@ -149,7 +149,7 @@ function payloadValidator(type) {
@@ -149,7 +149,7 @@ function payloadValidator(type) {
await check('the supporters page: off by default; the creator picks names, amounts and messages', async () => {
await check('the supporters page: off by default; the creator picks names, amounts and messages', async () => {
assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).status, 404);
assert.strictEqual((await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).status, 404);
- assert.strictEqual((await fetch(`${t.base}/alex/supporters`)).status, 404);
+ assert.strictEqual((await fetch(`${t.base}/@alex/supporters`)).status, 404);
await tip(other, { amount: 1000, supporter_name: 'Other', message: 'big fan' });
await tip(other, { amount: 1000, supporter_name: 'Other', message: 'big fan' });
await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { supporters_page: true } } });
await t.call('PATCH', '/api/v1/profiles/me', { user: alex, body: { page: { supporters_page: true } } });
let r = (await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json;
let r = (await t.call('GET', '/api/v1/profiles/alex/supporters', { token: null })).json;
@@ -166,19 +166,19 @@ function payloadValidator(type) {
@@ -166,19 +166,19 @@ function payloadValidator(type) {
assert.ok(!msgs.includes('just for you'), 'a private message never shows');
assert.ok(!msgs.includes('just for you'), 'a private message never shows');
assert.strictEqual(r.recent.find((v) => v.message === 'from nobody').supporter_name, 'Anonymous');
assert.strictEqual(r.recent.find((v) => v.message === 'from nobody').supporter_name, 'Anonymous');
assert.strictEqual(r.recent.find((v) => v.message === 'keep it quiet').amount, null);
assert.strictEqual(r.recent.find((v) => v.message === 'keep it quiet').amount, null);
- const html = await fetch(`${t.base}/alex/supporters`).then((x) => x.text());
+ const html = await fetch(`${t.base}/@alex/supporters`).then((x) => x.text());
assert.match(html, /big fan/);
assert.match(html, /big fan/);
assert.match(html, /<meta name="robots" content="index,follow">/);
assert.match(html, /<meta name="robots" content="index,follow">/);
assert.ok(!html.includes('just for you') && !html.includes('Viewer Real Name'));
assert.ok(!html.includes('just for you') && !html.includes('Viewer Real Name'));
- assert.match(await fetch(`${t.base}/alex`).then((x) => x.text()), /href="\/alex\/supporters"/);
+ assert.match(await fetch(`${t.base}/@alex`).then((x) => x.text()), /href="\/@alex\/supporters"/);
});
});
await check('the no-JS tip form carries the privacy choices', async () => {
await check('the no-JS tip form carries the privacy choices', async () => {
const cookie = `ov_token=${t.network.signUser(other)}`;
const cookie = `ov_token=${t.network.signUser(other)}`;
- const page = await fetch(`${t.base}/alex`, { headers: { Cookie: cookie } }).then((x) => x.text());
+ const page = await fetch(`${t.base}/@alex`, { headers: { Cookie: cookie } }).then((x) => x.text());
assert.match(page, /name="anonymous"/);
assert.match(page, /name="anonymous"/);
const csrf = page.match(/name="csrf" value="([^"]*)"/)[1];
const csrf = page.match(/name="csrf" value="([^"]*)"/)[1];
- const res = await fetch(`${t.base}/alex/tip`, {
+ const res = await fetch(`${t.base}/@alex/tip`, {
method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie },
method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie },
body: new URLSearchParams({ csrf, idem: 'form-privacy-00001', kind: 'tip', amount: '15', message: 'shh', pay_with: 'credit', supporter_name: 'Other', anonymous: '1', private_message: '1' }).toString(),
body: new URLSearchParams({ csrf, idem: 'form-privacy-00001', kind: 'tip', amount: '15', message: 'shh', pay_with: 'credit', supporter_name: 'Other', anonymous: '1', private_message: '1' }).toString(),
});
});
@@ -78,7 +78,7 @@ const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Bu
@@ -78,7 +78,7 @@ const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Bu
});
});
await check('switched off, nobody can buy a sound; switched on, the page offers them with a preview', async () => {
await check('switched off, nobody can buy a sound; switched on, the page offers them with a preview', async () => {
- assert.ok(!(await get('/alex', viewer)).text.includes('name="sound_id"'));
+ assert.ok(!(await get('/@alex', viewer)).text.includes('name="sound_id"'));
const off = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 100, sound: { id: horn.id } } });
const off = await t.call('POST', '/api/v1/sound-requests', { user: viewer, body: { creator: 'alex', amount: 100, sound: { id: horn.id } } });
assert.strictEqual(off.status, 409);
assert.strictEqual(off.status, 409);
assert.strictEqual(off.json.code, 'tips.sounds_disabled');
assert.strictEqual(off.json.code, 'tips.sounds_disabled');
@@ -86,7 +86,7 @@ const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Bu
@@ -86,7 +86,7 @@ const WAV = Buffer.concat([Buffer.from('RIFF'), Buffer.from([0x24, 0, 0, 0]), Bu
assert.strictEqual(on.status, 200, on.text);
assert.strictEqual(on.status, 200, on.text);
assert.deepStrictEqual(on.json.profile.sounds, { enabled: true });
assert.deepStrictEqual(on.json.profile.sounds, { enabled: true });
assert.strictEqual(on.json.profile.minimums.sound, 60);
assert.strictEqual(on.json.profile.minimums.sound, 60);
- const page = await get('/alex', viewer);
+ const page = await get('/@alex', viewer);
assert.match(page.text, /A sound \(played on stream\)/);
assert.match(page.text, /A sound \(played on stream\)/);
assert.match(page.text, new RegExp(`name="sound_id" value="${horn.id}" checked`));
assert.match(page.text, new RegExp(`name="sound_id" value="${horn.id}" checked`));
assert.match(page.text, new RegExp(`<audio controls preload="none" src="/sounds/${horn.id}">`));
assert.match(page.text, new RegExp(`<audio controls preload="none" src="/sounds/${horn.id}">`));
@@ -29,13 +29,13 @@ const { boot, check, done } = require('./helpers/app');
@@ -29,13 +29,13 @@ const { boot, check, done } = require('./helpers/app');
assert.match(r.text, /openvibe\.tips\/sam/);
assert.match(r.text, /openvibe\.tips\/sam/);
assert.match(r.text, /Turn on my tip page/);
assert.match(r.text, /Turn on my tip page/);
assert.match(r.text, /noindex/);
assert.match(r.text, /noindex/);
- assert.strictEqual((await req('GET', '/sam')).status, 404, 'the page is off until the creator turns it on');
+ 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 () => {
await check('a form without this site\'s anti-forgery field changes nothing', async () => {
const r = await req('POST', '/start', sam, { csrf: 'forged' });
const r = await req('POST', '/start', sam, { csrf: 'forged' });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
- assert.strictEqual((await req('GET', '/sam')).status, 404);
+ assert.strictEqual((await req('GET', '/@sam')).status, 404);
});
});
await check('the button turns the page on, accepting tips, and opens the dashboard', async () => {
await check('the button turns the page on, accepting tips, and opens the dashboard', async () => {
@@ -45,7 +45,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -45,7 +45,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.location, `/dashboard?done=${encodeURIComponent('Your tip page is on')}`);
assert.strictEqual(r.location, `/dashboard?done=${encodeURIComponent('Your tip page is on')}`);
const dash = await req('GET', r.location, sam);
const dash = await req('GET', r.location, sam);
assert.match(dash.text, /Your tip page is on/);
assert.match(dash.text, /Your tip page is on/);
- const pub = await req('GET', '/sam');
+ const pub = await req('GET', '/@sam');
assert.strictEqual(pub.status, 200);
assert.strictEqual(pub.status, 200);
const api = await t.call('GET', '/api/v1/profiles/sam', { token: null });
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]);
assert.deepStrictEqual([api.json.profile.page_enabled, api.json.profile.accepting], [true, true]);
@@ -50,7 +50,7 @@ const links = require('../server/domain/support-links');
@@ -50,7 +50,7 @@ const links = require('../server/domain/support-links');
});
});
await check('the tip page lists them as paid outside OpenVibe, nofollow and in a new tab', async () => {
await check('the tip page lists them as paid outside OpenVibe, nofollow and in a new tab', async () => {
- const page = await get('/tierra');
+ const page = await get('/@tierra');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.match(page.text, /Other ways to support/);
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, /<a class="btn ghost" href="https:\/\/ko-fi\.com\/tierra_tv" rel="nofollow noopener ugc" target="_blank">Ko-fi<\/a>/);