openvibers/openvibe.live Commits

Channel links carry the @: live.stream.started/ended's channel.url (what Network's go-live notifications link to), the Discord go-live post, new-follower and payment notifications, the broadcast page's chat popout and viewer preview (which also linked to /watch/<id>, a page that never existed) and the chat user list (whose click handler called showPage with a path) all pointed at /<username>, which showed the home page. A path that names an existing user without the @ (/<username>[/<slot>], query kept) now answers 301 to /@<username>, so links already sent in notifications and Discord land on the channel; route names are never taken for users

OP OpenVibers committed 23 Sept 2026 · 4c703326b2 · 8 files, +44 −18


Changes

ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 4610588a..6401ba12 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -6495,14 +6495,8 @@ function openViewerPreview() {
const ss = streamId ? getStreamState(streamId) : null;
const streamData = ss?.streamData || null;
const slug = streamData?.slug || null;
-- let url;
-- if (streamId) {
-- url = slug && currentUser?.username
-- ? `/${currentUser.username}/${slug}`
-- : `/watch/${streamId}`;
-- } else {
-- url = `/${currentUser?.username || ''}`;
-- }
++ // The channel page for this slot (by slug, else by id); /watch/<id> never existed.
++ const url = currentUser?.username ? channelPath(currentUser.username, streamId ? (slug || streamId) : null) : '/';
_viewerPreviewWindow = window.open(
url,
'viewer-preview',
@@@ -6516,7 +6510,7 @@ function popoutBroadcastChat() {
if (typeof popoutStreamChat === 'function') {
popoutStreamChat();
} else if (currentUser?.username) {
-- window.open(`/${currentUser.username}`, '_blank', 'width=400,height=700');
++ window.open(channelPath(currentUser.username), '_blank', 'width=400,height=700');
}
}
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 7eb9d1b1..5ed4658b 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -7641,7 +7641,7 @@ function renderChatUsersList(users) {
? `<img src="${esc(u.avatar_url)}" class="chat-users-avatar" alt="" loading="lazy">`
: `<div class="chat-users-avatar chat-users-avatar-default"><i class="fa-solid fa-user"></i></div>`;
const badge = getBadgeHTML(u.role);
-- listHtml += `<div class="chat-users-row"><a href="/${esc(u.username)}" class="chat-users-link" onclick="event.preventDefault(); if(typeof showPage==='function') showPage('/${esc(u.username)}')">${avatar}<span class="chat-users-name">${esc(u.display_name)}</span>${badge}</a></div>`;
++ listHtml += `<div class="chat-users-row"><a href="/@${esc(u.username)}" class="chat-users-link" onclick="if(typeof handleLinkClick==='function') handleLinkClick(event, this.getAttribute('href'))">${avatar}<span class="chat-users-name">${esc(u.display_name)}</span>${badge}</a></div>`;
}
if (anonCount > 0) {
listHtml += `<div class="chat-users-row chat-users-anon"><div class="chat-users-avatar chat-users-avatar-default"><i class="fa-solid fa-user-secret"></i></div><span class="chat-users-name">${anonCount} anonymous viewer${anonCount !== 1 ? 's' : ''}</span></div>`;
ddiff --git a/server/events/stream-events.js b/server/events/stream-events.js
iindex 86606c09..31103d90 100644
---- a/server/events/stream-events.js
++++ b/server/events/stream-events.js
@@@ -39,7 +39,7 @@ function envelopeFor(kind, streamId) {
FROM streams s JOIN users u ON u.id = s.user_id WHERE s.id = ?`).get(streamId);
if (!s) return null;
const subjectId = identity.subjectOf(s.user_id);
-- const channel = { username: s.username, display_name: s.display_name || s.username, url: `https://openvibe.live/${encodeURIComponent(s.username)}` };
++ const channel = { username: s.username, display_name: s.display_name || s.username, url: `https://openvibe.live/@${encodeURIComponent(s.username)}` };
if (subjectId) channel.subject = { type: 'user', id: subjectId };
const payload = {
stream_id: s.id,
ddiff --git a/server/integrations/discord-webhook.js b/server/integrations/discord-webhook.js
iindex 93e03d58..2ac807f1 100644
---- a/server/integrations/discord-webhook.js
++++ b/server/integrations/discord-webhook.js
@@@ -28,7 +28,7 @@ async function notifyDiscordGoLive(streamer, stream) {
if (lastNotify && (Date.now() - lastNotify) < cooldownMs) return;
const displayName = streamer.display_name || streamer.username;
-- const streamUrl = `https://openvibe.live/${streamer.username}`;
++ const streamUrl = `https://openvibe.live/@${encodeURIComponent(streamer.username)}`;
const title = stream.title || 'Started streaming';
const embed = {
ddiff --git a/server/monetization/payments.js b/server/monetization/payments.js
iindex f9e7c880..0c72f29c 100644
---- a/server/monetization/payments.js
++++ b/server/monetization/payments.js
@@@ -378,7 +378,7 @@ function _renewNotify(sub, message) {
pushNotification({
user_id: sub.subscriber_id, type: 'PAYMENT', title: 'Subscription update',
message: `${streamer ? (streamer.display_name || streamer.username) + ': ' : ''}${message}`,
-- url: streamer ? `/${streamer.username}` : '/',
++ url: streamer ? `/@${encodeURIComponent(streamer.username)}` : '/',
});
} catch { /* optional */ }
}
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 41cbbac3..f8e783f2 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -2336,7 +2336,7 @@ router.post('/:id/follow', requireAuth, (req, res) => {
type: 'FOLLOW',
title: 'New Follower',
message: `${follower?.display_name || follower?.username || 'Someone'} followed you`,
-- url: `${config.baseUrl}/${follower?.username || ''}`,
++ url: follower?.username ? `${config.baseUrl}/@${encodeURIComponent(follower.username)}` : config.baseUrl,
...actorInfo(follower),
});
} catch { /* non-critical */ }
@@@ -2381,7 +2381,7 @@ router.post('/channel/:username/follow', requireAuth, (req, res) => {
type: 'FOLLOW',
title: 'New Follower',
message: `${follower?.display_name || follower?.username || 'Someone'} followed you`,
-- url: `${config.baseUrl}/${follower?.username || ''}`,
++ url: follower?.username ? `${config.baseUrl}/@${encodeURIComponent(follower.username)}` : config.baseUrl,
...actorInfo(follower),
});
} catch { /* non-critical */ }
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex 470c970f..a9a24ef6 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -137,6 +137,25 @@ function channelStatus(segment) {
} catch { return OK; }
}
++/**
++ * Channel links used to be written without the @ (openvibe.live/<username>, in go-live
++ * notifications, Discord posts and follower notices): the canonical `/@<username>[/<slot>]` for
++ * a path that names an existing user that way, else null. Only asked for paths no route knows.
++ */
++const LEGACY_CHANNEL_RE = /^[A-Za-z0-9_]{3,24}$/;
++function legacyChannelPath(req) {
++ const segs = String(req.path || '/').split('/').filter(Boolean);
++ if (!segs.length || segs.length > 2 || !LEGACY_CHANNEL_RE.test(segs[0])) return null;
++ if (segs[1] !== undefined && !SLUG_RE.test(segs[1])) return null;
++ let row = null;
++ try { row = db.getChannelByUsername(segs[0]) || db.getUserByUsername(segs[0]); } catch { return null; }
++ const name = row && row.username;
++ if (!name || !LEGACY_CHANNEL_RE.test(name)) return null;
++ const q = String(req.originalUrl || req.url || '').indexOf('?');
++ const query = q >= 0 ? String(req.originalUrl || req.url).slice(q) : '';
++ return `/@${name}${segs[1] ? `/${segs[1]}` : ''}${query}`;
++}
++
function streamStatus(id) {
if (!ID_RE.test(id)) return NOT_FOUND;
try { return db.getStreamById(Number(id)) ? OK : NOT_FOUND; } catch { return OK; }
@@@ -182,6 +201,8 @@ function spaFallback(sendShell) {
}
const status = await statusFor(req);
if (res.headersSent) return;
++ const channel = status === NOT_FOUND ? legacyChannelPath(req) : null;
++ if (channel) return res.redirect(301, channel);
try {
res.status(status);
if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');
@@@ -192,4 +213,4 @@ function spaFallback(sendShell) {
/** Forget cached lookups (tests; or after a change that must show at once). */
function clearCache() { _cache.clear(); }
--module.exports = { statusFor, spaFallback, clearCache, EXACT, PREFIX, LOOKUP_DEADLINE_MS };
++module.exports = { statusFor, spaFallback, legacyChannelPath, clearCache, EXACT, PREFIX, LOOKUP_DEADLINE_MS };
ddiff --git a/test/page-status.test.js b/test/page-status.test.js
iindex 6375d13e..be7e5e48 100644
---- a/test/page-status.test.js
++++ b/test/page-status.test.js
@@@ -105,7 +105,7 @@ function get(p, { user, html = false, method = 'GET' } = {}) {
const req = http.request(base + p, { method, headers }, (res) => {
let body = '';
res.on('data', (c) => { body += c; });
-- res.on('end', () => resolve({ status: res.statusCode, body, type: res.headers['content-type'] || '' }));
++ res.on('end', () => resolve({ status: res.statusCode, body, type: res.headers['content-type'] || '', location: res.headers.location || null }));
});
req.on('error', reject);
req.end();
@@@ -145,12 +145,23 @@ server.listen(0, '127.0.0.1', async () => {
await check('unknown paths answer 404', () => expectAll([
'/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1',
-- '/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/alice',
++ '/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/nobody', '/nobody/main', '/alice/main/extra', '/alice/bad!slot',
'/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',
'/p/missing', '/p/bad!slug', '/p/abc123/extra',
'/recap/999999', '/recap/x', '/stream/999999', '/stream',
], 404));
++ await check('a channel link without the @ redirects to the channel page (old notifications)', async () => {
++ for (const [from, to] of [['/alice', '/@alice'], ['/Alice/', '/@alice'], ['/alice/main', '/@alice/main'], ['/bob?stream=5', '/@bob?stream=5']]) {
++ for (const html of [false, true]) {
++ const r = await get(from, { html });
++ assert.strictEqual(r.status, 301, `${from}${html ? ' (html)' : ''} → ${r.status}`);
++ assert.strictEqual(r.location, to, `${from} → ${r.location}`);
++ }
++ }
++ assert.strictEqual((await get('/vods')).status, 200, 'a route name is never taken for a user');
++ });
++
await check('a private VOD or clip is a 404 to strangers, with or without a sign-in', async () => {
await expectAll(['/vod/101', '/vod/102', '/clip/201'], 404);
await expectAll(['/vod/101', '/vod/102', '/clip/201'], 404, { user: 7 });