openvibers/openvibe.live Commits

Renamed channels (WS-B task 6): Live follows a Network rename (the signed token's username at sign-in, or GET /api/v1/users/names/:name for a /@name it does not know yet), keeps username_history, and the page fallback answers /@old with a 301 to /@current (sub-path and query kept). A bare /name stays a 404; a name another Live user holds is never taken. test/renamed-channels.test.js

OP OpenVibers committed 24 Sept 2026 · 0aa56ad041 · 4 files, +196 −2


Changes

ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex ac8cfaab..a4686ca9 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -99,6 +99,11 @@ const _ROLE_RANK = { user: 0, streamer: 1, global_mod: 2, admin: 3 };
function _syncSsoUserFields(user, decoded) {
if (!user || !decoded) return user;
++ // Renamed on the Network (staff only; the old name stays theirs there): follow it here, and
++ // /@old keeps working as a 301 (server/auth/usernames.js, WS-B task 6).
++ if (typeof decoded.username === 'string' && user.username && decoded.username.toLowerCase() !== String(user.username).toLowerCase()) {
++ try { if (require('./usernames').syncUsername(user.id, decoded.username)) user = db.getUserById(user.id) || user; } catch { /* keep the old name */ }
++ }
const updates = [];
const params = [];
if (decoded.avatar_url && decoded.avatar_url !== user.avatar_url) { updates.push('avatar_url = ?'); params.push(decoded.avatar_url); }
ddiff --git a/server/auth/usernames.js b/server/auth/usernames.js
nnew file mode 100644
iindex 00000000..e8a37852
---- /dev/null
++++ b/server/auth/usernames.js
@@@ -0,0 +1,103 @@
++'use strict';
++/**
++ * Renamed channels (roadmap WS-B task 6). OpenVibe.Network owns usernames and keeps their history
++ * (staff rename people there; the old name stays reserved). Live follows:
++ *
++ * - a Network token whose username differs from the linked Live user's renames them here too
++ * (auth.js _syncSsoUserFields), recorded in username_history;
++ * - /@name that Live does not know asks Network (GET /api/v1/users/names/:name): an old name
++ * answers 301 → /@current (server/web/page-status.js), and a new name Live has not seen yet is
++ * picked up at once, before the person's next sign-in.
++ *
++ * Only /@name redirects. A bare /name is never a channel URL and stays a 404.
++ */
++const db = require('../db/database');
++const config = require('../config');
++
++const NAME_RE = /^[A-Za-z0-9_]{3,24}$/;
++const LOOKUP_TTL_MS = 60_000;
++let ready = false;
++const _lookups = new Map(); // lowercased name → { at, value: record|null }
++
++function ensureSchema() {
++ if (ready) return;
++ db.run(`CREATE TABLE IF NOT EXISTS username_history (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ old_username TEXT NOT NULL,
++ new_username TEXT NOT NULL,
++ changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
++ )`);
++ db.run('CREATE INDEX IF NOT EXISTS idx_username_history_old ON username_history(old_username COLLATE NOCASE)');
++ ready = true;
++}
++
++/**
++ * Rename a Live user to the Network's name for them, unless only the case differs or another Live
++ * user already holds it. Returns true when it changed.
++ */
++function syncUsername(userId, newName) {
++ if (!userId || !NAME_RE.test(String(newName || ''))) return false;
++ const user = db.get('SELECT id, username FROM users WHERE id = ?', [userId]);
++ if (!user || String(user.username || '').toLowerCase() === newName.toLowerCase()) return false;
++ const clash = db.get('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?', [newName, userId]);
++ if (clash) { console.warn(`[Usernames] ${user.username} → ${newName}: another Live user holds that name; not renamed`); return false; }
++ ensureSchema();
++ db.getDb().transaction(() => {
++ db.run('INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, ?, ?)', [user.id, user.username, newName]);
++ db.run('UPDATE users SET username = ? WHERE id = ?', [newName, user.id]);
++ })();
++ console.log(`[Usernames] renamed ${user.username} → ${newName} (Network)`);
++ return true;
++}
++
++/** The current Live username for an old one, from Live's own history (null when unknown or reused). */
++function localRenamedTo(name) {
++ if (!NAME_RE.test(String(name || ''))) return null;
++ ensureSchema();
++ if (db.get('SELECT 1 AS x FROM users WHERE username = ? COLLATE NOCASE', [name])) return null;
++ const row = db.get(`SELECT u.username FROM username_history h JOIN users u ON u.id = h.user_id
++ WHERE h.old_username = ? COLLATE NOCASE ORDER BY h.id DESC LIMIT 1`, [name]);
++ return row ? row.username : null;
++}
++
++async function networkRecord(name, { fetchImpl = globalThis.fetch, timeoutMs = 1500 } = {}) {
++ const key = name.toLowerCase();
++ const hit = _lookups.get(key);
++ if (hit && Date.now() - hit.at < LOOKUP_TTL_MS) return hit.value;
++ let value = null;
++ try {
++ const base = String(config.openvibeToolsInternalUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ const r = await fetchImpl(`${base}/api/v1/users/names/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(timeoutMs) });
++ if (r.ok) {
++ const b = await r.json();
++ if (b && NAME_RE.test(String(b.current || '')) && Number.isInteger(b.network_id)) value = b;
++ } else if (r.status !== 404) return null; // an outage is not a verdict: do not cache it
++ } catch { return null; }
++ if (_lookups.size > 5000) _lookups.clear();
++ _lookups.set(key, { at: Date.now(), value });
++ return value;
++}
++
++/**
++ * For a /@name Live does not know: the username to send the visitor to (the current name of a
++ * renamed channel, or `name` itself once a new name was picked up), or null for a real 404.
++ */
++async function resolveUnknownChannel(name, opts = {}) {
++ if (!NAME_RE.test(String(name || ''))) return null;
++ const local = localRenamedTo(name);
++ if (local) return local;
++ const rec = await networkRecord(name, opts);
++ if (!rec) return null;
++ const linked = db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND service_user_id = ? ORDER BY id DESC LIMIT 1", [String(rec.network_id)]);
++ if (!linked) return null;
++ const user = db.get('SELECT id, username FROM users WHERE id = ?', [linked.user_id]);
++ if (!user) return null;
++ if (String(user.username).toLowerCase() !== rec.current.toLowerCase()) syncUsername(user.id, rec.current);
++ const now = db.get('SELECT username FROM users WHERE id = ?', [user.id]);
++ return now ? now.username : null;
++}
++
++function _reset() { _lookups.clear(); ready = false; }
++
++module.exports = { syncUsername, localRenamedTo, resolveUnknownChannel, ensureSchema, _reset, NAME_RE };
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex 4869f06d..97ed11f8 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -17,7 +17,8 @@
* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)
* /settings /admin /themes exact path (the client redirects them)
* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
-- * /@user /@user/<slot> a user that exists
++ * /@user /@user/<slot> a user that exists; a renamed one's old name
++ * answers 301 → /@current (never a bare /name)
* /vod/:id /clip/:id a Media item this visitor may see — a private
* one is missing to everyone but its owners and
* staff (server/media-proxy/access.js)
@@@ -184,8 +185,22 @@ function spaFallback(sendShell) {
if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {
return res.status(404).json({ error: 'Not found' });
}
-- const status = await statusFor(req);
++ let status = await statusFor(req);
if (res.headersSent) return;
++ if (status === NOT_FOUND) {
++ // A renamed channel (Network username history): /@old → 301 /@current, and a new name Live
++ // has not seen yet is picked up now. Only /@ paths: a bare /name is never a channel URL.
++ const m = /^\/@+([A-Za-z0-9_]{3,24})(\/[^/]+)?\/?$/.exec(req.path);
++ if (m) {
++ const target = await require('../auth/usernames').resolveUnknownChannel(m[1]).catch(() => null);
++ if (res.headersSent) return;
++ if (target && target.toLowerCase() !== m[1].toLowerCase()) {
++ const q = req.originalUrl.indexOf('?');
++ return res.redirect(301, `/@${target}${m[2] || ''}${q >= 0 ? req.originalUrl.slice(q) : ''}`);
++ }
++ if (target) status = OK;
++ }
++ }
try {
res.status(status);
if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');
ddiff --git a/test/renamed-channels.test.js b/test/renamed-channels.test.js
nnew file mode 100644
iindex 00000000..b90c3dcf
---- /dev/null
++++ b/test/renamed-channels.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++// Renamed channels (WS-B task 6): Live follows a Network rename (from the signed token, or from the
++// Network names lookup for a name it has not seen), keeps a history, and /@old answers 301 → /@current
++// (sub-path and query kept); a bare /old stays a 404, unknown names stay 404, clashes never rename.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-live-rename-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++(async () => {
++ // Stub Network: GET /api/v1/users/names/:name
++ const names = { carl_2: { current: 'carl_2', network_id: 88, renamed: false, previous_names: ['carl'] } };
++ const net = http.createServer((req, res) => {
++ const m = /^\/api\/v1\/users\/names\/([^/?]+)/.exec(req.url);
++ const rec = m && names[decodeURIComponent(m[1]).toLowerCase()];
++ res.writeHead(rec ? 200 : 404, { 'content-type': 'application/json' });
++ res.end(JSON.stringify(rec || { error: 'not_found' }));
++ });
++ await new Promise((r) => net.listen(0, '127.0.0.1', r));
++ process.env.OV_NETWORK_INTERNAL_URL = `http://127.0.0.1:${net.address().port}`;
++
++ const express = require('express');
++ const db = require('../server/db/database');
++ db.initDb();
++ const usernames = require('../server/auth/usernames');
++ const auth = require('../server/auth/auth');
++ const pageStatus = require('../server/web/page-status');
++ const add = (name, networkId) => {
++ const id = db.getDb().prepare("INSERT INTO users (username, password_hash, display_name) VALUES (?, 'x', ?)").run(name, name).lastInsertRowid;
++ if (networkId) db.getDb().prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, service_username) VALUES (?, 'network', ?, ?)").run(id, String(networkId), name);
++ return id;
++ };
++ const ann = add('ann_old', 57), bob = add('bob', 58), carl = add('carl', 88);
++
++ const app = express();
++ app.get('*', pageStatus.spaFallback((res) => { res.type('html').send('<shell>'); return true; }));
++ const srv = http.createServer(app);
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const get = (p) => fetch(`http://127.0.0.1:${srv.address().port}${p}`, { redirect: 'manual' }).then((r) => ({ status: r.status, location: r.headers.get('location') }));
++ try {
++ // Follow a rename carried by the signed token (resolveNetworkUser runs the SSO field sync).
++ const u = auth.resolveNetworkUser({ sub: 57, id: 57, username: 'ann_new', role: 'user' });
++ assert.strictEqual(u && u.username, 'ann_new');
++ assert.strictEqual(usernames.localRenamedTo('ANN_OLD'), 'ann_new');
++ assert.strictEqual(usernames.syncUsername(bob, 'ann_new'), false, 'never onto someone else\'s name');
++ assert.strictEqual(usernames.syncUsername(ann, 'ANN_NEW'), false, 'case-only is not a rename');
++
++ assert.deepStrictEqual(await get('/@ann_new'), { status: 200, location: null });
++ assert.deepStrictEqual(await get('/@ann_old'), { status: 301, location: '/@ann_new' });
++ assert.deepStrictEqual(await get('/@ann_old/slot2?ref=hat'), { status: 301, location: '/@ann_new/slot2?ref=hat' });
++ assert.strictEqual((await get('/ann_old')).status, 404, 'a bare /name is never a channel URL');
++ assert.strictEqual((await get('/@nobody_here')).status, 404);
++
++ // A new name Live has not seen yet: picked up from the Network, and the old one redirects.
++ assert.strictEqual((await get('/@carl_2')).status, 200);
++ assert.strictEqual(db.getUserById(carl).username, 'carl_2');
++ assert.deepStrictEqual(await get('/@carl'), { status: 301, location: '/@carl_2' });
++ } finally {
++ srv.close(); net.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ quiet('renamed channels: all checks passed');
++})().catch((e) => { console.error = quiet; quiet(e); process.exit(1); });