openvibers/openvibe.chat Commits
Chat rooms (WS-I task 4, first release): anyone signed in starts a room (public or private, up to 10 each); members, mods and owner roles, invitations, blocking, slow mode, unread counts; history and posting over /api/chat/rooms and live over /ws/chat (join_room, room_message), delivered only to the room's sockets; site bans and chat staff apply everywhere and mod actions are logged to the moderation audit. openvibe.chat gets Rooms: the list, each room (readable without JavaScript, join/post/delete by form) and owner/mod settings; public rooms are in the sitemap. tests: rooms.test.js, web.test.js
Changes
@@@ -71,3 +71,18 @@ button[disabled] { opacity: .6; cursor: default; }.oc-feed { max-height: 60vh; }.oc-account { margin-left: 0; }}++/* Rooms */++.oc-msg { position: relative; }++.oc-del { display: inline; margin: 0 0 0 6px; }++.oc-del button { background: transparent; color: var(--oc-muted); padding: 0 6px; font-weight: 700; border-radius: 6px; visibility: hidden; }++.oc-msg:hover .oc-del button, .oc-del button:focus-visible { visibility: visible; }++.oc-del button:hover { color: var(--oc-red); background: var(--oc-panel-2); }++@media (hover: none) { .oc-del button { visibility: visible; } }++.oc-inline { display: inline; margin: 0; }++.oc-link { background: none; color: var(--oc-accent); padding: 0; font-weight: 600; text-decoration: underline; }++.oc-create { margin-top: 24px; padding: 14px 16px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; }++.oc-create h2 { margin: 0 0 4px; font-size: 1.1rem; }++.oc-members { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.oc-members li { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 12px; }++.oc-members form { margin-left: auto; }++h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }@@@ -161,10 +161,82 @@}}++ // ── A room ──++ function roomItem(m) {++ var li = el('li', 'oc-msg'); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';++ li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));++ var who = el('a', 'oc-name', m.display_name || m.username || 'someone');++ who.href = LIVE + '/@' + encodeURIComponent(m.username || '');++ if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.color = m.profile_color;++ li.appendChild(who);++ var b = badge(m.user_role); if (b) li.appendChild(b);++ li.appendChild(document.createTextNode(' '));++ var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);++ if (cfg.moderate || (cfg.me && m.user_id === cfg.me)) {++ var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;++ var x = el('button', null, '×'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message');++ f.appendChild(x); li.appendChild(f);++ }++ return li;++ }++ function startRoom() {++ var live = document.getElementById('oc-live');++ var ws = null, delay = 1000, opened = false;++ var api = '/api/chat/rooms/' + encodeURIComponent(cfg.room);++ function catchUpRoom() {++ return fetch(api + '/messages?after=' + latest + '&limit=200', { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) { if (d && d.messages) d.messages.forEach(function (m) { latest = Math.max(latest, Number(m.id) || 0); append(roomItem(m), m.id); }); })++ .catch(function () {});++ }++ function connect() {++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');++ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: cfg.room })); if (opened) catchUpRoom(); opened = true; };++ ws.onmessage = function (ev) {++ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }++ if (m.type === 'room_joined') { if (live) live.hidden = false; }++ else if (m.type === 'room_message' && m.room === cfg.room && m.message) {++ latest = Math.max(latest, Number(m.message.id) || 0);++ append(roomItem(m.message), m.message.id);++ if (!document.hidden && cfg.me) fetch(api + '/read', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ last_id: latest }) }).catch(function () {});++ } else if (m.type === 'room_message_deleted' && m.room === cfg.room) {++ var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();++ } else if (m.type === 'room_error' && m.message) {++ showError(m.message);++ } else if (m.type === 'room_left') {++ showError('You can no longer read this room.'); setTimeout(function () { location.href = '/rooms'; }, 1500);++ } else if (m.type === 'auth_revoked') {++ showError('You were signed out. Sign in again to keep chatting.');++ }++ };++ ws.onclose = function () { if (live) live.hidden = true; setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };++ }++ connect();++ if (form && input) {++ form.addEventListener('submit', function (e) {++ var text = input.value.trim();++ if (!text) { e.preventDefault(); return; }++ if (!ws || ws.readyState !== 1) return;++ e.preventDefault();++ ws.send(JSON.stringify({ type: 'room_message', message: text }));++ input.value = ''; input.focus();++ });++ }++ if (feed) feed.addEventListener('submit', function (e) {++ var f = e.target; if (!f.classList || !f.classList.contains('oc-del')) return;++ e.preventDefault();++ var li = f.closest('.oc-msg');++ fetch(api + '/messages/' + encodeURIComponent(li.dataset.id), { method: 'DELETE', credentials: 'same-origin' })++ .then(function (r) { if (r.ok) li.remove(); else return r.json().then(function (d) { showError((d && d.error) || 'Could not delete'); }); })++ .catch(function () { f.submit(); });++ });++ }++if (input) input.addEventListener('keydown', function (e) {if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }});toBottom();if (cfg.view === 'global') startGlobal();else if (cfg.view === 'dm') startDm();++ else if (cfg.view === 'room') startRoom();})();@@@ -202,6 +202,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {// The person's chat preferences: Network user module chat.preferences (server/prefs/).app.use('/api/chat/preferences', require('./prefs/routes'));++ // Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…++ app.use('/api/chat/rooms', require('./rooms/routes'));app.use('/api/chat', require('./chat/routes'));app.use('/api/dm', require('./chat/dm-routes'));app.use('/api/tts', require('./chat/tts-routes'));@@@ -490,6 +490,33 @@ class ChatServer {case 'leave_stream':client.streamId = null;break;++ // Chat rooms (server/rooms/, openvibe.chat): a socket follows one room at a time.++ case 'join_room': {++ const rooms = require('../rooms/rooms');++ const room = rooms.bySlug(msg.room);++ const a = room ? rooms.access(room, client.user) : null;++ if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }++ client.roomId = room.id;++ client.roomSlug = room.slug;++ this.sendTo(ws, { type: 'room_joined', room: room.slug, role: a.role, can: { post: a.post, moderate: a.moderate } });++ break;++ }++ case 'leave_room':++ client.roomId = null;++ client.roomSlug = null;++ break;++ case 'room_message': {++ const rooms = require('../rooms/rooms');++ const room = client.roomId ? rooms.bySlug(client.roomSlug) : null;++ if (!room || room.id !== client.roomId) { this.sendTo(ws, { type: 'room_error', code: 'rooms.not_joined', message: 'Open a room first' }); break; }++ try {++ const message = rooms.post(room, client.user, msg.message);++ this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });++ } catch (err) {++ this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });++ }++ break;++ }case 'get-users':this.sendTo(ws, { type: 'users-list', users: this.getUserList(client.streamId) });break;@@@ -2264,12 +2291,32 @@ class ChatServer {for (const [ws, client] of this.clients) {// Pure global clients only (see forwardToGlobal) — channel/stream viewers get// global activity via their dedicated cross-feed socket, not their main one.-- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {++ if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {ws.send(msg);}}}++ /** Everyone following a chat room (server/rooms/) on this server. */++ broadcastToRoom(roomId, data) {++ const msg = JSON.stringify(data);++ for (const [ws, client] of this.clients) {++ if (client.roomId === roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) ws.send(msg);++ }++ }++++ /** A person may no longer read a room (blocked, removed from a private room, left): stop their feed. */++ removeFromRoom(roomId, userId) {++ let n = 0;++ for (const [ws, client] of this.clients) {++ if (client.roomId === roomId && client.user && client.user.id === userId) {++ client.roomId = null; client.roomSlug = null; n++;++ this.sendTo(ws, { type: 'room_left', reason: 'removed' });++ }++ }++ return n;++ }++/*** Notify everyone watching any of a channel owner's live streams that the* channel's emotes/sounds changed, so their chat pickers refresh livennew file mode 100644@@@ -0,0 +1,270 @@++'use strict';++/**++ * Chat rooms (roadmap WS-I task 4, first release): rooms anyone can create on openvibe.chat, beside the++ * global room and the stream/channel rooms Live's chat already has.++ *++ * rooms slug (URL id), name, topic, kind (community | system | call), visibility++ * (public: anyone reads, signed-in people join; private: members only), owner, slow mode++ * room_members role: owner | mod | member | blocked; last_read_id for unread counts++ * room_messages the messages (soft-deleted by their author, a room mod or chat staff)++ *++ * Moderation: the owner and mods delete messages and block members; site bans and chat staff++ * (staff.moderation.chat) apply in every room. Every staff/mod action is logged through++ * db.logModerationAction (scope room:<slug>), which also reports it to Network's audit log.++ * Nothing here talks to Live except the ban check (ctx.isUserBanned), so rooms keep working with++ * Live down. chat-server.js delivers messages live to sockets that joined the room (join_room).++ */++const db = require('../db/database');++const ctx = require('../live-context');++const wordFilter = require('../chat/word-filter');++const permissions = require('../auth/permissions');++++const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;++const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system']);++const ROLES = ['owner', 'mod', 'member', 'blocked'];++const KINDS = ['community', 'system', 'call'];++const MAX_TEXT = 2000;++const MAX_ROOMS_PER_OWNER = 10;++const RATE = { max: 6, windowMs: 10_000 };++++class RoomError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++const fail = (status, code, message) => { throw new RoomError(status, code, message); };++++let ready = false;++function ensureSchema() {++ if (ready) return;++ db.getDb().exec(`++ CREATE TABLE IF NOT EXISTS rooms (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ slug TEXT UNIQUE NOT NULL,++ name TEXT NOT NULL,++ topic TEXT,++ kind TEXT NOT NULL DEFAULT 'community',++ visibility TEXT NOT NULL DEFAULT 'public',++ owner_id INTEGER NOT NULL,++ owner_subject TEXT,++ slow_seconds INTEGER NOT NULL DEFAULT 0,++ message_count INTEGER NOT NULL DEFAULT 0,++ last_message_at DATETIME,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ archived_at DATETIME++ );++ CREATE TABLE IF NOT EXISTS room_members (++ room_id INTEGER NOT NULL,++ user_id INTEGER NOT NULL,++ role TEXT NOT NULL DEFAULT 'member',++ last_read_id INTEGER NOT NULL DEFAULT 0,++ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ PRIMARY KEY (room_id, user_id)++ );++ CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);++ CREATE TABLE IF NOT EXISTS room_messages (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ room_id INTEGER NOT NULL,++ user_id INTEGER NOT NULL,++ subject_id TEXT,++ message TEXT NOT NULL,++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,++ is_deleted INTEGER NOT NULL DEFAULT 0,++ deleted_by INTEGER++ );++ CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_id, id);++ `);++ ready = true;++}++++const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');++const roleOf = (roomId, userId) => (userId ? (db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);++const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());++++function slugify(name) {++ return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');++}++++function publicRoom(r, extra = {}) {++ if (!r) return null;++ return {++ id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: r.kind, visibility: r.visibility,++ slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,++ created_at: r.created_at, members: (db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,++ ...extra,++ };++}++++function bySlug(slug) {++ ensureSchema();++ return db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;++}++++/** What `user` may do in `room`: { read, post, moderate, role }. */++function access(room, user) {++ const role = user ? roleOf(room.id, user.id) : null;++ const staff = isStaff(user);++ const blocked = role === 'blocked';++ const member = !!role && !blocked;++ const read = !blocked && (room.visibility === 'public' || member || staff);++ return { role, read, post: !!user && member && !siteBanned(user), moderate: !!user && (role === 'owner' || role === 'mod' || staff), manage: !!user && (role === 'owner' || staff) };++}++++function create(user, { name, slug, topic, visibility = 'public', kind = 'community' } = {}) {++ ensureSchema();++ if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');++ if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');++ const n = String(name || '').replace(/\s+/g, ' ').trim();++ if (n.length < 3 || n.length > 40) fail(422, 'rooms.name', 'Room names are 3 to 40 characters');++ const s = slug ? String(slug).toLowerCase().trim() : slugify(n);++ if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'rooms.slug', 'Room addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');++ if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private');++ if (!KINDS.includes(kind) || (kind !== 'community' && !isStaff(user))) fail(422, 'rooms.kind', 'Only chat staff create system and call rooms');++ if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');++ const owned = db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id]).n;++ if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);++ if (bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');++ const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;++ const id = db.transaction(() => {++ const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?)', [s, n, t, kind, visibility, user.id, user.subject_id || null]);++ db.run("INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')", [r.lastInsertRowid, user.id]);++ return Number(r.lastInsertRowid);++ });++ return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });++}++++/** Public rooms by recent activity, plus the rooms `user` belongs to with unread counts. */++function list(user, { limit = 50 } = {}) {++ ensureSchema();++ const lim = Math.min(100, Math.max(1, Number(limit) || 50));++ const pub = db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim]).map((r) => publicRoom(r));++ const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id,++ (SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unread++ FROM room_members m JOIN rooms r ON r.id = m.room_id++ WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL++ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread })) : [];++ return { public: pub, mine };++}++++function join(room, user) {++ if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');++ const role = roleOf(room.id, user.id);++ if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');++ if (role) return role;++ if (room.visibility !== 'public' && !isStaff(user)) fail(403, 'rooms.private', 'This room is invite-only');++ const last = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;++ db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id) VALUES (?, ?, 'member', ?)", [room.id, user.id, last]);++ return 'member';++}++++function leave(room, user) {++ const role = roleOf(room.id, user && user.id);++ if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');++ if (role && role !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);++}++++const _rate = new Map();++function rateOk(key, now = Date.now()) {++ const arr = (_rate.get(key) || []).filter((t) => now - t < RATE.windowMs);++ if (arr.length >= RATE.max) { _rate.set(key, arr); return false; }++ arr.push(now); _rate.set(key, arr);++ if (_rate.size > 20000) _rate.clear();++ return true;++}++++/** Post a message; returns the stored row (with the author's names) for broadcasting. */++function post(room, user, text, { now = Date.now() } = {}) {++ const a = access(room, user);++ if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');++ if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');++ if (!a.post) fail(403, room.visibility === 'public' ? 'rooms.join_first' : 'rooms.private', room.visibility === 'public' ? 'Join the room to post' : 'This room is invite-only');++ const body = String(text || '').replace(/\r\n/g, '\n').trim();++ if (!body) fail(422, 'rooms.empty', 'Write something first');++ if (body.length > MAX_TEXT) fail(422, 'rooms.too_long', `Messages are at most ${MAX_TEXT} characters`);++ if (wordFilter.isSpam(body)) fail(422, 'rooms.spam', 'That looks like spam');++ if (!rateOk(`${room.id}:${user.id}`, now)) fail(429, 'rooms.slow_down', 'Slow down a little');++ if (room.slow_seconds > 0 && !a.moderate) {++ const last = db.get('SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1', [room.id, user.id]);++ if (last && now - Date.parse(`${String(last.created_at).replace(' ', 'T')}Z`) < room.slow_seconds * 1000) fail(429, 'rooms.slow_mode', `Slow mode: one message every ${room.slow_seconds} seconds`);++ }++ const filtered = wordFilter.check(body);++ const message = filtered.safe ? body : filtered.filtered;++ const id = db.transaction(() => {++ const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);++ db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);++ db.run('UPDATE room_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);++ return Number(r.lastInsertRowid);++ });++ return messageById(id);++}++++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, u.username, u.display_name, u.profile_color, u.role AS user_role++ FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id`;++function messageById(id) { return db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null; }++++/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */++function history(room, { before = null, after = null, limit = 60 } = {}) {++ const lim = Math.min(200, Math.max(1, Number(limit) || 60));++ if (after != null) return db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?`, [room.id, Number(after) || 0, lim]);++ const rows = before != null++ ? db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])++ : db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);++ return rows.reverse();++}++++function markRead(room, user, lastId) {++ if (!user) return;++ const top = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;++ const to = Math.min(top, Number(lastId) || top);++ db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);++}++++function deleteMessage(room, user, messageId) {++ const m = db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);++ if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');++ const own = user && m.user_id === user.id;++ const a = access(room, user);++ if (!own && !a.moderate) fail(403, 'rooms.not_yours', 'Only the author or a room moderator can delete this');++ db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);++ if (!own) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });++ return m.id;++}++++/** Owner/mod actions on a member: invite (by user id), set a role (mod/member), block or unblock. */++function setRole(room, actor, targetUserId, role) {++ if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');++ const a = access(room, actor);++ const current = roleOf(room.id, targetUserId);++ if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');++ if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');++ if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');++ if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');++ if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');++ const target = ctx.getUserById(targetUserId);++ if (!target) fail(404, 'rooms.no_user', 'No such person');++ if (role === 'none') { if (current && current !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }++ else if (current) db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);++ else db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);++ if (role === 'blocked' || current === 'blocked' || role === 'mod' || current === 'mod') {++ db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: role === 'blocked' ? 'room_block' : current === 'blocked' ? 'room_unblock' : role === 'mod' ? 'room_mod_add' : 'room_mod_remove', details: {} });++ }++ return role;++}++++function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {++ if (!access(room, actor).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');++ const next = { ...room };++ if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }++ if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;++ if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private'); next.visibility = visibility; }++ if (slow_seconds != null) { const v = Number(slow_seconds); if (!Number.isInteger(v) || v < 0 || v > 600) fail(422, 'rooms.slow', 'Slow mode is 0 to 600 seconds'); next.slow_seconds = v; }++ db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, room.id]);++ return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));++}++++function members(room) {++ return db.all(`SELECT m.user_id, m.role, u.username, u.display_name FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id++ WHERE m.room_id = ? ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'mod' THEN 1 WHEN 'member' THEN 2 ELSE 3 END, u.username COLLATE NOCASE LIMIT 500`, [room.id]);++}++++function _reset() { ready = false; _rate.clear(); }++++module.exports = { ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById, RoomError, SLUG_RE, _reset };nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++/**++ * /api/chat/rooms — chat rooms over REST (server/rooms/rooms.js); openvibe.chat and bots use it, and++ * chat-server.js carries the same rooms live (join_room / room_message).++ *++ * GET / public rooms by activity + the caller's rooms (with unread counts)++ * POST / create { name, slug?, topic?, visibility? }++ * GET /:slug the room and what the caller may do (404 when they may not read it)++ * PATCH /:slug owner: { name?, topic?, visibility?, slow_seconds? }++ * GET /:slug/messages ?before=<id> | ?after=<id>, &limit++ * POST /:slug/messages { message }++ * DELETE /:slug/messages/:id the author, a room moderator or chat staff++ * POST /:slug/join | /leave | /read { last_id? }++ * GET /:slug/members++ * POST /:slug/members owner/mods: { username, role: member | mod | blocked | none }++ */++const express = require('express');++const { requireAuth, optionalAuth } = require('../auth/auth');++const rooms = require('./rooms');++const ctx = require('../live-context');++++const router = express.Router();++++function send(res, err) {++ if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });++ console.error('[Rooms]', err && err.message);++ return res.status(500).json({ error: 'Something went wrong' });++}++const chatServer = () => require('../chat/chat-server');++++/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */++function readable(req, res) {++ const room = rooms.bySlug(req.params.slug);++ if (!room || !rooms.access(room, req.user || null).read) { res.status(404).json({ error: 'No such room', code: 'rooms.not_found' }); return null; }++ return room;++}++++router.get('/', optionalAuth, (req, res) => {++ try { res.set('Cache-Control', 'private, no-store').json(rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }++});++++router.post('/', requireAuth, (req, res) => {++ try { res.status(201).json({ room: rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }++});++++router.get('/:slug', optionalAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ const a = rooms.access(room, req.user || null);++ res.set('Cache-Control', 'private, no-store').json({ room: rooms.publicRoom(room, { role: a.role }), can: { post: a.post, moderate: a.moderate, manage: a.manage } });++});++++router.patch('/:slug', requireAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ try { res.json({ room: rooms.update(room, req.user, req.body || {}) }); } catch (err) { send(res, err); }++});++++router.get('/:slug/messages', optionalAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ try {++ const messages = rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });++ res.set('Cache-Control', 'private, no-store').json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });++ } catch (err) { send(res, err); }++});++++router.post('/:slug/messages', requireAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ try {++ const message = rooms.post(room, req.user, req.body && req.body.message);++ chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });++ res.status(201).json({ message });++ } catch (err) { send(res, err); }++});++++router.delete('/:slug/messages/:id', requireAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ try {++ const id = rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));++ chatServer().broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });++ res.json({ ok: true, id });++ } catch (err) { send(res, err); }++});++++router.post('/:slug/join', requireAuth, (req, res) => {++ const room = rooms.bySlug(req.params.slug);++ if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });++ try { res.json({ ok: true, role: rooms.join(room, req.user) }); } catch (err) { send(res, err); }++});++++router.post('/:slug/leave', requireAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ try { rooms.leave(room, req.user); chatServer().removeFromRoom(room.id, req.user.id); res.json({ ok: true }); } catch (err) { send(res, err); }++});++++router.post('/:slug/read', requireAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ try { rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }++});++++router.get('/:slug/members', optionalAuth, (req, res) => {++ const room = readable(req, res); if (!room) return;++ res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room).filter((m) => m.role !== 'blocked' || rooms.access(room, req.user || null).moderate) });++});++++router.post('/:slug/members', requireAuth, async (req, res) => {++ const room = readable(req, res); if (!room) return;++ try {++ const name = String((req.body && req.body.username) || '').trim();++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;++ if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);++ if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });++ const role = rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));++ if (role === 'blocked' || role === 'none' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);++ res.json({ ok: true, user_id: target.id, username: target.username, role });++ } catch (err) { send(res, err); }++});++++module.exports = router;@@@ -18,6 +18,7 @@ const DEFAULT_DESCRIPTION = 'OpenVibe.Chat: the network-wide chat room and yourconst PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');const LINKS = [{ key: 'global', label: 'Global chat', href: '/' },++ { key: 'rooms', label: 'Rooms', href: '/rooms' },{ key: 'messages', label: 'Messages', href: '/messages' },{ key: 'settings', label: 'Settings', href: '/settings' },];@@@ -267,15 +267,181 @@ ${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }});++ // ── Rooms (server/rooms/) ──++ const rooms = require('../rooms/rooms');++ const roomsOrError = (fn) => async (req, res, next) => {++ try { return await fn(req, res, next); } catch (err) {++ if (err instanceof rooms.RoomError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);++ return next(err);++ }++ };++ const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;++ const roomMessage = (m, can, slug, me) => {++ const t = hhmm(m.created_at);++ const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';++ return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="color:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;++ };++++ router.get('/rooms', async (req, res) => {++ const actor = await viewerOf(req);++ const me = actor.kind === 'user' ? actor.user : null;++ const { public: pub, mine } = rooms.list(me);++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">++<h2>Start a room</h2>++<label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls"></label>++<label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>++<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people you invite can read and post</label>++<p><button type="submit">Create room</button></p>++</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Frooms">Sign in with OpenVibe</a> to start or join a room.</p>`;++ await page(req, res, 200, {++ actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',++ description: 'Chat rooms on OpenVibe: start one about anything, invite people, or join a public room.',++ body: `<h1>Rooms</h1>${error}++${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).join('')}</ul>` : ''}++<h2>Public rooms</h2><ul class="oc-convs">${pub.map(roomCard).join('') || '<li class="oc-muted">No rooms yet. Start the first one.</li>'}</ul>++${create}`,++ });++ });++++ router.post('/rooms/new', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const b = req.body || {};++ const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public' });++ res.redirect(303, `/r/${room.slug}`);++ }));++++ const loadRoom = async (req, res) => {++ const actor = await viewerOf(req);++ const me = actor.kind === 'user' ? actor.user : null;++ const room = rooms.bySlug(req.params.slug);++ const can = room ? rooms.access(room, me) : null;++ if (!room || !can.read) { await page(req, res, 404, { actor, title: 'No such room', path: req.path, robots: 'noindex', body: '<h1>No such room</h1><p><a href="/rooms">All rooms</a></p>' }); return null; }++ req.roomBack = `/r/${room.slug}`;++ return { actor, me, room, can };++ };++++ router.get('/r/:slug', async (req, res) => {++ const x = await loadRoom(req, res); if (!x) return;++ const { actor, me, room, can } = x;++ const msgs = rooms.history(room, { limit: 60 });++ if (me && can.role) rooms.markRead(room, me);++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ let composer;++ if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;++ else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;++ else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">Join this room</button></form>`;++ else composer = notice('error', 'You cannot post here right now.');++ const tools = me && can.role && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/leave"><button type="submit" class="oc-link">Leave room</button></form>` : '';++ const manage = can.moderate ? ` · <a href="/r/${esc(room.slug)}/settings">${can.manage ? 'Room settings' : 'Members'}</a>` : '';++ await page(req, res, 200, {++ actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true,++ description: room.topic || `${room.name}, a chat room on OpenVibe.`,++ page: { view: 'room', room: room.slug, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate },++ body: `<p><a href="/rooms">← Rooms</a></p>++<h1>${esc(room.name)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>++<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>++${error}++<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>++${composer}${tools}`,++ });++ });++++ router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}`;++ const message = rooms.post(room, actor.user, req.body && req.body.message);++ require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });++ res.redirect(303, `/r/${room.slug}#oc-compose`);++ }));++++ router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}`;++ rooms.join(room, actor.user);++ res.redirect(303, `/r/${room.slug}`);++ }));++++ router.post('/r/:slug/leave', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}`;++ rooms.leave(room, actor.user);++ require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);++ res.redirect(303, '/rooms');++ }));++++ router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}`;++ const id = rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));++ require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });++ res.redirect(303, `/r/${room.slug}`);++ }));++++ router.get('/r/:slug/settings', async (req, res) => {++ const x = await loadRoom(req, res); if (!x) return;++ const { actor, room, can } = x;++ if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);++ const saved = req.query.saved ? notice('ok', 'Saved.') : '';++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ const members = rooms.members(room);++ const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>' : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${[...(can.manage ? ['mod'] : []), 'member', 'blocked', 'none'].map((r) => `<option value="${r}"${r === m.role ? ' selected' : ''}>${r === 'none' ? 'remove' : r}</option>`).join('')}</select> <button type="submit">Set</button></form>`;++ await page(req, res, 200, {++ actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',++ body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}++${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/settings">++<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>++<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>++<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>++<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>++<p><button type="submit">Save</button></p></form>` : ''}++<h2>Members</h2>++<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="member"> <button type="submit">Add</button></form>++<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span> ${roleForm(m)}</li>`).join('')}</ul>`,++ });++ });++++ router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/settings`;++ const b = req.body || {};++ rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds) });++ res.redirect(303, `/r/${room.slug}/settings?saved=1`);++ }));++++ router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = rooms.bySlug(req.params.slug);++ if (!room) return res.redirect(303, '/rooms');++ req.roomBack = `/r/${room.slug}/settings`;++ const name = String((req.body && req.body.username) || '').trim();++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;++ if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);++ if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);++ const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || 'member'));++ if (role === 'blocked' || role === 'none') require('../chat/chat-server').removeFromRoom(room.id, target.id);++ res.redirect(303, `/r/${room.slug}/settings?saved=1`);++ }));++// ── What shipped, robots, sitemap ──router.get('/updates', (req, res) => page(req, res, 200, {title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,}));router.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')-- .send(`User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));++ .send(`User-agent: *\nAllow: /$\nAllow: /updates\nAllow: /rooms\nAllow: /r/\nDisallow: /r/*/settings\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));router.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')-- .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/updates'].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));++ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/rooms', '/updates', ...rooms.list(null, { limit: 100 }).public.map((r) => `/r/${r.slug}`)].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));return router;}nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * Chat rooms (WS-I task 4, server/rooms/): create, join, post, history, unread; private rooms are++ * invisible to outsiders; owners and mods moderate (delete, block) and it is logged; sockets that++ * joined a room get its messages live, nobody else does; site bans and slow mode apply.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('rooms');++let h, ann, bob, cat, mod;++const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });++++t('boot', async () => {++ h = await boot();++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ cat = h.addUser('cat', { subject: ids.newId('user') });++ mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });++ for (const u of [ann, bob, cat, mod]) h.ctx.upsertUser(h.live.users.get(u.id));++});++++t('create, list, join, post, history and unread', async () => {++ let r = await api('POST', '/', ann, { name: 'Night Owls', topic: 'Late chats' });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.body.room.slug, r.body.room.role, r.body.room.members], ['night-owls', 'owner', 1]);++ assert.strictEqual((await api('POST', '/', bob, { name: 'Night Owls' })).status, 409, 'address taken');++ assert.strictEqual((await api('POST', '/', bob, { name: 'x' })).status, 422);++ assert.strictEqual((await api('POST', '/', bob, { name: 'Global', slug: 'global' })).status, 422, 'reserved address');++ assert.strictEqual((await api('POST', '/', null, { name: 'Anon room' })).status, 401);++++ r = await api('POST', '/night-owls/messages', bob, { message: 'hi' });++ assert.strictEqual(r.status, 403, 'join first');++ assert.strictEqual((await api('POST', '/night-owls/join', bob)).body.role, 'member');++ r = await api('POST', '/night-owls/messages', ann, { message: 'welcome in' });++ assert.strictEqual(r.status, 201);++ const first = r.body.message.id;++ await api('POST', '/night-owls/messages', ann, { message: 'second' });++ let list = (await api('GET', '/', bob)).body;++ assert.strictEqual(list.mine.find((x) => x.slug === 'night-owls').unread, 2);++ assert.ok(list.public.some((x) => x.slug === 'night-owls'));++ await api('POST', '/night-owls/read', bob, {});++ list = (await api('GET', '/', bob)).body;++ assert.strictEqual(list.mine.find((x) => x.slug === 'night-owls').unread, 0);++ const hist = (await api('GET', '/night-owls/messages', null)).body;++ assert.deepStrictEqual(hist.messages.map((m) => m.message), ['welcome in', 'second'], 'public rooms are readable signed out');++ assert.deepStrictEqual((await api('GET', `/night-owls/messages?after=${first}`, null)).body.messages.map((m) => m.message), ['second']);++});++++t('private rooms: invisible to outsiders, members by invitation', async () => {++ let r = await api('POST', '/', ann, { name: 'Secret Club', visibility: 'private' });++ assert.strictEqual(r.status, 201);++ assert.strictEqual((await api('GET', '/secret-club', bob)).status, 404);++ assert.strictEqual((await api('GET', '/secret-club/messages', null)).status, 404);++ assert.strictEqual((await api('POST', '/secret-club/join', bob)).status, 403);++ assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'secret-club'), 'not listed');++ assert.strictEqual((await api('POST', '/secret-club/members', bob, { username: 'cat' })).status, 404, 'a non-member cannot even see it to invite');++ r = await api('POST', '/secret-club/members', ann, { username: 'bob', role: 'member' });++ assert.deepStrictEqual([r.status, r.body.role], [200, 'member']);++ assert.strictEqual((await api('GET', '/secret-club', bob)).status, 200);++ assert.strictEqual((await api('POST', '/secret-club/messages', bob, { message: 'thanks for the invite' })).status, 201);++ assert.strictEqual((await api('GET', '/secret-club', mod)).status, 200, 'chat staff can look in');++});++++t('moderation: delete and block, owner-only mods, logged; live delivery only to the room', async () => {++ const annWs = await h.ws({ token: ann.token }); annWs.sendJson({ type: 'join_room', room: 'night-owls' });++ assert.strictEqual((await annWs.next((m) => m.type === 'room_joined')).role, 'owner');++ const outsider = await h.ws({ token: cat.token }); outsider.sendJson({ type: 'join', token: cat.token });++ await outsider.next((m) => m.type === 'auth');++ const bobWs = await h.ws({ token: bob.token }); bobWs.sendJson({ type: 'join_room', room: 'night-owls' });++ await bobWs.next((m) => m.type === 'room_joined');++++ bobWs.sendJson({ type: 'room_message', message: 'live hello' });++ const got = await annWs.next((m) => m.type === 'room_message' && m.message.message === 'live hello');++ assert.strictEqual(got.room, 'night-owls');++ await new Promise((r) => setTimeout(r, 200));++ assert.ok(!outsider.all.some((m) => m.type === 'room_message'), 'global sockets never see room messages');++++ assert.strictEqual((await api('POST', '/night-owls/members', bob, { username: 'cat', role: 'mod' })).status, 403, 'members do not appoint mods');++ let r = await api('DELETE', `/night-owls/messages/${got.message.id}`, ann);++ assert.strictEqual(r.status, 200);++ await bobWs.next((m) => m.type === 'room_message_deleted' && m.id === got.message.id);++ r = await api('POST', '/night-owls/members', ann, { username: 'bob', role: 'blocked' });++ assert.strictEqual(r.body.role, 'blocked');++ await bobWs.next((m) => m.type === 'room_left');++ assert.strictEqual((await api('POST', '/night-owls/messages', bob, { message: 'let me back' })).status, 404, 'blocked: the room is gone for them');++ const logged = h.db.all("SELECT action_type FROM moderation_actions WHERE scope_type = 'room' ORDER BY id");++ assert.deepStrictEqual(logged.map((x) => x.action_type), ['delete_message', 'room_block']);++ for (const ws of [annWs, bobWs, outsider]) ws.close();++});++++t('slow mode and site bans', async () => {++ let r = await api('PATCH', '/night-owls', ann, { slow_seconds: 30 });++ assert.strictEqual(r.body.room.slow_seconds, 30);++ assert.strictEqual((await api('PATCH', '/night-owls', cat, { slow_seconds: 0 })).status, 403);++ await api('POST', '/night-owls/join', cat);++ assert.strictEqual((await api('POST', '/night-owls/messages', cat, { message: 'one' })).status, 201);++ r = await api('POST', '/night-owls/messages', cat, { message: 'two' });++ assert.deepStrictEqual([r.status, r.body.code], [429, 'rooms.slow_mode']);++ assert.strictEqual((await api('POST', '/night-owls/messages', ann, { message: 'owners skip slow mode' })).status, 201);++ await api('PATCH', '/night-owls', ann, { slow_seconds: 0 });++ h.live.addBan({ user_id: cat.id });++ await h.ctx.invalidateBans();++ r = await api('POST', '/night-owls/messages', cat, { message: 'banned now' });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'rooms.banned'], 'a site ban applies in rooms');++});++++t.run(async () => { if (h && h.close) await h.close(); });@@@ -91,6 +91,47 @@ t('settings save to the chat.preferences record', async () => {assert.match(page.text, /<body class="oc-compact oc-times oc-nobadges" style="--oc-scale:1.18">/, 'the site follows them too');});++t('rooms: start one, post and join without JavaScript, private rooms stay private, owners manage', async () => {++ let r = await req('GET', '/rooms');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Sign in with OpenVibe<\/a> to start or join a room/);++ r = await req('POST', '/rooms/new', form(alice, { name: 'Coffee Talk', topic: 'Beans and brews' }));++ assert.deepStrictEqual([r.status, r.headers.get('location')], [303, '/r/coffee-talk']);++ r = await req('POST', '/r/coffee-talk', form(alice, { message: 'first cup <b>hot</b>' }));++ assert.strictEqual(r.status, 303);++ let page = await req('GET', '/r/coffee-talk');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('first cup <b>hot</b>'), 'escaped');++ assert.match(page.text, /Beans and brews/);++ assert.match(page.text, /index, follow/);++ page = await req('GET', '/r/coffee-talk', { headers: cookie(bob) });++ assert.match(page.text, /Join this room/);++ assert.strictEqual((await req('POST', '/r/coffee-talk', form(bob, { message: 'can I?' }))).headers.get('location').includes('error='), true, 'join first');++ assert.strictEqual((await req('POST', '/r/coffee-talk/join', form(bob, {}))).headers.get('location'), '/r/coffee-talk');++ assert.strictEqual((await req('POST', '/r/coffee-talk', form(bob, { message: 'now I can' }))).headers.get('location'), '/r/coffee-talk#oc-compose');++ const rooms = (await req('GET', '/rooms', { headers: cookie(bob) })).text;++ assert.match(rooms, /Your rooms/);++++ r = await req('POST', '/rooms/new', form(alice, { name: 'Inner Circle', private: '1' }));++ assert.strictEqual(r.headers.get('location'), '/r/inner-circle');++ assert.strictEqual((await req('GET', '/r/inner-circle', { headers: cookie(bob) })).status, 404, 'private');++ assert.strictEqual((await req('GET', '/r/inner-circle')).status, 404);++ assert.ok(!(await req('GET', '/rooms')).text.includes('inner-circle'), 'not listed');++ assert.match((await req('GET', '/r/inner-circle', { headers: cookie(alice) })).text, /noindex, nofollow/);++ r = await req('POST', '/r/inner-circle/members', form(alice, { username: 'bob', role: 'member' }));++ assert.strictEqual(r.headers.get('location'), '/r/inner-circle/settings?saved=1');++ assert.strictEqual((await req('GET', '/r/inner-circle', { headers: cookie(bob) })).status, 200, 'invited');++ assert.strictEqual((await req('GET', '/r/inner-circle/settings', { headers: cookie(bob) })).headers.get('location'), '/r/inner-circle', 'members are not managers');++ const settings = await req('GET', '/r/inner-circle/settings', { headers: cookie(alice) });++ assert.match(settings.text, /Room settings/);++ r = await req('POST', '/r/inner-circle/settings', form(alice, { name: 'Inner Circle', topic: 'Quiet', visibility: 'private', slow_seconds: '5' }));++ assert.strictEqual(r.headers.get('location'), '/r/inner-circle/settings?saved=1');++ assert.match((await req('GET', '/r/inner-circle', { headers: cookie(alice) })).text, /slow mode 5s/);++++ const sitemap = (await req('GET', '/sitemap.xml')).text;++ assert.ok(sitemap.includes('/r/coffee-talk') && !sitemap.includes('inner-circle'), 'public rooms only');++});++t('sign-in, the Frame, robots, sitemap and /updates', async () => {const r = await req('GET', '/auth/login?next=%2Fmessages');assert.strictEqual(r.status, 302);
@@ -71,3 +71,18 @@ button[disabled] { opacity: .6; cursor: default; }
@@ -71,3 +71,18 @@ button[disabled] { opacity: .6; cursor: default; }
.oc-feed { max-height: 60vh; }
.oc-feed { max-height: 60vh; }
.oc-account { margin-left: 0; }
.oc-account { margin-left: 0; }
}
}
+/* Rooms */
+.oc-msg { position: relative; }
+.oc-del { display: inline; margin: 0 0 0 6px; }
+.oc-del button { background: transparent; color: var(--oc-muted); padding: 0 6px; font-weight: 700; border-radius: 6px; visibility: hidden; }
+.oc-msg:hover .oc-del button, .oc-del button:focus-visible { visibility: visible; }
+.oc-del button:hover { color: var(--oc-red); background: var(--oc-panel-2); }
+@media (hover: none) { .oc-del button { visibility: visible; } }
+.oc-inline { display: inline; margin: 0; }
+.oc-link { background: none; color: var(--oc-accent); padding: 0; font-weight: 600; text-decoration: underline; }
+.oc-create { margin-top: 24px; padding: 14px 16px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; }
+.oc-create h2 { margin: 0 0 4px; font-size: 1.1rem; }
+.oc-members { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.oc-members li { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 12px; }
+.oc-members form { margin-left: auto; }
+h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -161,10 +161,82 @@
@@ -161,10 +161,82 @@
}
}
}
}
+ // ── A room ──
+ function roomItem(m) {
+ var li = el('li', 'oc-msg'); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';
+ li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));
+ var who = el('a', 'oc-name', m.display_name || m.username || 'someone');
+ who.href = LIVE + '/@' + encodeURIComponent(m.username || '');
+ if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.color = m.profile_color;
+ li.appendChild(who);
+ var b = badge(m.user_role); if (b) li.appendChild(b);
+ li.appendChild(document.createTextNode(' '));
+ var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
+ if (cfg.moderate || (cfg.me && m.user_id === cfg.me)) {
+ var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
+ var x = el('button', null, '×'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message');
+ f.appendChild(x); li.appendChild(f);
+ }
+ return li;
+ }
+ function startRoom() {
+ var live = document.getElementById('oc-live');
+ var ws = null, delay = 1000, opened = false;
+ var api = '/api/chat/rooms/' + encodeURIComponent(cfg.room);
+ function catchUpRoom() {
+ return fetch(api + '/messages?after=' + latest + '&limit=200', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) { if (d && d.messages) d.messages.forEach(function (m) { latest = Math.max(latest, Number(m.id) || 0); append(roomItem(m), m.id); }); })
+ .catch(function () {});
+ }
+ function connect() {
+ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
+ ws.onopen = function () { delay = 1000; ws.send(JSON.stringify({ type: 'join_room', room: cfg.room })); if (opened) catchUpRoom(); opened = true; };
+ ws.onmessage = function (ev) {
+ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
+ if (m.type === 'room_joined') { if (live) live.hidden = false; }
+ else if (m.type === 'room_message' && m.room === cfg.room && m.message) {
+ latest = Math.max(latest, Number(m.message.id) || 0);
+ append(roomItem(m.message), m.message.id);
+ if (!document.hidden && cfg.me) fetch(api + '/read', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ last_id: latest }) }).catch(function () {});
+ } else if (m.type === 'room_message_deleted' && m.room === cfg.room) {
+ var x = feed.querySelector('[data-id="' + m.id + '"]'); if (x) x.remove();
+ } else if (m.type === 'room_error' && m.message) {
+ showError(m.message);
+ } else if (m.type === 'room_left') {
+ showError('You can no longer read this room.'); setTimeout(function () { location.href = '/rooms'; }, 1500);
+ } else if (m.type === 'auth_revoked') {
+ showError('You were signed out. Sign in again to keep chatting.');
+ }
+ };
+ ws.onclose = function () { if (live) live.hidden = true; setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
+ }
+ connect();
+ if (form && input) {
+ form.addEventListener('submit', function (e) {
+ var text = input.value.trim();
+ if (!text) { e.preventDefault(); return; }
+ if (!ws || ws.readyState !== 1) return;
+ e.preventDefault();
+ ws.send(JSON.stringify({ type: 'room_message', message: text }));
+ input.value = ''; input.focus();
+ });
+ }
+ if (feed) feed.addEventListener('submit', function (e) {
+ var f = e.target; if (!f.classList || !f.classList.contains('oc-del')) return;
+ e.preventDefault();
+ var li = f.closest('.oc-msg');
+ fetch(api + '/messages/' + encodeURIComponent(li.dataset.id), { method: 'DELETE', credentials: 'same-origin' })
+ .then(function (r) { if (r.ok) li.remove(); else return r.json().then(function (d) { showError((d && d.error) || 'Could not delete'); }); })
+ .catch(function () { f.submit(); });
+ });
+ }
+
if (input) input.addEventListener('keydown', function (e) {
if (input) input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
});
});
toBottom();
toBottom();
if (cfg.view === 'global') startGlobal();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
else if (cfg.view === 'dm') startDm();
+ else if (cfg.view === 'room') startRoom();
})();
})();
@@ -202,6 +202,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
@@ -202,6 +202,8 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
// The person's chat preferences: Network user module chat.preferences (server/prefs/).
// The person's chat preferences: Network user module chat.preferences (server/prefs/).
app.use('/api/chat/preferences', require('./prefs/routes'));
app.use('/api/chat/preferences', require('./prefs/routes'));
+ // Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
+ app.use('/api/chat/rooms', require('./rooms/routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/chat', require('./chat/routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/dm', require('./chat/dm-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
@@ -490,6 +490,33 @@ class ChatServer {
@@ -490,6 +490,33 @@ class ChatServer {
case 'leave_stream':
case 'leave_stream':
client.streamId = null;
client.streamId = null;
break;
break;
+ // Chat rooms (server/rooms/, openvibe.chat): a socket follows one room at a time.
+ case 'join_room': {
+ const rooms = require('../rooms/rooms');
+ const room = rooms.bySlug(msg.room);
+ const a = room ? rooms.access(room, client.user) : null;
+ if (!room || !a.read) { this.sendTo(ws, { type: 'room_error', room: msg.room || null, code: 'rooms.not_found', message: 'No such room' }); break; }
+ client.roomId = room.id;
+ client.roomSlug = room.slug;
+ this.sendTo(ws, { type: 'room_joined', room: room.slug, role: a.role, can: { post: a.post, moderate: a.moderate } });
+ break;
+ }
+ case 'leave_room':
+ client.roomId = null;
+ client.roomSlug = null;
+ break;
+ case 'room_message': {
+ const rooms = require('../rooms/rooms');
+ const room = client.roomId ? rooms.bySlug(client.roomSlug) : null;
+ if (!room || room.id !== client.roomId) { this.sendTo(ws, { type: 'room_error', code: 'rooms.not_joined', message: 'Open a room first' }); break; }
+ try {
+ const message = rooms.post(room, client.user, msg.message);
+ this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
+ } catch (err) {
+ this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });
+ }
+ break;
+ }
case 'get-users':
case 'get-users':
this.sendTo(ws, { type: 'users-list', users: this.getUserList(client.streamId) });
this.sendTo(ws, { type: 'users-list', users: this.getUserList(client.streamId) });
break;
break;
@@ -2264,12 +2291,32 @@ class ChatServer {
@@ -2264,12 +2291,32 @@ class ChatServer {
for (const [ws, client] of this.clients) {
for (const [ws, client] of this.clients) {
// Pure global clients only (see forwardToGlobal) — channel/stream viewers get
// Pure global clients only (see forwardToGlobal) — channel/stream viewers get
// global activity via their dedicated cross-feed socket, not their main one.
// global activity via their dedicated cross-feed socket, not their main one.
- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
+ if (!client.streamId && !client.channelUserId && !client.roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
ws.send(msg);
ws.send(msg);
}
}
}
}
}
}
+ /** Everyone following a chat room (server/rooms/) on this server. */
+ broadcastToRoom(roomId, data) {
+ const msg = JSON.stringify(data);
+ for (const [ws, client] of this.clients) {
+ if (client.roomId === roomId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) ws.send(msg);
+ }
+ }
+
+ /** A person may no longer read a room (blocked, removed from a private room, left): stop their feed. */
+ removeFromRoom(roomId, userId) {
+ let n = 0;
+ for (const [ws, client] of this.clients) {
+ if (client.roomId === roomId && client.user && client.user.id === userId) {
+ client.roomId = null; client.roomSlug = null; n++;
+ this.sendTo(ws, { type: 'room_left', reason: 'removed' });
+ }
+ }
+ return n;
+ }
+
/**
/**
* Notify everyone watching any of a channel owner's live streams that the
* Notify everyone watching any of a channel owner's live streams that the
* channel's emotes/sounds changed, so their chat pickers refresh live
* channel's emotes/sounds changed, so their chat pickers refresh live
new file mode 100644
new file mode 100644
@@ -0,0 +1,270 @@
@@ -0,0 +1,270 @@
+'use strict';
+/**
+ * Chat rooms (roadmap WS-I task 4, first release): rooms anyone can create on openvibe.chat, beside the
+ * global room and the stream/channel rooms Live's chat already has.
+ *
+ * rooms slug (URL id), name, topic, kind (community | system | call), visibility
+ * (public: anyone reads, signed-in people join; private: members only), owner, slow mode
+ * room_members role: owner | mod | member | blocked; last_read_id for unread counts
+ * room_messages the messages (soft-deleted by their author, a room mod or chat staff)
+ *
+ * Moderation: the owner and mods delete messages and block members; site bans and chat staff
+ * (staff.moderation.chat) apply in every room. Every staff/mod action is logged through
+ * db.logModerationAction (scope room:<slug>), which also reports it to Network's audit log.
+ * Nothing here talks to Live except the ban check (ctx.isUserBanned), so rooms keep working with
+ * Live down. chat-server.js delivers messages live to sockets that joined the room (join_room).
+ */
+const db = require('../db/database');
+const ctx = require('../live-context');
+const wordFilter = require('../chat/word-filter');
+const permissions = require('../auth/permissions');
+
+const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
+const RESERVED = new Set(['global', 'new', 'rooms', 'settings', 'messages', 'updates', 'auth', 'api', 'admin', 'staff', 'help', 'system']);
+const ROLES = ['owner', 'mod', 'member', 'blocked'];
+const KINDS = ['community', 'system', 'call'];
+const MAX_TEXT = 2000;
+const MAX_ROOMS_PER_OWNER = 10;
+const RATE = { max: 6, windowMs: 10_000 };
+
+class RoomError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+const fail = (status, code, message) => { throw new RoomError(status, code, message); };
+
+let ready = false;
+function ensureSchema() {
+ if (ready) return;
+ db.getDb().exec(`
+ CREATE TABLE IF NOT EXISTS rooms (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ slug TEXT UNIQUE NOT NULL,
+ name TEXT NOT NULL,
+ topic TEXT,
+ kind TEXT NOT NULL DEFAULT 'community',
+ visibility TEXT NOT NULL DEFAULT 'public',
+ owner_id INTEGER NOT NULL,
+ owner_subject TEXT,
+ slow_seconds INTEGER NOT NULL DEFAULT 0,
+ message_count INTEGER NOT NULL DEFAULT 0,
+ last_message_at DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ archived_at DATETIME
+ );
+ CREATE TABLE IF NOT EXISTS room_members (
+ room_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ role TEXT NOT NULL DEFAULT 'member',
+ last_read_id INTEGER NOT NULL DEFAULT 0,
+ joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (room_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);
+ CREATE TABLE IF NOT EXISTS room_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ room_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ subject_id TEXT,
+ message TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ is_deleted INTEGER NOT NULL DEFAULT 0,
+ deleted_by INTEGER
+ );
+ CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_id, id);
+ `);
+ ready = true;
+}
+
+const isStaff = (user) => !!user && permissions.can(user, 'staff.moderation.chat');
+const roleOf = (roomId, userId) => (userId ? (db.get('SELECT role FROM room_members WHERE room_id = ? AND user_id = ?', [roomId, userId]) || {}).role || null : null);
+const siteBanned = (user) => !!user && (user.is_banned || (() => { try { return ctx.isUserBanned(user.id, null); } catch { return false; } })());
+
+function slugify(name) {
+ return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
+}
+
+function publicRoom(r, extra = {}) {
+ if (!r) return null;
+ return {
+ id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: r.kind, visibility: r.visibility,
+ slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
+ created_at: r.created_at, members: (db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
+ ...extra,
+ };
+}
+
+function bySlug(slug) {
+ ensureSchema();
+ return db.get('SELECT * FROM rooms WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null;
+}
+
+/** What `user` may do in `room`: { read, post, moderate, role }. */
+function access(room, user) {
+ const role = user ? roleOf(room.id, user.id) : null;
+ const staff = isStaff(user);
+ const blocked = role === 'blocked';
+ const member = !!role && !blocked;
+ const read = !blocked && (room.visibility === 'public' || member || staff);
+ return { role, read, post: !!user && member && !siteBanned(user), moderate: !!user && (role === 'owner' || role === 'mod' || staff), manage: !!user && (role === 'owner' || staff) };
+}
+
+function create(user, { name, slug, topic, visibility = 'public', kind = 'community' } = {}) {
+ ensureSchema();
+ if (!user) fail(401, 'rooms.sign_in', 'Sign in to create a room');
+ if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
+ const n = String(name || '').replace(/\s+/g, ' ').trim();
+ if (n.length < 3 || n.length > 40) fail(422, 'rooms.name', 'Room names are 3 to 40 characters');
+ const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
+ if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'rooms.slug', 'Room addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
+ if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private');
+ if (!KINDS.includes(kind) || (kind !== 'community' && !isStaff(user))) fail(422, 'rooms.kind', 'Only chat staff create system and call rooms');
+ if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
+ const owned = db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id]).n;
+ if (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);
+ if (bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');
+ const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;
+ const id = db.transaction(() => {
+ const r = db.run('INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject) VALUES (?, ?, ?, ?, ?, ?, ?)', [s, n, t, kind, visibility, user.id, user.subject_id || null]);
+ db.run("INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')", [r.lastInsertRowid, user.id]);
+ return Number(r.lastInsertRowid);
+ });
+ return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [id]), { role: 'owner' });
+}
+
+/** Public rooms by recent activity, plus the rooms `user` belongs to with unread counts. */
+function list(user, { limit = 50 } = {}) {
+ ensureSchema();
+ const lim = Math.min(100, Math.max(1, Number(limit) || 50));
+ const pub = db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim]).map((r) => publicRoom(r));
+ const mine = user ? db.all(`SELECT r.*, m.role, m.last_read_id,
+ (SELECT COUNT(*) FROM room_messages x WHERE x.room_id = r.id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != ?) AS unread
+ FROM room_members m JOIN rooms r ON r.id = m.room_id
+ WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL
+ ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id]).map((r) => publicRoom(r, { role: r.role, unread: r.unread })) : [];
+ return { public: pub, mine };
+}
+
+function join(room, user) {
+ if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
+ const role = roleOf(room.id, user.id);
+ if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
+ if (role) return role;
+ if (room.visibility !== 'public' && !isStaff(user)) fail(403, 'rooms.private', 'This room is invite-only');
+ const last = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
+ db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id) VALUES (?, ?, 'member', ?)", [room.id, user.id, last]);
+ return 'member';
+}
+
+function leave(room, user) {
+ const role = roleOf(room.id, user && user.id);
+ if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');
+ if (role && role !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);
+}
+
+const _rate = new Map();
+function rateOk(key, now = Date.now()) {
+ const arr = (_rate.get(key) || []).filter((t) => now - t < RATE.windowMs);
+ if (arr.length >= RATE.max) { _rate.set(key, arr); return false; }
+ arr.push(now); _rate.set(key, arr);
+ if (_rate.size > 20000) _rate.clear();
+ return true;
+}
+
+/** Post a message; returns the stored row (with the author's names) for broadcasting. */
+function post(room, user, text, { now = Date.now() } = {}) {
+ const a = access(room, user);
+ if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
+ if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
+ if (!a.post) fail(403, room.visibility === 'public' ? 'rooms.join_first' : 'rooms.private', room.visibility === 'public' ? 'Join the room to post' : 'This room is invite-only');
+ const body = String(text || '').replace(/\r\n/g, '\n').trim();
+ if (!body) fail(422, 'rooms.empty', 'Write something first');
+ if (body.length > MAX_TEXT) fail(422, 'rooms.too_long', `Messages are at most ${MAX_TEXT} characters`);
+ if (wordFilter.isSpam(body)) fail(422, 'rooms.spam', 'That looks like spam');
+ if (!rateOk(`${room.id}:${user.id}`, now)) fail(429, 'rooms.slow_down', 'Slow down a little');
+ if (room.slow_seconds > 0 && !a.moderate) {
+ const last = db.get('SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1', [room.id, user.id]);
+ if (last && now - Date.parse(`${String(last.created_at).replace(' ', 'T')}Z`) < room.slow_seconds * 1000) fail(429, 'rooms.slow_mode', `Slow mode: one message every ${room.slow_seconds} seconds`);
+ }
+ const filtered = wordFilter.check(body);
+ const message = filtered.safe ? body : filtered.filtered;
+ const id = db.transaction(() => {
+ const r = db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message) VALUES (?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message]);
+ db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = CURRENT_TIMESTAMP WHERE id = ?', [room.id]);
+ db.run('UPDATE room_members SET last_read_id = ? WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
+ return Number(r.lastInsertRowid);
+ });
+ return messageById(id);
+}
+
+const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.created_at, m.is_deleted, u.username, u.display_name, u.profile_color, u.role AS user_role
+ FROM room_messages m LEFT JOIN ctx_users u ON u.id = m.user_id`;
+function messageById(id) { return db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null; }
+
+/** Messages oldest → newest: the newest page, one before `before`, or everything after `after`. */
+function history(room, { before = null, after = null, limit = 60 } = {}) {
+ const lim = Math.min(200, Math.max(1, Number(limit) || 60));
+ if (after != null) return db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id > ? AND m.is_deleted = 0 ORDER BY m.id ASC LIMIT ?`, [room.id, Number(after) || 0, lim]);
+ const rows = before != null
+ ? db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])
+ : db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);
+ return rows.reverse();
+}
+
+function markRead(room, user, lastId) {
+ if (!user) return;
+ const top = (db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
+ const to = Math.min(top, Number(lastId) || top);
+ db.run('UPDATE room_members SET last_read_id = MAX(last_read_id, ?) WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
+}
+
+function deleteMessage(room, user, messageId) {
+ const m = db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);
+ if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');
+ const own = user && m.user_id === user.id;
+ const a = access(room, user);
+ if (!own && !a.moderate) fail(403, 'rooms.not_yours', 'Only the author or a room moderator can delete this');
+ db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);
+ if (!own) db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });
+ return m.id;
+}
+
+/** Owner/mod actions on a member: invite (by user id), set a role (mod/member), block or unblock. */
+function setRole(room, actor, targetUserId, role) {
+ if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');
+ const a = access(room, actor);
+ const current = roleOf(room.id, targetUserId);
+ if (current === 'owner') fail(403, 'rooms.owner', 'The owner\'s role does not change');
+ if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
+ if (role === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner appoints moderators');
+ if (!a.moderate) fail(403, 'rooms.not_mod', 'Only the room\'s owner and moderators manage members');
+ if (current === 'mod' && !a.manage) fail(403, 'rooms.not_owner', 'Only the owner changes a moderator');
+ const target = ctx.getUserById(targetUserId);
+ if (!target) fail(404, 'rooms.no_user', 'No such person');
+ if (role === 'none') { if (current && current !== 'blocked') db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, targetUserId]); }
+ else if (current) db.run('UPDATE room_members SET role = ? WHERE room_id = ? AND user_id = ?', [role, room.id, targetUserId]);
+ else db.run('INSERT INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)', [room.id, targetUserId, role]);
+ if (role === 'blocked' || current === 'blocked' || role === 'mod' || current === 'mod') {
+ db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: actor.id, target_user_id: targetUserId, action_type: role === 'blocked' ? 'room_block' : current === 'blocked' ? 'room_unblock' : role === 'mod' ? 'room_mod_add' : 'room_mod_remove', details: {} });
+ }
+ return role;
+}
+
+function update(room, actor, { name, topic, visibility, slow_seconds } = {}) {
+ if (!access(room, actor).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
+ const next = { ...room };
+ if (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < 3 || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', 'Room names are 3 to 40 characters'); next.name = n; }
+ if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;
+ if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private'); next.visibility = visibility; }
+ if (slow_seconds != null) { const v = Number(slow_seconds); if (!Number.isInteger(v) || v < 0 || v > 600) fail(422, 'rooms.slow', 'Slow mode is 0 to 600 seconds'); next.slow_seconds = v; }
+ db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, room.id]);
+ return publicRoom(db.get('SELECT * FROM rooms WHERE id = ?', [room.id]));
+}
+
+function members(room) {
+ return db.all(`SELECT m.user_id, m.role, u.username, u.display_name FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
+ WHERE m.room_id = ? ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'mod' THEN 1 WHEN 'member' THEN 2 ELSE 3 END, u.username COLLATE NOCASE LIMIT 500`, [room.id]);
+}
+
+function _reset() { ready = false; _rate.clear(); }
+
+module.exports = { ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById, RoomError, SLUG_RE, _reset };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+/**
+ * /api/chat/rooms — chat rooms over REST (server/rooms/rooms.js); openvibe.chat and bots use it, and
+ * chat-server.js carries the same rooms live (join_room / room_message).
+ *
+ * GET / public rooms by activity + the caller's rooms (with unread counts)
+ * POST / create { name, slug?, topic?, visibility? }
+ * GET /:slug the room and what the caller may do (404 when they may not read it)
+ * PATCH /:slug owner: { name?, topic?, visibility?, slow_seconds? }
+ * GET /:slug/messages ?before=<id> | ?after=<id>, &limit
+ * POST /:slug/messages { message }
+ * DELETE /:slug/messages/:id the author, a room moderator or chat staff
+ * POST /:slug/join | /leave | /read { last_id? }
+ * GET /:slug/members
+ * POST /:slug/members owner/mods: { username, role: member | mod | blocked | none }
+ */
+const express = require('express');
+const { requireAuth, optionalAuth } = require('../auth/auth');
+const rooms = require('./rooms');
+const ctx = require('../live-context');
+
+const router = express.Router();
+
+function send(res, err) {
+ if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
+ console.error('[Rooms]', err && err.message);
+ return res.status(500).json({ error: 'Something went wrong' });
+}
+const chatServer = () => require('../chat/chat-server');
+
+/** The room behind :slug when the caller may read it (else a 404 that says nothing about private rooms). */
+function readable(req, res) {
+ const room = rooms.bySlug(req.params.slug);
+ if (!room || !rooms.access(room, req.user || null).read) { res.status(404).json({ error: 'No such room', code: 'rooms.not_found' }); return null; }
+ return room;
+}
+
+router.get('/', optionalAuth, (req, res) => {
+ try { res.set('Cache-Control', 'private, no-store').json(rooms.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
+});
+
+router.post('/', requireAuth, (req, res) => {
+ try { res.status(201).json({ room: rooms.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
+});
+
+router.get('/:slug', optionalAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ const a = rooms.access(room, req.user || null);
+ res.set('Cache-Control', 'private, no-store').json({ room: rooms.publicRoom(room, { role: a.role }), can: { post: a.post, moderate: a.moderate, manage: a.manage } });
+});
+
+router.patch('/:slug', requireAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try { res.json({ room: rooms.update(room, req.user, req.body || {}) }); } catch (err) { send(res, err); }
+});
+
+router.get('/:slug/messages', optionalAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try {
+ const messages = rooms.history(room, { before: req.query.before ?? null, after: req.query.after ?? null, limit: req.query.limit });
+ res.set('Cache-Control', 'private, no-store').json({ messages, latest_id: messages.length ? messages[messages.length - 1].id : null });
+ } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/messages', requireAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try {
+ const message = rooms.post(room, req.user, req.body && req.body.message);
+ chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
+ res.status(201).json({ message });
+ } catch (err) { send(res, err); }
+});
+
+router.delete('/:slug/messages/:id', requireAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try {
+ const id = rooms.deleteMessage(room, req.user, parseInt(req.params.id, 10));
+ chatServer().broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
+ res.json({ ok: true, id });
+ } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/join', requireAuth, (req, res) => {
+ const room = rooms.bySlug(req.params.slug);
+ if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
+ try { res.json({ ok: true, role: rooms.join(room, req.user) }); } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/leave', requireAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try { rooms.leave(room, req.user); chatServer().removeFromRoom(room.id, req.user.id); res.json({ ok: true }); } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/read', requireAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try { rooms.markRead(room, req.user, req.body && req.body.last_id); res.json({ ok: true }); } catch (err) { send(res, err); }
+});
+
+router.get('/:slug/members', optionalAuth, (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ res.set('Cache-Control', 'private, no-store').json({ members: rooms.members(room).filter((m) => m.role !== 'blocked' || rooms.access(room, req.user || null).moderate) });
+});
+
+router.post('/:slug/members', requireAuth, async (req, res) => {
+ const room = readable(req, res); if (!room) return;
+ try {
+ const name = String((req.body && req.body.username) || '').trim();
+ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
+ if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
+ if (!target) return res.status(404).json({ error: `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'rooms.no_user' });
+ const role = rooms.setRole(room, req.user, target.id, String((req.body && req.body.role) || 'member'));
+ if (role === 'blocked' || role === 'none' || (room.visibility === 'private' && role === 'none')) chatServer().removeFromRoom(room.id, target.id);
+ res.json({ ok: true, user_id: target.id, username: target.username, role });
+ } catch (err) { send(res, err); }
+});
+
+module.exports = router;
@@ -18,6 +18,7 @@ const DEFAULT_DESCRIPTION = 'OpenVibe.Chat: the network-wide chat room and your
@@ -18,6 +18,7 @@ const DEFAULT_DESCRIPTION = 'OpenVibe.Chat: the network-wide chat room and your
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const LINKS = [
const LINKS = [
{ key: 'global', label: 'Global chat', href: '/' },
{ key: 'global', label: 'Global chat', href: '/' },
+ { key: 'rooms', label: 'Rooms', href: '/rooms' },
{ key: 'messages', label: 'Messages', href: '/messages' },
{ key: 'messages', label: 'Messages', href: '/messages' },
{ key: 'settings', label: 'Settings', href: '/settings' },
{ key: 'settings', label: 'Settings', href: '/settings' },
];
];
@@ -267,15 +267,181 @@ ${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
@@ -267,15 +267,181 @@ ${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }
catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }
});
});
+ // ── Rooms (server/rooms/) ──
+ const rooms = require('../rooms/rooms');
+ const roomsOrError = (fn) => async (req, res, next) => {
+ try { return await fn(req, res, next); } catch (err) {
+ if (err instanceof rooms.RoomError) return res.redirect(303, `${req.roomBack || '/rooms'}?error=${encodeURIComponent(err.message)}`);
+ return next(err);
+ }
+ };
+ const roomCard = (r) => `<li class="oc-conv${r.unread ? ' oc-unread' : ''}"><a href="/r/${esc(r.slug)}"><strong>${esc(r.name)}</strong>${r.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}${r.unread ? ` <span class="oc-badge">${r.unread} new</span>` : ''}<span class="oc-muted oc-last">${esc(r.topic || `${r.members} member${r.members === 1 ? '' : 's'}`)}</span></a></li>`;
+ const roomMessage = (m, can, slug, me) => {
+ const t = hhmm(m.created_at);
+ const del = (can.moderate || (me && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
+ return `<li class="oc-msg" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="color:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
+ };
+
+ router.get('/rooms', async (req, res) => {
+ const actor = await viewerOf(req);
+ const me = actor.kind === 'user' ? actor.user : null;
+ const { public: pub, mine } = rooms.list(me);
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">
+<h2>Start a room</h2>
+<label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls"></label>
+<label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>
+<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people you invite can read and post</label>
+<p><button type="submit">Create room</button></p>
+</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Frooms">Sign in with OpenVibe</a> to start or join a room.</p>`;
+ await page(req, res, 200, {
+ actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',
+ description: 'Chat rooms on OpenVibe: start one about anything, invite people, or join a public room.',
+ body: `<h1>Rooms</h1>${error}
+${mine.length ? `<h2>Your rooms</h2><ul class="oc-convs">${mine.map(roomCard).join('')}</ul>` : ''}
+<h2>Public rooms</h2><ul class="oc-convs">${pub.map(roomCard).join('') || '<li class="oc-muted">No rooms yet. Start the first one.</li>'}</ul>
+${create}`,
+ });
+ });
+
+ router.post('/rooms/new', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const b = req.body || {};
+ const room = rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public' });
+ res.redirect(303, `/r/${room.slug}`);
+ }));
+
+ const loadRoom = async (req, res) => {
+ const actor = await viewerOf(req);
+ const me = actor.kind === 'user' ? actor.user : null;
+ const room = rooms.bySlug(req.params.slug);
+ const can = room ? rooms.access(room, me) : null;
+ if (!room || !can.read) { await page(req, res, 404, { actor, title: 'No such room', path: req.path, robots: 'noindex', body: '<h1>No such room</h1><p><a href="/rooms">All rooms</a></p>' }); return null; }
+ req.roomBack = `/r/${room.slug}`;
+ return { actor, me, room, can };
+ };
+
+ router.get('/r/:slug', async (req, res) => {
+ const x = await loadRoom(req, res); if (!x) return;
+ const { actor, me, room, can } = x;
+ const msgs = rooms.history(room, { limit: 60 });
+ if (me && can.role) rooms.markRead(room, me);
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ let composer;
+ if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
+ else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;
+ else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">Join this room</button></form>`;
+ else composer = notice('error', 'You cannot post here right now.');
+ const tools = me && can.role && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/leave"><button type="submit" class="oc-link">Leave room</button></form>` : '';
+ const manage = can.moderate ? ` · <a href="/r/${esc(room.slug)}/settings">${can.manage ? 'Room settings' : 'Members'}</a>` : '';
+ await page(req, res, 200, {
+ actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true,
+ description: room.topic || `${room.name}, a chat room on OpenVibe.`,
+ page: { view: 'room', room: room.slug, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate },
+ body: `<p><a href="/rooms">← Rooms</a></p>
+<h1>${esc(room.name)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
+<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${rooms.publicRoom(room).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
+${error}
+<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>
+${composer}${tools}`,
+ });
+ });
+
+ router.post('/r/:slug', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}`;
+ const message = rooms.post(room, actor.user, req.body && req.body.message);
+ require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
+ res.redirect(303, `/r/${room.slug}#oc-compose`);
+ }));
+
+ router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}`;
+ rooms.join(room, actor.user);
+ res.redirect(303, `/r/${room.slug}`);
+ }));
+
+ router.post('/r/:slug/leave', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}`;
+ rooms.leave(room, actor.user);
+ require('../chat/chat-server').removeFromRoom(room.id, actor.user.id);
+ res.redirect(303, '/rooms');
+ }));
+
+ router.post('/r/:slug/delete/:id', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}`;
+ const id = rooms.deleteMessage(room, actor.user, parseInt(req.params.id, 10));
+ require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id });
+ res.redirect(303, `/r/${room.slug}`);
+ }));
+
+ router.get('/r/:slug/settings', async (req, res) => {
+ const x = await loadRoom(req, res); if (!x) return;
+ const { actor, room, can } = x;
+ if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
+ const saved = req.query.saved ? notice('ok', 'Saved.') : '';
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ const members = rooms.members(room);
+ const roleForm = (m) => m.role === 'owner' ? '<span class="oc-muted">owner</span>' : `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/members"><input type="hidden" name="username" value="${esc(m.username || '')}"><select name="role" aria-label="Role for ${esc(m.username || '')}">${[...(can.manage ? ['mod'] : []), 'member', 'blocked', 'none'].map((r) => `<option value="${r}"${r === m.role ? ' selected' : ''}>${r === 'none' ? 'remove' : r}</option>`).join('')}</select> <button type="submit">Set</button></form>`;
+ await page(req, res, 200, {
+ actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',
+ body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
+${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}/settings">
+<label>Name <input name="name" required minlength="3" maxlength="40" value="${esc(room.name)}"></label>
+<label>Topic <input name="topic" maxlength="200" value="${esc(room.topic || '')}"></label>
+<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>
+<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
+<p><button type="submit">Save</button></p></form>` : ''}
+<h2>Members</h2>
+<form class="oc-new" method="post" action="/r/${esc(room.slug)}/members"><label for="oc-invite">Add someone</label> <input id="oc-invite" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="member"> <button type="submit">Add</button></form>
+<ul class="oc-members">${members.map((m) => `<li><strong>${esc(m.display_name || m.username || '?')}</strong> <span class="oc-muted">@${esc(m.username || '')}</span> ${roleForm(m)}</li>`).join('')}</ul>`,
+ });
+ });
+
+ router.post('/r/:slug/settings', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/settings`;
+ const b = req.body || {};
+ rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds) });
+ res.redirect(303, `/r/${room.slug}/settings?saved=1`);
+ }));
+
+ router.post('/r/:slug/members', formLimit, sameSite, roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = rooms.bySlug(req.params.slug);
+ if (!room) return res.redirect(303, '/rooms');
+ req.roomBack = `/r/${room.slug}/settings`;
+ const name = String((req.body && req.body.username) || '').trim();
+ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
+ if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
+ if (!target) return res.redirect(303, `/r/${room.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
+ const role = rooms.setRole(room, actor.user, target.id, String((req.body && req.body.role) || 'member'));
+ if (role === 'blocked' || role === 'none') require('../chat/chat-server').removeFromRoom(room.id, target.id);
+ res.redirect(303, `/r/${room.slug}/settings?saved=1`);
+ }));
+
// ── What shipped, robots, sitemap ──
// ── What shipped, robots, sitemap ──
router.get('/updates', (req, res) => page(req, res, 200, {
router.get('/updates', (req, res) => page(req, res, 200, {
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
}));
}));
router.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
router.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
- .send(`User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));
+ .send(`User-agent: *\nAllow: /$\nAllow: /updates\nAllow: /rooms\nAllow: /r/\nDisallow: /r/*/settings\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));
router.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')
router.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')
- .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/updates'].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));
+ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/rooms', '/updates', ...rooms.list(null, { limit: 100 }).public.map((r) => `/r/${r.slug}`)].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));
return router;
return router;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * Chat rooms (WS-I task 4, server/rooms/): create, join, post, history, unread; private rooms are
+ * invisible to outsiders; owners and mods moderate (delete, block) and it is logged; sockets that
+ * joined a room get its messages live, nobody else does; site bans and slow mode apply.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('rooms');
+let h, ann, bob, cat, mod;
+const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
+
+t('boot', async () => {
+ h = await boot();
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ cat = h.addUser('cat', { subject: ids.newId('user') });
+ mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });
+ for (const u of [ann, bob, cat, mod]) h.ctx.upsertUser(h.live.users.get(u.id));
+});
+
+t('create, list, join, post, history and unread', async () => {
+ let r = await api('POST', '/', ann, { name: 'Night Owls', topic: 'Late chats' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.body.room.slug, r.body.room.role, r.body.room.members], ['night-owls', 'owner', 1]);
+ assert.strictEqual((await api('POST', '/', bob, { name: 'Night Owls' })).status, 409, 'address taken');
+ assert.strictEqual((await api('POST', '/', bob, { name: 'x' })).status, 422);
+ assert.strictEqual((await api('POST', '/', bob, { name: 'Global', slug: 'global' })).status, 422, 'reserved address');
+ assert.strictEqual((await api('POST', '/', null, { name: 'Anon room' })).status, 401);
+
+ r = await api('POST', '/night-owls/messages', bob, { message: 'hi' });
+ assert.strictEqual(r.status, 403, 'join first');
+ assert.strictEqual((await api('POST', '/night-owls/join', bob)).body.role, 'member');
+ r = await api('POST', '/night-owls/messages', ann, { message: 'welcome in' });
+ assert.strictEqual(r.status, 201);
+ const first = r.body.message.id;
+ await api('POST', '/night-owls/messages', ann, { message: 'second' });
+ let list = (await api('GET', '/', bob)).body;
+ assert.strictEqual(list.mine.find((x) => x.slug === 'night-owls').unread, 2);
+ assert.ok(list.public.some((x) => x.slug === 'night-owls'));
+ await api('POST', '/night-owls/read', bob, {});
+ list = (await api('GET', '/', bob)).body;
+ assert.strictEqual(list.mine.find((x) => x.slug === 'night-owls').unread, 0);
+ const hist = (await api('GET', '/night-owls/messages', null)).body;
+ assert.deepStrictEqual(hist.messages.map((m) => m.message), ['welcome in', 'second'], 'public rooms are readable signed out');
+ assert.deepStrictEqual((await api('GET', `/night-owls/messages?after=${first}`, null)).body.messages.map((m) => m.message), ['second']);
+});
+
+t('private rooms: invisible to outsiders, members by invitation', async () => {
+ let r = await api('POST', '/', ann, { name: 'Secret Club', visibility: 'private' });
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual((await api('GET', '/secret-club', bob)).status, 404);
+ assert.strictEqual((await api('GET', '/secret-club/messages', null)).status, 404);
+ assert.strictEqual((await api('POST', '/secret-club/join', bob)).status, 403);
+ assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'secret-club'), 'not listed');
+ assert.strictEqual((await api('POST', '/secret-club/members', bob, { username: 'cat' })).status, 404, 'a non-member cannot even see it to invite');
+ r = await api('POST', '/secret-club/members', ann, { username: 'bob', role: 'member' });
+ assert.deepStrictEqual([r.status, r.body.role], [200, 'member']);
+ assert.strictEqual((await api('GET', '/secret-club', bob)).status, 200);
+ assert.strictEqual((await api('POST', '/secret-club/messages', bob, { message: 'thanks for the invite' })).status, 201);
+ assert.strictEqual((await api('GET', '/secret-club', mod)).status, 200, 'chat staff can look in');
+});
+
+t('moderation: delete and block, owner-only mods, logged; live delivery only to the room', async () => {
+ const annWs = await h.ws({ token: ann.token }); annWs.sendJson({ type: 'join_room', room: 'night-owls' });
+ assert.strictEqual((await annWs.next((m) => m.type === 'room_joined')).role, 'owner');
+ const outsider = await h.ws({ token: cat.token }); outsider.sendJson({ type: 'join', token: cat.token });
+ await outsider.next((m) => m.type === 'auth');
+ const bobWs = await h.ws({ token: bob.token }); bobWs.sendJson({ type: 'join_room', room: 'night-owls' });
+ await bobWs.next((m) => m.type === 'room_joined');
+
+ bobWs.sendJson({ type: 'room_message', message: 'live hello' });
+ const got = await annWs.next((m) => m.type === 'room_message' && m.message.message === 'live hello');
+ assert.strictEqual(got.room, 'night-owls');
+ await new Promise((r) => setTimeout(r, 200));
+ assert.ok(!outsider.all.some((m) => m.type === 'room_message'), 'global sockets never see room messages');
+
+ assert.strictEqual((await api('POST', '/night-owls/members', bob, { username: 'cat', role: 'mod' })).status, 403, 'members do not appoint mods');
+ let r = await api('DELETE', `/night-owls/messages/${got.message.id}`, ann);
+ assert.strictEqual(r.status, 200);
+ await bobWs.next((m) => m.type === 'room_message_deleted' && m.id === got.message.id);
+ r = await api('POST', '/night-owls/members', ann, { username: 'bob', role: 'blocked' });
+ assert.strictEqual(r.body.role, 'blocked');
+ await bobWs.next((m) => m.type === 'room_left');
+ assert.strictEqual((await api('POST', '/night-owls/messages', bob, { message: 'let me back' })).status, 404, 'blocked: the room is gone for them');
+ const logged = h.db.all("SELECT action_type FROM moderation_actions WHERE scope_type = 'room' ORDER BY id");
+ assert.deepStrictEqual(logged.map((x) => x.action_type), ['delete_message', 'room_block']);
+ for (const ws of [annWs, bobWs, outsider]) ws.close();
+});
+
+t('slow mode and site bans', async () => {
+ let r = await api('PATCH', '/night-owls', ann, { slow_seconds: 30 });
+ assert.strictEqual(r.body.room.slow_seconds, 30);
+ assert.strictEqual((await api('PATCH', '/night-owls', cat, { slow_seconds: 0 })).status, 403);
+ await api('POST', '/night-owls/join', cat);
+ assert.strictEqual((await api('POST', '/night-owls/messages', cat, { message: 'one' })).status, 201);
+ r = await api('POST', '/night-owls/messages', cat, { message: 'two' });
+ assert.deepStrictEqual([r.status, r.body.code], [429, 'rooms.slow_mode']);
+ assert.strictEqual((await api('POST', '/night-owls/messages', ann, { message: 'owners skip slow mode' })).status, 201);
+ await api('PATCH', '/night-owls', ann, { slow_seconds: 0 });
+ h.live.addBan({ user_id: cat.id });
+ await h.ctx.invalidateBans();
+ r = await api('POST', '/night-owls/messages', cat, { message: 'banned now' });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'rooms.banned'], 'a site ban applies in rooms');
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });
@@ -91,6 +91,47 @@ t('settings save to the chat.preferences record', async () => {
@@ -91,6 +91,47 @@ t('settings save to the chat.preferences record', async () => {
assert.match(page.text, /<body class="oc-compact oc-times oc-nobadges" style="--oc-scale:1.18">/, 'the site follows them too');
assert.match(page.text, /<body class="oc-compact oc-times oc-nobadges" style="--oc-scale:1.18">/, 'the site follows them too');
});
});
+t('rooms: start one, post and join without JavaScript, private rooms stay private, owners manage', async () => {
+ let r = await req('GET', '/rooms');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Sign in with OpenVibe<\/a> to start or join a room/);
+ r = await req('POST', '/rooms/new', form(alice, { name: 'Coffee Talk', topic: 'Beans and brews' }));
+ assert.deepStrictEqual([r.status, r.headers.get('location')], [303, '/r/coffee-talk']);
+ r = await req('POST', '/r/coffee-talk', form(alice, { message: 'first cup <b>hot</b>' }));
+ assert.strictEqual(r.status, 303);
+ let page = await req('GET', '/r/coffee-talk');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('first cup <b>hot</b>'), 'escaped');
+ assert.match(page.text, /Beans and brews/);
+ assert.match(page.text, /index, follow/);
+ page = await req('GET', '/r/coffee-talk', { headers: cookie(bob) });
+ assert.match(page.text, /Join this room/);
+ assert.strictEqual((await req('POST', '/r/coffee-talk', form(bob, { message: 'can I?' }))).headers.get('location').includes('error='), true, 'join first');
+ assert.strictEqual((await req('POST', '/r/coffee-talk/join', form(bob, {}))).headers.get('location'), '/r/coffee-talk');
+ assert.strictEqual((await req('POST', '/r/coffee-talk', form(bob, { message: 'now I can' }))).headers.get('location'), '/r/coffee-talk#oc-compose');
+ const rooms = (await req('GET', '/rooms', { headers: cookie(bob) })).text;
+ assert.match(rooms, /Your rooms/);
+
+ r = await req('POST', '/rooms/new', form(alice, { name: 'Inner Circle', private: '1' }));
+ assert.strictEqual(r.headers.get('location'), '/r/inner-circle');
+ assert.strictEqual((await req('GET', '/r/inner-circle', { headers: cookie(bob) })).status, 404, 'private');
+ assert.strictEqual((await req('GET', '/r/inner-circle')).status, 404);
+ assert.ok(!(await req('GET', '/rooms')).text.includes('inner-circle'), 'not listed');
+ assert.match((await req('GET', '/r/inner-circle', { headers: cookie(alice) })).text, /noindex, nofollow/);
+ r = await req('POST', '/r/inner-circle/members', form(alice, { username: 'bob', role: 'member' }));
+ assert.strictEqual(r.headers.get('location'), '/r/inner-circle/settings?saved=1');
+ assert.strictEqual((await req('GET', '/r/inner-circle', { headers: cookie(bob) })).status, 200, 'invited');
+ assert.strictEqual((await req('GET', '/r/inner-circle/settings', { headers: cookie(bob) })).headers.get('location'), '/r/inner-circle', 'members are not managers');
+ const settings = await req('GET', '/r/inner-circle/settings', { headers: cookie(alice) });
+ assert.match(settings.text, /Room settings/);
+ r = await req('POST', '/r/inner-circle/settings', form(alice, { name: 'Inner Circle', topic: 'Quiet', visibility: 'private', slow_seconds: '5' }));
+ assert.strictEqual(r.headers.get('location'), '/r/inner-circle/settings?saved=1');
+ assert.match((await req('GET', '/r/inner-circle', { headers: cookie(alice) })).text, /slow mode 5s/);
+
+ const sitemap = (await req('GET', '/sitemap.xml')).text;
+ assert.ok(sitemap.includes('/r/coffee-talk') && !sitemap.includes('inner-circle'), 'public rooms only');
+});
+
t('sign-in, the Frame, robots, sitemap and /updates', async () => {
t('sign-in, the Frame, robots, sitemap and /updates', async () => {
const r = await req('GET', '/auth/login?next=%2Fmessages');
const r = await req('GET', '/auth/login?next=%2Fmessages');
assert.strictEqual(r.status, 302);
assert.strictEqual(r.status, 302);