openvibers/openvibe.chat Commits
Chat: servers, with text and voice channels, roles and invites (#104)
Changes
@@@ -308,11 +308,33 @@ only; members see names and handles, managers the set-up); the settings page manmember can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting anaccount keeps the AI members it added (they belong to the room) without its subject on them, and exports them.--**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/messages` the DM inbox++**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,++`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,++channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text++channel is a community room and a voice channel a call room (its call is `room-<slug>`), so messages, live delivery,++unread counts, calls and AI members work in channels unchanged. `rooms.access()` asks the server for any room with++`server_id`: the owner is the channel's owner, admins and moderators its moderators, members its members (in a voice++channel: speakers by default, or listeners when the channel says participant); a public server is read by anyone++and joined by signed-in people, a private one is a 404 to outsiders and joined only through an invite++(`/join/<code>`: any number of uses or 1/10/100, for a day, a week, a month or forever, revocable). The owner appoints++admins, admins appoint moderators and manage channels, settings and invites, moderators remove and ban members;++nobody acts on their own rank or above, and every action is logged (scope `server:<slug>`). A server starts with++`#general` and a voice Lounge; a person owns up to 5, a server has up to 50 channels and 25 live invites and keeps one++text channel. A channel's room slug is random (`ch…`); its page is `/s/<server>/<channel>` and `/r/<slug>` goes++there. Channels follow the server's visibility (going private redacts what they had published); the rooms API refuses++room-level joins, roles and visibility there, and `/rooms` leaves channels out. The page: your servers down the side++with unread counts, the channels, the channel (its call for a voice channel) and the people by rank; on a phone the++servers run across the top and the channel list folds away. API: `/api/chat/servers` (list, create, read, update,++delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:code`). Deleting an account removes its++memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when++nobody is.++++**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/servers`, `/s/:slug[/:channel]`,++`/s/:slug/settings` and `/join/:code` (servers, above), `/messages` the DM inbox(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),`/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Blockthe other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation--counts unread messages and room messages. Everything is server-rendered and works without JavaScript.++counts unread messages, room messages and server messages. Everything is server-rendered and works without JavaScript.**Working with Live down.** Nothing on openvibe.chat waits on Live: people sign in from their Network tokenand the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and@@@ -469,7 +491,8 @@ docs/ calls-cutover.md, parity.md, chat-ingress.md, capabil## Owns--- room types: global, stream, channel, community, DM, group-DM, call, system++- room types: global, stream, channel, community, DM, group-DM, call, system; servers (groups of rooms as text and++ voice channels, with server roles and invites)- messages, replies/rich payloads, membership/roles, unread/read state, presence/typing (ephemeral)- bans, timeouts, delete/purge, filters, slow/member-only modes- TTS/audio/soundboard/media-request queues with skip/clear/failure lifecyclennew file mode 100644@@@ -0,0 +1,50 @@++-- phase: expand++-- Servers (owner 2026-09-28 Q14: friend groups and couples first, then communities with servers, channels and voice):++-- a server is a group of rooms, its channels, with one membership and one set of roles for all of them++-- (server/rooms/servers.js). A channel is an ordinary room with server_id set: text channels are community rooms,++-- voice channels are call rooms, so messages, live delivery, calls and AI members work in them unchanged. A++-- channel's room_members rows only keep where each person read up to; what they may do comes from the server.++CREATE TABLE IF NOT EXISTS chat_servers (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ slug text COLLATE "C" UNIQUE NOT NULL, -- /s/<slug>++ name text COLLATE "C" NOT NULL, -- 2-40 characters++ description text COLLATE "C", -- <= 300 characters++ visibility text COLLATE "C" NOT NULL DEFAULT 'public',-- public: listed, anyone reads, signed-in people join; private: invites only++ owner_id bigint NOT NULL,++ owner_subject text COLLATE "C", -- usr_… (NULL once that account is deleted)++ member_count integer NOT NULL DEFAULT 0,++ created_at text COLLATE "C" DEFAULT ov_now(),++ updated_at text COLLATE "C" DEFAULT ov_now(),++ archived_at text COLLATE "C",++ CHECK (visibility IN ('public', 'private'))++);++CREATE INDEX IF NOT EXISTS idx_chat_servers_public ON chat_servers (visibility, member_count DESC) WHERE archived_at IS NULL;++CREATE INDEX IF NOT EXISTS idx_chat_servers_owner ON chat_servers (owner_id);++++CREATE TABLE IF NOT EXISTS chat_server_members (++ server_id bigint NOT NULL REFERENCES chat_servers(id) ON DELETE CASCADE,++ user_id bigint NOT NULL,++ role text COLLATE "C" NOT NULL DEFAULT 'member', -- owner | admin | mod | member | blocked++ joined_at text COLLATE "C" DEFAULT ov_now(),++ PRIMARY KEY (server_id, user_id),++ CHECK (role IN ('owner', 'admin', 'mod', 'member', 'blocked'))++);++CREATE INDEX IF NOT EXISTS idx_chat_server_members_user ON chat_server_members (user_id);++++CREATE TABLE IF NOT EXISTS chat_server_invites (++ code text COLLATE "C" PRIMARY KEY, -- 10 characters, /join/<code>++ server_id bigint NOT NULL REFERENCES chat_servers(id) ON DELETE CASCADE,++ created_by bigint NOT NULL,++ created_by_subject text COLLATE "C",++ uses integer NOT NULL DEFAULT 0,++ max_uses integer, -- NULL: no limit++ expires_at text COLLATE "C", -- NULL: never++ revoked_at text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now()++);++CREATE INDEX IF NOT EXISTS idx_chat_server_invites_server ON chat_server_invites (server_id);++++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS server_id bigint REFERENCES chat_servers(id) ON DELETE CASCADE;++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS channel_slug text COLLATE "C"; -- the channel's name in /s/<server>/<channel>++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS position integer NOT NULL DEFAULT 0;++CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_server_channel ON rooms (server_id, channel_slug) WHERE server_id IS NOT NULL AND archived_at IS NULL;@@@ -124,3 +124,98 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-ai-member h3 { margin: 0 0 6px; font-size: 1rem; }.oc-ai-form textarea { width: 100%; box-sizing: border-box; }@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .oc-badge.oc-ai { color: #6d28d9; border-color: rgba(109, 40, 217, .45); } :root:not([data-theme="dark"]) .oc-ai-name { color: #5b21b6; } }++++/* Servers (server/web/server-pages.js): your servers down the side, the channels, the channel, the people. */++.oc-main--wide { max-width: 1440px; }++.oc-srv { display: grid; grid-template-columns: 64px 232px minmax(0, 1fr) 212px; height: min(80vh, 900px); min-height: 480px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; overflow: hidden; }++.oc-srv-rail { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px 0; background: var(--oc-bg); border-right: 1px solid var(--oc-line); overflow-y: auto; scrollbar-width: none; }++.oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; font-weight: 800; font-size: .92rem; letter-spacing: .02em; text-decoration: none; transition: border-radius .15s ease, background-color .15s ease; }++.oc-srv-icon:hover, .oc-srv-icon:focus-visible, .oc-srv-icon.is-active { border-radius: 14px; }++.oc-srv-rail .oc-srv-icon.is-active::before { content: ''; position: absolute; left: -10px; top: 9px; bottom: 9px; width: 4px; border-radius: 0 4px 4px 0; background: var(--oc-text); }++.oc-srv-add, .oc-srv-explore { background: var(--oc-panel-2); color: var(--oc-ok); font-size: 1.35rem; font-weight: 500; }++.oc-srv-explore { color: var(--oc-accent); font-size: 1.15rem; }++.oc-srv-add:hover, .oc-srv-explore:hover { background: var(--oc-panel); }++.oc-srv-dot, .oc-chan-dot { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--oc-red); color: #1a0505; font-size: .66rem; font-weight: 800; line-height: 18px; text-align: center; }++.oc-srv-dot { position: absolute; right: -5px; bottom: -4px; border: 2px solid var(--oc-bg); line-height: 14px; }++.oc-srv-side { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 14px 10px; overflow-y: auto; border-right: 1px solid var(--oc-line); }++.oc-srv-head { padding: 0 6px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--oc-line); }++.oc-srv-head .oc-badge { margin-left: 0; }++.oc-srv-name { font-size: 1.08rem; line-height: 1.3; margin: 0 0 2px; overflow-wrap: anywhere; }++.oc-srv-meta { margin: 2px 0 0; font-size: .82rem; }++.oc-srv-desc { margin: 6px 0 0; font-size: .86rem; color: var(--oc-muted); }++.oc-srv-channels > summary { display: none; }++.oc-srv-group { margin: 14px 8px 4px; font-size: .7rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--oc-muted); }++.oc-srv-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }++.oc-chan { display: flex; align-items: center; gap: 7px; padding: 5px 8px; border-radius: 8px; color: var(--oc-muted); text-decoration: none; }++.oc-chan:hover, .oc-chan:focus-visible { background: var(--oc-panel-2); color: var(--oc-text); }++.oc-chan.is-active { background: color-mix(in srgb, var(--oc-accent) 18%, transparent); color: var(--oc-text); }++.oc-chan.is-unread { color: var(--oc-text); font-weight: 700; }++.oc-chan-hash { width: 16px; text-align: center; font-weight: 700; opacity: .75; }++.oc-chan-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-chan-in { font-size: .78rem; }++.oc-ico { flex: 0 0 auto; vertical-align: -3px; }++.oc-srv-join, .oc-srv-invite { margin: 14px 6px 0; }++.oc-srv-join button, .oc-srv-invite .oc-button { width: 100%; text-align: center; }++.oc-button--quiet { background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); }++.oc-button--quiet:hover { border-color: var(--oc-accent); }++.oc-srv-tools { margin: auto 6px 0; padding-top: 14px; font-size: .86rem; }++.oc-srv-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: color-mix(in srgb, var(--oc-panel-2) 60%, var(--oc-panel)); }++.oc-srv-main .oc-msg:hover { background: color-mix(in srgb, var(--oc-text) 5%, transparent); }++.oc-chan-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 12px 18px; border-bottom: 1px solid var(--oc-line); }++.oc-chan-head h2 { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 1.04rem; }++.oc-chan-topic { min-width: 0; font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here { margin: 10px 18px 0; }++.oc-srv-main > .oc-call { margin: 12px 18px 0; }++.oc-srv-feed { flex: 1; min-height: 0; max-height: none; margin: 0; padding: 10px 8px; background: transparent; border: 0; border-radius: 0; }++.oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { margin: 0; padding: 12px 18px 16px; border-top: 1px solid var(--oc-line); }++.oc-srv-people { min-width: 0; padding: 14px 12px; overflow-y: auto; border-left: 1px solid var(--oc-line); }++.oc-srv-people > details > summary { display: none; }++.oc-srv-people .oc-srv-group { margin-top: 4px; }++.oc-srv-people .oc-srv-group ~ .oc-srv-group { margin-top: 16px; }++.oc-srv-people-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }++.oc-srv-people-list a { display: block; padding: 3px 8px; border-radius: 6px; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-srv-people-list a:hover { background: var(--oc-panel-2); }++/* The directory, an invite, settings */++.oc-srv-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }++.oc-srv-card a { display: flex; align-items: center; gap: 12px; height: 100%; padding: 12px 14px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; color: var(--oc-text); text-decoration: none; }++.oc-srv-card a:hover, .oc-srv-card a:focus-visible { border-color: var(--oc-accent); }++.oc-srv-card-text { display: flex; flex-direction: column; min-width: 0; }++.oc-srv-card-text .oc-muted { font-size: .88em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.oc-srv-icon--big { width: 72px; height: 72px; margin: 0 auto 6px; border-radius: 22px; font-size: 1.5rem; }++.oc-invite-card { max-width: 440px; margin: 32px auto; padding: 28px 24px; text-align: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; }++.oc-invite-card h1 { margin-top: 0; }++.oc-invite-card form, .oc-invite-card .oc-notice { margin-top: 16px; }++.oc-invite-url { font-size: .85em; overflow-wrap: anywhere; }++.oc-chan-rows li { align-items: flex-start; }++.oc-chan-rows li > span { flex: 1 1 200px; min-width: 0; }++.oc-chan-edit { flex-basis: 100%; }++.oc-chan-edit summary { cursor: pointer; color: var(--oc-accent); font-size: .9rem; }++.oc-chan-edit .oc-form { margin-top: 8px; }++.oc-danger { background: var(--oc-red); color: #1a0505; }++.oc-link.oc-danger-link { color: var(--oc-red); margin-left: 8px; }++@media (max-width: 1180px) {++ .oc-srv { grid-template-columns: 64px 220px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; height: auto; min-height: 0; }++ .oc-srv-rail, .oc-srv-side, .oc-srv-main { height: min(78vh, 860px); }++ .oc-srv-people { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--oc-line); max-height: 260px; }++ .oc-srv-people > details > summary { display: list-item; cursor: pointer; font-weight: 700; margin-bottom: 6px; }++ .oc-srv-people-list { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }++}++@media (max-width: 760px) {++ .oc-srv { grid-template-columns: minmax(0, 1fr); border-radius: 12px; }++ .oc-srv-rail, .oc-srv-side, .oc-srv-main { height: auto; }++ .oc-srv-rail { flex-direction: row; justify-content: flex-start; padding: 10px 12px; gap: 10px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); }++ .oc-srv-rail .oc-srv-icon.is-active::before { left: 9px; right: 9px; top: auto; bottom: -10px; width: auto; height: 4px; border-radius: 4px 4px 0 0; }++ .oc-srv-side { border-right: 0; border-bottom: 1px solid var(--oc-line); padding: 12px; }++ .oc-srv-channels > summary { display: list-item; cursor: pointer; font-weight: 700; padding: 4px 6px; }++ .oc-srv-tools { margin-top: 8px; }++ .oc-srv-feed { max-height: 62vh; min-height: 220px; }++ .oc-chan-head, .oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { padding-left: 12px; padding-right: 12px; }++ .oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here, .oc-srv-main > .oc-call { margin-left: 12px; margin-right: 12px; }++}++++.oc-srv-card-title { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }++.oc-srv-card-title .oc-badge { margin-left: 0; }++.oc-badge-new { color: var(--oc-text); border-color: var(--oc-red); }++.oc-moves { display: inline-flex; gap: 4px; margin-left: auto; }++.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }++.oc-move:hover { border-color: var(--oc-accent); }@@@ -259,7 +259,7 @@} 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);++ showError('You can no longer read this room.'); setTimeout(function () { location.href = cfg.back || '/rooms'; }, 1500);} else if (m.type === 'room_access' && m.room === cfg.room && m.can) {// Your role changed (a moderator made you a member, a speaker, a viewer…): the page shows what you may do now.if (!!m.can.post !== !!cfg.post || (m.role || null) !== (cfg.role || null)) { showError('Your role in this room changed.'); setTimeout(function () { location.reload(); }, 1200); }@@@ -329,6 +329,9 @@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(); }});++ // A server's channel list folds away on a phone: the channel's own header says where you are.++ var chans = document.querySelector ? document.querySelector('.oc-srv-channels') : null;++ if (chans && window.matchMedia && window.matchMedia('(max-width: 760px)').matches) chans.open = false;toBottom();if (cfg.view === 'global') startGlobal();else if (cfg.view === 'dm') startDm();@@@ -242,6 +242,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);// 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/servers', require('./rooms/server-routes'));// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.app.use('/api/chat/channels', require('./chat/channel-mod-routes'));// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.@@@ -12,7 +12,8 @@* - chat, room and direct messages they sent, their DM memberships (a* conversation left with nobody goes), room memberships and attachments;* - rooms they own go when nobody else is in them, and otherwise stay without an-- * owner; calls they made or took, and blocks both ways;++ * owner (servers they own too, with their channels); their server memberships;++ * calls they made or took, and blocks both ways;* - their channel's emotes, sounds, moderators, first-chat stats and audio* requests, their tags, and the AI summaries and timeline about them;* - the channel, stream and user mirrors of their Live account (the user mirror@@@ -47,7 +48,10 @@ const EXPORTS = [['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],['direct_messages.json', `SELECT * FROM dm_messages WHERE sender_subject_id = ANY(@s) OR sender_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],['conversations.json', `SELECT * FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],-- ['rooms_owned.json', `SELECT * FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['rooms_owned.json', `SELECT * FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL ORDER BY id DESC LIMIT ${LIMIT}`],++ ['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],++ ['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.joined_at FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id WHERE m.user_id = ANY(@ids) ORDER BY m.joined_at DESC LIMIT ${LIMIT}`],++ ['server_invites_made.json', `SELECT code, server_id, uses, max_uses, expires_at, revoked_at, created_at FROM chat_server_invites WHERE created_by_subject = ANY(@s) OR created_by = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],['dm_blocks.json', `SELECT * FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],@@@ -117,7 +121,22 @@ async function erase(d, subjects) {const del = async ([key, text]) => { const st = d.prepare(text); add(erased, key, (await (text.includes('@') ? st.run(p) : st.run())).changes); };await d.tx(async () => {for (const e of ERASE_FIRST) await del(e);-- for (const r of await d.prepare('SELECT id FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)').all(p)) {++ // Servers (migration 0013): their memberships go; a server they own stays, without an owner, while anyone else++ // is in it, and goes with its channels when nobody is; invites they made stay, without their name.++ add(erased, 'server_memberships', (await d.prepare("DELETE FROM chat_server_members WHERE user_id = ANY(@ids) AND role != 'owner'").run(p)).changes);++ for (const sv of await d.prepare('SELECT id FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)').all(p)) {++ const others = await d.prepare("SELECT 1 FROM chat_server_members WHERE server_id = ? AND role != 'owner' AND role != 'blocked' LIMIT 1").get(sv.id);++ await d.prepare("DELETE FROM chat_server_members WHERE server_id = ? AND role = 'owner'").run(sv.id);++ if (others) { await d.prepare('UPDATE chat_servers SET owner_subject = NULL WHERE id = ?').run(sv.id); add(retained, 'servers_without_owner', 1); }++ else {++ await d.prepare('DELETE FROM room_messages WHERE room_id IN (SELECT id FROM rooms WHERE server_id = ?)').run(sv.id);++ await d.prepare('DELETE FROM rooms WHERE server_id = ?').run(sv.id);++ await d.prepare('DELETE FROM chat_servers WHERE id = ?').run(sv.id);++ add(erased, 'servers', 1);++ }++ }++ await d.prepare('UPDATE chat_server_invites SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)').run(p);++ for (const r of await d.prepare('SELECT id FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL').all(p)) {const others = await d.prepare('SELECT 1 FROM room_members WHERE room_id = ? LIMIT 1').get(r.id);if (others) { await d.prepare('UPDATE rooms SET owner_subject = NULL WHERE id = ?').run(r.id); add(retained, 'rooms_without_owner', 1); }else {@@@ -99,6 +99,7 @@ async function publicRoom(r, extra = {}) {...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,created_at: r.created_at, members: (await db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,++ ...(r.server_id ? { server: { slug: (await db.get('SELECT slug FROM chat_servers WHERE id = ?', [r.server_id]) || {}).slug || null, channel: r.channel_slug } } : {}),...extra,};}@@@ -113,6 +114,8 @@ async function bySlug(slug) {* `join` and `talk` are the room's call (call rooms only).*/async function access(room, user) {++ // A server's channel: the server decides (server/rooms/servers.js).++ if (room.server_id) return await require('./servers').channelAccess(room, user);const kind = kindOf(room);const role = user ? await roleOf(room.id, user.id) : null;const staff = isStaff(user);@@@ -140,7 +143,7 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = 'if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');-- const owned = (await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id])).n;++ const owned = (await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL AND server_id 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 (await bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;@@@ -157,20 +160,22 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = 'async function list(user, { limit = 50 } = {}) {await ensureSchema();const lim = Math.min(100, Math.max(1, Number(limit) || 50));-- const pub = (await Promise.all((await 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(async (r) => await publicRoom(r))));++ // A server's channels are listed with their server (server/rooms/servers.js), not here.++ const pub = (await Promise.all((await db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL AND server_id IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim])).map(async (r) => await publicRoom(r))));const mine = user ? (await Promise.all((await db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,(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 unreadFROM 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++ WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL AND r.server_id IS NULLORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id])).map(async (r) => await publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })))) : [];return { public: pub, mine };}/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */--async function unreadTotal(user) {++async function unreadTotal(user, { servers = false } = {}) {if (!user) return 0;await ensureSchema();-- return (await db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL++ // Rooms count their own unread messages; servers (servers: true) count their channels'.++ return (await db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL AND r.server_id IS ${servers ? 'NOT ' : ''}NULLJOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_idWHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;}@@@ -178,6 +183,7 @@ async function unreadTotal(user) {/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */async function join(room, user) {if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');++ if (room.server_id) fail(409, 'rooms.server_channel', 'This is a server\'s channel: join the server to post in it');const role = await roleOf(room.id, user.id);if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');if (role) return role;@@@ -190,6 +196,7 @@ async function join(room, user) {}async function leave(room, user) {++ if (room.server_id) fail(409, 'rooms.server_channel', 'This is a server\'s channel: leave the server instead');const role = await roleOf(room.id, user && user.id);if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');if (role && role !== 'blocked') await db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);@@@ -310,6 +317,10 @@ async function markRead(room, user, lastId) {if (!user) return;const top = (await 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);++ // A server channel keeps a read row for each member (made on joining; a channel made later gets one here).++ if (room.server_id && (await access(room, user)).role) {++ await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, 'member', ?, ov_now()) ON CONFLICT (room_id, user_id) DO NOTHING", [room.id, user.id, to]);++ }await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);}@@@ -333,6 +344,7 @@ async function deleteMessage(room, user, messageId) {* chat staff's) to appoint and remove; everything else is any moderator's.*/async function setRole(room, actor, targetUserId, role) {++ if (room.server_id) fail(409, 'rooms.server_channel', 'Roles in a server\'s channels are set for the whole server');const kind = kindOf(room);if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');@@@ -358,10 +370,12 @@ async function setRole(room, actor, targetUserId, role) {return role;}--async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {++async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}, { fromServer = false, minName = 3 } = {}) {if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');++ // A server's channel follows the server's visibility, and is changed through the server (servers.js).++ if (room.server_id && !fromServer && visibility != null && visibility !== room.visibility) fail(409, 'rooms.server_channel', 'A channel is as public as its server: change the server instead');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 (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < minName || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', `Room names are ${minName} 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; }@@@ -381,6 +395,13 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */async function members(room, { seen = false } = {}) {++ // A server's channel: the server's people, with the role each has in the channel.++ if (room.server_id) {++ const server = await require('./servers').byId(room.server_id);++ if (!server) return [];++ const as = (r) => (r === 'owner' ? 'owner' : r === 'admin' || r === 'mod' ? 'mod' : kindOf(room) === 'call' ? joinRoleOf(room) : 'member');++ return (await require('./servers').members(server)).map((m) => ({ user_id: m.user_id, role: as(m.role), server_role: m.role, username: m.username, display_name: m.display_name }));++ }const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;return await db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_idWHERE m.room_id = ? ORDER BY ${order}, lower(u.username) LIMIT 500`, [room.id]);@@@ -437,6 +458,6 @@ function _reset() { _rate.clear(); }module.exports = {postAsAi,ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,-- attach, detach, attachments, joinRoleOf, unreadTotal,++ attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, POSTERS, TALKERS,RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,};nnew file mode 100644@@@ -0,0 +1,181 @@++'use strict';++/**++ * /api/chat/servers — servers over REST (server/rooms/servers.js); openvibe.chat and bots use it. A channel is a room,++ * so its messages, history, reads and calls are /api/chat/rooms/<channel.room> and /ws/chat join_room as usual.++ *++ * GET / public servers by size + the caller's servers (with unread counts)++ * POST / create { name, slug?, description?, visibility? } (with #general and a voice Lounge)++ * GET /:slug the server, its channels (members: unread counts), what the caller may do++ * PATCH /:slug owner/admins: { name?, description?, visibility? } (channels follow it)++ * DELETE /:slug the owner (or chat staff): the server and its channels are archived++ * POST /:slug/join { invite? }: a public server, or any with a valid invite → { role }++ * POST /:slug/leave++ * GET /:slug/members (moderators also see who is banned)++ * POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }++ * POST /:slug/channels owner/admins: { name, kind: text | voice, topic? }++ * PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }++ * DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)++ * GET /:slug/invites owner/admins: the live invites++ * POST /:slug/invites { max_uses?, expires_hours? } → { invite: { code, … }, url }++ * DELETE /:slug/invites/:code++ * GET /invites/:code what an invite opens (anyone with the code): the server's name and size++ *++ * A private server is a 404 to anyone who is not in it, whatever they ask.++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { requireAuth, optionalAuth } = require('../auth/auth');++const { limits } = require('../net/actor-limits');++const rooms = require('./rooms');++const servers = require('./servers');++const ctx = require('../live-context');++++const router = express.Router();++const createLimit = limits('chat.server.create', { minute: 5, hour: 20 });++const manageLimit = limits('chat.server.manage', { minute: 30, hour: 300 });++const joinLimit = limits('chat.server.join', { minute: 20, hour: 200 });++++function send(res, err) {++ if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });++ console.error('[Servers]', err && err.message);++ return res.status(500).json({ error: 'Something went wrong' });++}++const chatServer = () => require('../chat/chat-server');++const roomRoutes = () => require('./routes');++const siteUrl = () => { try { return require('../config').web.baseUrl; } catch { return 'https://openvibe.chat'; } };++++/** After a membership, role or visibility change: every channel's sockets and calls follow the server. */++async function applyServerChange(server) {++ for (const r of await require('../db/database').all('SELECT * FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id])) {++ try { await roomRoutes().applyRoomChange(r); } catch (err) { console.warn('[Servers] room update:', err.message); }++ }++}++++async function readable(req, res) {++ const server = await servers.bySlug(req.params.slug);++ if (!server || !(await servers.access(server, req.user || null)).read) { res.status(404).json({ error: 'No such server', code: 'servers.not_found' }); return null; }++ return server;++}++async function channelOf(req, res, server) {++ const room = await servers.channelBySlug(server, req.params.channel);++ if (!room) { res.status(404).json({ error: 'No such channel', code: 'servers.no_channel' }); return null; }++ return room;++}++const canOf = (a) => ({ read: a.read, member: a.member, moderate: a.moderate, manage: a.manage, owner: a.owner });++++router.get('/', optionalAuth, async (req, res) => {++ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await servers.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }++});++++router.post('/', requireAuth, createLimit, async (req, res) => {++ try { res.status(201).json({ server: await servers.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }++});++++router.get('/invites/:code', optionalAuth, async (req, res) => {++ const found = await servers.inviteByCode(req.params.code);++ if (!found) return res.status(404).json({ error: 'That invite has expired or was used up', code: 'servers.invite_invalid' });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ server: servers.publicServer(found.server), invite: { code: found.invite.code, expires_at: found.invite.expires_at || null } });++});++++router.get('/:slug', optionalAuth, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const a = await servers.access(server, req.user || null);++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({++ server: servers.publicServer(server, { role: a.role }),++ channels: await servers.channels(server, a.member ? req.user : null),++ can: canOf(a),++ });++});++++router.patch('/:slug', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ try { const out = await servers.update(server, req.user, req.body || {}); await applyServerChange(out); res.json({ server: servers.publicServer(out) }); } catch (err) { send(res, err); }++});++++router.delete('/:slug', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ try { await servers.archive(server, req.user); await applyServerChange(server); res.status(204).end(); } catch (err) { send(res, err); }++});++++router.post('/:slug/join', requireAuth, joinLimit, async (req, res) => {++ const server = await servers.bySlug(req.params.slug);++ if (!server) return res.status(404).json({ error: 'No such server', code: 'servers.not_found' });++ try { const role = await servers.join(server, req.user, { invite: req.body && req.body.invite }); await applyServerChange(server); res.json({ ok: true, role }); } catch (err) { send(res, err); }++});++++router.post('/:slug/leave', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ try { await servers.leave(server, req.user); await applyServerChange(server); res.json({ ok: true }); } catch (err) { send(res, err); }++});++++router.get('/:slug/members', optionalAuth, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const a = await servers.access(server, req.user || null);++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ members: await servers.members(server, { blocked: a.moderate }) });++});++++router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ try {++ const name = String((req.body && req.body.username) || '').trim();++ const subject = String((req.body && req.body.subject) || '').replace(/^user:/, '').trim();++ let target = null;++ if (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) target = await ctx.getUserBySubject(subject);++ else if (/^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.getUserByUsername(name) || await ctx.ensureUserByUsername(name).catch(() => null);++ if (!target) return res.status(404).json({ error: subject ? 'No such OpenVibe account' : `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'servers.no_user' });++ const role = await servers.setRole(server, req.user, target.id, String((req.body && req.body.role) || 'member'));++ if (role === 'blocked' || role == null) {++ for (const r of await require('../db/database').all('SELECT id FROM rooms WHERE server_id = ?', [server.id])) chatServer().removeFromRoom(r.id, target.id);++ }++ await applyServerChange(server);++ res.json({ ok: true, user_id: target.id, username: target.username, role });++ } catch (err) { send(res, err); }++});++++router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ try {++ const b = req.body || {};++ const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic });++ res.status(201).json({ channel: servers.publicChannel(room) });++ } catch (err) { send(res, err); }++});++++router.patch('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await channelOf(req, res, server); if (!room) return;++ try {++ const out = await servers.updateChannel(server, req.user, room, req.body || {});++ await roomRoutes().applyRoomChange(out);++ res.json({ channel: servers.publicChannel(out) });++ } catch (err) { send(res, err); }++});++++router.delete('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const room = await channelOf(req, res, server); if (!room) return;++ try { await servers.deleteChannel(server, req.user, room); res.status(204).end(); } catch (err) { send(res, err); }++});++++router.get('/:slug/invites', requireAuth, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ const a = await servers.access(server, req.user);++ if (!a.moderate) return res.status(403).json({ error: 'Only the server\'s owner, admins and moderators see its invites', code: 'servers.not_mod' });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ invites: await servers.invites(server) });++});++++router.post('/:slug/invites', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ try {++ const invite = await servers.createInvite(server, req.user, req.body || {});++ res.status(201).json({ invite, url: `${siteUrl()}/join/${invite.code}` });++ } catch (err) { send(res, err); }++});++++router.delete('/:slug/invites/:code', requireAuth, manageLimit, async (req, res) => {++ const server = await readable(req, res); if (!server) return;++ try { await servers.revokeInvite(server, req.user, req.params.code); res.status(204).end(); } catch (err) { send(res, err); }++});++++module.exports = router;++module.exports.applyServerChange = applyServerChange;nnew file mode 100644@@@ -0,0 +1,417 @@++'use strict';++/**++ * Servers (migration 0013; owner 2026-09-28 Q14: friend groups and couples first, then communities with servers,++ * channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles:++ *++ * chat_servers slug (/s/<slug>), name, description, visibility (public: listed, anyone reads, signed-in++ * people join; private: only people with an invite), owner++ * chat_server_members role: owner (the creator, one), admin, mod, member, blocked (banned from the server)++ * chat_server_invites /join/<code>: any number of uses or a cap, never expiring or after a while, revocable++ * rooms.server_id a channel: a text channel is a community room, a voice channel a call room (its call is++ * the room's call, so voice works like any call room: who talks follows the role)++ *++ * What a person may do in a channel comes from the server (channelAccess, which rooms.access() calls for any room++ * with a server): the room's own room_members rows only keep where each person read up to.++ *++ * server role read post voice: talk moderate (delete, kick, ban) manage (channels, settings, invites)++ * owner yes yes yes yes yes, and appoints admins++ * admin yes yes yes yes yes, and appoints mods++ * mod yes yes yes yes (members only) -++ * member yes yes as the channel says (speakers by default) - -++ * none public server: read and listen; private server: nothing, not even that it exists++ * blocked nothing++ * chat staff (staff.moderation.chat): read, moderate and manage every server; a site ban: no posting, no talking++ *++ * Every action on another person is logged with db.logModerationAction (scope server:<slug>).++ */++const crypto = require('crypto');++const db = require('../db/database');++const ctx = require('../live-context');++const wordFilter = require('../chat/word-filter');++const rooms = require('./rooms');++++const { RoomError } = rooms;++const fail = (status, code, message) => { throw new RoomError(status, code, message); };++++const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;++const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invites', 'api', 'admin', 'staff', 'help', 'explore', 'discover', 'openvibe']);++const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];++const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };++const CHANNEL_KINDS = { text: 'community', voice: 'call' };++/** Channel names the server's own pages use (/s/<server>/settings …). */++const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);++const MAX_SERVERS_PER_OWNER = 5;++const MAX_CHANNELS = 50;++const MAX_INVITES = 25;++const INVITE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';++++const isStaff = (user) => rooms.isStaff(user);++++function slugify(name) {++ return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');++}++const cleanName = (v, min, max) => {++ const n = String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();++ return n.length >= min && n.length <= max ? n : null;++};++const randomCode = (n) => Array.from(crypto.randomBytes(n), (b) => INVITE_ALPHABET[b % INVITE_ALPHABET.length]).join('');++++async function bySlug(slug) { return await db.get('SELECT * FROM chat_servers WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null; }++async function byId(id) { return await db.get('SELECT * FROM chat_servers WHERE id = ? AND archived_at IS NULL', [Number(id)]) || null; }++async function roleIn(serverId, userId) {++ if (!userId) return null;++ return (await db.get('SELECT role FROM chat_server_members WHERE server_id = ? AND user_id = ?', [serverId, userId]) || {}).role || null;++}++++/** What `user` may do in the server itself: { role, read, member, moderate, manage, owner }. */++async function access(server, user) {++ const role = user ? await roleIn(server.id, user.id) : null;++ const staff = isStaff(user);++ const blocked = role === 'blocked';++ const member = !!role && !blocked;++ return {++ role, member, staff,++ read: !blocked && (server.visibility === 'public' || member || staff),++ moderate: !!user && !blocked && (['owner', 'admin', 'mod'].includes(role) || staff),++ manage: !!user && !blocked && (['owner', 'admin'].includes(role) || staff),++ owner: !!user && role === 'owner',++ };++}++++/**++ * What `user` may do in a channel (a room with server_id): the room's access shape (rooms.access), decided by the++ * server role. A channel of a server that is gone reads as nothing.++ */++async function channelAccess(room, user) {++ const kind = room.kind === 'call' ? 'call' : 'community';++ const server = await byId(room.server_id);++ if (!server) return { role: null, kind, read: false, post: false, join: false, talk: false, moderate: false, manage: false, server: null, server_role: null };++ const a = await access(server, user);++ const srole = a.role;++ // Server owner → room owner; admins and moderators → room moderators; members → the channel's member role.++ const vrole = !a.member ? null : srole === 'owner' ? 'owner' : RANK[srole] <= RANK.mod ? 'mod' : kind === 'call' ? rooms.joinRoleOf(room) : 'member';++ const banned = rooms.siteBanned(user);++ const post = !!user && a.read && !banned && a.member && rooms.POSTERS[kind].has(vrole);++ const join = kind === 'call' && a.read;++ const talk = join && !!user && !banned && ((a.member && rooms.TALKERS.has(vrole)) || a.staff);++ return { role: vrole, kind, read: a.read, post, join, talk, moderate: a.moderate, manage: a.manage, server: server.slug, server_role: srole };++}++++function publicServer(s, extra = {}) {++ if (!s) return null;++ return {++ id: Number(s.id), slug: s.slug, name: s.name, description: s.description || null, visibility: s.visibility,++ members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,++ };++}++++/** A channel as the server shows it. */++function publicChannel(r, extra = {}) {++ return {++ slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.kind === 'call' ? 'voice' : 'text',++ position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,++ ...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: rooms.joinRoleOf(r) } : {}), ...extra,++ };++}++++/** The server's channels, text first, each kind in its order. `user` adds unread counts (members). */++async function channels(server, user = null) {++ const rows = await db.all(`SELECT r.* ${user ? ', m.last_read_id' : ''} FROM rooms r++ ${user ? 'LEFT JOIN room_members m ON m.room_id = r.id AND m.user_id = ?' : ''}++ WHERE r.server_id = ? AND r.archived_at IS NULL ORDER BY CASE r.kind WHEN 'call' THEN 1 ELSE 0 END, r.position, r.id`,++ user ? [user.id, server.id] : [server.id]);++ if (!user) return rows.map((r) => publicChannel(r));++ const out = [];++ for (const r of rows) {++ const unread = r.kind === 'call' && !r.message_count ? 0 : Number((await db.get(++ 'SELECT COUNT(*) AS n FROM room_messages WHERE room_id = ? AND id > ? AND is_deleted = 0 AND user_id != ?', [r.id, Number(r.last_read_id) || 0, user.id]) || {}).n) || 0;++ out.push(publicChannel(r, { unread: r.last_read_id == null ? Math.min(unread, 99) : unread }));++ }++ return out;++}++async function channelBySlug(server, channelSlug) {++ return await db.get('SELECT * FROM rooms WHERE server_id = ? AND channel_slug = ? AND archived_at IS NULL', [server.id, String(channelSlug || '').toLowerCase()]) || null;++}++++async function insertChannel(server, user, { name, kind, topic = null, position = null }) {++ const n = cleanName(name, 1, 40);++ if (!n) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');++ if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');++ const roomKind = CHANNEL_KINDS[kind];++ if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');++ const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;++ if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);++ let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');++ if (cs.length < 2 || CHANNEL_RESERVED.has(cs)) cs = `${cs}-${kind}`;++ for (let i = 2; await channelBySlug(server, cs); i++) cs = `${slugify(n).slice(0, 28) || kind}-${i}`;++ let roomSlug;++ do { roomSlug = `ch${randomCode(12)}`; } while (await db.get('SELECT 1 FROM rooms WHERE slug = ?', [roomSlug]));++ const pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;++ const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;++ // The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.++ const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position)++ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)`,++ [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? 'speaker' : null, server.id, cs, pos]);++ return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);++}++++async function create(user, { name, slug, description, visibility = 'public' } = {}) {++ if (!user) fail(401, 'servers.sign_in', 'Sign in to create a server');++ if (rooms.siteBanned(user)) fail(403, 'servers.banned', 'Your account is banned from chat');++ const n = cleanName(name, 2, 40);++ if (!n) fail(422, 'servers.name', 'Server names are 2 to 40 characters');++ if (wordFilter.check(n).safe === false) fail(422, 'servers.name_filtered', 'That server name is not allowed');++ const s = slug ? String(slug).toLowerCase().trim() : slugify(n);++ if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'servers.slug', 'Server addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');++ if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private');++ const d = description ? String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null : null;++ if (d && wordFilter.check(d).safe === false) fail(422, 'servers.description_filtered', 'That description is not allowed');++ const owned = Number((await db.get('SELECT COUNT(*) AS n FROM chat_servers WHERE owner_id = ? AND archived_at IS NULL', [user.id]) || {}).n) || 0;++ if (owned >= MAX_SERVERS_PER_OWNER && !isStaff(user)) fail(429, 'servers.too_many', `You can own up to ${MAX_SERVERS_PER_OWNER} servers`);++ if (await db.get('SELECT 1 FROM chat_servers WHERE slug = ?', [s])) fail(409, 'servers.taken', 'That server address is taken');++ const id = await db.tx(async () => {++ const r = await db.run('INSERT INTO chat_servers (slug, name, description, visibility, owner_id, owner_subject, member_count) VALUES (?, ?, ?, ?, ?, ?, 1)',++ [s, n, d, visibility, user.id, user.subject_id || null]);++ const server = await db.get('SELECT * FROM chat_servers WHERE id = ?', [r.lastInsertRowid]);++ await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'owner')", [server.id, user.id]);++ await insertChannel(server, user, { name: 'general', kind: 'text', topic: 'Say hello', position: 0 });++ await insertChannel(server, user, { name: 'Lounge', kind: 'voice', position: 0 });++ await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) SELECT id, ?, 'member', 0, ov_now() FROM rooms WHERE server_id = ?", [user.id, server.id]);++ return Number(server.id);++ });++ return publicServer(await byId(id), { role: 'owner' });++}++++/** The servers `user` is in (with unread counts), and public servers by size. */++async function list(user, { limit = 50 } = {}) {++ const lim = Math.min(100, Math.max(1, Number(limit) || 50));++ const pub = (await db.all("SELECT * FROM chat_servers WHERE visibility = 'public' AND archived_at IS NULL ORDER BY member_count DESC, id DESC LIMIT ?", [lim])).map((s) => publicServer(s));++ if (!user) return { public: pub, mine: [] };++ const rows = await db.all(`SELECT s.*, m.role FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id++ WHERE m.user_id = ? AND m.role != 'blocked' AND s.archived_at IS NULL ORDER BY m.joined_at, s.id`, [user.id]);++ const mine = [];++ for (const s of rows) {++ const unread = Number((await db.get(`SELECT COUNT(*) AS n FROM rooms r JOIN room_members rm ON rm.room_id = r.id AND rm.user_id = ?++ JOIN room_messages x ON x.room_id = r.id AND x.id > rm.last_read_id AND x.is_deleted = 0 AND x.user_id != ?++ WHERE r.server_id = ? AND r.archived_at IS NULL`, [user.id, user.id, s.id]) || {}).n) || 0;++ mine.push(publicServer(s, { role: s.role, unread }));++ }++ return { public: pub, mine };++}++++async function syncCount(server) {++ await db.run("UPDATE chat_servers SET member_count = (SELECT COUNT(*) FROM chat_server_members WHERE server_id = ? AND role != 'blocked'), updated_at = ov_now() WHERE id = ?", [server.id, server.id]);++}++++/** A valid invite by its code (with its server), or null. */++async function inviteByCode(code) {++ const c = String(code || '').toLowerCase();++ if (!/^[a-z0-9]{6,16}$/.test(c)) return null;++ const inv = await db.get('SELECT * FROM chat_server_invites WHERE code = ? AND revoked_at IS NULL', [c]);++ if (!inv) return null;++ if (inv.expires_at && Date.parse(inv.expires_at) <= Date.now()) return null;++ if (inv.max_uses != null && inv.uses >= inv.max_uses) return null;++ const server = await byId(inv.server_id);++ return server ? { invite: inv, server } : null;++}++++/** Join: a public server, or any server with a valid invite. → the caller's role. */++async function join(server, user, { invite = null } = {}) {++ if (!user) fail(401, 'servers.sign_in', 'Sign in to join a server');++ const role = await roleIn(server.id, user.id);++ if (role === 'blocked') fail(403, 'servers.blocked', 'You are banned from this server');++ if (role) return role;++ let inv = null;++ if (invite) {++ const found = await inviteByCode(invite);++ if (!found || Number(found.server.id) !== Number(server.id)) fail(410, 'servers.invite_invalid', 'That invite has expired or was used up');++ inv = found.invite;++ }++ // A private server is not there for someone without an invite (the same answer as a missing one).++ if (server.visibility !== 'public' && !inv && !isStaff(user)) fail(404, 'servers.not_found', 'No such server');++ await db.tx(async () => {++ await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'member') ON CONFLICT DO NOTHING", [server.id, user.id]);++ // Everything already in the channels counts as read: a new member starts with nothing unread.++ await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)++ SELECT r.id, ?, 'member', COALESCE((SELECT MAX(x.id) FROM room_messages x WHERE x.room_id = r.id), 0), ov_now()++ FROM rooms r WHERE r.server_id = ? AND r.archived_at IS NULL++ ON CONFLICT (room_id, user_id) DO NOTHING`, [user.id, server.id]);++ if (inv) await db.run('UPDATE chat_server_invites SET uses = uses + 1 WHERE code = ?', [inv.code]);++ await syncCount(server);++ });++ return 'member';++}++++async function dropFromChannels(server, userId) {++ await db.run('DELETE FROM room_members WHERE user_id = ? AND room_id IN (SELECT id FROM rooms WHERE server_id = ?)', [userId, server.id]);++}++++async function leave(server, user) {++ const role = await roleIn(server.id, user && user.id);++ if (role === 'owner') fail(409, 'servers.owner_leaves', 'The owner cannot leave their server');++ if (!role || role === 'blocked') return;++ await db.tx(async () => {++ await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, user.id]);++ await dropFromChannels(server, user.id);++ await syncCount(server);++ });++}++++/**++ * Change someone's role: admin, mod, member, blocked (a ban) or none (kick). The owner appoints and removes admins;++ * admins appoint mods; mods kick and ban members. Nobody acts on someone of their own rank or above, and nobody++ * changes the owner.++ */++async function setRole(server, actor, targetUserId, role) {++ if (!ROLES.includes(role) && role !== 'none') fail(422, 'servers.role', 'Unknown role');++ if (role === 'owner') fail(422, 'servers.role', 'A server has one owner');++ const a = await access(server, actor);++ if (!a.moderate) fail(403, 'servers.not_mod', 'Only the server\'s owner, admins and moderators manage members');++ const target = await ctx.getUserById(Number(targetUserId));++ if (!target) fail(404, 'servers.no_user', 'No such person');++ const current = await roleIn(server.id, target.id);++ if (current === 'owner') fail(403, 'servers.owner', 'The owner\'s role does not change');++ const mine = a.staff ? -1 : RANK[a.role];++ const need = (r) => (r === 'admin' ? RANK.owner : r === 'mod' ? RANK.admin : RANK.mod);++ const RANK_REFUSAL = { admin: 'Only the owner appoints admins', mod: 'Only the owner and admins appoint moderators' };++ if (mine > need(role === 'none' || role === 'blocked' || role === 'member' ? 'member' : role)) fail(403, 'servers.rank', RANK_REFUSAL[role] || 'Only the owner, admins and moderators manage members');++ if (current && current !== 'blocked' && current !== 'member' && mine >= RANK[current]) fail(403, 'servers.rank', 'You cannot change someone of your own rank or above');++ if (!current && role !== 'blocked' && role !== 'none' && server.visibility !== 'public' && !a.manage) fail(403, 'servers.invite_first', 'Invite them first');++ await db.tx(async () => {++ if (role === 'none') {++ if (current && current !== 'blocked') { await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, target.id]); await dropFromChannels(server, target.id); }++ else if (current === 'blocked') await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, target.id]);++ } else if (current) {++ await db.run('UPDATE chat_server_members SET role = ? WHERE server_id = ? AND user_id = ?', [role, server.id, target.id]);++ if (role === 'blocked') await dropFromChannels(server, target.id);++ } else {++ await db.run('INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, ?)', [server.id, target.id, role]);++ }++ await syncCount(server);++ });++ const action = role === 'blocked' && current !== 'blocked' ? 'server_ban'++ : current === 'blocked' && role !== 'blocked' ? 'server_unban'++ : role === 'none' && current ? 'server_kick'++ : role !== current ? `server_role_${role}` : null;++ if (action) await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: target.id, action_type: action, details: { from: current, to: role } });++ return role === 'none' ? null : role;++}++++/** The server's people by rank (blocked last, only for moderators). */++async function members(server, { blocked = false, limit = 1000 } = {}) {++ const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;++ return await db.all(`SELECT m.user_id, m.role, m.joined_at, u.username, u.display_name, u.profile_color FROM chat_server_members m++ LEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}++ ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);++}++++async function update(server, actor, { name, description, visibility } = {}) {++ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins change it');++ const next = { name: server.name, description: server.description, visibility: server.visibility };++ if (name != null) { const n = cleanName(name, 2, 40); if (!n || wordFilter.check(n).safe === false) fail(422, 'servers.name', 'Server names are 2 to 40 characters'); next.name = n; }++ if (description != null) next.description = String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null;++ if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private'); next.visibility = visibility; }++ await db.run('UPDATE chat_servers SET name = ?, description = ?, visibility = ?, updated_at = ov_now() WHERE id = ?', [next.name, next.description, next.visibility, server.id]);++ // The channels follow the server: going private redacts what public channels had published (rooms.update).++ if (next.visibility !== server.visibility) {++ for (const r of await db.all('SELECT * FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id])) {++ await rooms.update(r, actor, { visibility: next.visibility }, { fromServer: true });++ }++ }++ return await byId(server.id);++}++++async function createChannel(server, actor, { name, kind = 'text', topic = null } = {}) {++ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins add channels');++ const room = await insertChannel(server, actor, { name, kind, topic });++ // Every member reads the new channel from its first message on.++ await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)++ SELECT ?, user_id, 'member', 0, NULL FROM chat_server_members WHERE server_id = ? AND role != 'blocked'++ ON CONFLICT (room_id, user_id) DO NOTHING`, [room.id, server.id]);++ return room;++}++++/** Rename, retopic, slow mode, a voice channel's join role, or move it up/down within its kind. */++async function updateChannel(server, actor, room, { name, topic, slow_seconds, join_role, move } = {}) {++ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins change channels');++ if (Number(room.server_id) !== Number(server.id)) fail(404, 'servers.no_channel', 'No such channel');++ if (name != null) {++ const n = cleanName(name, 1, 40);++ if (!n || wordFilter.check(n).safe === false) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');++ }++ await rooms.update(room, actor, { name: name != null ? name : null, topic, slow_seconds, join_role }, { fromServer: true, minName: 1 });++ if (move === 'up' || move === 'down') {++ const siblings = await db.all('SELECT id, position FROM rooms WHERE server_id = ? AND kind = ? AND archived_at IS NULL ORDER BY position, id', [server.id, room.kind]);++ const i = siblings.findIndex((x) => Number(x.id) === Number(room.id));++ const j = move === 'up' ? i - 1 : i + 1;++ if (i >= 0 && j >= 0 && j < siblings.length) {++ const order = siblings.map((x) => x.id);++ [order[i], order[j]] = [order[j], order[i]];++ await db.tx(async () => { for (let k = 0; k < order.length; k++) await db.run('UPDATE rooms SET position = ? WHERE id = ?', [k, order[k]]); });++ }++ }++ return await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]);++}++++/** Delete a channel (archived: its page goes, its messages stay for moderation). A server keeps one text channel. */++async function deleteChannel(server, actor, room) {++ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins delete channels');++ if (Number(room.server_id) !== Number(server.id)) fail(404, 'servers.no_channel', 'No such channel');++ if (room.kind !== 'call') {++ const texts = Number((await db.get("SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND kind != 'call' AND archived_at IS NULL", [server.id]) || {}).n) || 0;++ if (texts <= 1) fail(409, 'servers.last_channel', 'A server keeps at least one text channel');++ }++ await db.run('UPDATE rooms SET archived_at = ov_now() WHERE id = ? AND archived_at IS NULL', [room.id]);++ await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: null, action_type: 'server_channel_delete', details: { channel: room.channel_slug } });++}++++function publicInvite(i) {++ return { code: i.code, uses: Number(i.uses) || 0, max_uses: i.max_uses == null ? null : Number(i.max_uses), expires_at: i.expires_at || null, created_at: i.created_at, revoked: !!i.revoked_at };++}++++/** A new invite: `max_uses` (null or 1..1000) and `expires_hours` (null or 1..720). Managers make them; mods too in a public server. */++async function createInvite(server, actor, { max_uses = null, expires_hours = null } = {}) {++ const a = await access(server, actor);++ if (!(a.manage || (a.moderate && server.visibility === 'public'))) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins invite people');++ const uses = max_uses == null || max_uses === '' ? null : Number(max_uses);++ if (uses != null && (!Number.isInteger(uses) || uses < 1 || uses > 1000)) fail(422, 'servers.invite_uses', 'An invite is used 1 to 1000 times, or without a limit');++ const hours = expires_hours == null || expires_hours === '' ? null : Number(expires_hours);++ if (hours != null && (!Number.isInteger(hours) || hours < 1 || hours > 720)) fail(422, 'servers.invite_expiry', 'An invite lasts 1 to 720 hours, or forever');++ const live = Number((await db.get('SELECT COUNT(*) AS n FROM chat_server_invites WHERE server_id = ? AND revoked_at IS NULL', [server.id]) || {}).n) || 0;++ if (live >= MAX_INVITES) fail(429, 'servers.too_many_invites', `A server has up to ${MAX_INVITES} invites: revoke one first`);++ let code;++ do { code = randomCode(10); } while (await db.get('SELECT 1 FROM chat_server_invites WHERE code = ?', [code]));++ const expires = hours ? new Date(Date.now() + hours * 3600_000).toISOString() : null;++ await db.run('INSERT INTO chat_server_invites (code, server_id, created_by, created_by_subject, max_uses, expires_at) VALUES (?, ?, ?, ?, ?, ?)',++ [code, server.id, actor.id, actor.subject_id || null, uses, expires]);++ return publicInvite(await db.get('SELECT * FROM chat_server_invites WHERE code = ?', [code]));++}++async function invites(server) {++ return (await db.all('SELECT * FROM chat_server_invites WHERE server_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 100', [server.id])).map(publicInvite);++}++async function revokeInvite(server, actor, code) {++ const a = await access(server, actor);++ const inv = await db.get('SELECT * FROM chat_server_invites WHERE code = ? AND server_id = ?', [String(code || '').toLowerCase(), server.id]);++ if (!inv) fail(404, 'servers.no_invite', 'No such invite');++ if (!a.manage && !(a.moderate && inv.created_by === actor.id)) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins revoke invites');++ await db.run('UPDATE chat_server_invites SET revoked_at = ov_now() WHERE code = ? AND revoked_at IS NULL', [inv.code]);++}++++/** Delete the whole server (the owner, or chat staff): it and its channels are archived. */++async function archive(server, actor) {++ const a = await access(server, actor);++ if (!a.owner && !a.staff) fail(403, 'servers.not_owner', 'Only the owner deletes the server');++ await db.tx(async () => {++ await db.run('UPDATE rooms SET archived_at = ov_now() WHERE server_id = ? AND archived_at IS NULL', [server.id]);++ await db.run('UPDATE chat_server_invites SET revoked_at = ov_now() WHERE server_id = ? AND revoked_at IS NULL', [server.id]);++ await db.run('UPDATE chat_servers SET archived_at = ov_now() WHERE id = ?', [server.id]);++ });++ if (!a.owner) await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: server.owner_id, action_type: 'server_delete', details: {} });++}++++module.exports = {++ bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,++ create, list, join, leave, setRole, members, update, createChannel, updateChannel, deleteChannel,++ createInvite, invites, revokeInvite, inviteByCode, archive,++ SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS,++};@@@ -29,11 +29,16 @@ async function publicPages() {const out = [{ path: '/', changefreq: 'hourly', priority: 1.0, lastmod: async () => await newestGlobalMessage() },{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },++ { path: '/servers', changefreq: 'daily', priority: 0.8 },{ path: '/updates', changefreq: 'daily', priority: 0.5 },];for (const r of pages) {out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });}++ // Public servers (server/rooms/servers.js): their front page, the first text channel.++ for (const sv of (await require('../rooms/servers').list(null, { limit: 100 })).public) {++ out.push({ path: `/s/${sv.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(sv.created_at) });++ }return out;}@@@ -78,8 +83,8 @@ function createDiscoveryRoutes({ config }) {// the sitemap. Every previous Disallow is kept.res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({sitemaps: [abs('/sitemap.xml')],-- allow: ['/', '/updates', '/rooms', '/r/'],-- disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],++ allow: ['/', '/updates', '/rooms', '/r/', '/servers', '/s/'],++ disallow: ['/r/*/settings', '/s/*/settings', '/join/', '/messages', '/settings', '/auth/', '/api/'],}));});@@@ -92,6 +97,7 @@ function createDiscoveryRoutes({ config }) {{ title: 'Start here', links: [{ title: 'Global chat', url: abs('/'), note: 'one room for everyone on OpenVibe, newest messages at the end' },{ title: 'Rooms', url: abs('/rooms'), note: 'every public room, with its topic and activity' },++ { title: 'Servers', url: abs('/servers'), note: 'public servers: groups with text and voice channels, roles and invites' },{ title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },] },{ title: 'Machine-readable', links: [@@@ -20,6 +20,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');const LINKS = [{ key: 'global', label: 'Global chat', href: '/' },{ key: 'rooms', label: 'Rooms', href: '/rooms' },++ { key: 'servers', label: 'Servers', href: '/servers' },{ key: 'messages', label: 'Messages', href: '/messages' },{ key: 'settings', label: 'Settings', href: '/settings' },];@@@ -102,7 +103,7 @@ ${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}<a class="oc-skip" href="#main">Skip to content</a><div id="navbar-mount"></div>${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}--<main id="main" class="oc-main">++<main id="main" class="oc-main${o.wide ? ' oc-main--wide' : ''}"><noscript><p class="oc-account">${who}</p></noscript>${o.body || ''}</main>@@@ -142,7 +142,12 @@ function createWebRoutes({ config }) {const countsOf = async (actor) => {if (actor.kind !== 'user') return {};const n = async (fn) => { try { return Number(await fn()) || 0; } catch { return 0; } };-- return { messages: await n(async () => await dm.getTotalUnread(actor.user.id)), rooms: await n(async () => await require('../rooms/rooms').unreadTotal(actor.user)) };++ const rooms = require('../rooms/rooms');++ return {++ messages: await n(async () => await dm.getTotalUnread(actor.user.id)),++ rooms: await n(async () => await rooms.unreadTotal(actor.user)),++ servers: await n(async () => await rooms.unreadTotal(actor.user, { servers: true })),++ };};// Under the global room on the front page: what openvibe.chat is for (openvibe-shared/showcase), each card a real// place to go. Every line is true today (calls run since CHAT_CALLS=1; rooms, messages and settings are pages here).@@@ -152,7 +157,7 @@ function createWebRoutes({ config }) {items: [{ icon: 'ov:chat', title: 'One room for the network', text: 'Global chat reaches everyone on Live, Tools, Games and the rest, and reads without an account.', href: '#oc-h' },{ icon: 'ov:live', title: 'Stream chat on Live', text: 'Every channel on OpenVibe.Live has its room, with the streamer\'s moderators, timeouts, bans, emotes and text to speech.', href: 'https://openvibe.live/' },-- { icon: 'ov:community', title: 'Rooms of your own', text: 'Start a public room anyone can read, or a private one that stays invisible to everyone else.', href: '/rooms' },++ { icon: 'ov:community', title: 'Servers and rooms', text: 'Start a server with text and voice channels, roles and invite links, or a single room, public or private.', href: '/servers' },{ icon: 'ov:account', title: 'Direct messages', text: 'One-to-one and group conversations. You choose who may start one, and whether groups may add you.', href: signedIn ? '/messages' : '/auth/login?next=%2Fmessages' },{ icon: 'ov:stream', title: 'Voice and video calls', text: 'Call a person, open a call room, or join a stream\'s call: up to eight people, with mute, camera and kick controls.', href: '/rooms' },{ icon: 'ov:theme', title: 'Your preferences everywhere', text: 'Compact mode, timestamps, text size, badges and emotes follow your account to every site.', href: signedIn ? '/settings' : '/auth/login?next=%2Fsettings' },@@@ -503,9 +508,16 @@ ${people}</section>`;}++ // A server's channel has one page, in its server (server-pages.js).++ const channelUrl = async (room, rest = '') => {++ const s = await require('../rooms/servers').byId(room.server_id);++ return s ? `/s/${s.slug}${rest || `/${room.channel_slug}`}` : '/servers';++ };++router.get('/r/:slug', async (req, res) => {const x = await loadRoom(req, res); if (!x) return;const { actor, me, room, can } = x;++ if (room.server_id) return res.redirect(302, await channelUrl(room));const msgs = await rooms.history(room, { limit: 60 });if (me && can.role) await rooms.markRead(room, me);const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';@@@ -584,6 +596,7 @@ ${composer}${tools}`,router.get('/r/:slug/settings', async (req, res) => {const x = await loadRoom(req, res); if (!x) return;const { actor, room, can } = x;++ if (room.server_id) return res.redirect(302, await channelUrl(room, '/settings'));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)) : '';@@@ -691,6 +704,9 @@ ${attachList}`,}));// ── What shipped, robots, sitemap ──++ // ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──++ require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config });++router.get('/updates', async (req, res) => await page(req, res, 200, {title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,nnew file mode 100644@@@ -0,0 +1,351 @@++'use strict';++/**++ * Servers on openvibe.chat (server/rooms/servers.js), mounted by pages.js:++ *++ * GET /servers your servers, public servers, and the form to start one++ * GET /s/:slug[/:channel] the server: your servers down the side, its channels, the channel (a text channel's++ * messages, or a voice channel's call and its chat), and its people by rank++ * GET /s/:slug/settings overview, channels, invites, members and bans, delete (by what the role may do)++ * GET /join/:code an invite: the server it opens, and the button to join++ * POST /servers/new, /s/:slug/join | /leave | /settings | /channels | /channels/:channel | /invites | /invites/:code/revoke++ * /s/:slug/members | /delete, /s/:slug/:channel (a message), /join/:code++ *++ * Like every page here it reads and posts without JavaScript; chat.js adds live messages in a channel exactly as in a++ * room (a channel is a room), and call.js the voice channel's call.++ */++const { esc } = require('./layout');++const rooms = require('../rooms/rooms');++const servers = require('../rooms/servers');++const ctx = require('../live-context');++++const LIVE = 'https://openvibe.live';++const RANK_TITLE = { owner: 'Owner', admin: 'Admins', mod: 'Moderators', member: 'Members' };++const ROLE_NAME = { owner: 'owner', admin: 'admin', mod: 'moderator', member: 'member', blocked: 'banned' };++const YOU_ARE = { owner: 'the owner', admin: 'an admin', mod: 'a moderator', member: 'a member' };++const SEARCH_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM20 20l-4.8-4.8"/></svg>';++const VOICE_ICON = '<svg class="oc-ico" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M3 9v6h4l5 4V5L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>';++++const initials = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';++/** A steady hue per server, so its icon is recognisable in the rail. */++const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };++++function mountServerPages(router, h) {++ const { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config } = h;++ const site = config.web.baseUrl;++ const serverRoutes = () => require('../rooms/server-routes');++ const orError = (fn) => async (req, res, next) => {++ try { return await fn(req, res, next); } catch (err) {++ if (err instanceof rooms.RoomError) return res.redirect(303, `${req.back || '/servers'}?error=${encodeURIComponent(err.message)}`);++ return next(err);++ }++ };++ const userOf = (actor) => (actor.kind === 'user' ? actor.user : null);++ const errorOf = (req) => (req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '');++++ const icon = (s, active) => `<a class="oc-srv-icon${active ? ' is-active' : ''}" href="/s/${esc(s.slug)}" title="${esc(s.name)}" style="--oc-hue:${hueOf(s.slug)}"${active ? ' aria-current="page"' : ''}><span aria-hidden="true">${esc(initials(s.name))}</span><span class="oc-sr">${esc(s.name)}</span>${s.unread ? `<span class="oc-srv-dot" aria-label="${s.unread} unread">${s.unread > 99 ? '99+' : s.unread}</span>` : ''}</a>`;++ const serverCard = (s) => `<li class="oc-srv-card"><a href="/s/${esc(s.slug)}"><span class="oc-srv-icon oc-srv-icon--card" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(initials(s.name))}</span><span class="oc-srv-card-text"><span class="oc-srv-card-title"><strong>${esc(s.name)}</strong>${s.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}${s.unread ? `<span class="oc-badge oc-badge-new">${s.unread} new</span>` : ''}</span><span class="oc-muted">${esc(s.description || `${s.members} member${s.members === 1 ? '' : 's'}`)}</span></span></a></li>`;++++ // ── The directory ──++ router.get('/servers', async (req, res) => {++ const actor = await viewerOf(req);++ const me = userOf(actor);++ const { public: pub, mine } = await servers.list(me);++ const create = me ? `<form class="oc-form oc-create" id="oc-new-server" method="post" action="/servers/new">++<h2>Start a server</h2>++<p class="oc-muted">A server is a place for a group: text channels to write in and voice channels to talk in, one membership and one set of roles for all of them. It starts with #general and a voice Lounge.</p>++<label>Name <input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>++<label>Description <input name="description" maxlength="300" placeholder="What it is for (optional)"></label>++<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people with an invite can find, read or join it</label>++<p><button type="submit">Create server</button></p>++</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Fservers">Sign in with OpenVibe</a> to start or join a server.</p>`;++ await page(req, res, 200, {++ actor, title: 'Servers', path: '/servers', active: 'servers', robots: 'index, follow',++ description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',++ body: `<h1>Servers</h1>${errorOf(req)}++${mine.length ? `<h2>Your servers</h2><ul class="oc-srv-cards">${mine.map(serverCard).join('')}</ul>` : ''}++<h2>Public servers</h2><ul class="oc-srv-cards">${pub.map(serverCard).join('') || '<li class="oc-muted">No public servers yet. Start the first one.</li>'}</ul>++${create}`,++ });++ });++++ router.post('/servers/new', formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const b = req.body || {};++ const s = await servers.create(actor.user, { name: b.name, description: b.description, visibility: b.private === '1' ? 'private' : 'public' });++ res.redirect(303, `/s/${s.slug}`);++ }));++++ /** The server behind :slug for this viewer (404 page when they may not read it). */++ async function loadServer(req, res) {++ const actor = await viewerOf(req);++ const me = userOf(actor);++ const server = await servers.bySlug(req.params.slug);++ const can = server ? await servers.access(server, me) : null;++ if (!server || !can.read) {++ await page(req, res, 404, { actor, title: 'No such server', path: req.path, robots: 'noindex', body: '<h1>No such server</h1><p><a href="/servers">All servers</a></p>' });++ return null;++ }++ return { actor, me, server, can };++ }++++ // ── A server and one of its channels ──++ async function serverPage(req, res) {++ const x = await loadServer(req, res); if (!x) return;++ const { actor, me, server, can } = x;++ const chans = await servers.channels(server, can.member ? me : null);++ const pick = req.params.channel ? chans.find((c) => c.slug === String(req.params.channel).toLowerCase()) : chans.find((c) => c.kind === 'text') || chans[0];++ if (!pick) return page(req, res, 404, { actor, title: 'No such channel', path: req.path, robots: 'noindex', body: `<h1>No such channel</h1><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });++ const room = await rooms.bySlug(pick.room);++ const rcan = await rooms.access(room, me);++ const msgs = await rooms.history(room, { limit: 60 });++ if (me && rcan.role) await rooms.markRead(room, me);++ const mine = me ? (await servers.list(me)).mine : [];++ const people = await servers.members(server, { limit: 300 });++ const isVoice = room.kind === 'call';++ const callReady = isVoice && !!require('../config').calls.enabled;++ const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };++ const chanLink = (c) => {++ const here = c.slug === pick.slug;++ const unread = !here && c.unread ? `<span class="oc-chan-dot" aria-label="${c.unread} unread">${c.unread > 99 ? '99+' : c.unread}</span>` : '';++ const inCall = c.kind === 'voice' ? (callOf(c) || {}).participants : 0;++ return `<li><a class="oc-chan${c.kind === 'voice' ? ' oc-chan-voice' : ''}${here ? ' is-active' : ''}${unread ? ' is-unread' : ''}" href="/s/${esc(server.slug)}/${esc(c.slug)}"${here ? ' aria-current="page"' : ''}>${c.kind === 'voice' ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}<span class="oc-chan-name">${esc(c.name)}</span>${inCall ? `<span class="oc-muted oc-chan-in">${inCall} in</span>` : ''}${unread}</a></li>`;++ };++ const texts = chans.filter((c) => c.kind === 'text');++ const voices = chans.filter((c) => c.kind === 'voice');++ let composer;++ const back = `/s/${server.slug}/${pick.slug}`;++ if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;++ else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;++ else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;++ else composer = notice('error', 'You cannot write here right now.');++ const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];++ const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';++ const byRank = {};++ for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);++ const person = (p) => `<li><a class="oc-name" href="${LIVE}/@${encodeURIComponent(p.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? ` style="--nc:${esc(p.profile_color)}"` : ''}>${esc(p.display_name || p.username || '?')}</a></li>`;++ const peopleHtml = ['owner', 'admin', 'mod', 'member'].filter((r) => byRank[r]).map((r) => `<h3 class="oc-srv-group">${RANK_TITLE[r]} <span class="oc-muted">${byRank[r].length}</span></h3><ul class="oc-srv-people-list">${byRank[r].map(person).join('')}</ul>`).join('');++ const tools = [++ can.moderate ? `<a href="/s/${esc(server.slug)}/settings">${can.manage ? 'Server settings' : 'Members and invites'}</a>` : '',++ can.member && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/leave"><button type="submit" class="oc-link">Leave server</button></form>` : '',++ ].filter(Boolean).join(' · ');++ const canInvite = can.manage || (can.moderate && server.visibility === 'public');++ await page(req, res, 200, {++ actor, wide: true, title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: back, active: 'servers',++ robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,++ description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,++ page: {++ view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0,++ me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,++ ...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),++ },++ body: `<div class="oc-srv">++<nav class="oc-srv-rail" aria-label="Your servers">${mine.map((s) => icon(s, s.slug === server.slug)).join('')}${mine.some((s) => s.slug === server.slug) ? '' : icon(servers.publicServer(server), true)}++<a class="oc-srv-icon oc-srv-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>++<a class="oc-srv-icon oc-srv-explore" href="/servers" title="Find servers">${SEARCH_ICON}<span class="oc-sr">Find servers</span></a></nav>++<aside class="oc-srv-side" aria-label="${esc(server.name)}">++<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}++<p class="oc-muted oc-srv-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : ''}</p>++${server.description ? `<p class="oc-srv-desc">${esc(server.description)}</p>` : ''}</header>++<details class="oc-srv-channels" open><summary>Channels</summary>++<h2 class="oc-srv-group">Text channels</h2><ul class="oc-srv-list">${texts.map(chanLink).join('')}</ul>++${voices.length ? `<h2 class="oc-srv-group">Voice channels</h2><ul class="oc-srv-list">${voices.map(chanLink).join('')}</ul>` : ''}++</details>++${!can.member && me ? `<form class="oc-srv-join" method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>` : ''}++${canInvite ? `<p class="oc-srv-invite"><a class="oc-button oc-button--quiet" href="/s/${esc(server.slug)}/settings#oc-invites">Invite people</a></p>` : ''}++${tools ? `<p class="oc-srv-tools">${tools}</p>` : ''}++</aside>++<section class="oc-srv-main" aria-labelledby="oc-chan-h">++<header class="oc-chan-head"><h2 id="oc-chan-h">${isVoice ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}${esc(room.name)}</h2>${room.topic ? `<span class="oc-muted oc-chan-topic">${esc(room.topic)}</span>` : ''}<span class="oc-live" id="oc-live" hidden>● live</span></header>++${aiHere}${errorOf(req)}++${isVoice ? callPanel(room, rcan, me) : ''}++<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>++${composer}++</section>++<aside class="oc-srv-people" aria-label="People in ${esc(server.name)}"><details open><summary>People <span class="oc-muted">${Number(server.member_count) || 0}</span></summary>${peopleHtml}${people.length >= 300 ? '<p class="oc-muted">And more.</p>' : ''}</details></aside>++</div>`,++ });++ }++ router.get('/s/:slug', serverPage);++++ router.post('/s/:slug/join', formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const server = await servers.bySlug(req.params.slug);++ if (!server) return res.redirect(303, '/servers');++ req.back = `/s/${server.slug}`;++ await servers.join(server, actor.user);++ await serverRoutes().applyServerChange(server);++ res.redirect(303, `/s/${server.slug}`);++ }));++++ router.post('/s/:slug/leave', formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const server = await servers.bySlug(req.params.slug);++ if (!server) return res.redirect(303, '/servers');++ req.back = `/s/${server.slug}`;++ await servers.leave(server, actor.user);++ await serverRoutes().applyServerChange(server);++ res.redirect(303, '/servers');++ }));++++ // ── Settings: what each rank may do (overview and channels: owner/admins; invites: owner/admins, and mods in a++ // public server; members: owner/admins/mods; delete: the owner) ──++ router.get('/s/:slug/settings', async (req, res) => {++ const x = await loadServer(req, res); if (!x) return;++ const { actor, me, server, can } = x;++ if (!can.moderate) return res.redirect(303, `/s/${server.slug}`);++ const saved = req.query.saved ? notice('ok', 'Saved.') : '';++ const chans = await servers.channels(server);++ const people = await servers.members(server, { blocked: true, limit: 2000 });++ const canInvite = can.manage || server.visibility === 'public';++ const invites = canInvite ? await servers.invites(server) : [];++ const myRank = can.staff ? -1 : servers.RANK[can.role];++ const choices = (target) => {++ const list = [];++ if (myRank <= servers.RANK.owner) list.push('admin');++ if (myRank <= servers.RANK.admin) list.push('mod');++ list.push('member', 'blocked', 'none');++ if (target && target.role !== 'member' && target.role !== 'blocked' && myRank >= servers.RANK[target.role]) return null;++ return list;++ };++ const roleForm = (p) => {++ if (p.role === 'owner') return '<span class="oc-muted">owner</span>';++ const opts = choices(p);++ if (!opts || Number(p.user_id) === Number(me.id)) return `<span class="oc-muted">${ROLE_NAME[p.role]}</span>`;++ return `<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/members"><input type="hidden" name="username" value="${esc(p.username || '')}"><select name="role" aria-label="Role for ${esc(p.username || '')}">${opts.map((r) => `<option value="${r}"${r === p.role ? ' selected' : ''}>${r === 'none' ? 'remove from the server' : r === 'blocked' ? 'ban' : ROLE_NAME[r]}</option>`).join('')}</select> <button type="submit">Set</button></form>`;++ };++ const active = people.filter((p) => p.role !== 'blocked');++ const banned = people.filter((p) => p.role === 'blocked');++ const chanRow = (c) => `<li><span>${c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>++<form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form>++<details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}">++<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>++<label>Topic <input name="topic" maxlength="200" value="${esc(c.topic || '')}"></label>++${c.kind === 'voice' ? `<label>Members in the call <select name="join_role"><option value="speaker"${c.join_role === 'speaker' ? ' selected' : ''}>can talk</option><option value="participant"${c.join_role === 'participant' ? ' selected' : ''}>listen and write (moderators talk)</option></select></label>` : ''}++<p><button type="submit">Save</button> <button type="submit" name="delete" value="1" class="oc-link oc-danger-link">Delete this channel</button></p></form></details></li>`;++ const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span>++<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;++ await page(req, res, 200, {++ actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow',++ body: `<p><a href="/s/${esc(server.slug)}">← ${esc(server.name)}</a></p><h1>Server settings</h1>${saved}${errorOf(req)}++${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/${esc(server.slug)}/settings">++<label>Name <input name="name" required minlength="2" maxlength="40" value="${esc(server.name)}"></label>++<label>Description <input name="description" maxlength="300" value="${esc(server.description || '')}"></label>++<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>++<p><button type="submit">Save</button></p></form>++<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option></select> <button type="submit">Add</button></form>` : ''}++${canInvite ? `<h2 id="oc-invites">Invites</h2><p class="oc-muted">${server.visibility === 'private' ? 'A private server is joined only through an invite.' : 'Anyone can join a public server; an invite link is a handy way to bring people straight to it.'}</p>++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/invites"><label for="oc-inv-uses">New invite</label> <select id="oc-inv-uses" name="max_uses" aria-label="How many times it can be used"><option value="">any number of uses</option><option value="1">1 use</option><option value="10">10 uses</option><option value="100">100 uses</option></select> <select name="expires_hours" aria-label="When it expires"><option value="168">for 7 days</option><option value="24">for 1 day</option><option value="720">for 30 days</option><option value="">forever</option></select> <button type="submit">Make an invite</button></form>++<ul class="oc-members">${invites.map(inviteRow).join('') || '<li class="oc-muted">No invites yet.</li>'}</ul>` : ''}++<h2>Members <span class="oc-muted">${active.length}</span></h2>++<p class="oc-muted">Admins manage channels, invites and members; moderators delete messages and remove or ban members. The owner appoints admins, admins appoint moderators.</p>++<ul class="oc-members">${active.map((p) => `<li><strong>${esc(p.display_name || p.username || '?')}</strong> <span class="oc-muted">@${esc(p.username || '')}</span> ${roleForm(p)}</li>`).join('')}</ul>++${banned.length ? `<details class="oc-blocked"><summary>Banned (${banned.length})</summary><ul class="oc-members">${banned.map((p) => `<li><strong>${esc(p.display_name || p.username || '?')}</strong> <span class="oc-muted">@${esc(p.username || '')}</span><form class="oc-inline" method="post" action="/s/${esc(server.slug)}/members"><input type="hidden" name="username" value="${esc(p.username || '')}"><input type="hidden" name="role" value="none"><button type="submit" class="oc-link">Lift the ban</button></form></li>`).join('')}</ul></details>` : ''}++<form class="oc-new" method="post" action="/s/${esc(server.slug)}/members"><label for="oc-ban">Ban someone</label> <input id="oc-ban" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="blocked"> <button type="submit">Ban</button></form>++${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" method="post" action="/s/${esc(server.slug)}/delete"><label class="oc-check"><input type="checkbox" name="confirm" value="1" required> Delete ${esc(server.name)} and all its channels</label><p><button type="submit" class="oc-danger">Delete server</button></p></form>` : ''}`,++ });++ });++++ const settingsAction = (fn) => [formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const server = await servers.bySlug(req.params.slug);++ if (!server || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');++ req.back = `/s/${server.slug}/settings`;++ await fn(req, res, server, actor.user);++ })];++++ router.post('/s/:slug/settings', ...settingsAction(async (req, res, server, user) => {++ const b = req.body || {};++ const out = await servers.update(server, user, { name: b.name, description: b.description, visibility: b.visibility });++ await serverRoutes().applyServerChange(out);++ res.redirect(303, `/s/${server.slug}/settings?saved=1`);++ }));++ router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {++ const b = req.body || {};++ const room = await servers.createChannel(server, user, { name: b.name, kind: b.kind === 'voice' ? 'voice' : 'text' });++ res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);++ }));++ router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {++ const room = await servers.channelBySlug(server, req.params.channel);++ if (!room) return res.redirect(303, `/s/${server.slug}/settings?error=${encodeURIComponent('No such channel')}`);++ const b = req.body || {};++ if (b.delete === '1') await servers.deleteChannel(server, user, room);++ else {++ const out = await servers.updateChannel(server, user, room, { name: b.name, topic: b.topic, join_role: room.kind === 'call' ? b.join_role : undefined, move: b.move });++ await applyRoomChange(out);++ }++ res.redirect(303, `/s/${server.slug}/settings?saved=1`);++ }));++ router.post('/s/:slug/invites', ...settingsAction(async (req, res, server, user) => {++ const b = req.body || {};++ await servers.createInvite(server, user, { max_uses: b.max_uses || null, expires_hours: b.expires_hours || null });++ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-invites`);++ }));++ router.post('/s/:slug/invites/:code/revoke', ...settingsAction(async (req, res, server, user) => {++ await servers.revokeInvite(server, user, req.params.code);++ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-invites`);++ }));++ router.post('/s/:slug/members', ...settingsAction(async (req, res, server, user) => {++ const name = String((req.body && req.body.username) || '').trim();++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await 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, `/s/${server.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);++ const role = await servers.setRole(server, user, target.id, String((req.body && req.body.role) || 'member'));++ if (role === 'blocked' || role == null) {++ for (const r of await require('../db/database').all('SELECT id FROM rooms WHERE server_id = ?', [server.id])) require('../chat/chat-server').removeFromRoom(r.id, target.id);++ }++ await serverRoutes().applyServerChange(server);++ res.redirect(303, `/s/${server.slug}/settings?saved=1`);++ }));++ router.post('/s/:slug/delete', ...settingsAction(async (req, res, server, user) => {++ if ((req.body || {}).confirm !== '1') return res.redirect(303, `/s/${server.slug}/settings`);++ await servers.archive(server, user);++ await serverRoutes().applyServerChange(server);++ res.redirect(303, '/servers');++ }));++++ // ── An invite ──++ router.get('/join/:code', async (req, res) => {++ const actor = await viewerOf(req);++ const me = userOf(actor);++ const found = await servers.inviteByCode(req.params.code);++ if (!found) return page(req, res, 404, { actor, title: 'Invite not valid', path: req.path, robots: 'noindex, nofollow', body: '<h1>This invite is not valid</h1><p class="oc-muted">It has expired, was used up, or was revoked. Ask whoever sent it for a new one.</p><p><a href="/servers">Find servers</a></p>' });++ const { server } = found;++ const role = me ? await servers.roleIn(server.id, me.id) : null;++ const action = !me ? `<p><a class="oc-button" href="/auth/login?next=${encodeURIComponent(req.path)}">Sign in with OpenVibe to join</a></p>`++ : role === 'blocked' ? notice('error', 'You are banned from this server.')++ : role ? `<p><a class="oc-button" href="/s/${esc(server.slug)}">Open ${esc(server.name)}</a> <span class="oc-muted">You are already in it.</span></p>`++ : `<form method="post" action="/join/${esc(found.invite.code)}"><button type="submit">Join ${esc(server.name)}</button></form>`;++ await page(req, res, 200, {++ actor, title: `Join ${server.name}`, path: req.path, robots: 'noindex, nofollow',++ body: `<section class="oc-invite-card"><span class="oc-srv-icon oc-srv-icon--big" style="--oc-hue:${hueOf(server.slug)}" aria-hidden="true">${esc(initials(server.name))}</span>++<p class="oc-muted">You are invited to</p><h1>${esc(server.name)}</h1>${server.description ? `<p>${esc(server.description)}</p>` : ''}++<p class="oc-muted">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${server.visibility === 'private' ? ' · private server' : ''}</p>++${errorOf(req)}${action}</section>`,++ });++ });++ router.post('/join/:code', formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ req.back = `/join/${encodeURIComponent(req.params.code)}`;++ const found = await servers.inviteByCode(req.params.code);++ if (!found) return res.redirect(303, req.back);++ await servers.join(found.server, actor.user, { invite: found.invite.code });++ await serverRoutes().applyServerChange(found.server);++ res.redirect(303, `/s/${found.server.slug}`);++ }));++++ // A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).++ router.get('/s/:slug/:channel', serverPage);++ router.post('/s/:slug/:channel', formLimit, sameSite, orError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const server = await servers.bySlug(req.params.slug);++ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/servers');++ req.back = `/s/${server.slug}/${room.channel_slug}`;++ const message = await 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, `/s/${server.slug}/${room.channel_slug}#oc-compose`);++ }));++}++++module.exports = { mountServerPages };@@@ -27,7 +27,7 @@ const TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events','ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox','chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'token_revocations', 'chat_control_log',++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log','network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',];const INDEXES = [nnew file mode 100644@@@ -0,0 +1,224 @@++'use strict';++/**++ * Servers (migration 0013, server/rooms/servers.js): a server starts with #general and a voice Lounge; a public one is++ * read by anyone and joined by signed-in people, a private one is a 404 to outsiders and is joined through an invite++ * (uses, expiry, revoke). One membership and one set of roles for every channel: the owner appoints admins, admins++ * appoint moderators and manage channels and invites, moderators remove and ban members, nobody acts on their own rank++ * or above; it is all logged. A channel is a room: messages over REST and the socket, unread counts, the voice++ * channel's call roles; the rooms API refuses room-level joins and roles there, and rooms lists leave channels out.++ * The site: the directory, a server's page, settings by rank, an invite page, /r/<channel> going to its server.++ * Deleting an account removes its memberships; a server it owned stays without an owner while anyone is in it.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('servers');++let h, ann, bob, cat, dee, eve, staff;++const api = (method, path, u, body) => h.http(method, `/api/chat/servers${path}`, { token: u && u.token, body });++const roomApi = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });++const page = async (path, u, { method = 'GET', form = null } = {}) => {++ const headers = { ...(u ? { cookie: `ov_token=${u.token}` } : {}), ...(form ? { 'content-type': 'application/x-www-form-urlencoded' } : {}) };++ const r = await fetch(`${h.base}${path}`, { method, headers, body: form ? new URLSearchParams(form).toString() : undefined, redirect: 'manual' });++ return { status: r.status, text: await r.text(), location: r.headers.get('location') };++};++const ev = (type, payload) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: 'network', actor: { type: 'user', id: payload.subject },++ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: payload.subject }, payload });++let general, lounge;++++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') });++ dee = h.addUser('dee', { subject: ids.newId('user') });++ eve = h.addUser('eve', { subject: ids.newId('user') });++ staff = h.addUser('staffer', { subject: ids.newId('user'), role: 'global_mod' });++ for (const u of [ann, bob, cat, dee, eve, staff]) await h.ctx.upsertUser(h.live.users.get(u.id));++});++++t('create: #general and a voice Lounge, the owner, and the rules on names', async () => {++ const r = await api('POST', '/', ann, { name: 'Saturday Raiders', description: 'Raids on Saturdays' });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.body.server.slug, r.body.server.role, r.body.server.members], ['saturday-raiders', 'owner', 1]);++ assert.strictEqual((await api('POST', '/', bob, { name: 'Saturday Raiders' })).status, 409);++ assert.strictEqual((await api('POST', '/', bob, { name: 'x' })).status, 422);++ assert.strictEqual((await api('POST', '/', bob, { name: 'Settings', slug: 'settings' })).status, 422, 'reserved');++ assert.strictEqual((await api('POST', '/', null, { name: 'Anon' })).status, 401);++ const s = (await api('GET', '/saturday-raiders', ann)).body;++ assert.deepStrictEqual(s.channels.map((c) => [c.slug, c.kind]), [['general', 'text'], ['lounge', 'voice']]);++ general = s.channels[0].room; lounge = s.channels[1].room;++ assert.match(general, /^ch[a-z2-9]{12}$/);++ assert.deepStrictEqual(s.can, { read: true, member: true, moderate: true, manage: true, owner: true });++ assert.ok(!(await roomApi('GET', '/', ann)).body.public.some((x) => x.slug === general), 'channels are not in the rooms list');++});++++t('a public server: anyone reads, signed-in people join and write; unread counts; the rooms API defers to the server', async () => {++ assert.strictEqual((await api('GET', '/saturday-raiders', null)).status, 200);++ assert.strictEqual((await roomApi('GET', `/${general}/messages`, null)).status, 200, 'its channels read signed out');++ let r = await roomApi('POST', `/${general}/messages`, bob, { message: 'hi' });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'rooms.join_first']);++ assert.deepStrictEqual([(await roomApi('POST', `/${general}/join`, bob)).status, (await roomApi('POST', `/${general}/join`, bob)).body.code], [409, 'rooms.server_channel']);++ assert.strictEqual((await api('POST', '/saturday-raiders/join', bob)).body.role, 'member');++ assert.strictEqual((await roomApi('POST', `/${general}/messages`, bob, { message: 'hello raiders' })).status, 201);++ assert.strictEqual((await roomApi('POST', `/${general}/messages`, bob, { message: 'anyone on tonight?' })).status, 201);++ let mine = (await api('GET', '/', ann)).body.mine;++ assert.strictEqual(mine.find((x) => x.slug === 'saturday-raiders').unread, 2);++ assert.strictEqual((await api('GET', '/saturday-raiders', ann)).body.channels[0].unread, 2);++ await roomApi('POST', `/${general}/read`, ann, {});++ mine = (await api('GET', '/', ann)).body.mine;++ assert.strictEqual(mine.find((x) => x.slug === 'saturday-raiders').unread, 0);++ assert.strictEqual((await api('GET', '/', null)).body.public.find((x) => x.slug === 'saturday-raiders').members, 2);++ r = await roomApi('GET', `/${general}/members`, ann);++ assert.deepStrictEqual(r.body.members.map((m) => [m.username, m.role]), [['ann', 'owner'], ['bob', 'member']], 'a channel\'s people are the server\'s');++ assert.strictEqual((await roomApi('POST', `/${general}/members`, ann, { username: 'cat', role: 'member' })).body.code, 'rooms.server_channel');++});++++t('the socket: a member joins a channel like a room and gets its messages live', async () => {++ const annWs = await h.ws({ token: ann.token }); annWs.sendJson({ type: 'join_room', room: general });++ assert.strictEqual((await annWs.next((m) => m.type === 'room_joined')).role, 'owner');++ const bobWs = await h.ws({ token: bob.token }); bobWs.sendJson({ type: 'join_room', room: general });++ assert.strictEqual((await bobWs.next((m) => m.type === 'room_joined')).role, 'member');++ bobWs.sendJson({ type: 'room_message', message: 'live in #general' });++ const got = await annWs.next((m) => m.type === 'room_message' && m.message.message === 'live in #general');++ assert.strictEqual(got.room, general);++ annWs.close(); bobWs.close();++});++++t('roles: the owner appoints admins, admins appoint mods, mods remove and ban members, never upward; logged', async () => {++ for (const u of [cat, dee, eve]) await api('POST', '/saturday-raiders/join', u);++ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'mod' })).status, 403, 'a member manages nobody');++ assert.strictEqual((await api('POST', '/saturday-raiders/members', ann, { username: 'bob', role: 'admin' })).body.role, 'admin');++ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'admin' })).status, 403, 'only the owner appoints admins');++ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'mod' })).body.role, 'mod');++ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'dee', role: 'mod' })).status, 403, 'mods do not appoint mods');++ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'bob', role: 'none' })).status, 403, 'a mod cannot remove an admin');++ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'ann', role: 'member' })).status, 403, 'nobody changes the owner');++ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'dee', role: 'none' })).body.role, null, 'a mod removes a member');++ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'eve', role: 'blocked' })).body.role, 'blocked');++ assert.deepStrictEqual([(await api('POST', '/saturday-raiders/join', eve)).status, (await api('POST', '/saturday-raiders/join', eve)).body.code], [403, 'servers.blocked']);++ assert.strictEqual((await roomApi('GET', `/${general}/messages`, eve)).status, 404, 'banned: not even reading');++ const lounges = (await roomApi('GET', `/${lounge}`, cat)).body.can;++ assert.ok(lounges.talk && lounges.moderate, 'a mod talks and moderates in voice');++ assert.ok((await roomApi('GET', `/${lounge}`, bob)).body.can.manage, 'an admin manages the channels');++ const log = await require('../server/db/database').all("SELECT action_type FROM moderation_actions WHERE scope_type = 'server' ORDER BY id");++ assert.deepStrictEqual(log.map((x) => x.action_type), ['server_role_admin', 'server_role_mod', 'server_kick', 'server_ban']);++ assert.strictEqual((await api('GET', '/saturday-raiders/members', bob)).body.members.some((m) => m.role === 'blocked'), true, 'moderators see bans');++ assert.strictEqual((await api('GET', '/saturday-raiders/members', null)).body.members.some((m) => m.role === 'blocked'), false);++});++++t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {++ assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');++ let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });++ assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);++ r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });++ assert.strictEqual(r.body.channel.slug, 'settings-voice', 'a channel never takes a page\'s address');++ await api('PATCH', '/saturday-raiders/channels/announcements', bob, { move: 'up' });++ assert.deepStrictEqual((await api('GET', '/saturday-raiders', bob)).body.channels.filter((c) => c.kind === 'text').map((c) => c.slug), ['announcements', 'general']);++ r = await api('PATCH', '/saturday-raiders/channels/lounge', bob, { join_role: 'participant' });++ assert.strictEqual(r.body.channel.join_role, 'participant');++ assert.strictEqual((await roomApi('GET', `/${lounge}`, cat)).body.can.talk, true, 'mods still talk');++ const dee2 = (await api('POST', '/saturday-raiders/join', dee)).body.role;++ assert.strictEqual(dee2, 'member');++ assert.strictEqual((await roomApi('GET', `/${lounge}`, dee)).body.can.talk, false, 'members listen in a participant channel');++ assert.strictEqual((await api('DELETE', '/saturday-raiders/channels/announcements', bob)).status, 204);++ assert.deepStrictEqual([(await api('DELETE', '/saturday-raiders/channels/general', bob)).status, (await api('DELETE', '/saturday-raiders/channels/general', bob)).body.code], [409, 'servers.last_channel']);++ assert.strictEqual((await roomApi('PATCH', `/${general}`, ann, { visibility: 'private' })).body.code, 'rooms.server_channel', 'a channel is as public as its server');++});++++t('private servers: invisible to outsiders, joined through an invite with uses and expiry, revocable', async () => {++ const r = await api('POST', '/', ann, { name: 'Ann and Bob', visibility: 'private' });++ assert.strictEqual(r.status, 201);++ const room = (await api('GET', '/ann-and-bob', ann)).body.channels[0].room;++ assert.strictEqual((await api('GET', '/ann-and-bob', bob)).status, 404);++ assert.strictEqual((await api('POST', '/ann-and-bob/join', bob)).status, 404, 'as missing as a server that does not exist');++ assert.strictEqual((await roomApi('GET', `/${room}/messages`, bob)).status, 404);++ assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'ann-and-bob'), 'not listed');++ const inv = await api('POST', '/ann-and-bob/invites', ann, { max_uses: 1, expires_hours: 24 });++ assert.strictEqual(inv.status, 201, inv.text);++ assert.match(inv.body.url, /\/join\/[a-z2-9]{10}$/);++ const code = inv.body.invite.code;++ assert.strictEqual((await api('GET', `/invites/${code}`, null)).body.server.name, 'Ann and Bob', 'the code shows what it opens');++ assert.strictEqual((await api('POST', '/ann-and-bob/join', bob, { invite: code })).body.role, 'member');++ assert.strictEqual((await roomApi('POST', `/${room}/messages`, bob, { message: 'thanks for the invite' })).status, 201);++ const used = await api('POST', '/ann-and-bob/join', cat, { invite: code });++ assert.deepStrictEqual([used.status, used.body.code], [410, 'servers.invite_invalid'], 'used up (the code already told cat the server exists)');++ assert.strictEqual((await api('GET', `/invites/${code}`, null)).status, 404);++ const again = (await api('POST', '/ann-and-bob/invites', ann, {})).body.invite.code;++ assert.strictEqual((await api('POST', '/ann-and-bob/invites', bob, {})).status, 403, 'members do not invite to a private server');++ assert.strictEqual((await api('DELETE', `/ann-and-bob/invites/${again}`, ann)).status, 204);++ assert.strictEqual((await api('POST', '/ann-and-bob/join', cat, { invite: again })).status, 410, 'revoked');++ assert.strictEqual((await api('GET', '/ann-and-bob', cat)).status, 404, 'still not there for cat');++ assert.strictEqual((await api('GET', '/ann-and-bob', staff)).status, 200, 'chat staff can look in');++});++++t('going private: the channels follow and outsiders lose them; deleting the server takes everything', async () => {++ assert.strictEqual((await api('PATCH', '/saturday-raiders', bob, { visibility: 'private' })).body.server.visibility, 'private');++ assert.strictEqual((await require('../server/db/database').get('SELECT visibility FROM rooms WHERE slug = ?', [general])).visibility, 'private');++ assert.strictEqual((await roomApi('GET', `/${general}/messages`, null)).status, 404);++ assert.strictEqual((await roomApi('GET', `/${general}/messages`, dee)).status, 200, 'members still read');++ await api('PATCH', '/saturday-raiders', bob, { visibility: 'public' });++ const tmp = (await api('POST', '/', bob, { name: 'Short Lived' })).body.server;++ assert.strictEqual((await api('DELETE', `/${tmp.slug}`, ann)).status, 403, 'only its owner deletes a server');++ assert.strictEqual((await api('DELETE', `/${tmp.slug}`, bob)).status, 204);++ assert.strictEqual((await api('GET', `/${tmp.slug}`, bob)).status, 404);++});++++t('the site: directory, a server page, settings by rank, an invite page, /r/<channel> goes to its server', async () => {++ let r = await page('/servers', null);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Saturday Raiders/);++ assert.doesNotMatch(r.text, /Ann and Bob/, 'private servers are not listed');++ r = await page('/s/saturday-raiders', dee);++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /class="oc-srv"/);++ assert.match(r.text, /aria-current="page"[^>]*>.*general/s);++ assert.match(r.text, /Voice channels/);++ assert.match(r.text, /<h3 class="oc-srv-group">Owner/);++ assert.match(r.text, /id="oc-compose"/, 'a member writes');++ assert.doesNotMatch(r.text, /Server settings/, 'a member sees no settings');++ r = await page('/s/saturday-raiders/lounge', null);++ assert.match(r.text, /<section class="oc-call"/, 'a voice channel shows its call (here: calls are off in tests)');++ assert.match(r.text, /Sign in with OpenVibe/);++ r = await page(`/r/${general}`, dee);++ assert.deepStrictEqual([r.status, r.location], [302, '/s/saturday-raiders/general']);++ r = await page('/s/saturday-raiders/settings', ann);++ assert.match(r.text, /<h2>Channels<\/h2>/);++ assert.match(r.text, /id="oc-invites"/);++ assert.match(r.text, /Delete the server/);++ r = await page('/s/saturday-raiders/settings', cat);++ assert.doesNotMatch(r.text, /<h2>Channels<\/h2>/, 'a mod sees members and invites, not channels');++ assert.match(r.text, /<h2>Members/);++ assert.strictEqual((await page('/s/saturday-raiders/settings', dee)).status, 303, 'a member is sent back');++ r = await page('/s/saturday-raiders/general', dee, { method: 'POST', form: { message: 'from the form' } });++ assert.strictEqual(r.status, 303);++ assert.match((await page('/s/saturday-raiders/general', dee)).text, /from the form/);++ r = await page('/servers/new', cat, { method: 'POST', form: { name: 'Book Club', private: '1' } });++ assert.deepStrictEqual([r.status, r.location], [303, '/s/book-club']);++ const code = (await api('POST', '/book-club/invites', cat, {})).body.invite.code;++ r = await page(`/join/${code}`, dee);++ assert.match(r.text, /You are invited to/);++ assert.match(r.text, /Book Club/);++ r = await page(`/join/${code}`, dee, { method: 'POST', form: {} });++ assert.deepStrictEqual([r.status, r.location], [303, '/s/book-club']);++ assert.strictEqual((await page('/join/nosuchcode', dee)).status, 404);++ assert.match((await page('/sitemap.xml', null)).text, /\/s\/saturday-raiders/);++ assert.doesNotMatch((await page('/sitemap.xml', null)).text, /book-club/);++});++++t('account deletion: memberships go; an owned server stays without an owner while people are in it', async () => {++ const accountData = require('../server/chat/account-data');++ const d = require('../server/db/database');++ const sent = [];++ const send = async (p, body) => { sent.push({ path: p, body }); return { ok: true, status: 200 }; };++ const solo = (await api('POST', '/', ann, { name: 'Just Ann' })).body.server;++ const del = ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject: ann.subject_id, aliases: [], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });++ assert.strictEqual(await accountData.apply(del, { send }), 'erased');++ const raiders = await d.get("SELECT owner_subject, archived_at FROM chat_servers WHERE slug = 'saturday-raiders'");++ assert.deepStrictEqual([raiders.owner_subject, raiders.archived_at], [null, null], 'the server stays for its people');++ assert.strictEqual(await d.get('SELECT 1 FROM chat_servers WHERE id = ?', [solo.id]), undefined, 'a server with nobody else goes');++ assert.strictEqual((await d.all('SELECT * FROM chat_server_members WHERE user_id = ?', [ann.id])).length, 0);++ assert.ok(sent[0].body.erased.servers >= 1 && sent[0].body.retained.servers_without_owner >= 1, JSON.stringify(sent[0].body));++});++++t.run(async () => { if (h && h.close) await h.close(); });
@@ -308,11 +308,33 @@ only; members see names and handles, managers the set-up); the settings page man
@@ -308,11 +308,33 @@ only; members see names and handles, managers the set-up); the settings page man
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
member can also be added by account subject: `POST /api/chat/rooms/:slug/members { subject: "usr_…" }`. Deleting an
account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
account keeps the AI members it added (they belong to the room) without its subject on them, and exports them.
-**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/messages` the DM inbox
+**Servers** (`server/rooms/servers.js`, `server/rooms/server-routes.js`, `server/web/server-pages.js`,
+`migrations/0013_chat_servers.sql`; the owner's Q14: friend groups and couples first, then communities with servers,
+channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles: a text
+channel is a community room and a voice channel a call room (its call is `room-<slug>`), so messages, live delivery,
+unread counts, calls and AI members work in channels unchanged. `rooms.access()` asks the server for any room with
+`server_id`: the owner is the channel's owner, admins and moderators its moderators, members its members (in a voice
+channel: speakers by default, or listeners when the channel says participant); a public server is read by anyone
+and joined by signed-in people, a private one is a 404 to outsiders and joined only through an invite
+(`/join/<code>`: any number of uses or 1/10/100, for a day, a week, a month or forever, revocable). The owner appoints
+admins, admins appoint moderators and manage channels, settings and invites, moderators remove and ban members;
+nobody acts on their own rank or above, and every action is logged (scope `server:<slug>`). A server starts with
+`#general` and a voice Lounge; a person owns up to 5, a server has up to 50 channels and 25 live invites and keeps one
+text channel. A channel's room slug is random (`ch…`); its page is `/s/<server>/<channel>` and `/r/<slug>` goes
+there. Channels follow the server's visibility (going private redacts what they had published); the rooms API refuses
+room-level joins, roles and visibility there, and `/rooms` leaves channels out. The page: your servers down the side
+with unread counts, the channels, the channel (its call for a voice channel) and the people by rank; on a phone the
+servers run across the top and the channel list folds away. API: `/api/chat/servers` (list, create, read, update,
+delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:code`). Deleting an account removes its
+memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when
+nobody is.
+
+**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/servers`, `/s/:slug[/:channel]`,
+`/s/:slug/settings` and `/join/:code` (servers, above), `/messages` the DM inbox
(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),
(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),
`/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Block
`/messages/:id` a conversation (participants only; a guessed id is the same 404 as a missing one; Block
the other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation
the other person of a 1:1 conversation, which closes it both ways), `/settings`, `/updates`. The navigation
-counts unread messages and room messages. Everything is server-rendered and works without JavaScript.
+counts unread messages, room messages and server messages. Everything is server-rendered and works without JavaScript.
**Working with Live down.** Nothing on openvibe.chat waits on Live: people sign in from their Network token
**Working with Live down.** Nothing on openvibe.chat waits on Live: people sign in from their Network token
and the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and
and the `ctx_users` projection, pages read Chat's own tables, cosmetics are waited for at most 1.5 s, and
@@ -469,7 +491,8 @@ docs/ calls-cutover.md, parity.md, chat-ingress.md, capabil
@@ -469,7 +491,8 @@ docs/ calls-cutover.md, parity.md, chat-ingress.md, capabil
## Owns
## Owns
-- room types: global, stream, channel, community, DM, group-DM, call, system
+- room types: global, stream, channel, community, DM, group-DM, call, system; servers (groups of rooms as text and
+ voice channels, with server roles and invites)
- messages, replies/rich payloads, membership/roles, unread/read state, presence/typing (ephemeral)
- messages, replies/rich payloads, membership/roles, unread/read state, presence/typing (ephemeral)
- bans, timeouts, delete/purge, filters, slow/member-only modes
- bans, timeouts, delete/purge, filters, slow/member-only modes
- TTS/audio/soundboard/media-request queues with skip/clear/failure lifecycle
- TTS/audio/soundboard/media-request queues with skip/clear/failure lifecycle
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+-- phase: expand
+-- Servers (owner 2026-09-28 Q14: friend groups and couples first, then communities with servers, channels and voice):
+-- a server is a group of rooms, its channels, with one membership and one set of roles for all of them
+-- (server/rooms/servers.js). A channel is an ordinary room with server_id set: text channels are community rooms,
+-- voice channels are call rooms, so messages, live delivery, calls and AI members work in them unchanged. A
+-- channel's room_members rows only keep where each person read up to; what they may do comes from the server.
+CREATE TABLE IF NOT EXISTS chat_servers (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ slug text COLLATE "C" UNIQUE NOT NULL, -- /s/<slug>
+ name text COLLATE "C" NOT NULL, -- 2-40 characters
+ description text COLLATE "C", -- <= 300 characters
+ visibility text COLLATE "C" NOT NULL DEFAULT 'public',-- public: listed, anyone reads, signed-in people join; private: invites only
+ owner_id bigint NOT NULL,
+ owner_subject text COLLATE "C", -- usr_… (NULL once that account is deleted)
+ member_count integer NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ archived_at text COLLATE "C",
+ CHECK (visibility IN ('public', 'private'))
+);
+CREATE INDEX IF NOT EXISTS idx_chat_servers_public ON chat_servers (visibility, member_count DESC) WHERE archived_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_chat_servers_owner ON chat_servers (owner_id);
+
+CREATE TABLE IF NOT EXISTS chat_server_members (
+ server_id bigint NOT NULL REFERENCES chat_servers(id) ON DELETE CASCADE,
+ user_id bigint NOT NULL,
+ role text COLLATE "C" NOT NULL DEFAULT 'member', -- owner | admin | mod | member | blocked
+ joined_at text COLLATE "C" DEFAULT ov_now(),
+ PRIMARY KEY (server_id, user_id),
+ CHECK (role IN ('owner', 'admin', 'mod', 'member', 'blocked'))
+);
+CREATE INDEX IF NOT EXISTS idx_chat_server_members_user ON chat_server_members (user_id);
+
+CREATE TABLE IF NOT EXISTS chat_server_invites (
+ code text COLLATE "C" PRIMARY KEY, -- 10 characters, /join/<code>
+ server_id bigint NOT NULL REFERENCES chat_servers(id) ON DELETE CASCADE,
+ created_by bigint NOT NULL,
+ created_by_subject text COLLATE "C",
+ uses integer NOT NULL DEFAULT 0,
+ max_uses integer, -- NULL: no limit
+ expires_at text COLLATE "C", -- NULL: never
+ revoked_at text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now()
+);
+CREATE INDEX IF NOT EXISTS idx_chat_server_invites_server ON chat_server_invites (server_id);
+
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS server_id bigint REFERENCES chat_servers(id) ON DELETE CASCADE;
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS channel_slug text COLLATE "C"; -- the channel's name in /s/<server>/<channel>
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS position integer NOT NULL DEFAULT 0;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_server_channel ON rooms (server_id, channel_slug) WHERE server_id IS NOT NULL AND archived_at IS NULL;
@@ -124,3 +124,98 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -124,3 +124,98 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-ai-member h3 { margin: 0 0 6px; font-size: 1rem; }
.oc-ai-member h3 { margin: 0 0 6px; font-size: 1rem; }
.oc-ai-form textarea { width: 100%; box-sizing: border-box; }
.oc-ai-form textarea { width: 100%; box-sizing: border-box; }
@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .oc-badge.oc-ai { color: #6d28d9; border-color: rgba(109, 40, 217, .45); } :root:not([data-theme="dark"]) .oc-ai-name { color: #5b21b6; } }
@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .oc-badge.oc-ai { color: #6d28d9; border-color: rgba(109, 40, 217, .45); } :root:not([data-theme="dark"]) .oc-ai-name { color: #5b21b6; } }
+
+/* Servers (server/web/server-pages.js): your servers down the side, the channels, the channel, the people. */
+.oc-main--wide { max-width: 1440px; }
+.oc-srv { display: grid; grid-template-columns: 64px 232px minmax(0, 1fr) 212px; height: min(80vh, 900px); min-height: 480px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; overflow: hidden; }
+.oc-srv-rail { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px 0; background: var(--oc-bg); border-right: 1px solid var(--oc-line); overflow-y: auto; scrollbar-width: none; }
+.oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-hue, 215) 52% 34%); color: #fff; font-weight: 800; font-size: .92rem; letter-spacing: .02em; text-decoration: none; transition: border-radius .15s ease, background-color .15s ease; }
+.oc-srv-icon:hover, .oc-srv-icon:focus-visible, .oc-srv-icon.is-active { border-radius: 14px; }
+.oc-srv-rail .oc-srv-icon.is-active::before { content: ''; position: absolute; left: -10px; top: 9px; bottom: 9px; width: 4px; border-radius: 0 4px 4px 0; background: var(--oc-text); }
+.oc-srv-add, .oc-srv-explore { background: var(--oc-panel-2); color: var(--oc-ok); font-size: 1.35rem; font-weight: 500; }
+.oc-srv-explore { color: var(--oc-accent); font-size: 1.15rem; }
+.oc-srv-add:hover, .oc-srv-explore:hover { background: var(--oc-panel); }
+.oc-srv-dot, .oc-chan-dot { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--oc-red); color: #1a0505; font-size: .66rem; font-weight: 800; line-height: 18px; text-align: center; }
+.oc-srv-dot { position: absolute; right: -5px; bottom: -4px; border: 2px solid var(--oc-bg); line-height: 14px; }
+.oc-srv-side { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 14px 10px; overflow-y: auto; border-right: 1px solid var(--oc-line); }
+.oc-srv-head { padding: 0 6px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--oc-line); }
+.oc-srv-head .oc-badge { margin-left: 0; }
+.oc-srv-name { font-size: 1.08rem; line-height: 1.3; margin: 0 0 2px; overflow-wrap: anywhere; }
+.oc-srv-meta { margin: 2px 0 0; font-size: .82rem; }
+.oc-srv-desc { margin: 6px 0 0; font-size: .86rem; color: var(--oc-muted); }
+.oc-srv-channels > summary { display: none; }
+.oc-srv-group { margin: 14px 8px 4px; font-size: .7rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--oc-muted); }
+.oc-srv-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }
+.oc-chan { display: flex; align-items: center; gap: 7px; padding: 5px 8px; border-radius: 8px; color: var(--oc-muted); text-decoration: none; }
+.oc-chan:hover, .oc-chan:focus-visible { background: var(--oc-panel-2); color: var(--oc-text); }
+.oc-chan.is-active { background: color-mix(in srgb, var(--oc-accent) 18%, transparent); color: var(--oc-text); }
+.oc-chan.is-unread { color: var(--oc-text); font-weight: 700; }
+.oc-chan-hash { width: 16px; text-align: center; font-weight: 700; opacity: .75; }
+.oc-chan-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-chan-in { font-size: .78rem; }
+.oc-ico { flex: 0 0 auto; vertical-align: -3px; }
+.oc-srv-join, .oc-srv-invite { margin: 14px 6px 0; }
+.oc-srv-join button, .oc-srv-invite .oc-button { width: 100%; text-align: center; }
+.oc-button--quiet { background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); }
+.oc-button--quiet:hover { border-color: var(--oc-accent); }
+.oc-srv-tools { margin: auto 6px 0; padding-top: 14px; font-size: .86rem; }
+.oc-srv-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: color-mix(in srgb, var(--oc-panel-2) 60%, var(--oc-panel)); }
+.oc-srv-main .oc-msg:hover { background: color-mix(in srgb, var(--oc-text) 5%, transparent); }
+.oc-chan-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 12px 18px; border-bottom: 1px solid var(--oc-line); }
+.oc-chan-head h2 { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 1.04rem; }
+.oc-chan-topic { min-width: 0; font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here { margin: 10px 18px 0; }
+.oc-srv-main > .oc-call { margin: 12px 18px 0; }
+.oc-srv-feed { flex: 1; min-height: 0; max-height: none; margin: 0; padding: 10px 8px; background: transparent; border: 0; border-radius: 0; }
+.oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { margin: 0; padding: 12px 18px 16px; border-top: 1px solid var(--oc-line); }
+.oc-srv-people { min-width: 0; padding: 14px 12px; overflow-y: auto; border-left: 1px solid var(--oc-line); }
+.oc-srv-people > details > summary { display: none; }
+.oc-srv-people .oc-srv-group { margin-top: 4px; }
+.oc-srv-people .oc-srv-group ~ .oc-srv-group { margin-top: 16px; }
+.oc-srv-people-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; }
+.oc-srv-people-list a { display: block; padding: 3px 8px; border-radius: 6px; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-srv-people-list a:hover { background: var(--oc-panel-2); }
+/* The directory, an invite, settings */
+.oc-srv-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
+.oc-srv-card a { display: flex; align-items: center; gap: 12px; height: 100%; padding: 12px 14px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; color: var(--oc-text); text-decoration: none; }
+.oc-srv-card a:hover, .oc-srv-card a:focus-visible { border-color: var(--oc-accent); }
+.oc-srv-card-text { display: flex; flex-direction: column; min-width: 0; }
+.oc-srv-card-text .oc-muted { font-size: .88em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.oc-srv-icon--big { width: 72px; height: 72px; margin: 0 auto 6px; border-radius: 22px; font-size: 1.5rem; }
+.oc-invite-card { max-width: 440px; margin: 32px auto; padding: 28px 24px; text-align: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 16px; }
+.oc-invite-card h1 { margin-top: 0; }
+.oc-invite-card form, .oc-invite-card .oc-notice { margin-top: 16px; }
+.oc-invite-url { font-size: .85em; overflow-wrap: anywhere; }
+.oc-chan-rows li { align-items: flex-start; }
+.oc-chan-rows li > span { flex: 1 1 200px; min-width: 0; }
+.oc-chan-edit { flex-basis: 100%; }
+.oc-chan-edit summary { cursor: pointer; color: var(--oc-accent); font-size: .9rem; }
+.oc-chan-edit .oc-form { margin-top: 8px; }
+.oc-danger { background: var(--oc-red); color: #1a0505; }
+.oc-link.oc-danger-link { color: var(--oc-red); margin-left: 8px; }
+@media (max-width: 1180px) {
+ .oc-srv { grid-template-columns: 64px 220px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; height: auto; min-height: 0; }
+ .oc-srv-rail, .oc-srv-side, .oc-srv-main { height: min(78vh, 860px); }
+ .oc-srv-people { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--oc-line); max-height: 260px; }
+ .oc-srv-people > details > summary { display: list-item; cursor: pointer; font-weight: 700; margin-bottom: 6px; }
+ .oc-srv-people-list { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
+}
+@media (max-width: 760px) {
+ .oc-srv { grid-template-columns: minmax(0, 1fr); border-radius: 12px; }
+ .oc-srv-rail, .oc-srv-side, .oc-srv-main { height: auto; }
+ .oc-srv-rail { flex-direction: row; justify-content: flex-start; padding: 10px 12px; gap: 10px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--oc-line); }
+ .oc-srv-rail .oc-srv-icon.is-active::before { left: 9px; right: 9px; top: auto; bottom: -10px; width: auto; height: 4px; border-radius: 4px 4px 0 0; }
+ .oc-srv-side { border-right: 0; border-bottom: 1px solid var(--oc-line); padding: 12px; }
+ .oc-srv-channels > summary { display: list-item; cursor: pointer; font-weight: 700; padding: 4px 6px; }
+ .oc-srv-tools { margin-top: 8px; }
+ .oc-srv-feed { max-height: 62vh; min-height: 220px; }
+ .oc-chan-head, .oc-srv-main > .oc-compose, .oc-srv-main > .oc-signin, .oc-srv-main > form { padding-left: 12px; padding-right: 12px; }
+ .oc-srv-main > .oc-notice, .oc-srv-main > .oc-ai-here, .oc-srv-main > .oc-call { margin-left: 12px; margin-right: 12px; }
+}
+
+.oc-srv-card-title { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; }
+.oc-srv-card-title .oc-badge { margin-left: 0; }
+.oc-badge-new { color: var(--oc-text); border-color: var(--oc-red); }
+.oc-moves { display: inline-flex; gap: 4px; margin-left: auto; }
+.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }
+.oc-move:hover { border-color: var(--oc-accent); }
@@ -259,7 +259,7 @@
@@ -259,7 +259,7 @@
} else if (m.type === 'room_error' && m.message) {
} else if (m.type === 'room_error' && m.message) {
showError(m.message);
showError(m.message);
} else if (m.type === 'room_left') {
} else if (m.type === 'room_left') {
- showError('You can no longer read this room.'); setTimeout(function () { location.href = '/rooms'; }, 1500);
+ showError('You can no longer read this room.'); setTimeout(function () { location.href = cfg.back || '/rooms'; }, 1500);
} else if (m.type === 'room_access' && m.room === cfg.room && m.can) {
} else if (m.type === 'room_access' && m.room === cfg.room && m.can) {
// Your role changed (a moderator made you a member, a speaker, a viewer…): the page shows what you may do now.
// Your role changed (a moderator made you a member, a speaker, a viewer…): the page shows what you may do now.
if (!!m.can.post !== !!cfg.post || (m.role || null) !== (cfg.role || null)) { showError('Your role in this room changed.'); setTimeout(function () { location.reload(); }, 1200); }
if (!!m.can.post !== !!cfg.post || (m.role || null) !== (cfg.role || null)) { showError('Your role in this room changed.'); setTimeout(function () { location.reload(); }, 1200); }
@@ -329,6 +329,9 @@
@@ -329,6 +329,9 @@
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(); }
});
});
+ // A server's channel list folds away on a phone: the channel's own header says where you are.
+ var chans = document.querySelector ? document.querySelector('.oc-srv-channels') : null;
+ if (chans && window.matchMedia && window.matchMedia('(max-width: 760px)').matches) chans.open = false;
toBottom();
toBottom();
if (cfg.view === 'global') startGlobal();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
else if (cfg.view === 'dm') startDm();
@@ -242,6 +242,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -242,6 +242,7 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
for (const [mount, router] of Object.entries(require('./prefs/routes').ROUTES)) app.use(`/api/chat/${mount}`, router);
// Chat rooms (server/rooms/): before /api/chat, whose /:streamId routes would take /rooms/…
// 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/rooms', require('./rooms/routes'));
+ app.use('/api/chat/servers', require('./rooms/server-routes'));
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
app.use('/api/chat/channels', require('./chat/channel-mod-routes'));
// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
// ICE servers for call rooms (STUN, and TURN with credentials when TURN_URL is set): server/net/turn.js.
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
* - chat, room and direct messages they sent, their DM memberships (a
* - chat, room and direct messages they sent, their DM memberships (a
* conversation left with nobody goes), room memberships and attachments;
* conversation left with nobody goes), room memberships and attachments;
* - rooms they own go when nobody else is in them, and otherwise stay without an
* - rooms they own go when nobody else is in them, and otherwise stay without an
- * owner; calls they made or took, and blocks both ways;
+ * owner (servers they own too, with their channels); their server memberships;
+ * calls they made or took, and blocks both ways;
* - their channel's emotes, sounds, moderators, first-chat stats and audio
* - their channel's emotes, sounds, moderators, first-chat stats and audio
* requests, their tags, and the AI summaries and timeline about them;
* requests, their tags, and the AI summaries and timeline about them;
* - the channel, stream and user mirrors of their Live account (the user mirror
* - the channel, stream and user mirrors of their Live account (the user mirror
@@ -47,7 +48,10 @@ const EXPORTS = [
@@ -47,7 +48,10 @@ const EXPORTS = [
['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['room_messages.json', `SELECT * FROM room_messages WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['direct_messages.json', `SELECT * FROM dm_messages WHERE sender_subject_id = ANY(@s) OR sender_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['direct_messages.json', `SELECT * FROM dm_messages WHERE sender_subject_id = ANY(@s) OR sender_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['conversations.json', `SELECT * FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['conversations.json', `SELECT * FROM dm_participants WHERE subject_id = ANY(@s) OR user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
- ['rooms_owned.json', `SELECT * FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['rooms_owned.json', `SELECT * FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['servers_owned.json', `SELECT * FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
+ ['server_memberships.json', `SELECT m.server_id, s.slug, s.name, m.role, m.joined_at FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id WHERE m.user_id = ANY(@ids) ORDER BY m.joined_at DESC LIMIT ${LIMIT}`],
+ ['server_invites_made.json', `SELECT code, server_id, uses, max_uses, expires_at, revoked_at, created_at FROM chat_server_invites WHERE created_by_subject = ANY(@s) OR created_by = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['calls.json', `SELECT * FROM calls WHERE created_by_subject = ANY(@s) OR target_subject = ANY(@s) OR created_by = ANY(@ids) OR target_user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],
['blocks.json', `SELECT * FROM network_blocks WHERE blocker_subject = ANY(@s) ORDER BY updated_at DESC, blocked_subject DESC LIMIT ${LIMIT}`],
['dm_blocks.json', `SELECT * FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['dm_blocks.json', `SELECT * FROM dm_blocks WHERE blocker_subject_id = ANY(@s) OR blocker_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
@@ -117,7 +121,22 @@ async function erase(d, subjects) {
@@ -117,7 +121,22 @@ async function erase(d, subjects) {
const del = async ([key, text]) => { const st = d.prepare(text); add(erased, key, (await (text.includes('@') ? st.run(p) : st.run())).changes); };
const del = async ([key, text]) => { const st = d.prepare(text); add(erased, key, (await (text.includes('@') ? st.run(p) : st.run())).changes); };
await d.tx(async () => {
await d.tx(async () => {
for (const e of ERASE_FIRST) await del(e);
for (const e of ERASE_FIRST) await del(e);
- for (const r of await d.prepare('SELECT id FROM rooms WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)').all(p)) {
+ // Servers (migration 0013): their memberships go; a server they own stays, without an owner, while anyone else
+ // is in it, and goes with its channels when nobody is; invites they made stay, without their name.
+ add(erased, 'server_memberships', (await d.prepare("DELETE FROM chat_server_members WHERE user_id = ANY(@ids) AND role != 'owner'").run(p)).changes);
+ for (const sv of await d.prepare('SELECT id FROM chat_servers WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids)').all(p)) {
+ const others = await d.prepare("SELECT 1 FROM chat_server_members WHERE server_id = ? AND role != 'owner' AND role != 'blocked' LIMIT 1").get(sv.id);
+ await d.prepare("DELETE FROM chat_server_members WHERE server_id = ? AND role = 'owner'").run(sv.id);
+ if (others) { await d.prepare('UPDATE chat_servers SET owner_subject = NULL WHERE id = ?').run(sv.id); add(retained, 'servers_without_owner', 1); }
+ else {
+ await d.prepare('DELETE FROM room_messages WHERE room_id IN (SELECT id FROM rooms WHERE server_id = ?)').run(sv.id);
+ await d.prepare('DELETE FROM rooms WHERE server_id = ?').run(sv.id);
+ await d.prepare('DELETE FROM chat_servers WHERE id = ?').run(sv.id);
+ add(erased, 'servers', 1);
+ }
+ }
+ await d.prepare('UPDATE chat_server_invites SET created_by_subject = NULL WHERE created_by_subject = ANY(@s)').run(p);
+ for (const r of await d.prepare('SELECT id FROM rooms WHERE (owner_subject = ANY(@s) OR owner_id = ANY(@ids)) AND server_id IS NULL').all(p)) {
const others = await d.prepare('SELECT 1 FROM room_members WHERE room_id = ? LIMIT 1').get(r.id);
const others = await d.prepare('SELECT 1 FROM room_members WHERE room_id = ? LIMIT 1').get(r.id);
if (others) { await d.prepare('UPDATE rooms SET owner_subject = NULL WHERE id = ?').run(r.id); add(retained, 'rooms_without_owner', 1); }
if (others) { await d.prepare('UPDATE rooms SET owner_subject = NULL WHERE id = ?').run(r.id); add(retained, 'rooms_without_owner', 1); }
else {
else {
@@ -99,6 +99,7 @@ async function publicRoom(r, extra = {}) {
@@ -99,6 +99,7 @@ async function publicRoom(r, extra = {}) {
...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),
...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),
slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
created_at: r.created_at, members: (await db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
created_at: r.created_at, members: (await db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
+ ...(r.server_id ? { server: { slug: (await db.get('SELECT slug FROM chat_servers WHERE id = ?', [r.server_id]) || {}).slug || null, channel: r.channel_slug } } : {}),
...extra,
...extra,
};
};
}
}
@@ -113,6 +114,8 @@ async function bySlug(slug) {
@@ -113,6 +114,8 @@ async function bySlug(slug) {
* `join` and `talk` are the room's call (call rooms only).
* `join` and `talk` are the room's call (call rooms only).
*/
*/
async function access(room, user) {
async function access(room, user) {
+ // A server's channel: the server decides (server/rooms/servers.js).
+ if (room.server_id) return await require('./servers').channelAccess(room, user);
const kind = kindOf(room);
const kind = kindOf(room);
const role = user ? await roleOf(room.id, user.id) : null;
const role = user ? await roleOf(room.id, user.id) : null;
const staff = isStaff(user);
const staff = isStaff(user);
@@ -140,7 +143,7 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
@@ -140,7 +143,7 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');
if (kind === 'system' && !isStaff(user)) fail(403, 'rooms.kind', 'Only chat staff create system rooms');
if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
if (join_role != null && join_role !== '' && (kind !== 'call' || !JOIN_ROLES_CALL.includes(join_role))) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
if (wordFilter.check(n).safe === false) fail(422, 'rooms.name_filtered', 'That room name is not allowed');
- const owned = (await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL', [user.id])).n;
+ const owned = (await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND archived_at IS NULL AND server_id 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 (owned >= MAX_ROOMS_PER_OWNER && !isStaff(user)) fail(429, 'rooms.too_many', `You can own up to ${MAX_ROOMS_PER_OWNER} rooms`);
if (await bySlug(s)) fail(409, 'rooms.taken', 'That room address is taken');
if (await 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 t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) : null;
@@ -157,20 +160,22 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
@@ -157,20 +160,22 @@ async function create(user, { name, slug, topic, visibility = 'public', kind = '
async function list(user, { limit = 50 } = {}) {
async function list(user, { limit = 50 } = {}) {
await ensureSchema();
await ensureSchema();
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
const lim = Math.min(100, Math.max(1, Number(limit) || 50));
- const pub = (await Promise.all((await 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(async (r) => await publicRoom(r))));
+ // A server's channels are listed with their server (server/rooms/servers.js), not here.
+ const pub = (await Promise.all((await db.all("SELECT * FROM rooms WHERE visibility = 'public' AND archived_at IS NULL AND server_id IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?", [lim])).map(async (r) => await publicRoom(r))));
const mine = user ? (await Promise.all((await db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,
const mine = user ? (await Promise.all((await db.all(`SELECT r.*, m.role, m.last_read_id, m.last_seen_at,
(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
(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
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
+ WHERE m.user_id = ? AND m.role != 'blocked' AND r.archived_at IS NULL AND r.server_id IS NULL
ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id])).map(async (r) => await publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })))) : [];
ORDER BY COALESCE(r.last_message_at, r.created_at) DESC`, [user.id, user.id])).map(async (r) => await publicRoom(r, { role: r.role, unread: r.unread, last_seen_at: r.last_seen_at || null })))) : [];
return { public: pub, mine };
return { public: pub, mine };
}
}
/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */
/** Unread messages across the rooms `user` belongs to (the site's navigation shows it). */
-async function unreadTotal(user) {
+async function unreadTotal(user, { servers = false } = {}) {
if (!user) return 0;
if (!user) return 0;
await ensureSchema();
await ensureSchema();
- return (await db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL
+ // Rooms count their own unread messages; servers (servers: true) count their channels'.
+ return (await db.get(`SELECT COUNT(*) AS n FROM room_members m JOIN rooms r ON r.id = m.room_id AND r.archived_at IS NULL AND r.server_id IS ${servers ? 'NOT ' : ''}NULL
JOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_id
JOIN room_messages x ON x.room_id = m.room_id AND x.id > m.last_read_id AND x.is_deleted = 0 AND x.user_id != m.user_id
WHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;
WHERE m.user_id = ? AND m.role != 'blocked'`, [user.id]) || {}).n || 0;
}
}
@@ -178,6 +183,7 @@ async function unreadTotal(user) {
@@ -178,6 +183,7 @@ async function unreadTotal(user) {
/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */
/** Join a public room (staff: any room) with the kind's join role; returns the caller's role. */
async function join(room, user) {
async function join(room, user) {
if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to join a room');
+ if (room.server_id) fail(409, 'rooms.server_channel', 'This is a server\'s channel: join the server to post in it');
const role = await roleOf(room.id, user.id);
const role = await roleOf(room.id, user.id);
if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
if (role === 'blocked') fail(403, 'rooms.blocked', 'You are blocked from this room');
if (role) return role;
if (role) return role;
@@ -190,6 +196,7 @@ async function join(room, user) {
@@ -190,6 +196,7 @@ async function join(room, user) {
}
}
async function leave(room, user) {
async function leave(room, user) {
+ if (room.server_id) fail(409, 'rooms.server_channel', 'This is a server\'s channel: leave the server instead');
const role = await roleOf(room.id, user && user.id);
const role = await roleOf(room.id, user && user.id);
if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');
if (role === 'owner') fail(409, 'rooms.owner_leaves', 'The owner cannot leave their room');
if (role && role !== 'blocked') await db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);
if (role && role !== 'blocked') await db.run('DELETE FROM room_members WHERE room_id = ? AND user_id = ?', [room.id, user.id]);
@@ -310,6 +317,10 @@ async function markRead(room, user, lastId) {
@@ -310,6 +317,10 @@ async function markRead(room, user, lastId) {
if (!user) return;
if (!user) return;
const top = (await db.get('SELECT MAX(id) AS id FROM room_messages WHERE room_id = ?', [room.id]) || {}).id || 0;
const top = (await 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);
const to = Math.min(top, Number(lastId) || top);
+ // A server channel keeps a read row for each member (made on joining; a channel made later gets one here).
+ if (room.server_id && (await access(room, user)).role) {
+ await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) VALUES (?, ?, 'member', ?, ov_now()) ON CONFLICT (room_id, user_id) DO NOTHING", [room.id, user.id, to]);
+ }
await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
}
}
@@ -333,6 +344,7 @@ async function deleteMessage(room, user, messageId) {
@@ -333,6 +344,7 @@ async function deleteMessage(room, user, messageId) {
* chat staff's) to appoint and remove; everything else is any moderator's.
* chat staff's) to appoint and remove; everything else is any moderator's.
*/
*/
async function setRole(room, actor, targetUserId, role) {
async function setRole(room, actor, targetUserId, role) {
+ if (room.server_id) fail(409, 'rooms.server_channel', 'Roles in a server\'s channels are set for the whole server');
const kind = kindOf(room);
const kind = kindOf(room);
if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');
if (!ROLES.includes(role) && role !== 'none') fail(422, 'rooms.role', 'Unknown role');
if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
if (role === 'owner') fail(422, 'rooms.role', 'A room has one owner');
@@ -358,10 +370,12 @@ async function setRole(room, actor, targetUserId, role) {
@@ -358,10 +370,12 @@ async function setRole(room, actor, targetUserId, role) {
return role;
return role;
}
}
-async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}) {
+async function update(room, actor, { name, topic, visibility, slow_seconds, join_role } = {}, { fromServer = false, minName = 3 } = {}) {
if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
+ // A server's channel follows the server's visibility, and is changed through the server (servers.js).
+ if (room.server_id && !fromServer && visibility != null && visibility !== room.visibility) fail(409, 'rooms.server_channel', 'A channel is as public as its server: change the server instead');
const next = { ...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 (name != null) { const n = String(name).replace(/\s+/g, ' ').trim(); if (n.length < minName || n.length > 40 || !wordFilter.check(n).safe) fail(422, 'rooms.name', `Room names are ${minName} to 40 characters`); next.name = n; }
if (topic != null) next.topic = String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null;
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 (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; }
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; }
@@ -381,6 +395,13 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
@@ -381,6 +395,13 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
/** The room's people, by role. `seen` adds last_seen_at (moderators only: it is presence). */
async function members(room, { seen = false } = {}) {
async function members(room, { seen = false } = {}) {
+ // A server's channel: the server's people, with the role each has in the channel.
+ if (room.server_id) {
+ const server = await require('./servers').byId(room.server_id);
+ if (!server) return [];
+ const as = (r) => (r === 'owner' ? 'owner' : r === 'admin' || r === 'mod' ? 'mod' : kindOf(room) === 'call' ? joinRoleOf(room) : 'member');
+ return (await require('./servers').members(server)).map((m) => ({ user_id: m.user_id, role: as(m.role), server_role: m.role, username: m.username, display_name: m.display_name }));
+ }
const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
const order = `CASE m.role ${Object.entries(ROLE_ORDER).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
return await db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
return await db.all(`SELECT m.user_id, m.role, u.username, u.display_name${seen ? ', m.last_seen_at' : ''} FROM room_members m LEFT JOIN ctx_users u ON u.id = m.user_id
WHERE m.room_id = ? ORDER BY ${order}, lower(u.username) LIMIT 500`, [room.id]);
WHERE m.room_id = ? ORDER BY ${order}, lower(u.username) LIMIT 500`, [room.id]);
@@ -437,6 +458,6 @@ function _reset() { _rate.clear(); }
@@ -437,6 +458,6 @@ function _reset() { _rate.clear(); }
module.exports = {
module.exports = {
postAsAi,
postAsAi,
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
ensureSchema, create, list, bySlug, access, join, leave, post, history, markRead, deleteMessage, setRole, update, members, publicRoom, messageById,
- attach, detach, attachments, joinRoleOf, unreadTotal,
+ attach, detach, attachments, joinRoleOf, unreadTotal, isStaff, siteBanned, POSTERS, TALKERS,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
RoomError, SLUG_RE, KINDS, ROLES, KIND_ROLES, JOIN_ROLE, JOIN_ROLES_CALL, ATTACH_SERVICES, _reset,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+'use strict';
+/**
+ * /api/chat/servers — servers over REST (server/rooms/servers.js); openvibe.chat and bots use it. A channel is a room,
+ * so its messages, history, reads and calls are /api/chat/rooms/<channel.room> and /ws/chat join_room as usual.
+ *
+ * GET / public servers by size + the caller's servers (with unread counts)
+ * POST / create { name, slug?, description?, visibility? } (with #general and a voice Lounge)
+ * GET /:slug the server, its channels (members: unread counts), what the caller may do
+ * PATCH /:slug owner/admins: { name?, description?, visibility? } (channels follow it)
+ * DELETE /:slug the owner (or chat staff): the server and its channels are archived
+ * POST /:slug/join { invite? }: a public server, or any with a valid invite → { role }
+ * POST /:slug/leave
+ * GET /:slug/members (moderators also see who is banned)
+ * POST /:slug/members moderators: { username | subject, role: admin | mod | member | blocked | none }
+ * POST /:slug/channels owner/admins: { name, kind: text | voice, topic? }
+ * PATCH /:slug/channels/:channel owner/admins: { name?, topic?, slow_seconds?, join_role?, move?: up | down }
+ * DELETE /:slug/channels/:channel owner/admins (a server keeps one text channel)
+ * GET /:slug/invites owner/admins: the live invites
+ * POST /:slug/invites { max_uses?, expires_hours? } → { invite: { code, … }, url }
+ * DELETE /:slug/invites/:code
+ * GET /invites/:code what an invite opens (anyone with the code): the server's name and size
+ *
+ * A private server is a 404 to anyone who is not in it, whatever they ask.
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { requireAuth, optionalAuth } = require('../auth/auth');
+const { limits } = require('../net/actor-limits');
+const rooms = require('./rooms');
+const servers = require('./servers');
+const ctx = require('../live-context');
+
+const router = express.Router();
+const createLimit = limits('chat.server.create', { minute: 5, hour: 20 });
+const manageLimit = limits('chat.server.manage', { minute: 30, hour: 300 });
+const joinLimit = limits('chat.server.join', { minute: 20, hour: 200 });
+
+function send(res, err) {
+ if (err instanceof rooms.RoomError) return res.status(err.status).json({ error: err.message, code: err.code });
+ console.error('[Servers]', err && err.message);
+ return res.status(500).json({ error: 'Something went wrong' });
+}
+const chatServer = () => require('../chat/chat-server');
+const roomRoutes = () => require('./routes');
+const siteUrl = () => { try { return require('../config').web.baseUrl; } catch { return 'https://openvibe.chat'; } };
+
+/** After a membership, role or visibility change: every channel's sockets and calls follow the server. */
+async function applyServerChange(server) {
+ for (const r of await require('../db/database').all('SELECT * FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id])) {
+ try { await roomRoutes().applyRoomChange(r); } catch (err) { console.warn('[Servers] room update:', err.message); }
+ }
+}
+
+async function readable(req, res) {
+ const server = await servers.bySlug(req.params.slug);
+ if (!server || !(await servers.access(server, req.user || null)).read) { res.status(404).json({ error: 'No such server', code: 'servers.not_found' }); return null; }
+ return server;
+}
+async function channelOf(req, res, server) {
+ const room = await servers.channelBySlug(server, req.params.channel);
+ if (!room) { res.status(404).json({ error: 'No such channel', code: 'servers.no_channel' }); return null; }
+ return room;
+}
+const canOf = (a) => ({ read: a.read, member: a.member, moderate: a.moderate, manage: a.manage, owner: a.owner });
+
+router.get('/', optionalAuth, async (req, res) => {
+ try { res.set('Cache-Control', cache.htmlHeaders({ private: true })).json(await servers.list(req.user || null, { limit: req.query.limit })); } catch (err) { send(res, err); }
+});
+
+router.post('/', requireAuth, createLimit, async (req, res) => {
+ try { res.status(201).json({ server: await servers.create(req.user, req.body || {}) }); } catch (err) { send(res, err); }
+});
+
+router.get('/invites/:code', optionalAuth, async (req, res) => {
+ const found = await servers.inviteByCode(req.params.code);
+ if (!found) return res.status(404).json({ error: 'That invite has expired or was used up', code: 'servers.invite_invalid' });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ server: servers.publicServer(found.server), invite: { code: found.invite.code, expires_at: found.invite.expires_at || null } });
+});
+
+router.get('/:slug', optionalAuth, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const a = await servers.access(server, req.user || null);
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({
+ server: servers.publicServer(server, { role: a.role }),
+ channels: await servers.channels(server, a.member ? req.user : null),
+ can: canOf(a),
+ });
+});
+
+router.patch('/:slug', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ try { const out = await servers.update(server, req.user, req.body || {}); await applyServerChange(out); res.json({ server: servers.publicServer(out) }); } catch (err) { send(res, err); }
+});
+
+router.delete('/:slug', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ try { await servers.archive(server, req.user); await applyServerChange(server); res.status(204).end(); } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/join', requireAuth, joinLimit, async (req, res) => {
+ const server = await servers.bySlug(req.params.slug);
+ if (!server) return res.status(404).json({ error: 'No such server', code: 'servers.not_found' });
+ try { const role = await servers.join(server, req.user, { invite: req.body && req.body.invite }); await applyServerChange(server); res.json({ ok: true, role }); } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/leave', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ try { await servers.leave(server, req.user); await applyServerChange(server); res.json({ ok: true }); } catch (err) { send(res, err); }
+});
+
+router.get('/:slug/members', optionalAuth, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const a = await servers.access(server, req.user || null);
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ members: await servers.members(server, { blocked: a.moderate }) });
+});
+
+router.post('/:slug/members', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ try {
+ const name = String((req.body && req.body.username) || '').trim();
+ const subject = String((req.body && req.body.subject) || '').replace(/^user:/, '').trim();
+ let target = null;
+ if (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) target = await ctx.getUserBySubject(subject);
+ else if (/^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.getUserByUsername(name) || await ctx.ensureUserByUsername(name).catch(() => null);
+ if (!target) return res.status(404).json({ error: subject ? 'No such OpenVibe account' : `Nobody called ${name.slice(0, 24)} on OpenVibe`, code: 'servers.no_user' });
+ const role = await servers.setRole(server, req.user, target.id, String((req.body && req.body.role) || 'member'));
+ if (role === 'blocked' || role == null) {
+ for (const r of await require('../db/database').all('SELECT id FROM rooms WHERE server_id = ?', [server.id])) chatServer().removeFromRoom(r.id, target.id);
+ }
+ await applyServerChange(server);
+ res.json({ ok: true, user_id: target.id, username: target.username, role });
+ } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/channels', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ try {
+ const b = req.body || {};
+ const room = await servers.createChannel(server, req.user, { name: b.name, kind: b.kind || 'text', topic: b.topic });
+ res.status(201).json({ channel: servers.publicChannel(room) });
+ } catch (err) { send(res, err); }
+});
+
+router.patch('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await channelOf(req, res, server); if (!room) return;
+ try {
+ const out = await servers.updateChannel(server, req.user, room, req.body || {});
+ await roomRoutes().applyRoomChange(out);
+ res.json({ channel: servers.publicChannel(out) });
+ } catch (err) { send(res, err); }
+});
+
+router.delete('/:slug/channels/:channel', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const room = await channelOf(req, res, server); if (!room) return;
+ try { await servers.deleteChannel(server, req.user, room); res.status(204).end(); } catch (err) { send(res, err); }
+});
+
+router.get('/:slug/invites', requireAuth, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ const a = await servers.access(server, req.user);
+ if (!a.moderate) return res.status(403).json({ error: 'Only the server\'s owner, admins and moderators see its invites', code: 'servers.not_mod' });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ invites: await servers.invites(server) });
+});
+
+router.post('/:slug/invites', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ try {
+ const invite = await servers.createInvite(server, req.user, req.body || {});
+ res.status(201).json({ invite, url: `${siteUrl()}/join/${invite.code}` });
+ } catch (err) { send(res, err); }
+});
+
+router.delete('/:slug/invites/:code', requireAuth, manageLimit, async (req, res) => {
+ const server = await readable(req, res); if (!server) return;
+ try { await servers.revokeInvite(server, req.user, req.params.code); res.status(204).end(); } catch (err) { send(res, err); }
+});
+
+module.exports = router;
+module.exports.applyServerChange = applyServerChange;
new file mode 100644
new file mode 100644
@@ -0,0 +1,417 @@
@@ -0,0 +1,417 @@
+'use strict';
+/**
+ * Servers (migration 0013; owner 2026-09-28 Q14: friend groups and couples first, then communities with servers,
+ * channels and voice). A server is a group of rooms, its channels, with one membership and one set of roles:
+ *
+ * chat_servers slug (/s/<slug>), name, description, visibility (public: listed, anyone reads, signed-in
+ * people join; private: only people with an invite), owner
+ * chat_server_members role: owner (the creator, one), admin, mod, member, blocked (banned from the server)
+ * chat_server_invites /join/<code>: any number of uses or a cap, never expiring or after a while, revocable
+ * rooms.server_id a channel: a text channel is a community room, a voice channel a call room (its call is
+ * the room's call, so voice works like any call room: who talks follows the role)
+ *
+ * What a person may do in a channel comes from the server (channelAccess, which rooms.access() calls for any room
+ * with a server): the room's own room_members rows only keep where each person read up to.
+ *
+ * server role read post voice: talk moderate (delete, kick, ban) manage (channels, settings, invites)
+ * owner yes yes yes yes yes, and appoints admins
+ * admin yes yes yes yes yes, and appoints mods
+ * mod yes yes yes yes (members only) -
+ * member yes yes as the channel says (speakers by default) - -
+ * none public server: read and listen; private server: nothing, not even that it exists
+ * blocked nothing
+ * chat staff (staff.moderation.chat): read, moderate and manage every server; a site ban: no posting, no talking
+ *
+ * Every action on another person is logged with db.logModerationAction (scope server:<slug>).
+ */
+const crypto = require('crypto');
+const db = require('../db/database');
+const ctx = require('../live-context');
+const wordFilter = require('../chat/word-filter');
+const rooms = require('./rooms');
+
+const { RoomError } = rooms;
+const fail = (status, code, message) => { throw new RoomError(status, code, message); };
+
+const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$/;
+const RESERVED = new Set(['new', 'servers', 'settings', 'join', 'invite', 'invites', 'api', 'admin', 'staff', 'help', 'explore', 'discover', 'openvibe']);
+const ROLES = ['owner', 'admin', 'mod', 'member', 'blocked'];
+const RANK = { owner: 0, admin: 1, mod: 2, member: 3, blocked: 4 };
+const CHANNEL_KINDS = { text: 'community', voice: 'call' };
+/** Channel names the server's own pages use (/s/<server>/settings …). */
+const CHANNEL_RESERVED = new Set(['settings', 'join', 'leave', 'channels', 'invites', 'members', 'delete']);
+const MAX_SERVERS_PER_OWNER = 5;
+const MAX_CHANNELS = 50;
+const MAX_INVITES = 25;
+const INVITE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
+
+const isStaff = (user) => rooms.isStaff(user);
+
+function slugify(name) {
+ return String(name || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32).replace(/-+$/g, '');
+}
+const cleanName = (v, min, max) => {
+ const n = String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
+ return n.length >= min && n.length <= max ? n : null;
+};
+const randomCode = (n) => Array.from(crypto.randomBytes(n), (b) => INVITE_ALPHABET[b % INVITE_ALPHABET.length]).join('');
+
+async function bySlug(slug) { return await db.get('SELECT * FROM chat_servers WHERE slug = ? AND archived_at IS NULL', [String(slug || '').toLowerCase()]) || null; }
+async function byId(id) { return await db.get('SELECT * FROM chat_servers WHERE id = ? AND archived_at IS NULL', [Number(id)]) || null; }
+async function roleIn(serverId, userId) {
+ if (!userId) return null;
+ return (await db.get('SELECT role FROM chat_server_members WHERE server_id = ? AND user_id = ?', [serverId, userId]) || {}).role || null;
+}
+
+/** What `user` may do in the server itself: { role, read, member, moderate, manage, owner }. */
+async function access(server, user) {
+ const role = user ? await roleIn(server.id, user.id) : null;
+ const staff = isStaff(user);
+ const blocked = role === 'blocked';
+ const member = !!role && !blocked;
+ return {
+ role, member, staff,
+ read: !blocked && (server.visibility === 'public' || member || staff),
+ moderate: !!user && !blocked && (['owner', 'admin', 'mod'].includes(role) || staff),
+ manage: !!user && !blocked && (['owner', 'admin'].includes(role) || staff),
+ owner: !!user && role === 'owner',
+ };
+}
+
+/**
+ * What `user` may do in a channel (a room with server_id): the room's access shape (rooms.access), decided by the
+ * server role. A channel of a server that is gone reads as nothing.
+ */
+async function channelAccess(room, user) {
+ const kind = room.kind === 'call' ? 'call' : 'community';
+ const server = await byId(room.server_id);
+ if (!server) return { role: null, kind, read: false, post: false, join: false, talk: false, moderate: false, manage: false, server: null, server_role: null };
+ const a = await access(server, user);
+ const srole = a.role;
+ // Server owner → room owner; admins and moderators → room moderators; members → the channel's member role.
+ const vrole = !a.member ? null : srole === 'owner' ? 'owner' : RANK[srole] <= RANK.mod ? 'mod' : kind === 'call' ? rooms.joinRoleOf(room) : 'member';
+ const banned = rooms.siteBanned(user);
+ const post = !!user && a.read && !banned && a.member && rooms.POSTERS[kind].has(vrole);
+ const join = kind === 'call' && a.read;
+ const talk = join && !!user && !banned && ((a.member && rooms.TALKERS.has(vrole)) || a.staff);
+ return { role: vrole, kind, read: a.read, post, join, talk, moderate: a.moderate, manage: a.manage, server: server.slug, server_role: srole };
+}
+
+function publicServer(s, extra = {}) {
+ if (!s) return null;
+ return {
+ id: Number(s.id), slug: s.slug, name: s.name, description: s.description || null, visibility: s.visibility,
+ members: Number(s.member_count) || 0, created_at: s.created_at, ...extra,
+ };
+}
+
+/** A channel as the server shows it. */
+function publicChannel(r, extra = {}) {
+ return {
+ slug: r.channel_slug, room: r.slug, name: r.name, topic: r.topic || null, kind: r.kind === 'call' ? 'voice' : 'text',
+ position: Number(r.position) || 0, message_count: Number(r.message_count) || 0, last_message_at: r.last_message_at || null,
+ ...(r.kind === 'call' ? { call_channel: `room-${r.slug}`, join_role: rooms.joinRoleOf(r) } : {}), ...extra,
+ };
+}
+
+/** The server's channels, text first, each kind in its order. `user` adds unread counts (members). */
+async function channels(server, user = null) {
+ const rows = await db.all(`SELECT r.* ${user ? ', m.last_read_id' : ''} FROM rooms r
+ ${user ? 'LEFT JOIN room_members m ON m.room_id = r.id AND m.user_id = ?' : ''}
+ WHERE r.server_id = ? AND r.archived_at IS NULL ORDER BY CASE r.kind WHEN 'call' THEN 1 ELSE 0 END, r.position, r.id`,
+ user ? [user.id, server.id] : [server.id]);
+ if (!user) return rows.map((r) => publicChannel(r));
+ const out = [];
+ for (const r of rows) {
+ const unread = r.kind === 'call' && !r.message_count ? 0 : Number((await db.get(
+ 'SELECT COUNT(*) AS n FROM room_messages WHERE room_id = ? AND id > ? AND is_deleted = 0 AND user_id != ?', [r.id, Number(r.last_read_id) || 0, user.id]) || {}).n) || 0;
+ out.push(publicChannel(r, { unread: r.last_read_id == null ? Math.min(unread, 99) : unread }));
+ }
+ return out;
+}
+async function channelBySlug(server, channelSlug) {
+ return await db.get('SELECT * FROM rooms WHERE server_id = ? AND channel_slug = ? AND archived_at IS NULL', [server.id, String(channelSlug || '').toLowerCase()]) || null;
+}
+
+async function insertChannel(server, user, { name, kind, topic = null, position = null }) {
+ const n = cleanName(name, 1, 40);
+ if (!n) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');
+ if (wordFilter.check(n).safe === false) fail(422, 'servers.channel_name_filtered', 'That channel name is not allowed');
+ const roomKind = CHANNEL_KINDS[kind];
+ if (!roomKind) fail(422, 'servers.channel_kind', 'A channel is text or voice');
+ const count = Number((await db.get('SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id]) || {}).n) || 0;
+ if (count >= MAX_CHANNELS) fail(429, 'servers.too_many_channels', `A server has up to ${MAX_CHANNELS} channels`);
+ let cs = slugify(n) || (kind === 'voice' ? 'voice' : 'chat');
+ if (cs.length < 2 || CHANNEL_RESERVED.has(cs)) cs = `${cs}-${kind}`;
+ for (let i = 2; await channelBySlug(server, cs); i++) cs = `${slugify(n).slice(0, 28) || kind}-${i}`;
+ let roomSlug;
+ do { roomSlug = `ch${randomCode(12)}`; } while (await db.get('SELECT 1 FROM rooms WHERE slug = ?', [roomSlug]));
+ const pos = position != null ? position : Number((await db.get('SELECT COALESCE(MAX(position), -1) + 1 AS p FROM rooms WHERE server_id = ? AND kind = ?', [server.id, roomKind]) || {}).p) || 0;
+ const t = topic ? String(topic).replace(/\s+/g, ' ').trim().slice(0, 200) || null : null;
+ // The server owns its channels: owner_subject stays empty, so a person's own-room tools never act on one.
+ const r = await db.run(`INSERT INTO rooms (slug, name, topic, kind, visibility, owner_id, owner_subject, join_role, server_id, channel_slug, position)
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)`,
+ [roomSlug, n, t, roomKind, server.visibility, server.owner_id, roomKind === 'call' ? 'speaker' : null, server.id, cs, pos]);
+ return await db.get('SELECT * FROM rooms WHERE id = ?', [r.lastInsertRowid]);
+}
+
+async function create(user, { name, slug, description, visibility = 'public' } = {}) {
+ if (!user) fail(401, 'servers.sign_in', 'Sign in to create a server');
+ if (rooms.siteBanned(user)) fail(403, 'servers.banned', 'Your account is banned from chat');
+ const n = cleanName(name, 2, 40);
+ if (!n) fail(422, 'servers.name', 'Server names are 2 to 40 characters');
+ if (wordFilter.check(n).safe === false) fail(422, 'servers.name_filtered', 'That server name is not allowed');
+ const s = slug ? String(slug).toLowerCase().trim() : slugify(n);
+ if (!SLUG_RE.test(s) || RESERVED.has(s)) fail(422, 'servers.slug', 'Server addresses are 3 to 32 lowercase letters, numbers and dashes (and not a reserved word)');
+ if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private');
+ const d = description ? String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null : null;
+ if (d && wordFilter.check(d).safe === false) fail(422, 'servers.description_filtered', 'That description is not allowed');
+ const owned = Number((await db.get('SELECT COUNT(*) AS n FROM chat_servers WHERE owner_id = ? AND archived_at IS NULL', [user.id]) || {}).n) || 0;
+ if (owned >= MAX_SERVERS_PER_OWNER && !isStaff(user)) fail(429, 'servers.too_many', `You can own up to ${MAX_SERVERS_PER_OWNER} servers`);
+ if (await db.get('SELECT 1 FROM chat_servers WHERE slug = ?', [s])) fail(409, 'servers.taken', 'That server address is taken');
+ const id = await db.tx(async () => {
+ const r = await db.run('INSERT INTO chat_servers (slug, name, description, visibility, owner_id, owner_subject, member_count) VALUES (?, ?, ?, ?, ?, ?, 1)',
+ [s, n, d, visibility, user.id, user.subject_id || null]);
+ const server = await db.get('SELECT * FROM chat_servers WHERE id = ?', [r.lastInsertRowid]);
+ await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'owner')", [server.id, user.id]);
+ await insertChannel(server, user, { name: 'general', kind: 'text', topic: 'Say hello', position: 0 });
+ await insertChannel(server, user, { name: 'Lounge', kind: 'voice', position: 0 });
+ await db.run("INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at) SELECT id, ?, 'member', 0, ov_now() FROM rooms WHERE server_id = ?", [user.id, server.id]);
+ return Number(server.id);
+ });
+ return publicServer(await byId(id), { role: 'owner' });
+}
+
+/** The servers `user` is in (with unread counts), and public servers by size. */
+async function list(user, { limit = 50 } = {}) {
+ const lim = Math.min(100, Math.max(1, Number(limit) || 50));
+ const pub = (await db.all("SELECT * FROM chat_servers WHERE visibility = 'public' AND archived_at IS NULL ORDER BY member_count DESC, id DESC LIMIT ?", [lim])).map((s) => publicServer(s));
+ if (!user) return { public: pub, mine: [] };
+ const rows = await db.all(`SELECT s.*, m.role FROM chat_server_members m JOIN chat_servers s ON s.id = m.server_id
+ WHERE m.user_id = ? AND m.role != 'blocked' AND s.archived_at IS NULL ORDER BY m.joined_at, s.id`, [user.id]);
+ const mine = [];
+ for (const s of rows) {
+ const unread = Number((await db.get(`SELECT COUNT(*) AS n FROM rooms r JOIN room_members rm ON rm.room_id = r.id AND rm.user_id = ?
+ JOIN room_messages x ON x.room_id = r.id AND x.id > rm.last_read_id AND x.is_deleted = 0 AND x.user_id != ?
+ WHERE r.server_id = ? AND r.archived_at IS NULL`, [user.id, user.id, s.id]) || {}).n) || 0;
+ mine.push(publicServer(s, { role: s.role, unread }));
+ }
+ return { public: pub, mine };
+}
+
+async function syncCount(server) {
+ await db.run("UPDATE chat_servers SET member_count = (SELECT COUNT(*) FROM chat_server_members WHERE server_id = ? AND role != 'blocked'), updated_at = ov_now() WHERE id = ?", [server.id, server.id]);
+}
+
+/** A valid invite by its code (with its server), or null. */
+async function inviteByCode(code) {
+ const c = String(code || '').toLowerCase();
+ if (!/^[a-z0-9]{6,16}$/.test(c)) return null;
+ const inv = await db.get('SELECT * FROM chat_server_invites WHERE code = ? AND revoked_at IS NULL', [c]);
+ if (!inv) return null;
+ if (inv.expires_at && Date.parse(inv.expires_at) <= Date.now()) return null;
+ if (inv.max_uses != null && inv.uses >= inv.max_uses) return null;
+ const server = await byId(inv.server_id);
+ return server ? { invite: inv, server } : null;
+}
+
+/** Join: a public server, or any server with a valid invite. → the caller's role. */
+async function join(server, user, { invite = null } = {}) {
+ if (!user) fail(401, 'servers.sign_in', 'Sign in to join a server');
+ const role = await roleIn(server.id, user.id);
+ if (role === 'blocked') fail(403, 'servers.blocked', 'You are banned from this server');
+ if (role) return role;
+ let inv = null;
+ if (invite) {
+ const found = await inviteByCode(invite);
+ if (!found || Number(found.server.id) !== Number(server.id)) fail(410, 'servers.invite_invalid', 'That invite has expired or was used up');
+ inv = found.invite;
+ }
+ // A private server is not there for someone without an invite (the same answer as a missing one).
+ if (server.visibility !== 'public' && !inv && !isStaff(user)) fail(404, 'servers.not_found', 'No such server');
+ await db.tx(async () => {
+ await db.run("INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, 'member') ON CONFLICT DO NOTHING", [server.id, user.id]);
+ // Everything already in the channels counts as read: a new member starts with nothing unread.
+ await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)
+ SELECT r.id, ?, 'member', COALESCE((SELECT MAX(x.id) FROM room_messages x WHERE x.room_id = r.id), 0), ov_now()
+ FROM rooms r WHERE r.server_id = ? AND r.archived_at IS NULL
+ ON CONFLICT (room_id, user_id) DO NOTHING`, [user.id, server.id]);
+ if (inv) await db.run('UPDATE chat_server_invites SET uses = uses + 1 WHERE code = ?', [inv.code]);
+ await syncCount(server);
+ });
+ return 'member';
+}
+
+async function dropFromChannels(server, userId) {
+ await db.run('DELETE FROM room_members WHERE user_id = ? AND room_id IN (SELECT id FROM rooms WHERE server_id = ?)', [userId, server.id]);
+}
+
+async function leave(server, user) {
+ const role = await roleIn(server.id, user && user.id);
+ if (role === 'owner') fail(409, 'servers.owner_leaves', 'The owner cannot leave their server');
+ if (!role || role === 'blocked') return;
+ await db.tx(async () => {
+ await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, user.id]);
+ await dropFromChannels(server, user.id);
+ await syncCount(server);
+ });
+}
+
+/**
+ * Change someone's role: admin, mod, member, blocked (a ban) or none (kick). The owner appoints and removes admins;
+ * admins appoint mods; mods kick and ban members. Nobody acts on someone of their own rank or above, and nobody
+ * changes the owner.
+ */
+async function setRole(server, actor, targetUserId, role) {
+ if (!ROLES.includes(role) && role !== 'none') fail(422, 'servers.role', 'Unknown role');
+ if (role === 'owner') fail(422, 'servers.role', 'A server has one owner');
+ const a = await access(server, actor);
+ if (!a.moderate) fail(403, 'servers.not_mod', 'Only the server\'s owner, admins and moderators manage members');
+ const target = await ctx.getUserById(Number(targetUserId));
+ if (!target) fail(404, 'servers.no_user', 'No such person');
+ const current = await roleIn(server.id, target.id);
+ if (current === 'owner') fail(403, 'servers.owner', 'The owner\'s role does not change');
+ const mine = a.staff ? -1 : RANK[a.role];
+ const need = (r) => (r === 'admin' ? RANK.owner : r === 'mod' ? RANK.admin : RANK.mod);
+ const RANK_REFUSAL = { admin: 'Only the owner appoints admins', mod: 'Only the owner and admins appoint moderators' };
+ if (mine > need(role === 'none' || role === 'blocked' || role === 'member' ? 'member' : role)) fail(403, 'servers.rank', RANK_REFUSAL[role] || 'Only the owner, admins and moderators manage members');
+ if (current && current !== 'blocked' && current !== 'member' && mine >= RANK[current]) fail(403, 'servers.rank', 'You cannot change someone of your own rank or above');
+ if (!current && role !== 'blocked' && role !== 'none' && server.visibility !== 'public' && !a.manage) fail(403, 'servers.invite_first', 'Invite them first');
+ await db.tx(async () => {
+ if (role === 'none') {
+ if (current && current !== 'blocked') { await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, target.id]); await dropFromChannels(server, target.id); }
+ else if (current === 'blocked') await db.run('DELETE FROM chat_server_members WHERE server_id = ? AND user_id = ?', [server.id, target.id]);
+ } else if (current) {
+ await db.run('UPDATE chat_server_members SET role = ? WHERE server_id = ? AND user_id = ?', [role, server.id, target.id]);
+ if (role === 'blocked') await dropFromChannels(server, target.id);
+ } else {
+ await db.run('INSERT INTO chat_server_members (server_id, user_id, role) VALUES (?, ?, ?)', [server.id, target.id, role]);
+ }
+ await syncCount(server);
+ });
+ const action = role === 'blocked' && current !== 'blocked' ? 'server_ban'
+ : current === 'blocked' && role !== 'blocked' ? 'server_unban'
+ : role === 'none' && current ? 'server_kick'
+ : role !== current ? `server_role_${role}` : null;
+ if (action) await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: target.id, action_type: action, details: { from: current, to: role } });
+ return role === 'none' ? null : role;
+}
+
+/** The server's people by rank (blocked last, only for moderators). */
+async function members(server, { blocked = false, limit = 1000 } = {}) {
+ const order = `CASE m.role ${Object.entries(RANK).map(([r, n]) => `WHEN '${r}' THEN ${n}`).join(' ')} ELSE 9 END`;
+ return await db.all(`SELECT m.user_id, m.role, m.joined_at, u.username, u.display_name, u.profile_color FROM chat_server_members m
+ LEFT JOIN ctx_users u ON u.id = m.user_id WHERE m.server_id = ? ${blocked ? '' : "AND m.role != 'blocked'"}
+ ORDER BY ${order}, lower(u.username) LIMIT ?`, [server.id, Math.min(5000, Math.max(1, Number(limit) || 1000))]);
+}
+
+async function update(server, actor, { name, description, visibility } = {}) {
+ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins change it');
+ const next = { name: server.name, description: server.description, visibility: server.visibility };
+ if (name != null) { const n = cleanName(name, 2, 40); if (!n || wordFilter.check(n).safe === false) fail(422, 'servers.name', 'Server names are 2 to 40 characters'); next.name = n; }
+ if (description != null) next.description = String(description).replace(/\s+/g, ' ').trim().slice(0, 300) || null;
+ if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'servers.visibility', 'A server is public or private'); next.visibility = visibility; }
+ await db.run('UPDATE chat_servers SET name = ?, description = ?, visibility = ?, updated_at = ov_now() WHERE id = ?', [next.name, next.description, next.visibility, server.id]);
+ // The channels follow the server: going private redacts what public channels had published (rooms.update).
+ if (next.visibility !== server.visibility) {
+ for (const r of await db.all('SELECT * FROM rooms WHERE server_id = ? AND archived_at IS NULL', [server.id])) {
+ await rooms.update(r, actor, { visibility: next.visibility }, { fromServer: true });
+ }
+ }
+ return await byId(server.id);
+}
+
+async function createChannel(server, actor, { name, kind = 'text', topic = null } = {}) {
+ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins add channels');
+ const room = await insertChannel(server, actor, { name, kind, topic });
+ // Every member reads the new channel from its first message on.
+ await db.run(`INSERT INTO room_members (room_id, user_id, role, last_read_id, last_seen_at)
+ SELECT ?, user_id, 'member', 0, NULL FROM chat_server_members WHERE server_id = ? AND role != 'blocked'
+ ON CONFLICT (room_id, user_id) DO NOTHING`, [room.id, server.id]);
+ return room;
+}
+
+/** Rename, retopic, slow mode, a voice channel's join role, or move it up/down within its kind. */
+async function updateChannel(server, actor, room, { name, topic, slow_seconds, join_role, move } = {}) {
+ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins change channels');
+ if (Number(room.server_id) !== Number(server.id)) fail(404, 'servers.no_channel', 'No such channel');
+ if (name != null) {
+ const n = cleanName(name, 1, 40);
+ if (!n || wordFilter.check(n).safe === false) fail(422, 'servers.channel_name', 'Channel names are 1 to 40 characters');
+ }
+ await rooms.update(room, actor, { name: name != null ? name : null, topic, slow_seconds, join_role }, { fromServer: true, minName: 1 });
+ if (move === 'up' || move === 'down') {
+ const siblings = await db.all('SELECT id, position FROM rooms WHERE server_id = ? AND kind = ? AND archived_at IS NULL ORDER BY position, id', [server.id, room.kind]);
+ const i = siblings.findIndex((x) => Number(x.id) === Number(room.id));
+ const j = move === 'up' ? i - 1 : i + 1;
+ if (i >= 0 && j >= 0 && j < siblings.length) {
+ const order = siblings.map((x) => x.id);
+ [order[i], order[j]] = [order[j], order[i]];
+ await db.tx(async () => { for (let k = 0; k < order.length; k++) await db.run('UPDATE rooms SET position = ? WHERE id = ?', [k, order[k]]); });
+ }
+ }
+ return await db.get('SELECT * FROM rooms WHERE id = ?', [room.id]);
+}
+
+/** Delete a channel (archived: its page goes, its messages stay for moderation). A server keeps one text channel. */
+async function deleteChannel(server, actor, room) {
+ if (!(await access(server, actor)).manage) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins delete channels');
+ if (Number(room.server_id) !== Number(server.id)) fail(404, 'servers.no_channel', 'No such channel');
+ if (room.kind !== 'call') {
+ const texts = Number((await db.get("SELECT COUNT(*) AS n FROM rooms WHERE server_id = ? AND kind != 'call' AND archived_at IS NULL", [server.id]) || {}).n) || 0;
+ if (texts <= 1) fail(409, 'servers.last_channel', 'A server keeps at least one text channel');
+ }
+ await db.run('UPDATE rooms SET archived_at = ov_now() WHERE id = ? AND archived_at IS NULL', [room.id]);
+ await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: null, action_type: 'server_channel_delete', details: { channel: room.channel_slug } });
+}
+
+function publicInvite(i) {
+ return { code: i.code, uses: Number(i.uses) || 0, max_uses: i.max_uses == null ? null : Number(i.max_uses), expires_at: i.expires_at || null, created_at: i.created_at, revoked: !!i.revoked_at };
+}
+
+/** A new invite: `max_uses` (null or 1..1000) and `expires_hours` (null or 1..720). Managers make them; mods too in a public server. */
+async function createInvite(server, actor, { max_uses = null, expires_hours = null } = {}) {
+ const a = await access(server, actor);
+ if (!(a.manage || (a.moderate && server.visibility === 'public'))) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins invite people');
+ const uses = max_uses == null || max_uses === '' ? null : Number(max_uses);
+ if (uses != null && (!Number.isInteger(uses) || uses < 1 || uses > 1000)) fail(422, 'servers.invite_uses', 'An invite is used 1 to 1000 times, or without a limit');
+ const hours = expires_hours == null || expires_hours === '' ? null : Number(expires_hours);
+ if (hours != null && (!Number.isInteger(hours) || hours < 1 || hours > 720)) fail(422, 'servers.invite_expiry', 'An invite lasts 1 to 720 hours, or forever');
+ const live = Number((await db.get('SELECT COUNT(*) AS n FROM chat_server_invites WHERE server_id = ? AND revoked_at IS NULL', [server.id]) || {}).n) || 0;
+ if (live >= MAX_INVITES) fail(429, 'servers.too_many_invites', `A server has up to ${MAX_INVITES} invites: revoke one first`);
+ let code;
+ do { code = randomCode(10); } while (await db.get('SELECT 1 FROM chat_server_invites WHERE code = ?', [code]));
+ const expires = hours ? new Date(Date.now() + hours * 3600_000).toISOString() : null;
+ await db.run('INSERT INTO chat_server_invites (code, server_id, created_by, created_by_subject, max_uses, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
+ [code, server.id, actor.id, actor.subject_id || null, uses, expires]);
+ return publicInvite(await db.get('SELECT * FROM chat_server_invites WHERE code = ?', [code]));
+}
+async function invites(server) {
+ return (await db.all('SELECT * FROM chat_server_invites WHERE server_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 100', [server.id])).map(publicInvite);
+}
+async function revokeInvite(server, actor, code) {
+ const a = await access(server, actor);
+ const inv = await db.get('SELECT * FROM chat_server_invites WHERE code = ? AND server_id = ?', [String(code || '').toLowerCase(), server.id]);
+ if (!inv) fail(404, 'servers.no_invite', 'No such invite');
+ if (!a.manage && !(a.moderate && inv.created_by === actor.id)) fail(403, 'servers.not_admin', 'Only the server\'s owner and admins revoke invites');
+ await db.run('UPDATE chat_server_invites SET revoked_at = ov_now() WHERE code = ? AND revoked_at IS NULL', [inv.code]);
+}
+
+/** Delete the whole server (the owner, or chat staff): it and its channels are archived. */
+async function archive(server, actor) {
+ const a = await access(server, actor);
+ if (!a.owner && !a.staff) fail(403, 'servers.not_owner', 'Only the owner deletes the server');
+ await db.tx(async () => {
+ await db.run('UPDATE rooms SET archived_at = ov_now() WHERE server_id = ? AND archived_at IS NULL', [server.id]);
+ await db.run('UPDATE chat_server_invites SET revoked_at = ov_now() WHERE server_id = ? AND revoked_at IS NULL', [server.id]);
+ await db.run('UPDATE chat_servers SET archived_at = ov_now() WHERE id = ?', [server.id]);
+ });
+ if (!a.owner) await db.logModerationAction({ scope_type: 'server', scope_id: server.slug, actor_user_id: actor.id, target_user_id: server.owner_id, action_type: 'server_delete', details: {} });
+}
+
+module.exports = {
+ bySlug, byId, roleIn, access, channelAccess, publicServer, publicChannel, channels, channelBySlug,
+ create, list, join, leave, setRole, members, update, createChannel, updateChannel, deleteChannel,
+ createInvite, invites, revokeInvite, inviteByCode, archive,
+ SLUG_RE, ROLES, RANK, MAX_SERVERS_PER_OWNER, MAX_CHANNELS,
+};
@@ -29,11 +29,16 @@ async function publicPages() {
@@ -29,11 +29,16 @@ async function publicPages() {
const out = [
const out = [
{ path: '/', changefreq: 'hourly', priority: 1.0, lastmod: async () => await newestGlobalMessage() },
{ path: '/', changefreq: 'hourly', priority: 1.0, lastmod: async () => await newestGlobalMessage() },
{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },
{ path: '/rooms', changefreq: 'daily', priority: 0.8, lastmod: () => newestRoomActivity(pages) },
+ { path: '/servers', changefreq: 'daily', priority: 0.8 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
];
];
for (const r of pages) {
for (const r of pages) {
out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });
out.push({ path: `/r/${r.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(r.last_message_at || r.created_at) });
}
}
+ // Public servers (server/rooms/servers.js): their front page, the first text channel.
+ for (const sv of (await require('../rooms/servers').list(null, { limit: 100 })).public) {
+ out.push({ path: `/s/${sv.slug}`, changefreq: 'daily', priority: 0.6, lastmod: () => dayOf(sv.created_at) });
+ }
return out;
return out;
}
}
@@ -78,8 +83,8 @@ function createDiscoveryRoutes({ config }) {
@@ -78,8 +83,8 @@ function createDiscoveryRoutes({ config }) {
// the sitemap. Every previous Disallow is kept.
// the sitemap. Every previous Disallow is kept.
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.robotsTxt({
sitemaps: [abs('/sitemap.xml')],
sitemaps: [abs('/sitemap.xml')],
- allow: ['/', '/updates', '/rooms', '/r/'],
- disallow: ['/r/*/settings', '/messages', '/settings', '/auth/', '/api/'],
+ allow: ['/', '/updates', '/rooms', '/r/', '/servers', '/s/'],
+ disallow: ['/r/*/settings', '/s/*/settings', '/join/', '/messages', '/settings', '/auth/', '/api/'],
}));
}));
});
});
@@ -92,6 +97,7 @@ function createDiscoveryRoutes({ config }) {
@@ -92,6 +97,7 @@ function createDiscoveryRoutes({ config }) {
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'Global chat', url: abs('/'), note: 'one room for everyone on OpenVibe, newest messages at the end' },
{ title: 'Global chat', url: abs('/'), note: 'one room for everyone on OpenVibe, newest messages at the end' },
{ title: 'Rooms', url: abs('/rooms'), note: 'every public room, with its topic and activity' },
{ title: 'Rooms', url: abs('/rooms'), note: 'every public room, with its topic and activity' },
+ { title: 'Servers', url: abs('/servers'), note: 'public servers: groups with text and voice channels, roles and invites' },
{ title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Chat', url: abs('/updates') },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
@@ -20,6 +20,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
@@ -20,6 +20,7 @@ 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: 'rooms', label: 'Rooms', href: '/rooms' },
+ { key: 'servers', label: 'Servers', href: '/servers' },
{ key: 'messages', label: 'Messages', href: '/messages' },
{ key: 'messages', label: 'Messages', href: '/messages' },
{ key: 'settings', label: 'Settings', href: '/settings' },
{ key: 'settings', label: 'Settings', href: '/settings' },
];
];
@@ -102,7 +103,7 @@ ${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
@@ -102,7 +103,7 @@ ${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
<a class="oc-skip" href="#main">Skip to content</a>
<a class="oc-skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}
${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label, href }) => ({ label, href })) })}
-<main id="main" class="oc-main">
+<main id="main" class="oc-main${o.wide ? ' oc-main--wide' : ''}">
<noscript><p class="oc-account">${who}</p></noscript>
<noscript><p class="oc-account">${who}</p></noscript>
${o.body || ''}
${o.body || ''}
</main>
</main>
@@ -142,7 +142,12 @@ function createWebRoutes({ config }) {
@@ -142,7 +142,12 @@ function createWebRoutes({ config }) {
const countsOf = async (actor) => {
const countsOf = async (actor) => {
if (actor.kind !== 'user') return {};
if (actor.kind !== 'user') return {};
const n = async (fn) => { try { return Number(await fn()) || 0; } catch { return 0; } };
const n = async (fn) => { try { return Number(await fn()) || 0; } catch { return 0; } };
- return { messages: await n(async () => await dm.getTotalUnread(actor.user.id)), rooms: await n(async () => await require('../rooms/rooms').unreadTotal(actor.user)) };
+ const rooms = require('../rooms/rooms');
+ return {
+ messages: await n(async () => await dm.getTotalUnread(actor.user.id)),
+ rooms: await n(async () => await rooms.unreadTotal(actor.user)),
+ servers: await n(async () => await rooms.unreadTotal(actor.user, { servers: true })),
+ };
};
};
// Under the global room on the front page: what openvibe.chat is for (openvibe-shared/showcase), each card a real
// Under the global room on the front page: what openvibe.chat is for (openvibe-shared/showcase), each card a real
// place to go. Every line is true today (calls run since CHAT_CALLS=1; rooms, messages and settings are pages here).
// place to go. Every line is true today (calls run since CHAT_CALLS=1; rooms, messages and settings are pages here).
@@ -152,7 +157,7 @@ function createWebRoutes({ config }) {
@@ -152,7 +157,7 @@ function createWebRoutes({ config }) {
items: [
items: [
{ icon: 'ov:chat', title: 'One room for the network', text: 'Global chat reaches everyone on Live, Tools, Games and the rest, and reads without an account.', href: '#oc-h' },
{ icon: 'ov:chat', title: 'One room for the network', text: 'Global chat reaches everyone on Live, Tools, Games and the rest, and reads without an account.', href: '#oc-h' },
{ icon: 'ov:live', title: 'Stream chat on Live', text: 'Every channel on OpenVibe.Live has its room, with the streamer\'s moderators, timeouts, bans, emotes and text to speech.', href: 'https://openvibe.live/' },
{ icon: 'ov:live', title: 'Stream chat on Live', text: 'Every channel on OpenVibe.Live has its room, with the streamer\'s moderators, timeouts, bans, emotes and text to speech.', href: 'https://openvibe.live/' },
- { icon: 'ov:community', title: 'Rooms of your own', text: 'Start a public room anyone can read, or a private one that stays invisible to everyone else.', href: '/rooms' },
+ { icon: 'ov:community', title: 'Servers and rooms', text: 'Start a server with text and voice channels, roles and invite links, or a single room, public or private.', href: '/servers' },
{ icon: 'ov:account', title: 'Direct messages', text: 'One-to-one and group conversations. You choose who may start one, and whether groups may add you.', href: signedIn ? '/messages' : '/auth/login?next=%2Fmessages' },
{ icon: 'ov:account', title: 'Direct messages', text: 'One-to-one and group conversations. You choose who may start one, and whether groups may add you.', href: signedIn ? '/messages' : '/auth/login?next=%2Fmessages' },
{ icon: 'ov:stream', title: 'Voice and video calls', text: 'Call a person, open a call room, or join a stream\'s call: up to eight people, with mute, camera and kick controls.', href: '/rooms' },
{ icon: 'ov:stream', title: 'Voice and video calls', text: 'Call a person, open a call room, or join a stream\'s call: up to eight people, with mute, camera and kick controls.', href: '/rooms' },
{ icon: 'ov:theme', title: 'Your preferences everywhere', text: 'Compact mode, timestamps, text size, badges and emotes follow your account to every site.', href: signedIn ? '/settings' : '/auth/login?next=%2Fsettings' },
{ icon: 'ov:theme', title: 'Your preferences everywhere', text: 'Compact mode, timestamps, text size, badges and emotes follow your account to every site.', href: signedIn ? '/settings' : '/auth/login?next=%2Fsettings' },
@@ -503,9 +508,16 @@ ${people}
@@ -503,9 +508,16 @@ ${people}
</section>`;
</section>`;
}
}
+ // A server's channel has one page, in its server (server-pages.js).
+ const channelUrl = async (room, rest = '') => {
+ const s = await require('../rooms/servers').byId(room.server_id);
+ return s ? `/s/${s.slug}${rest || `/${room.channel_slug}`}` : '/servers';
+ };
+
router.get('/r/:slug', async (req, res) => {
router.get('/r/:slug', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
const { actor, me, room, can } = x;
+ if (room.server_id) return res.redirect(302, await channelUrl(room));
const msgs = await rooms.history(room, { limit: 60 });
const msgs = await rooms.history(room, { limit: 60 });
if (me && can.role) await rooms.markRead(room, me);
if (me && can.role) await rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
@@ -584,6 +596,7 @@ ${composer}${tools}`,
@@ -584,6 +596,7 @@ ${composer}${tools}`,
router.get('/r/:slug/settings', async (req, res) => {
router.get('/r/:slug/settings', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const x = await loadRoom(req, res); if (!x) return;
const { actor, room, can } = x;
const { actor, room, can } = x;
+ if (room.server_id) return res.redirect(302, await channelUrl(room, '/settings'));
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
if (!can.moderate) return res.redirect(303, `/r/${room.slug}`);
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const saved = req.query.saved ? notice('ok', 'Saved.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
@@ -691,6 +704,9 @@ ${attachList}`,
@@ -691,6 +704,9 @@ ${attachList}`,
}));
}));
// ── What shipped, robots, sitemap ──
// ── What shipped, robots, sitemap ──
+ // ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
+ require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config });
+
router.get('/updates', async (req, res) => await page(req, res, 200, {
router.get('/updates', async (req, res) => await page(req, res, 200, {
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 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>`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,351 @@
@@ -0,0 +1,351 @@
+'use strict';
+/**
+ * Servers on openvibe.chat (server/rooms/servers.js), mounted by pages.js:
+ *
+ * GET /servers your servers, public servers, and the form to start one
+ * GET /s/:slug[/:channel] the server: your servers down the side, its channels, the channel (a text channel's
+ * messages, or a voice channel's call and its chat), and its people by rank
+ * GET /s/:slug/settings overview, channels, invites, members and bans, delete (by what the role may do)
+ * GET /join/:code an invite: the server it opens, and the button to join
+ * POST /servers/new, /s/:slug/join | /leave | /settings | /channels | /channels/:channel | /invites | /invites/:code/revoke
+ * /s/:slug/members | /delete, /s/:slug/:channel (a message), /join/:code
+ *
+ * Like every page here it reads and posts without JavaScript; chat.js adds live messages in a channel exactly as in a
+ * room (a channel is a room), and call.js the voice channel's call.
+ */
+const { esc } = require('./layout');
+const rooms = require('../rooms/rooms');
+const servers = require('../rooms/servers');
+const ctx = require('../live-context');
+
+const LIVE = 'https://openvibe.live';
+const RANK_TITLE = { owner: 'Owner', admin: 'Admins', mod: 'Moderators', member: 'Members' };
+const ROLE_NAME = { owner: 'owner', admin: 'admin', mod: 'moderator', member: 'member', blocked: 'banned' };
+const YOU_ARE = { owner: 'the owner', admin: 'an admin', mod: 'a moderator', member: 'a member' };
+const SEARCH_ICON = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M10.5 4a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13zM20 20l-4.8-4.8"/></svg>';
+const VOICE_ICON = '<svg class="oc-ico" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M3 9v6h4l5 4V5L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>';
+
+const initials = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
+/** A steady hue per server, so its icon is recognisable in the rail. */
+const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
+
+function mountServerPages(router, h) {
+ const { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config } = h;
+ const site = config.web.baseUrl;
+ const serverRoutes = () => require('../rooms/server-routes');
+ const orError = (fn) => async (req, res, next) => {
+ try { return await fn(req, res, next); } catch (err) {
+ if (err instanceof rooms.RoomError) return res.redirect(303, `${req.back || '/servers'}?error=${encodeURIComponent(err.message)}`);
+ return next(err);
+ }
+ };
+ const userOf = (actor) => (actor.kind === 'user' ? actor.user : null);
+ const errorOf = (req) => (req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '');
+
+ const icon = (s, active) => `<a class="oc-srv-icon${active ? ' is-active' : ''}" href="/s/${esc(s.slug)}" title="${esc(s.name)}" style="--oc-hue:${hueOf(s.slug)}"${active ? ' aria-current="page"' : ''}><span aria-hidden="true">${esc(initials(s.name))}</span><span class="oc-sr">${esc(s.name)}</span>${s.unread ? `<span class="oc-srv-dot" aria-label="${s.unread} unread">${s.unread > 99 ? '99+' : s.unread}</span>` : ''}</a>`;
+ const serverCard = (s) => `<li class="oc-srv-card"><a href="/s/${esc(s.slug)}"><span class="oc-srv-icon oc-srv-icon--card" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(initials(s.name))}</span><span class="oc-srv-card-text"><span class="oc-srv-card-title"><strong>${esc(s.name)}</strong>${s.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}${s.unread ? `<span class="oc-badge oc-badge-new">${s.unread} new</span>` : ''}</span><span class="oc-muted">${esc(s.description || `${s.members} member${s.members === 1 ? '' : 's'}`)}</span></span></a></li>`;
+
+ // ── The directory ──
+ router.get('/servers', async (req, res) => {
+ const actor = await viewerOf(req);
+ const me = userOf(actor);
+ const { public: pub, mine } = await servers.list(me);
+ const create = me ? `<form class="oc-form oc-create" id="oc-new-server" method="post" action="/servers/new">
+<h2>Start a server</h2>
+<p class="oc-muted">A server is a place for a group: text channels to write in and voice channels to talk in, one membership and one set of roles for all of them. It starts with #general and a voice Lounge.</p>
+<label>Name <input name="name" required minlength="2" maxlength="40" placeholder="Saturday Raiders"></label>
+<label>Description <input name="description" maxlength="300" placeholder="What it is for (optional)"></label>
+<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people with an invite can find, read or join it</label>
+<p><button type="submit">Create server</button></p>
+</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Fservers">Sign in with OpenVibe</a> to start or join a server.</p>`;
+ await page(req, res, 200, {
+ actor, title: 'Servers', path: '/servers', active: 'servers', robots: 'index, follow',
+ description: 'Servers on OpenVibe.Chat: a place for a group, with text and voice channels, roles and invites.',
+ body: `<h1>Servers</h1>${errorOf(req)}
+${mine.length ? `<h2>Your servers</h2><ul class="oc-srv-cards">${mine.map(serverCard).join('')}</ul>` : ''}
+<h2>Public servers</h2><ul class="oc-srv-cards">${pub.map(serverCard).join('') || '<li class="oc-muted">No public servers yet. Start the first one.</li>'}</ul>
+${create}`,
+ });
+ });
+
+ router.post('/servers/new', formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const b = req.body || {};
+ const s = await servers.create(actor.user, { name: b.name, description: b.description, visibility: b.private === '1' ? 'private' : 'public' });
+ res.redirect(303, `/s/${s.slug}`);
+ }));
+
+ /** The server behind :slug for this viewer (404 page when they may not read it). */
+ async function loadServer(req, res) {
+ const actor = await viewerOf(req);
+ const me = userOf(actor);
+ const server = await servers.bySlug(req.params.slug);
+ const can = server ? await servers.access(server, me) : null;
+ if (!server || !can.read) {
+ await page(req, res, 404, { actor, title: 'No such server', path: req.path, robots: 'noindex', body: '<h1>No such server</h1><p><a href="/servers">All servers</a></p>' });
+ return null;
+ }
+ return { actor, me, server, can };
+ }
+
+ // ── A server and one of its channels ──
+ async function serverPage(req, res) {
+ const x = await loadServer(req, res); if (!x) return;
+ const { actor, me, server, can } = x;
+ const chans = await servers.channels(server, can.member ? me : null);
+ const pick = req.params.channel ? chans.find((c) => c.slug === String(req.params.channel).toLowerCase()) : chans.find((c) => c.kind === 'text') || chans[0];
+ if (!pick) return page(req, res, 404, { actor, title: 'No such channel', path: req.path, robots: 'noindex', body: `<h1>No such channel</h1><p><a href="/s/${esc(server.slug)}">${esc(server.name)}</a></p>` });
+ const room = await rooms.bySlug(pick.room);
+ const rcan = await rooms.access(room, me);
+ const msgs = await rooms.history(room, { limit: 60 });
+ if (me && rcan.role) await rooms.markRead(room, me);
+ const mine = me ? (await servers.list(me)).mine : [];
+ const people = await servers.members(server, { limit: 300 });
+ const isVoice = room.kind === 'call';
+ const callReady = isVoice && !!require('../config').calls.enabled;
+ const callOf = (c) => { try { return require('../rooms/routes').callOf({ kind: 'call', slug: c.room }) || null; } catch { return null; } };
+ const chanLink = (c) => {
+ const here = c.slug === pick.slug;
+ const unread = !here && c.unread ? `<span class="oc-chan-dot" aria-label="${c.unread} unread">${c.unread > 99 ? '99+' : c.unread}</span>` : '';
+ const inCall = c.kind === 'voice' ? (callOf(c) || {}).participants : 0;
+ return `<li><a class="oc-chan${c.kind === 'voice' ? ' oc-chan-voice' : ''}${here ? ' is-active' : ''}${unread ? ' is-unread' : ''}" href="/s/${esc(server.slug)}/${esc(c.slug)}"${here ? ' aria-current="page"' : ''}>${c.kind === 'voice' ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}<span class="oc-chan-name">${esc(c.name)}</span>${inCall ? `<span class="oc-muted oc-chan-in">${inCall} in</span>` : ''}${unread}</a></li>`;
+ };
+ const texts = chans.filter((c) => c.kind === 'text');
+ const voices = chans.filter((c) => c.kind === 'voice');
+ let composer;
+ const back = `/s/${server.slug}/${pick.slug}`;
+ if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
+ else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;
+ else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;
+ else composer = notice('error', 'You cannot write here right now.');
+ const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
+ const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';
+ const byRank = {};
+ for (const p of people) (byRank[p.role] = byRank[p.role] || []).push(p);
+ const person = (p) => `<li><a class="oc-name" href="${LIVE}/@${encodeURIComponent(p.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(p.profile_color || '') ? ` style="--nc:${esc(p.profile_color)}"` : ''}>${esc(p.display_name || p.username || '?')}</a></li>`;
+ const peopleHtml = ['owner', 'admin', 'mod', 'member'].filter((r) => byRank[r]).map((r) => `<h3 class="oc-srv-group">${RANK_TITLE[r]} <span class="oc-muted">${byRank[r].length}</span></h3><ul class="oc-srv-people-list">${byRank[r].map(person).join('')}</ul>`).join('');
+ const tools = [
+ can.moderate ? `<a href="/s/${esc(server.slug)}/settings">${can.manage ? 'Server settings' : 'Members and invites'}</a>` : '',
+ can.member && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/leave"><button type="submit" class="oc-link">Leave server</button></form>` : '',
+ ].filter(Boolean).join(' · ');
+ const canInvite = can.manage || (can.moderate && server.visibility === 'public');
+ await page(req, res, 200, {
+ actor, wide: true, title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: back, active: 'servers',
+ robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
+ description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
+ page: {
+ view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0,
+ me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
+ ...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
+ },
+ body: `<div class="oc-srv">
+<nav class="oc-srv-rail" aria-label="Your servers">${mine.map((s) => icon(s, s.slug === server.slug)).join('')}${mine.some((s) => s.slug === server.slug) ? '' : icon(servers.publicServer(server), true)}
+<a class="oc-srv-icon oc-srv-add" href="/servers#oc-new-server" title="Start a server"><span aria-hidden="true">+</span><span class="oc-sr">Start a server</span></a>
+<a class="oc-srv-icon oc-srv-explore" href="/servers" title="Find servers">${SEARCH_ICON}<span class="oc-sr">Find servers</span></a></nav>
+<aside class="oc-srv-side" aria-label="${esc(server.name)}">
+<header class="oc-srv-head"><h1 class="oc-srv-name">${esc(server.name)}</h1>${server.visibility === 'private' ? '<span class="oc-badge">private</span>' : ''}
+<p class="oc-muted oc-srv-meta">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : ''}</p>
+${server.description ? `<p class="oc-srv-desc">${esc(server.description)}</p>` : ''}</header>
+<details class="oc-srv-channels" open><summary>Channels</summary>
+<h2 class="oc-srv-group">Text channels</h2><ul class="oc-srv-list">${texts.map(chanLink).join('')}</ul>
+${voices.length ? `<h2 class="oc-srv-group">Voice channels</h2><ul class="oc-srv-list">${voices.map(chanLink).join('')}</ul>` : ''}
+</details>
+${!can.member && me ? `<form class="oc-srv-join" method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>` : ''}
+${canInvite ? `<p class="oc-srv-invite"><a class="oc-button oc-button--quiet" href="/s/${esc(server.slug)}/settings#oc-invites">Invite people</a></p>` : ''}
+${tools ? `<p class="oc-srv-tools">${tools}</p>` : ''}
+</aside>
+<section class="oc-srv-main" aria-labelledby="oc-chan-h">
+<header class="oc-chan-head"><h2 id="oc-chan-h">${isVoice ? VOICE_ICON : '<span class="oc-chan-hash" aria-hidden="true">#</span>'}${esc(room.name)}</h2>${room.topic ? `<span class="oc-muted oc-chan-topic">${esc(room.topic)}</span>` : ''}<span class="oc-live" id="oc-live" hidden>● live</span></header>
+${aiHere}${errorOf(req)}
+${isVoice ? callPanel(room, rcan, me) : ''}
+<ol class="oc-feed oc-srv-feed" id="oc-feed" aria-live="polite" aria-label="Messages in ${esc(room.name)}">${msgs.map((m) => roomMessage(m, rcan, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${isVoice ? 'The voice channel\'s chat is empty.' : `This is the start of #${esc(room.name)}.`}</li>`}</ol>
+${composer}
+</section>
+<aside class="oc-srv-people" aria-label="People in ${esc(server.name)}"><details open><summary>People <span class="oc-muted">${Number(server.member_count) || 0}</span></summary>${peopleHtml}${people.length >= 300 ? '<p class="oc-muted">And more.</p>' : ''}</details></aside>
+</div>`,
+ });
+ }
+ router.get('/s/:slug', serverPage);
+
+ router.post('/s/:slug/join', formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const server = await servers.bySlug(req.params.slug);
+ if (!server) return res.redirect(303, '/servers');
+ req.back = `/s/${server.slug}`;
+ await servers.join(server, actor.user);
+ await serverRoutes().applyServerChange(server);
+ res.redirect(303, `/s/${server.slug}`);
+ }));
+
+ router.post('/s/:slug/leave', formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const server = await servers.bySlug(req.params.slug);
+ if (!server) return res.redirect(303, '/servers');
+ req.back = `/s/${server.slug}`;
+ await servers.leave(server, actor.user);
+ await serverRoutes().applyServerChange(server);
+ res.redirect(303, '/servers');
+ }));
+
+ // ── Settings: what each rank may do (overview and channels: owner/admins; invites: owner/admins, and mods in a
+ // public server; members: owner/admins/mods; delete: the owner) ──
+ router.get('/s/:slug/settings', async (req, res) => {
+ const x = await loadServer(req, res); if (!x) return;
+ const { actor, me, server, can } = x;
+ if (!can.moderate) return res.redirect(303, `/s/${server.slug}`);
+ const saved = req.query.saved ? notice('ok', 'Saved.') : '';
+ const chans = await servers.channels(server);
+ const people = await servers.members(server, { blocked: true, limit: 2000 });
+ const canInvite = can.manage || server.visibility === 'public';
+ const invites = canInvite ? await servers.invites(server) : [];
+ const myRank = can.staff ? -1 : servers.RANK[can.role];
+ const choices = (target) => {
+ const list = [];
+ if (myRank <= servers.RANK.owner) list.push('admin');
+ if (myRank <= servers.RANK.admin) list.push('mod');
+ list.push('member', 'blocked', 'none');
+ if (target && target.role !== 'member' && target.role !== 'blocked' && myRank >= servers.RANK[target.role]) return null;
+ return list;
+ };
+ const roleForm = (p) => {
+ if (p.role === 'owner') return '<span class="oc-muted">owner</span>';
+ const opts = choices(p);
+ if (!opts || Number(p.user_id) === Number(me.id)) return `<span class="oc-muted">${ROLE_NAME[p.role]}</span>`;
+ return `<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/members"><input type="hidden" name="username" value="${esc(p.username || '')}"><select name="role" aria-label="Role for ${esc(p.username || '')}">${opts.map((r) => `<option value="${r}"${r === p.role ? ' selected' : ''}>${r === 'none' ? 'remove from the server' : r === 'blocked' ? 'ban' : ROLE_NAME[r]}</option>`).join('')}</select> <button type="submit">Set</button></form>`;
+ };
+ const active = people.filter((p) => p.role !== 'blocked');
+ const banned = people.filter((p) => p.role === 'blocked');
+ const chanRow = (c) => `<li><span>${c.kind === 'voice' ? VOICE_ICON : '#'} <strong>${esc(c.name)}</strong>${c.topic ? ` <span class="oc-muted">${esc(c.topic)}</span>` : ''}</span>
+<form class="oc-inline oc-moves" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}"><button type="submit" name="move" value="up" class="oc-move" aria-label="Move ${esc(c.name)} up" title="Move up">↑</button><button type="submit" name="move" value="down" class="oc-move" aria-label="Move ${esc(c.name)} down" title="Move down">↓</button></form>
+<details class="oc-chan-edit"><summary>Edit</summary><form class="oc-form" method="post" action="/s/${esc(server.slug)}/channels/${esc(c.slug)}">
+<label>Name <input name="name" required maxlength="40" value="${esc(c.name)}"></label>
+<label>Topic <input name="topic" maxlength="200" value="${esc(c.topic || '')}"></label>
+${c.kind === 'voice' ? `<label>Members in the call <select name="join_role"><option value="speaker"${c.join_role === 'speaker' ? ' selected' : ''}>can talk</option><option value="participant"${c.join_role === 'participant' ? ' selected' : ''}>listen and write (moderators talk)</option></select></label>` : ''}
+<p><button type="submit">Save</button> <button type="submit" name="delete" value="1" class="oc-link oc-danger-link">Delete this channel</button></p></form></details></li>`;
+ const inviteRow = (i) => `<li><code class="oc-invite-url">${esc(`${site}/join/${i.code}`)}</code> <span class="oc-muted">${i.uses} use${i.uses === 1 ? '' : 's'}${i.max_uses != null ? ` of ${i.max_uses}` : ''}${i.expires_at ? ` · until <time datetime="${esc(i.expires_at)}">${esc(i.expires_at.slice(0, 16).replace('T', ' '))} UTC</time>` : ' · never expires'}</span>
+<form class="oc-inline" method="post" action="/s/${esc(server.slug)}/invites/${esc(i.code)}/revoke"><button type="submit" class="oc-link">Revoke</button></form></li>`;
+ await page(req, res, 200, {
+ actor, title: `${server.name}: settings`, path: `/s/${server.slug}/settings`, active: 'servers', robots: 'noindex, nofollow',
+ body: `<p><a href="/s/${esc(server.slug)}">← ${esc(server.name)}</a></p><h1>Server settings</h1>${saved}${errorOf(req)}
+${can.manage ? `<h2>Overview</h2><form class="oc-form" method="post" action="/s/${esc(server.slug)}/settings">
+<label>Name <input name="name" required minlength="2" maxlength="40" value="${esc(server.name)}"></label>
+<label>Description <input name="description" maxlength="300" value="${esc(server.description || '')}"></label>
+<label>Who can find and read it <select name="visibility"><option value="public"${server.visibility === 'public' ? ' selected' : ''}>Anyone: listed, and signed-in people join (public)</option><option value="private"${server.visibility === 'private' ? ' selected' : ''}>Only people with an invite (private)</option></select></label>
+<p><button type="submit">Save</button></p></form>
+<h2>Channels</h2><ul class="oc-members oc-chan-rows">${chans.map(chanRow).join('')}</ul>
+<form class="oc-new" method="post" action="/s/${esc(server.slug)}/channels"><label for="oc-chan-new">Add a channel</label> <input id="oc-chan-new" name="name" required maxlength="40" placeholder="announcements"> <select name="kind" aria-label="Kind of channel"><option value="text">Text</option><option value="voice">Voice</option></select> <button type="submit">Add</button></form>` : ''}
+${canInvite ? `<h2 id="oc-invites">Invites</h2><p class="oc-muted">${server.visibility === 'private' ? 'A private server is joined only through an invite.' : 'Anyone can join a public server; an invite link is a handy way to bring people straight to it.'}</p>
+<form class="oc-new" method="post" action="/s/${esc(server.slug)}/invites"><label for="oc-inv-uses">New invite</label> <select id="oc-inv-uses" name="max_uses" aria-label="How many times it can be used"><option value="">any number of uses</option><option value="1">1 use</option><option value="10">10 uses</option><option value="100">100 uses</option></select> <select name="expires_hours" aria-label="When it expires"><option value="168">for 7 days</option><option value="24">for 1 day</option><option value="720">for 30 days</option><option value="">forever</option></select> <button type="submit">Make an invite</button></form>
+<ul class="oc-members">${invites.map(inviteRow).join('') || '<li class="oc-muted">No invites yet.</li>'}</ul>` : ''}
+<h2>Members <span class="oc-muted">${active.length}</span></h2>
+<p class="oc-muted">Admins manage channels, invites and members; moderators delete messages and remove or ban members. The owner appoints admins, admins appoint moderators.</p>
+<ul class="oc-members">${active.map((p) => `<li><strong>${esc(p.display_name || p.username || '?')}</strong> <span class="oc-muted">@${esc(p.username || '')}</span> ${roleForm(p)}</li>`).join('')}</ul>
+${banned.length ? `<details class="oc-blocked"><summary>Banned (${banned.length})</summary><ul class="oc-members">${banned.map((p) => `<li><strong>${esc(p.display_name || p.username || '?')}</strong> <span class="oc-muted">@${esc(p.username || '')}</span><form class="oc-inline" method="post" action="/s/${esc(server.slug)}/members"><input type="hidden" name="username" value="${esc(p.username || '')}"><input type="hidden" name="role" value="none"><button type="submit" class="oc-link">Lift the ban</button></form></li>`).join('')}</ul></details>` : ''}
+<form class="oc-new" method="post" action="/s/${esc(server.slug)}/members"><label for="oc-ban">Ban someone</label> <input id="oc-ban" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username"><input type="hidden" name="role" value="blocked"> <button type="submit">Ban</button></form>
+${can.owner || can.staff ? `<h2>Delete the server</h2><form class="oc-form" method="post" action="/s/${esc(server.slug)}/delete"><label class="oc-check"><input type="checkbox" name="confirm" value="1" required> Delete ${esc(server.name)} and all its channels</label><p><button type="submit" class="oc-danger">Delete server</button></p></form>` : ''}`,
+ });
+ });
+
+ const settingsAction = (fn) => [formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const server = await servers.bySlug(req.params.slug);
+ if (!server || !(await servers.access(server, actor.user)).read) return res.redirect(303, '/servers');
+ req.back = `/s/${server.slug}/settings`;
+ await fn(req, res, server, actor.user);
+ })];
+
+ router.post('/s/:slug/settings', ...settingsAction(async (req, res, server, user) => {
+ const b = req.body || {};
+ const out = await servers.update(server, user, { name: b.name, description: b.description, visibility: b.visibility });
+ await serverRoutes().applyServerChange(out);
+ res.redirect(303, `/s/${server.slug}/settings?saved=1`);
+ }));
+ router.post('/s/:slug/channels', ...settingsAction(async (req, res, server, user) => {
+ const b = req.body || {};
+ const room = await servers.createChannel(server, user, { name: b.name, kind: b.kind === 'voice' ? 'voice' : 'text' });
+ res.redirect(303, `/s/${server.slug}/${room.channel_slug}`);
+ }));
+ router.post('/s/:slug/channels/:channel', ...settingsAction(async (req, res, server, user) => {
+ const room = await servers.channelBySlug(server, req.params.channel);
+ if (!room) return res.redirect(303, `/s/${server.slug}/settings?error=${encodeURIComponent('No such channel')}`);
+ const b = req.body || {};
+ if (b.delete === '1') await servers.deleteChannel(server, user, room);
+ else {
+ const out = await servers.updateChannel(server, user, room, { name: b.name, topic: b.topic, join_role: room.kind === 'call' ? b.join_role : undefined, move: b.move });
+ await applyRoomChange(out);
+ }
+ res.redirect(303, `/s/${server.slug}/settings?saved=1`);
+ }));
+ router.post('/s/:slug/invites', ...settingsAction(async (req, res, server, user) => {
+ const b = req.body || {};
+ await servers.createInvite(server, user, { max_uses: b.max_uses || null, expires_hours: b.expires_hours || null });
+ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-invites`);
+ }));
+ router.post('/s/:slug/invites/:code/revoke', ...settingsAction(async (req, res, server, user) => {
+ await servers.revokeInvite(server, user, req.params.code);
+ res.redirect(303, `/s/${server.slug}/settings?saved=1#oc-invites`);
+ }));
+ router.post('/s/:slug/members', ...settingsAction(async (req, res, server, user) => {
+ const name = String((req.body && req.body.username) || '').trim();
+ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? await 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, `/s/${server.slug}/settings?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
+ const role = await servers.setRole(server, user, target.id, String((req.body && req.body.role) || 'member'));
+ if (role === 'blocked' || role == null) {
+ for (const r of await require('../db/database').all('SELECT id FROM rooms WHERE server_id = ?', [server.id])) require('../chat/chat-server').removeFromRoom(r.id, target.id);
+ }
+ await serverRoutes().applyServerChange(server);
+ res.redirect(303, `/s/${server.slug}/settings?saved=1`);
+ }));
+ router.post('/s/:slug/delete', ...settingsAction(async (req, res, server, user) => {
+ if ((req.body || {}).confirm !== '1') return res.redirect(303, `/s/${server.slug}/settings`);
+ await servers.archive(server, user);
+ await serverRoutes().applyServerChange(server);
+ res.redirect(303, '/servers');
+ }));
+
+ // ── An invite ──
+ router.get('/join/:code', async (req, res) => {
+ const actor = await viewerOf(req);
+ const me = userOf(actor);
+ const found = await servers.inviteByCode(req.params.code);
+ if (!found) return page(req, res, 404, { actor, title: 'Invite not valid', path: req.path, robots: 'noindex, nofollow', body: '<h1>This invite is not valid</h1><p class="oc-muted">It has expired, was used up, or was revoked. Ask whoever sent it for a new one.</p><p><a href="/servers">Find servers</a></p>' });
+ const { server } = found;
+ const role = me ? await servers.roleIn(server.id, me.id) : null;
+ const action = !me ? `<p><a class="oc-button" href="/auth/login?next=${encodeURIComponent(req.path)}">Sign in with OpenVibe to join</a></p>`
+ : role === 'blocked' ? notice('error', 'You are banned from this server.')
+ : role ? `<p><a class="oc-button" href="/s/${esc(server.slug)}">Open ${esc(server.name)}</a> <span class="oc-muted">You are already in it.</span></p>`
+ : `<form method="post" action="/join/${esc(found.invite.code)}"><button type="submit">Join ${esc(server.name)}</button></form>`;
+ await page(req, res, 200, {
+ actor, title: `Join ${server.name}`, path: req.path, robots: 'noindex, nofollow',
+ body: `<section class="oc-invite-card"><span class="oc-srv-icon oc-srv-icon--big" style="--oc-hue:${hueOf(server.slug)}" aria-hidden="true">${esc(initials(server.name))}</span>
+<p class="oc-muted">You are invited to</p><h1>${esc(server.name)}</h1>${server.description ? `<p>${esc(server.description)}</p>` : ''}
+<p class="oc-muted">${Number(server.member_count) || 0} member${Number(server.member_count) === 1 ? '' : 's'}${server.visibility === 'private' ? ' · private server' : ''}</p>
+${errorOf(req)}${action}</section>`,
+ });
+ });
+ router.post('/join/:code', formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ req.back = `/join/${encodeURIComponent(req.params.code)}`;
+ const found = await servers.inviteByCode(req.params.code);
+ if (!found) return res.redirect(303, req.back);
+ await servers.join(found.server, actor.user, { invite: found.invite.code });
+ await serverRoutes().applyServerChange(found.server);
+ res.redirect(303, `/s/${found.server.slug}`);
+ }));
+
+ // A channel's page and its no-JavaScript message form (after the fixed /s/:slug/… routes above).
+ router.get('/s/:slug/:channel', serverPage);
+ router.post('/s/:slug/:channel', formLimit, sameSite, orError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const server = await servers.bySlug(req.params.slug);
+ const room = server ? await servers.channelBySlug(server, req.params.channel) : null;
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/servers');
+ req.back = `/s/${server.slug}/${room.channel_slug}`;
+ const message = await 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, `/s/${server.slug}/${room.channel_slug}#oc-compose`);
+ }));
+}
+
+module.exports = { mountServerPages };
@@ -27,7 +27,7 @@ const TABLES = [
@@ -27,7 +27,7 @@ const TABLES = [
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'token_revocations', 'chat_control_log',
+ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
];
const INDEXES = [
const INDEXES = [
new file mode 100644
new file mode 100644
@@ -0,0 +1,224 @@
@@ -0,0 +1,224 @@
+'use strict';
+/**
+ * Servers (migration 0013, server/rooms/servers.js): a server starts with #general and a voice Lounge; a public one is
+ * read by anyone and joined by signed-in people, a private one is a 404 to outsiders and is joined through an invite
+ * (uses, expiry, revoke). One membership and one set of roles for every channel: the owner appoints admins, admins
+ * appoint moderators and manage channels and invites, moderators remove and ban members, nobody acts on their own rank
+ * or above; it is all logged. A channel is a room: messages over REST and the socket, unread counts, the voice
+ * channel's call roles; the rooms API refuses room-level joins and roles there, and rooms lists leave channels out.
+ * The site: the directory, a server's page, settings by rank, an invite page, /r/<channel> going to its server.
+ * Deleting an account removes its memberships; a server it owned stays without an owner while anyone is in it.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('servers');
+let h, ann, bob, cat, dee, eve, staff;
+const api = (method, path, u, body) => h.http(method, `/api/chat/servers${path}`, { token: u && u.token, body });
+const roomApi = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
+const page = async (path, u, { method = 'GET', form = null } = {}) => {
+ const headers = { ...(u ? { cookie: `ov_token=${u.token}` } : {}), ...(form ? { 'content-type': 'application/x-www-form-urlencoded' } : {}) };
+ const r = await fetch(`${h.base}${path}`, { method, headers, body: form ? new URLSearchParams(form).toString() : undefined, redirect: 'manual' });
+ return { status: r.status, text: await r.text(), location: r.headers.get('location') };
+};
+const ev = (type, payload) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: 'network', actor: { type: 'user', id: payload.subject },
+ timestamp: new Date().toISOString(), visibility: 'internal', subject: { type: 'user', id: payload.subject }, payload });
+let general, lounge;
+
+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') });
+ dee = h.addUser('dee', { subject: ids.newId('user') });
+ eve = h.addUser('eve', { subject: ids.newId('user') });
+ staff = h.addUser('staffer', { subject: ids.newId('user'), role: 'global_mod' });
+ for (const u of [ann, bob, cat, dee, eve, staff]) await h.ctx.upsertUser(h.live.users.get(u.id));
+});
+
+t('create: #general and a voice Lounge, the owner, and the rules on names', async () => {
+ const r = await api('POST', '/', ann, { name: 'Saturday Raiders', description: 'Raids on Saturdays' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.body.server.slug, r.body.server.role, r.body.server.members], ['saturday-raiders', 'owner', 1]);
+ assert.strictEqual((await api('POST', '/', bob, { name: 'Saturday Raiders' })).status, 409);
+ assert.strictEqual((await api('POST', '/', bob, { name: 'x' })).status, 422);
+ assert.strictEqual((await api('POST', '/', bob, { name: 'Settings', slug: 'settings' })).status, 422, 'reserved');
+ assert.strictEqual((await api('POST', '/', null, { name: 'Anon' })).status, 401);
+ const s = (await api('GET', '/saturday-raiders', ann)).body;
+ assert.deepStrictEqual(s.channels.map((c) => [c.slug, c.kind]), [['general', 'text'], ['lounge', 'voice']]);
+ general = s.channels[0].room; lounge = s.channels[1].room;
+ assert.match(general, /^ch[a-z2-9]{12}$/);
+ assert.deepStrictEqual(s.can, { read: true, member: true, moderate: true, manage: true, owner: true });
+ assert.ok(!(await roomApi('GET', '/', ann)).body.public.some((x) => x.slug === general), 'channels are not in the rooms list');
+});
+
+t('a public server: anyone reads, signed-in people join and write; unread counts; the rooms API defers to the server', async () => {
+ assert.strictEqual((await api('GET', '/saturday-raiders', null)).status, 200);
+ assert.strictEqual((await roomApi('GET', `/${general}/messages`, null)).status, 200, 'its channels read signed out');
+ let r = await roomApi('POST', `/${general}/messages`, bob, { message: 'hi' });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'rooms.join_first']);
+ assert.deepStrictEqual([(await roomApi('POST', `/${general}/join`, bob)).status, (await roomApi('POST', `/${general}/join`, bob)).body.code], [409, 'rooms.server_channel']);
+ assert.strictEqual((await api('POST', '/saturday-raiders/join', bob)).body.role, 'member');
+ assert.strictEqual((await roomApi('POST', `/${general}/messages`, bob, { message: 'hello raiders' })).status, 201);
+ assert.strictEqual((await roomApi('POST', `/${general}/messages`, bob, { message: 'anyone on tonight?' })).status, 201);
+ let mine = (await api('GET', '/', ann)).body.mine;
+ assert.strictEqual(mine.find((x) => x.slug === 'saturday-raiders').unread, 2);
+ assert.strictEqual((await api('GET', '/saturday-raiders', ann)).body.channels[0].unread, 2);
+ await roomApi('POST', `/${general}/read`, ann, {});
+ mine = (await api('GET', '/', ann)).body.mine;
+ assert.strictEqual(mine.find((x) => x.slug === 'saturday-raiders').unread, 0);
+ assert.strictEqual((await api('GET', '/', null)).body.public.find((x) => x.slug === 'saturday-raiders').members, 2);
+ r = await roomApi('GET', `/${general}/members`, ann);
+ assert.deepStrictEqual(r.body.members.map((m) => [m.username, m.role]), [['ann', 'owner'], ['bob', 'member']], 'a channel\'s people are the server\'s');
+ assert.strictEqual((await roomApi('POST', `/${general}/members`, ann, { username: 'cat', role: 'member' })).body.code, 'rooms.server_channel');
+});
+
+t('the socket: a member joins a channel like a room and gets its messages live', async () => {
+ const annWs = await h.ws({ token: ann.token }); annWs.sendJson({ type: 'join_room', room: general });
+ assert.strictEqual((await annWs.next((m) => m.type === 'room_joined')).role, 'owner');
+ const bobWs = await h.ws({ token: bob.token }); bobWs.sendJson({ type: 'join_room', room: general });
+ assert.strictEqual((await bobWs.next((m) => m.type === 'room_joined')).role, 'member');
+ bobWs.sendJson({ type: 'room_message', message: 'live in #general' });
+ const got = await annWs.next((m) => m.type === 'room_message' && m.message.message === 'live in #general');
+ assert.strictEqual(got.room, general);
+ annWs.close(); bobWs.close();
+});
+
+t('roles: the owner appoints admins, admins appoint mods, mods remove and ban members, never upward; logged', async () => {
+ for (const u of [cat, dee, eve]) await api('POST', '/saturday-raiders/join', u);
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'mod' })).status, 403, 'a member manages nobody');
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', ann, { username: 'bob', role: 'admin' })).body.role, 'admin');
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'admin' })).status, 403, 'only the owner appoints admins');
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'cat', role: 'mod' })).body.role, 'mod');
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'dee', role: 'mod' })).status, 403, 'mods do not appoint mods');
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'bob', role: 'none' })).status, 403, 'a mod cannot remove an admin');
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', bob, { username: 'ann', role: 'member' })).status, 403, 'nobody changes the owner');
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'dee', role: 'none' })).body.role, null, 'a mod removes a member');
+ assert.strictEqual((await api('POST', '/saturday-raiders/members', cat, { username: 'eve', role: 'blocked' })).body.role, 'blocked');
+ assert.deepStrictEqual([(await api('POST', '/saturday-raiders/join', eve)).status, (await api('POST', '/saturday-raiders/join', eve)).body.code], [403, 'servers.blocked']);
+ assert.strictEqual((await roomApi('GET', `/${general}/messages`, eve)).status, 404, 'banned: not even reading');
+ const lounges = (await roomApi('GET', `/${lounge}`, cat)).body.can;
+ assert.ok(lounges.talk && lounges.moderate, 'a mod talks and moderates in voice');
+ assert.ok((await roomApi('GET', `/${lounge}`, bob)).body.can.manage, 'an admin manages the channels');
+ const log = await require('../server/db/database').all("SELECT action_type FROM moderation_actions WHERE scope_type = 'server' ORDER BY id");
+ assert.deepStrictEqual(log.map((x) => x.action_type), ['server_role_admin', 'server_role_mod', 'server_kick', 'server_ban']);
+ assert.strictEqual((await api('GET', '/saturday-raiders/members', bob)).body.members.some((m) => m.role === 'blocked'), true, 'moderators see bans');
+ assert.strictEqual((await api('GET', '/saturday-raiders/members', null)).body.members.some((m) => m.role === 'blocked'), false);
+});
+
+t('channels: admins add, rename, move and delete them; a server keeps one text channel; voice follows the join role', async () => {
+ assert.strictEqual((await api('POST', '/saturday-raiders/channels', cat, { name: 'mods only' })).status, 403, 'mods do not add channels');
+ let r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Announcements', topic: 'News' });
+ assert.deepStrictEqual([r.status, r.body.channel.slug, r.body.channel.kind], [201, 'announcements', 'text']);
+ r = await api('POST', '/saturday-raiders/channels', bob, { name: 'Settings', kind: 'voice' });
+ assert.strictEqual(r.body.channel.slug, 'settings-voice', 'a channel never takes a page\'s address');
+ await api('PATCH', '/saturday-raiders/channels/announcements', bob, { move: 'up' });
+ assert.deepStrictEqual((await api('GET', '/saturday-raiders', bob)).body.channels.filter((c) => c.kind === 'text').map((c) => c.slug), ['announcements', 'general']);
+ r = await api('PATCH', '/saturday-raiders/channels/lounge', bob, { join_role: 'participant' });
+ assert.strictEqual(r.body.channel.join_role, 'participant');
+ assert.strictEqual((await roomApi('GET', `/${lounge}`, cat)).body.can.talk, true, 'mods still talk');
+ const dee2 = (await api('POST', '/saturday-raiders/join', dee)).body.role;
+ assert.strictEqual(dee2, 'member');
+ assert.strictEqual((await roomApi('GET', `/${lounge}`, dee)).body.can.talk, false, 'members listen in a participant channel');
+ assert.strictEqual((await api('DELETE', '/saturday-raiders/channels/announcements', bob)).status, 204);
+ assert.deepStrictEqual([(await api('DELETE', '/saturday-raiders/channels/general', bob)).status, (await api('DELETE', '/saturday-raiders/channels/general', bob)).body.code], [409, 'servers.last_channel']);
+ assert.strictEqual((await roomApi('PATCH', `/${general}`, ann, { visibility: 'private' })).body.code, 'rooms.server_channel', 'a channel is as public as its server');
+});
+
+t('private servers: invisible to outsiders, joined through an invite with uses and expiry, revocable', async () => {
+ const r = await api('POST', '/', ann, { name: 'Ann and Bob', visibility: 'private' });
+ assert.strictEqual(r.status, 201);
+ const room = (await api('GET', '/ann-and-bob', ann)).body.channels[0].room;
+ assert.strictEqual((await api('GET', '/ann-and-bob', bob)).status, 404);
+ assert.strictEqual((await api('POST', '/ann-and-bob/join', bob)).status, 404, 'as missing as a server that does not exist');
+ assert.strictEqual((await roomApi('GET', `/${room}/messages`, bob)).status, 404);
+ assert.ok(!(await api('GET', '/', bob)).body.public.some((x) => x.slug === 'ann-and-bob'), 'not listed');
+ const inv = await api('POST', '/ann-and-bob/invites', ann, { max_uses: 1, expires_hours: 24 });
+ assert.strictEqual(inv.status, 201, inv.text);
+ assert.match(inv.body.url, /\/join\/[a-z2-9]{10}$/);
+ const code = inv.body.invite.code;
+ assert.strictEqual((await api('GET', `/invites/${code}`, null)).body.server.name, 'Ann and Bob', 'the code shows what it opens');
+ assert.strictEqual((await api('POST', '/ann-and-bob/join', bob, { invite: code })).body.role, 'member');
+ assert.strictEqual((await roomApi('POST', `/${room}/messages`, bob, { message: 'thanks for the invite' })).status, 201);
+ const used = await api('POST', '/ann-and-bob/join', cat, { invite: code });
+ assert.deepStrictEqual([used.status, used.body.code], [410, 'servers.invite_invalid'], 'used up (the code already told cat the server exists)');
+ assert.strictEqual((await api('GET', `/invites/${code}`, null)).status, 404);
+ const again = (await api('POST', '/ann-and-bob/invites', ann, {})).body.invite.code;
+ assert.strictEqual((await api('POST', '/ann-and-bob/invites', bob, {})).status, 403, 'members do not invite to a private server');
+ assert.strictEqual((await api('DELETE', `/ann-and-bob/invites/${again}`, ann)).status, 204);
+ assert.strictEqual((await api('POST', '/ann-and-bob/join', cat, { invite: again })).status, 410, 'revoked');
+ assert.strictEqual((await api('GET', '/ann-and-bob', cat)).status, 404, 'still not there for cat');
+ assert.strictEqual((await api('GET', '/ann-and-bob', staff)).status, 200, 'chat staff can look in');
+});
+
+t('going private: the channels follow and outsiders lose them; deleting the server takes everything', async () => {
+ assert.strictEqual((await api('PATCH', '/saturday-raiders', bob, { visibility: 'private' })).body.server.visibility, 'private');
+ assert.strictEqual((await require('../server/db/database').get('SELECT visibility FROM rooms WHERE slug = ?', [general])).visibility, 'private');
+ assert.strictEqual((await roomApi('GET', `/${general}/messages`, null)).status, 404);
+ assert.strictEqual((await roomApi('GET', `/${general}/messages`, dee)).status, 200, 'members still read');
+ await api('PATCH', '/saturday-raiders', bob, { visibility: 'public' });
+ const tmp = (await api('POST', '/', bob, { name: 'Short Lived' })).body.server;
+ assert.strictEqual((await api('DELETE', `/${tmp.slug}`, ann)).status, 403, 'only its owner deletes a server');
+ assert.strictEqual((await api('DELETE', `/${tmp.slug}`, bob)).status, 204);
+ assert.strictEqual((await api('GET', `/${tmp.slug}`, bob)).status, 404);
+});
+
+t('the site: directory, a server page, settings by rank, an invite page, /r/<channel> goes to its server', async () => {
+ let r = await page('/servers', null);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Saturday Raiders/);
+ assert.doesNotMatch(r.text, /Ann and Bob/, 'private servers are not listed');
+ r = await page('/s/saturday-raiders', dee);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /class="oc-srv"/);
+ assert.match(r.text, /aria-current="page"[^>]*>.*general/s);
+ assert.match(r.text, /Voice channels/);
+ assert.match(r.text, /<h3 class="oc-srv-group">Owner/);
+ assert.match(r.text, /id="oc-compose"/, 'a member writes');
+ assert.doesNotMatch(r.text, /Server settings/, 'a member sees no settings');
+ r = await page('/s/saturday-raiders/lounge', null);
+ assert.match(r.text, /<section class="oc-call"/, 'a voice channel shows its call (here: calls are off in tests)');
+ assert.match(r.text, /Sign in with OpenVibe/);
+ r = await page(`/r/${general}`, dee);
+ assert.deepStrictEqual([r.status, r.location], [302, '/s/saturday-raiders/general']);
+ r = await page('/s/saturday-raiders/settings', ann);
+ assert.match(r.text, /<h2>Channels<\/h2>/);
+ assert.match(r.text, /id="oc-invites"/);
+ assert.match(r.text, /Delete the server/);
+ r = await page('/s/saturday-raiders/settings', cat);
+ assert.doesNotMatch(r.text, /<h2>Channels<\/h2>/, 'a mod sees members and invites, not channels');
+ assert.match(r.text, /<h2>Members/);
+ assert.strictEqual((await page('/s/saturday-raiders/settings', dee)).status, 303, 'a member is sent back');
+ r = await page('/s/saturday-raiders/general', dee, { method: 'POST', form: { message: 'from the form' } });
+ assert.strictEqual(r.status, 303);
+ assert.match((await page('/s/saturday-raiders/general', dee)).text, /from the form/);
+ r = await page('/servers/new', cat, { method: 'POST', form: { name: 'Book Club', private: '1' } });
+ assert.deepStrictEqual([r.status, r.location], [303, '/s/book-club']);
+ const code = (await api('POST', '/book-club/invites', cat, {})).body.invite.code;
+ r = await page(`/join/${code}`, dee);
+ assert.match(r.text, /You are invited to/);
+ assert.match(r.text, /Book Club/);
+ r = await page(`/join/${code}`, dee, { method: 'POST', form: {} });
+ assert.deepStrictEqual([r.status, r.location], [303, '/s/book-club']);
+ assert.strictEqual((await page('/join/nosuchcode', dee)).status, 404);
+ assert.match((await page('/sitemap.xml', null)).text, /\/s\/saturday-raiders/);
+ assert.doesNotMatch((await page('/sitemap.xml', null)).text, /book-club/);
+});
+
+t('account deletion: memberships go; an owned server stays without an owner while people are in it', async () => {
+ const accountData = require('../server/chat/account-data');
+ const d = require('../server/db/database');
+ const sent = [];
+ const send = async (p, body) => { sent.push({ path: p, body }); return { ok: true, status: 200 }; };
+ const solo = (await api('POST', '/', ann, { name: 'Just Ann' })).body.server;
+ const del = ev('network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject: ann.subject_id, aliases: [], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });
+ assert.strictEqual(await accountData.apply(del, { send }), 'erased');
+ const raiders = await d.get("SELECT owner_subject, archived_at FROM chat_servers WHERE slug = 'saturday-raiders'");
+ assert.deepStrictEqual([raiders.owner_subject, raiders.archived_at], [null, null], 'the server stays for its people');
+ assert.strictEqual(await d.get('SELECT 1 FROM chat_servers WHERE id = ?', [solo.id]), undefined, 'a server with nobody else goes');
+ assert.strictEqual((await d.all('SELECT * FROM chat_server_members WHERE user_id = ?', [ann.id])).length, 0);
+ assert.ok(sent[0].body.erased.servers >= 1 && sent[0].body.retained.servers_without_owner >= 1, JSON.stringify(sent[0].body));
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });