openvibers/openvibe.live Commits
Remove Live's inert local chat REST routes and their mounts (#19)
Changes
@@@ -154,7 +154,7 @@ and refills history on reconnect.| Log redaction | `server/utils/redact.js` | stream keys and credentials in log lines || Database | `server/db/database.js` | queries and inline table setup || Migrations | `server/db/migrations.js` | versioned, transactional migrations with a ledger (`schema_migrations`) |--| Chat | `server/chat/chat-server.js`, `server/chat/routes.js` | WebSocket chat, history, moderation |++| Chat | `server/chat/chat-server.js` | WebSocket chat, history, moderation (REST: OpenVibe.Chat) || Streams | `server/streaming/routes.js` | stream and slot CRUD, channel pages || Media proxy | `server/media-proxy/*.js` | VODs, clips, pastes, thumbnails via OpenVibe.Media || Auth | `server/auth/auth.js`, `server/auth/permissions.js` | JWT/API tokens, scopes, role ranks |@@@ -5,7 +5,7 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte## Architecture- **WebSocket server**: `server/chat/chat-server.js` — manages connections, rooms, and message routing--- **REST API**: `server/chat/routes.js` — moderation endpoints, message search, admin tools++- **REST API**: OpenVibe.Chat (nginx sends `/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds` there) — moderation endpoints, message search, admin tools. Live's local `server/chat/routes.js` is retired.- **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync### OpenVibe.Chat (roadmap Wave 6)ddeleted file mode 100644@@@ -1,438 +0,0 @@--/**-- * OpenVibe.Live — DM REST API Routes-- *-- * GET /api/dm/conversations — list conversations-- * POST /api/dm/conversations — create or get 1-on-1 / group-- * GET /api/dm/conversations/:id — get conversation details + participants-- * GET /api/dm/conversations/:id/messages — paginated messages-- * POST /api/dm/conversations/:id/messages — send a message-- * POST /api/dm/conversations/:id/read — mark conversation read-- * POST /api/dm/conversations/:id/participants — add participant to group-- * DELETE /api/dm/conversations/:id/participants/:userId — remove participant-- * PATCH /api/dm/conversations/:id — rename group-- * GET /api/dm/unread — total unread count-- * GET /api/dm/users/search — search users for new-message picker-- */--const express = require('express');--const router = express.Router();--const { requireAuth } = require('../auth/auth');--const dm = require('./dm');----// All DM routes require authentication--router.use(requireAuth);----// ── Per-user rate limiting (in-memory sliding window) ────────--const _rateBuckets = new Map();--const RATE_MSG_MAX = 10, RATE_MSG_WINDOW = 60_000; // 10 messages / 60s--const RATE_CONV_MAX = 5, RATE_CONV_WINDOW = 3_600_000; // 5 new conversations / hour--const MAX_GROUP_SIZE = 20;----function _checkRate(userId, bucket, max, windowMs) {-- if (!_rateBuckets.has(userId)) _rateBuckets.set(userId, { messages: [], convos: [] });-- const u = _rateBuckets.get(userId);-- const now = Date.now(), cutoff = now - windowMs;-- u[bucket] = u[bucket].filter(t => t > cutoff);-- if (u[bucket].length >= max) return false;-- u[bucket].push(now);-- return true;--}--// Not in a restore drill (LIVE_DRILL): it runs no timers, and it takes no DMs.--if (!require('../drill').enabled) setInterval(() => {-- const cutoff = Date.now() - 3_600_000;-- for (const [uid, b] of _rateBuckets) {-- b.messages = b.messages.filter(t => t > cutoff);-- b.convos = b.convos.filter(t => t > cutoff);-- if (!b.messages.length && !b.convos.length) _rateBuckets.delete(uid);-- }--}, 300_000);----// List conversations for current user--router.get('/conversations', (req, res) => {-- try {-- const conversations = dm.getConversations(req.user.id);-- // Attach participant info to each conversation-- for (const conv of conversations) {-- conv.participants = dm.getParticipants(conv.id);-- }-- res.json({ conversations });-- } catch (err) {-- console.error('[DM] List conversations error:', err.message);-- res.status(500).json({ error: 'Failed to load conversations' });-- }--});----// Create or get a conversation--router.post('/conversations', (req, res) => {-- try {-- const { user_ids, name } = req.body;-- if (!user_ids || !Array.isArray(user_ids) || user_ids.length === 0) {-- return res.status(400).json({ error: 'user_ids array required' });-- }-- const participantIds = [...new Set([req.user.id, ...user_ids.map(Number).filter(Boolean)])];-- if (participantIds.length < 2) {-- return res.status(400).json({ error: 'Need at least one other user' });-- }-- if (participantIds.length > MAX_GROUP_SIZE) {-- return res.status(400).json({ error: `Groups are limited to ${MAX_GROUP_SIZE} members` });-- }---- // Rate limit conversation creation-- if (!_checkRate(req.user.id, 'convos', RATE_CONV_MAX, RATE_CONV_WINDOW)) {-- return res.status(429).json({ error: 'Too many new conversations. Try again later.' });-- }---- // New account restriction-- const accountCheck = dm.isAccountTooNew(req.user.id, 5);-- if (accountCheck.tooNew) {-- return res.status(403).json({ error: `Your account is too new to start conversations. Please wait ${accountCheck.minutesRemaining} more minute(s).` });-- }---- // Validate all target users exist, aren't banned, aren't blocked-- const userDb = require('../db/database');-- for (const uid of participantIds) {-- if (uid === req.user.id) continue;-- const target = userDb.getUserById(uid);-- if (!target) return res.status(400).json({ error: 'User not found' });-- if (target.is_banned) return res.status(400).json({ error: 'Cannot message banned users' });-- if (dm.isBlockedEither(req.user.id, uid)) {-- return res.status(403).json({ error: 'Cannot start a conversation with this user' });-- }-- }---- let conversationId;-- if (participantIds.length === 2) {-- const otherId = participantIds.find(id => id !== req.user.id);-- conversationId = dm.getOrCreateDirect(req.user.id, otherId);-- } else {-- const safeName = name ? String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100) : null;-- conversationId = dm.createConversation(req.user.id, participantIds, safeName);-- }---- const conversation = dm.getConversation(conversationId);-- conversation.participants = dm.getParticipants(conversationId);-- res.json({ conversation });-- } catch (err) {-- console.error('[DM] Create conversation error:', err.message);-- res.status(500).json({ error: 'Failed to create conversation' });-- }--});----// Get conversation details--router.get('/conversations/:id', (req, res) => {-- try {-- const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {-- return res.status(403).json({ error: 'Not a participant' });-- }-- const conversation = dm.getConversation(convId);-- if (!conversation) return res.status(404).json({ error: 'Not found' });-- conversation.participants = dm.getParticipants(convId);-- res.json({ conversation });-- } catch (err) {-- console.error('[DM] Get conversation error:', err.message);-- res.status(500).json({ error: 'Failed to load conversation' });-- }--});----// Get messages (paginated)--router.get('/conversations/:id/messages', (req, res) => {-- try {-- const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {-- return res.status(403).json({ error: 'Not a participant' });-- }-- const limit = Math.min(parseInt(req.query.limit) || 50, 100);-- const before = parseInt(req.query.before) || null;-- const after = parseInt(req.query.after) || null;-- const messages = dm.getMessages(convId, limit, before, after);-- res.json({ messages });-- } catch (err) {-- console.error('[DM] Get messages error:', err.message);-- res.status(500).json({ error: 'Failed to load messages' });-- }--});----// Send a message--router.post('/conversations/:id/messages', (req, res) => {-- try {-- const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {-- return res.status(403).json({ error: 'Not a participant' });-- }---- // Per-user message rate limit-- if (!_checkRate(req.user.id, 'messages', RATE_MSG_MAX, RATE_MSG_WINDOW)) {-- return res.status(429).json({ error: 'Slow down! You\'re sending messages too fast.' });-- }---- // Block check for 1-on-1 conversations-- const conv = dm.getConversation(convId);-- if (conv && !conv.is_group) {-- const participants = dm.getParticipants(convId);-- const other = participants.find(p => p.id !== req.user.id);-- if (other && dm.isBlockedEither(req.user.id, other.id)) {-- return res.status(403).json({ error: 'Cannot send messages in this conversation' });-- }-- }---- const { message } = req.body;-- if (!message || typeof message !== 'string' || !message.trim()) {-- return res.status(400).json({ error: 'Message required' });-- }---- const msg = dm.sendMessage(convId, req.user.id, message);-- if (!msg) return res.status(400).json({ error: 'Failed to send' });---- // Attach sender info-- const db = require('../db/database');-- const sender = db.getUserById(req.user.id);-- msg.username = sender?.username;-- msg.display_name = sender?.display_name;-- msg.avatar_url = sender?.avatar_url;-- msg.profile_color = sender?.profile_color;---- // Real-time delivery via chat WebSocket-- try {-- const chatServer = require('./chat-server');-- const participants = dm.getParticipants(convId);-- for (const p of participants) {-- if (p.id === req.user.id) continue; // skip sender-- chatServer.sendDm(p.id, {-- type: 'dm',-- conversation_id: convId,-- message: msg,-- });-- }-- } catch { /* non-critical */ }---- // Push notifications to other participants (for when they're offline)-- try {-- const { pushBulkNotification, actorInfo } = require('../utils/notify');-- const participants = dm.getParticipants(convId);-- const otherIds = participants.filter(p => p.id !== req.user.id).map(p => p.id);-- if (otherIds.length) {-- const senderName = sender?.display_name || sender?.username || 'Someone';-- // Use generic preview — actual message content is only in real-time delivery-- const preview = 'Sent you a message';-- pushBulkNotification(otherIds, {-- type: 'DIRECT_MESSAGE',-- title: `Message from ${senderName}`,-- message: preview,-- url: `https://openvibe.live/?dm=${convId}`,-- ...actorInfo(sender),-- });-- }-- } catch { /* non-critical */ }---- res.json({ message: msg });-- } catch (err) {-- console.error('[DM] Send message error:', err.message);-- res.status(500).json({ error: 'Failed to send message' });-- }--});----// Mark conversation read--router.post('/conversations/:id/read', (req, res) => {-- try {-- const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {-- return res.status(403).json({ error: 'Not a participant' });-- }-- dm.markRead(convId, req.user.id);---- // Clear matching DM notifications in openvibe-tools-- try {-- const { markNotificationsRead } = require('../utils/notify');-- markNotificationsRead(req.user.id, 'DIRECT_MESSAGE', `%/dm/${convId}`);-- } catch { /* non-critical */ }---- res.json({ ok: true });-- } catch (err) {-- console.error('[DM] Mark read error:', err.message);-- res.status(500).json({ error: 'Failed' });-- }--});----// Add participant to group--router.post('/conversations/:id/participants', (req, res) => {-- try {-- const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {-- return res.status(403).json({ error: 'Not a participant' });-- }-- const { user_id } = req.body;-- if (!user_id) return res.status(400).json({ error: 'user_id required' });---- // Validate target user exists, isn't banned, isn't blocked-- const userDb = require('../db/database');-- const target = userDb.getUserById(user_id);-- if (!target) return res.status(400).json({ error: 'User not found' });-- if (target.is_banned) return res.status(400).json({ error: 'Cannot add banned users' });-- // A private 1:1 stays private: adding a third person would hand them the whole history.-- const conv = dm.getConversation(convId);-- if (!conv || !conv.is_group) {-- return res.status(400).json({ error: 'Start a group conversation to add people' });-- }-- // Nobody already in the group should end up talking to someone they blocked (or who blocked them).-- const members = dm.getParticipants(convId) || [];-- if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {-- return res.status(403).json({ error: 'Cannot add this user' });-- }---- // Group size limit-- if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {-- return res.status(400).json({ error: `Group is full (max ${MAX_GROUP_SIZE} members)` });-- }---- dm.addParticipant(convId, user_id);-- const participants = dm.getParticipants(convId);---- // Notify all participants about the new member-- try {-- const chatServer = require('./chat-server');-- for (const p of participants) {-- chatServer.sendDm(p.id, {-- type: 'dm-participant-added',-- conversation_id: convId,-- user_id,-- participants,-- });-- }-- } catch { /* non-critical */ }---- res.json({ participants });-- } catch (err) {-- console.error('[DM] Add participant error:', err.message);-- res.status(500).json({ error: 'Failed' });-- }--});----// Remove participant from group--router.delete('/conversations/:id/participants/:userId', (req, res) => {-- try {-- const convId = parseInt(req.params.id);-- const targetUserId = parseInt(req.params.userId);-- if (!dm.isParticipant(convId, req.user.id)) {-- return res.status(403).json({ error: 'Not a participant' });-- }-- // Can only remove self (leave) or be the creator-- const conv = dm.getConversation(convId);-- if (targetUserId !== req.user.id && conv?.created_by !== req.user.id) {-- return res.status(403).json({ error: 'Only the creator can remove others' });-- }-- dm.removeParticipant(convId, targetUserId);-- res.json({ ok: true });-- } catch (err) {-- console.error('[DM] Remove participant error:', err.message);-- res.status(500).json({ error: 'Failed' });-- }--});----// Rename group conversation--router.patch('/conversations/:id', (req, res) => {-- try {-- const convId = parseInt(req.params.id);-- if (!dm.isParticipant(convId, req.user.id)) {-- return res.status(403).json({ error: 'Not a participant' });-- }-- let { name } = req.body;-- if (name) {-- name = String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);-- }-- dm.renameConversation(convId, name || null);-- res.json({ ok: true });-- } catch (err) {-- console.error('[DM] Rename error:', err.message);-- res.status(500).json({ error: 'Failed' });-- }--});----// Total unread count--router.get('/unread', (req, res) => {-- try {-- const total = dm.getTotalUnread(req.user.id);-- res.json({ unread: total });-- } catch (err) {-- res.status(500).json({ error: 'Failed' });-- }--});----// Search users for new-message picker--router.get('/users/search', (req, res) => {-- try {-- const q = (req.query.q || '').trim();-- if (q.length < 2) return res.json({ users: [] });-- const users = dm.searchUsers(q, req.user.id);-- res.json({ users });-- } catch (err) {-- res.status(500).json({ error: 'Failed' });-- }--});----// ── Block & message management ───────────────────────────────----// Block a user--router.post('/blocks/:userId', (req, res) => {-- try {-- const targetId = parseInt(req.params.userId);-- if (!targetId || targetId === req.user.id) {-- return res.status(400).json({ error: 'Invalid user' });-- }-- dm.blockUser(req.user.id, targetId);-- res.json({ ok: true });-- } catch (err) {-- console.error('[DM] Block error:', err.message);-- res.status(500).json({ error: 'Failed to block user' });-- }--});----// Unblock a user--router.delete('/blocks/:userId', (req, res) => {-- try {-- const targetId = parseInt(req.params.userId);-- dm.unblockUser(req.user.id, targetId);-- res.json({ ok: true });-- } catch (err) {-- console.error('[DM] Unblock error:', err.message);-- res.status(500).json({ error: 'Failed to unblock user' });-- }--});----// List blocked users--router.get('/blocks', (req, res) => {-- try {-- const blocked = dm.getBlockedUsers(req.user.id);-- res.json({ blocked });-- } catch (err) {-- res.status(500).json({ error: 'Failed to load blocked users' });-- }--});----// Check if a specific user is blocked--router.get('/blocks/check/:userId', (req, res) => {-- try {-- const targetId = parseInt(req.params.userId);-- res.json({ blocked: dm.hasBlocked(req.user.id, targetId) });-- } catch {-- res.json({ blocked: false });-- }--});----// Delete own message--router.delete('/conversations/:id/messages/:msgId', (req, res) => {-- try {-- const convId = parseInt(req.params.id);-- const msgId = parseInt(req.params.msgId);-- if (!dm.isParticipant(convId, req.user.id)) {-- return res.status(403).json({ error: 'Not a participant' });-- }-- const deleted = dm.deleteMessage(msgId, req.user.id);-- if (!deleted) return res.status(403).json({ error: 'Cannot delete this message' });-- res.json({ ok: true });-- } catch (err) {-- console.error('[DM] Delete message error:', err.message);-- res.status(500).json({ error: 'Failed to delete message' });-- }--});----module.exports = router;ddeleted file mode 100644@@@ -1,777 +0,0 @@--/**-- * OpenVibe.Live — Chat API Routes-- *-- * POST /api/chat/send - Send a chat message (REST fallback)-- * GET /api/chat/:streamId/history - Get chat history for a stream-- * GET /api/chat/:streamId/users - Get users in chat-- * GET /api/chat/search - Search chat messages-- * GET /api/chat/user/:userId/history - Get a user's chat history-- * GET /api/chat/user/:username/profile - Get user profile card data-- */--const express = require('express');--const db = require('../db/database');--const { optionalAuth, requireAuth, requireAdmin } = require('../auth/auth');--const permissions = require('../auth/permissions');--const historyStore = require('./history-store');----const router = express.Router();----/**-- * Attach each author's CURRENT cosmetics (name/particle/hat effects) + tag to-- * history rows, so historical messages render with the same effects as live ones-- * (chat_messages doesn't persist cosmetics). Cached per user_id within the batch.-- */--function enrichMessagesWithCosmetics(messages) {-- let cosmetics = null, tags = null;-- try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }-- try { tags = require('./tags'); } catch { /* */ }-- if ((!cosmetics && !tags) || !Array.isArray(messages)) return messages;-- const cache = new Map();-- for (const m of messages) {-- if (!m || !m.user_id) continue;-- let prof = cache.get(m.user_id);-- if (!prof) {-- prof = {};-- try { if (cosmetics) Object.assign(prof, cosmetics.getCosmeticProfile(m.user_id) || {}); } catch { /* */ }-- try { if (tags) prof.tag = tags.getTagProfile(m.user_id) || null; } catch { /* */ }-- cache.set(m.user_id, prof);-- }-- if (prof.nameFX) m.nameFX = prof.nameFX;-- if (prof.particleFX) m.particleFX = prof.particleFX;-- if (prof.hatFX) m.hatFX = prof.hatFX;-- if (prof.tag) m.tag = prof.tag;-- }-- return messages;--}----const MIN_SELF_DELETE_MINUTES = 3;--const MAX_SELF_DELETE_MINUTES = 10080;--const GIF_ALLOWED_PROVIDERS = new Set(['tenor', 'giphy']);----function getGifProviderConfig(provider) {-- if (provider === 'giphy') {-- return {-- key: db.getSetting('gif_giphy_api_key') || '',-- trendingUrl: 'https://api.giphy.com/v1/gifs/trending',-- searchUrl: 'https://api.giphy.com/v1/gifs/search',-- };-- }-- return {-- key: db.getSetting('gif_tenor_api_key') || '',-- trendingUrl: 'https://tenor.googleapis.com/v2/featured',-- searchUrl: 'https://tenor.googleapis.com/v2/search',-- };--}----function normalizeGifProvider(provider) {-- const normalized = String(provider || 'tenor').trim().toLowerCase();-- return GIF_ALLOWED_PROVIDERS.has(normalized) ? normalized : 'tenor';--}----function mapGifResults(provider, payload) {-- if (provider === 'giphy') {-- const items = Array.isArray(payload?.data) ? payload.data : [];-- return items.map((item) => {-- const tiny = item?.images?.fixed_width_small?.url || item?.images?.preview_gif?.url || item?.images?.fixed_width?.url || '';-- const full = item?.images?.original?.url || item?.images?.fixed_width?.url || tiny;-- return {-- id: item?.id || null,-- preview_url: tiny,-- full_url: full,-- title: item?.title || item?.slug || 'GIF',-- provider: 'giphy',-- source_url: item?.url || full,-- };-- }).filter((item) => item.preview_url && item.full_url);-- }---- const items = Array.isArray(payload?.results) ? payload.results : [];-- return items.map((item) => {-- const tiny = item?.media_formats?.tinygif?.url || item?.media_formats?.gif?.url || '';-- const full = item?.media_formats?.gif?.url || tiny;-- return {-- id: item?.id || null,-- preview_url: tiny,-- full_url: full,-- title: item?.content_description || item?.title || 'GIF',-- provider: 'tenor',-- source_url: item?.itemurl || full,-- };-- }).filter((item) => item.preview_url && item.full_url);--}----function normalizeAutoDeleteMinutes(value) {-- const mins = parseInt(value, 10);-- if (!Number.isFinite(mins) || mins < MIN_SELF_DELETE_MINUTES) return 0;-- return Math.min(MAX_SELF_DELETE_MINUTES, mins);--}----router.get('/gif/providers', optionalAuth, (req, res) => {-- res.json({-- providers: {-- tenor: !!db.getSetting('gif_tenor_api_key'),-- giphy: !!db.getSetting('gif_giphy_api_key'),-- },-- defaultProvider: db.getSetting('gif_tenor_api_key') ? 'tenor' : (db.getSetting('gif_giphy_api_key') ? 'giphy' : null),-- });--});----router.get('/gif/trending', optionalAuth, async (req, res) => {-- try {-- const provider = normalizeGifProvider(req.query.provider);-- const config = getGifProviderConfig(provider);-- if (!config.key) return res.status(503).json({ error: `${provider} API key not configured` });---- const url = new URL(config.trendingUrl);-- if (provider === 'giphy') {-- url.searchParams.set('api_key', config.key);-- url.searchParams.set('limit', '30');-- url.searchParams.set('rating', 'pg-13');-- } else {-- url.searchParams.set('key', config.key);-- url.searchParams.set('limit', '30');-- url.searchParams.set('media_filter', 'tinygif,gif');-- }---- const upstream = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });-- if (!upstream.ok) return res.status(502).json({ error: `GIF provider request failed (${upstream.status})` });-- const payload = await upstream.json();-- res.json({ provider, results: mapGifResults(provider, payload) });-- } catch (err) {-- res.status(500).json({ error: err.message || 'Failed to load GIFs' });-- }--});----router.get('/gif/search', optionalAuth, async (req, res) => {-- try {-- const provider = normalizeGifProvider(req.query.provider);-- const query = String(req.query.q || '').trim();-- if (query.length < 2) return res.status(400).json({ error: 'Query must be at least 2 characters' });---- const config = getGifProviderConfig(provider);-- if (!config.key) return res.status(503).json({ error: `${provider} API key not configured` });---- const url = new URL(config.searchUrl);-- if (provider === 'giphy') {-- url.searchParams.set('api_key', config.key);-- url.searchParams.set('q', query);-- url.searchParams.set('limit', '30');-- url.searchParams.set('rating', 'pg-13');-- } else {-- url.searchParams.set('key', config.key);-- url.searchParams.set('q', query);-- url.searchParams.set('limit', '30');-- url.searchParams.set('media_filter', 'tinygif,gif');-- }---- const upstream = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });-- if (!upstream.ok) return res.status(502).json({ error: `GIF provider request failed (${upstream.status})` });-- const payload = await upstream.json();-- res.json({ provider, results: mapGifResults(provider, payload) });-- } catch (err) {-- res.status(500).json({ error: err.message || 'Failed to search GIFs' });-- }--});----/**-- * Hydrate reply_to context onto an array of message rows.-- * Each row must have reply_to_id (from DB). Adds a reply_to object-- * with { id, username, user_id, message } for the parent message.-- */--function hydrateReplies(messages) {-- const replyIds = [...new Set(messages.map(m => m.reply_to_id).filter(Boolean))];-- if (!replyIds.length) return messages;-- const placeholders = replyIds.map(() => '?').join(',');-- const parents = db.all(-- `SELECT id, username, user_id, message-- FROM chat_messages-- WHERE id IN (${placeholders})-- AND is_deleted = 0-- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,-- replyIds-- );-- const parentMap = new Map(parents.map(p => [p.id, p]));-- return messages.map(m => {-- if (m.reply_to_id && parentMap.has(m.reply_to_id)) {-- const p = parentMap.get(m.reply_to_id);-- m.reply_to = {-- id: p.id,-- username: p.username,-- user_id: p.user_id,-- message: p.message.length > 100 ? p.message.slice(0, 100) + '…' : p.message,-- };-- }-- return m;-- });--}----// ── Send Chat Message (REST fallback when WS is down) ────────--router.post('/send', requireAuth, (req, res) => {-- try {-- const text = (req.body.message || '').trim();-- // 6000 = the absolute ceiling any channel/admin can configure (see chat-server handleChatMessage).-- if (!text || text.length > 6000) {-- return res.status(400).json({ error: 'Invalid message' });-- }---- // Ban check-- if (db.isUserBanned(req.user.id, null)) {-- return res.status(403).json({ error: 'You are banned from chat' });-- }---- const chatServer = require('./chat-server');---- // Word filter-- const wordFilter = require('./word-filter');-- const filterResult = wordFilter.check(text);-- const filtered = filterResult.safe ? text : filterResult.filtered;---- if (wordFilter.isSpam(filtered)) {-- return res.status(400).json({ error: 'Message blocked: detected as spam' });-- }---- const username = req.user.display_name || req.user.username;-- const autoDeleteMinutes = normalizeAutoDeleteMinutes(req.body.auto_delete_minutes);-- const autoDeleteAt = autoDeleteMinutes-- ? new Date(Date.now() + autoDeleteMinutes * 60 * 1000).toISOString()-- : null;---- // Reply-to support-- const replyToId = req.body.reply_to_id ? parseInt(req.body.reply_to_id) : null;-- let replyTo = null;-- if (replyToId) {-- const parent = db.getChatMessageById(replyToId);-- if (parent && !parent.is_deleted) {-- replyTo = {-- id: parent.id,-- username: parent.username,-- user_id: parent.user_id,-- message: parent.message.length > 100 ? parent.message.slice(0, 100) + '…' : parent.message,-- };-- }-- }---- const chatMsg = {-- type: 'chat',-- username,-- core_username: req.user.username,-- user_id: req.user.id,-- anon_id: null,-- role: req.user.role || 'user',-- message: filtered,-- stream_id: null,-- is_global: true,-- avatar_url: req.user.avatar_url || null,-- profile_color: req.user.profile_color || '#999',-- filtered: !filterResult.safe,-- timestamp: new Date().toISOString(),-- auto_delete_at: autoDeleteAt,-- };---- // Attach cosmetics-- try {-- const cosmetics = require('../monetization/cosmetics');-- const cosmeticProfile = cosmetics.getCosmeticProfile(req.user.id);-- if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;-- if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;-- if (cosmeticProfile.hatFX) chatMsg.hatFX = cosmeticProfile.hatFX;-- if (cosmeticProfile.voiceFX) chatMsg.voiceFX = cosmeticProfile.voiceFX;-- } catch { /* non-critical */ }---- // Attach tag-- try {-- const tags = require('./tags');-- const tagProfile = tags.getTagProfile(req.user.id);-- if (tagProfile) chatMsg.tag = tagProfile;-- } catch { /* non-critical */ }---- // Save to database-- let savedId = null;-- try {-- const result = db.saveChatMessage({-- stream_id: null,-- user_id: req.user.id,-- anon_id: null,-- username,-- message: filtered,-- message_type: 'chat',-- is_global: true,-- reply_to_id: replyToId,-- auto_delete_at: autoDeleteAt,-- });-- savedId = result.lastInsertRowid;-- } catch { /* non-critical */ }---- // Attach message ID and reply context to broadcast-- if (savedId) chatMsg.id = Number(savedId);-- if (replyTo) chatMsg.reply_to = replyTo;---- // Broadcast to all global chat clients-- chatServer.broadcastGlobal(chatMsg);-- res.json({ ok: true });-- } catch (err) {-- console.error('[Chat] REST send error:', err.message);-- res.status(500).json({ error: 'Failed to send message' });-- }--});----// ── Search Chat Messages (admin or self) ─────────────────────--router.get('/search', requireAuth, (req, res) => {-- try {-- const limit = Math.min(parseInt(req.query.limit || '50'), 200);-- const offset = parseInt(req.query.offset || '0');-- const query = req.query.q || '';-- const userId = req.query.user_id ? parseInt(req.query.user_id) : null;-- const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;---- // Admin / global_mod can search anyone; others search only their own-- const effectiveUserId = permissions.canViewOtherUserLogs(req.user) ? userId : req.user.id;---- const result = db.searchChatMessages({-- query, userId: effectiveUserId, streamId, limit, offset,-- });---- res.json(result);-- } catch (err) {-- res.status(500).json({ error: 'Search failed' });-- }--});----// ── User Chat History ────────────────────────────────────────--router.get('/user/:userId/history', requireAuth, (req, res) => {-- try {-- const userId = parseInt(req.params.userId);-- const limit = Math.min(parseInt(req.query.limit || '50'), 200);-- const offset = parseInt(req.query.offset || '0');---- // Admin / global_mod can view anyone; others view only their own-- if (!permissions.canViewOtherUserLogs(req.user) && req.user.id !== userId) {-- return res.status(403).json({ error: 'Access denied' });-- }---- const result = db.getUserChatHistory(userId, limit, offset);-- res.json(result);-- } catch (err) {-- res.status(500).json({ error: 'Failed to get chat history' });-- }--});----// ── User Profile Card ────────────────────────────────────────--router.get('/user/:username/profile', optionalAuth, (req, res) => {-- try {-- // Try core username first, then fall back to display_name lookup-- let user = db.getUserByUsername(req.params.username);-- if (!user) {-- user = db.get('SELECT * FROM users WHERE display_name = ? COLLATE NOCASE', [req.params.username]);-- }-- if (!user) return res.status(404).json({ error: 'User not found' });---- const profile = db.getUserProfile(user.id);-- if (!profile) return res.status(404).json({ error: 'Profile not found' });-- // The card shows OpenCoins (earned, part of the game). Vibes are real money and presence is-- // personal, so both stay with the user themselves.-- if (!req.user || req.user.id !== user.id) {-- delete profile.openvibe_bucks_balance;-- delete profile.last_seen;-- }---- // Legacy game skills, read-only (never creates a game_players row)-- const game = db.getLegacyGameProfile(user.id);-- if (game) profile.game = game;---- res.json(profile);-- } catch (err) {-- res.status(500).json({ error: 'Failed to get profile' });-- }--});----// ── Chat-relay (external) user info: join date (first message) etc. ──--router.get('/relay-user/:platform/:username', optionalAuth, (req, res) => {-- try {-- const r = db.getRelayUser(req.params.platform, req.params.username);-- res.json({ relayUser: r || null });-- } catch (err) {-- res.status(500).json({ error: 'Failed to get relay user' });-- }--});----// Anonymous chatter info: first-seen (anon-number assignment) + first chat + count.--router.get('/anon/:anonId', optionalAuth, (req, res) => {-- try {-- const anonId = String(req.params.anonId || '');-- if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });-- res.json({ anon: db.getAnonMeta(anonId) });-- } catch (err) {-- res.status(500).json({ error: 'Failed to get anon info' });-- }--});----// An anonymous chatter's chat-message history — same gate as native logs.--router.get('/anon/:anonId/logs', requireAuth, (req, res) => {-- try {-- if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });-- const anonId = String(req.params.anonId || '');-- if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });-- const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);-- const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);-- const query = String(req.query.q || '').trim();-- const r = db.getAnonChatHistory(anonId, { limit, offset, query });-- res.json(r);-- } catch (err) {-- res.status(500).json({ error: 'Failed to get anon user logs' });-- }--});----// A relay (external-platform) user's chat-message history — same gate as native logs.--router.get('/relay-user/:platform/:username/logs', requireAuth, (req, res) => {-- try {-- if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });-- const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);-- const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);-- const query = String(req.query.q || '').trim();-- const r = db.getRelayUserChatHistory(req.params.platform, req.params.username, { limit, offset, query });-- res.json(r);-- } catch (err) {-- res.status(500).json({ error: 'Failed to get relay user logs' });-- }--});----// ── Global Chat History (all streams) ────────────────────────--// Patterns behind the viewer-side "Friendly global chat" setting (server/chat/moderation-utils.js is--// the single source; the client compiles these).--router.get('/filters/friendly', (req, res) => {-- const { FRIENDLY_FILTER_CATEGORIES } = require('./moderation-utils');-- res.set('Cache-Control', 'public, max-age=3600');-- res.json({ version: 1, categories: FRIENDLY_FILTER_CATEGORIES.map(({ key, label, patterns }) => ({ key, label, patterns })) });--});----router.get('/global/history', optionalAuth, (req, res) => {-- try {-- const limit = req.query.limit;-- const before = req.query.before;-- const afterId = req.query.after_id;-- const channelUsername = String(req.query.username || '').trim();-- const decorate = (rows) => enrichMessagesWithCosmetics(hydrateReplies(rows));-- // after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).-- // Anything else = a page: the newest rows, optionally before a timestamp.-- const out = afterId != null-- ? historyStore.delta('global', { afterId, limit, channelUsername, decorate })-- : historyStore.page('global', { limit, before, channelUsername, decorate });-- res.json(out);-- } catch (err) {-- res.status(500).json({ error: 'Failed to get global chat history' });-- }--});----// ── Chat Replay (for VOD/clip playback sync) ────────────────--router.get('/:streamId/replay', optionalAuth, (req, res) => {-- try {-- const streamId = parseInt(req.params.streamId);-- if (!streamId) return res.status(400).json({ error: 'Invalid stream ID' });---- const from = req.query.from || null; // ISO timestamp-- const to = req.query.to || null; // ISO timestamp---- const messages = db.getChatReplay(streamId, from, to);-- res.json({ messages });-- } catch (err) {-- res.status(500).json({ error: 'Failed to get chat replay' });-- }--});----// ── Chat History ─────────────────────────────────────────────--// Loads history for a streamer's chat. By default this spans ALL of that--// broadcaster's stream sessions (not just the one slot/session being viewed),--// so viewers keep context when a stream restarts or the streamer runs multiple--// slots. Each message carries its source stream (title/slug/live) so the client--// can label it and let viewers hop between the streamer's live slots.--// Pass ?scope=stream to restrict to the single session (legacy behavior).--router.get('/:streamId/history', optionalAuth, (req, res) => {-- try {-- const limit = Math.min(parseInt(req.query.limit || '500'), 500);-- const before = req.query.before; // ISO timestamp for pagination-- const scope = req.query.scope || 'streamer';---- // Resolve which broadcaster this stream belongs to.-- const stream = db.getStreamById(req.params.streamId);-- const broadcasterId = stream ? stream.user_id : null;-- const spanStreamer = scope !== 'stream' && broadcasterId;---- // COALESCE the channel name from the source stream's owner (live rows) OR the-- // message's channel_user_id (offline rows) so the streamer/channel tag renders-- // in history for both. cu = the broadcaster resolved from channel_user_id.-- const select = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name,-- u.username AS core_username,-- s.title AS source_stream_title, s.managed_stream_id AS source_managed_id,-- s.is_live AS source_is_live, ms.slug AS source_slug,-- COALESCE(bu.username, cu.username) AS source_channel`;-- let sql;-- const params = [];-- if (spanStreamer) {-- // Span by channel_user_id (LEFT JOIN streams) so OFFLINE messages-- // (stream_id NULL) survive when the streamer goes live — the old INNER-- // JOIN on streams dropped them.-- sql = `${select}-- FROM chat_messages cm-- LEFT JOIN users u ON cm.user_id = u.id-- LEFT JOIN streams s ON cm.stream_id = s.id-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id-- LEFT JOIN users bu ON s.user_id = bu.id-- LEFT JOIN users cu ON cm.channel_user_id = cu.id-- WHERE cm.channel_user_id = ? AND cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;-- params.push(broadcasterId);-- } else {-- sql = `${select}-- FROM chat_messages cm-- LEFT JOIN users u ON cm.user_id = u.id-- LEFT JOIN streams s ON cm.stream_id = s.id-- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id-- LEFT JOIN users bu ON s.user_id = bu.id-- LEFT JOIN users cu ON cm.channel_user_id = cu.id-- WHERE cm.stream_id = ? AND cm.is_deleted = 0-- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;-- params.push(req.params.streamId);-- }---- // after_id: cursor read (oldest→newest, primary-key range scan) for reopen/reconnect.-- // Otherwise a page: the newest rows, optionally before a timestamp.-- const afterId = req.query.after_id != null ? Math.max(0, parseInt(req.query.after_id, 10) || 0) : null;-- if (afterId != null) {-- sql += ` AND cm.id > ? ORDER BY cm.id ASC LIMIT ?`;-- params.push(afterId, limit + 1);-- } else {-- if (before) {-- sql += ` AND cm.timestamp < ?`;-- params.push(before);-- }-- sql += ` ORDER BY cm.timestamp DESC LIMIT ?`;-- params.push(limit);-- }---- let rows = db.all(sql, params);-- let complete = true;-- if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();-- const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);-- const messages = enrichMessagesWithCosmetics(hydrateReplies(rows));---- // Currently-live slots for this broadcaster — lets the client render a-- // "hop between live streams" affordance.-- let liveSlots = [];-- let channel = null;-- if (broadcasterId) {-- try {-- const owner = db.getUserById(broadcasterId);-- channel = owner ? owner.username : null;-- liveSlots = (db.getManagedStreamsByUserId(broadcasterId) || [])-- .filter(ms => ms.is_currently_live)-- .map(ms => ({-- managed_stream_id: ms.id,-- slug: ms.slug,-- title: ms.title,-- live_session_id: ms.live_session_id,-- }));-- } catch { /* non-critical */ }-- }-- res.json({-- messages, latest_id, complete, liveSlots, channel,-- activeStreamId: parseInt(req.params.streamId) || null,-- activeManagedId: stream ? (stream.managed_stream_id || null) : null,-- });-- } catch (err) {-- res.status(500).json({ error: 'Failed to get chat history' });-- }--});----// Persistent per-streamer chat history keyed by the broadcaster's USER id — spans--// every live slot AND offline periods (channel_user_id), so it works when the--// streamer has no active session. Used by the channel page (online + offline).--router.get('/channel/:userId/history', optionalAuth, (req, res) => {-- try {-- const userId = parseInt(req.params.userId);-- if (!userId) return res.status(400).json({ error: 'Bad user id' });-- const decorate = (rows) => enrichMessagesWithCosmetics(hydrateReplies(rows));-- const room = `channel:${userId}`;-- const out = req.query.after_id != null-- ? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit, decorate })-- : historyStore.page(room, { limit: req.query.limit, before: req.query.before, decorate });-- let liveSlots = [], channel = null;-- try {-- const owner = db.getUserById(userId);-- channel = owner ? owner.username : null;-- liveSlots = (db.getManagedStreamsByUserId(userId) || [])-- .filter(ms => ms.is_currently_live)-- .map(ms => ({ managed_stream_id: ms.id, slug: ms.slug, title: ms.title, live_session_id: ms.live_session_id }));-- } catch { /* non-critical */ }-- res.json({ ...out, liveSlots, channel, activeStreamId: null, activeManagedId: null });-- } catch (err) {-- res.status(500).json({ error: 'Failed to get channel chat history' });-- }--});----// ── Chat User Count ──────────────────────────────────────────--router.get('/:streamId/users', (req, res) => {-- const chatServer = require('./chat-server');-- const count = chatServer.getStreamViewerCount(parseInt(req.params.streamId));-- res.json({ count });--});----// ── Chat Log Management ─────────────────────────────────────----// Preview count of messages in a time range--router.post('/admin/purge/preview', requireAuth, (req, res) => {-- try {-- const { streamId, from, to } = req.body;-- if (!from || !to) return res.status(400).json({ error: 'from and to are required' });---- // Must be admin or stream owner-- let effectiveStreamId = streamId || null;-- if (!permissions.isAdmin(req.user)) {-- if (streamId) {-- const stream = db.getStreamById(streamId);-- if (!stream || stream.user_id !== req.user.id) {-- return res.status(403).json({ error: 'Not authorized' });-- }-- } else {-- // Non-admin without streamId: scope to their most recent stream-- const userStreams = db.getStreamsByUserId(req.user.id, 1);-- if (!userStreams?.length) {-- return res.json({ count: 0 });-- }-- effectiveStreamId = userStreams[0].id;-- }-- }---- const count = db.countChatMessagesByTimeRange(effectiveStreamId, from, to);-- res.json({ count });-- } catch (e) {-- console.error('[Chat] Purge preview error:', e.message);-- res.status(500).json({ error: 'Failed to count messages' });-- }--});----// Delete messages in a time range (soft delete)--router.delete('/admin/purge', requireAuth, (req, res) => {-- try {-- const { streamId, from, to } = req.body;-- if (!from || !to) return res.status(400).json({ error: 'from and to are required' });---- let effectiveStreamId = streamId || null;-- if (!permissions.isAdmin(req.user)) {-- if (streamId) {-- const stream = db.getStreamById(streamId);-- if (!stream || stream.user_id !== req.user.id) {-- return res.status(403).json({ error: 'Not authorized' });-- }-- } else {-- // Non-admin without streamId: scope to their most recent stream-- const userStreams = db.getStreamsByUserId(req.user.id, 1);-- if (!userStreams?.length) {-- return res.json({ deleted: 0 });-- }-- effectiveStreamId = userStreams[0].id;-- }-- }---- const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);---- // Broadcast delete event to live chat-- try {-- const chatServer = require('./chat-server');-- const payload = { type: 'purge', streamId: effectiveStreamId, from, to, by: req.user.display_name };-- if (effectiveStreamId) {-- chatServer.broadcastToStream(effectiveStreamId, payload);-- } else {-- chatServer.broadcastGlobal(payload);-- }-- } catch { /* chat server may not be initialized */ }---- console.log(`[Chat] Purged messages: stream=${effectiveStreamId || 'global'} from=${from} to=${to} by=${req.user.username} changes=${result?.changes || 0}`);-- res.json({ deleted: result?.changes || 0 });-- } catch (e) {-- console.error('[Chat] Purge error:', e.message);-- res.status(500).json({ error: 'Failed to purge messages' });-- }--});----// Get paginated chat logs with filters--router.get('/admin/logs', requireAuth, (req, res) => {-- try {-- const { streamId, username, search, from, to, messageType, page, limit, includeDeleted } = req.query;---- // Must be admin or stream owner-- let effectiveStreamId = streamId ? parseInt(streamId) : undefined;-- if (!permissions.isAdmin(req.user)) {-- if (streamId) {-- const stream = db.getStreamById(parseInt(streamId));-- if (!stream || stream.user_id !== req.user.id) {-- return res.status(403).json({ error: 'Not authorized' });-- }-- } else {-- // Non-admin without streamId: scope to their most recent stream-- const userStreams = db.getStreamsByUserId(req.user.id, 1);-- if (!userStreams?.length) {-- return res.json({ rows: [], total: 0, page: 1, limit: 50, totalPages: 0 });-- }-- effectiveStreamId = userStreams[0].id;-- }-- }---- const result = db.getChatLogs({-- streamId: effectiveStreamId,-- username, search, from, to, messageType,-- page: parseInt(page) || 1,-- limit: Math.min(parseInt(limit) || 50, 200),-- includeDeleted: includeDeleted === 'true' && permissions.isAdmin(req.user),-- });-- res.json(result);-- } catch (e) {-- console.error('[Chat] Logs error:', e.message);-- res.status(500).json({ error: 'Failed to get chat logs' });-- }--});----// Export chat logs as CSV or JSON--router.get('/admin/logs/export', requireAuth, (req, res) => {-- try {-- const { streamId, username, search, from, to, messageType, format } = req.query;---- if (!permissions.isAdmin(req.user)) {-- if (streamId) {-- const stream = db.getStreamById(parseInt(streamId));-- if (!stream || stream.user_id !== req.user.id) {-- return res.status(403).json({ error: 'Not authorized' });-- }-- } else {-- return res.status(403).json({ error: 'Only admins can export all chat logs' });-- }-- }---- // Get all matching rows (up to 50k)-- const result = db.getChatLogs({-- streamId: streamId ? parseInt(streamId) : undefined,-- username, search, from, to, messageType,-- page: 1, limit: 50000,-- });---- if (format === 'csv') {-- const header = 'id,timestamp,username,message,message_type,stream_id,is_global,source_platform\n';-- const csvRows = result.rows.map(r => {-- const msg = (r.message || '').replace(/"/g, '""');-- return `${r.id},"${r.timestamp}","${(r.username || '').replace(/"/g, '""')}","${msg}","${r.message_type || ''}",${r.stream_id || ''},"${r.is_global || 0}","${r.source_platform || ''}"`;-- });-- res.setHeader('Content-Type', 'text/csv');-- res.setHeader('Content-Disposition', `attachment; filename="chat-logs-${Date.now()}.csv"`);-- res.send(header + csvRows.join('\n'));-- } else {-- res.setHeader('Content-Type', 'application/json');-- res.setHeader('Content-Disposition', `attachment; filename="chat-logs-${Date.now()}.json"`);-- res.json(result.rows);-- }-- } catch (e) {-- console.error('[Chat] Export error:', e.message);-- res.status(500).json({ error: 'Failed to export chat logs' });-- }--});----module.exports = router;ddeleted file mode 100644@@@ -1,392 +0,0 @@--/**-- * OpenVibe.Live — Channel Sound Commands API-- *-- * Per-channel, viewer-uploadable sound clips triggered by !command in chat.-- *-- * GET /api/sounds/channel/:userId - Sounds for a streamer's channel-- * GET /api/sounds/all/:streamId - Sounds available in a stream context-- * POST /api/sounds - Upload a channel sound (auth required)-- * DELETE /api/sounds/:id - Delete a sound (uploader / mod / owner)-- * GET /api/sounds/file/:filename - Serve a sound file-- */--const express = require('express');--const path = require('path');--const fs = require('fs');--const { spawn } = require('child_process');--const multer = require('multer');--const db = require('../db/database');--const { requireAuth } = require('../auth/auth');--const permissions = require('../auth/permissions');--const config = require('../config');----const router = express.Router();----// Commands already handled elsewhere in chat — cannot be overridden by a sound.--const RESERVED_COMMANDS = new Set([-- 'sb', 'gotti', 'sr', 'yt', 'youtube', 'req', 'request', 'queue', 'np',-- 'nowplaying', 'watching', 'skip', 'mediahelp', 'say',-- 'forward', 'backward', 'left', 'right', 'liftup', 'liftdown', 'headup', 'headdown',--]);----const MIME_TO_EXT = {-- 'audio/mpeg': '.mp3', 'audio/mp3': '.mp3', 'audio/wav': '.wav', 'audio/x-wav': '.wav',-- 'audio/wave': '.wav', 'audio/vnd.wave': '.wav', 'audio/x-pn-wav': '.wav',-- 'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/mp4': '.m4a', 'audio/x-m4a': '.m4a',-- 'audio/aac': '.aac', 'audio/flac': '.flac', 'audio/x-flac': '.flac', 'audio/opus': '.opus',--};--// Browsers/OSes report .wav (and others) under many mimetypes — or none at all--// (application/octet-stream). Fall back to the file extension so uploads don't wrongly fail.--const ALLOWED_EXT = new Set(['.mp3', '.wav', '.ogg', '.oga', '.opus', '.webm', '.m4a', '.mp4', '.aac', '.flac']);----function extFor(file) {-- if (MIME_TO_EXT[file.mimetype]) return MIME_TO_EXT[file.mimetype];-- const ext = path.extname(file.originalname || '').toLowerCase();-- return ALLOWED_EXT.has(ext) ? ext : '';--}----function soundsDir() {-- const dir = path.resolve(config.sounds.path);-- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });-- return dir;--}----const soundStorage = multer.diskStorage({-- destination: (req, file, cb) => cb(null, soundsDir()),-- filename: (req, file, cb) => {-- const ext = extFor(file) || '.bin';-- cb(null, `snd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);-- },--});--const soundUpload = multer({-- storage: soundStorage,-- limits: { fileSize: config.sounds.maxSizeKb * 1024 },-- fileFilter: (req, file, cb) => {-- // Accept by known audio mimetype OR by a recognized audio extension.-- if (MIME_TO_EXT[file.mimetype] || ALLOWED_EXT.has(path.extname(file.originalname || '').toLowerCase())) {-- cb(null, true);-- } else {-- cb(new Error('Only audio files are allowed (MP3, WAV, OGG, Opus, WebM, M4A, AAC, FLAC).'));-- }-- },--});----/** Transcode an audio file to a canonical MP3 (fast decode, small, universal). Returns the-- * new path on success (original left for the caller to delete), or null on failure. */--function convertToMp3(srcPath) {-- return new Promise((resolve) => {-- const outPath = srcPath.replace(/\.[^.]+$/, '') + '.conv.mp3';-- let proc;-- try {-- proc = spawn('ffmpeg', ['-y', '-i', srcPath, '-vn', '-ac', '2', '-ar', '44100', '-c:a', 'libmp3lame', '-b:a', '128k', outPath]);-- } catch { return resolve(null); }-- proc.on('close', (code) => resolve(code === 0 && fs.existsSync(outPath) ? outPath : null));-- proc.on('error', () => resolve(null));-- setTimeout(() => { try { proc.kill(); } catch {} resolve(null); }, 20000);-- });--}----/** Probe an audio file's duration in seconds (0 on failure). */--function probeDuration(filePath) {-- return new Promise((resolve) => {-- let done = false;-- const finish = (v) => { if (!done) { done = true; resolve(v); } };-- let out = '';-- let probe;-- try {-- probe = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', filePath]);-- } catch { return finish(0); }-- probe.stdout.on('data', (d) => { out += d; });-- probe.on('close', () => {-- try {-- const info = JSON.parse(out);-- const dur = parseFloat(info.format?.duration || '0');-- finish(Number.isFinite(dur) && dur > 0 ? dur : 0);-- } catch { finish(0); }-- });-- probe.on('error', () => finish(0));-- setTimeout(() => { try { probe.kill(); } catch {} finish(0); }, 10000);-- });--}----function serializeSound(s) {-- return {-- id: s.id,-- command: s.command,-- url: `/api/sounds/file/${path.basename(s.url)}`,-- duration_seconds: Math.round((s.duration_seconds || 0) * 10) / 10,-- uploader: s.created_by_name || 'someone',-- uploader_id: s.created_by,-- emote_code: s.emote_code || '',-- created_at: s.created_at,-- };--}----// ── List a channel's sounds ──────────────────────────────────--router.get('/channel/:userId', (req, res) => {-- try {-- const userId = parseInt(req.params.userId);-- if (!userId) return res.json({ sounds: [] });-- res.json({ sounds: db.getChannelSounds(userId).map(serializeSound) });-- } catch (err) {-- res.status(500).json({ error: 'Failed to load channel sounds' });-- }--});----// ── List sounds available in a stream context ────────────────--router.get('/all/:streamId', (req, res) => {-- try {-- const stream = db.getStreamById(parseInt(req.params.streamId));-- if (!stream?.user_id) return res.json({ sounds: [] });-- res.json({ sounds: db.getChannelSounds(stream.user_id).map(serializeSound) });-- } catch (err) {-- res.status(500).json({ error: 'Failed to load sounds' });-- }--});----// ── Upload a channel sound ───────────────────────────────────--router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {-- const cleanup = () => { if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch {} } };-- try {-- if (!req.file) return res.status(400).json({ error: 'No audio file uploaded' });---- const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');-- if (!/^[a-z0-9_]{2,24}$/.test(command)) {-- cleanup();-- return res.status(400).json({ error: 'Command must be 2-24 letters, numbers, or underscores' });-- }-- if (RESERVED_COMMANDS.has(command)) {-- cleanup();-- return res.status(400).json({ error: `"!${command}" is a reserved command — pick another name` });-- }---- // Resolve target channel: channel_id = streamer's user id; stream_id resolves to its owner.-- let channelOwnerId = parseInt(req.body.channel_id) || null;-- if (!channelOwnerId && req.body.stream_id) {-- channelOwnerId = db.getStreamById(parseInt(req.body.stream_id))?.user_id || null;-- }-- if (!channelOwnerId) channelOwnerId = req.user.id;-- const channel = db.getChannelByUserId(channelOwnerId) || (channelOwnerId === req.user.id ? db.ensureChannel(req.user.id) : null);-- if (!channel) {-- cleanup();-- return res.status(404).json({ error: 'Channel not found' });-- }---- const settings = (await require('./moderation-client').getChannelModeration(channel.id)).settings;-- const isMod = await permissions.canModerateChannel(req.user, channel.id);-- const isOwnChannel = channelOwnerId === req.user.id;---- if (!isMod && !settings.custom_sounds_enabled) {-- cleanup();-- return res.status(403).json({ error: 'This streamer has disabled viewer sound uploads.' });-- }-- if (!isMod && !isOwnChannel && (settings.uploads_mods_only || settings.sounds_mods_only)) {-- cleanup();-- return res.status(403).json({ error: 'Only channel mods can upload sounds here.' });-- }---- // Adding a NEW file to an EXISTING command is restricted to that command's-- // creator (or channel mods/owner), so viewers can't hijack someone's command.-- const existingForCmd = db.getChannelSoundByCommand(channelOwnerId, command);-- if (existingForCmd && !isMod && !isOwnChannel && existingForCmd.created_by !== req.user.id) {-- cleanup();-- return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can add more sounds to it.` });-- }---- // Per-channel + per-uploader caps-- if (db.countChannelSounds(channelOwnerId) >= config.sounds.maxPerChannel) {-- cleanup();-- return res.status(400).json({ error: `This channel has reached its sound limit (${config.sounds.maxPerChannel}).` });-- }-- if (!isMod && db.countChannelSoundsByUploader(channelOwnerId, req.user.id) >= config.sounds.maxPerUploaderPerChannel) {-- cleanup();-- return res.status(400).json({ error: `You've reached your upload limit for this channel (${config.sounds.maxPerUploaderPerChannel}).` });-- }---- // A command may hold multiple sounds (one is chosen at random on playback),-- // so duplicates are allowed. Per-channel / per-uploader count caps below still apply.---- // Duration gate-- const maxSeconds = Math.min(30, Math.max(1, settings.max_sound_seconds || config.sounds.defaultMaxSeconds));-- const duration = await probeDuration(req.file.path);-- if (!duration) {-- cleanup();-- return res.status(400).json({ error: 'Could not read that audio file — try a standard MP3 or WAV.' });-- }-- if (duration > maxSeconds + 0.25) {-- cleanup();-- return res.status(400).json({ error: `Sound is too long (${duration.toFixed(1)}s). Max is ${maxSeconds}s for this channel.` });-- }---- // Normalize every upload to MP3 — universal, small, and the fastest to decode for-- // low-latency chat playback. (Skip if it's already an .mp3.)-- let finalPath = req.file.path;-- let finalMime = req.file.mimetype;-- if (path.extname(finalPath).toLowerCase() !== '.mp3') {-- const mp3 = await convertToMp3(req.file.path);-- if (mp3) {-- try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }-- finalPath = mp3;-- finalMime = 'audio/mpeg';-- } else {-- cleanup();-- return res.status(400).json({ error: 'Could not process that audio file. Try a standard MP3 or WAV.' });-- }-- }---- // Emote attached to the command (per-command; new files inherit the existing one).-- const emoteCode = String(req.body.emote_code || (existingForCmd && existingForCmd.emote_code) || '').trim().slice(0, 32);-- const result = db.createChannelSound({-- channel_owner_id: channelOwnerId,-- command,-- url: finalPath,-- mime: finalMime,-- duration_seconds: duration,-- created_by: req.user.id,-- created_by_name: req.user.display_name || req.user.username,-- emote_code: emoteCode,-- });-- // If an emote was (re)specified, apply it to every sound under this command.-- if (req.body.emote_code !== undefined) { try { db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }-- try { require('../media-proxy/asset-sync').syncSoon(); } catch { /* mirror is best-effort */ }---- // Tell everyone watching this channel to refresh their sound list live.-- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }---- res.json({-- sound: {-- id: result.lastInsertRowid,-- command,-- url: `/api/sounds/file/${path.basename(finalPath)}`,-- duration_seconds: Math.round(duration * 10) / 10,-- channel_id: channelOwnerId,-- },-- });-- } catch (err) {-- console.error('[Sounds] Upload error:', err);-- cleanup();-- if (err && /UNIQUE constraint/i.test(err.message || '')) {-- return res.status(409).json({ error: 'That command already exists on this channel.' });-- }-- res.status(500).json({ error: 'Failed to upload sound' });-- }--});----// ── Multer error handler ─────────────────────────────────────--router.use((err, req, res, next) => {-- if (err.code === 'LIMIT_FILE_SIZE') {-- return res.status(400).json({ error: `File too large (max ${Math.round(config.sounds.maxSizeKb / 1024 * 10) / 10}MB)` });-- }-- if (err.message && err.message.includes('Only')) {-- return res.status(400).json({ error: err.message });-- }-- console.error('[Sounds] Middleware error:', err);-- res.status(500).json({ error: 'Sound upload failed' });--});----// ── Delete a sound ───────────────────────────────────────────--router.delete('/:id', requireAuth, async (req, res) => {-- try {-- const sound = db.getChannelSoundById(parseInt(req.params.id));-- if (!sound) return res.status(404).json({ error: 'Sound not found' });-- let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');-- if (!allowed) {-- const channel = db.getChannelByUserId(sound.channel_owner_id);-- if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;-- }-- if (!allowed) return res.status(403).json({ error: 'Not your sound' });---- if (sound.url && fs.existsSync(sound.url)) { try { fs.unlinkSync(sound.url); } catch {} }-- db.deleteChannelSound(sound.id);-- try { require('../media-proxy/asset-sync').removeAsset(sound.media_asset_id); } catch { /* */ }-- try { require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }-- res.json({ message: 'Sound deleted' });-- } catch (err) {-- res.status(500).json({ error: 'Failed to delete sound' });-- }--});----// ── Edit a !command group in place (rename / set emote) ──────--// Body: { channel_id? , stream_id?, command, newCommand?, emoteCode? }.--// A command may hold several sounds, so edits apply to the whole group.--// Allowed: channel owner, channel mods, admins, or the creator of every--// sound in the group (mirrors the add-to-command rule).--router.patch('/command', requireAuth, async (req, res) => {-- try {-- let channelOwnerId = parseInt(req.body.channel_id) || null;-- if (!channelOwnerId && req.body.stream_id) {-- channelOwnerId = db.getStreamById(parseInt(req.body.stream_id))?.user_id || null;-- }-- if (!channelOwnerId) channelOwnerId = req.user.id;-- const channel = db.getChannelByUserId(channelOwnerId);-- if (!channel) return res.status(404).json({ error: 'Channel not found' });---- const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');-- const group = (db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);-- if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });---- const isMod = await permissions.canModerateChannel(req.user, channel.id);-- const isOwnChannel = channelOwnerId === req.user.id;-- const isCreator = group.every((s) => s.created_by === req.user.id);-- if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {-- return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can edit it.` });-- }---- let finalCommand = command;-- if (req.body.newCommand !== undefined) {-- const next = String(req.body.newCommand || '').trim().toLowerCase().replace(/^!+/, '');-- if (!/^[a-z0-9_]{2,24}$/.test(next)) {-- return res.status(400).json({ error: 'Command must be 2-24 letters, numbers, or underscores' });-- }-- if (RESERVED_COMMANDS.has(next)) {-- return res.status(400).json({ error: `"!${next}" is a reserved command — pick another name` });-- }-- if (next !== command && db.getChannelSoundByCommand(channelOwnerId, next)) {-- return res.status(409).json({ error: `!${next} already exists in this channel.` });-- }-- if (next !== command) {-- db.renameChannelSoundCommand(channelOwnerId, command, next);-- finalCommand = next;-- }-- }-- if (req.body.emoteCode !== undefined) {-- const emoteCode = String(req.body.emoteCode || '').trim().replace(/^:|:$/g, '');-- if (emoteCode && !/^[a-zA-Z0-9_]{2,32}$/.test(emoteCode)) {-- return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });-- }-- db.setChannelSoundEmote(channelOwnerId, finalCommand, emoteCode);-- }---- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }-- res.json({ message: 'Sound command updated', command: finalCommand });-- } catch (err) {-- res.status(500).json({ error: 'Failed to update sound command' });-- }--});----// ── Serve a sound file ───────────────────────────────────────--const EXT_TO_MIME = {-- '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',-- '.webm': 'audio/webm', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.flac': 'audio/flac',--};--router.get('/file/:filename', (req, res) => {-- try {-- const filename = path.basename(req.params.filename);-- const filePath = path.resolve(soundsDir(), filename);-- if (!filePath.startsWith(soundsDir()) || !fs.existsSync(filePath)) {-- return res.status(404).json({ error: 'Sound not found' });-- }-- const ext = path.extname(filename).toLowerCase();-- res.setHeader('Content-Type', EXT_TO_MIME[ext] || 'application/octet-stream');-- res.setHeader('Cache-Control', 'public, max-age=86400');-- fs.createReadStream(filePath).pipe(res);-- } catch (err) {-- res.status(500).json({ error: 'Failed to serve sound' });-- }--});----// Streamer alert sounds (donation / goal-reached) moved to OpenVibe.Chat with the six tables--// (roadmap T3): they live on channel_moderation_settings, which Chat owns, and nginx routes--// /api/sounds there. Live's own sounds router keeps only the channel-sound commands above and--// runs only when Live itself serves chat.----module.exports = router;ddeleted file mode 100644@@@ -1,134 +0,0 @@--/**-- * OpenVibe.Live — TTS API Routes-- *-- * Public routes (authenticated users):-- * GET /api/tts/voices — List available voices-- * GET /api/tts/settings — Get TTS config for client-- *-- * Admin routes:-- * GET /api/tts/admin/settings — Full TTS config with API keys-- * PUT /api/tts/admin/settings — Update TTS config-- */--const express = require('express');--const router = express.Router();--const { requireAuth, requireAdmin } = require('../auth/auth');--const { isOwner } = require('../auth/permissions');--const ttsEngine = require('./tts-engine');--const db = require('../db/database');----// TTS credential fields/keys are owner-only. Admins may still manage the--// non-secret TTS config (enabled, provider, limits, default voice).--const TTS_SECRET_FIELDS = ['googleApiKey', 'googleServiceAccount', 'awsAccessKeyId', 'awsSecretAccessKey'];--const TTS_SECRET_KEYS = new Set([-- 'tts_google_api_key', 'tts_google_service_account',-- 'tts_aws_access_key_id', 'tts_aws_secret_access_key',--]);--function maskTtsSecret(v) {-- if (!v) return v;-- const s = String(v);-- return s.length <= 8 ? '••••••••' : '••••' + s.slice(-4);--}----// ── Public: Get available voices ──────────────────────────────--router.get('/voices', (req, res) => {-- try {-- const voices = ttsEngine.getAvailableVoices();-- res.json({ voices });-- } catch (e) {-- res.status(500).json({ error: e.message });-- }--});----// ── Public: Get TTS settings for client ───────────────────────--router.get('/settings', (req, res) => {-- try {-- const settings = ttsEngine.getTTSSettings();-- // Don't expose API keys to the client-- res.json({-- enabled: settings.enabled,-- provider: settings.provider,-- googleConfigured: !!(settings.googleApiKey || settings.googleServiceAccount),-- pollyConfigured: !!settings.awsAccessKeyId,-- espeakAvailable: !!ttsEngine.detectEspeak(),-- maxLength: settings.maxLength,-- maxQueuePerUser: settings.maxQueuePerUser,-- maxQueueGlobal: settings.maxQueueGlobal,-- defaultVoice: settings.defaultVoice,-- });-- } catch (e) {-- res.status(500).json({ error: e.message });-- }--});----// ── Admin: Get full TTS config ────────────────────────────────--router.get('/admin/settings', requireAuth, requireAdmin, (req, res) => {-- try {-- const settings = ttsEngine.getTTSSettings();-- // Mask credential fields for non-owners.-- if (!isOwner(req.user)) {-- for (const f of TTS_SECRET_FIELDS) {-- if (settings[f]) settings[f] = maskTtsSecret(settings[f]);-- }-- settings._secretsRedacted = true;-- }-- res.json({ settings });-- } catch (e) {-- res.status(500).json({ error: e.message });-- }--});----// ── Admin: Update TTS config ──────────────────────────────────--router.put('/admin/settings', requireAuth, requireAdmin, (req, res) => {-- try {-- const allowed = [-- 'tts_enabled', 'tts_provider',-- 'tts_google_api_key', 'tts_google_service_account',-- 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region',-- 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global',-- 'tts_default_voice',-- ];-- const updates = req.body.settings || req.body;-- const owner = isOwner(req.user);-- let count = 0;-- for (const [key, value] of Object.entries(updates)) {-- if (!allowed.includes(key)) continue;-- // Credential keys are owner-only.-- if (TTS_SECRET_KEYS.has(key) && !owner) continue;-- // Never overwrite a stored secret with a masked placeholder.-- if (TTS_SECRET_KEYS.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;-- db.setSetting(key, value);-- count++;-- }-- ttsEngine.invalidateSettingsCache();-- res.json({ success: true, updated: count });-- } catch (e) {-- res.status(500).json({ error: e.message });-- }--});----// ── Admin: Test voice synthesis ───────────────────────────────--router.post('/admin/test', requireAuth, requireAdmin, async (req, res) => {-- try {-- const { voiceId, text } = req.body;-- const result = await ttsEngine.synthesize(text || 'This is a TTS test from OpenVibe.Live', voiceId);-- if (!result) return res.status(400).json({ error: 'Voice unavailable or TTS disabled' });-- // Same-origin URL alongside the base64: plays under media-src 'self' even where the-- // browser (or an overzealous shield) refuses blob:/data: audio.-- try { const stashed = require('../arena/voice').stash(result.audio, result.mimeType); if (stashed) result.url = `/api/tts/audio/${stashed.file}`; } catch { /* base64 still works */ }-- res.json(result);-- } catch (e) {-- res.status(500).json({ error: e.message });-- }--});----// Cached one-off clips (admin tests, previews) — streamed same-origin so no blob:/data: is needed.--router.get('/audio/:file', (req, res) => {-- try {-- const hit = require('../arena/voice').cachedByName(req.params.file);-- if (!hit) return res.status(404).json({ error: 'No such clip' });-- res.set({ 'Content-Type': hit.mimeType, 'Cache-Control': 'private, max-age=86400' });-- require('fs').createReadStream(hit.path).pipe(res);-- } catch (e) { res.status(500).json({ error: e.message }); }--});----module.exports = router;@@@ -108,7 +108,6 @@ const VibeCodingPublishServer = require('./vibe-coding/publish-server');// Routesconst authRoutes = require('./auth/routes');const streamRoutes = require('./streaming/routes');--const chatRoutes = require('./chat/routes');const monetizationRoutes = require('./monetization/routes');const coinsRoutes = require('./monetization/coins-routes');const cosmeticsRoutes = require('./monetization/cosmetics-routes');@@@ -669,11 +668,8 @@ app.use('/api/streams', streamRoutes);// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).const liveEvents = require('./streaming/live-events');app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));--// CHAT_AUTHORITY=chat: OpenVibe.Chat serves the chat REST routes (nginx sends /api/chat/, /api/dm/,--// /api/tts/, /api/sounds there). One that still reaches Live must not write to the mirror.--const chatRemote = require('./chat/chat-authority').isRemote();--const chatMoved = (req, res) => res.status(503).set('Retry-After', '2').json({ error: 'Chat is served by OpenVibe.Chat' });--app.use('/api/chat', chatRemote ? chatMoved : chatRoutes);++// OpenVibe.Chat owns the chat REST routes (/api/chat/, /api/dm/, /api/tts/, /api/sounds); nginx++// sends them there, so Live mounts none of them and keeps no CHAT_AUTHORITY rollback.app.use('/api/funds', monetizationRoutes);app.use('/api/coins', coinsRoutes);app.use('/api/payments', require('./monetization/payments-routes'));@@@ -700,7 +696,6 @@ app.use('/api/restream', restreamRoutes);// they may be embedded cross-site; everything else keeps helmet's same-origin resource policy.app.use('/api/thumbnails', (req, res, next) => { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); next(); }, thumbnailRoutes);app.use('/api/themes', themeRoutes);--app.use('/api/sounds', chatRemote ? chatMoved : require('./chat/sounds-routes'));app.use('/api/ai-viewers', require('./ai/viewers/routes'));app.use('/api/powerchat', require('./integrations/powerchat-routes'));app.use('/api/meta', metaRoutes);@@@ -717,10 +712,6 @@ app.use('/api/media', require('./media/routes'));// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.app.use('/api/img-proxy', require('./media/external-image-proxy'));app.use('/api/vibe-coding', vibeCodingRoutes);--const ttsRoutes = require('./chat/tts-routes');--app.use('/api/tts', chatRemote ? chatMoved : ttsRoutes);--const dmRoutes = require('./chat/dm-routes');--app.use('/api/dm', chatRemote ? chatMoved : dmRoutes);const analyticsRoutes = require('./streaming/analytics-routes');app.use('/api/analytics', analyticsRoutes);const newsRoutes = require('./news/news-routes');@@@ -60,16 +60,10 @@ voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, usassert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');assert.strictEqual(voice.cachedByName('zz.wav'), null);assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');-- const app2 = require('express')(); app2.use('/api/tts', require('../server/chat/tts-routes'));-- const srv2 = await new Promise(r => { const x = app2.listen(0, () => r(x)); });-- const clip = await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/${st.file}`);-- assert.strictEqual(clip.status, 200); assert.strictEqual(clip.headers.get('content-type'), 'audio/wav'); assert.strictEqual(await clip.text(), 'RIFFfakewav');-- assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/nope.wav`)).status, 404);-- // an arena voice line is reachable through the same clip route by its cache filename++ // An arena voice line lands in the clip cache by filename (the /api/tts clip route is Chat's now).const line = await voice.speak({ user, text: 'you think your chat is loud?' });-- assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/${path.basename(line.path)}`)).status, 200);-- srv2.close();-- console.log('✅ one-off clip stash + same-origin /api/tts/audio route');++ assert.ok(voice.cachedByName(path.basename(line.path)), 'a spoken line is served from the clip cache');++ console.log('✅ one-off clip stash + arena voice cache');console.log('\n✅ All Arena voice tests passed');process.exit(0);})().catch(err => { console.error(err); process.exit(1); });@@@ -57,7 +57,6 @@ app.use(express.json());app.use('/api/mod', require('../server/admin/mod-routes'));app.use('/api/controls', require('../server/controls/routes'));app.use('/api/admin', require('../server/admin/routes'));--app.use('/api/dm', require('../server/chat/dm-routes'));app.use('/api/media', require('../server/media/routes'));app.use('/api/ai-viewers', require('../server/ai/viewers/routes'));app.use('/api/payments', require('../server/monetization/payments-routes'));@@@ -146,16 +145,6 @@ async function check(name, fn) {assert.strictEqual(r.status, 403, r.text);});-- await check('DM: a private 1:1 cannot have a third person added', async () => {-- const dm = require('../server/chat/dm');-- dm.ensureTables();-- const conv = dm.createConversation(3, [3, 4]);-- const convId = typeof conv === 'object' ? (conv.id || conv.lastInsertRowid) : conv;-- const r = await call('POST', `/api/dm/conversations/${convId}/participants`, 3, { user_id: 5 });-- assert.strictEqual(r.status, 400, r.text);-- assert.ok(!dm.isParticipant(convId, 5));-- });--await check('media refund: no credit when the streamer no longer holds the Vibes', () => {const mq = require('../server/media/media-queue');const reqRow = raw.prepare(`INSERT INTO media_requests (streamer_id, user_id, username, title, cost, currency, status, provider, input, canonical_url)ddeleted file mode 100644@@@ -1,254 +0,0 @@--/**-- * Legacy parity (roadmap D20): the chat-log views.-- *-- * streamer Dashboard → Chat Logs (public/js/dashboard.js loadDashChatLogs / dashPurgeChatRange):-- * search, user and time filters, pages of 50, purge a time range after a preview count.-- * Scoped server-side to the streamer's own (most recent) stream.-- * mod the chat user card's "View Chat Logs" (public/js/chat.js openChatLogsModal): a user's,-- * an anonymous chatter's or a relay chatter's history, and search. Staff (admin, global-- * mod: staff.moderation.logs) see anyone's; everyone else only their own.-- *-- * The page's own functions run against a fake DOM and the real routes (server/chat/routes.js, the-- * CHAT_AUTHORITY=live path; OpenVibe.Chat serves the same API otherwise) on a temp database.-- *-- * node test/chat-log-ui.test.js-- */--'use strict';--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-chat-logs-'));--process.env.DB_PATH = path.join(tmp, 'live.db');--process.env.DATA_DIR = tmp;--process.env.NODE_ENV = 'test';--const quiet = console.log;--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };--console.warn = () => {};----const root = path.join(__dirname, '..');--const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');--const db = require('../server/db/database');--db.initDb();--const raw = db.getDb();----const auth = require('../server/auth/auth');--const signIn = (req) => {-- const id = Number(req.headers['x-test-user'] || 0);-- const u = id ? db.getUserById(id) : null;-- if (u) { req.user = u; req.authSource = 'network'; }-- return u;--};--auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));--auth.optionalAuth = (req, res, next) => { signIn(req); next(); };--require.cache[require.resolve('../server/chat/chat-server')] = {-- id: 'chat-server-stub', filename: 'chat-server-stub', loaded: true,-- exports: { broadcastToStream() {}, broadcastGlobal() {}, getStreamViewerCount: () => 0 },--};----const addUser = (id, username, role) => raw.prepare(-- `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)-- VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);--addUser(1, 'admin', 'admin');--addUser(3, 'alice', 'streamer');--addUser(4, 'bob', 'streamer');--addUser(6, 'mod', 'global_mod');--addUser(7, 'viewer', 'user');--for (const id of [3, 4]) db.ensureChannel(id);--const stream = (userId, created) => {-- const id = Number(db.createStream({ user_id: userId, channel_id: db.getChannelByUserId(userId).id, title: 's', protocol: 'webrtc' }).lastInsertRowid);-- raw.prepare('UPDATE streams SET created_at = ? WHERE id = ?').run(created, id);-- return id;--};--const aliceOld = stream(3, '2026-09-19 10:00:00');--const aliceNow = stream(3, '2026-09-20 10:00:00');--const bobs = stream(4, '2026-09-20 11:00:00');--const say = (sid, userId, username, message, ts, extra = {}) => raw.prepare(-- `INSERT INTO chat_messages (stream_id, user_id, anon_id, username, message, message_type, timestamp) VALUES (?, ?, ?, ?, ?, 'chat', ?)`)-- .run(sid, userId, extra.anon || null, username, message, ts);--say(aliceOld, 7, 'viewer', 'yesterday hello', '2026-09-19 10:05:00');--say(aliceNow, 7, 'viewer', 'hi alice', '2026-09-20 10:01:00');--say(aliceNow, 7, 'viewer', '<img src=x onerror=alert(1)>', '2026-09-20 10:02:00');--say(aliceNow, null, 'anon42', 'anon says hi', '2026-09-20 10:03:00', { anon: 'anon42' });--say(bobs, 7, 'viewer', 'hi bob', '2026-09-20 11:01:00');--say(bobs, 4, 'bob', 'bob replies', '2026-09-20 11:02:00');----const express = require('express');--const app = express();--app.use(express.json());--app.use('/api/chat', require('../server/chat/routes'));--const server = http.createServer(app).listen(0);--function call(method, p, user, body) {-- return new Promise((resolve, reject) => {-- const data = body ? JSON.stringify(body) : null;-- const headers = { 'content-type': 'application/json' };-- if (data) headers['content-length'] = Buffer.byteLength(data);-- if (user) headers['x-test-user'] = String(user);-- const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {-- let text = '';-- res.on('data', (c) => { text += c; });-- res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text, headers: res.headers }); });-- });-- req.on('error', reject);-- if (data) req.write(data);-- req.end();-- });--}----/** The source of a top-level function declaration, braces matched. */--function extract(src, name) {-- const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);-- assert.ok(m, `${name}() must exist`);-- let i = src.indexOf('{', m.index);-- let depth = 0;-- for (; i < src.length; i++) {-- if (src[i] === '{') depth++;-- else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);-- }-- throw new Error(`unbalanced ${name}`);--}----/** The dashboard card, with the page's own functions and api() going to the real routes as `user`. */--function dashboard(user, inputs = {}) {-- const els = {};-- for (const id of ['dash-chatlog-results', 'dash-chatlog-pagination', 'dash-chatlog-search', 'dash-chatlog-username', 'dash-chatlog-from', 'dash-chatlog-to']) {-- els[id] = { innerHTML: '', value: inputs[id] || '' };-- }-- const toasts = [];-- const requests = [];-- const fixtures = {-- document: { getElementById: (id) => els[id] || null },-- api: async (p, opts = {}) => {-- requests.push([opts.method || 'GET', p]);-- const r = await call(opts.method || 'GET', `/api${p}`, user, opts.body ? JSON.parse(opts.body) : undefined);-- if (r.status >= 400) throw new Error(r.json?.error || `HTTP ${r.status}`);-- return r.json;-- },-- toast: (msg, kind) => toasts.push([msg, kind]),-- confirm: () => true,-- _chatLogPage: 1,-- };-- const scope = new Proxy(fixtures, {-- has: (t, k) => typeof k === 'string',-- get: (t, k) => (k === Symbol.unscopables ? undefined : (k in t ? t[k] : globalThis[k])),-- set: (t, k, v) => { t[k] = v; return true; },-- });-- const src = [extract(read('public/js/app.js'), 'esc'), extract(read('public/js/dashboard.js'), 'loadDashChatLogs'), extract(read('public/js/dashboard.js'), 'dashPurgeChatRange')].join('\n');-- // eslint-disable-next-line no-new-func-- const fns = new Function('scope', `with (scope) { ${src}\n return { loadDashChatLogs, dashPurgeChatRange }; }`)(scope);-- return { fns, els, toasts, requests };--}----let failures = 0;--async function check(name, fn) {-- try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }--}----(async () => {-- await new Promise((r) => server.once('listening', r));---- await check('the dashboard card is part of the dashboard page and wired to the page\'s functions', async () => {-- const reg = JSON.parse(read('public/features.json'));-- assert.strictEqual(reg.features.dashboard.fragment, 'dashboard');-- assert.ok(reg.features.dashboard.js.includes('/js/dashboard.js'));-- const html = read('public/fragments/dashboard.html');-- for (const id of ['dash-card-chatlogs', 'dash-chatlog-search', 'dash-chatlog-username', 'dash-chatlog-from', 'dash-chatlog-to', 'dash-chatlog-results', 'dash-chatlog-pagination']) {-- assert.ok(html.includes(`id="${id}"`), `#${id}`);-- }-- assert.match(html, /onclick="loadDashChatLogs\(\)"/);-- assert.match(html, /onclick="dashPurgeChatRange\(\)"/);-- });---- await check('a streamer sees their own stream\'s log, escaped, newest first', async () => {-- const d = dashboard(3);-- await d.fns.loadDashChatLogs();-- const out = d.els['dash-chatlog-results'].innerHTML;-- assert.ok(out.includes('<table'), out);-- assert.ok(out.includes('hi alice') && out.includes('anon says hi'));-- assert.ok(!out.includes('hi bob') && !out.includes('yesterday hello'), 'not another channel\'s, nor an older stream\'s');-- assert.ok(out.includes('<img src=x onerror=alert(1)>') && !out.includes('<img src=x'), 'messages are escaped');-- assert.ok(out.indexOf('anon says hi') < out.indexOf('hi alice'), 'newest first');-- });---- await check('the search, user and time filters narrow it', async () => {-- let d = dashboard(3, { 'dash-chatlog-search': 'says' });-- await d.fns.loadDashChatLogs();-- assert.ok(d.els['dash-chatlog-results'].innerHTML.includes('anon says hi'));-- assert.ok(!d.els['dash-chatlog-results'].innerHTML.includes('hi alice'));-- d = dashboard(3, { 'dash-chatlog-username': 'nobody' });-- await d.fns.loadDashChatLogs();-- assert.match(d.els['dash-chatlog-results'].innerHTML, /No messages found/);-- // datetime-local inputs are local time; the page sends them as ISO instants.-- const local = (iso) => { const t = new Date(iso); return new Date(t.getTime() - t.getTimezoneOffset() * 60000).toISOString().slice(0, 16); };-- d = dashboard(3, { 'dash-chatlog-from': local('2026-09-20T10:02:00Z'), 'dash-chatlog-to': local('2026-09-20T10:02:59Z') });-- await d.fns.loadDashChatLogs();-- const out = d.els['dash-chatlog-results'].innerHTML;-- assert.ok(out.includes('onerror') && !out.includes('hi alice') && !out.includes('anon says hi'), 'exactly the minute asked for');-- });---- await check('pages of 50 with previous/next', async () => {-- for (let i = 0; i < 60; i++) say(aliceNow, 7, 'viewer', `line ${i}`, `2026-09-20 12:${String(i).padStart(2, '0')}:00`);-- const d = dashboard(3);-- await d.fns.loadDashChatLogs();-- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /Page 1 of 2/);-- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /loadDashChatLogs\(2\)/);-- await d.fns.loadDashChatLogs(2);-- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /loadDashChatLogs\(1\)/);-- assert.ok(d.requests.every(([, p]) => /limit=50/.test(p)));-- });---- await check('purge a range from the card: preview count, confirm, purge, reload', async () => {-- const local = (iso) => { const t = new Date(iso); return new Date(t.getTime() - t.getTimezoneOffset() * 60000).toISOString().slice(0, 16); };-- const d = dashboard(3, { 'dash-chatlog-from': local('2026-09-20T12:00:00Z'), 'dash-chatlog-to': local('2026-09-20T12:09:00Z') });-- await d.fns.dashPurgeChatRange();-- assert.deepStrictEqual(d.toasts.pop(), ['Purged 10 messages', 'success']);-- assert.deepStrictEqual(d.requests.map(([m, p]) => `${m} ${p.split('?')[0]}`), ['POST /chat/admin/purge/preview', 'DELETE /chat/admin/purge', 'GET /chat/admin/logs']);-- const empty = dashboard(3);-- await empty.fns.dashPurgeChatRange();-- assert.strictEqual(empty.toasts[0][1], 'error', 'both ends of the range are required');-- });---- await check('the server keeps each streamer to their own logs; staff see any, and purged lines on request', async () => {-- assert.strictEqual((await call('GET', `/api/chat/admin/logs?streamId=${bobs}`, 3)).status, 403);-- assert.strictEqual((await call('GET', `/api/chat/admin/logs/export?streamId=${bobs}&format=json`, 3)).status, 403);-- assert.strictEqual((await call('GET', '/api/chat/admin/logs/export?format=json', 3)).status, 403, 'only staff export everything');-- const own = await call('GET', `/api/chat/admin/logs/export?streamId=${aliceOld}&format=csv`, 3);-- assert.strictEqual(own.status, 200);-- assert.match(own.headers['content-type'], /text\/csv/);-- assert.match(own.text, /yesterday hello/, 'an older stream of their own by id');-- const admin = await call('GET', `/api/chat/admin/logs?streamId=${aliceNow}&includeDeleted=true&limit=200`, 1);-- assert.strictEqual(admin.json.total, 63, 'purged lines stay auditable by staff');-- const owner = await call('GET', `/api/chat/admin/logs?streamId=${aliceNow}&includeDeleted=true&limit=200`, 3);-- assert.strictEqual(owner.json.total, 53, 'the streamer only sees what is left');-- });---- await check('the mod view: staff read anyone\'s history, anonymous and relay chatters included; others only their own', async () => {-- const chat = read('public/js/chat.js');-- const load = extract(chat, 'loadChatLogs');-- for (const route of ['/chat/anon/${encodeURIComponent(ctx.anon)}/logs', '/chat/relay-user/${encodeURIComponent(ctx.relay.platform)}/${encodeURIComponent(ctx.relay.username)}/logs', '/chat/user/${ctx.userId}/history', '/chat/search?${params}']) {-- assert.ok(load.includes(route), `the modal reads ${route}`);-- }-- assert.match(load, /esc\(m\.message\)/, 'and escapes what it shows');-- assert.match(chat, /onclick="ctxViewLogs\('\$\{esc\(username\)\}', '\$\{data\.user\.id\}'\);closeModal\(\)"/, 'from the user card');---- const mine = await call('GET', '/api/chat/user/7/history?limit=50', 7);-- assert.strictEqual(mine.status, 200, 'a user reads their own');-- assert.strictEqual((await call('GET', '/api/chat/user/4/history', 7)).status, 403, 'not someone else\'s');-- const modView = await call('GET', '/api/chat/user/7/history?limit=200', 6);-- assert.strictEqual(modView.status, 200);-- assert.strictEqual(modView.json.total, 54, 'the viewer\'s lines on every channel, less the 10 purged');-- assert.strictEqual((await call('GET', '/api/chat/anon/anon42/logs', 6)).json.messages[0].message, 'anon says hi');-- assert.strictEqual((await call('GET', '/api/chat/anon/anon42/logs', 3)).status, 403, 'a streamer is not staff');-- const search = await call('GET', '/api/chat/search?q=bob&user_id=4', 7);-- assert.ok(search.json.messages.every((m) => m.user_id === 7), 'a non-staff search is limited to their own lines');-- const staffSearch = await call('GET', '/api/chat/search?q=bob&user_id=4', 6);-- assert.deepStrictEqual(staffSearch.json.messages.map((m) => m.message), ['bob replies']);-- });---- server.close();-- if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }-- quiet('\nAll chat-log view checks passed');-- process.exit(0);--})().catch((e) => { quiet(e); process.exit(1); });@@@ -54,26 +54,10 @@ for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {assert.strictEqual(g.total_level, 11 + 3 + 6, 'eight skills summed, like the old getPlayer()');assert.strictEqual(g.total_coins_earned, 42);-- // The chat profile card (Live's route and the one OpenVibe.Chat reads) never adds a row.-- const express = require('express');-- const app = express();-- app.use('/api/chat', require('../server/chat/routes'));-- const server = http.createServer(app).listen(0);-- const get = (p) => new Promise((resolve, reject) => {-- http.get({ port: server.address().port, path: p }, (res) => {-- let text = '';-- res.on('data', (c) => { text += c; });-- res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(text || 'null') }));-- }).on('error', reject);-- });-- let r = await get('/api/chat/user/newcomer/profile');-- assert.strictEqual(r.status, 200);-- assert.strictEqual(r.json.game, undefined, 'no legacy game block for someone who never played');-- assert.strictEqual(count(), 1, 'and viewing their profile created no game_players row');-- r = await get('/api/chat/user/veteran/profile');-- assert.strictEqual(r.json.game.total_level, 20, 'a legacy player still shows their skills');-- assert.strictEqual(count(), 1);-- server.close();++ // The profile-card reader (what Live's removed route and OpenVibe.Chat both called) never adds a row.++ assert.strictEqual(db.getLegacyGameProfile(2), null, 'no legacy game block for someone who never played');++ assert.strictEqual(g.total_level, 20, 'a legacy player still shows their skills');++ assert.strictEqual(count(), 1, 'reading a profile created no game_players row');// Chat tags: read-only, and still shown.const tags = require('../server/chat/tags');@@@ -1,18 +1,15 @@/**-- * Legacy parity (roadmap D20): purging chat and deleting replays stays consistent.++ * Legacy parity (roadmap D20): deleting a VOD or clip keeps Live's rows, its comment thread and its++ * Search document consistent.*-- * 1. Chat purge ↔ VOD chat replay. The dashboard's "Purge Range" (and its preview and log filter)-- * sends ISO instants ('2026-09-20T10:00:00.000Z'); chat_messages keeps SQLite's-- * '2026-09-20 10:00:00'. Compared as TEXT ('T' sorts after ' '), a range matched nothing on its-- * first day and ALL of its last day: the purge removed lines outside the chosen range, kept the ones-- * inside it, and VOD chat replay went on showing them. Live's local routes (CHAT_AUTHORITY=live, and-- * the mirror Live keeps under CHAT_AUTHORITY=chat) now read both forms as UTC, as OpenVibe.Chat does.++ * Media deletes the item; Live used to only hide the comment thread, and kept its own rows: the AI++ * state the backfill takes newest-first (a deleted VOD's row sat at the head of the overview queue++ * for good) and the unique views. The admin storage page and the "older than" action also left the++ * item's Search document until the daily refresh. Every delete path now goes through++ * server/media-proxy/purge.js.*-- * 2. Deleting a VOD or clip. Media deletes the item; Live used to only hide the comment thread, and-- * kept its own rows: the AI state the backfill takes newest-first (a deleted VOD's row sat at the-- * head of the overview queue for good) and the unique views. The admin storage page and the-- * "older than" action also left the item's Search document until the daily refresh. Every delete-- * path now goes through server/media-proxy/purge.js.++ * (The chat purge ↔ VOD chat replay half moved with Live's /api/chat routes: OpenVibe.Chat serves++ * them and covers that consistency in its own suite.)** node test/replay-purge.test.js*/@@@ -57,29 +54,6 @@ const streamA = Number(db.createStream({ user_id: 3, channel_id: db.getChannelByraw.prepare("UPDATE streams SET started_at = '2026-09-20 09:00:00', ended_at = '2026-09-21 03:00:00', is_live = 0 WHERE id = ?").run(streamA);Number(db.createStream({ user_id: 7, channel_id: db.getChannelByUserId(7).id, title: 'M', protocol: 'webrtc' }).lastInsertRowid);--// Alice's chat during her stream, as Live stores it (UTC, SQLite form).--const LINES = [-- ['2026-09-20 09:59:59', 'before'],-- ['2026-09-20 10:00:00', 'first in range'],-- ['2026-09-20 10:30:00', 'middle'],-- ['2026-09-20 11:00:00', 'last in range'],-- ['2026-09-20 11:00:01', 'just after'],-- ['2026-09-20 23:30:00', 'late that evening'],-- ['2026-09-21 01:00:00', 'after midnight'],-- ['2026-09-21 02:30:00', 'next morning'],--];--for (const [ts, text] of LINES) {-- raw.prepare(`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, username, message, message_type, timestamp)-- VALUES (?, 3, 7, 'mallory', ?, 'chat', ?)`).run(streamA, text, ts);--}----// ── the chat server the purge route announces to ──--const announced = [];--require.cache[require.resolve('../server/chat/chat-server')] = {-- id: 'chat-server-stub', filename: 'chat-server-stub', loaded: true,-- exports: { broadcastToStream: (sid, p) => announced.push([sid, p]), broadcastGlobal: (p) => announced.push(['global', p]) },--};--// ── Media, Community and Search stubs for the delete paths ──const media = require('../server/media-client');const VODS = {@@@ -114,7 +88,6 @@ raw.prepare("INSERT INTO content_views (content_type, content_id, ip) VALUES ('cconst express = require('express');const app = express();app.use(express.json());--app.use('/api/chat', require('../server/chat/routes'));app.use('/api/vods', require('../server/media-proxy/vods'));app.use('/api/clips', require('../server/media-proxy/clips'));const server = http.createServer(app).listen(0);@@@ -135,7 +108,6 @@ function call(method, p, user, body) {req.end();});}--const replayTexts = async () => (await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:00:00')}&to=${encodeURIComponent('2026-09-21 03:00:00')}`)).json.messages.map((m) => m.message);const aiRow = (id) => raw.prepare('SELECT 1 FROM vod_ai_state WHERE vod_id = ?').get(id);const views = (type, id) => raw.prepare('SELECT COUNT(*) AS n FROM content_views WHERE content_type = ? AND content_id = ?').get(type, id).n;@@@ -146,53 +118,8 @@ async function check(name, fn) {(async () => {await new Promise((r) => server.once('listening', r));-- const all = LINES.map(([, t]) => t);---- await check('chat replay covers the whole stream before any purge', async () => {-- assert.deepStrictEqual(await replayTexts(), all);-- });---- // What the dashboard sends for 10:00–11:00 UTC (new Date(input).toISOString()).-- const range = { from: '2026-09-20T10:00:00.000Z', to: '2026-09-20T11:00:00.000Z' };---- await check('the log filter, the purge preview and the purge agree on an ISO range', async () => {-- const q = new URLSearchParams(range).toString();-- const logs = await call('GET', `/api/chat/admin/logs?${q}`, 3);-- assert.strictEqual(logs.status, 200);-- assert.deepStrictEqual(logs.json.rows.map((r) => r.message).sort(), ['first in range', 'last in range', 'middle']);-- const preview = await call('POST', '/api/chat/admin/purge/preview', 3, range);-- assert.deepStrictEqual(preview.json, { count: 3 }, 'the preview counts exactly the lines in the range');-- const purge = await call('DELETE', '/api/chat/admin/purge', 3, range);-- assert.deepStrictEqual(purge.json, { deleted: 3 }, 'and the purge removes exactly those');-- assert.deepStrictEqual(announced.pop(), [streamA, { type: 'purge', streamId: streamA, ...range, by: 'alice' }], 'live chat is told');-- });---- await check('VOD chat replay no longer shows the purged lines, and still shows every other one', async () => {-- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'late that evening', 'after midnight', 'next morning']);-- });---- await check('a range across midnight purges only its own hours, not the whole last day', async () => {-- const overnight = { from: '2026-09-20T23:00:00.000Z', to: '2026-09-21T01:30:00.000Z' };-- assert.deepStrictEqual((await call('POST', '/api/chat/admin/purge/preview', 3, overnight)).json, { count: 2 });-- assert.deepStrictEqual((await call('DELETE', '/api/chat/admin/purge', 3, overnight)).json, { deleted: 2 });-- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning'], '02:30 the next morning is kept');-- });---- await check('a clip\'s replay window (SQLite form) still works, and staff see purged lines only on request', async () => {-- const r = await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:59:00')}&to=${encodeURIComponent('2026-09-20 11:00:05')}`);-- assert.deepStrictEqual(r.json.messages.map((m) => m.message), ['before', 'just after']);-- const staff = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 1);-- assert.strictEqual(staff.json.total, LINES.length, 'the purge is a soft delete an admin can still audit');-- const owner = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 3);-- assert.strictEqual(owner.json.total, 3, 'the streamer sees only what is left');-- });---- await check('nobody purges someone else\'s stream', async () => {-- const r = await call('DELETE', '/api/chat/admin/purge', 7, { streamId: streamA, from: '2026-09-20T00:00:00Z', to: '2026-09-22T00:00:00Z' });-- assert.strictEqual(r.status, 403);-- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning']);-- });++ // Deleting replays: Live's rows about each item, its comment thread and its Search document.const before = db.getVodsNeedingOverview(6).map((r) => r.id);await check('deleting a VOD drops Live\'s rows about it, hides its thread and re-reads its Search document', async () => {assert.ok(before.includes(100), 'the VOD was queued for an AI overview');ddeleted file mode 100644@@@ -1,172 +0,0 @@--'use strict';--/**-- * Direct messages stay between their participants on every HTTP path (roadmap WS-R task 5, the-- * private-room class). dm-delivery.test.js pins live delivery (dm.isParticipant before a socket-- * gets a message) and authorization.test.js that a 1:1 cannot grow a third person; this suite-- * walks every /api/dm route as someone outside the conversation, as a participant who was removed-- * from a group, and anonymously:-- *-- * read the conversation, its messages (every cursor form), the conversation list and the unread-- * count; post into it, mark it read, rename it, add themselves or someone else, remove a-- * participant, delete a participant's message.-- *-- * Each refusal leaves the conversation as it was and returns none of its text. (Private VODs and-- * clips are media-privacy.test.js's; with Chat serving /api/dm, OpenVibe.Chat has the same suite.)-- *-- * node test/security-private.test.js-- */--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const http = require('http');----const tmp = path.join(os.tmpdir(), `ov-private-${process.pid}.db`);--process.env.DB_PATH = tmp;--process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/--process.env.NODE_ENV = 'test';--const quiet = console.log;--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };--console.warn = () => {};--console.error = () => {};----const db = require('../server/db/database');--db.initDb();--const raw = db.getDb();--const dm = require('../server/chat/dm');--dm.ensureTables();----const auth = require('../server/auth/auth');--const signIn = (req) => {-- const id = Number(req.headers['x-test-user'] || 0);-- const u = id ? db.getUserById(id) : null;-- if (u) { req.user = u; req.authSource = 'network'; }-- return u;--};--auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));--auth.optionalAuth = (req, res, next) => { signIn(req); next(); };----const addUser = (id, username, role = 'user') => raw.prepare(-- `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)-- VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@example.test`, role);--const ANN = 11, BEN = 12, CAT = 13, EVE = 14, MOD = 15;--addUser(ANN, 'ann'); addUser(BEN, 'ben'); addUser(CAT, 'cat'); addUser(EVE, 'eve'); addUser(MOD, 'moddy', 'global_mod');----// A 1:1 between Ann and Ben, and a group Ann made with Ben and Cat (Cat is removed later).--const directId = Number(dm.getOrCreateDirect(ANN, BEN));--const groupId = Number(dm.createConversation(ANN, [ANN, BEN, CAT], 'Plans'));--const say = (conv, who, text) => Number(dm.sendMessage(conv, who, text).id);--const msgDirect = say(directId, BEN, 'the secret direct message');--const msgGroupOld = say(groupId, BEN, 'the secret group message');----const express = require('express');--const app = express();--app.use(express.json());--app.use('/api/dm', require('../server/chat/dm-routes'));--const server = http.createServer(app).listen(0, '127.0.0.1');--function call(method, p, user, body) {-- return new Promise((resolve, reject) => {-- const data = body ? JSON.stringify(body) : null;-- const headers = { 'content-type': 'application/json' };-- if (user) headers['x-test-user'] = String(user);-- const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers, agent: false }, (res) => {-- let text = '';-- res.on('data', (c) => { text += c; });-- res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });-- });-- req.on('error', reject);-- if (data) req.write(data);-- req.end();-- });--}--const SECRETS = ['secret direct', 'secret group', 'Plans'];--const snapshot = () => ({-- conversations: raw.prepare('SELECT * FROM dm_conversations ORDER BY id').all(),-- participants: raw.prepare('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id').all(),-- messages: raw.prepare('SELECT id, conversation_id, sender_id, message FROM dm_messages ORDER BY id').all().map((m) => ({ ...m })),--});----let failures = 0;--async function check(name, fn) {-- try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', String(e.stack || e.message).split('\n').slice(0, 6).join('\n ')); }--}----/** Every DM path against conversation `id`, as `user`: all must refuse and carry nothing. */--async function outsider(user, id, label) {-- const before = snapshot();-- const reads = [-- ['GET', `/api/dm/conversations/${id}`], ['GET', `/api/dm/conversations/${id}/messages`],-- ['GET', `/api/dm/conversations/${id}/messages?before=999999`], ['GET', `/api/dm/conversations/${id}/messages?after=0`],-- ['GET', `/api/dm/conversations/${id}/messages?limit=500`],-- ];-- const writes = [-- ['POST', `/api/dm/conversations/${id}/messages`, { message: 'hi', text: 'hi' }],-- ['POST', `/api/dm/conversations/${id}/read`, {}],-- ['PATCH', `/api/dm/conversations/${id}`, { name: 'pwned' }],-- ['POST', `/api/dm/conversations/${id}/participants`, { user_id: user || EVE }],-- ['POST', `/api/dm/conversations/${id}/participants`, { user_id: MOD }],-- ['DELETE', `/api/dm/conversations/${id}/participants/${BEN}`],-- ['DELETE', `/api/dm/conversations/${id}/messages/${msgDirect}`],-- ['DELETE', `/api/dm/conversations/${id}/messages/${msgGroupOld}`],-- ];-- for (const [m, p, body] of [...reads, ...writes]) {-- const r = await call(m, p, user, body);-- assert.ok([401, 403, 404].includes(r.status), `${label}: ${m} ${p} → ${r.status} ${r.text.slice(0, 160)}`);-- for (const s of SECRETS) assert.ok(!r.text.includes(s), `${label}: ${m} ${p} carries "${s}"`);-- }-- assert.deepStrictEqual(snapshot(), before, `${label}: nothing changed`);--}----(async () => {-- await new Promise((r) => server.once('listening', r));-- quiet('private: direct messages');---- await check('control: participants read their conversations (the routes work in this harness)', async () => {-- const r = await call('GET', `/api/dm/conversations/${directId}/messages`, ANN);-- assert.strictEqual(r.status, 200, r.text.slice(0, 200));-- assert.ok(r.text.includes('the secret direct message'));-- const g = await call('GET', `/api/dm/conversations/${groupId}/messages`, CAT);-- assert.strictEqual(g.status, 200);-- assert.ok(g.text.includes('the secret group message'));-- });-- await check('an outsider gets nothing from a 1:1 on any path, and changes nothing', () => outsider(EVE, directId, 'outsider/direct'));-- await check('an outsider gets nothing from a group on any path, and changes nothing', () => outsider(EVE, groupId, 'outsider/group'));-- await check('a global mod is an outsider to other people\'s DMs too', () => outsider(MOD, directId, 'mod/direct'));-- await check('anonymous gets 401 everywhere', () => outsider(null, directId, 'anonymous'));-- await check('the outsider\'s own lists hold none of it (conversations, unread, search)', async () => {-- const list = await call('GET', '/api/dm/conversations', EVE);-- assert.strictEqual(list.status, 200);-- for (const s of SECRETS) assert.ok(!list.text.includes(s), list.text.slice(0, 200));-- const unread = await call('GET', '/api/dm/unread', EVE);-- assert.strictEqual(unread.status, 200);-- assert.ok(!/[1-9]/.test(JSON.stringify(unread.json)), `unread: ${unread.text}`);-- const search = await call('GET', '/api/dm/users/search?q=ann', EVE);-- assert.ok(!search.text.includes('@example.test'), 'no email in user search');-- });-- await check('a participant removed from a group loses every path from then on', async () => {-- const r = await call('DELETE', `/api/dm/conversations/${groupId}/participants/${CAT}`, ANN);-- assert.strictEqual(r.status, 200, r.text.slice(0, 200));-- say(groupId, BEN, 'the secret group message after cat left');-- await outsider(CAT, groupId, 'removed/group');-- const list = await call('GET', '/api/dm/conversations', CAT);-- assert.ok(!list.text.includes('Plans'), 'the group left her list');-- });-- await check('a participant of one conversation cannot reach another through it (ids swapped)', async () => {-- // Ann is in both; Eve starts her own 1:1 with Ann and tries Ann's other conversations' message ids.-- const mineId = Number(dm.getOrCreateDirect(EVE, ANN));-- const before = snapshot();-- const del = await call('DELETE', `/api/dm/conversations/${mineId}/messages/${msgDirect}`, EVE);-- assert.ok([403, 404].includes(del.status), `delete through her own conversation: ${del.status}`);-- const page = await call('GET', `/api/dm/conversations/${mineId}/messages?before=${msgDirect + 1}`, EVE);-- assert.strictEqual(page.status, 200);-- for (const s of SECRETS) assert.ok(!page.text.includes(s), `her own conversation's page carries "${s}"`);-- assert.deepStrictEqual(snapshot(), before);-- });---- server.close();-- for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }-- try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }-- if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }-- quiet('\nsecurity-private: all checks passed');-- process.exit(0);--})().catch((e) => { quiet(e); process.exit(1); });@@@ -364,7 +364,6 @@ async function check(name, fn) {'server/openre/openre-client.js': 'OpenRe.Stream (configured)','server/ai/ai-service.js': 'OpenVibe.AI (configured)','server/ai/llm.js': 'images are Live\'s own frames; a streamer\'s typed provider address (testProvider) goes through egress.postJson',-- 'server/chat/routes.js': 'GIF providers (fixed hosts)','server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)','server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)','server/meta/routes.js': 'GitHub API (fixed host)',
@@ -154,7 +154,7 @@ and refills history on reconnect.
@@ -154,7 +154,7 @@ and refills history on reconnect.
| Log redaction | `server/utils/redact.js` | stream keys and credentials in log lines |
| Log redaction | `server/utils/redact.js` | stream keys and credentials in log lines |
| Database | `server/db/database.js` | queries and inline table setup |
| Database | `server/db/database.js` | queries and inline table setup |
| Migrations | `server/db/migrations.js` | versioned, transactional migrations with a ledger (`schema_migrations`) |
| Migrations | `server/db/migrations.js` | versioned, transactional migrations with a ledger (`schema_migrations`) |
-| Chat | `server/chat/chat-server.js`, `server/chat/routes.js` | WebSocket chat, history, moderation |
+| Chat | `server/chat/chat-server.js` | WebSocket chat, history, moderation (REST: OpenVibe.Chat) |
| Streams | `server/streaming/routes.js` | stream and slot CRUD, channel pages |
| Streams | `server/streaming/routes.js` | stream and slot CRUD, channel pages |
| Media proxy | `server/media-proxy/*.js` | VODs, clips, pastes, thumbnails via OpenVibe.Media |
| Media proxy | `server/media-proxy/*.js` | VODs, clips, pastes, thumbnails via OpenVibe.Media |
| Auth | `server/auth/auth.js`, `server/auth/permissions.js` | JWT/API tokens, scopes, role ranks |
| Auth | `server/auth/auth.js`, `server/auth/permissions.js` | JWT/API tokens, scopes, role ranks |
@@ -5,7 +5,7 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
@@ -5,7 +5,7 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
## Architecture
## Architecture
- **WebSocket server**: `server/chat/chat-server.js` — manages connections, rooms, and message routing
- **WebSocket server**: `server/chat/chat-server.js` — manages connections, rooms, and message routing
-- **REST API**: `server/chat/routes.js` — moderation endpoints, message search, admin tools
+- **REST API**: OpenVibe.Chat (nginx sends `/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds` there) — moderation endpoints, message search, admin tools. Live's local `server/chat/routes.js` is retired.
- **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync
- **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync
### OpenVibe.Chat (roadmap Wave 6)
### OpenVibe.Chat (roadmap Wave 6)
deleted file mode 100644
deleted file mode 100644
@@ -1,438 +0,0 @@
@@ -1,438 +0,0 @@
-/**
- * OpenVibe.Live — DM REST API Routes
- *
- * GET /api/dm/conversations — list conversations
- * POST /api/dm/conversations — create or get 1-on-1 / group
- * GET /api/dm/conversations/:id — get conversation details + participants
- * GET /api/dm/conversations/:id/messages — paginated messages
- * POST /api/dm/conversations/:id/messages — send a message
- * POST /api/dm/conversations/:id/read — mark conversation read
- * POST /api/dm/conversations/:id/participants — add participant to group
- * DELETE /api/dm/conversations/:id/participants/:userId — remove participant
- * PATCH /api/dm/conversations/:id — rename group
- * GET /api/dm/unread — total unread count
- * GET /api/dm/users/search — search users for new-message picker
- */
-const express = require('express');
-const router = express.Router();
-const { requireAuth } = require('../auth/auth');
-const dm = require('./dm');
-
-// All DM routes require authentication
-router.use(requireAuth);
-
-// ── Per-user rate limiting (in-memory sliding window) ────────
-const _rateBuckets = new Map();
-const RATE_MSG_MAX = 10, RATE_MSG_WINDOW = 60_000; // 10 messages / 60s
-const RATE_CONV_MAX = 5, RATE_CONV_WINDOW = 3_600_000; // 5 new conversations / hour
-const MAX_GROUP_SIZE = 20;
-
-function _checkRate(userId, bucket, max, windowMs) {
- if (!_rateBuckets.has(userId)) _rateBuckets.set(userId, { messages: [], convos: [] });
- const u = _rateBuckets.get(userId);
- const now = Date.now(), cutoff = now - windowMs;
- u[bucket] = u[bucket].filter(t => t > cutoff);
- if (u[bucket].length >= max) return false;
- u[bucket].push(now);
- return true;
-}
-// Not in a restore drill (LIVE_DRILL): it runs no timers, and it takes no DMs.
-if (!require('../drill').enabled) setInterval(() => {
- const cutoff = Date.now() - 3_600_000;
- for (const [uid, b] of _rateBuckets) {
- b.messages = b.messages.filter(t => t > cutoff);
- b.convos = b.convos.filter(t => t > cutoff);
- if (!b.messages.length && !b.convos.length) _rateBuckets.delete(uid);
- }
-}, 300_000);
-
-// List conversations for current user
-router.get('/conversations', (req, res) => {
- try {
- const conversations = dm.getConversations(req.user.id);
- // Attach participant info to each conversation
- for (const conv of conversations) {
- conv.participants = dm.getParticipants(conv.id);
- }
- res.json({ conversations });
- } catch (err) {
- console.error('[DM] List conversations error:', err.message);
- res.status(500).json({ error: 'Failed to load conversations' });
- }
-});
-
-// Create or get a conversation
-router.post('/conversations', (req, res) => {
- try {
- const { user_ids, name } = req.body;
- if (!user_ids || !Array.isArray(user_ids) || user_ids.length === 0) {
- return res.status(400).json({ error: 'user_ids array required' });
- }
- const participantIds = [...new Set([req.user.id, ...user_ids.map(Number).filter(Boolean)])];
- if (participantIds.length < 2) {
- return res.status(400).json({ error: 'Need at least one other user' });
- }
- if (participantIds.length > MAX_GROUP_SIZE) {
- return res.status(400).json({ error: `Groups are limited to ${MAX_GROUP_SIZE} members` });
- }
-
- // Rate limit conversation creation
- if (!_checkRate(req.user.id, 'convos', RATE_CONV_MAX, RATE_CONV_WINDOW)) {
- return res.status(429).json({ error: 'Too many new conversations. Try again later.' });
- }
-
- // New account restriction
- const accountCheck = dm.isAccountTooNew(req.user.id, 5);
- if (accountCheck.tooNew) {
- return res.status(403).json({ error: `Your account is too new to start conversations. Please wait ${accountCheck.minutesRemaining} more minute(s).` });
- }
-
- // Validate all target users exist, aren't banned, aren't blocked
- const userDb = require('../db/database');
- for (const uid of participantIds) {
- if (uid === req.user.id) continue;
- const target = userDb.getUserById(uid);
- if (!target) return res.status(400).json({ error: 'User not found' });
- if (target.is_banned) return res.status(400).json({ error: 'Cannot message banned users' });
- if (dm.isBlockedEither(req.user.id, uid)) {
- return res.status(403).json({ error: 'Cannot start a conversation with this user' });
- }
- }
-
- let conversationId;
- if (participantIds.length === 2) {
- const otherId = participantIds.find(id => id !== req.user.id);
- conversationId = dm.getOrCreateDirect(req.user.id, otherId);
- } else {
- const safeName = name ? String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100) : null;
- conversationId = dm.createConversation(req.user.id, participantIds, safeName);
- }
-
- const conversation = dm.getConversation(conversationId);
- conversation.participants = dm.getParticipants(conversationId);
- res.json({ conversation });
- } catch (err) {
- console.error('[DM] Create conversation error:', err.message);
- res.status(500).json({ error: 'Failed to create conversation' });
- }
-});
-
-// Get conversation details
-router.get('/conversations/:id', (req, res) => {
- try {
- const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
- return res.status(403).json({ error: 'Not a participant' });
- }
- const conversation = dm.getConversation(convId);
- if (!conversation) return res.status(404).json({ error: 'Not found' });
- conversation.participants = dm.getParticipants(convId);
- res.json({ conversation });
- } catch (err) {
- console.error('[DM] Get conversation error:', err.message);
- res.status(500).json({ error: 'Failed to load conversation' });
- }
-});
-
-// Get messages (paginated)
-router.get('/conversations/:id/messages', (req, res) => {
- try {
- const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
- return res.status(403).json({ error: 'Not a participant' });
- }
- const limit = Math.min(parseInt(req.query.limit) || 50, 100);
- const before = parseInt(req.query.before) || null;
- const after = parseInt(req.query.after) || null;
- const messages = dm.getMessages(convId, limit, before, after);
- res.json({ messages });
- } catch (err) {
- console.error('[DM] Get messages error:', err.message);
- res.status(500).json({ error: 'Failed to load messages' });
- }
-});
-
-// Send a message
-router.post('/conversations/:id/messages', (req, res) => {
- try {
- const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
- return res.status(403).json({ error: 'Not a participant' });
- }
-
- // Per-user message rate limit
- if (!_checkRate(req.user.id, 'messages', RATE_MSG_MAX, RATE_MSG_WINDOW)) {
- return res.status(429).json({ error: 'Slow down! You\'re sending messages too fast.' });
- }
-
- // Block check for 1-on-1 conversations
- const conv = dm.getConversation(convId);
- if (conv && !conv.is_group) {
- const participants = dm.getParticipants(convId);
- const other = participants.find(p => p.id !== req.user.id);
- if (other && dm.isBlockedEither(req.user.id, other.id)) {
- return res.status(403).json({ error: 'Cannot send messages in this conversation' });
- }
- }
-
- const { message } = req.body;
- if (!message || typeof message !== 'string' || !message.trim()) {
- return res.status(400).json({ error: 'Message required' });
- }
-
- const msg = dm.sendMessage(convId, req.user.id, message);
- if (!msg) return res.status(400).json({ error: 'Failed to send' });
-
- // Attach sender info
- const db = require('../db/database');
- const sender = db.getUserById(req.user.id);
- msg.username = sender?.username;
- msg.display_name = sender?.display_name;
- msg.avatar_url = sender?.avatar_url;
- msg.profile_color = sender?.profile_color;
-
- // Real-time delivery via chat WebSocket
- try {
- const chatServer = require('./chat-server');
- const participants = dm.getParticipants(convId);
- for (const p of participants) {
- if (p.id === req.user.id) continue; // skip sender
- chatServer.sendDm(p.id, {
- type: 'dm',
- conversation_id: convId,
- message: msg,
- });
- }
- } catch { /* non-critical */ }
-
- // Push notifications to other participants (for when they're offline)
- try {
- const { pushBulkNotification, actorInfo } = require('../utils/notify');
- const participants = dm.getParticipants(convId);
- const otherIds = participants.filter(p => p.id !== req.user.id).map(p => p.id);
- if (otherIds.length) {
- const senderName = sender?.display_name || sender?.username || 'Someone';
- // Use generic preview — actual message content is only in real-time delivery
- const preview = 'Sent you a message';
- pushBulkNotification(otherIds, {
- type: 'DIRECT_MESSAGE',
- title: `Message from ${senderName}`,
- message: preview,
- url: `https://openvibe.live/?dm=${convId}`,
- ...actorInfo(sender),
- });
- }
- } catch { /* non-critical */ }
-
- res.json({ message: msg });
- } catch (err) {
- console.error('[DM] Send message error:', err.message);
- res.status(500).json({ error: 'Failed to send message' });
- }
-});
-
-// Mark conversation read
-router.post('/conversations/:id/read', (req, res) => {
- try {
- const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
- return res.status(403).json({ error: 'Not a participant' });
- }
- dm.markRead(convId, req.user.id);
-
- // Clear matching DM notifications in openvibe-tools
- try {
- const { markNotificationsRead } = require('../utils/notify');
- markNotificationsRead(req.user.id, 'DIRECT_MESSAGE', `%/dm/${convId}`);
- } catch { /* non-critical */ }
-
- res.json({ ok: true });
- } catch (err) {
- console.error('[DM] Mark read error:', err.message);
- res.status(500).json({ error: 'Failed' });
- }
-});
-
-// Add participant to group
-router.post('/conversations/:id/participants', (req, res) => {
- try {
- const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
- return res.status(403).json({ error: 'Not a participant' });
- }
- const { user_id } = req.body;
- if (!user_id) return res.status(400).json({ error: 'user_id required' });
-
- // Validate target user exists, isn't banned, isn't blocked
- const userDb = require('../db/database');
- const target = userDb.getUserById(user_id);
- if (!target) return res.status(400).json({ error: 'User not found' });
- if (target.is_banned) return res.status(400).json({ error: 'Cannot add banned users' });
- // A private 1:1 stays private: adding a third person would hand them the whole history.
- const conv = dm.getConversation(convId);
- if (!conv || !conv.is_group) {
- return res.status(400).json({ error: 'Start a group conversation to add people' });
- }
- // Nobody already in the group should end up talking to someone they blocked (or who blocked them).
- const members = dm.getParticipants(convId) || [];
- if (members.some((p) => dm.isBlockedEither(p.user_id || p.id, user_id))) {
- return res.status(403).json({ error: 'Cannot add this user' });
- }
-
- // Group size limit
- if (dm.getParticipantCount(convId) >= MAX_GROUP_SIZE) {
- return res.status(400).json({ error: `Group is full (max ${MAX_GROUP_SIZE} members)` });
- }
-
- dm.addParticipant(convId, user_id);
- const participants = dm.getParticipants(convId);
-
- // Notify all participants about the new member
- try {
- const chatServer = require('./chat-server');
- for (const p of participants) {
- chatServer.sendDm(p.id, {
- type: 'dm-participant-added',
- conversation_id: convId,
- user_id,
- participants,
- });
- }
- } catch { /* non-critical */ }
-
- res.json({ participants });
- } catch (err) {
- console.error('[DM] Add participant error:', err.message);
- res.status(500).json({ error: 'Failed' });
- }
-});
-
-// Remove participant from group
-router.delete('/conversations/:id/participants/:userId', (req, res) => {
- try {
- const convId = parseInt(req.params.id);
- const targetUserId = parseInt(req.params.userId);
- if (!dm.isParticipant(convId, req.user.id)) {
- return res.status(403).json({ error: 'Not a participant' });
- }
- // Can only remove self (leave) or be the creator
- const conv = dm.getConversation(convId);
- if (targetUserId !== req.user.id && conv?.created_by !== req.user.id) {
- return res.status(403).json({ error: 'Only the creator can remove others' });
- }
- dm.removeParticipant(convId, targetUserId);
- res.json({ ok: true });
- } catch (err) {
- console.error('[DM] Remove participant error:', err.message);
- res.status(500).json({ error: 'Failed' });
- }
-});
-
-// Rename group conversation
-router.patch('/conversations/:id', (req, res) => {
- try {
- const convId = parseInt(req.params.id);
- if (!dm.isParticipant(convId, req.user.id)) {
- return res.status(403).json({ error: 'Not a participant' });
- }
- let { name } = req.body;
- if (name) {
- name = String(name).replace(/<[^>]*>/g, '').replace(/[\\`'"<>(){};:/\[\]]/g, '').replace(/\s+/g, ' ').trim().slice(0, 100);
- }
- dm.renameConversation(convId, name || null);
- res.json({ ok: true });
- } catch (err) {
- console.error('[DM] Rename error:', err.message);
- res.status(500).json({ error: 'Failed' });
- }
-});
-
-// Total unread count
-router.get('/unread', (req, res) => {
- try {
- const total = dm.getTotalUnread(req.user.id);
- res.json({ unread: total });
- } catch (err) {
- res.status(500).json({ error: 'Failed' });
- }
-});
-
-// Search users for new-message picker
-router.get('/users/search', (req, res) => {
- try {
- const q = (req.query.q || '').trim();
- if (q.length < 2) return res.json({ users: [] });
- const users = dm.searchUsers(q, req.user.id);
- res.json({ users });
- } catch (err) {
- res.status(500).json({ error: 'Failed' });
- }
-});
-
-// ── Block & message management ───────────────────────────────
-
-// Block a user
-router.post('/blocks/:userId', (req, res) => {
- try {
- const targetId = parseInt(req.params.userId);
- if (!targetId || targetId === req.user.id) {
- return res.status(400).json({ error: 'Invalid user' });
- }
- dm.blockUser(req.user.id, targetId);
- res.json({ ok: true });
- } catch (err) {
- console.error('[DM] Block error:', err.message);
- res.status(500).json({ error: 'Failed to block user' });
- }
-});
-
-// Unblock a user
-router.delete('/blocks/:userId', (req, res) => {
- try {
- const targetId = parseInt(req.params.userId);
- dm.unblockUser(req.user.id, targetId);
- res.json({ ok: true });
- } catch (err) {
- console.error('[DM] Unblock error:', err.message);
- res.status(500).json({ error: 'Failed to unblock user' });
- }
-});
-
-// List blocked users
-router.get('/blocks', (req, res) => {
- try {
- const blocked = dm.getBlockedUsers(req.user.id);
- res.json({ blocked });
- } catch (err) {
- res.status(500).json({ error: 'Failed to load blocked users' });
- }
-});
-
-// Check if a specific user is blocked
-router.get('/blocks/check/:userId', (req, res) => {
- try {
- const targetId = parseInt(req.params.userId);
- res.json({ blocked: dm.hasBlocked(req.user.id, targetId) });
- } catch {
- res.json({ blocked: false });
- }
-});
-
-// Delete own message
-router.delete('/conversations/:id/messages/:msgId', (req, res) => {
- try {
- const convId = parseInt(req.params.id);
- const msgId = parseInt(req.params.msgId);
- if (!dm.isParticipant(convId, req.user.id)) {
- return res.status(403).json({ error: 'Not a participant' });
- }
- const deleted = dm.deleteMessage(msgId, req.user.id);
- if (!deleted) return res.status(403).json({ error: 'Cannot delete this message' });
- res.json({ ok: true });
- } catch (err) {
- console.error('[DM] Delete message error:', err.message);
- res.status(500).json({ error: 'Failed to delete message' });
- }
-});
-
-module.exports = router;
deleted file mode 100644
deleted file mode 100644
@@ -1,777 +0,0 @@
@@ -1,777 +0,0 @@
-/**
- * OpenVibe.Live — Chat API Routes
- *
- * POST /api/chat/send - Send a chat message (REST fallback)
- * GET /api/chat/:streamId/history - Get chat history for a stream
- * GET /api/chat/:streamId/users - Get users in chat
- * GET /api/chat/search - Search chat messages
- * GET /api/chat/user/:userId/history - Get a user's chat history
- * GET /api/chat/user/:username/profile - Get user profile card data
- */
-const express = require('express');
-const db = require('../db/database');
-const { optionalAuth, requireAuth, requireAdmin } = require('../auth/auth');
-const permissions = require('../auth/permissions');
-const historyStore = require('./history-store');
-
-const router = express.Router();
-
-/**
- * Attach each author's CURRENT cosmetics (name/particle/hat effects) + tag to
- * history rows, so historical messages render with the same effects as live ones
- * (chat_messages doesn't persist cosmetics). Cached per user_id within the batch.
- */
-function enrichMessagesWithCosmetics(messages) {
- let cosmetics = null, tags = null;
- try { cosmetics = require('../monetization/cosmetics'); } catch { /* */ }
- try { tags = require('./tags'); } catch { /* */ }
- if ((!cosmetics && !tags) || !Array.isArray(messages)) return messages;
- const cache = new Map();
- for (const m of messages) {
- if (!m || !m.user_id) continue;
- let prof = cache.get(m.user_id);
- if (!prof) {
- prof = {};
- try { if (cosmetics) Object.assign(prof, cosmetics.getCosmeticProfile(m.user_id) || {}); } catch { /* */ }
- try { if (tags) prof.tag = tags.getTagProfile(m.user_id) || null; } catch { /* */ }
- cache.set(m.user_id, prof);
- }
- if (prof.nameFX) m.nameFX = prof.nameFX;
- if (prof.particleFX) m.particleFX = prof.particleFX;
- if (prof.hatFX) m.hatFX = prof.hatFX;
- if (prof.tag) m.tag = prof.tag;
- }
- return messages;
-}
-
-const MIN_SELF_DELETE_MINUTES = 3;
-const MAX_SELF_DELETE_MINUTES = 10080;
-const GIF_ALLOWED_PROVIDERS = new Set(['tenor', 'giphy']);
-
-function getGifProviderConfig(provider) {
- if (provider === 'giphy') {
- return {
- key: db.getSetting('gif_giphy_api_key') || '',
- trendingUrl: 'https://api.giphy.com/v1/gifs/trending',
- searchUrl: 'https://api.giphy.com/v1/gifs/search',
- };
- }
- return {
- key: db.getSetting('gif_tenor_api_key') || '',
- trendingUrl: 'https://tenor.googleapis.com/v2/featured',
- searchUrl: 'https://tenor.googleapis.com/v2/search',
- };
-}
-
-function normalizeGifProvider(provider) {
- const normalized = String(provider || 'tenor').trim().toLowerCase();
- return GIF_ALLOWED_PROVIDERS.has(normalized) ? normalized : 'tenor';
-}
-
-function mapGifResults(provider, payload) {
- if (provider === 'giphy') {
- const items = Array.isArray(payload?.data) ? payload.data : [];
- return items.map((item) => {
- const tiny = item?.images?.fixed_width_small?.url || item?.images?.preview_gif?.url || item?.images?.fixed_width?.url || '';
- const full = item?.images?.original?.url || item?.images?.fixed_width?.url || tiny;
- return {
- id: item?.id || null,
- preview_url: tiny,
- full_url: full,
- title: item?.title || item?.slug || 'GIF',
- provider: 'giphy',
- source_url: item?.url || full,
- };
- }).filter((item) => item.preview_url && item.full_url);
- }
-
- const items = Array.isArray(payload?.results) ? payload.results : [];
- return items.map((item) => {
- const tiny = item?.media_formats?.tinygif?.url || item?.media_formats?.gif?.url || '';
- const full = item?.media_formats?.gif?.url || tiny;
- return {
- id: item?.id || null,
- preview_url: tiny,
- full_url: full,
- title: item?.content_description || item?.title || 'GIF',
- provider: 'tenor',
- source_url: item?.itemurl || full,
- };
- }).filter((item) => item.preview_url && item.full_url);
-}
-
-function normalizeAutoDeleteMinutes(value) {
- const mins = parseInt(value, 10);
- if (!Number.isFinite(mins) || mins < MIN_SELF_DELETE_MINUTES) return 0;
- return Math.min(MAX_SELF_DELETE_MINUTES, mins);
-}
-
-router.get('/gif/providers', optionalAuth, (req, res) => {
- res.json({
- providers: {
- tenor: !!db.getSetting('gif_tenor_api_key'),
- giphy: !!db.getSetting('gif_giphy_api_key'),
- },
- defaultProvider: db.getSetting('gif_tenor_api_key') ? 'tenor' : (db.getSetting('gif_giphy_api_key') ? 'giphy' : null),
- });
-});
-
-router.get('/gif/trending', optionalAuth, async (req, res) => {
- try {
- const provider = normalizeGifProvider(req.query.provider);
- const config = getGifProviderConfig(provider);
- if (!config.key) return res.status(503).json({ error: `${provider} API key not configured` });
-
- const url = new URL(config.trendingUrl);
- if (provider === 'giphy') {
- url.searchParams.set('api_key', config.key);
- url.searchParams.set('limit', '30');
- url.searchParams.set('rating', 'pg-13');
- } else {
- url.searchParams.set('key', config.key);
- url.searchParams.set('limit', '30');
- url.searchParams.set('media_filter', 'tinygif,gif');
- }
-
- const upstream = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });
- if (!upstream.ok) return res.status(502).json({ error: `GIF provider request failed (${upstream.status})` });
- const payload = await upstream.json();
- res.json({ provider, results: mapGifResults(provider, payload) });
- } catch (err) {
- res.status(500).json({ error: err.message || 'Failed to load GIFs' });
- }
-});
-
-router.get('/gif/search', optionalAuth, async (req, res) => {
- try {
- const provider = normalizeGifProvider(req.query.provider);
- const query = String(req.query.q || '').trim();
- if (query.length < 2) return res.status(400).json({ error: 'Query must be at least 2 characters' });
-
- const config = getGifProviderConfig(provider);
- if (!config.key) return res.status(503).json({ error: `${provider} API key not configured` });
-
- const url = new URL(config.searchUrl);
- if (provider === 'giphy') {
- url.searchParams.set('api_key', config.key);
- url.searchParams.set('q', query);
- url.searchParams.set('limit', '30');
- url.searchParams.set('rating', 'pg-13');
- } else {
- url.searchParams.set('key', config.key);
- url.searchParams.set('q', query);
- url.searchParams.set('limit', '30');
- url.searchParams.set('media_filter', 'tinygif,gif');
- }
-
- const upstream = await fetch(url, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } });
- if (!upstream.ok) return res.status(502).json({ error: `GIF provider request failed (${upstream.status})` });
- const payload = await upstream.json();
- res.json({ provider, results: mapGifResults(provider, payload) });
- } catch (err) {
- res.status(500).json({ error: err.message || 'Failed to search GIFs' });
- }
-});
-
-/**
- * Hydrate reply_to context onto an array of message rows.
- * Each row must have reply_to_id (from DB). Adds a reply_to object
- * with { id, username, user_id, message } for the parent message.
- */
-function hydrateReplies(messages) {
- const replyIds = [...new Set(messages.map(m => m.reply_to_id).filter(Boolean))];
- if (!replyIds.length) return messages;
- const placeholders = replyIds.map(() => '?').join(',');
- const parents = db.all(
- `SELECT id, username, user_id, message
- FROM chat_messages
- WHERE id IN (${placeholders})
- AND is_deleted = 0
- AND (auto_delete_at IS NULL OR datetime(auto_delete_at) > CURRENT_TIMESTAMP)`,
- replyIds
- );
- const parentMap = new Map(parents.map(p => [p.id, p]));
- return messages.map(m => {
- if (m.reply_to_id && parentMap.has(m.reply_to_id)) {
- const p = parentMap.get(m.reply_to_id);
- m.reply_to = {
- id: p.id,
- username: p.username,
- user_id: p.user_id,
- message: p.message.length > 100 ? p.message.slice(0, 100) + '…' : p.message,
- };
- }
- return m;
- });
-}
-
-// ── Send Chat Message (REST fallback when WS is down) ────────
-router.post('/send', requireAuth, (req, res) => {
- try {
- const text = (req.body.message || '').trim();
- // 6000 = the absolute ceiling any channel/admin can configure (see chat-server handleChatMessage).
- if (!text || text.length > 6000) {
- return res.status(400).json({ error: 'Invalid message' });
- }
-
- // Ban check
- if (db.isUserBanned(req.user.id, null)) {
- return res.status(403).json({ error: 'You are banned from chat' });
- }
-
- const chatServer = require('./chat-server');
-
- // Word filter
- const wordFilter = require('./word-filter');
- const filterResult = wordFilter.check(text);
- const filtered = filterResult.safe ? text : filterResult.filtered;
-
- if (wordFilter.isSpam(filtered)) {
- return res.status(400).json({ error: 'Message blocked: detected as spam' });
- }
-
- const username = req.user.display_name || req.user.username;
- const autoDeleteMinutes = normalizeAutoDeleteMinutes(req.body.auto_delete_minutes);
- const autoDeleteAt = autoDeleteMinutes
- ? new Date(Date.now() + autoDeleteMinutes * 60 * 1000).toISOString()
- : null;
-
- // Reply-to support
- const replyToId = req.body.reply_to_id ? parseInt(req.body.reply_to_id) : null;
- let replyTo = null;
- if (replyToId) {
- const parent = db.getChatMessageById(replyToId);
- if (parent && !parent.is_deleted) {
- replyTo = {
- id: parent.id,
- username: parent.username,
- user_id: parent.user_id,
- message: parent.message.length > 100 ? parent.message.slice(0, 100) + '…' : parent.message,
- };
- }
- }
-
- const chatMsg = {
- type: 'chat',
- username,
- core_username: req.user.username,
- user_id: req.user.id,
- anon_id: null,
- role: req.user.role || 'user',
- message: filtered,
- stream_id: null,
- is_global: true,
- avatar_url: req.user.avatar_url || null,
- profile_color: req.user.profile_color || '#999',
- filtered: !filterResult.safe,
- timestamp: new Date().toISOString(),
- auto_delete_at: autoDeleteAt,
- };
-
- // Attach cosmetics
- try {
- const cosmetics = require('../monetization/cosmetics');
- const cosmeticProfile = cosmetics.getCosmeticProfile(req.user.id);
- if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;
- if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;
- if (cosmeticProfile.hatFX) chatMsg.hatFX = cosmeticProfile.hatFX;
- if (cosmeticProfile.voiceFX) chatMsg.voiceFX = cosmeticProfile.voiceFX;
- } catch { /* non-critical */ }
-
- // Attach tag
- try {
- const tags = require('./tags');
- const tagProfile = tags.getTagProfile(req.user.id);
- if (tagProfile) chatMsg.tag = tagProfile;
- } catch { /* non-critical */ }
-
- // Save to database
- let savedId = null;
- try {
- const result = db.saveChatMessage({
- stream_id: null,
- user_id: req.user.id,
- anon_id: null,
- username,
- message: filtered,
- message_type: 'chat',
- is_global: true,
- reply_to_id: replyToId,
- auto_delete_at: autoDeleteAt,
- });
- savedId = result.lastInsertRowid;
- } catch { /* non-critical */ }
-
- // Attach message ID and reply context to broadcast
- if (savedId) chatMsg.id = Number(savedId);
- if (replyTo) chatMsg.reply_to = replyTo;
-
- // Broadcast to all global chat clients
- chatServer.broadcastGlobal(chatMsg);
- res.json({ ok: true });
- } catch (err) {
- console.error('[Chat] REST send error:', err.message);
- res.status(500).json({ error: 'Failed to send message' });
- }
-});
-
-// ── Search Chat Messages (admin or self) ─────────────────────
-router.get('/search', requireAuth, (req, res) => {
- try {
- const limit = Math.min(parseInt(req.query.limit || '50'), 200);
- const offset = parseInt(req.query.offset || '0');
- const query = req.query.q || '';
- const userId = req.query.user_id ? parseInt(req.query.user_id) : null;
- const streamId = req.query.stream_id ? parseInt(req.query.stream_id) : null;
-
- // Admin / global_mod can search anyone; others search only their own
- const effectiveUserId = permissions.canViewOtherUserLogs(req.user) ? userId : req.user.id;
-
- const result = db.searchChatMessages({
- query, userId: effectiveUserId, streamId, limit, offset,
- });
-
- res.json(result);
- } catch (err) {
- res.status(500).json({ error: 'Search failed' });
- }
-});
-
-// ── User Chat History ────────────────────────────────────────
-router.get('/user/:userId/history', requireAuth, (req, res) => {
- try {
- const userId = parseInt(req.params.userId);
- const limit = Math.min(parseInt(req.query.limit || '50'), 200);
- const offset = parseInt(req.query.offset || '0');
-
- // Admin / global_mod can view anyone; others view only their own
- if (!permissions.canViewOtherUserLogs(req.user) && req.user.id !== userId) {
- return res.status(403).json({ error: 'Access denied' });
- }
-
- const result = db.getUserChatHistory(userId, limit, offset);
- res.json(result);
- } catch (err) {
- res.status(500).json({ error: 'Failed to get chat history' });
- }
-});
-
-// ── User Profile Card ────────────────────────────────────────
-router.get('/user/:username/profile', optionalAuth, (req, res) => {
- try {
- // Try core username first, then fall back to display_name lookup
- let user = db.getUserByUsername(req.params.username);
- if (!user) {
- user = db.get('SELECT * FROM users WHERE display_name = ? COLLATE NOCASE', [req.params.username]);
- }
- if (!user) return res.status(404).json({ error: 'User not found' });
-
- const profile = db.getUserProfile(user.id);
- if (!profile) return res.status(404).json({ error: 'Profile not found' });
- // The card shows OpenCoins (earned, part of the game). Vibes are real money and presence is
- // personal, so both stay with the user themselves.
- if (!req.user || req.user.id !== user.id) {
- delete profile.openvibe_bucks_balance;
- delete profile.last_seen;
- }
-
- // Legacy game skills, read-only (never creates a game_players row)
- const game = db.getLegacyGameProfile(user.id);
- if (game) profile.game = game;
-
- res.json(profile);
- } catch (err) {
- res.status(500).json({ error: 'Failed to get profile' });
- }
-});
-
-// ── Chat-relay (external) user info: join date (first message) etc. ──
-router.get('/relay-user/:platform/:username', optionalAuth, (req, res) => {
- try {
- const r = db.getRelayUser(req.params.platform, req.params.username);
- res.json({ relayUser: r || null });
- } catch (err) {
- res.status(500).json({ error: 'Failed to get relay user' });
- }
-});
-
-// Anonymous chatter info: first-seen (anon-number assignment) + first chat + count.
-router.get('/anon/:anonId', optionalAuth, (req, res) => {
- try {
- const anonId = String(req.params.anonId || '');
- if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
- res.json({ anon: db.getAnonMeta(anonId) });
- } catch (err) {
- res.status(500).json({ error: 'Failed to get anon info' });
- }
-});
-
-// An anonymous chatter's chat-message history — same gate as native logs.
-router.get('/anon/:anonId/logs', requireAuth, (req, res) => {
- try {
- if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
- const anonId = String(req.params.anonId || '');
- if (!/^anon\d+$/i.test(anonId)) return res.status(400).json({ error: 'Invalid anon id' });
- const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
- const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
- const query = String(req.query.q || '').trim();
- const r = db.getAnonChatHistory(anonId, { limit, offset, query });
- res.json(r);
- } catch (err) {
- res.status(500).json({ error: 'Failed to get anon user logs' });
- }
-});
-
-// A relay (external-platform) user's chat-message history — same gate as native logs.
-router.get('/relay-user/:platform/:username/logs', requireAuth, (req, res) => {
- try {
- if (!permissions.canViewOtherUserLogs(req.user)) return res.status(403).json({ error: 'Not authorized' });
- const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
- const offset = Math.max(parseInt(req.query.offset || '0', 10), 0);
- const query = String(req.query.q || '').trim();
- const r = db.getRelayUserChatHistory(req.params.platform, req.params.username, { limit, offset, query });
- res.json(r);
- } catch (err) {
- res.status(500).json({ error: 'Failed to get relay user logs' });
- }
-});
-
-// ── Global Chat History (all streams) ────────────────────────
-// Patterns behind the viewer-side "Friendly global chat" setting (server/chat/moderation-utils.js is
-// the single source; the client compiles these).
-router.get('/filters/friendly', (req, res) => {
- const { FRIENDLY_FILTER_CATEGORIES } = require('./moderation-utils');
- res.set('Cache-Control', 'public, max-age=3600');
- res.json({ version: 1, categories: FRIENDLY_FILTER_CATEGORIES.map(({ key, label, patterns }) => ({ key, label, patterns })) });
-});
-
-router.get('/global/history', optionalAuth, (req, res) => {
- try {
- const limit = req.query.limit;
- const before = req.query.before;
- const afterId = req.query.after_id;
- const channelUsername = String(req.query.username || '').trim();
- const decorate = (rows) => enrichMessagesWithCosmetics(hydrateReplies(rows));
- // after_id = cursor read: only what the caller has not seen (oldest→newest, PK range scan).
- // Anything else = a page: the newest rows, optionally before a timestamp.
- const out = afterId != null
- ? historyStore.delta('global', { afterId, limit, channelUsername, decorate })
- : historyStore.page('global', { limit, before, channelUsername, decorate });
- res.json(out);
- } catch (err) {
- res.status(500).json({ error: 'Failed to get global chat history' });
- }
-});
-
-// ── Chat Replay (for VOD/clip playback sync) ────────────────
-router.get('/:streamId/replay', optionalAuth, (req, res) => {
- try {
- const streamId = parseInt(req.params.streamId);
- if (!streamId) return res.status(400).json({ error: 'Invalid stream ID' });
-
- const from = req.query.from || null; // ISO timestamp
- const to = req.query.to || null; // ISO timestamp
-
- const messages = db.getChatReplay(streamId, from, to);
- res.json({ messages });
- } catch (err) {
- res.status(500).json({ error: 'Failed to get chat replay' });
- }
-});
-
-// ── Chat History ─────────────────────────────────────────────
-// Loads history for a streamer's chat. By default this spans ALL of that
-// broadcaster's stream sessions (not just the one slot/session being viewed),
-// so viewers keep context when a stream restarts or the streamer runs multiple
-// slots. Each message carries its source stream (title/slug/live) so the client
-// can label it and let viewers hop between the streamer's live slots.
-// Pass ?scope=stream to restrict to the single session (legacy behavior).
-router.get('/:streamId/history', optionalAuth, (req, res) => {
- try {
- const limit = Math.min(parseInt(req.query.limit || '500'), 500);
- const before = req.query.before; // ISO timestamp for pagination
- const scope = req.query.scope || 'streamer';
-
- // Resolve which broadcaster this stream belongs to.
- const stream = db.getStreamById(req.params.streamId);
- const broadcasterId = stream ? stream.user_id : null;
- const spanStreamer = scope !== 'stream' && broadcasterId;
-
- // COALESCE the channel name from the source stream's owner (live rows) OR the
- // message's channel_user_id (offline rows) so the streamer/channel tag renders
- // in history for both. cu = the broadcaster resolved from channel_user_id.
- const select = `SELECT cm.*, u.avatar_url, u.profile_color, u.role, u.display_name,
- u.username AS core_username,
- s.title AS source_stream_title, s.managed_stream_id AS source_managed_id,
- s.is_live AS source_is_live, ms.slug AS source_slug,
- COALESCE(bu.username, cu.username) AS source_channel`;
- let sql;
- const params = [];
- if (spanStreamer) {
- // Span by channel_user_id (LEFT JOIN streams) so OFFLINE messages
- // (stream_id NULL) survive when the streamer goes live — the old INNER
- // JOIN on streams dropped them.
- sql = `${select}
- FROM chat_messages cm
- LEFT JOIN users u ON cm.user_id = u.id
- LEFT JOIN streams s ON cm.stream_id = s.id
- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
- LEFT JOIN users bu ON s.user_id = bu.id
- LEFT JOIN users cu ON cm.channel_user_id = cu.id
- WHERE cm.channel_user_id = ? AND cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
- params.push(broadcasterId);
- } else {
- sql = `${select}
- FROM chat_messages cm
- LEFT JOIN users u ON cm.user_id = u.id
- LEFT JOIN streams s ON cm.stream_id = s.id
- LEFT JOIN managed_streams ms ON s.managed_stream_id = ms.id
- LEFT JOIN users bu ON s.user_id = bu.id
- LEFT JOIN users cu ON cm.channel_user_id = cu.id
- WHERE cm.stream_id = ? AND cm.is_deleted = 0
- AND (cm.auto_delete_at IS NULL OR datetime(cm.auto_delete_at) > CURRENT_TIMESTAMP)`;
- params.push(req.params.streamId);
- }
-
- // after_id: cursor read (oldest→newest, primary-key range scan) for reopen/reconnect.
- // Otherwise a page: the newest rows, optionally before a timestamp.
- const afterId = req.query.after_id != null ? Math.max(0, parseInt(req.query.after_id, 10) || 0) : null;
- if (afterId != null) {
- sql += ` AND cm.id > ? ORDER BY cm.id ASC LIMIT ?`;
- params.push(afterId, limit + 1);
- } else {
- if (before) {
- sql += ` AND cm.timestamp < ?`;
- params.push(before);
- }
- sql += ` ORDER BY cm.timestamp DESC LIMIT ?`;
- params.push(limit);
- }
-
- let rows = db.all(sql, params);
- let complete = true;
- if (afterId != null) { complete = rows.length <= limit; rows = rows.slice(0, limit); } else rows.reverse();
- const latest_id = rows.reduce((m, x) => (x.id > m ? x.id : m), 0) || (afterId || 0);
- const messages = enrichMessagesWithCosmetics(hydrateReplies(rows));
-
- // Currently-live slots for this broadcaster — lets the client render a
- // "hop between live streams" affordance.
- let liveSlots = [];
- let channel = null;
- if (broadcasterId) {
- try {
- const owner = db.getUserById(broadcasterId);
- channel = owner ? owner.username : null;
- liveSlots = (db.getManagedStreamsByUserId(broadcasterId) || [])
- .filter(ms => ms.is_currently_live)
- .map(ms => ({
- managed_stream_id: ms.id,
- slug: ms.slug,
- title: ms.title,
- live_session_id: ms.live_session_id,
- }));
- } catch { /* non-critical */ }
- }
- res.json({
- messages, latest_id, complete, liveSlots, channel,
- activeStreamId: parseInt(req.params.streamId) || null,
- activeManagedId: stream ? (stream.managed_stream_id || null) : null,
- });
- } catch (err) {
- res.status(500).json({ error: 'Failed to get chat history' });
- }
-});
-
-// Persistent per-streamer chat history keyed by the broadcaster's USER id — spans
-// every live slot AND offline periods (channel_user_id), so it works when the
-// streamer has no active session. Used by the channel page (online + offline).
-router.get('/channel/:userId/history', optionalAuth, (req, res) => {
- try {
- const userId = parseInt(req.params.userId);
- if (!userId) return res.status(400).json({ error: 'Bad user id' });
- const decorate = (rows) => enrichMessagesWithCosmetics(hydrateReplies(rows));
- const room = `channel:${userId}`;
- const out = req.query.after_id != null
- ? historyStore.delta(room, { afterId: req.query.after_id, limit: req.query.limit, decorate })
- : historyStore.page(room, { limit: req.query.limit, before: req.query.before, decorate });
- let liveSlots = [], channel = null;
- try {
- const owner = db.getUserById(userId);
- channel = owner ? owner.username : null;
- liveSlots = (db.getManagedStreamsByUserId(userId) || [])
- .filter(ms => ms.is_currently_live)
- .map(ms => ({ managed_stream_id: ms.id, slug: ms.slug, title: ms.title, live_session_id: ms.live_session_id }));
- } catch { /* non-critical */ }
- res.json({ ...out, liveSlots, channel, activeStreamId: null, activeManagedId: null });
- } catch (err) {
- res.status(500).json({ error: 'Failed to get channel chat history' });
- }
-});
-
-// ── Chat User Count ──────────────────────────────────────────
-router.get('/:streamId/users', (req, res) => {
- const chatServer = require('./chat-server');
- const count = chatServer.getStreamViewerCount(parseInt(req.params.streamId));
- res.json({ count });
-});
-
-// ── Chat Log Management ─────────────────────────────────────
-
-// Preview count of messages in a time range
-router.post('/admin/purge/preview', requireAuth, (req, res) => {
- try {
- const { streamId, from, to } = req.body;
- if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
-
- // Must be admin or stream owner
- let effectiveStreamId = streamId || null;
- if (!permissions.isAdmin(req.user)) {
- if (streamId) {
- const stream = db.getStreamById(streamId);
- if (!stream || stream.user_id !== req.user.id) {
- return res.status(403).json({ error: 'Not authorized' });
- }
- } else {
- // Non-admin without streamId: scope to their most recent stream
- const userStreams = db.getStreamsByUserId(req.user.id, 1);
- if (!userStreams?.length) {
- return res.json({ count: 0 });
- }
- effectiveStreamId = userStreams[0].id;
- }
- }
-
- const count = db.countChatMessagesByTimeRange(effectiveStreamId, from, to);
- res.json({ count });
- } catch (e) {
- console.error('[Chat] Purge preview error:', e.message);
- res.status(500).json({ error: 'Failed to count messages' });
- }
-});
-
-// Delete messages in a time range (soft delete)
-router.delete('/admin/purge', requireAuth, (req, res) => {
- try {
- const { streamId, from, to } = req.body;
- if (!from || !to) return res.status(400).json({ error: 'from and to are required' });
-
- let effectiveStreamId = streamId || null;
- if (!permissions.isAdmin(req.user)) {
- if (streamId) {
- const stream = db.getStreamById(streamId);
- if (!stream || stream.user_id !== req.user.id) {
- return res.status(403).json({ error: 'Not authorized' });
- }
- } else {
- // Non-admin without streamId: scope to their most recent stream
- const userStreams = db.getStreamsByUserId(req.user.id, 1);
- if (!userStreams?.length) {
- return res.json({ deleted: 0 });
- }
- effectiveStreamId = userStreams[0].id;
- }
- }
-
- const result = db.deleteChatMessagesByTimeRange(effectiveStreamId, from, to, req.user.display_name || req.user.username);
-
- // Broadcast delete event to live chat
- try {
- const chatServer = require('./chat-server');
- const payload = { type: 'purge', streamId: effectiveStreamId, from, to, by: req.user.display_name };
- if (effectiveStreamId) {
- chatServer.broadcastToStream(effectiveStreamId, payload);
- } else {
- chatServer.broadcastGlobal(payload);
- }
- } catch { /* chat server may not be initialized */ }
-
- console.log(`[Chat] Purged messages: stream=${effectiveStreamId || 'global'} from=${from} to=${to} by=${req.user.username} changes=${result?.changes || 0}`);
- res.json({ deleted: result?.changes || 0 });
- } catch (e) {
- console.error('[Chat] Purge error:', e.message);
- res.status(500).json({ error: 'Failed to purge messages' });
- }
-});
-
-// Get paginated chat logs with filters
-router.get('/admin/logs', requireAuth, (req, res) => {
- try {
- const { streamId, username, search, from, to, messageType, page, limit, includeDeleted } = req.query;
-
- // Must be admin or stream owner
- let effectiveStreamId = streamId ? parseInt(streamId) : undefined;
- if (!permissions.isAdmin(req.user)) {
- if (streamId) {
- const stream = db.getStreamById(parseInt(streamId));
- if (!stream || stream.user_id !== req.user.id) {
- return res.status(403).json({ error: 'Not authorized' });
- }
- } else {
- // Non-admin without streamId: scope to their most recent stream
- const userStreams = db.getStreamsByUserId(req.user.id, 1);
- if (!userStreams?.length) {
- return res.json({ rows: [], total: 0, page: 1, limit: 50, totalPages: 0 });
- }
- effectiveStreamId = userStreams[0].id;
- }
- }
-
- const result = db.getChatLogs({
- streamId: effectiveStreamId,
- username, search, from, to, messageType,
- page: parseInt(page) || 1,
- limit: Math.min(parseInt(limit) || 50, 200),
- includeDeleted: includeDeleted === 'true' && permissions.isAdmin(req.user),
- });
- res.json(result);
- } catch (e) {
- console.error('[Chat] Logs error:', e.message);
- res.status(500).json({ error: 'Failed to get chat logs' });
- }
-});
-
-// Export chat logs as CSV or JSON
-router.get('/admin/logs/export', requireAuth, (req, res) => {
- try {
- const { streamId, username, search, from, to, messageType, format } = req.query;
-
- if (!permissions.isAdmin(req.user)) {
- if (streamId) {
- const stream = db.getStreamById(parseInt(streamId));
- if (!stream || stream.user_id !== req.user.id) {
- return res.status(403).json({ error: 'Not authorized' });
- }
- } else {
- return res.status(403).json({ error: 'Only admins can export all chat logs' });
- }
- }
-
- // Get all matching rows (up to 50k)
- const result = db.getChatLogs({
- streamId: streamId ? parseInt(streamId) : undefined,
- username, search, from, to, messageType,
- page: 1, limit: 50000,
- });
-
- if (format === 'csv') {
- const header = 'id,timestamp,username,message,message_type,stream_id,is_global,source_platform\n';
- const csvRows = result.rows.map(r => {
- const msg = (r.message || '').replace(/"/g, '""');
- return `${r.id},"${r.timestamp}","${(r.username || '').replace(/"/g, '""')}","${msg}","${r.message_type || ''}",${r.stream_id || ''},"${r.is_global || 0}","${r.source_platform || ''}"`;
- });
- res.setHeader('Content-Type', 'text/csv');
- res.setHeader('Content-Disposition', `attachment; filename="chat-logs-${Date.now()}.csv"`);
- res.send(header + csvRows.join('\n'));
- } else {
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Content-Disposition', `attachment; filename="chat-logs-${Date.now()}.json"`);
- res.json(result.rows);
- }
- } catch (e) {
- console.error('[Chat] Export error:', e.message);
- res.status(500).json({ error: 'Failed to export chat logs' });
- }
-});
-
-module.exports = router;
deleted file mode 100644
deleted file mode 100644
@@ -1,392 +0,0 @@
@@ -1,392 +0,0 @@
-/**
- * OpenVibe.Live — Channel Sound Commands API
- *
- * Per-channel, viewer-uploadable sound clips triggered by !command in chat.
- *
- * GET /api/sounds/channel/:userId - Sounds for a streamer's channel
- * GET /api/sounds/all/:streamId - Sounds available in a stream context
- * POST /api/sounds - Upload a channel sound (auth required)
- * DELETE /api/sounds/:id - Delete a sound (uploader / mod / owner)
- * GET /api/sounds/file/:filename - Serve a sound file
- */
-const express = require('express');
-const path = require('path');
-const fs = require('fs');
-const { spawn } = require('child_process');
-const multer = require('multer');
-const db = require('../db/database');
-const { requireAuth } = require('../auth/auth');
-const permissions = require('../auth/permissions');
-const config = require('../config');
-
-const router = express.Router();
-
-// Commands already handled elsewhere in chat — cannot be overridden by a sound.
-const RESERVED_COMMANDS = new Set([
- 'sb', 'gotti', 'sr', 'yt', 'youtube', 'req', 'request', 'queue', 'np',
- 'nowplaying', 'watching', 'skip', 'mediahelp', 'say',
- 'forward', 'backward', 'left', 'right', 'liftup', 'liftdown', 'headup', 'headdown',
-]);
-
-const MIME_TO_EXT = {
- 'audio/mpeg': '.mp3', 'audio/mp3': '.mp3', 'audio/wav': '.wav', 'audio/x-wav': '.wav',
- 'audio/wave': '.wav', 'audio/vnd.wave': '.wav', 'audio/x-pn-wav': '.wav',
- 'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/mp4': '.m4a', 'audio/x-m4a': '.m4a',
- 'audio/aac': '.aac', 'audio/flac': '.flac', 'audio/x-flac': '.flac', 'audio/opus': '.opus',
-};
-// Browsers/OSes report .wav (and others) under many mimetypes — or none at all
-// (application/octet-stream). Fall back to the file extension so uploads don't wrongly fail.
-const ALLOWED_EXT = new Set(['.mp3', '.wav', '.ogg', '.oga', '.opus', '.webm', '.m4a', '.mp4', '.aac', '.flac']);
-
-function extFor(file) {
- if (MIME_TO_EXT[file.mimetype]) return MIME_TO_EXT[file.mimetype];
- const ext = path.extname(file.originalname || '').toLowerCase();
- return ALLOWED_EXT.has(ext) ? ext : '';
-}
-
-function soundsDir() {
- const dir = path.resolve(config.sounds.path);
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
- return dir;
-}
-
-const soundStorage = multer.diskStorage({
- destination: (req, file, cb) => cb(null, soundsDir()),
- filename: (req, file, cb) => {
- const ext = extFor(file) || '.bin';
- cb(null, `snd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
- },
-});
-const soundUpload = multer({
- storage: soundStorage,
- limits: { fileSize: config.sounds.maxSizeKb * 1024 },
- fileFilter: (req, file, cb) => {
- // Accept by known audio mimetype OR by a recognized audio extension.
- if (MIME_TO_EXT[file.mimetype] || ALLOWED_EXT.has(path.extname(file.originalname || '').toLowerCase())) {
- cb(null, true);
- } else {
- cb(new Error('Only audio files are allowed (MP3, WAV, OGG, Opus, WebM, M4A, AAC, FLAC).'));
- }
- },
-});
-
-/** Transcode an audio file to a canonical MP3 (fast decode, small, universal). Returns the
- * new path on success (original left for the caller to delete), or null on failure. */
-function convertToMp3(srcPath) {
- return new Promise((resolve) => {
- const outPath = srcPath.replace(/\.[^.]+$/, '') + '.conv.mp3';
- let proc;
- try {
- proc = spawn('ffmpeg', ['-y', '-i', srcPath, '-vn', '-ac', '2', '-ar', '44100', '-c:a', 'libmp3lame', '-b:a', '128k', outPath]);
- } catch { return resolve(null); }
- proc.on('close', (code) => resolve(code === 0 && fs.existsSync(outPath) ? outPath : null));
- proc.on('error', () => resolve(null));
- setTimeout(() => { try { proc.kill(); } catch {} resolve(null); }, 20000);
- });
-}
-
-/** Probe an audio file's duration in seconds (0 on failure). */
-function probeDuration(filePath) {
- return new Promise((resolve) => {
- let done = false;
- const finish = (v) => { if (!done) { done = true; resolve(v); } };
- let out = '';
- let probe;
- try {
- probe = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', filePath]);
- } catch { return finish(0); }
- probe.stdout.on('data', (d) => { out += d; });
- probe.on('close', () => {
- try {
- const info = JSON.parse(out);
- const dur = parseFloat(info.format?.duration || '0');
- finish(Number.isFinite(dur) && dur > 0 ? dur : 0);
- } catch { finish(0); }
- });
- probe.on('error', () => finish(0));
- setTimeout(() => { try { probe.kill(); } catch {} finish(0); }, 10000);
- });
-}
-
-function serializeSound(s) {
- return {
- id: s.id,
- command: s.command,
- url: `/api/sounds/file/${path.basename(s.url)}`,
- duration_seconds: Math.round((s.duration_seconds || 0) * 10) / 10,
- uploader: s.created_by_name || 'someone',
- uploader_id: s.created_by,
- emote_code: s.emote_code || '',
- created_at: s.created_at,
- };
-}
-
-// ── List a channel's sounds ──────────────────────────────────
-router.get('/channel/:userId', (req, res) => {
- try {
- const userId = parseInt(req.params.userId);
- if (!userId) return res.json({ sounds: [] });
- res.json({ sounds: db.getChannelSounds(userId).map(serializeSound) });
- } catch (err) {
- res.status(500).json({ error: 'Failed to load channel sounds' });
- }
-});
-
-// ── List sounds available in a stream context ────────────────
-router.get('/all/:streamId', (req, res) => {
- try {
- const stream = db.getStreamById(parseInt(req.params.streamId));
- if (!stream?.user_id) return res.json({ sounds: [] });
- res.json({ sounds: db.getChannelSounds(stream.user_id).map(serializeSound) });
- } catch (err) {
- res.status(500).json({ error: 'Failed to load sounds' });
- }
-});
-
-// ── Upload a channel sound ───────────────────────────────────
-router.post('/', requireAuth, soundUpload.single('sound'), async (req, res) => {
- const cleanup = () => { if (req.file && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch {} } };
- try {
- if (!req.file) return res.status(400).json({ error: 'No audio file uploaded' });
-
- const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');
- if (!/^[a-z0-9_]{2,24}$/.test(command)) {
- cleanup();
- return res.status(400).json({ error: 'Command must be 2-24 letters, numbers, or underscores' });
- }
- if (RESERVED_COMMANDS.has(command)) {
- cleanup();
- return res.status(400).json({ error: `"!${command}" is a reserved command — pick another name` });
- }
-
- // Resolve target channel: channel_id = streamer's user id; stream_id resolves to its owner.
- let channelOwnerId = parseInt(req.body.channel_id) || null;
- if (!channelOwnerId && req.body.stream_id) {
- channelOwnerId = db.getStreamById(parseInt(req.body.stream_id))?.user_id || null;
- }
- if (!channelOwnerId) channelOwnerId = req.user.id;
- const channel = db.getChannelByUserId(channelOwnerId) || (channelOwnerId === req.user.id ? db.ensureChannel(req.user.id) : null);
- if (!channel) {
- cleanup();
- return res.status(404).json({ error: 'Channel not found' });
- }
-
- const settings = (await require('./moderation-client').getChannelModeration(channel.id)).settings;
- const isMod = await permissions.canModerateChannel(req.user, channel.id);
- const isOwnChannel = channelOwnerId === req.user.id;
-
- if (!isMod && !settings.custom_sounds_enabled) {
- cleanup();
- return res.status(403).json({ error: 'This streamer has disabled viewer sound uploads.' });
- }
- if (!isMod && !isOwnChannel && (settings.uploads_mods_only || settings.sounds_mods_only)) {
- cleanup();
- return res.status(403).json({ error: 'Only channel mods can upload sounds here.' });
- }
-
- // Adding a NEW file to an EXISTING command is restricted to that command's
- // creator (or channel mods/owner), so viewers can't hijack someone's command.
- const existingForCmd = db.getChannelSoundByCommand(channelOwnerId, command);
- if (existingForCmd && !isMod && !isOwnChannel && existingForCmd.created_by !== req.user.id) {
- cleanup();
- return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can add more sounds to it.` });
- }
-
- // Per-channel + per-uploader caps
- if (db.countChannelSounds(channelOwnerId) >= config.sounds.maxPerChannel) {
- cleanup();
- return res.status(400).json({ error: `This channel has reached its sound limit (${config.sounds.maxPerChannel}).` });
- }
- if (!isMod && db.countChannelSoundsByUploader(channelOwnerId, req.user.id) >= config.sounds.maxPerUploaderPerChannel) {
- cleanup();
- return res.status(400).json({ error: `You've reached your upload limit for this channel (${config.sounds.maxPerUploaderPerChannel}).` });
- }
-
- // A command may hold multiple sounds (one is chosen at random on playback),
- // so duplicates are allowed. Per-channel / per-uploader count caps below still apply.
-
- // Duration gate
- const maxSeconds = Math.min(30, Math.max(1, settings.max_sound_seconds || config.sounds.defaultMaxSeconds));
- const duration = await probeDuration(req.file.path);
- if (!duration) {
- cleanup();
- return res.status(400).json({ error: 'Could not read that audio file — try a standard MP3 or WAV.' });
- }
- if (duration > maxSeconds + 0.25) {
- cleanup();
- return res.status(400).json({ error: `Sound is too long (${duration.toFixed(1)}s). Max is ${maxSeconds}s for this channel.` });
- }
-
- // Normalize every upload to MP3 — universal, small, and the fastest to decode for
- // low-latency chat playback. (Skip if it's already an .mp3.)
- let finalPath = req.file.path;
- let finalMime = req.file.mimetype;
- if (path.extname(finalPath).toLowerCase() !== '.mp3') {
- const mp3 = await convertToMp3(req.file.path);
- if (mp3) {
- try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
- finalPath = mp3;
- finalMime = 'audio/mpeg';
- } else {
- cleanup();
- return res.status(400).json({ error: 'Could not process that audio file. Try a standard MP3 or WAV.' });
- }
- }
-
- // Emote attached to the command (per-command; new files inherit the existing one).
- const emoteCode = String(req.body.emote_code || (existingForCmd && existingForCmd.emote_code) || '').trim().slice(0, 32);
- const result = db.createChannelSound({
- channel_owner_id: channelOwnerId,
- command,
- url: finalPath,
- mime: finalMime,
- duration_seconds: duration,
- created_by: req.user.id,
- created_by_name: req.user.display_name || req.user.username,
- emote_code: emoteCode,
- });
- // If an emote was (re)specified, apply it to every sound under this command.
- if (req.body.emote_code !== undefined) { try { db.setChannelSoundEmote(channelOwnerId, command, emoteCode); } catch { /* */ } }
- try { require('../media-proxy/asset-sync').syncSoon(); } catch { /* mirror is best-effort */ }
-
- // Tell everyone watching this channel to refresh their sound list live.
- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
-
- res.json({
- sound: {
- id: result.lastInsertRowid,
- command,
- url: `/api/sounds/file/${path.basename(finalPath)}`,
- duration_seconds: Math.round(duration * 10) / 10,
- channel_id: channelOwnerId,
- },
- });
- } catch (err) {
- console.error('[Sounds] Upload error:', err);
- cleanup();
- if (err && /UNIQUE constraint/i.test(err.message || '')) {
- return res.status(409).json({ error: 'That command already exists on this channel.' });
- }
- res.status(500).json({ error: 'Failed to upload sound' });
- }
-});
-
-// ── Multer error handler ─────────────────────────────────────
-router.use((err, req, res, next) => {
- if (err.code === 'LIMIT_FILE_SIZE') {
- return res.status(400).json({ error: `File too large (max ${Math.round(config.sounds.maxSizeKb / 1024 * 10) / 10}MB)` });
- }
- if (err.message && err.message.includes('Only')) {
- return res.status(400).json({ error: err.message });
- }
- console.error('[Sounds] Middleware error:', err);
- res.status(500).json({ error: 'Sound upload failed' });
-});
-
-// ── Delete a sound ───────────────────────────────────────────
-router.delete('/:id', requireAuth, async (req, res) => {
- try {
- const sound = db.getChannelSoundById(parseInt(req.params.id));
- if (!sound) return res.status(404).json({ error: 'Sound not found' });
- let allowed = sound.created_by === req.user.id || permissions.can(req.user, 'staff.assets.manage');
- if (!allowed) {
- const channel = db.getChannelByUserId(sound.channel_owner_id);
- if (channel && await permissions.canModerateChannel(req.user, channel.id)) allowed = true;
- }
- if (!allowed) return res.status(403).json({ error: 'Not your sound' });
-
- if (sound.url && fs.existsSync(sound.url)) { try { fs.unlinkSync(sound.url); } catch {} }
- db.deleteChannelSound(sound.id);
- try { require('../media-proxy/asset-sync').removeAsset(sound.media_asset_id); } catch { /* */ }
- try { require('./chat-server').broadcastToOwnerStreams(sound.channel_owner_id, { type: 'sounds-updated' }); } catch { /* */ }
- res.json({ message: 'Sound deleted' });
- } catch (err) {
- res.status(500).json({ error: 'Failed to delete sound' });
- }
-});
-
-// ── Edit a !command group in place (rename / set emote) ──────
-// Body: { channel_id? , stream_id?, command, newCommand?, emoteCode? }.
-// A command may hold several sounds, so edits apply to the whole group.
-// Allowed: channel owner, channel mods, admins, or the creator of every
-// sound in the group (mirrors the add-to-command rule).
-router.patch('/command', requireAuth, async (req, res) => {
- try {
- let channelOwnerId = parseInt(req.body.channel_id) || null;
- if (!channelOwnerId && req.body.stream_id) {
- channelOwnerId = db.getStreamById(parseInt(req.body.stream_id))?.user_id || null;
- }
- if (!channelOwnerId) channelOwnerId = req.user.id;
- const channel = db.getChannelByUserId(channelOwnerId);
- if (!channel) return res.status(404).json({ error: 'Channel not found' });
-
- const command = String(req.body.command || '').trim().toLowerCase().replace(/^!+/, '');
- const group = (db.getChannelSounds(channelOwnerId) || []).filter((s) => s.command === command);
- if (!group.length) return res.status(404).json({ error: `No sound command !${command} in this channel.` });
-
- const isMod = await permissions.canModerateChannel(req.user, channel.id);
- const isOwnChannel = channelOwnerId === req.user.id;
- const isCreator = group.every((s) => s.created_by === req.user.id);
- if (!isMod && !isOwnChannel && !isCreator && !permissions.can(req.user, 'staff.assets.manage')) {
- return res.status(403).json({ error: `Only the creator of !${command} (or a mod) can edit it.` });
- }
-
- let finalCommand = command;
- if (req.body.newCommand !== undefined) {
- const next = String(req.body.newCommand || '').trim().toLowerCase().replace(/^!+/, '');
- if (!/^[a-z0-9_]{2,24}$/.test(next)) {
- return res.status(400).json({ error: 'Command must be 2-24 letters, numbers, or underscores' });
- }
- if (RESERVED_COMMANDS.has(next)) {
- return res.status(400).json({ error: `"!${next}" is a reserved command — pick another name` });
- }
- if (next !== command && db.getChannelSoundByCommand(channelOwnerId, next)) {
- return res.status(409).json({ error: `!${next} already exists in this channel.` });
- }
- if (next !== command) {
- db.renameChannelSoundCommand(channelOwnerId, command, next);
- finalCommand = next;
- }
- }
- if (req.body.emoteCode !== undefined) {
- const emoteCode = String(req.body.emoteCode || '').trim().replace(/^:|:$/g, '');
- if (emoteCode && !/^[a-zA-Z0-9_]{2,32}$/.test(emoteCode)) {
- return res.status(400).json({ error: 'Emote code can only contain letters, numbers, and underscores' });
- }
- db.setChannelSoundEmote(channelOwnerId, finalCommand, emoteCode);
- }
-
- try { require('./chat-server').broadcastToOwnerStreams(channelOwnerId, { type: 'sounds-updated' }); } catch { /* */ }
- res.json({ message: 'Sound command updated', command: finalCommand });
- } catch (err) {
- res.status(500).json({ error: 'Failed to update sound command' });
- }
-});
-
-// ── Serve a sound file ───────────────────────────────────────
-const EXT_TO_MIME = {
- '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
- '.webm': 'audio/webm', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.flac': 'audio/flac',
-};
-router.get('/file/:filename', (req, res) => {
- try {
- const filename = path.basename(req.params.filename);
- const filePath = path.resolve(soundsDir(), filename);
- if (!filePath.startsWith(soundsDir()) || !fs.existsSync(filePath)) {
- return res.status(404).json({ error: 'Sound not found' });
- }
- const ext = path.extname(filename).toLowerCase();
- res.setHeader('Content-Type', EXT_TO_MIME[ext] || 'application/octet-stream');
- res.setHeader('Cache-Control', 'public, max-age=86400');
- fs.createReadStream(filePath).pipe(res);
- } catch (err) {
- res.status(500).json({ error: 'Failed to serve sound' });
- }
-});
-
-// Streamer alert sounds (donation / goal-reached) moved to OpenVibe.Chat with the six tables
-// (roadmap T3): they live on channel_moderation_settings, which Chat owns, and nginx routes
-// /api/sounds there. Live's own sounds router keeps only the channel-sound commands above and
-// runs only when Live itself serves chat.
-
-module.exports = router;
deleted file mode 100644
deleted file mode 100644
@@ -1,134 +0,0 @@
@@ -1,134 +0,0 @@
-/**
- * OpenVibe.Live — TTS API Routes
- *
- * Public routes (authenticated users):
- * GET /api/tts/voices — List available voices
- * GET /api/tts/settings — Get TTS config for client
- *
- * Admin routes:
- * GET /api/tts/admin/settings — Full TTS config with API keys
- * PUT /api/tts/admin/settings — Update TTS config
- */
-const express = require('express');
-const router = express.Router();
-const { requireAuth, requireAdmin } = require('../auth/auth');
-const { isOwner } = require('../auth/permissions');
-const ttsEngine = require('./tts-engine');
-const db = require('../db/database');
-
-// TTS credential fields/keys are owner-only. Admins may still manage the
-// non-secret TTS config (enabled, provider, limits, default voice).
-const TTS_SECRET_FIELDS = ['googleApiKey', 'googleServiceAccount', 'awsAccessKeyId', 'awsSecretAccessKey'];
-const TTS_SECRET_KEYS = new Set([
- 'tts_google_api_key', 'tts_google_service_account',
- 'tts_aws_access_key_id', 'tts_aws_secret_access_key',
-]);
-function maskTtsSecret(v) {
- if (!v) return v;
- const s = String(v);
- return s.length <= 8 ? '••••••••' : '••••' + s.slice(-4);
-}
-
-// ── Public: Get available voices ──────────────────────────────
-router.get('/voices', (req, res) => {
- try {
- const voices = ttsEngine.getAvailableVoices();
- res.json({ voices });
- } catch (e) {
- res.status(500).json({ error: e.message });
- }
-});
-
-// ── Public: Get TTS settings for client ───────────────────────
-router.get('/settings', (req, res) => {
- try {
- const settings = ttsEngine.getTTSSettings();
- // Don't expose API keys to the client
- res.json({
- enabled: settings.enabled,
- provider: settings.provider,
- googleConfigured: !!(settings.googleApiKey || settings.googleServiceAccount),
- pollyConfigured: !!settings.awsAccessKeyId,
- espeakAvailable: !!ttsEngine.detectEspeak(),
- maxLength: settings.maxLength,
- maxQueuePerUser: settings.maxQueuePerUser,
- maxQueueGlobal: settings.maxQueueGlobal,
- defaultVoice: settings.defaultVoice,
- });
- } catch (e) {
- res.status(500).json({ error: e.message });
- }
-});
-
-// ── Admin: Get full TTS config ────────────────────────────────
-router.get('/admin/settings', requireAuth, requireAdmin, (req, res) => {
- try {
- const settings = ttsEngine.getTTSSettings();
- // Mask credential fields for non-owners.
- if (!isOwner(req.user)) {
- for (const f of TTS_SECRET_FIELDS) {
- if (settings[f]) settings[f] = maskTtsSecret(settings[f]);
- }
- settings._secretsRedacted = true;
- }
- res.json({ settings });
- } catch (e) {
- res.status(500).json({ error: e.message });
- }
-});
-
-// ── Admin: Update TTS config ──────────────────────────────────
-router.put('/admin/settings', requireAuth, requireAdmin, (req, res) => {
- try {
- const allowed = [
- 'tts_enabled', 'tts_provider',
- 'tts_google_api_key', 'tts_google_service_account',
- 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region',
- 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global',
- 'tts_default_voice',
- ];
- const updates = req.body.settings || req.body;
- const owner = isOwner(req.user);
- let count = 0;
- for (const [key, value] of Object.entries(updates)) {
- if (!allowed.includes(key)) continue;
- // Credential keys are owner-only.
- if (TTS_SECRET_KEYS.has(key) && !owner) continue;
- // Never overwrite a stored secret with a masked placeholder.
- if (TTS_SECRET_KEYS.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;
- db.setSetting(key, value);
- count++;
- }
- ttsEngine.invalidateSettingsCache();
- res.json({ success: true, updated: count });
- } catch (e) {
- res.status(500).json({ error: e.message });
- }
-});
-
-// ── Admin: Test voice synthesis ───────────────────────────────
-router.post('/admin/test', requireAuth, requireAdmin, async (req, res) => {
- try {
- const { voiceId, text } = req.body;
- const result = await ttsEngine.synthesize(text || 'This is a TTS test from OpenVibe.Live', voiceId);
- if (!result) return res.status(400).json({ error: 'Voice unavailable or TTS disabled' });
- // Same-origin URL alongside the base64: plays under media-src 'self' even where the
- // browser (or an overzealous shield) refuses blob:/data: audio.
- try { const stashed = require('../arena/voice').stash(result.audio, result.mimeType); if (stashed) result.url = `/api/tts/audio/${stashed.file}`; } catch { /* base64 still works */ }
- res.json(result);
- } catch (e) {
- res.status(500).json({ error: e.message });
- }
-});
-
-// Cached one-off clips (admin tests, previews) — streamed same-origin so no blob:/data: is needed.
-router.get('/audio/:file', (req, res) => {
- try {
- const hit = require('../arena/voice').cachedByName(req.params.file);
- if (!hit) return res.status(404).json({ error: 'No such clip' });
- res.set({ 'Content-Type': hit.mimeType, 'Cache-Control': 'private, max-age=86400' });
- require('fs').createReadStream(hit.path).pipe(res);
- } catch (e) { res.status(500).json({ error: e.message }); }
-});
-
-module.exports = router;
@@ -108,7 +108,6 @@ const VibeCodingPublishServer = require('./vibe-coding/publish-server');
@@ -108,7 +108,6 @@ const VibeCodingPublishServer = require('./vibe-coding/publish-server');
// Routes
// Routes
const authRoutes = require('./auth/routes');
const authRoutes = require('./auth/routes');
const streamRoutes = require('./streaming/routes');
const streamRoutes = require('./streaming/routes');
-const chatRoutes = require('./chat/routes');
const monetizationRoutes = require('./monetization/routes');
const monetizationRoutes = require('./monetization/routes');
const coinsRoutes = require('./monetization/coins-routes');
const coinsRoutes = require('./monetization/coins-routes');
const cosmeticsRoutes = require('./monetization/cosmetics-routes');
const cosmeticsRoutes = require('./monetization/cosmetics-routes');
@@ -669,11 +668,8 @@ app.use('/api/streams', streamRoutes);
@@ -669,11 +668,8 @@ app.use('/api/streams', streamRoutes);
// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
// Cross-site "streamer went live" SSE feed (consumed by /live-notify.js everywhere).
const liveEvents = require('./streaming/live-events');
const liveEvents = require('./streaming/live-events');
app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
app.get('/api/live-events', (req, res) => liveEvents.subscribe(req, res));
-// CHAT_AUTHORITY=chat: OpenVibe.Chat serves the chat REST routes (nginx sends /api/chat/, /api/dm/,
-// /api/tts/, /api/sounds there). One that still reaches Live must not write to the mirror.
-const chatRemote = require('./chat/chat-authority').isRemote();
-const chatMoved = (req, res) => res.status(503).set('Retry-After', '2').json({ error: 'Chat is served by OpenVibe.Chat' });
-app.use('/api/chat', chatRemote ? chatMoved : chatRoutes);
+// OpenVibe.Chat owns the chat REST routes (/api/chat/, /api/dm/, /api/tts/, /api/sounds); nginx
+// sends them there, so Live mounts none of them and keeps no CHAT_AUTHORITY rollback.
app.use('/api/funds', monetizationRoutes);
app.use('/api/funds', monetizationRoutes);
app.use('/api/coins', coinsRoutes);
app.use('/api/coins', coinsRoutes);
app.use('/api/payments', require('./monetization/payments-routes'));
app.use('/api/payments', require('./monetization/payments-routes'));
@@ -700,7 +696,6 @@ app.use('/api/restream', restreamRoutes);
@@ -700,7 +696,6 @@ app.use('/api/restream', restreamRoutes);
// they may be embedded cross-site; everything else keeps helmet's same-origin resource policy.
// they may be embedded cross-site; everything else keeps helmet's same-origin resource policy.
app.use('/api/thumbnails', (req, res, next) => { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); next(); }, thumbnailRoutes);
app.use('/api/thumbnails', (req, res, next) => { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); next(); }, thumbnailRoutes);
app.use('/api/themes', themeRoutes);
app.use('/api/themes', themeRoutes);
-app.use('/api/sounds', chatRemote ? chatMoved : require('./chat/sounds-routes'));
app.use('/api/ai-viewers', require('./ai/viewers/routes'));
app.use('/api/ai-viewers', require('./ai/viewers/routes'));
app.use('/api/powerchat', require('./integrations/powerchat-routes'));
app.use('/api/powerchat', require('./integrations/powerchat-routes'));
app.use('/api/meta', metaRoutes);
app.use('/api/meta', metaRoutes);
@@ -717,10 +712,6 @@ app.use('/api/media', require('./media/routes'));
@@ -717,10 +712,6 @@ app.use('/api/media', require('./media/routes'));
// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.
// viewer's IP/UA never reaches a host a streamer chose. SSRF-guarded, image-only, cache-forever.
app.use('/api/img-proxy', require('./media/external-image-proxy'));
app.use('/api/img-proxy', require('./media/external-image-proxy'));
app.use('/api/vibe-coding', vibeCodingRoutes);
app.use('/api/vibe-coding', vibeCodingRoutes);
-const ttsRoutes = require('./chat/tts-routes');
-app.use('/api/tts', chatRemote ? chatMoved : ttsRoutes);
-const dmRoutes = require('./chat/dm-routes');
-app.use('/api/dm', chatRemote ? chatMoved : dmRoutes);
const analyticsRoutes = require('./streaming/analytics-routes');
const analyticsRoutes = require('./streaming/analytics-routes');
app.use('/api/analytics', analyticsRoutes);
app.use('/api/analytics', analyticsRoutes);
const newsRoutes = require('./news/news-routes');
const newsRoutes = require('./news/news-routes');
@@ -60,16 +60,10 @@ voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, us
@@ -60,16 +60,10 @@ voice._setSynth(async (v, text, username) => { calls.push({ sig: v.sig, text, us
assert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');
assert.strictEqual(voice.cachedByName('../../etc/passwd'), null, 'no traversal');
assert.strictEqual(voice.cachedByName('zz.wav'), null);
assert.strictEqual(voice.cachedByName('zz.wav'), null);
assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');
assert.ok(voice.cachedByName(st.file).mimeType === 'audio/wav');
- const app2 = require('express')(); app2.use('/api/tts', require('../server/chat/tts-routes'));
- const srv2 = await new Promise(r => { const x = app2.listen(0, () => r(x)); });
- const clip = await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/${st.file}`);
- assert.strictEqual(clip.status, 200); assert.strictEqual(clip.headers.get('content-type'), 'audio/wav'); assert.strictEqual(await clip.text(), 'RIFFfakewav');
- assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/nope.wav`)).status, 404);
- // an arena voice line is reachable through the same clip route by its cache filename
+ // An arena voice line lands in the clip cache by filename (the /api/tts clip route is Chat's now).
const line = await voice.speak({ user, text: 'you think your chat is loud?' });
const line = await voice.speak({ user, text: 'you think your chat is loud?' });
- assert.strictEqual((await fetch(`http://127.0.0.1:${srv2.address().port}/api/tts/audio/${path.basename(line.path)}`)).status, 200);
- srv2.close();
- console.log('✅ one-off clip stash + same-origin /api/tts/audio route');
+ assert.ok(voice.cachedByName(path.basename(line.path)), 'a spoken line is served from the clip cache');
+ console.log('✅ one-off clip stash + arena voice cache');
console.log('\n✅ All Arena voice tests passed');
console.log('\n✅ All Arena voice tests passed');
process.exit(0);
process.exit(0);
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
@@ -57,7 +57,6 @@ app.use(express.json());
@@ -57,7 +57,6 @@ app.use(express.json());
app.use('/api/mod', require('../server/admin/mod-routes'));
app.use('/api/mod', require('../server/admin/mod-routes'));
app.use('/api/controls', require('../server/controls/routes'));
app.use('/api/controls', require('../server/controls/routes'));
app.use('/api/admin', require('../server/admin/routes'));
app.use('/api/admin', require('../server/admin/routes'));
-app.use('/api/dm', require('../server/chat/dm-routes'));
app.use('/api/media', require('../server/media/routes'));
app.use('/api/media', require('../server/media/routes'));
app.use('/api/ai-viewers', require('../server/ai/viewers/routes'));
app.use('/api/ai-viewers', require('../server/ai/viewers/routes'));
app.use('/api/payments', require('../server/monetization/payments-routes'));
app.use('/api/payments', require('../server/monetization/payments-routes'));
@@ -146,16 +145,6 @@ async function check(name, fn) {
@@ -146,16 +145,6 @@ async function check(name, fn) {
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);
});
});
- await check('DM: a private 1:1 cannot have a third person added', async () => {
- const dm = require('../server/chat/dm');
- dm.ensureTables();
- const conv = dm.createConversation(3, [3, 4]);
- const convId = typeof conv === 'object' ? (conv.id || conv.lastInsertRowid) : conv;
- const r = await call('POST', `/api/dm/conversations/${convId}/participants`, 3, { user_id: 5 });
- assert.strictEqual(r.status, 400, r.text);
- assert.ok(!dm.isParticipant(convId, 5));
- });
-
await check('media refund: no credit when the streamer no longer holds the Vibes', () => {
await check('media refund: no credit when the streamer no longer holds the Vibes', () => {
const mq = require('../server/media/media-queue');
const mq = require('../server/media/media-queue');
const reqRow = raw.prepare(`INSERT INTO media_requests (streamer_id, user_id, username, title, cost, currency, status, provider, input, canonical_url)
const reqRow = raw.prepare(`INSERT INTO media_requests (streamer_id, user_id, username, title, cost, currency, status, provider, input, canonical_url)
deleted file mode 100644
deleted file mode 100644
@@ -1,254 +0,0 @@
@@ -1,254 +0,0 @@
-/**
- * Legacy parity (roadmap D20): the chat-log views.
- *
- * streamer Dashboard → Chat Logs (public/js/dashboard.js loadDashChatLogs / dashPurgeChatRange):
- * search, user and time filters, pages of 50, purge a time range after a preview count.
- * Scoped server-side to the streamer's own (most recent) stream.
- * mod the chat user card's "View Chat Logs" (public/js/chat.js openChatLogsModal): a user's,
- * an anonymous chatter's or a relay chatter's history, and search. Staff (admin, global
- * mod: staff.moderation.logs) see anyone's; everyone else only their own.
- *
- * The page's own functions run against a fake DOM and the real routes (server/chat/routes.js, the
- * CHAT_AUTHORITY=live path; OpenVibe.Chat serves the same API otherwise) on a temp database.
- *
- * node test/chat-log-ui.test.js
- */
-'use strict';
-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-chat-logs-'));
-process.env.DB_PATH = path.join(tmp, 'live.db');
-process.env.DATA_DIR = tmp;
-process.env.NODE_ENV = 'test';
-const quiet = console.log;
-console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
-console.warn = () => {};
-
-const root = path.join(__dirname, '..');
-const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
-const db = require('../server/db/database');
-db.initDb();
-const raw = db.getDb();
-
-const auth = require('../server/auth/auth');
-const signIn = (req) => {
- const id = Number(req.headers['x-test-user'] || 0);
- const u = id ? db.getUserById(id) : null;
- if (u) { req.user = u; req.authSource = 'network'; }
- return u;
-};
-auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
-auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
-require.cache[require.resolve('../server/chat/chat-server')] = {
- id: 'chat-server-stub', filename: 'chat-server-stub', loaded: true,
- exports: { broadcastToStream() {}, broadcastGlobal() {}, getStreamViewerCount: () => 0 },
-};
-
-const addUser = (id, username, role) => raw.prepare(
- `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
- VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
-addUser(1, 'admin', 'admin');
-addUser(3, 'alice', 'streamer');
-addUser(4, 'bob', 'streamer');
-addUser(6, 'mod', 'global_mod');
-addUser(7, 'viewer', 'user');
-for (const id of [3, 4]) db.ensureChannel(id);
-const stream = (userId, created) => {
- const id = Number(db.createStream({ user_id: userId, channel_id: db.getChannelByUserId(userId).id, title: 's', protocol: 'webrtc' }).lastInsertRowid);
- raw.prepare('UPDATE streams SET created_at = ? WHERE id = ?').run(created, id);
- return id;
-};
-const aliceOld = stream(3, '2026-09-19 10:00:00');
-const aliceNow = stream(3, '2026-09-20 10:00:00');
-const bobs = stream(4, '2026-09-20 11:00:00');
-const say = (sid, userId, username, message, ts, extra = {}) => raw.prepare(
- `INSERT INTO chat_messages (stream_id, user_id, anon_id, username, message, message_type, timestamp) VALUES (?, ?, ?, ?, ?, 'chat', ?)`)
- .run(sid, userId, extra.anon || null, username, message, ts);
-say(aliceOld, 7, 'viewer', 'yesterday hello', '2026-09-19 10:05:00');
-say(aliceNow, 7, 'viewer', 'hi alice', '2026-09-20 10:01:00');
-say(aliceNow, 7, 'viewer', '<img src=x onerror=alert(1)>', '2026-09-20 10:02:00');
-say(aliceNow, null, 'anon42', 'anon says hi', '2026-09-20 10:03:00', { anon: 'anon42' });
-say(bobs, 7, 'viewer', 'hi bob', '2026-09-20 11:01:00');
-say(bobs, 4, 'bob', 'bob replies', '2026-09-20 11:02:00');
-
-const express = require('express');
-const app = express();
-app.use(express.json());
-app.use('/api/chat', require('../server/chat/routes'));
-const server = http.createServer(app).listen(0);
-function call(method, p, user, body) {
- return new Promise((resolve, reject) => {
- const data = body ? JSON.stringify(body) : null;
- const headers = { 'content-type': 'application/json' };
- if (data) headers['content-length'] = Buffer.byteLength(data);
- if (user) headers['x-test-user'] = String(user);
- const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
- let text = '';
- res.on('data', (c) => { text += c; });
- res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text, headers: res.headers }); });
- });
- req.on('error', reject);
- if (data) req.write(data);
- req.end();
- });
-}
-
-/** The source of a top-level function declaration, braces matched. */
-function extract(src, name) {
- const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
- assert.ok(m, `${name}() must exist`);
- let i = src.indexOf('{', m.index);
- let depth = 0;
- for (; i < src.length; i++) {
- if (src[i] === '{') depth++;
- else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
- }
- throw new Error(`unbalanced ${name}`);
-}
-
-/** The dashboard card, with the page's own functions and api() going to the real routes as `user`. */
-function dashboard(user, inputs = {}) {
- const els = {};
- for (const id of ['dash-chatlog-results', 'dash-chatlog-pagination', 'dash-chatlog-search', 'dash-chatlog-username', 'dash-chatlog-from', 'dash-chatlog-to']) {
- els[id] = { innerHTML: '', value: inputs[id] || '' };
- }
- const toasts = [];
- const requests = [];
- const fixtures = {
- document: { getElementById: (id) => els[id] || null },
- api: async (p, opts = {}) => {
- requests.push([opts.method || 'GET', p]);
- const r = await call(opts.method || 'GET', `/api${p}`, user, opts.body ? JSON.parse(opts.body) : undefined);
- if (r.status >= 400) throw new Error(r.json?.error || `HTTP ${r.status}`);
- return r.json;
- },
- toast: (msg, kind) => toasts.push([msg, kind]),
- confirm: () => true,
- _chatLogPage: 1,
- };
- const scope = new Proxy(fixtures, {
- has: (t, k) => typeof k === 'string',
- get: (t, k) => (k === Symbol.unscopables ? undefined : (k in t ? t[k] : globalThis[k])),
- set: (t, k, v) => { t[k] = v; return true; },
- });
- const src = [extract(read('public/js/app.js'), 'esc'), extract(read('public/js/dashboard.js'), 'loadDashChatLogs'), extract(read('public/js/dashboard.js'), 'dashPurgeChatRange')].join('\n');
- // eslint-disable-next-line no-new-func
- const fns = new Function('scope', `with (scope) { ${src}\n return { loadDashChatLogs, dashPurgeChatRange }; }`)(scope);
- return { fns, els, toasts, requests };
-}
-
-let failures = 0;
-async function check(name, fn) {
- try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack || e.message); }
-}
-
-(async () => {
- await new Promise((r) => server.once('listening', r));
-
- await check('the dashboard card is part of the dashboard page and wired to the page\'s functions', async () => {
- const reg = JSON.parse(read('public/features.json'));
- assert.strictEqual(reg.features.dashboard.fragment, 'dashboard');
- assert.ok(reg.features.dashboard.js.includes('/js/dashboard.js'));
- const html = read('public/fragments/dashboard.html');
- for (const id of ['dash-card-chatlogs', 'dash-chatlog-search', 'dash-chatlog-username', 'dash-chatlog-from', 'dash-chatlog-to', 'dash-chatlog-results', 'dash-chatlog-pagination']) {
- assert.ok(html.includes(`id="${id}"`), `#${id}`);
- }
- assert.match(html, /onclick="loadDashChatLogs\(\)"/);
- assert.match(html, /onclick="dashPurgeChatRange\(\)"/);
- });
-
- await check('a streamer sees their own stream\'s log, escaped, newest first', async () => {
- const d = dashboard(3);
- await d.fns.loadDashChatLogs();
- const out = d.els['dash-chatlog-results'].innerHTML;
- assert.ok(out.includes('<table'), out);
- assert.ok(out.includes('hi alice') && out.includes('anon says hi'));
- assert.ok(!out.includes('hi bob') && !out.includes('yesterday hello'), 'not another channel\'s, nor an older stream\'s');
- assert.ok(out.includes('<img src=x onerror=alert(1)>') && !out.includes('<img src=x'), 'messages are escaped');
- assert.ok(out.indexOf('anon says hi') < out.indexOf('hi alice'), 'newest first');
- });
-
- await check('the search, user and time filters narrow it', async () => {
- let d = dashboard(3, { 'dash-chatlog-search': 'says' });
- await d.fns.loadDashChatLogs();
- assert.ok(d.els['dash-chatlog-results'].innerHTML.includes('anon says hi'));
- assert.ok(!d.els['dash-chatlog-results'].innerHTML.includes('hi alice'));
- d = dashboard(3, { 'dash-chatlog-username': 'nobody' });
- await d.fns.loadDashChatLogs();
- assert.match(d.els['dash-chatlog-results'].innerHTML, /No messages found/);
- // datetime-local inputs are local time; the page sends them as ISO instants.
- const local = (iso) => { const t = new Date(iso); return new Date(t.getTime() - t.getTimezoneOffset() * 60000).toISOString().slice(0, 16); };
- d = dashboard(3, { 'dash-chatlog-from': local('2026-09-20T10:02:00Z'), 'dash-chatlog-to': local('2026-09-20T10:02:59Z') });
- await d.fns.loadDashChatLogs();
- const out = d.els['dash-chatlog-results'].innerHTML;
- assert.ok(out.includes('onerror') && !out.includes('hi alice') && !out.includes('anon says hi'), 'exactly the minute asked for');
- });
-
- await check('pages of 50 with previous/next', async () => {
- for (let i = 0; i < 60; i++) say(aliceNow, 7, 'viewer', `line ${i}`, `2026-09-20 12:${String(i).padStart(2, '0')}:00`);
- const d = dashboard(3);
- await d.fns.loadDashChatLogs();
- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /Page 1 of 2/);
- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /loadDashChatLogs\(2\)/);
- await d.fns.loadDashChatLogs(2);
- assert.match(d.els['dash-chatlog-pagination'].innerHTML, /loadDashChatLogs\(1\)/);
- assert.ok(d.requests.every(([, p]) => /limit=50/.test(p)));
- });
-
- await check('purge a range from the card: preview count, confirm, purge, reload', async () => {
- const local = (iso) => { const t = new Date(iso); return new Date(t.getTime() - t.getTimezoneOffset() * 60000).toISOString().slice(0, 16); };
- const d = dashboard(3, { 'dash-chatlog-from': local('2026-09-20T12:00:00Z'), 'dash-chatlog-to': local('2026-09-20T12:09:00Z') });
- await d.fns.dashPurgeChatRange();
- assert.deepStrictEqual(d.toasts.pop(), ['Purged 10 messages', 'success']);
- assert.deepStrictEqual(d.requests.map(([m, p]) => `${m} ${p.split('?')[0]}`), ['POST /chat/admin/purge/preview', 'DELETE /chat/admin/purge', 'GET /chat/admin/logs']);
- const empty = dashboard(3);
- await empty.fns.dashPurgeChatRange();
- assert.strictEqual(empty.toasts[0][1], 'error', 'both ends of the range are required');
- });
-
- await check('the server keeps each streamer to their own logs; staff see any, and purged lines on request', async () => {
- assert.strictEqual((await call('GET', `/api/chat/admin/logs?streamId=${bobs}`, 3)).status, 403);
- assert.strictEqual((await call('GET', `/api/chat/admin/logs/export?streamId=${bobs}&format=json`, 3)).status, 403);
- assert.strictEqual((await call('GET', '/api/chat/admin/logs/export?format=json', 3)).status, 403, 'only staff export everything');
- const own = await call('GET', `/api/chat/admin/logs/export?streamId=${aliceOld}&format=csv`, 3);
- assert.strictEqual(own.status, 200);
- assert.match(own.headers['content-type'], /text\/csv/);
- assert.match(own.text, /yesterday hello/, 'an older stream of their own by id');
- const admin = await call('GET', `/api/chat/admin/logs?streamId=${aliceNow}&includeDeleted=true&limit=200`, 1);
- assert.strictEqual(admin.json.total, 63, 'purged lines stay auditable by staff');
- const owner = await call('GET', `/api/chat/admin/logs?streamId=${aliceNow}&includeDeleted=true&limit=200`, 3);
- assert.strictEqual(owner.json.total, 53, 'the streamer only sees what is left');
- });
-
- await check('the mod view: staff read anyone\'s history, anonymous and relay chatters included; others only their own', async () => {
- const chat = read('public/js/chat.js');
- const load = extract(chat, 'loadChatLogs');
- for (const route of ['/chat/anon/${encodeURIComponent(ctx.anon)}/logs', '/chat/relay-user/${encodeURIComponent(ctx.relay.platform)}/${encodeURIComponent(ctx.relay.username)}/logs', '/chat/user/${ctx.userId}/history', '/chat/search?${params}']) {
- assert.ok(load.includes(route), `the modal reads ${route}`);
- }
- assert.match(load, /esc\(m\.message\)/, 'and escapes what it shows');
- assert.match(chat, /onclick="ctxViewLogs\('\$\{esc\(username\)\}', '\$\{data\.user\.id\}'\);closeModal\(\)"/, 'from the user card');
-
- const mine = await call('GET', '/api/chat/user/7/history?limit=50', 7);
- assert.strictEqual(mine.status, 200, 'a user reads their own');
- assert.strictEqual((await call('GET', '/api/chat/user/4/history', 7)).status, 403, 'not someone else\'s');
- const modView = await call('GET', '/api/chat/user/7/history?limit=200', 6);
- assert.strictEqual(modView.status, 200);
- assert.strictEqual(modView.json.total, 54, 'the viewer\'s lines on every channel, less the 10 purged');
- assert.strictEqual((await call('GET', '/api/chat/anon/anon42/logs', 6)).json.messages[0].message, 'anon says hi');
- assert.strictEqual((await call('GET', '/api/chat/anon/anon42/logs', 3)).status, 403, 'a streamer is not staff');
- const search = await call('GET', '/api/chat/search?q=bob&user_id=4', 7);
- assert.ok(search.json.messages.every((m) => m.user_id === 7), 'a non-staff search is limited to their own lines');
- const staffSearch = await call('GET', '/api/chat/search?q=bob&user_id=4', 6);
- assert.deepStrictEqual(staffSearch.json.messages.map((m) => m.message), ['bob replies']);
- });
-
- server.close();
- if (failures) { quiet(`\n${failures} check(s) failed`); process.exit(1); }
- quiet('\nAll chat-log view checks passed');
- process.exit(0);
-})().catch((e) => { quiet(e); process.exit(1); });
@@ -54,26 +54,10 @@ for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {
@@ -54,26 +54,10 @@ for (const [id, name] of [[1, 'veteran'], [2, 'newcomer']]) {
assert.strictEqual(g.total_level, 11 + 3 + 6, 'eight skills summed, like the old getPlayer()');
assert.strictEqual(g.total_level, 11 + 3 + 6, 'eight skills summed, like the old getPlayer()');
assert.strictEqual(g.total_coins_earned, 42);
assert.strictEqual(g.total_coins_earned, 42);
- // The chat profile card (Live's route and the one OpenVibe.Chat reads) never adds a row.
- const express = require('express');
- const app = express();
- app.use('/api/chat', require('../server/chat/routes'));
- const server = http.createServer(app).listen(0);
- const get = (p) => new Promise((resolve, reject) => {
- http.get({ port: server.address().port, path: p }, (res) => {
- let text = '';
- res.on('data', (c) => { text += c; });
- res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(text || 'null') }));
- }).on('error', reject);
- });
- let r = await get('/api/chat/user/newcomer/profile');
- assert.strictEqual(r.status, 200);
- assert.strictEqual(r.json.game, undefined, 'no legacy game block for someone who never played');
- assert.strictEqual(count(), 1, 'and viewing their profile created no game_players row');
- r = await get('/api/chat/user/veteran/profile');
- assert.strictEqual(r.json.game.total_level, 20, 'a legacy player still shows their skills');
- assert.strictEqual(count(), 1);
- server.close();
+ // The profile-card reader (what Live's removed route and OpenVibe.Chat both called) never adds a row.
+ assert.strictEqual(db.getLegacyGameProfile(2), null, 'no legacy game block for someone who never played');
+ assert.strictEqual(g.total_level, 20, 'a legacy player still shows their skills');
+ assert.strictEqual(count(), 1, 'reading a profile created no game_players row');
// Chat tags: read-only, and still shown.
// Chat tags: read-only, and still shown.
const tags = require('../server/chat/tags');
const tags = require('../server/chat/tags');
@@ -1,18 +1,15 @@
@@ -1,18 +1,15 @@
/**
/**
- * Legacy parity (roadmap D20): purging chat and deleting replays stays consistent.
+ * Legacy parity (roadmap D20): deleting a VOD or clip keeps Live's rows, its comment thread and its
+ * Search document consistent.
*
*
- * 1. Chat purge ↔ VOD chat replay. The dashboard's "Purge Range" (and its preview and log filter)
- * sends ISO instants ('2026-09-20T10:00:00.000Z'); chat_messages keeps SQLite's
- * '2026-09-20 10:00:00'. Compared as TEXT ('T' sorts after ' '), a range matched nothing on its
- * first day and ALL of its last day: the purge removed lines outside the chosen range, kept the ones
- * inside it, and VOD chat replay went on showing them. Live's local routes (CHAT_AUTHORITY=live, and
- * the mirror Live keeps under CHAT_AUTHORITY=chat) now read both forms as UTC, as OpenVibe.Chat does.
+ * Media deletes the item; Live used to only hide the comment thread, and kept its own rows: the AI
+ * state the backfill takes newest-first (a deleted VOD's row sat at the head of the overview queue
+ * for good) and the unique views. The admin storage page and the "older than" action also left the
+ * item's Search document until the daily refresh. Every delete path now goes through
+ * server/media-proxy/purge.js.
*
*
- * 2. Deleting a VOD or clip. Media deletes the item; Live used to only hide the comment thread, and
- * kept its own rows: the AI state the backfill takes newest-first (a deleted VOD's row sat at the
- * head of the overview queue for good) and the unique views. The admin storage page and the
- * "older than" action also left the item's Search document until the daily refresh. Every delete
- * path now goes through server/media-proxy/purge.js.
+ * (The chat purge ↔ VOD chat replay half moved with Live's /api/chat routes: OpenVibe.Chat serves
+ * them and covers that consistency in its own suite.)
*
*
* node test/replay-purge.test.js
* node test/replay-purge.test.js
*/
*/
@@ -57,29 +54,6 @@ const streamA = Number(db.createStream({ user_id: 3, channel_id: db.getChannelBy
@@ -57,29 +54,6 @@ const streamA = Number(db.createStream({ user_id: 3, channel_id: db.getChannelBy
raw.prepare("UPDATE streams SET started_at = '2026-09-20 09:00:00', ended_at = '2026-09-21 03:00:00', is_live = 0 WHERE id = ?").run(streamA);
raw.prepare("UPDATE streams SET started_at = '2026-09-20 09:00:00', ended_at = '2026-09-21 03:00:00', is_live = 0 WHERE id = ?").run(streamA);
Number(db.createStream({ user_id: 7, channel_id: db.getChannelByUserId(7).id, title: 'M', protocol: 'webrtc' }).lastInsertRowid);
Number(db.createStream({ user_id: 7, channel_id: db.getChannelByUserId(7).id, title: 'M', protocol: 'webrtc' }).lastInsertRowid);
-// Alice's chat during her stream, as Live stores it (UTC, SQLite form).
-const LINES = [
- ['2026-09-20 09:59:59', 'before'],
- ['2026-09-20 10:00:00', 'first in range'],
- ['2026-09-20 10:30:00', 'middle'],
- ['2026-09-20 11:00:00', 'last in range'],
- ['2026-09-20 11:00:01', 'just after'],
- ['2026-09-20 23:30:00', 'late that evening'],
- ['2026-09-21 01:00:00', 'after midnight'],
- ['2026-09-21 02:30:00', 'next morning'],
-];
-for (const [ts, text] of LINES) {
- raw.prepare(`INSERT INTO chat_messages (stream_id, channel_user_id, user_id, username, message, message_type, timestamp)
- VALUES (?, 3, 7, 'mallory', ?, 'chat', ?)`).run(streamA, text, ts);
-}
-
-// ── the chat server the purge route announces to ──
-const announced = [];
-require.cache[require.resolve('../server/chat/chat-server')] = {
- id: 'chat-server-stub', filename: 'chat-server-stub', loaded: true,
- exports: { broadcastToStream: (sid, p) => announced.push([sid, p]), broadcastGlobal: (p) => announced.push(['global', p]) },
-};
-
// ── Media, Community and Search stubs for the delete paths ──
// ── Media, Community and Search stubs for the delete paths ──
const media = require('../server/media-client');
const media = require('../server/media-client');
const VODS = {
const VODS = {
@@ -114,7 +88,6 @@ raw.prepare("INSERT INTO content_views (content_type, content_id, ip) VALUES ('c
@@ -114,7 +88,6 @@ raw.prepare("INSERT INTO content_views (content_type, content_id, ip) VALUES ('c
const express = require('express');
const express = require('express');
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
-app.use('/api/chat', require('../server/chat/routes'));
app.use('/api/vods', require('../server/media-proxy/vods'));
app.use('/api/vods', require('../server/media-proxy/vods'));
app.use('/api/clips', require('../server/media-proxy/clips'));
app.use('/api/clips', require('../server/media-proxy/clips'));
const server = http.createServer(app).listen(0);
const server = http.createServer(app).listen(0);
@@ -135,7 +108,6 @@ function call(method, p, user, body) {
@@ -135,7 +108,6 @@ function call(method, p, user, body) {
req.end();
req.end();
});
});
}
}
-const replayTexts = async () => (await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:00:00')}&to=${encodeURIComponent('2026-09-21 03:00:00')}`)).json.messages.map((m) => m.message);
const aiRow = (id) => raw.prepare('SELECT 1 FROM vod_ai_state WHERE vod_id = ?').get(id);
const aiRow = (id) => raw.prepare('SELECT 1 FROM vod_ai_state WHERE vod_id = ?').get(id);
const views = (type, id) => raw.prepare('SELECT COUNT(*) AS n FROM content_views WHERE content_type = ? AND content_id = ?').get(type, id).n;
const views = (type, id) => raw.prepare('SELECT COUNT(*) AS n FROM content_views WHERE content_type = ? AND content_id = ?').get(type, id).n;
@@ -146,53 +118,8 @@ async function check(name, fn) {
@@ -146,53 +118,8 @@ async function check(name, fn) {
(async () => {
(async () => {
await new Promise((r) => server.once('listening', r));
await new Promise((r) => server.once('listening', r));
- const all = LINES.map(([, t]) => t);
-
- await check('chat replay covers the whole stream before any purge', async () => {
- assert.deepStrictEqual(await replayTexts(), all);
- });
-
- // What the dashboard sends for 10:00–11:00 UTC (new Date(input).toISOString()).
- const range = { from: '2026-09-20T10:00:00.000Z', to: '2026-09-20T11:00:00.000Z' };
-
- await check('the log filter, the purge preview and the purge agree on an ISO range', async () => {
- const q = new URLSearchParams(range).toString();
- const logs = await call('GET', `/api/chat/admin/logs?${q}`, 3);
- assert.strictEqual(logs.status, 200);
- assert.deepStrictEqual(logs.json.rows.map((r) => r.message).sort(), ['first in range', 'last in range', 'middle']);
- const preview = await call('POST', '/api/chat/admin/purge/preview', 3, range);
- assert.deepStrictEqual(preview.json, { count: 3 }, 'the preview counts exactly the lines in the range');
- const purge = await call('DELETE', '/api/chat/admin/purge', 3, range);
- assert.deepStrictEqual(purge.json, { deleted: 3 }, 'and the purge removes exactly those');
- assert.deepStrictEqual(announced.pop(), [streamA, { type: 'purge', streamId: streamA, ...range, by: 'alice' }], 'live chat is told');
- });
-
- await check('VOD chat replay no longer shows the purged lines, and still shows every other one', async () => {
- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'late that evening', 'after midnight', 'next morning']);
- });
-
- await check('a range across midnight purges only its own hours, not the whole last day', async () => {
- const overnight = { from: '2026-09-20T23:00:00.000Z', to: '2026-09-21T01:30:00.000Z' };
- assert.deepStrictEqual((await call('POST', '/api/chat/admin/purge/preview', 3, overnight)).json, { count: 2 });
- assert.deepStrictEqual((await call('DELETE', '/api/chat/admin/purge', 3, overnight)).json, { deleted: 2 });
- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning'], '02:30 the next morning is kept');
- });
-
- await check('a clip\'s replay window (SQLite form) still works, and staff see purged lines only on request', async () => {
- const r = await call('GET', `/api/chat/${streamA}/replay?from=${encodeURIComponent('2026-09-20 09:59:00')}&to=${encodeURIComponent('2026-09-20 11:00:05')}`);
- assert.deepStrictEqual(r.json.messages.map((m) => m.message), ['before', 'just after']);
- const staff = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 1);
- assert.strictEqual(staff.json.total, LINES.length, 'the purge is a soft delete an admin can still audit');
- const owner = await call('GET', `/api/chat/admin/logs?streamId=${streamA}&includeDeleted=true&limit=50`, 3);
- assert.strictEqual(owner.json.total, 3, 'the streamer sees only what is left');
- });
-
- await check('nobody purges someone else\'s stream', async () => {
- const r = await call('DELETE', '/api/chat/admin/purge', 7, { streamId: streamA, from: '2026-09-20T00:00:00Z', to: '2026-09-22T00:00:00Z' });
- assert.strictEqual(r.status, 403);
- assert.deepStrictEqual(await replayTexts(), ['before', 'just after', 'next morning']);
- });
+ // Deleting replays: Live's rows about each item, its comment thread and its Search document.
const before = db.getVodsNeedingOverview(6).map((r) => r.id);
const before = db.getVodsNeedingOverview(6).map((r) => r.id);
await check('deleting a VOD drops Live\'s rows about it, hides its thread and re-reads its Search document', async () => {
await check('deleting a VOD drops Live\'s rows about it, hides its thread and re-reads its Search document', async () => {
assert.ok(before.includes(100), 'the VOD was queued for an AI overview');
assert.ok(before.includes(100), 'the VOD was queued for an AI overview');
deleted file mode 100644
deleted file mode 100644
@@ -1,172 +0,0 @@
@@ -1,172 +0,0 @@
-'use strict';
-/**
- * Direct messages stay between their participants on every HTTP path (roadmap WS-R task 5, the
- * private-room class). dm-delivery.test.js pins live delivery (dm.isParticipant before a socket
- * gets a message) and authorization.test.js that a 1:1 cannot grow a third person; this suite
- * walks every /api/dm route as someone outside the conversation, as a participant who was removed
- * from a group, and anonymously:
- *
- * read the conversation, its messages (every cursor form), the conversation list and the unread
- * count; post into it, mark it read, rename it, add themselves or someone else, remove a
- * participant, delete a participant's message.
- *
- * Each refusal leaves the conversation as it was and returns none of its text. (Private VODs and
- * clips are media-privacy.test.js's; with Chat serving /api/dm, OpenVibe.Chat has the same suite.)
- *
- * node test/security-private.test.js
- */
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const http = require('http');
-
-const tmp = path.join(os.tmpdir(), `ov-private-${process.pid}.db`);
-process.env.DB_PATH = tmp;
-process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/
-process.env.NODE_ENV = 'test';
-const quiet = console.log;
-console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
-console.warn = () => {};
-console.error = () => {};
-
-const db = require('../server/db/database');
-db.initDb();
-const raw = db.getDb();
-const dm = require('../server/chat/dm');
-dm.ensureTables();
-
-const auth = require('../server/auth/auth');
-const signIn = (req) => {
- const id = Number(req.headers['x-test-user'] || 0);
- const u = id ? db.getUserById(id) : null;
- if (u) { req.user = u; req.authSource = 'network'; }
- return u;
-};
-auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
-auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
-
-const addUser = (id, username, role = 'user') => raw.prepare(
- `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
- VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@example.test`, role);
-const ANN = 11, BEN = 12, CAT = 13, EVE = 14, MOD = 15;
-addUser(ANN, 'ann'); addUser(BEN, 'ben'); addUser(CAT, 'cat'); addUser(EVE, 'eve'); addUser(MOD, 'moddy', 'global_mod');
-
-// A 1:1 between Ann and Ben, and a group Ann made with Ben and Cat (Cat is removed later).
-const directId = Number(dm.getOrCreateDirect(ANN, BEN));
-const groupId = Number(dm.createConversation(ANN, [ANN, BEN, CAT], 'Plans'));
-const say = (conv, who, text) => Number(dm.sendMessage(conv, who, text).id);
-const msgDirect = say(directId, BEN, 'the secret direct message');
-const msgGroupOld = say(groupId, BEN, 'the secret group message');
-
-const express = require('express');
-const app = express();
-app.use(express.json());
-app.use('/api/dm', require('../server/chat/dm-routes'));
-const server = http.createServer(app).listen(0, '127.0.0.1');
-function call(method, p, user, body) {
- return new Promise((resolve, reject) => {
- const data = body ? JSON.stringify(body) : null;
- const headers = { 'content-type': 'application/json' };
- if (user) headers['x-test-user'] = String(user);
- const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers, agent: false }, (res) => {
- let text = '';
- res.on('data', (c) => { text += c; });
- res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
- });
- req.on('error', reject);
- if (data) req.write(data);
- req.end();
- });
-}
-const SECRETS = ['secret direct', 'secret group', 'Plans'];
-const snapshot = () => ({
- conversations: raw.prepare('SELECT * FROM dm_conversations ORDER BY id').all(),
- participants: raw.prepare('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id').all(),
- messages: raw.prepare('SELECT id, conversation_id, sender_id, message FROM dm_messages ORDER BY id').all().map((m) => ({ ...m })),
-});
-
-let failures = 0;
-async function check(name, fn) {
- try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', String(e.stack || e.message).split('\n').slice(0, 6).join('\n ')); }
-}
-
-/** Every DM path against conversation `id`, as `user`: all must refuse and carry nothing. */
-async function outsider(user, id, label) {
- const before = snapshot();
- const reads = [
- ['GET', `/api/dm/conversations/${id}`], ['GET', `/api/dm/conversations/${id}/messages`],
- ['GET', `/api/dm/conversations/${id}/messages?before=999999`], ['GET', `/api/dm/conversations/${id}/messages?after=0`],
- ['GET', `/api/dm/conversations/${id}/messages?limit=500`],
- ];
- const writes = [
- ['POST', `/api/dm/conversations/${id}/messages`, { message: 'hi', text: 'hi' }],
- ['POST', `/api/dm/conversations/${id}/read`, {}],
- ['PATCH', `/api/dm/conversations/${id}`, { name: 'pwned' }],
- ['POST', `/api/dm/conversations/${id}/participants`, { user_id: user || EVE }],
- ['POST', `/api/dm/conversations/${id}/participants`, { user_id: MOD }],
- ['DELETE', `/api/dm/conversations/${id}/participants/${BEN}`],
- ['DELETE', `/api/dm/conversations/${id}/messages/${msgDirect}`],
- ['DELETE', `/api/dm/conversations/${id}/messages/${msgGroupOld}`],
- ];
- for (const [m, p, body] of [...reads, ...writes]) {
- const r = await call(m, p, user, body);
- assert.ok([401, 403, 404].includes(r.status), `${label}: ${m} ${p} → ${r.status} ${r.text.slice(0, 160)}`);
- for (const s of SECRETS) assert.ok(!r.text.includes(s), `${label}: ${m} ${p} carries "${s}"`);
- }
- assert.deepStrictEqual(snapshot(), before, `${label}: nothing changed`);
-}
-
-(async () => {
- await new Promise((r) => server.once('listening', r));
- quiet('private: direct messages');
-
- await check('control: participants read their conversations (the routes work in this harness)', async () => {
- const r = await call('GET', `/api/dm/conversations/${directId}/messages`, ANN);
- assert.strictEqual(r.status, 200, r.text.slice(0, 200));
- assert.ok(r.text.includes('the secret direct message'));
- const g = await call('GET', `/api/dm/conversations/${groupId}/messages`, CAT);
- assert.strictEqual(g.status, 200);
- assert.ok(g.text.includes('the secret group message'));
- });
- await check('an outsider gets nothing from a 1:1 on any path, and changes nothing', () => outsider(EVE, directId, 'outsider/direct'));
- await check('an outsider gets nothing from a group on any path, and changes nothing', () => outsider(EVE, groupId, 'outsider/group'));
- await check('a global mod is an outsider to other people\'s DMs too', () => outsider(MOD, directId, 'mod/direct'));
- await check('anonymous gets 401 everywhere', () => outsider(null, directId, 'anonymous'));
- await check('the outsider\'s own lists hold none of it (conversations, unread, search)', async () => {
- const list = await call('GET', '/api/dm/conversations', EVE);
- assert.strictEqual(list.status, 200);
- for (const s of SECRETS) assert.ok(!list.text.includes(s), list.text.slice(0, 200));
- const unread = await call('GET', '/api/dm/unread', EVE);
- assert.strictEqual(unread.status, 200);
- assert.ok(!/[1-9]/.test(JSON.stringify(unread.json)), `unread: ${unread.text}`);
- const search = await call('GET', '/api/dm/users/search?q=ann', EVE);
- assert.ok(!search.text.includes('@example.test'), 'no email in user search');
- });
- await check('a participant removed from a group loses every path from then on', async () => {
- const r = await call('DELETE', `/api/dm/conversations/${groupId}/participants/${CAT}`, ANN);
- assert.strictEqual(r.status, 200, r.text.slice(0, 200));
- say(groupId, BEN, 'the secret group message after cat left');
- await outsider(CAT, groupId, 'removed/group');
- const list = await call('GET', '/api/dm/conversations', CAT);
- assert.ok(!list.text.includes('Plans'), 'the group left her list');
- });
- await check('a participant of one conversation cannot reach another through it (ids swapped)', async () => {
- // Ann is in both; Eve starts her own 1:1 with Ann and tries Ann's other conversations' message ids.
- const mineId = Number(dm.getOrCreateDirect(EVE, ANN));
- const before = snapshot();
- const del = await call('DELETE', `/api/dm/conversations/${mineId}/messages/${msgDirect}`, EVE);
- assert.ok([403, 404].includes(del.status), `delete through her own conversation: ${del.status}`);
- const page = await call('GET', `/api/dm/conversations/${mineId}/messages?before=${msgDirect + 1}`, EVE);
- assert.strictEqual(page.status, 200);
- for (const s of SECRETS) assert.ok(!page.text.includes(s), `her own conversation's page carries "${s}"`);
- assert.deepStrictEqual(snapshot(), before);
- });
-
- server.close();
- for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
- try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }
- if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
- quiet('\nsecurity-private: all checks passed');
- process.exit(0);
-})().catch((e) => { quiet(e); process.exit(1); });
@@ -364,7 +364,6 @@ async function check(name, fn) {
@@ -364,7 +364,6 @@ async function check(name, fn) {
'server/openre/openre-client.js': 'OpenRe.Stream (configured)',
'server/openre/openre-client.js': 'OpenRe.Stream (configured)',
'server/ai/ai-service.js': 'OpenVibe.AI (configured)',
'server/ai/ai-service.js': 'OpenVibe.AI (configured)',
'server/ai/llm.js': 'images are Live\'s own frames; a streamer\'s typed provider address (testProvider) goes through egress.postJson',
'server/ai/llm.js': 'images are Live\'s own frames; a streamer\'s typed provider address (testProvider) goes through egress.postJson',
- 'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/meta/routes.js': 'GitHub API (fixed host)',
'server/meta/routes.js': 'GitHub API (fixed host)',