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:
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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
70
server/routes/users.js
Normal 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 });
|
||||
});
|
||||
Reference in New Issue
Block a user