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
Changes
@@@ -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');}}@@@ -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>`;@@@ -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,@@@ -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 = {@@@ -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 */ }}@@@ -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 */ }@@@ -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 };@@@ -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 });
@@ -6495,14 +6495,8 @@ function openViewerPreview() {
@@ -6495,14 +6495,8 @@ function openViewerPreview() {
const ss = streamId ? getStreamState(streamId) : null;
const ss = streamId ? getStreamState(streamId) : null;
const streamData = ss?.streamData || null;
const streamData = ss?.streamData || null;
const slug = streamData?.slug || 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(
_viewerPreviewWindow = window.open(
url,
url,
'viewer-preview',
'viewer-preview',
@@ -6516,7 +6510,7 @@ function popoutBroadcastChat() {
@@ -6516,7 +6510,7 @@ function popoutBroadcastChat() {
if (typeof popoutStreamChat === 'function') {
if (typeof popoutStreamChat === 'function') {
popoutStreamChat();
popoutStreamChat();
} else if (currentUser?.username) {
} else if (currentUser?.username) {
- window.open(`/${currentUser.username}`, '_blank', 'width=400,height=700');
+ window.open(channelPath(currentUser.username), '_blank', 'width=400,height=700');
}
}
}
}
@@ -7641,7 +7641,7 @@ function renderChatUsersList(users) {
@@ -7641,7 +7641,7 @@ function renderChatUsersList(users) {
? `<img src="${esc(u.avatar_url)}" class="chat-users-avatar" alt="" loading="lazy">`
? `<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>`;
: `<div class="chat-users-avatar chat-users-avatar-default"><i class="fa-solid fa-user"></i></div>`;
const badge = getBadgeHTML(u.role);
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) {
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>`;
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>`;
@@ -39,7 +39,7 @@ function envelopeFor(kind, streamId) {
@@ -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);
FROM streams s JOIN users u ON u.id = s.user_id WHERE s.id = ?`).get(streamId);
if (!s) return null;
if (!s) return null;
const subjectId = identity.subjectOf(s.user_id);
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 };
if (subjectId) channel.subject = { type: 'user', id: subjectId };
const payload = {
const payload = {
stream_id: s.id,
stream_id: s.id,
@@ -28,7 +28,7 @@ async function notifyDiscordGoLive(streamer, stream) {
@@ -28,7 +28,7 @@ async function notifyDiscordGoLive(streamer, stream) {
if (lastNotify && (Date.now() - lastNotify) < cooldownMs) return;
if (lastNotify && (Date.now() - lastNotify) < cooldownMs) return;
const displayName = streamer.display_name || streamer.username;
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 title = stream.title || 'Started streaming';
const embed = {
const embed = {
@@ -378,7 +378,7 @@ function _renewNotify(sub, message) {
@@ -378,7 +378,7 @@ function _renewNotify(sub, message) {
pushNotification({
pushNotification({
user_id: sub.subscriber_id, type: 'PAYMENT', title: 'Subscription update',
user_id: sub.subscriber_id, type: 'PAYMENT', title: 'Subscription update',
message: `${streamer ? (streamer.display_name || streamer.username) + ': ' : ''}${message}`,
message: `${streamer ? (streamer.display_name || streamer.username) + ': ' : ''}${message}`,
- url: streamer ? `/${streamer.username}` : '/',
+ url: streamer ? `/@${encodeURIComponent(streamer.username)}` : '/',
});
});
} catch { /* optional */ }
} catch { /* optional */ }
}
}
@@ -2336,7 +2336,7 @@ router.post('/:id/follow', requireAuth, (req, res) => {
@@ -2336,7 +2336,7 @@ router.post('/:id/follow', requireAuth, (req, res) => {
type: 'FOLLOW',
type: 'FOLLOW',
title: 'New Follower',
title: 'New Follower',
message: `${follower?.display_name || follower?.username || 'Someone'} followed you`,
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),
...actorInfo(follower),
});
});
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -2381,7 +2381,7 @@ router.post('/channel/:username/follow', requireAuth, (req, res) => {
@@ -2381,7 +2381,7 @@ router.post('/channel/:username/follow', requireAuth, (req, res) => {
type: 'FOLLOW',
type: 'FOLLOW',
title: 'New Follower',
title: 'New Follower',
message: `${follower?.display_name || follower?.username || 'Someone'} followed you`,
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),
...actorInfo(follower),
});
});
} catch { /* non-critical */ }
} catch { /* non-critical */ }
@@ -137,6 +137,25 @@ function channelStatus(segment) {
@@ -137,6 +137,25 @@ function channelStatus(segment) {
} catch { return OK; }
} 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) {
function streamStatus(id) {
if (!ID_RE.test(id)) return NOT_FOUND;
if (!ID_RE.test(id)) return NOT_FOUND;
try { return db.getStreamById(Number(id)) ? OK : NOT_FOUND; } catch { return OK; }
try { return db.getStreamById(Number(id)) ? OK : NOT_FOUND; } catch { return OK; }
@@ -182,6 +201,8 @@ function spaFallback(sendShell) {
@@ -182,6 +201,8 @@ function spaFallback(sendShell) {
}
}
const status = await statusFor(req);
const status = await statusFor(req);
if (res.headersSent) return;
if (res.headersSent) return;
+ const channel = status === NOT_FOUND ? legacyChannelPath(req) : null;
+ if (channel) return res.redirect(301, channel);
try {
try {
res.status(status);
res.status(status);
if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');
if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');
@@ -192,4 +213,4 @@ function spaFallback(sendShell) {
@@ -192,4 +213,4 @@ function spaFallback(sendShell) {
/** Forget cached lookups (tests; or after a change that must show at once). */
/** Forget cached lookups (tests; or after a change that must show at once). */
function clearCache() { _cache.clear(); }
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 };
@@ -105,7 +105,7 @@ function get(p, { user, html = false, method = 'GET' } = {}) {
@@ -105,7 +105,7 @@ function get(p, { user, html = false, method = 'GET' } = {}) {
const req = http.request(base + p, { method, headers }, (res) => {
const req = http.request(base + p, { method, headers }, (res) => {
let body = '';
let body = '';
res.on('data', (c) => { body += c; });
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.on('error', reject);
req.end();
req.end();
@@ -145,12 +145,23 @@ server.listen(0, '127.0.0.1', async () => {
@@ -145,12 +145,23 @@ server.listen(0, '127.0.0.1', async () => {
await check('unknown paths answer 404', () => expectAll([
await check('unknown paths answer 404', () => expectAll([
'/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1',
'/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',
'/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',
'/p/missing', '/p/bad!slug', '/p/abc123/extra',
'/p/missing', '/p/bad!slug', '/p/abc123/extra',
'/recap/999999', '/recap/x', '/stream/999999', '/stream',
'/recap/999999', '/recap/x', '/stream/999999', '/stream',
], 404));
], 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 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);
await expectAll(['/vod/101', '/vod/102', '/clip/201'], 404, { user: 7 });
await expectAll(['/vod/101', '/vod/102', '/clip/201'], 404, { user: 7 });