openvibers/openvibe.tips Commits

Creator pages at /@name; the root /name 301s there for the 301 window (#210)

OP OpenVibers committed 6 days ago · 83aed822c4 · 12 files, +120 −74

Changes

ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex e069324..77d419b 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -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;
ddiff --git a/server/app.js b/server/app.js
iindex 23bb86e..d264de0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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
ddiff --git a/server/domain/profiles.js b/server/domain/profiles.js
iindex 5da8c0b..b182d55 100644
---- a/server/domain/profiles.js
++++ b/server/domain/profiles.js
@@@ -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 });
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 695377f..c466792 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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, e
const 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>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 9422d80..ff59255 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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: a
const 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: a
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);
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: a
const 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,
ddiff --git a/test/indexnow.test.js b/test/indexnow.test.js
iindex ce70c1a..921881d 100644
---- a/test/indexnow.test.js
++++ b/test/indexnow.test.js
@@@ -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));
});
ddiff --git a/test/moderation.test.js b/test/moderation.test.js
iindex ba9c07e..412c7c8 100644
---- a/test/moderation.test.js
++++ b/test/moderation.test.js
@@@ -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]);
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex fefbc8d..3e856dd 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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]}`);
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
iindex a26b9ed..e903452 100644
---- a/test/privacy.test.js
++++ b/test/privacy.test.js
@@@ -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(),
});
ddiff --git a/test/sounds.test.js b/test/sounds.test.js
iindex 8983e79..6a2f1e0 100644
---- a/test/sounds.test.js
++++ b/test/sounds.test.js
@@@ -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]), Bu
assert.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}">`));
ddiff --git a/test/start.test.js b/test/start.test.js
iindex ee7123b..9a7273b 100644
---- a/test/start.test.js
++++ b/test/start.test.js
@@@ -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]);
ddiff --git a/test/support-links.test.js b/test/support-links.test.js
iindex 04a52b0..f8e3ff3 100644
---- a/test/support-links.test.js
++++ b/test/support-links.test.js
@@@ -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>/);