feat: add multi-user support for favorites management and room clock synchronization

- Implemented a new API endpoint for retrieving and managing user favorites in /api/users.
- Added functionality for admins to edit the shared "main" user's favorites.
- Created a one-shot DB smoke test script for verifying multi-user kiosk migrations.
- Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
This commit is contained in:
Marco Mooren
2026-05-13 13:53:12 +02:00
parent f6cdfd975c
commit 29423288ca
41 changed files with 4229 additions and 275 deletions

View File

@@ -3,7 +3,9 @@ import { randomBytes } from 'node:crypto';
import { getDb } from './db/index.js';
const SESSION_DAYS = 30;
const DEVICE_DAYS = 365;
const COOKIE_NAME = 'oradio_sid';
const DEVICE_COOKIE = 'oradio_device';
export function hashPassword(plain) {
return bcrypt.hashSync(plain, 10);
@@ -27,7 +29,7 @@ export function destroySession(token) {
export function getUserBySession(token) {
if (!token) return null;
return getDb().prepare(`
SELECT u.id, u.username, u.role
SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > datetime('now')
`).get(token);
@@ -50,11 +52,18 @@ export function setSessionCookie(res, token, expires) {
'SameSite=Lax',
`Expires=${new Date(expires).toUTCString()}`
];
res.setHeader('Set-Cookie', attrs.join('; '));
appendSetCookieRaw(res, attrs.join('; '));
}
export function clearSessionCookie(res) {
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
appendSetCookieRaw(res, `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
}
function appendSetCookieRaw(res, value) {
const prev = res.getHeader('Set-Cookie');
if (!prev) res.setHeader('Set-Cookie', value);
else if (Array.isArray(prev)) res.setHeader('Set-Cookie', [...prev, value]);
else res.setHeader('Set-Cookie', [prev, value]);
}
export function authMiddleware(req, _res, next) {
@@ -86,3 +95,107 @@ export function ensureBootstrapAdmin({ username, password }) {
.run(info.lastInsertRowid, username);
console.log(`[auth] bootstrap admin '${username}' created`);
}
/**
* Ensure exactly one user is flagged `is_main = 1`. Resolution order:
* 1. The user named `mainUsername` (if it exists)
* 2. The current main user (if any)
* 3. The first admin
* 4. The first user
* Idempotent.
*/
export function ensureMainUser(mainUsername) {
const db = getDb();
let main = null;
if (mainUsername) {
main = db.prepare('SELECT id, username FROM users WHERE username = ?').get(mainUsername);
}
if (!main) main = db.prepare('SELECT id, username FROM users WHERE is_main = 1').get();
if (!main) main = db.prepare("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get();
if (!main) main = db.prepare('SELECT id, username FROM users ORDER BY id LIMIT 1').get();
if (!main) return null;
db.transaction(() => {
db.prepare('UPDATE users SET is_main = 0 WHERE is_main = 1 AND id != ?').run(main.id);
db.prepare('UPDATE users SET is_main = 1 WHERE id = ?').run(main.id);
})();
console.log(`[auth] main user is '${main.username}' (id=${main.id})`);
return main;
}
/** Read the public-shape main user, or null. */
export function getMainUser() {
const row = getDb().prepare(
'SELECT id, username, avatar_color, avatar_emoji FROM users WHERE is_main = 1'
).get();
return row || null;
}
// ---------- Trusted devices (fast user switching) ----------
export function readDeviceToken(req) {
const raw = req.headers.cookie || '';
for (const part of raw.split(';')) {
const [k, v] = part.trim().split('=');
if (k === DEVICE_COOKIE) return decodeURIComponent(v || '');
}
return null;
}
export function setDeviceCookie(res, token) {
const expires = new Date(Date.now() + DEVICE_DAYS * 86400e3);
const attrs = [
`${DEVICE_COOKIE}=${encodeURIComponent(token)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Expires=${expires.toUTCString()}`
];
appendSetCookieRaw(res, attrs.join('; '));
}
function appendSetCookie(res, value) {
const prev = res.getHeader('Set-Cookie');
if (!prev) res.setHeader('Set-Cookie', value);
else if (Array.isArray(prev)) res.setHeader('Set-Cookie', [...prev, value]);
else res.setHeader('Set-Cookie', [prev, value]);
}
/** Return the trusted device row matching the request's device cookie, or null. */
export function getTrustedDevice(req) {
const token = readDeviceToken(req);
if (!token) return null;
const db = getDb();
const dev = db.prepare('SELECT * FROM kiosk_devices WHERE token = ?').get(token);
if (!dev) return null;
db.prepare("UPDATE kiosk_devices SET last_seen_at = datetime('now') WHERE id = ?").run(dev.id);
return dev;
}
/** Return the list of users this device is allowed to fast-switch to. */
export function listDeviceUsers(deviceId) {
return getDb().prepare(`
SELECT u.id, u.username, u.role, u.is_main, u.avatar_color, u.avatar_emoji
FROM kiosk_device_users kdu JOIN users u ON u.id = kdu.user_id
WHERE kdu.device_id = ?
ORDER BY u.is_main DESC, u.username COLLATE NOCASE
`).all(deviceId);
}
/** Create (or replace) a trusted device with the given user whitelist. */
export function trustDevice({ label, userIds }) {
const db = getDb();
const token = randomBytes(24).toString('hex');
return db.transaction(() => {
const info = db.prepare('INSERT INTO kiosk_devices (token, label) VALUES (?, ?)')
.run(token, label || null);
const stmt = db.prepare('INSERT OR IGNORE INTO kiosk_device_users (device_id, user_id) VALUES (?, ?)');
for (const uid of userIds || []) stmt.run(info.lastInsertRowid, uid);
return { id: info.lastInsertRowid, token };
})();
}
export function isUserAllowedOnDevice(deviceId, userId) {
return !!getDb().prepare(
'SELECT 1 FROM kiosk_device_users WHERE device_id = ? AND user_id = ?'
).get(deviceId, userId);
}

View File

@@ -71,5 +71,25 @@ function runMigrations(db) {
if (!playCols.has('total_play_ms')) {
db.exec('ALTER TABLE station_plays ADD COLUMN total_play_ms INTEGER NOT NULL DEFAULT 0');
}
// Multi-user kiosk: per-user metadata + designation of "main" identity.
const userCols = new Set(db.prepare("PRAGMA table_info(users)").all().map((c) => c.name));
if (!userCols.has('is_main')) {
db.exec('ALTER TABLE users ADD COLUMN is_main INTEGER NOT NULL DEFAULT 0');
}
if (!userCols.has('avatar_color')) {
db.exec('ALTER TABLE users ADD COLUMN avatar_color TEXT');
}
if (!userCols.has('avatar_emoji')) {
db.exec('ALTER TABLE users ADD COLUMN avatar_emoji TEXT');
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_only_one_main ON users(is_main) WHERE is_main = 1');
// Cross-client stream sync: when a room enters 'play', record the wall-clock
// moment so every client can target the same playhead.
const stateCols = new Set(db.prepare("PRAGMA table_info(room_state)").all().map((c) => c.name));
if (!stateCols.has('started_at')) {
db.exec('ALTER TABLE room_state ADD COLUMN started_at INTEGER');
}
}

View File

@@ -3,8 +3,30 @@ CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
is_main INTEGER NOT NULL DEFAULT 0, -- exactly one user is the shared "main" identity
avatar_color TEXT, -- cosmetic, used by avatar picker
avatar_emoji TEXT, -- cosmetic, used by avatar picker
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Note: the partial unique index on users(is_main) is created by runMigrations()
-- after legacy DBs have the is_main column added via ALTER TABLE.
-- Trusted kiosk/browser devices. A device cookie (random token) is set after
-- an admin marks the device trusted. Users on the device's whitelist can
-- fast-switch without re-entering a password.
CREATE TABLE IF NOT EXISTS kiosk_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
label TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT
);
CREATE TABLE IF NOT EXISTS kiosk_device_users (
device_id INTEGER NOT NULL REFERENCES kiosk_devices(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_kiosk_device_users_user ON kiosk_device_users(user_id);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
@@ -132,6 +154,7 @@ CREATE TABLE IF NOT EXISTS room_state (
station_id INTEGER REFERENCES stations(id) ON DELETE SET NULL,
playing INTEGER NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0.7,
started_at INTEGER, -- epoch ms when playback (or current station) began; anchors cross-client sync
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -5,9 +5,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title>
<script type="module" crossorigin src="/assets/admin-BnGhtAku.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/debug-DBzSAgZo.js">
<link rel="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css">
</head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
function x(l,t={}){let e,i=0,r=!1;function m(){const h=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;t.room&&o.set("room",t.room),t.kind&&o.set("kind",t.kind);const c=o.toString();e=new WebSocket(`${h}://${location.host}/ws${c?"?"+c:""}`),e.addEventListener("open",()=>{var n;i=0,(n=t.onOpen)==null||n.call(t)}),e.addEventListener("message",n=>{try{l(JSON.parse(n.data))}catch{}}),e.addEventListener("close",()=>{var n;(n=t.onClose)==null||n.call(t),!r&&(i=Math.min(i+1,6),setTimeout(m,500*2**i))}),e.addEventListener("error",()=>e.close())}return m(),{send(h){(e==null?void 0:e.readyState)===WebSocket.OPEN&&e.send(JSON.stringify(h))},close(){r=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}const w=1e3,N=5e3,C=5,I=8,v=16;function S(l){if(!l.length)return 0;const t=l.slice().sort((i,r)=>i-r),e=t.length>>1;return t.length%2?t[e]:(t[e-1]+t[e])/2}class M{constructor(){this.offset=0,this.rtt=1/0,this.offsetStd=1/0,this.samples=[],this.synced=!1,this._pending=new Map,this._listeners=new Set,this._timeoutId=null,this._ws=null}attachWs(t){this._ws=t,this.reset();let e=0;const i=()=>{if(e++>=5){this._scheduleNext();return}this._sendPing(),setTimeout(i,150)};i()}detach(){this._timeoutId&&clearTimeout(this._timeoutId),this._timeoutId=null,this._pending.clear(),this._ws=null}reset(){this.samples=[],this.synced=!1,this.offsetStd=1/0,this._pending.clear(),this._timeoutId&&(clearTimeout(this._timeoutId),this._timeoutId=null)}now(){return Date.now()+this.offset}isStable(){return this.synced&&this.samples.length>=I&&this.offsetStd<=C}onUpdate(t){return this._listeners.add(t),()=>this._listeners.delete(t)}handlePong(t){if(this._pending.get(t.t1)==null)return;this._pending.delete(t.t1);const i=Date.now(),r=i-t.t1,m=t.t2-(t.t1+i)/2;this.samples.push({offset:m,rtt:r}),this.samples.length>v&&this.samples.shift();const h=S(this.samples.map(s=>s.rtt)),o=Math.max(h*2,h+10),c=this.samples.filter(s=>s.rtt<=o),n=c.length?c.map(s=>s.offset):this.samples.map(s=>s.offset),_=S(n),p=n.reduce((s,d)=>s+d,0)/n.length,f=n.reduce((s,d)=>s+(d-p)**2,0)/n.length;this.offsetStd=Math.sqrt(f),this.offset=_,this.rtt=r,this.synced=!0;for(const s of this._listeners)s({offset:this.offset,rtt:this.rtt,offsetStd:this.offsetStd,samples:this.samples.length,accepted:c.length,stable:this.isStable()})}_sendPing(){if(!this._ws)return;const t=Date.now();this._pending.set(t,t);for(const e of this._pending.keys())t-e>5e3&&this._pending.delete(e);this._ws.send({type:"clock-ping",t1:t})}_scheduleNext(){this._timeoutId&&clearTimeout(this._timeoutId);const t=this.isStable()?N:w;this._timeoutId=setTimeout(()=>{this._sendPing(),this._scheduleNext()},t)}}const E="oradio.autoplayDismissed";function b(){var l;try{return!!(typeof window<"u"&&((l=window.oradioNative)!=null&&l.isElectron))}catch{return!1}}function L(){if(!b())return!1;try{return localStorage.getItem(E)==="1"}catch{return!1}}let u=null;function T({stationName:l="Radio",subtitle:t="",onStart:e,onCancel:i}={}){if(u)return u.promise;let r,m;const h=new Promise((a,g)=>{r=a,m=g}),o=document.createElement("div");o.className="play-gate-backdrop",o.setAttribute("role","dialog"),o.setAttribute("aria-modal","true"),o.setAttribute("aria-label","Tap to start audio");const c=document.createElement("div");c.className="play-gate-card";const n=document.createElement("h2");n.className="play-gate-title",n.textContent="Tap to start audio",c.appendChild(n);const _=document.createElement("div");if(_.className="play-gate-station",_.textContent=l,c.appendChild(_),t){const a=document.createElement("div");a.className="play-gate-sub",a.textContent=t,c.appendChild(a)}const p=document.createElement("div");p.className="play-gate-row";const f=document.createElement("button");f.className="play-gate-start",f.textContent="▶ Start";const s=document.createElement("button");s.className="play-gate-cancel",s.textContent="Cancel",p.appendChild(f),p.appendChild(s),c.appendChild(p);let d=null;if(b()){const a=document.createElement("label");a.className="play-gate-dismiss",d=document.createElement("input"),d.type="checkbox",d.id="play-gate-dismiss-cb",a.appendChild(d);const g=document.createElement("span");g.textContent=" Don't show again on this device",a.appendChild(g),c.appendChild(a)}o.appendChild(c),document.body.appendChild(o),queueMicrotask(()=>f.focus());function y(){var a;(a=u==null?void 0:u.backdrop)!=null&&a.parentNode&&u.backdrop.parentNode.removeChild(u.backdrop),u=null}function k(){if(d&&d.checked)try{localStorage.setItem(E,"1")}catch{}}return f.addEventListener("click",()=>{k(),y();try{e&&e()}catch{}r()}),s.addEventListener("click",()=>{y();try{i&&i()}catch{}m(new Error("autoplay-cancelled"))}),o.addEventListener("keydown",a=>{a.key==="Escape"&&s.click()}),u={backdrop:o,promise:h},h}export{M as R,L as a,x as c,T as s};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
function l(s,n={}){let e,r=0,c=!1;function i(){const a=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;n.room&&o.set("room",n.room),n.kind&&o.set("kind",n.kind);const d=o.toString();e=new WebSocket(`${a}://${location.host}/ws${d?"?"+d:""}`),e.addEventListener("open",()=>{var t;r=0,(t=n.onOpen)==null||t.call(n)}),e.addEventListener("message",t=>{try{s(JSON.parse(t.data))}catch{}}),e.addEventListener("close",()=>{var t;(t=n.onClose)==null||t.call(n),!c&&(r=Math.min(r+1,6),setTimeout(i,500*2**r))}),e.addEventListener("error",()=>e.close())}return i(),{send(a){(e==null?void 0:e.readyState)===WebSocket.OPEN&&e.send(JSON.stringify(a))},close(){c=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}export{l as c};

View File

@@ -5,11 +5,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title>
<script type="module" crossorigin src="/assets/kiosk-DIx-PLJP.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
<link rel="modulepreload" crossorigin href="/assets/ws-BM1PmMVd.js">
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/debug-DBzSAgZo.js">
<link rel="modulepreload" crossorigin href="/assets/playGate-C1e0nYli.js">
<link rel="stylesheet" crossorigin href="/assets/kiosk-DuoYH-tL.css">
</head>

View File

@@ -5,11 +5,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Master</title>
<script type="module" crossorigin src="/assets/master-BGIwPPRC.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
<link rel="modulepreload" crossorigin href="/assets/ws-BM1PmMVd.js">
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/debug-DBzSAgZo.js">
<link rel="modulepreload" crossorigin href="/assets/playGate-C1e0nYli.js">
<link rel="stylesheet" crossorigin href="/assets/master-B8Vyo4--.css">
</head>

View File

@@ -117,11 +117,12 @@ export function ensurePersonalRoom(user) {
export function getRoomState(roomId) {
const row = getDb().prepare('SELECT * FROM room_state WHERE room_id = ?').get(roomId);
if (!row) return { station_id: null, playing: false, volume: 0.7, updated_at: null };
if (!row) return { station_id: null, playing: false, volume: 0.7, started_at: null, updated_at: null };
return {
station_id: row.station_id,
playing: !!row.playing,
volume: row.volume,
started_at: row.started_at ?? null,
updated_at: row.updated_at
};
}
@@ -130,13 +131,14 @@ export function setRoomState(roomId, patch) {
const cur = getRoomState(roomId);
const next = { ...cur, ...patch };
getDb().prepare(`
INSERT INTO room_state (room_id, station_id, playing, volume, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
INSERT INTO room_state (room_id, station_id, playing, volume, started_at, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(room_id) DO UPDATE SET
station_id = excluded.station_id,
playing = excluded.playing,
volume = excluded.volume,
started_at = excluded.started_at,
updated_at = excluded.updated_at
`).run(roomId, next.station_id ?? null, next.playing ? 1 : 0, next.volume ?? 0.7);
`).run(roomId, next.station_id ?? null, next.playing ? 1 : 0, next.volume ?? 0.7, next.started_at ?? null);
return getRoomState(roomId);
}

View File

@@ -1,7 +1,9 @@
import { Router } from 'express';
import {
verifyPassword, createSession, destroySession, setSessionCookie, clearSessionCookie,
hashPassword, requireAdmin
hashPassword, requireAdmin, requireUser,
getMainUser, getTrustedDevice, listDeviceUsers, trustDevice, setDeviceCookie,
isUserAllowedOnDevice
} from '../auth.js';
import { getDb } from '../db/index.js';
@@ -27,12 +29,107 @@ router.post('/logout', (req, res) => {
router.get('/me', (req, res) => {
if (!req.user) return res.status(401).json({ error: 'not signed in' });
res.json(req.user);
const row = getDb().prepare(
'SELECT id, username, role, is_main, avatar_color, avatar_emoji FROM users WHERE id = ?'
).get(req.user.id);
res.json(row || req.user);
});
// Public list of users (for the favorite-tabs strip and avatar picker).
// Returns only safe cosmetic fields, no roles or timestamps.
router.get('/users/public', requireUser, (_req, res) => {
const rows = getDb().prepare(`
SELECT id, username, is_main, avatar_color, avatar_emoji
FROM users
ORDER BY is_main DESC, username COLLATE NOCASE
`).all();
res.json(rows);
});
// The designated "main" user (the shared/house identity). Anyone signed in
// may read it so the UI can pin the main tab and offer "Follow main".
router.get('/main', requireUser, (_req, res) => {
const main = getMainUser();
if (!main) return res.status(404).json({ error: 'no main user configured' });
res.json(main);
});
// ----- Trusted-device fast-switching -----
// What does *this* device know about itself? Returns the whitelist of users
// that can be switched to without a password. Empty object if untrusted.
router.get('/devices/me', (req, res) => {
const dev = getTrustedDevice(req);
if (!dev) return res.json({ trusted: false, users: [] });
res.json({
trusted: true,
id: dev.id,
label: dev.label,
users: listDeviceUsers(dev.id)
});
});
// Trust this device with a user whitelist. Admin-only; sets a long-lived
// `oradio_device` cookie. The current admin is auto-included.
router.post('/devices/trust', requireAdmin, (req, res) => {
const { label, user_ids } = req.body || {};
const ids = Array.isArray(user_ids) ? user_ids.map(Number).filter(Boolean) : [];
if (!ids.includes(req.user.id)) ids.push(req.user.id);
// Validate
const placeholders = ids.map(() => '?').join(',');
const valid = getDb().prepare(
`SELECT id FROM users WHERE id IN (${placeholders})`
).all(...ids).map((r) => r.id);
if (!valid.length) return res.status(400).json({ error: 'no valid users' });
const { id, token } = trustDevice({ label: label?.trim() || null, userIds: valid });
setDeviceCookie(res, token);
res.status(201).json({ id, label: label || null, users: listDeviceUsers(id) });
});
// Update the whitelist of the current trusted device (admin only).
router.patch('/devices/me', requireAdmin, (req, res) => {
const dev = getTrustedDevice(req);
if (!dev) return res.status(404).json({ error: 'device not trusted' });
const { label, user_ids } = req.body || {};
const db = getDb();
if (typeof label === 'string') {
db.prepare('UPDATE kiosk_devices SET label = ? WHERE id = ?').run(label.trim() || null, dev.id);
}
if (Array.isArray(user_ids)) {
const ids = user_ids.map(Number).filter(Boolean);
db.transaction(() => {
db.prepare('DELETE FROM kiosk_device_users WHERE device_id = ?').run(dev.id);
const stmt = db.prepare('INSERT OR IGNORE INTO kiosk_device_users (device_id, user_id) VALUES (?, ?)');
for (const uid of ids) stmt.run(dev.id, uid);
})();
}
res.json({ id: dev.id, users: listDeviceUsers(dev.id) });
});
// Fast switch: trade a trusted-device cookie + whitelisted username for a
// fresh session. No password required. The previous session is destroyed.
router.post('/switch', (req, res) => {
const dev = getTrustedDevice(req);
if (!dev) return res.status(403).json({ error: 'device not trusted' });
const username = String(req.body?.username || '').trim();
if (!username) return res.status(400).json({ error: 'username required' });
const user = getDb().prepare('SELECT id, username, role FROM users WHERE username = ?').get(username);
if (!user) return res.status(404).json({ error: 'unknown user' });
if (!isUserAllowedOnDevice(dev.id, user.id)) {
return res.status(403).json({ error: 'user not allowed on this device' });
}
// Kill the previous session before issuing a new one.
if (req.session?.token) destroySession(req.session.token);
const { token, expires } = createSession(user.id);
setSessionCookie(res, token, expires);
res.json({ id: user.id, username: user.username, role: user.role });
});
// Admin-only user management
router.get('/users', requireAdmin, (_req, res) => {
const users = getDb().prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all();
const users = getDb().prepare(
'SELECT id, username, role, is_main, avatar_color, avatar_emoji, created_at FROM users ORDER BY username'
).all();
res.json(users);
});
@@ -54,12 +151,24 @@ router.post('/users', requireAdmin, (req, res) => {
router.patch('/users/:id', requireAdmin, (req, res) => {
const id = Number(req.params.id);
const { password, role } = req.body || {};
const { password, role, avatar_color, avatar_emoji, is_main } = req.body || {};
const db = getDb();
if (password) db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
if (role && ['admin', 'user'].includes(role)) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
}
if (typeof avatar_color === 'string') {
db.prepare('UPDATE users SET avatar_color = ? WHERE id = ?').run(avatar_color || null, id);
}
if (typeof avatar_emoji === 'string') {
db.prepare('UPDATE users SET avatar_emoji = ? WHERE id = ?').run(avatar_emoji || null, id);
}
if (is_main === true) {
db.transaction(() => {
db.prepare('UPDATE users SET is_main = 0 WHERE is_main = 1 AND id != ?').run(id);
db.prepare('UPDATE users SET is_main = 1 WHERE id = ?').run(id);
})();
}
res.json({ ok: true });
});

View File

@@ -8,6 +8,7 @@ import {
ensurePersonalRoom
} from '../rooms.js';
import { getStation } from '../stations.js';
import { dispatchRoomCommand, hasDisplay } from '../ws.js';
export const router = Router();
router.use(requireUser);
@@ -59,3 +60,38 @@ router.delete('/:slug/members/:userId', (req, res) => {
removeMember(room.id, Number(req.params.userId));
res.json(listMembers(room.id));
});
// Inject a playback intent for the room from outside the WS hub. Used by
// smart-home/scripts/integrations: the request behaves like a synthetic
// controller — the master (display) actually plays the audio and emits its
// authoritative `state`, which the server rebroadcasts so every other peer
// stays in sync. 409 when no display is connected, because without a master
// there's no audio source to honour the intent.
router.post('/:slug/command', (req, res) => {
const room = getRoomBySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'not found' });
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
const action = String(req.body?.action || '');
if (!['play', 'pause', 'stop', 'volume'].includes(action)) {
return res.status(400).json({ error: 'action must be one of play|pause|stop|volume' });
}
const msg = { type: 'command', action };
if (action === 'play') {
const stationId = Number(req.body?.stationId);
if (!Number.isFinite(stationId)) return res.status(400).json({ error: 'stationId required for play' });
msg.stationId = stationId;
} else if (action === 'volume') {
const value = Number(req.body?.value);
if (!Number.isFinite(value)) return res.status(400).json({ error: 'numeric value required for volume' });
msg.value = Math.max(0, Math.min(1, value));
}
if (!hasDisplay(room.slug)) {
return res.status(409).json({ error: 'no display connected to room', slug: room.slug });
}
const result = dispatchRoomCommand(room, msg);
if (!result) return res.status(400).json({ error: 'command rejected' });
res.json({ ok: true, room: { id: room.id, slug: room.slug, name: room.name }, state: { ...result.state, station: result.station } });
});

70
server/routes/users.js Normal file
View File

@@ -0,0 +1,70 @@
// /api/users — cross-user reads for the favorites-as-tabs UI.
//
// Writes are still owned by /api/me/favorites. The one exception is the
// shared "main" user (e.g. morphix) which any admin may edit so the house
// favorites tab is collaboratively curated without revealing morphix's
// password.
import { Router } from 'express';
import { requireUser, requireAdmin, getMainUser } from '../auth.js';
import { getDb } from '../db/index.js';
import { getStatsMap } from '../stats.js';
export const router = Router();
router.use(requireUser);
function findUserByName(username) {
return getDb().prepare(
'SELECT id, username, is_main, avatar_color, avatar_emoji FROM users WHERE username = ?'
).get(username);
}
router.get('/:username/favorites', (req, res) => {
const user = findUserByName(req.params.username);
if (!user) return res.status(404).json({ error: 'unknown user' });
const rows = getDb().prepare(`
SELECT s.*, f.position
FROM favorites f JOIN stations s ON s.id = f.station_id
WHERE f.user_id = ? AND s.enabled = 1
ORDER BY f.position ASC, f.created_at ASC
`).all(user.id);
// Stats are scoped to the *viewer* (so my_vote reflects me, not the owner).
const stats = getStatsMap(req.user.id);
res.json(rows.map((r) => {
const st = stats.get(r.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
return {
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position,
up: st.up, down: st.down, plays: st.plays, my_vote: st.myVote, score: st.score
};
}));
});
// Admin shortcut for editing the shared "main" user's favorites without
// logging in as them. Any other user is rejected so we don't accidentally
// mutate someone else's library.
function canWriteFavoritesFor(viewer, target) {
if (!target) return false;
if (viewer.id === target.id) return true;
return viewer.role === 'admin' && target.is_main === 1;
}
router.put('/:username/favorites/:stationId', (req, res) => {
const user = findUserByName(req.params.username);
if (!canWriteFavoritesFor(req.user, user)) return res.status(403).json({ error: 'forbidden' });
const stationId = Number(req.params.stationId);
const position = Number(req.body?.position ?? 0);
getDb().prepare(`
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
`).run(user.id, stationId, position);
res.json({ ok: true });
});
router.delete('/:username/favorites/:stationId', (req, res) => {
const user = findUserByName(req.params.username);
if (!canWriteFavoritesFor(req.user, user)) return res.status(403).json({ error: 'forbidden' });
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
.run(user.id, Number(req.params.stationId));
res.json({ ok: true });
});

View File

@@ -0,0 +1,24 @@
// One-shot DB smoke test for the multi-user kiosk migrations.
import { initDb, getDb } from '../db/index.js';
import { ensureMainUser, getMainUser } from '../auth.js';
import { app } from 'electron';
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
const db = getDb();
ensureMainUser(process.env.MAIN_USER || 'morphix');
const userCols = db.prepare("PRAGMA table_info(users)").all().map((c) => c.name);
const deviceCols = db.prepare("PRAGMA table_info(kiosk_devices)").all().map((c) => c.name);
const wlCols = db.prepare("PRAGMA table_info(kiosk_device_users)").all().map((c) => c.name);
const roomStateCols = db.prepare("PRAGMA table_info(room_state)").all().map((c) => c.name);
const users = db.prepare('SELECT id, username, role, is_main, avatar_color, avatar_emoji FROM users').all();
const mainCount = db.prepare('SELECT count(*) AS c FROM users WHERE is_main = 1').get().c;
console.log('users cols: ', userCols.join(', '));
console.log('kiosk_devices cols: ', deviceCols.join(', '));
console.log('kiosk_device_users: ', wlCols.join(', '));
console.log('room_state cols: ', roomStateCols.join(', '));
console.log('users: ', JSON.stringify(users, null, 2));
console.log('main user count: ', mainCount);
console.log('getMainUser(): ', getMainUser());
app.quit();

View File

@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import { initDb } from './db/index.js';
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
import { authMiddleware, ensureBootstrapAdmin, ensureMainUser } from './auth.js';
import { applySeedIfEmpty } from './sources/seed.js';
import { scheduleHealthCheck } from './streams/checker.js';
import { attachWs } from './ws.js';
@@ -24,6 +24,7 @@ import { router as meRoutes } from './routes/me.js';
import { router as adminRoutes } from './routes/admin.js';
import { router as v1Routes } from './routes/v1.js';
import { router as roomRoutes } from './routes/rooms.js';
import { router as userRoutes } from './routes/users.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -37,6 +38,7 @@ export async function startServer(opts = {}) {
username: process.env.ADMIN_BOOTSTRAP_USER,
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
});
ensureMainUser(process.env.MAIN_USER || 'morphix');
const seedResult = applySeedIfEmpty();
console.log('[seed]', seedResult);
ensureImageDirs();
@@ -48,6 +50,7 @@ export async function startServer(opts = {}) {
app.use('/api/auth', authRoutes);
app.use('/api/stations', stationRoutes);
app.use('/api/me', meRoutes);
app.use('/api/users', userRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/rooms', roomRoutes);
app.use('/api/v1', v1Routes);

View File

@@ -13,6 +13,8 @@
// server->room: forwarded as-is
// - state display->server: ground-truth playback snapshot
// server->room: persisted snapshot
// - sync-pos display->server: { stationId, masterCT, atServerNow }
// server->room: forwarded; latest cached for hello replay
// - devices display->server: { list, current } server->room: same
// - vote server->room: { stationId, stats } emitted after castVote
// - plays server->room: { stationId, plays } emitted after recordPlay
@@ -26,10 +28,17 @@ import {
} from './rooms.js';
import { getStation } from './stations.js';
const DEBUG = !!process.env.ORADIO_DEBUG_SYNC;
function dlog(...args) { if (DEBUG) console.log('[ws]', ...args); }
// roomSlug -> Set<ws>
const rooms = new Map();
// userId -> Set<ws>
const byUser = new Map();
// roomSlug -> last `sync-pos` payload from this room's display, plus a
// server-clock timestamp so receivers can age it. In-memory only; this churns
// every couple of seconds and never needs to outlive a process.
const lastSyncPos = new Map();
function addToIndex(map, key, ws) {
if (!map.has(key)) map.set(key, new Set());
@@ -91,13 +100,55 @@ export function broadcastGlobal(msg) {
}
}
function hasDisplay(roomSlug) {
export function hasDisplay(roomSlug) {
const set = rooms.get(roomSlug);
if (!set) return false;
for (const ws of set) if (ws.kind === 'display') return true;
return false;
}
// Mutates room_state for a play|pause|stop|volume intent and broadcasts both
// the command (so the display actually changes audio) and the resulting state
// (so every other peer's UI mirrors). Shared by the WS `command` handler and
// the HTTP `POST /api/rooms/:slug/command` route so both paths produce
// byte-identical broadcasts.
//
// `except` lets the WS path exclude the original sender from the command
// rebroadcast (kiosks in linked mode would otherwise loop play→stop→play).
// The state broadcast always goes to everyone — including the sender — so
// UIs stay in sync.
export function dispatchRoomCommand(room, msg, { except = null } = {}) {
const slug = room.slug;
const cur = getRoomState(room.id);
const displayPresent = hasDisplay(slug);
if (msg.action === 'play' && Number.isFinite(msg.stationId)) {
const newStation = Number(msg.stationId);
const sameAndPlaying = cur.playing && cur.station_id === newStation && cur.started_at;
const patch = { station_id: newStation, playing: true };
if (!displayPresent) {
patch.started_at = sameAndPlaying ? cur.started_at : Date.now();
} else if (cur.station_id !== newStation) {
patch.started_at = null;
}
setRoomState(room.id, patch);
} else if (msg.action === 'pause') {
setRoomState(room.id, { playing: false });
} else if (msg.action === 'stop') {
setRoomState(room.id, { playing: false, started_at: null });
} else if (msg.action === 'volume' && typeof msg.value === 'number') {
setRoomState(room.id, { volume: Math.max(0, Math.min(1, msg.value)) });
} else {
return null;
}
const next = getRoomState(room.id);
const station = next.station_id ? getStation(next.station_id) : null;
broadcastToRoom(slug, msg, except);
broadcastToRoom(slug, { type: 'state', ...next, station, server_now: Date.now() });
return { state: next, station };
}
export function attachWs(server) {
const wss = new WebSocketServer({ noServer: true });
@@ -146,6 +197,11 @@ export function attachWs(server) {
ws.on('close', () => {
removeFromIndex(rooms, room.slug, ws);
removeFromIndex(byUser, user.id, ws);
// If the departing socket was the display, its sync-pos cache
// is stale (no source until the new display starts emitting).
if (ws.kind === 'display' && !hasDisplay(room.slug)) {
lastSyncPos.delete(room.slug);
}
broadcastToRoom(room.slug, { type: 'presence', peers: presenceFor(room.slug) });
});
@@ -163,7 +219,9 @@ export function attachWs(server) {
you: { id: user.id, username: user.username, role: user.role, kind },
room: { id: room.id, slug: room.slug, name: room.name },
state: { ...state, station },
peers: presenceFor(room.slug)
server_now: Date.now(),
peers: presenceFor(room.slug),
last_sync_pos: lastSyncPos.get(room.slug) || null
});
broadcastToRoom(room.slug, { type: 'presence', peers: presenceFor(room.slug) }, ws);
});
@@ -183,14 +241,40 @@ function handleClientMessage(ws, msg) {
// optimistically reflect simple intents into room_state so a
// late-joining peer sees the latest target station/volume even
// before the display emits a confirmation `state`.
if (msg.action === 'play' && Number.isFinite(msg.stationId)) {
setRoomState(ws.room.id, { station_id: Number(msg.stationId), playing: true });
} else if (msg.action === 'stop') {
setRoomState(ws.room.id, { playing: false });
} else if (msg.action === 'volume' && typeof msg.value === 'number') {
setRoomState(ws.room.id, { volume: Math.max(0, Math.min(1, msg.value)) });
dlog('cmd', msg.action, 'from', ws.user.username, 'kind', ws.kind, 'display?', hasDisplay(slug));
if (msg.action === 'setSyncBuffer' && Number.isFinite(msg.value)) {
// Room-wide sync buffer. Forward to everyone (including the
// master, which will adopt it and include in the next
// sync-pos so late joiners pick it up). Not persisted to
// room_state — it's ephemeral per session.
const v = Math.max(500, Math.min(60000, Math.round(msg.value)));
broadcastToRoom(slug, { type: 'command', action: 'setSyncBuffer', value: v }, ws);
return;
}
broadcastToRoom(slug, msg, null); // include sender so its UI mirrors
if (msg.action === 'peerVolume' && Number.isFinite(msg.userId) && typeof msg.value === 'number') {
// Targeted per-zone hint: do NOT persist in room_state. Just
// forward to that user's connections in this room. Receiving
// clients apply it as their LOCAL volume.
const v = Math.max(0, Math.min(1, msg.value));
const set = rooms.get(slug);
if (set) {
const payload = JSON.stringify({ type: 'peerVolume', userId: msg.userId, value: v, from: ws.user.id });
for (const peer of set) {
if (peer.user.id === msg.userId && peer.readyState === peer.OPEN) {
peer.send(payload);
}
}
}
return;
}
// play/pause/stop/volume share their effect with the HTTP route,
// so the mutation + broadcasts live in dispatchRoomCommand. The
// command itself is NOT echoed back to `ws`: the sender already
// acted locally, and echoing would re-trigger their command
// handler (kiosks in linked mode would loop play→stop→play).
// Other clients still see the command, and EVERYONE — including
// the sender — sees the resulting `state` so UIs stay in sync.
dispatchRoomCommand(ws.room, msg, { except: ws });
return;
}
case 'state': {
@@ -200,9 +284,38 @@ function handleClientMessage(ws, msg) {
if ('stationId' in msg) patch.station_id = msg.stationId ?? null;
if ('playing' in msg) patch.playing = !!msg.playing;
if (typeof msg.volume === 'number') patch.volume = msg.volume;
// Display can re-anchor started_at (e.g. when it begins playback).
if ('started_at' in msg) patch.started_at = msg.started_at ?? null;
dlog('state from display', patch);
const prev = getRoomState(ws.room.id);
const next = setRoomState(ws.room.id, patch);
// Station change invalidates any cached master position.
if (prev.station_id !== next.station_id) lastSyncPos.delete(slug);
const station = next.station_id ? getStation(next.station_id) : null;
broadcastToRoom(slug, { type: 'state', ...next, station });
broadcastToRoom(slug, { type: 'state', ...next, station, server_now: Date.now() });
return;
}
case 'clock-ping': {
// NTP-lite: echo client's t1 + server t2; client computes offset.
send(ws, { type: 'clock-pong', t1: msg.t1, t2: Date.now() });
return;
}
case 'sync-pos': {
// Only the room's display has an authoritative stream position.
// Forward to every other peer and cache so reconnecting peers can
// anchor immediately from `hello.last_sync_pos`.
if (ws.kind !== 'display') return;
const payload = {
stationId: Number.isFinite(msg.stationId) ? Number(msg.stationId) : null,
masterCT: Number.isFinite(msg.masterCT) ? Number(msg.masterCT) : null,
atServerNow: Number.isFinite(msg.atServerNow) ? Number(msg.atServerNow) : Date.now(),
pdtMs: Number.isFinite(msg.pdtMs) ? Number(msg.pdtMs) : null,
bufferMs: Number.isFinite(msg.bufferMs) ? Number(msg.bufferMs) : null
};
if (payload.masterCT == null || payload.stationId == null) return;
lastSyncPos.set(slug, payload);
dlog('sync-pos', payload);
broadcastToRoom(slug, { type: 'sync-pos', ...payload }, ws);
return;
}
case 'devices': {