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

OP OpenVibers committed 25 Sept 2026 · 27852aff3b · 10 files, +843 −3


Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 8d4a69b..700845c 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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; }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 3e443ec..66d953f 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -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();
})();
ddiff --git a/server/app.js b/server/app.js
iindex 9556383..7f2cf8d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'));
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 878295e..a1fa996 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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 live
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
nnew file mode 100644
iindex 0000000..754fcf9
---- /dev/null
++++ b/server/rooms/rooms.js
@@@ -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 };
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
nnew file mode 100644
iindex 0000000..50e97d7
---- /dev/null
++++ b/server/rooms/routes.js
@@@ -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;
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 07ac6e7..d15544e 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -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 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' },
];
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 0d4763b..b6f187b 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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;
}
ddiff --git a/test/rooms.test.js b/test/rooms.test.js
nnew file mode 100644
iindex 0000000..eb3c490
---- /dev/null
++++ b/test/rooms.test.js
@@@ -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(); });
ddiff --git a/test/web.test.js b/test/web.test.js
iindex 99156b4..23a876a 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -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 &lt;b&gt;hot&lt;/b&gt;'), '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);