openvibers/openvibe.chat Commits
openvibe.chat, the site (WS-I task 8): global chat (server-rendered, live over /ws/chat), messages (inbox, conversations for participants only, start one by username) and settings (the chat.preferences record Live's chat reads), /updates, robots and sitemap, served by Chat itself so it works with Live down. Sign in with OpenVibe.Network (client chat, CHAT_WEB_URL/auth/callback); the Frame from this site's own /shared/ (openvibe-shared 1.12.0). Forms post without JavaScript through the same API handlers (one set of checks), only from this origin. test/web.test.js
Changes
@@@ -18,7 +18,7 @@"multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","ws": "^8.18.0"},"engines": {@@@ -1087,9 +1087,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.7.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",-- "integrity": "sha512-gF1PABMEGHhtu0WMkJQHwJOvbYzWcdfOi3feGYrCUkLXIZhpeSvrSOD7SF0+nveWhfCQMFjqNU8Q0d90vv4s/A==",++ "version": "1.12.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",++ "integrity": "sha512-Qpy4YVoPMs1q/0/08/5ZODI0+7ev+Krk75nsRPsTjNCm7Eu91tt1LPnUNLt4kYfRj6duS+N9icm02gksnlMiyA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -29,7 +29,7 @@"multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","ws": "^8.18.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,73 @@++/* openvibe.chat. Colours follow the network theme variables (theme-loader.js) with dark defaults that++ match the critical canvas; everything reads and posts without JavaScript. */++:root {++ --oc-bg: var(--bg-primary, #0a0f1c);++ --oc-panel: var(--bg-secondary, #111a2c);++ --oc-panel-2: var(--bg-tertiary, #16213a);++ --oc-text: var(--text-primary, #e6edf7);++ --oc-muted: var(--text-secondary, #9fb0c8);++ --oc-line: var(--border-color, rgba(255, 255, 255, .12));++ --oc-accent: var(--accent, #60a5fa);++ --oc-on-accent: var(--on-accent, #0a0f1c);++ --oc-red: #f87171;++ --oc-ok: #34d399;++ --oc-scale: 1;++}++* { box-sizing: border-box; }++html { -webkit-text-size-adjust: 100%; }++body { margin: 0; background: var(--oc-bg); color: var(--oc-text); font: calc(16px * var(--oc-scale))/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++a { color: var(--oc-accent); }++a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }++.oc-skip { position: absolute; left: -9999px; }++.oc-skip:focus { left: 16px; top: 8px; background: var(--oc-panel); padding: 6px 10px; z-index: 10; }++.oc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++.oc-bar { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; max-width: 960px; margin: 0 auto; padding: 12px 16px; border-bottom: 1px solid var(--oc-line); }++.oc-brand { font-weight: 800; color: var(--oc-text); text-decoration: none; letter-spacing: -.01em; }++.oc-tabs { display: flex; gap: 4px; flex-wrap: wrap; }++.oc-tabs a { color: var(--oc-muted); text-decoration: none; padding: 4px 10px; border-radius: 999px; }++.oc-tabs a[aria-current="page"], .oc-tabs a:hover { color: var(--oc-text); background: var(--oc-panel); }++.oc-account { font-size: 14px; color: var(--oc-muted); margin-left: auto; }++.oc-main { max-width: 960px; margin: 0 auto; padding: 16px 16px 48px; overflow-wrap: anywhere; }++h1 { font-size: 1.6rem; line-height: 1.25; margin: .3em 0 .2em; }++.oc-muted { color: var(--oc-muted); }++.oc-live { color: var(--oc-ok); font-size: .85em; margin-left: 6px; }++.oc-notice { padding: 8px 12px; border-left: 4px solid var(--oc-accent); background: var(--oc-panel); border-radius: 0 8px 8px 0; }++.oc-notice.oc-error { border-color: var(--oc-red); }++.oc-notice.oc-ok { border-color: var(--oc-ok); }++.oc-feed { list-style: none; margin: 12px 0; padding: 8px 4px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; max-height: min(64vh, 720px); overflow-y: auto; overscroll-behavior: contain; }++.oc-msg { padding: 5px 10px; border-radius: 8px; line-height: 1.45; }++.oc-msg:hover { background: var(--oc-panel-2); }++.oc-compact .oc-msg { padding: 1px 10px; }++.oc-time { display: none; color: var(--oc-muted); font-size: .8em; font-variant-numeric: tabular-nums; margin-right: 4px; }++.oc-times .oc-time { display: inline; }++.oc-name { font-weight: 700; text-decoration: none; }++.oc-anon { color: var(--oc-muted); font-weight: 600; }++.oc-where { color: var(--oc-muted); font-size: .8em; text-decoration: none; margin-left: 4px; }++.oc-text { white-space: pre-wrap; }++.oc-badge { display: inline-block; font-size: .7em; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: 0 6px; margin-left: 6px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-muted); vertical-align: 1px; }++.oc-badge-staff { color: #fbbf24; border-color: rgba(251, 191, 36, .45); }++.oc-badge-mod { color: var(--oc-ok); border-color: rgba(52, 211, 153, .45); }++.oc-nobadges .oc-badge-staff, .oc-nobadges .oc-badge-mod { display: none; }++.oc-mine .oc-name { color: var(--oc-accent); }++.oc-empty { padding: 16px; text-align: center; }++.oc-compose { display: flex; gap: 8px; align-items: flex-end; }++.oc-compose textarea { flex: 1; min-height: 44px; resize: vertical; }++input, select, textarea { font: inherit; color: var(--oc-text); background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 10px; max-width: 100%; }++button, .oc-button { display: inline-block; background: var(--oc-accent); color: var(--oc-on-accent); border: 0; border-radius: 10px; padding: 9px 16px; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; }++button[disabled] { opacity: .6; cursor: default; }++.oc-signin { margin: 12px 0; }++.oc-new { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0 16px; }++.oc-new input { flex: 1 1 180px; }++.oc-convs { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }++.oc-conv { display: flex; gap: 12px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; padding: 10px 14px; }++.oc-conv a { flex: 1; min-width: 0; color: var(--oc-text); text-decoration: none; display: flex; flex-direction: column; }++.oc-conv .oc-last { font-size: .9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.oc-conv.oc-unread { border-color: var(--oc-accent); }++.oc-form { display: grid; gap: 10px; max-width: 520px; }++.oc-check { display: flex; gap: 8px; align-items: center; }++.oc-form select { margin-left: 8px; }++@media (max-width: 560px) {++ .oc-main { padding: 12px 12px 40px; }++ .oc-feed { max-height: 60vh; }++ .oc-account { margin-left: 0; }++}nnew file mode 100644@@@ -0,0 +1,170 @@++/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs refresh every few++ seconds. Pages work without this file (server-rendered, forms post); it only adds liveness. User text++ is always set with textContent, never parsed as HTML. */++(function () {++ 'use strict';++ var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;++ if (!cfg) return;++ var feed = document.getElementById('oc-feed');++ var form = document.getElementById('oc-compose');++ var input = document.getElementById('oc-input');++ var latest = Number(cfg.latest) || 0;++ var LIVE = 'https://openvibe.live';++++ function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }++ function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }++ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }++ function clock(ts) {++ var d = new Date(ts && String(ts).indexOf('T') < 0 ? String(ts).replace(' ', 'T') + 'Z' : ts);++ var t = el('time', 'oc-time', isNaN(d) ? '' : d.toISOString().slice(11, 16));++ if (!isNaN(d)) t.dateTime = d.toISOString();++ return t;++ }++ function textWithLinks(parent, text) {++ var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;++ text = String(text || '');++ while ((m = re.exec(text))) {++ if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));++ var a = el('a', null, m[0]); a.href = m[0]; a.rel = 'nofollow ugc noopener'; a.target = '_blank';++ parent.appendChild(a);++ last = m.index + m[0].length;++ }++ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));++ }++ // Display only (the server decides who may do what): the same badges the page renders.++ var BADGES = { admin: ['oc-badge oc-badge-staff', 'staff'], global_mod: ['oc-badge oc-badge-mod', 'mod'] };++ function badge(role) { var b = BADGES[role]; return b ? el('span', b[0], b[1]) : null; }++ function removeEmpty() { var e = feed && feed.querySelector('.oc-empty'); if (e) e.remove(); }++ function append(li, id) {++ if (!feed) return;++ if (id && feed.querySelector('[data-id="' + id + '"]')) return;++ var stick = nearBottom();++ removeEmpty();++ feed.appendChild(li);++ while (feed.children.length > 300) feed.removeChild(feed.firstChild);++ if (stick) toBottom();++ }++ function showError(text) {++ var p = document.querySelector('.oc-live-error');++ if (!p) { p = el('p', 'oc-notice oc-error oc-live-error'); p.setAttribute('role', 'alert'); (form || feed).insertAdjacentElement('beforebegin', p); }++ p.textContent = text;++ clearTimeout(showError.t); showError.t = setTimeout(function () { p.remove(); }, 6000);++ }++++ // ── Global chat ──++ function globalItem(m) {++ var li = el('li', 'oc-msg'); li.dataset.id = m.id || '';++ li.appendChild(clock(m.timestamp)); li.appendChild(document.createTextNode(' '));++ var handle = m.core_username || (m.user_id ? m.username : null);++ var name = m.display_name || m.username || m.anon_id || 'someone';++ var who;++ if (handle) { who = el('a', 'oc-name', name); who.href = LIVE + '/@' + encodeURIComponent(handle); if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.color = m.profile_color; }++ else who = el('span', 'oc-name oc-anon', name);++ li.appendChild(who);++ var b = badge(m.role); if (b) li.appendChild(b);++ li.appendChild(document.createTextNode(' '));++ var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);++ return li;++ }++ function catchUp() {++ return fetch('/api/chat/global/history?after_id=' + latest + '&limit=200', { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) {++ if (!d || !d.messages) return;++ d.messages.forEach(function (m) { if ((m.message_type || 'chat') === 'chat' && !m.is_deleted) append(globalItem(m), m.id); });++ if (d.latest_id) latest = Math.max(latest, d.latest_id);++ }).catch(function () {});++ }++ function startGlobal() {++ var live = document.getElementById('oc-live');++ var ws = null, delay = 1000, opened = false;++ function connect() {++ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');++ ws.onopen = function () {++ delay = 1000;++ ws.send(JSON.stringify({ type: 'join' }));++ if (live) live.hidden = false;++ if (opened) catchUp();++ opened = true;++ };++ ws.onmessage = function (ev) {++ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }++ if (m.type === 'chat' && m.is_global) {++ if (m.id) latest = Math.max(latest, Number(m.id));++ append(globalItem(m), m.id);++ } else if (m.type === 'delete-messages' && Array.isArray(m.ids || m.message_ids)) {++ (m.ids || m.message_ids).forEach(function (id) { var x = feed.querySelector('[data-id="' + id + '"]'); if (x) x.remove(); });++ } else if (m.type === 'error' && m.message) {++ showError(m.message);++ } else if (m.type === 'auth_revoked') {++ showError('You were signed out. Sign in again to keep chatting.');++ }++ };++ ws.onclose = function () {++ if (live) live.hidden = true;++ setTimeout(connect, delay); delay = Math.min(delay * 2, 30000);++ };++ }++ connect();++ if (form && input) {++ form.addEventListener('submit', function (e) {++ var text = input.value.trim();++ if (!text) { e.preventDefault(); return; }++ if (!ws || ws.readyState !== 1) return; // not connected: the form posts as without JavaScript++ e.preventDefault();++ ws.send(JSON.stringify({ type: 'chat', message: text }));++ input.value = '';++ input.focus();++ });++ }++ }++++ // ── A conversation ──++ function dmItem(m) {++ var li = el('li', 'oc-msg' + (m.sender_id === cfg.me ? ' oc-mine' : '')); li.dataset.id = m.id || '';++ li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));++ li.appendChild(el('span', 'oc-name', m.display_name || m.username));++ li.appendChild(document.createTextNode(' '));++ var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);++ return li;++ }++ function startDm() {++ var base = '/api/dm/conversations/' + encodeURIComponent(cfg.conversation);++ function poll() {++ if (document.hidden) return;++ fetch(base + '/messages?after=' + latest + '&limit=100', { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) {++ if (!d || !d.messages || !d.messages.length) return;++ d.messages.forEach(function (m) { latest = Math.max(latest, Number(m.id) || 0); append(dmItem(m), m.id); });++ fetch(base + '/read', { method: 'POST', credentials: 'same-origin' }).catch(function () {});++ }).catch(function () {});++ }++ setInterval(poll, 4000);++ document.addEventListener('visibilitychange', poll);++ if (form && input) {++ form.addEventListener('submit', function (e) {++ var text = input.value.trim();++ if (!text) { e.preventDefault(); return; }++ e.preventDefault();++ var btn = form.querySelector('button'); if (btn) btn.disabled = true;++ fetch(base + '/messages', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text }) })++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })++ .then(function (x) {++ if (!x.ok) { showError((x.d && x.d.error) || 'Could not send'); return; }++ if (x.d && x.d.message) { latest = Math.max(latest, Number(x.d.message.id) || 0); append(dmItem(x.d.message), x.d.message.id); }++ input.value = ''; input.focus();++ })++ .catch(function () { form.submit(); })++ .then(function () { if (btn) btn.disabled = false; });++ });++ }++ }++++ if (input) input.addEventListener('keydown', function (e) {++ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }++ });++ toBottom();++ if (cfg.view === 'global') startGlobal();++ else if (cfg.view === 'dm') startDm();++})();@@@ -48,6 +48,7 @@ function getAllowedOrigins() {} catch { /* */ }}allowed.add(normalizeOrigin(config.networkUrl) || 'https://openvibe.network');++ allowed.add(normalizeOrigin(config.web.baseUrl) || 'https://openvibe.chat');allowed.add('https://openvibe.games');allowed.add('https://openvibe.tools');if (!config.isProduction) {@@@ -206,6 +207,9 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {app.use('/api/tts', require('./chat/tts-routes'));app.use('/api/sounds', require('./chat/sounds-routes'));++ // openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).++ app.use(require('./web/pages').createWebRoutes({ config }));++app.use((req, res) => res.status(404).json({ error: 'Not found' }));app.use((err, req, res, _next) => {if (err.name === 'MulterError' || (err.message && err.message.includes('file'))) {@@@ -39,12 +39,21 @@ module.exports = {// PEM with the Network's RS256 public key. When unset or unreadable the key is fetched from// ${networkInternalUrl}/api/.well-known/jwks.networkPublicKeyPath: process.env.OV_NETWORK_PUBLIC_KEY || '',-- // Chat's service principal (client `chat` in the Network's oauth_clients).++ // Chat's service principal (client `chat` in the Network's oauth_clients). The same client signs++ // people in to openvibe.chat (redirect <web.baseUrl>/auth/callback), as Wiki and Blog do.oauth: {clientId: process.env.OV_OAUTH_CLIENT_ID || 'chat',clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: process.env.CHAT_OAUTH_REDIRECT_URI || `${strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400'))}/auth/callback`,++ scope: 'profile theme',},++ // openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.++ web: {++ baseUrl: strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400')),++ },++ cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },++// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.live: {internalUrl: strip(process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++/**++ * openvibe.chat page shell (server/web/). Every page is server-rendered and readable without++ * JavaScript; the OpenVibe Frame (navbar, footer, themes) comes from this site's own /shared/ copy of++ * openvibe-shared, and public/web/chat.js adds the live feed and the composer.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const ovServe = require('openvibe-shared/serve');++const sharedSeo = require('openvibe-shared/seo');++const appIcon = require('openvibe-shared/app-icon');++const frame = require('openvibe-shared/frame');++++const SITE_NAME = 'OpenVibe.Chat';++const NETWORK_URL = 'https://openvibe.network';++const DEFAULT_DESCRIPTION = 'OpenVibe.Chat: the network-wide chat room and your direct messages, one account across every OpenVibe site.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');++const LINKS = [++ { key: 'global', label: 'Global chat', href: '/' },++ { key: 'messages', label: 'Messages', href: '/messages' },++ { key: 'settings', label: 'Settings', href: '/settings' },++];++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++const hashes = new Map();++function asset(rel) {++ if (!hashes.has(rel)) {++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ }++ return `/web/${rel}?v=${hashes.get(rel)}`;++}++++function navConfig(o, config) {++ return {++ service: 'chat',++ apiBase: NETWORK_URL,++ links: LINKS.map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,++ logoutUrl: '/auth/logout?next={path}',++ };++}++++/**++ * o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),++ * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges)++ */++function renderPage(o) {++ const { config } = o;++ if (!o.robots) throw new TypeError('renderPage needs explicit robots');++ const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;++ const head = sharedSeo.headTags({++ title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,++ robots: o.robots, siteName: SITE_NAME, type: 'website',++ });++ const actor = o.actor || { kind: 'anonymous' };++ const who = actor.kind === 'user'++ ? `<span>Signed in as ${esc(actor.user.display_name || actor.user.username)}</span> · <a href="/auth/logout?next=${encodeURIComponent(o.path || '/')}">Sign out</a>`++ : `<a href="/auth/login?next=${encodeURIComponent(o.path || '/')}">Sign in with OpenVibe</a>`;++ const p = o.prefs || {};++ const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');++ const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++${head}++${appIcon.headTags({ site: 'network' })}++<link rel="stylesheet" href="${asset('chat.css')}">++<script src="${ovServe.url('theme-loader.js')}" defer></script>++<script src="${ovServe.url('navbar.js')}" defer></script>++<script src="${ovServe.url('footer.js')}" defer></script>++${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}++</head>++<body class="${cls}"${scale}>++<a class="oc-skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++${frame.noscriptNav({ name: SITE_NAME, home: '/', links: LINKS.map(({ label, href }) => ({ label, href })) })}++<header class="oc-bar"><a class="oc-brand" href="/">${SITE_NAME}</a><nav class="oc-tabs" aria-label="Chat">${LINKS.map((l) => `<a href="${l.href}"${o.active === l.key ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav><noscript><span class="oc-account">${who}</span></noscript></header>++<main id="main" class="oc-main">++${o.body || ''}++</main>++${frame.footer({ service: 'chat', variant: 'full', updates: '/updates' })}++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'chat', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }++});++</script>++</body>++</html>`;++}++++module.exports = { renderPage, asset, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };nnew file mode 100644@@@ -0,0 +1,283 @@++'use strict';++/**++ * openvibe.chat, the site (roadmap WS-I task 8): the network-wide chat room, your direct messages and++ * your chat settings, served by OpenVibe.Chat itself, so it keeps working while OpenVibe.Live is down.++ *++ * GET / global chat: the last messages server-rendered (readable without++ * JavaScript); public/web/chat.js adds the live feed over /ws/chat++ * POST /send the composer without JavaScript (the same handler as POST /api/chat/send)++ * GET /messages your conversations; POST /messages/new starts one by username++ * GET /messages/:id one conversation (participants only); POST /messages/:id replies++ * GET /settings your portable chat settings (Network module chat.preferences); POST saves++ * GET /updates what shipped on openvibe.chat (the shared changelog)++ * GET /robots.txt, /sitemap.xml++ * /auth/* sign in with OpenVibe.Network (./session.js), cookies host-only++ *++ * Forms go through the same API handlers the JavaScript client calls (apiBridge), so rate limits,++ * bans, blocks, spam checks and DM participation are enforced in one place. Posts must come from this++ * site (Origin check; the session cookie is SameSite=Lax).++ */++const path = require('path');++const express = require('express');++const rateLimit = require('express-rate-limit');++const ovServe = require('openvibe-shared/serve');++const frame = require('openvibe-shared/frame');++const { renderPage, esc, SITE_NAME } = require('./layout');++const { createSessionRoutes } = require('./session');++const session = require('../auth/network-session');++const historyStore = require('../chat/history-store');++const dm = require('../chat/dm');++const prefs = require('../prefs/chat-preferences');++const ctx = require('../live-context');++const permissions = require('../auth/permissions');++++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');++const ANON = Object.freeze({ kind: 'anonymous' });++const LIVE = 'https://openvibe.live';++const FONT_SCALES = { small: 0.88, default: null, large: 1.18 };++++async function viewerOf(req) {++ const token = req.cookies && req.cookies.ov_token;++ if (!token) return ANON;++ const u = await session.authenticate(token).catch(() => null);++ if (!u || u.auth_source === 'api_token') return ANON;++ return { kind: 'user', user: u, token };++}++++async function prefsOf(actor) {++ if (actor.kind !== 'user' || !actor.user.subject_id) return {};++ try { return (await prefs.get(actor.user.subject_id)).preferences || {}; } catch { return {}; }++}++++const hhmm = (ts) => {++ const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`);++ return Number.isNaN(d.getTime()) ? { iso: '', text: '' } : { iso: d.toISOString(), text: d.toISOString().slice(11, 16) };++};++/** Plain text with http(s) links, escaped. */++function linkify(text) {++ return esc(text).replace(/https?:\/\/[^\s<>"']+/g, (u) => `<a href="${u}" rel="nofollow ugc noopener" target="_blank">${u}</a>`);++}++/** The author's badge, from the staff map (ADR-022): staff run the site, moderators moderate chat. */++function roleBadge(role) {++ const who = { role: String(role || 'user') };++ if (permissions.can(who, 'staff.site.configure')) return '<span class="oc-badge oc-badge-staff" title="OpenVibe staff">staff</span>';++ if (permissions.can(who, 'staff.moderation.chat')) return '<span class="oc-badge oc-badge-mod" title="Moderator">mod</span>';++ return '';++}++function globalMessage(m) {++ const t = hhmm(m.timestamp);++ const handle = m.core_username || (m.user_id ? m.username : null);++ const name = esc(m.display_name || m.username || m.anon_id || 'someone');++ const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="color:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;++ const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';++ return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span></li>`;++}++function dmMessage(m, me) {++ const t = hhmm(m.created_at);++ const mine = me && m.sender_id === me.id;++ return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span></li>`;++}++const notice = (kind, text) => `<p class="oc-notice oc-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${esc(text)}</p>`;++++/**++ * Run a form through an existing JSON API handler: the route is re-dispatched under `apiPath`, and its++ * JSON answer becomes a redirect (success) or an error page. One set of checks for both clients.++ */++function apiBridge(apiRouter, apiPath, { body, ok, fail }) {++ return (req, res, next) => {++ const original = req.url;++ req.url = typeof apiPath === 'function' ? apiPath(req) : apiPath;++ if (body) req.body = body(req);++ res.json = (payload) => {++ req.url = original;++ if (res.statusCode < 400) return ok(req, res, payload || {});++ return fail(req, res, res.statusCode, (payload && payload.error) || 'That did not work');++ };++ apiRouter(req, res, (err) => { req.url = original; next(err); });++ };++}++++function createWebRoutes({ config }) {++ const router = express.Router();++ const site = config.web.baseUrl;++ const siteOrigin = (() => { try { return new URL(site).origin; } catch { return null; } })();++ const webConfig = { ...config, baseUrl: site };++ const viewers = { resolve: async (req) => viewerOf(req) };++ const formLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });++ const sameSite = (req, res, next) => {++ const o = req.get('origin');++ if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');++ next();++ };++ const page = async (req, res, status, o) => {++ const actor = o.actor || await viewerOf(req);++ res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')++ .send(renderPage({ config, actor, prefs: await prefsOf(actor), ...o }));++ };++ const needUser = async (req, res) => {++ const actor = await viewerOf(req);++ if (actor.kind === 'user') return actor;++ res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ return null;++ };++++ router.use('/auth', createSessionRoutes({ config: webConfig, viewers }));++ router.use('/shared', ovServe.handler());++ router.use('/web', express.static(PUBLIC_DIR, { maxAge: '365d', immutable: true, index: false }));++++ // ── Global chat ──++ router.get('/', async (req, res) => {++ const actor = await viewerOf(req);++ const { messages, latest_id } = historyStore.page('global', { limit: 60 });++ const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat');++ const composer = actor.kind === 'user'++ ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">++<label for="oc-input" class="oc-sr">Message to everyone</label>++<textarea id="oc-input" name="message" maxlength="500" rows="2" required placeholder="Say something to everyone on OpenVibe"></textarea>++<button type="submit">Send</button>++</form>`)++ : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ await page(req, res, 200, {++ actor, title: 'Global chat', path: '/', active: 'global', robots: 'index, follow', script: true,++ description: 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation.',++ page: { view: 'global', latest: latest_id, signedIn: actor.kind === 'user', me: actor.kind === 'user' ? actor.user.username : null },++ body: `<section class="oc-room" aria-labelledby="oc-h">++<h1 id="oc-h">Global chat</h1>++<p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>++${error}++<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${shown.map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>++${composer}++</section>`,++ });++ });++++ const chatApi = require('../chat/routes');++ router.post('/send', formLimit, sameSite, async (req, res, next) => {++ if (!(await needUser(req, res))) return;++ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;++ return apiBridge(chatApi, '/send', {++ body: (r) => ({ message: String((r.body && r.body.message) || '').slice(0, 6000) }),++ ok: (r, s) => s.redirect(303, '/#oc-compose'),++ fail: (r, s, status, error) => s.redirect(303, `/?error=${encodeURIComponent(error)}#oc-compose`),++ })(req, res, next);++ });++++ // ── Direct messages ──++ const dmApi = require('../chat/dm-routes');++ router.get('/messages', async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const me = actor.user;++ const convs = dm.getConversations(me.id);++ const rows = convs.map((c) => {++ const others = dm.getParticipants(c.id).filter((p) => p.id !== me.id);++ const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';++ const t = hhmm(c.last_message_at);++ return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;++ });++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ await page(req, res, 200, {++ actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow',++ body: `<h1>Messages</h1>${error}++<form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>++<ul class="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>`,++ });++ });++++ router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {++ const actor = await needUser(req, res); if (!actor) return;++ const name = String((req.body && req.body.username) || '').trim();++ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;++ if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);++ if (!target) return res.redirect(303, `/messages?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);++ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;++ return apiBridge(dmApi, '/conversations', {++ body: () => ({ user_ids: [target.id] }),++ ok: (r, s, p) => { const id = (p.conversation && p.conversation.id) || p.id; return s.redirect(303, id ? `/messages/${Number(id)}` : '/messages'); },++ fail: (r, s, status, error) => s.redirect(303, `/messages?error=${encodeURIComponent(error)}`),++ })(req, res, next);++ });++++ router.get('/messages/:id', async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const me = actor.user;++ const id = parseInt(req.params.id, 10);++ if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });++ const others = dm.getParticipants(id).filter((p) => p.id !== me.id);++ const conv = dm.getConversation(id);++ const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';++ const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);++ try { dm.markRead(id, me.id); } catch { /* best effort */ }++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ await page(req, res, 200, {++ actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,++ page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },++ body: `<p><a href="/messages">← Messages</a></p><h1>${esc(title)}</h1>${error}++<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>++<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`,++ });++ });++++ router.post('/messages/:id', formLimit, sameSite, async (req, res, next) => {++ if (!(await needUser(req, res))) return;++ const id = parseInt(req.params.id, 10);++ if (!Number.isInteger(id)) return res.redirect(303, '/messages');++ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;++ return apiBridge(dmApi, `/conversations/${id}/messages`, {++ body: (r) => ({ message: String((r.body && r.body.message) || '').slice(0, 2000) }),++ ok: (r, s) => s.redirect(303, `/messages/${id}#oc-compose`),++ fail: (r, s, status, error) => s.redirect(303, `/messages/${id}?error=${encodeURIComponent(error)}#oc-compose`),++ })(req, res, next);++ });++++ // ── Settings (chat.preferences, the same record Live's chat reads) ──++ router.get('/settings', async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const p = await prefsOf(actor);++ const size = p.font_scale == null ? 'default' : p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';++ const box = (name, label, on) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}> ${label}</label>`;++ const saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';++ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';++ await page(req, res, 200, {++ actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow',++ body: `<h1>Chat settings</h1>${saved}${error}++<p class="oc-muted">Saved to your OpenVibe account, so they apply here, in Live chat and on every device you sign in to.</p>++<form class="oc-form" method="post" action="/settings">++${box('timestamps', 'Show the time next to each message', p.timestamps === true)}++${box('compact', 'Compact messages', p.compact === true)}++${box('show_badges', 'Show staff and moderator badges', p.show_badges !== false)}++${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}++<label>Text size <select name="font_size">${['small', 'default', 'large'].map((s) => `<option value="${s}"${s === size ? ' selected' : ''}>${s[0].toUpperCase()}${s.slice(1)}</option>`).join('')}</select></label>++<p><button type="submit">Save</button></p>++</form>`,++ });++ });++++ router.post('/settings', formLimit, sameSite, async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const b = req.body || {};++ const patch = {++ timestamps: b.timestamps === '1' ? true : null,++ compact: b.compact === '1' ? true : null,++ show_badges: b.show_badges === '1' ? null : false,++ hide_emotes: b.hide_emotes === '1' ? true : null,++ font_scale: Object.prototype.hasOwnProperty.call(FONT_SCALES, b.font_size) ? FONT_SCALES[b.font_size] : null,++ };++ try { await prefs.update(actor.user.subject_id, patch); return res.redirect(303, '/settings?saved=1'); }++ catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }++ });++++ // ── What shipped, robots, sitemap ──++ router.get('/updates', (req, res) => page(req, res, 200, {++ title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',++ body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,++ }));++ router.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')++ .send(`User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));++ router.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')++ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/updates'].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));++++ return router;++}++++module.exports = { createWebRoutes, apiBridge, globalMessage, linkify };nnew file mode 100644@@@ -0,0 +1,199 @@++'use strict';++/**++ * openvibe.chat as an OAuth2 client of OpenVibe.Network (client `chat`, redirect++ * <CHAT_WEB_URL>/auth/callback): the session layer OpenVibe.Wiki and OpenVibe.Community use.++ *++ * GET /auth/login → Network /oauth/authorize (?silent=1 adds prompt=none; ?next= same-site path)++ * GET /auth/callback → server-side code exchange, sets the cookies++ * POST /auth/fedcm → the shared navbar's FedCM assertion (nonce checked here, signature by the Network)++ * GET /auth/logout → clears the cookies (best-effort refresh revoke)++ * GET /auth/me → the signed-in profile (offline JWT verification)++ * POST /auth/refresh → rotates tokens with the refresh token++ *++ * Cookies (host-only): ov_token (access JWT, JS-readable for the navbar), ov_refresh (httpOnly,++ * Path=/auth), ov_sso_hint ('account' | 'guest', tells the navbar whether a silent login is worth it).++ */++const crypto = require('crypto');++const express = require('express');++++const ACCESS_COOKIE = 'ov_token';++const REFRESH_COOKIE = 'ov_refresh';++const HINT_COOKIE = 'ov_sso_hint';++const STATE_COOKIE = 'ov_oauth_state';++const NEXT_COOKIE = 'ov_oauth_next';++const SILENT_COOKIE = 'ov_oauth_silent';++++function sanitizeNext(next, config) {++ if (!next || typeof next !== 'string') return '/';++ if (/^\/(?!\/|\\)/.test(next)) return next;++ try {++ const u = new URL(next);++ if (u.protocol !== 'https:') return '/';++ const allowed = [config.baseUrl, config.networkUrl].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);++ if (allowed.includes(u.hostname)) return u.toString();++ } catch { /* fall through */ }++ return '/';++}++++function withParam(target, key, value) {++ const at = target.indexOf('#');++ const hash = at >= 0 ? target.slice(at) : '';++ const base = at >= 0 ? target.slice(0, at) : target;++ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;++}++++function authorizeUrl(config, { silent = false } = {}) {++ const state = crypto.randomBytes(16).toString('hex');++ const u = new URL(`${config.networkUrl}/oauth/authorize`);++ u.searchParams.set('client_id', config.oauth.clientId);++ u.searchParams.set('redirect_uri', config.oauth.redirectUri);++ u.searchParams.set('response_type', 'code');++ u.searchParams.set('scope', config.oauth.scope);++ u.searchParams.set('state', state);++ if (silent) u.searchParams.set('prompt', 'none');++ return { url: u.toString(), state };++}++++function fedcmNonceMatches(token, nonce) {++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return false;++ let claims;++ try { claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return false; }++ if (!claims || typeof claims.nonce !== 'string') return false;++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);++ return a.length === b.length && crypto.timingSafeEqual(a, b);++}++++function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, log = console }) {++ const router = express.Router();++ const secure = config.cookies.secure;++ const access = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });++ const refresh = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });++ const flow = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });++ const hint = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });++++ function setSession(res, accessToken, refreshToken) {++ res.cookie(ACCESS_COOKIE, accessToken, access());++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refresh());++ res.cookie(HINT_COOKIE, 'account', hint());++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, { ...access(), maxAge: undefined });++ res.clearCookie(REFRESH_COOKIE, { ...refresh(), maxAge: undefined });++ }++ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }++++ async function tokenGrant(body) {++ let lastErr = null;++ for (const base of [config.networkInternalUrl, config.networkUrl]) {++ if (!base) continue;++ try {++ const res = await fetchImpl(`${base}/oauth/token`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),++ signal: AbortSignal.timeout(10000),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status, error: data.error || 'invalid_grant' });++ return data;++ } catch (err) {++ lastErr = err;++ if (err.status && err.status < 500) throw err;++ }++ }++ throw lastErr || new Error('Network unreachable');++ }++++ router.get('/login', async (req, res) => {++ const silent = !!req.query.silent && req.query.silent !== '0';++ const next = sanitizeNext(req.query.next, config);++ if (silent) {++ const actor = await viewers.resolve(req, { services: false }).catch(() => null);++ if (actor && actor.kind === 'user') { clearFlow(res); return res.redirect(next); }++ }++ const { url, state } = authorizeUrl(config, { silent });++ res.cookie(STATE_COOKIE, state, flow());++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flow()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });++ if (silent) res.cookie(SILENT_COOKIE, '1', flow()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });++ res.redirect(url);++ });++++ router.get('/callback', async (req, res) => {++ const { code, state, error } = req.query;++ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);++ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';++ if (error) {++ clearFlow(res);++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));++ return res.redirect(withParam('/', 'auth_error', String(error)));++ }++ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');++ const expected = req.cookies && req.cookies[STATE_COOKIE];++ clearFlow(res);++ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {++ return res.status(400).type('text/plain').send('OAuth state mismatch — please try signing in again.');++ }++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });++ setSession(res, data.access_token, data.refresh_token);++ return res.redirect(next);++ } catch (err) {++ log.error(`[Wiki] code exchange failed: ${err.message}`);++ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');++ }++ });++++ router.post('/fedcm', express.json({ limit: '16kb' }), async (req, res) => {++ const { token, nonce } = req.body || {};++ if (typeof token !== 'string' || !token || !fedcmNonceMatches(token, nonce)) return res.status(400).json({ error: 'invalid_request', error_description: 'token and a matching nonce are required' });++ try {++ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401 });++ setSession(res, data.access_token, data.refresh_token);++ return res.json({ ok: true, user: data.user || null });++ } catch (err) {++ if (err.status && err.status < 500) return res.status(401).json({ error: 'invalid_grant' });++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });++ }++ });++++ router.get('/logout', async (req, res) => {++ const token = req.cookies && req.cookies[REFRESH_COOKIE];++ if (token) {++ try {++ await fetchImpl(`${config.networkInternalUrl || config.networkUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token }),++ signal: AbortSignal.timeout(3000),++ });++ } catch { /* optional */ }++ }++ clearSession(res);++ res.cookie(HINT_COOKIE, 'guest', hint());++ res.redirect(sanitizeNext(req.query.next, config));++ });++++ router.get('/me', async (req, res) => {++ const actor = await viewers.resolve(req, { services: false }).catch(() => null);++ if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });++ res.set('Cache-Control', 'private, no-store').json({ user: actor.user });++ });++++ router.post('/refresh', async (req, res) => {++ const token = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!token) return res.status(401).json({ error: 'No refresh token' });++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: token });++ setSession(res, data.access_token, data.refresh_token);++ return res.json({ token: data.access_token });++ } catch (err) {++ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });++ }++ });++++ return router;++}++++module.exports = { createSessionRoutes, sanitizeNext, authorizeUrl, fedcmNonceMatches };nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * openvibe.chat, the site (server/web/): global chat reads without JavaScript and posts through the same++ * handler as the API; messages, a conversation (participants only) and settings need a signed-in++ * person; forms from another origin are refused; user text is escaped; sign-in goes to the Network with++ * the site's callback; the Frame's files, robots, sitemap and /updates are served.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('web');++let h, alice, bob, carol;++const SITE = 'https://openvibe.chat';++const cookie = (u) => ({ cookie: `ov_token=${u.token}` });++// Like h.http, but redirects are answers here, not followed.++const req = async (method, path, { headers = {}, raw } = {}) => {++ const r = await fetch(`${h.base}${path}`, { method, headers, body: raw, redirect: 'manual' });++ return { status: r.status, headers: r.headers, text: await r.text() };++};++const form = (u, fields, extra = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE, ...extra }, raw: new URLSearchParams(fields).toString() });++++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: SITE } });++ alice = h.addUser('alice', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ carol = h.addUser('carol', { subject: ids.newId('user') });++ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }++});++++t('global chat reads without JavaScript; signing in shows the composer', async () => {++ const r = await req('GET', '/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<h1 id="oc-h">Global chat<\/h1>/);++ assert.match(r.text, /Sign in with OpenVibe/);++ assert.match(r.text, /<meta name="robots" content="index, follow">/);++ assert.match(r.text, /\/shared\/navbar\.js\?v=/, 'the Frame from this site\'s own /shared/');++ assert.ok(!/id="oc-compose"/.test(r.text), 'no composer for a guest');++ const me = await req('GET', '/', { headers: cookie(alice) });++ assert.match(me.text, /id="oc-compose"/);++});++++t('the composer posts without JavaScript, through the API\'s checks; other origins are refused', async () => {++ let r = await req('POST', '/send', form(alice, { message: 'hello <script>alert(1)</script> https://example.org/x' }));++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), '/#oc-compose');++ const page = (await req('GET', '/')).text;++ assert.ok(page.includes('hello <script>alert(1)</script>'), 'escaped');++ assert.ok(page.includes('<a href="https://example.org/x" rel="nofollow ugc noopener"'), 'links are links');++ assert.ok(!page.includes('<script>alert(1)</script>'));++ r = await req('POST', '/send', form(alice, { message: 'from elsewhere' }, { origin: 'https://evil.example' }));++ assert.strictEqual(r.status, 403);++ r = await req('POST', '/send', form(alice, { message: '' }));++ assert.strictEqual(r.status, 303);++ assert.match(r.headers.get('location'), /^\/\?error=/, 'the API\'s refusal comes back as a notice');++ r = await req('POST', '/send', { headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, raw: 'message=hi' });++ assert.strictEqual(r.status, 303);++ assert.match(r.headers.get('location'), /^\/auth\/login\?next=/, 'signed out: sign in first');++});++++t('messages: start a conversation by username, reply without JavaScript, participants only', async () => {++ assert.match((await req('GET', '/messages')).headers.get('location'), /^\/auth\/login\?next=%2Fmessages/);++ let r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));++ assert.strictEqual(r.status, 303);++ const m = /^\/messages\/(\d+)$/.exec(r.headers.get('location'));++ assert.ok(m, r.headers.get('location'));++ r = await req('POST', `/messages/${m[1]}`, form(alice, { message: 'hi bob' }));++ assert.strictEqual(r.status, 303);++ const thread = await req('GET', `/messages/${m[1]}`, { headers: cookie(bob) });++ assert.strictEqual(thread.status, 200);++ assert.ok(thread.text.includes('hi bob'));++ assert.match(thread.text, /noindex, nofollow/);++ const inbox = await req('GET', '/messages', { headers: cookie(bob) });++ assert.ok(inbox.text.includes(`/messages/${m[1]}`));++ assert.strictEqual((await req('GET', `/messages/${m[1]}`, { headers: cookie(carol) })).status, 404, 'not a participant');++ r = await req('POST', `/messages/${m[1]}`, form(carol, { message: 'let me in' }));++ assert.match(r.headers.get('location'), /error=/, 'the API refuses a non-participant');++ r = await req('POST', '/messages/new', form(alice, { username: 'nobody_like_this' }));++ assert.match(r.headers.get('location'), /^\/messages\?error=/);++});++++t('settings save to the chat.preferences record', async () => {++ let r = await req('GET', '/settings', { headers: cookie(alice) });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /name="timestamps"/);++ r = await req('POST', '/settings', form(alice, { timestamps: '1', compact: '1', font_size: 'large' }));++ assert.strictEqual(r.headers.get('location'), '/settings?saved=1');++ const rec = h.netModules.records.get(h.netModules.key('chat.preferences', alice.subject_id));++ assert.deepStrictEqual(rec && rec.data, { timestamps: true, compact: true, show_badges: false, font_scale: 1.18 });++ const page = await req('GET', '/', { headers: cookie(alice) });++ assert.match(page.text, /<body class="oc-compact oc-times oc-nobadges" style="--oc-scale:1.18">/, 'the site follows them too');++});++++t('sign-in, the Frame, robots, sitemap and /updates', async () => {++ const r = await req('GET', '/auth/login?next=%2Fmessages');++ assert.strictEqual(r.status, 302);++ const u = new URL(r.headers.get('location'));++ assert.deepStrictEqual([u.pathname, u.searchParams.get('client_id'), u.searchParams.get('redirect_uri')], ['/oauth/authorize', 'chat', `${SITE}/auth/callback`]);++ assert.strictEqual((await req('GET', '/shared/navbar.js')).status, 200);++ assert.strictEqual((await req('GET', '/web/chat.css')).status, 200);++ assert.match((await req('GET', '/robots.txt')).text, /Disallow: \/messages/);++ assert.match((await req('GET', '/sitemap.xml')).text, /<loc>https:\/\/openvibe\.chat\/updates<\/loc>/);++ const up = await req('GET', '/updates');++ assert.strictEqual(up.status, 200);++ assert.match(up.text, /data-ov-shipped/);++ assert.strictEqual((await req('GET', '/api/nope')).status, 404, 'the API keeps its JSON 404');++});++++t.run(async () => { if (h && h.close) await h.close(); });
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
"engines": {
"engines": {
@@ -1087,9 +1087,9 @@
@@ -1087,9 +1087,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.7.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",
- "integrity": "sha512-gF1PABMEGHhtu0WMkJQHwJOvbYzWcdfOi3feGYrCUkLXIZhpeSvrSOD7SF0+nveWhfCQMFjqNU8Q0d90vv4s/A==",
+ "version": "1.12.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
+ "integrity": "sha512-Qpy4YVoPMs1q/0/08/5ZODI0+7ev+Krk75nsRPsTjNCm7Eu91tt1LPnUNLt4kYfRj6duS+N9icm02gksnlMiyA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.7.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+/* openvibe.chat. Colours follow the network theme variables (theme-loader.js) with dark defaults that
+ match the critical canvas; everything reads and posts without JavaScript. */
+:root {
+ --oc-bg: var(--bg-primary, #0a0f1c);
+ --oc-panel: var(--bg-secondary, #111a2c);
+ --oc-panel-2: var(--bg-tertiary, #16213a);
+ --oc-text: var(--text-primary, #e6edf7);
+ --oc-muted: var(--text-secondary, #9fb0c8);
+ --oc-line: var(--border-color, rgba(255, 255, 255, .12));
+ --oc-accent: var(--accent, #60a5fa);
+ --oc-on-accent: var(--on-accent, #0a0f1c);
+ --oc-red: #f87171;
+ --oc-ok: #34d399;
+ --oc-scale: 1;
+}
+* { box-sizing: border-box; }
+html { -webkit-text-size-adjust: 100%; }
+body { margin: 0; background: var(--oc-bg); color: var(--oc-text); font: calc(16px * var(--oc-scale))/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+a { color: var(--oc-accent); }
+a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
+.oc-skip { position: absolute; left: -9999px; }
+.oc-skip:focus { left: 16px; top: 8px; background: var(--oc-panel); padding: 6px 10px; z-index: 10; }
+.oc-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.oc-bar { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; max-width: 960px; margin: 0 auto; padding: 12px 16px; border-bottom: 1px solid var(--oc-line); }
+.oc-brand { font-weight: 800; color: var(--oc-text); text-decoration: none; letter-spacing: -.01em; }
+.oc-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
+.oc-tabs a { color: var(--oc-muted); text-decoration: none; padding: 4px 10px; border-radius: 999px; }
+.oc-tabs a[aria-current="page"], .oc-tabs a:hover { color: var(--oc-text); background: var(--oc-panel); }
+.oc-account { font-size: 14px; color: var(--oc-muted); margin-left: auto; }
+.oc-main { max-width: 960px; margin: 0 auto; padding: 16px 16px 48px; overflow-wrap: anywhere; }
+h1 { font-size: 1.6rem; line-height: 1.25; margin: .3em 0 .2em; }
+.oc-muted { color: var(--oc-muted); }
+.oc-live { color: var(--oc-ok); font-size: .85em; margin-left: 6px; }
+.oc-notice { padding: 8px 12px; border-left: 4px solid var(--oc-accent); background: var(--oc-panel); border-radius: 0 8px 8px 0; }
+.oc-notice.oc-error { border-color: var(--oc-red); }
+.oc-notice.oc-ok { border-color: var(--oc-ok); }
+.oc-feed { list-style: none; margin: 12px 0; padding: 8px 4px; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; max-height: min(64vh, 720px); overflow-y: auto; overscroll-behavior: contain; }
+.oc-msg { padding: 5px 10px; border-radius: 8px; line-height: 1.45; }
+.oc-msg:hover { background: var(--oc-panel-2); }
+.oc-compact .oc-msg { padding: 1px 10px; }
+.oc-time { display: none; color: var(--oc-muted); font-size: .8em; font-variant-numeric: tabular-nums; margin-right: 4px; }
+.oc-times .oc-time { display: inline; }
+.oc-name { font-weight: 700; text-decoration: none; }
+.oc-anon { color: var(--oc-muted); font-weight: 600; }
+.oc-where { color: var(--oc-muted); font-size: .8em; text-decoration: none; margin-left: 4px; }
+.oc-text { white-space: pre-wrap; }
+.oc-badge { display: inline-block; font-size: .7em; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; padding: 0 6px; margin-left: 6px; border-radius: 999px; border: 1px solid var(--oc-line); color: var(--oc-muted); vertical-align: 1px; }
+.oc-badge-staff { color: #fbbf24; border-color: rgba(251, 191, 36, .45); }
+.oc-badge-mod { color: var(--oc-ok); border-color: rgba(52, 211, 153, .45); }
+.oc-nobadges .oc-badge-staff, .oc-nobadges .oc-badge-mod { display: none; }
+.oc-mine .oc-name { color: var(--oc-accent); }
+.oc-empty { padding: 16px; text-align: center; }
+.oc-compose { display: flex; gap: 8px; align-items: flex-end; }
+.oc-compose textarea { flex: 1; min-height: 44px; resize: vertical; }
+input, select, textarea { font: inherit; color: var(--oc-text); background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 10px; padding: 8px 10px; max-width: 100%; }
+button, .oc-button { display: inline-block; background: var(--oc-accent); color: var(--oc-on-accent); border: 0; border-radius: 10px; padding: 9px 16px; font: inherit; font-weight: 700; cursor: pointer; text-decoration: none; }
+button[disabled] { opacity: .6; cursor: default; }
+.oc-signin { margin: 12px 0; }
+.oc-new { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0 16px; }
+.oc-new input { flex: 1 1 180px; }
+.oc-convs { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.oc-conv { display: flex; gap: 12px; align-items: center; background: var(--oc-panel); border: 1px solid var(--oc-line); border-radius: 12px; padding: 10px 14px; }
+.oc-conv a { flex: 1; min-width: 0; color: var(--oc-text); text-decoration: none; display: flex; flex-direction: column; }
+.oc-conv .oc-last { font-size: .9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.oc-conv.oc-unread { border-color: var(--oc-accent); }
+.oc-form { display: grid; gap: 10px; max-width: 520px; }
+.oc-check { display: flex; gap: 8px; align-items: center; }
+.oc-form select { margin-left: 8px; }
+@media (max-width: 560px) {
+ .oc-main { padding: 12px 12px 40px; }
+ .oc-feed { max-height: 60vh; }
+ .oc-account { margin-left: 0; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+/* openvibe.chat client: the live global feed over /ws/chat and a quicker composer; DMs refresh every few
+ seconds. Pages work without this file (server-rendered, forms post); it only adds liveness. User text
+ is always set with textContent, never parsed as HTML. */
+(function () {
+ 'use strict';
+ var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
+ if (!cfg) return;
+ var feed = document.getElementById('oc-feed');
+ var form = document.getElementById('oc-compose');
+ var input = document.getElementById('oc-input');
+ var latest = Number(cfg.latest) || 0;
+ var LIVE = 'https://openvibe.live';
+
+ function nearBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; }
+ function toBottom() { if (feed) feed.scrollTop = feed.scrollHeight; }
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
+ function clock(ts) {
+ var d = new Date(ts && String(ts).indexOf('T') < 0 ? String(ts).replace(' ', 'T') + 'Z' : ts);
+ var t = el('time', 'oc-time', isNaN(d) ? '' : d.toISOString().slice(11, 16));
+ if (!isNaN(d)) t.dateTime = d.toISOString();
+ return t;
+ }
+ function textWithLinks(parent, text) {
+ var re = /https?:\/\/[^\s<>"']+/g, last = 0, m;
+ text = String(text || '');
+ while ((m = re.exec(text))) {
+ if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));
+ var a = el('a', null, m[0]); a.href = m[0]; a.rel = 'nofollow ugc noopener'; a.target = '_blank';
+ parent.appendChild(a);
+ last = m.index + m[0].length;
+ }
+ if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
+ }
+ // Display only (the server decides who may do what): the same badges the page renders.
+ var BADGES = { admin: ['oc-badge oc-badge-staff', 'staff'], global_mod: ['oc-badge oc-badge-mod', 'mod'] };
+ function badge(role) { var b = BADGES[role]; return b ? el('span', b[0], b[1]) : null; }
+ function removeEmpty() { var e = feed && feed.querySelector('.oc-empty'); if (e) e.remove(); }
+ function append(li, id) {
+ if (!feed) return;
+ if (id && feed.querySelector('[data-id="' + id + '"]')) return;
+ var stick = nearBottom();
+ removeEmpty();
+ feed.appendChild(li);
+ while (feed.children.length > 300) feed.removeChild(feed.firstChild);
+ if (stick) toBottom();
+ }
+ function showError(text) {
+ var p = document.querySelector('.oc-live-error');
+ if (!p) { p = el('p', 'oc-notice oc-error oc-live-error'); p.setAttribute('role', 'alert'); (form || feed).insertAdjacentElement('beforebegin', p); }
+ p.textContent = text;
+ clearTimeout(showError.t); showError.t = setTimeout(function () { p.remove(); }, 6000);
+ }
+
+ // ── Global chat ──
+ function globalItem(m) {
+ var li = el('li', 'oc-msg'); li.dataset.id = m.id || '';
+ li.appendChild(clock(m.timestamp)); li.appendChild(document.createTextNode(' '));
+ var handle = m.core_username || (m.user_id ? m.username : null);
+ var name = m.display_name || m.username || m.anon_id || 'someone';
+ var who;
+ if (handle) { who = el('a', 'oc-name', name); who.href = LIVE + '/@' + encodeURIComponent(handle); if (/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '')) who.style.color = m.profile_color; }
+ else who = el('span', 'oc-name oc-anon', name);
+ li.appendChild(who);
+ var b = badge(m.role); if (b) li.appendChild(b);
+ li.appendChild(document.createTextNode(' '));
+ var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
+ return li;
+ }
+ function catchUp() {
+ return fetch('/api/chat/global/history?after_id=' + latest + '&limit=200', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (!d || !d.messages) return;
+ d.messages.forEach(function (m) { if ((m.message_type || 'chat') === 'chat' && !m.is_deleted) append(globalItem(m), m.id); });
+ if (d.latest_id) latest = Math.max(latest, d.latest_id);
+ }).catch(function () {});
+ }
+ function startGlobal() {
+ var live = document.getElementById('oc-live');
+ var ws = null, delay = 1000, opened = false;
+ function connect() {
+ ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
+ ws.onopen = function () {
+ delay = 1000;
+ ws.send(JSON.stringify({ type: 'join' }));
+ if (live) live.hidden = false;
+ if (opened) catchUp();
+ opened = true;
+ };
+ ws.onmessage = function (ev) {
+ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
+ if (m.type === 'chat' && m.is_global) {
+ if (m.id) latest = Math.max(latest, Number(m.id));
+ append(globalItem(m), m.id);
+ } else if (m.type === 'delete-messages' && Array.isArray(m.ids || m.message_ids)) {
+ (m.ids || m.message_ids).forEach(function (id) { var x = feed.querySelector('[data-id="' + id + '"]'); if (x) x.remove(); });
+ } else if (m.type === 'error' && m.message) {
+ showError(m.message);
+ } else if (m.type === 'auth_revoked') {
+ showError('You were signed out. Sign in again to keep chatting.');
+ }
+ };
+ ws.onclose = function () {
+ if (live) live.hidden = true;
+ setTimeout(connect, delay); delay = Math.min(delay * 2, 30000);
+ };
+ }
+ connect();
+ if (form && input) {
+ form.addEventListener('submit', function (e) {
+ var text = input.value.trim();
+ if (!text) { e.preventDefault(); return; }
+ if (!ws || ws.readyState !== 1) return; // not connected: the form posts as without JavaScript
+ e.preventDefault();
+ ws.send(JSON.stringify({ type: 'chat', message: text }));
+ input.value = '';
+ input.focus();
+ });
+ }
+ }
+
+ // ── A conversation ──
+ function dmItem(m) {
+ var li = el('li', 'oc-msg' + (m.sender_id === cfg.me ? ' oc-mine' : '')); li.dataset.id = m.id || '';
+ li.appendChild(clock(m.created_at)); li.appendChild(document.createTextNode(' '));
+ li.appendChild(el('span', 'oc-name', m.display_name || m.username));
+ li.appendChild(document.createTextNode(' '));
+ var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
+ return li;
+ }
+ function startDm() {
+ var base = '/api/dm/conversations/' + encodeURIComponent(cfg.conversation);
+ function poll() {
+ if (document.hidden) return;
+ fetch(base + '/messages?after=' + latest + '&limit=100', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (!d || !d.messages || !d.messages.length) return;
+ d.messages.forEach(function (m) { latest = Math.max(latest, Number(m.id) || 0); append(dmItem(m), m.id); });
+ fetch(base + '/read', { method: 'POST', credentials: 'same-origin' }).catch(function () {});
+ }).catch(function () {});
+ }
+ setInterval(poll, 4000);
+ document.addEventListener('visibilitychange', poll);
+ if (form && input) {
+ form.addEventListener('submit', function (e) {
+ var text = input.value.trim();
+ if (!text) { e.preventDefault(); return; }
+ e.preventDefault();
+ var btn = form.querySelector('button'); if (btn) btn.disabled = true;
+ fetch(base + '/messages', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text }) })
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
+ .then(function (x) {
+ if (!x.ok) { showError((x.d && x.d.error) || 'Could not send'); return; }
+ if (x.d && x.d.message) { latest = Math.max(latest, Number(x.d.message.id) || 0); append(dmItem(x.d.message), x.d.message.id); }
+ input.value = ''; input.focus();
+ })
+ .catch(function () { form.submit(); })
+ .then(function () { if (btn) btn.disabled = false; });
+ });
+ }
+ }
+
+ if (input) input.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
+ });
+ toBottom();
+ if (cfg.view === 'global') startGlobal();
+ else if (cfg.view === 'dm') startDm();
+})();
@@ -48,6 +48,7 @@ function getAllowedOrigins() {
@@ -48,6 +48,7 @@ function getAllowedOrigins() {
} catch { /* */ }
} catch { /* */ }
}
}
allowed.add(normalizeOrigin(config.networkUrl) || 'https://openvibe.network');
allowed.add(normalizeOrigin(config.networkUrl) || 'https://openvibe.network');
+ allowed.add(normalizeOrigin(config.web.baseUrl) || 'https://openvibe.chat');
allowed.add('https://openvibe.games');
allowed.add('https://openvibe.games');
allowed.add('https://openvibe.tools');
allowed.add('https://openvibe.tools');
if (!config.isProduction) {
if (!config.isProduction) {
@@ -206,6 +207,9 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
@@ -206,6 +207,9 @@ function createApp({ chatServer, bridge, mirror, relay, events = null }) {
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/tts', require('./chat/tts-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
app.use('/api/sounds', require('./chat/sounds-routes'));
+ // openvibe.chat, the site: pages, sign-in, the Frame's /shared/ files (server/web/).
+ app.use(require('./web/pages').createWebRoutes({ config }));
+
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
if (err.name === 'MulterError' || (err.message && err.message.includes('file'))) {
if (err.name === 'MulterError' || (err.message && err.message.includes('file'))) {
@@ -39,12 +39,21 @@ module.exports = {
@@ -39,12 +39,21 @@ module.exports = {
// PEM with the Network's RS256 public key. When unset or unreadable the key is fetched from
// PEM with the Network's RS256 public key. When unset or unreadable the key is fetched from
// ${networkInternalUrl}/api/.well-known/jwks.
// ${networkInternalUrl}/api/.well-known/jwks.
networkPublicKeyPath: process.env.OV_NETWORK_PUBLIC_KEY || '',
networkPublicKeyPath: process.env.OV_NETWORK_PUBLIC_KEY || '',
- // Chat's service principal (client `chat` in the Network's oauth_clients).
+ // Chat's service principal (client `chat` in the Network's oauth_clients). The same client signs
+ // people in to openvibe.chat (redirect <web.baseUrl>/auth/callback), as Wiki and Blog do.
oauth: {
oauth: {
clientId: process.env.OV_OAUTH_CLIENT_ID || 'chat',
clientId: process.env.OV_OAUTH_CLIENT_ID || 'chat',
clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: process.env.CHAT_OAUTH_REDIRECT_URI || `${strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400'))}/auth/callback`,
+ scope: 'profile theme',
},
},
+ // openvibe.chat, the site (server/web/): global chat, messages and settings, served by Chat itself.
+ web: {
+ baseUrl: strip(process.env.CHAT_WEB_URL || (isProduction ? 'https://openvibe.chat' : 'http://localhost:4400')),
+ },
+ cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
+
// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.
live: {
live: {
internalUrl: strip(process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
internalUrl: strip(process.env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * openvibe.chat page shell (server/web/). Every page is server-rendered and readable without
+ * JavaScript; the OpenVibe Frame (navbar, footer, themes) comes from this site's own /shared/ copy of
+ * openvibe-shared, and public/web/chat.js adds the live feed and the composer.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const ovServe = require('openvibe-shared/serve');
+const sharedSeo = require('openvibe-shared/seo');
+const appIcon = require('openvibe-shared/app-icon');
+const frame = require('openvibe-shared/frame');
+
+const SITE_NAME = 'OpenVibe.Chat';
+const NETWORK_URL = 'https://openvibe.network';
+const DEFAULT_DESCRIPTION = 'OpenVibe.Chat: the network-wide chat room and your direct messages, one account across every OpenVibe site.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
+const LINKS = [
+ { key: 'global', label: 'Global chat', href: '/' },
+ { key: 'messages', label: 'Messages', href: '/messages' },
+ { key: 'settings', label: 'Settings', href: '/settings' },
+];
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+const hashes = new Map();
+function asset(rel) {
+ if (!hashes.has(rel)) {
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ }
+ return `/web/${rel}?v=${hashes.get(rel)}`;
+}
+
+function navConfig(o, config) {
+ return {
+ service: 'chat',
+ apiBase: NETWORK_URL,
+ links: LINKS.map((l) => ({ label: l.label, href: l.href, active: o.active === l.key })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${config.web.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: `/auth/login?next=${encodeURIComponent(o.path || '/')}`,
+ logoutUrl: '/auth/logout?next={path}',
+ };
+}
+
+/**
+ * o: title, description, path, robots (required), body, active, actor, config, page (JSON for chat.js),
+ * prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges)
+ */
+function renderPage(o) {
+ const { config } = o;
+ if (!o.robots) throw new TypeError('renderPage needs explicit robots');
+ const title = o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME;
+ const head = sharedSeo.headTags({
+ title, description: o.description || DEFAULT_DESCRIPTION, canonical: `${config.web.baseUrl}${o.path || '/'}`,
+ robots: o.robots, siteName: SITE_NAME, type: 'website',
+ });
+ const actor = o.actor || { kind: 'anonymous' };
+ const who = actor.kind === 'user'
+ ? `<span>Signed in as ${esc(actor.user.display_name || actor.user.username)}</span> · <a href="/auth/logout?next=${encodeURIComponent(o.path || '/')}">Sign out</a>`
+ : `<a href="/auth/login?next=${encodeURIComponent(o.path || '/')}">Sign in with OpenVibe</a>`;
+ const p = o.prefs || {};
+ const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');
+ const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+${head}
+${appIcon.headTags({ site: 'network' })}
+<link rel="stylesheet" href="${asset('chat.css')}">
+<script src="${ovServe.url('theme-loader.js')}" defer></script>
+<script src="${ovServe.url('navbar.js')}" defer></script>
+<script src="${ovServe.url('footer.js')}" defer></script>
+${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
+</head>
+<body class="${cls}"${scale}>
+<a class="oc-skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+${frame.noscriptNav({ name: SITE_NAME, home: '/', links: LINKS.map(({ label, href }) => ({ label, href })) })}
+<header class="oc-bar"><a class="oc-brand" href="/">${SITE_NAME}</a><nav class="oc-tabs" aria-label="Chat">${LINKS.map((l) => `<a href="${l.href}"${o.active === l.key ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav><noscript><span class="oc-account">${who}</span></noscript></header>
+<main id="main" class="oc-main">
+${o.body || ''}
+</main>
+${frame.footer({ service: 'chat', variant: 'full', updates: '/updates' })}
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'chat', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
+});
+</script>
+</body>
+</html>`;
+}
+
+module.exports = { renderPage, asset, esc, SITE_NAME, DEFAULT_DESCRIPTION, NETWORK_URL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,283 @@
@@ -0,0 +1,283 @@
+'use strict';
+/**
+ * openvibe.chat, the site (roadmap WS-I task 8): the network-wide chat room, your direct messages and
+ * your chat settings, served by OpenVibe.Chat itself, so it keeps working while OpenVibe.Live is down.
+ *
+ * GET / global chat: the last messages server-rendered (readable without
+ * JavaScript); public/web/chat.js adds the live feed over /ws/chat
+ * POST /send the composer without JavaScript (the same handler as POST /api/chat/send)
+ * GET /messages your conversations; POST /messages/new starts one by username
+ * GET /messages/:id one conversation (participants only); POST /messages/:id replies
+ * GET /settings your portable chat settings (Network module chat.preferences); POST saves
+ * GET /updates what shipped on openvibe.chat (the shared changelog)
+ * GET /robots.txt, /sitemap.xml
+ * /auth/* sign in with OpenVibe.Network (./session.js), cookies host-only
+ *
+ * Forms go through the same API handlers the JavaScript client calls (apiBridge), so rate limits,
+ * bans, blocks, spam checks and DM participation are enforced in one place. Posts must come from this
+ * site (Origin check; the session cookie is SameSite=Lax).
+ */
+const path = require('path');
+const express = require('express');
+const rateLimit = require('express-rate-limit');
+const ovServe = require('openvibe-shared/serve');
+const frame = require('openvibe-shared/frame');
+const { renderPage, esc, SITE_NAME } = require('./layout');
+const { createSessionRoutes } = require('./session');
+const session = require('../auth/network-session');
+const historyStore = require('../chat/history-store');
+const dm = require('../chat/dm');
+const prefs = require('../prefs/chat-preferences');
+const ctx = require('../live-context');
+const permissions = require('../auth/permissions');
+
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
+const ANON = Object.freeze({ kind: 'anonymous' });
+const LIVE = 'https://openvibe.live';
+const FONT_SCALES = { small: 0.88, default: null, large: 1.18 };
+
+async function viewerOf(req) {
+ const token = req.cookies && req.cookies.ov_token;
+ if (!token) return ANON;
+ const u = await session.authenticate(token).catch(() => null);
+ if (!u || u.auth_source === 'api_token') return ANON;
+ return { kind: 'user', user: u, token };
+}
+
+async function prefsOf(actor) {
+ if (actor.kind !== 'user' || !actor.user.subject_id) return {};
+ try { return (await prefs.get(actor.user.subject_id)).preferences || {}; } catch { return {}; }
+}
+
+const hhmm = (ts) => {
+ const d = new Date(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`);
+ return Number.isNaN(d.getTime()) ? { iso: '', text: '' } : { iso: d.toISOString(), text: d.toISOString().slice(11, 16) };
+};
+/** Plain text with http(s) links, escaped. */
+function linkify(text) {
+ return esc(text).replace(/https?:\/\/[^\s<>"']+/g, (u) => `<a href="${u}" rel="nofollow ugc noopener" target="_blank">${u}</a>`);
+}
+/** The author's badge, from the staff map (ADR-022): staff run the site, moderators moderate chat. */
+function roleBadge(role) {
+ const who = { role: String(role || 'user') };
+ if (permissions.can(who, 'staff.site.configure')) return '<span class="oc-badge oc-badge-staff" title="OpenVibe staff">staff</span>';
+ if (permissions.can(who, 'staff.moderation.chat')) return '<span class="oc-badge oc-badge-mod" title="Moderator">mod</span>';
+ return '';
+}
+function globalMessage(m) {
+ const t = hhmm(m.timestamp);
+ const handle = m.core_username || (m.user_id ? m.username : null);
+ const name = esc(m.display_name || m.username || m.anon_id || 'someone');
+ const who = handle ? `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(handle)}" style="color:${esc(/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? m.profile_color : 'inherit')}">${name}</a>` : `<span class="oc-name oc-anon">${name}</span>`;
+ const where = m.stream_channel ? ` <a class="oc-where" href="${LIVE}/@${encodeURIComponent(m.stream_channel)}" title="Sent from ${esc(m.stream_channel)}'s channel">@${esc(m.stream_channel)}</a>` : '';
+ return `<li class="oc-msg" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who}${roleBadge(m.role)}${where} <span class="oc-text">${linkify(m.message)}</span></li>`;
+}
+function dmMessage(m, me) {
+ const t = hhmm(m.created_at);
+ const mine = me && m.sender_id === me.id;
+ return `<li class="oc-msg${mine ? ' oc-mine' : ''}" data-id="${Number(m.id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> <span class="oc-name">${esc(m.display_name || m.username)}</span> <span class="oc-text">${linkify(m.message)}</span></li>`;
+}
+const notice = (kind, text) => `<p class="oc-notice oc-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${esc(text)}</p>`;
+
+/**
+ * Run a form through an existing JSON API handler: the route is re-dispatched under `apiPath`, and its
+ * JSON answer becomes a redirect (success) or an error page. One set of checks for both clients.
+ */
+function apiBridge(apiRouter, apiPath, { body, ok, fail }) {
+ return (req, res, next) => {
+ const original = req.url;
+ req.url = typeof apiPath === 'function' ? apiPath(req) : apiPath;
+ if (body) req.body = body(req);
+ res.json = (payload) => {
+ req.url = original;
+ if (res.statusCode < 400) return ok(req, res, payload || {});
+ return fail(req, res, res.statusCode, (payload && payload.error) || 'That did not work');
+ };
+ apiRouter(req, res, (err) => { req.url = original; next(err); });
+ };
+}
+
+function createWebRoutes({ config }) {
+ const router = express.Router();
+ const site = config.web.baseUrl;
+ const siteOrigin = (() => { try { return new URL(site).origin; } catch { return null; } })();
+ const webConfig = { ...config, baseUrl: site };
+ const viewers = { resolve: async (req) => viewerOf(req) };
+ const formLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false });
+ const sameSite = (req, res, next) => {
+ const o = req.get('origin');
+ if (o && o !== siteOrigin && o !== 'null') return res.status(403).type('text').send('Forms are only accepted from this site.');
+ next();
+ };
+ const page = async (req, res, status, o) => {
+ const actor = o.actor || await viewerOf(req);
+ res.status(status).set('Cache-Control', o.cache || 'private, no-store').type('html')
+ .send(renderPage({ config, actor, prefs: await prefsOf(actor), ...o }));
+ };
+ const needUser = async (req, res) => {
+ const actor = await viewerOf(req);
+ if (actor.kind === 'user') return actor;
+ res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ return null;
+ };
+
+ router.use('/auth', createSessionRoutes({ config: webConfig, viewers }));
+ router.use('/shared', ovServe.handler());
+ router.use('/web', express.static(PUBLIC_DIR, { maxAge: '365d', immutable: true, index: false }));
+
+ // ── Global chat ──
+ router.get('/', async (req, res) => {
+ const actor = await viewerOf(req);
+ const { messages, latest_id } = historyStore.page('global', { limit: 60 });
+ const shown = messages.filter((m) => !m.is_deleted && (m.message_type || 'chat') === 'chat');
+ const composer = actor.kind === 'user'
+ ? (actor.user.is_banned ? notice('error', 'Your account is banned from chat.') : `<form class="oc-compose" method="post" action="/send" id="oc-compose">
+<label for="oc-input" class="oc-sr">Message to everyone</label>
+<textarea id="oc-input" name="message" maxlength="500" rows="2" required placeholder="Say something to everyone on OpenVibe"></textarea>
+<button type="submit">Send</button>
+</form>`)
+ : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ await page(req, res, 200, {
+ actor, title: 'Global chat', path: '/', active: 'global', robots: 'index, follow', script: true,
+ description: 'The network-wide chat room of OpenVibe: everyone on every OpenVibe site, in one conversation.',
+ page: { view: 'global', latest: latest_id, signedIn: actor.kind === 'user', me: actor.kind === 'user' ? actor.user.username : null },
+ body: `<section class="oc-room" aria-labelledby="oc-h">
+<h1 id="oc-h">Global chat</h1>
+<p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>
+${error}
+<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${shown.map(globalMessage).join('\n') || '<li class="oc-empty oc-muted">No messages yet. Say hello.</li>'}</ol>
+${composer}
+</section>`,
+ });
+ });
+
+ const chatApi = require('../chat/routes');
+ router.post('/send', formLimit, sameSite, async (req, res, next) => {
+ if (!(await needUser(req, res))) return;
+ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
+ return apiBridge(chatApi, '/send', {
+ body: (r) => ({ message: String((r.body && r.body.message) || '').slice(0, 6000) }),
+ ok: (r, s) => s.redirect(303, '/#oc-compose'),
+ fail: (r, s, status, error) => s.redirect(303, `/?error=${encodeURIComponent(error)}#oc-compose`),
+ })(req, res, next);
+ });
+
+ // ── Direct messages ──
+ const dmApi = require('../chat/dm-routes');
+ router.get('/messages', async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const me = actor.user;
+ const convs = dm.getConversations(me.id);
+ const rows = convs.map((c) => {
+ const others = dm.getParticipants(c.id).filter((p) => p.id !== me.id);
+ const title = c.name || others.map((p) => p.display_name || p.username).join(', ') || 'Just you';
+ const t = hhmm(c.last_message_at);
+ return `<li class="oc-conv${c.unread_count ? ' oc-unread' : ''}"><a href="/messages/${c.id}"><strong>${esc(title)}</strong>${c.unread_count ? ` <span class="oc-badge">${c.unread_count} new</span>` : ''}<span class="oc-muted oc-last">${c.last_message ? esc(String(c.last_message).slice(0, 120)) : 'No messages yet'}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`;
+ });
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ await page(req, res, 200, {
+ actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow',
+ body: `<h1>Messages</h1>${error}
+<form class="oc-new" method="post" action="/messages/new"><label for="oc-to">Message someone</label> <input id="oc-to" name="username" required pattern="[A-Za-z0-9_]{3,24}" placeholder="their username" autocomplete="off"> <button type="submit">Start</button></form>
+<ul class="oc-convs">${rows.join('\n') || '<li class="oc-muted">No conversations yet.</li>'}</ul>`,
+ });
+ });
+
+ router.post('/messages/new', formLimit, sameSite, async (req, res, next) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const name = String((req.body && req.body.username) || '').trim();
+ let target = /^[A-Za-z0-9_]{3,24}$/.test(name) ? ctx.getUserByUsername(name) : null;
+ if (!target && /^[A-Za-z0-9_]{3,24}$/.test(name)) target = await ctx.ensureUserByUsername(name).catch(() => null);
+ if (!target) return res.redirect(303, `/messages?error=${encodeURIComponent(`Nobody called ${name.slice(0, 24)} on OpenVibe`)}`);
+ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
+ return apiBridge(dmApi, '/conversations', {
+ body: () => ({ user_ids: [target.id] }),
+ ok: (r, s, p) => { const id = (p.conversation && p.conversation.id) || p.id; return s.redirect(303, id ? `/messages/${Number(id)}` : '/messages'); },
+ fail: (r, s, status, error) => s.redirect(303, `/messages?error=${encodeURIComponent(error)}`),
+ })(req, res, next);
+ });
+
+ router.get('/messages/:id', async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const me = actor.user;
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || !dm.isParticipant(id, me.id)) return page(req, res, 404, { actor, title: 'Not found', path: req.path, robots: 'noindex', body: '<h1>No such conversation</h1><p><a href="/messages">Back to your messages</a></p>' });
+ const others = dm.getParticipants(id).filter((p) => p.id !== me.id);
+ const conv = dm.getConversation(id);
+ const title = (conv && conv.name) || others.map((p) => p.display_name || p.username).join(', ') || 'Conversation';
+ const msgs = dm.getMessages(id, 60).slice().sort((a, b) => a.id - b.id);
+ try { dm.markRead(id, me.id); } catch { /* best effort */ }
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ await page(req, res, 200, {
+ actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,
+ page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
+ body: `<p><a href="/messages">← Messages</a></p><h1>${esc(title)}</h1>${error}
+<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
+<form class="oc-compose" method="post" action="/messages/${id}" id="oc-compose"><label for="oc-input" class="oc-sr">Reply</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Write a message"></textarea><button type="submit">Send</button></form>`,
+ });
+ });
+
+ router.post('/messages/:id', formLimit, sameSite, async (req, res, next) => {
+ if (!(await needUser(req, res))) return;
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id)) return res.redirect(303, '/messages');
+ req.headers.authorization = `Bearer ${req.cookies.ov_token}`;
+ return apiBridge(dmApi, `/conversations/${id}/messages`, {
+ body: (r) => ({ message: String((r.body && r.body.message) || '').slice(0, 2000) }),
+ ok: (r, s) => s.redirect(303, `/messages/${id}#oc-compose`),
+ fail: (r, s, status, error) => s.redirect(303, `/messages/${id}?error=${encodeURIComponent(error)}#oc-compose`),
+ })(req, res, next);
+ });
+
+ // ── Settings (chat.preferences, the same record Live's chat reads) ──
+ router.get('/settings', async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const p = await prefsOf(actor);
+ const size = p.font_scale == null ? 'default' : p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
+ const box = (name, label, on) => `<label class="oc-check"><input type="checkbox" name="${name}" value="1"${on ? ' checked' : ''}> ${label}</label>`;
+ const saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';
+ const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
+ await page(req, res, 200, {
+ actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow',
+ body: `<h1>Chat settings</h1>${saved}${error}
+<p class="oc-muted">Saved to your OpenVibe account, so they apply here, in Live chat and on every device you sign in to.</p>
+<form class="oc-form" method="post" action="/settings">
+${box('timestamps', 'Show the time next to each message', p.timestamps === true)}
+${box('compact', 'Compact messages', p.compact === true)}
+${box('show_badges', 'Show staff and moderator badges', p.show_badges !== false)}
+${box('hide_emotes', 'Hide emotes', p.hide_emotes === true)}
+<label>Text size <select name="font_size">${['small', 'default', 'large'].map((s) => `<option value="${s}"${s === size ? ' selected' : ''}>${s[0].toUpperCase()}${s.slice(1)}</option>`).join('')}</select></label>
+<p><button type="submit">Save</button></p>
+</form>`,
+ });
+ });
+
+ router.post('/settings', formLimit, sameSite, async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const b = req.body || {};
+ const patch = {
+ timestamps: b.timestamps === '1' ? true : null,
+ compact: b.compact === '1' ? true : null,
+ show_badges: b.show_badges === '1' ? null : false,
+ hide_emotes: b.hide_emotes === '1' ? true : null,
+ font_scale: Object.prototype.hasOwnProperty.call(FONT_SCALES, b.font_size) ? FONT_SCALES[b.font_size] : null,
+ };
+ try { await prefs.update(actor.user.subject_id, patch); return res.redirect(303, '/settings?saved=1'); }
+ catch (err) { return res.redirect(303, `/settings?error=${encodeURIComponent(err.message || 'Could not save right now')}`); }
+ });
+
+ // ── What shipped, robots, sitemap ──
+ router.get('/updates', (req, res) => page(req, res, 200, {
+ title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: 'public, max-age=300',
+ body: frame.updatesBody({ service: 'chat', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`,
+ }));
+ router.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600')
+ .send(`User-agent: *\nAllow: /$\nAllow: /updates\nDisallow: /messages\nDisallow: /settings\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${site}/sitemap.xml\n`));
+ router.get('/sitemap.xml', (req, res) => res.type('application/xml').set('Cache-Control', 'public, max-age=3600')
+ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${['/', '/updates'].map((p) => `<url><loc>${site}${p}</loc></url>`).join('')}</urlset>\n`));
+
+ return router;
+}
+
+module.exports = { createWebRoutes, apiBridge, globalMessage, linkify };
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
+'use strict';
+/**
+ * openvibe.chat as an OAuth2 client of OpenVibe.Network (client `chat`, redirect
+ * <CHAT_WEB_URL>/auth/callback): the session layer OpenVibe.Wiki and OpenVibe.Community use.
+ *
+ * GET /auth/login → Network /oauth/authorize (?silent=1 adds prompt=none; ?next= same-site path)
+ * GET /auth/callback → server-side code exchange, sets the cookies
+ * POST /auth/fedcm → the shared navbar's FedCM assertion (nonce checked here, signature by the Network)
+ * GET /auth/logout → clears the cookies (best-effort refresh revoke)
+ * GET /auth/me → the signed-in profile (offline JWT verification)
+ * POST /auth/refresh → rotates tokens with the refresh token
+ *
+ * Cookies (host-only): ov_token (access JWT, JS-readable for the navbar), ov_refresh (httpOnly,
+ * Path=/auth), ov_sso_hint ('account' | 'guest', tells the navbar whether a silent login is worth it).
+ */
+const crypto = require('crypto');
+const express = require('express');
+
+const ACCESS_COOKIE = 'ov_token';
+const REFRESH_COOKIE = 'ov_refresh';
+const HINT_COOKIE = 'ov_sso_hint';
+const STATE_COOKIE = 'ov_oauth_state';
+const NEXT_COOKIE = 'ov_oauth_next';
+const SILENT_COOKIE = 'ov_oauth_silent';
+
+function sanitizeNext(next, config) {
+ if (!next || typeof next !== 'string') return '/';
+ if (/^\/(?!\/|\\)/.test(next)) return next;
+ try {
+ const u = new URL(next);
+ if (u.protocol !== 'https:') return '/';
+ const allowed = [config.baseUrl, config.networkUrl].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);
+ if (allowed.includes(u.hostname)) return u.toString();
+ } catch { /* fall through */ }
+ return '/';
+}
+
+function withParam(target, key, value) {
+ const at = target.indexOf('#');
+ const hash = at >= 0 ? target.slice(at) : '';
+ const base = at >= 0 ? target.slice(0, at) : target;
+ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
+}
+
+function authorizeUrl(config, { silent = false } = {}) {
+ const state = crypto.randomBytes(16).toString('hex');
+ const u = new URL(`${config.networkUrl}/oauth/authorize`);
+ u.searchParams.set('client_id', config.oauth.clientId);
+ u.searchParams.set('redirect_uri', config.oauth.redirectUri);
+ u.searchParams.set('response_type', 'code');
+ u.searchParams.set('scope', config.oauth.scope);
+ u.searchParams.set('state', state);
+ if (silent) u.searchParams.set('prompt', 'none');
+ return { url: u.toString(), state };
+}
+
+function fedcmNonceMatches(token, nonce) {
+ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return false;
+ let claims;
+ try { claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return false; }
+ if (!claims || typeof claims.nonce !== 'string') return false;
+ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, log = console }) {
+ const router = express.Router();
+ const secure = config.cookies.secure;
+ const access = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
+ const refresh = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
+ const flow = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
+ const hint = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
+
+ function setSession(res, accessToken, refreshToken) {
+ res.cookie(ACCESS_COOKIE, accessToken, access());
+ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refresh());
+ res.cookie(HINT_COOKIE, 'account', hint());
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, { ...access(), maxAge: undefined });
+ res.clearCookie(REFRESH_COOKIE, { ...refresh(), maxAge: undefined });
+ }
+ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
+
+ async function tokenGrant(body) {
+ let lastErr = null;
+ for (const base of [config.networkInternalUrl, config.networkUrl]) {
+ if (!base) continue;
+ try {
+ const res = await fetchImpl(`${base}/oauth/token`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
+ signal: AbortSignal.timeout(10000),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status, error: data.error || 'invalid_grant' });
+ return data;
+ } catch (err) {
+ lastErr = err;
+ if (err.status && err.status < 500) throw err;
+ }
+ }
+ throw lastErr || new Error('Network unreachable');
+ }
+
+ router.get('/login', async (req, res) => {
+ const silent = !!req.query.silent && req.query.silent !== '0';
+ const next = sanitizeNext(req.query.next, config);
+ if (silent) {
+ const actor = await viewers.resolve(req, { services: false }).catch(() => null);
+ if (actor && actor.kind === 'user') { clearFlow(res); return res.redirect(next); }
+ }
+ const { url, state } = authorizeUrl(config, { silent });
+ res.cookie(STATE_COOKIE, state, flow());
+ if (next !== '/') res.cookie(NEXT_COOKIE, next, flow()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
+ if (silent) res.cookie(SILENT_COOKIE, '1', flow()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
+ res.redirect(url);
+ });
+
+ router.get('/callback', async (req, res) => {
+ const { code, state, error } = req.query;
+ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);
+ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';
+ if (error) {
+ clearFlow(res);
+ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
+ return res.redirect(withParam('/', 'auth_error', String(error)));
+ }
+ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
+ const expected = req.cookies && req.cookies[STATE_COOKIE];
+ clearFlow(res);
+ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
+ return res.status(400).type('text/plain').send('OAuth state mismatch — please try signing in again.');
+ }
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.redirect(next);
+ } catch (err) {
+ log.error(`[Wiki] code exchange failed: ${err.message}`);
+ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
+ }
+ });
+
+ router.post('/fedcm', express.json({ limit: '16kb' }), async (req, res) => {
+ const { token, nonce } = req.body || {};
+ if (typeof token !== 'string' || !token || !fedcmNonceMatches(token, nonce)) return res.status(400).json({ error: 'invalid_request', error_description: 'token and a matching nonce are required' });
+ try {
+ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
+ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401 });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.json({ ok: true, user: data.user || null });
+ } catch (err) {
+ if (err.status && err.status < 500) return res.status(401).json({ error: 'invalid_grant' });
+ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ router.get('/logout', async (req, res) => {
+ const token = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (token) {
+ try {
+ await fetchImpl(`${config.networkInternalUrl || config.networkUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token }),
+ signal: AbortSignal.timeout(3000),
+ });
+ } catch { /* optional */ }
+ }
+ clearSession(res);
+ res.cookie(HINT_COOKIE, 'guest', hint());
+ res.redirect(sanitizeNext(req.query.next, config));
+ });
+
+ router.get('/me', async (req, res) => {
+ const actor = await viewers.resolve(req, { services: false }).catch(() => null);
+ if (!actor || actor.kind !== 'user') return res.status(401).json({ error: 'Not authenticated' });
+ res.set('Cache-Control', 'private, no-store').json({ user: actor.user });
+ });
+
+ router.post('/refresh', async (req, res) => {
+ const token = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!token) return res.status(401).json({ error: 'No refresh token' });
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: token });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.json({ token: data.access_token });
+ } catch (err) {
+ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
+ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createSessionRoutes, sanitizeNext, authorizeUrl, fedcmNonceMatches };
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * openvibe.chat, the site (server/web/): global chat reads without JavaScript and posts through the same
+ * handler as the API; messages, a conversation (participants only) and settings need a signed-in
+ * person; forms from another origin are refused; user text is escaped; sign-in goes to the Network with
+ * the site's callback; the Frame's files, robots, sitemap and /updates are served.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('web');
+let h, alice, bob, carol;
+const SITE = 'https://openvibe.chat';
+const cookie = (u) => ({ cookie: `ov_token=${u.token}` });
+// Like h.http, but redirects are answers here, not followed.
+const req = async (method, path, { headers = {}, raw } = {}) => {
+ const r = await fetch(`${h.base}${path}`, { method, headers, body: raw, redirect: 'manual' });
+ return { status: r.status, headers: r.headers, text: await r.text() };
+};
+const form = (u, fields, extra = {}) => ({ headers: { ...cookie(u), 'content-type': 'application/x-www-form-urlencoded', origin: SITE, ...extra }, raw: new URLSearchParams(fields).toString() });
+
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: SITE } });
+ alice = h.addUser('alice', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ carol = h.addUser('carol', { subject: ids.newId('user') });
+ for (const u of [alice, bob, carol]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
+});
+
+t('global chat reads without JavaScript; signing in shows the composer', async () => {
+ const r = await req('GET', '/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<h1 id="oc-h">Global chat<\/h1>/);
+ assert.match(r.text, /Sign in with OpenVibe/);
+ assert.match(r.text, /<meta name="robots" content="index, follow">/);
+ assert.match(r.text, /\/shared\/navbar\.js\?v=/, 'the Frame from this site\'s own /shared/');
+ assert.ok(!/id="oc-compose"/.test(r.text), 'no composer for a guest');
+ const me = await req('GET', '/', { headers: cookie(alice) });
+ assert.match(me.text, /id="oc-compose"/);
+});
+
+t('the composer posts without JavaScript, through the API\'s checks; other origins are refused', async () => {
+ let r = await req('POST', '/send', form(alice, { message: 'hello <script>alert(1)</script> https://example.org/x' }));
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), '/#oc-compose');
+ const page = (await req('GET', '/')).text;
+ assert.ok(page.includes('hello <script>alert(1)</script>'), 'escaped');
+ assert.ok(page.includes('<a href="https://example.org/x" rel="nofollow ugc noopener"'), 'links are links');
+ assert.ok(!page.includes('<script>alert(1)</script>'));
+ r = await req('POST', '/send', form(alice, { message: 'from elsewhere' }, { origin: 'https://evil.example' }));
+ assert.strictEqual(r.status, 403);
+ r = await req('POST', '/send', form(alice, { message: '' }));
+ assert.strictEqual(r.status, 303);
+ assert.match(r.headers.get('location'), /^\/\?error=/, 'the API\'s refusal comes back as a notice');
+ r = await req('POST', '/send', { headers: { 'content-type': 'application/x-www-form-urlencoded', origin: SITE }, raw: 'message=hi' });
+ assert.strictEqual(r.status, 303);
+ assert.match(r.headers.get('location'), /^\/auth\/login\?next=/, 'signed out: sign in first');
+});
+
+t('messages: start a conversation by username, reply without JavaScript, participants only', async () => {
+ assert.match((await req('GET', '/messages')).headers.get('location'), /^\/auth\/login\?next=%2Fmessages/);
+ let r = await req('POST', '/messages/new', form(alice, { username: 'bob' }));
+ assert.strictEqual(r.status, 303);
+ const m = /^\/messages\/(\d+)$/.exec(r.headers.get('location'));
+ assert.ok(m, r.headers.get('location'));
+ r = await req('POST', `/messages/${m[1]}`, form(alice, { message: 'hi bob' }));
+ assert.strictEqual(r.status, 303);
+ const thread = await req('GET', `/messages/${m[1]}`, { headers: cookie(bob) });
+ assert.strictEqual(thread.status, 200);
+ assert.ok(thread.text.includes('hi bob'));
+ assert.match(thread.text, /noindex, nofollow/);
+ const inbox = await req('GET', '/messages', { headers: cookie(bob) });
+ assert.ok(inbox.text.includes(`/messages/${m[1]}`));
+ assert.strictEqual((await req('GET', `/messages/${m[1]}`, { headers: cookie(carol) })).status, 404, 'not a participant');
+ r = await req('POST', `/messages/${m[1]}`, form(carol, { message: 'let me in' }));
+ assert.match(r.headers.get('location'), /error=/, 'the API refuses a non-participant');
+ r = await req('POST', '/messages/new', form(alice, { username: 'nobody_like_this' }));
+ assert.match(r.headers.get('location'), /^\/messages\?error=/);
+});
+
+t('settings save to the chat.preferences record', async () => {
+ let r = await req('GET', '/settings', { headers: cookie(alice) });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /name="timestamps"/);
+ r = await req('POST', '/settings', form(alice, { timestamps: '1', compact: '1', font_size: 'large' }));
+ assert.strictEqual(r.headers.get('location'), '/settings?saved=1');
+ const rec = h.netModules.records.get(h.netModules.key('chat.preferences', alice.subject_id));
+ assert.deepStrictEqual(rec && rec.data, { timestamps: true, compact: true, show_badges: false, font_scale: 1.18 });
+ const page = await req('GET', '/', { headers: cookie(alice) });
+ assert.match(page.text, /<body class="oc-compact oc-times oc-nobadges" style="--oc-scale:1.18">/, 'the site follows them too');
+});
+
+t('sign-in, the Frame, robots, sitemap and /updates', async () => {
+ const r = await req('GET', '/auth/login?next=%2Fmessages');
+ assert.strictEqual(r.status, 302);
+ const u = new URL(r.headers.get('location'));
+ assert.deepStrictEqual([u.pathname, u.searchParams.get('client_id'), u.searchParams.get('redirect_uri')], ['/oauth/authorize', 'chat', `${SITE}/auth/callback`]);
+ assert.strictEqual((await req('GET', '/shared/navbar.js')).status, 200);
+ assert.strictEqual((await req('GET', '/web/chat.css')).status, 200);
+ assert.match((await req('GET', '/robots.txt')).text, /Disallow: \/messages/);
+ assert.match((await req('GET', '/sitemap.xml')).text, /<loc>https:\/\/openvibe\.chat\/updates<\/loc>/);
+ const up = await req('GET', '/updates');
+ assert.strictEqual(up.status, 200);
+ assert.match(up.text, /data-ov-shipped/);
+ assert.strictEqual((await req('GET', '/api/nope')).status, 404, 'the API keeps its JSON 404');
+});
+
+t.run(async () => { if (h && h.close) await h.close(); });