openvibers/openvibe.chat Commits
Chat: pictures in rooms and channels, through OpenVibe.Pics as the person (#105)
Changes
@@@ -85,6 +85,9 @@ SOUNDS_PATH=./data/sounds# Voice/video calls (server/calls/, Live's call server moved): off until the calls cutover# (docs/calls-cutover.md). Off = /api/streams/… 404, /ws/call refused, /internal/calls/* 409.#CHAT_CALLS=1++# Pictures in rooms and channels go to OpenVibe.Pics as the person, through MediaHub on this host (off when unset).++#CHAT_PICS_INTERNAL_URL=http://127.0.0.1:4990++#CHAT_PICS_MAX_BYTES=20971520# A direct call nobody answers is missed after this long (ms); the caller hears "no answer".#CALL_RING_TIMEOUT_MS=45000# ICE for calls (GET /api/chat/ice-servers; openvibe.chat's call rooms): STUN always, TURN when set.@@@ -329,6 +329,18 @@ delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:comemberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels whennobody is.++**Pictures** (`server/rooms/pictures.js`; the owner's "files and pics" for Chat, through OpenVibe.Pics). A person who++may post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, the++file goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands it++to OpenVibe.Pics **as that person**: their own Network token, through MediaHub on this host++(`CHAT_PICS_INTERNAL_URL`, `Host: openvibe.pics`, their address in X-Forwarded-For), so the picture is theirs on Pics++(re-encoded without its metadata, reportable, deletable, inside their Pics limits). The room gets a message with the++caption and the picture's link. Rooms and channels show OpenVibe.Pics direct links (`https://openvibe.pics/i/<slug>.<ext>`)++as thumbnails (`/i/<slug>/thumb.webp`) linked to the picture's page, at most four a message, on the page and live++(chat.js); no other host's images, and global and stream chat stay text. JPEG, PNG, GIF, WebP and AVIF up to++`CHAT_PICS_MAX_BYTES` (20 MB); `chat.room.picture` 10 a minute and 120 an hour per person; API tokens cannot send++pictures. Off (no form; the API answers 503 `pictures.off`) without `CHAT_PICS_INTERNAL_URL`.++**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/servers`, `/s/:slug[/:channel]`,`/s/:slug/settings` and `/join/:code` (servers, above), `/messages` the DM inbox(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),@@@ -219,3 +219,15 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-moves { display: inline-flex; gap: 4px; margin-left: auto; }.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }.oc-move:hover { border-color: var(--oc-accent); }++++/* Pictures in rooms and channels (server/rooms/pictures.js): thumbnails under a message, the "Add a picture" form. */++.oc-pics { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }++.oc-pic { display: block; line-height: 0; border-radius: 10px; }++.oc-pic img { display: block; width: auto; max-width: min(320px, 100%); max-height: 240px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel-2); object-fit: cover; }++.oc-pic:hover img, .oc-pic:focus-visible img { border-color: var(--oc-accent); }++.oc-pic-compose { margin: 8px 0 0; }++.oc-pic-compose > summary { cursor: pointer; color: var(--oc-accent); font-size: .92rem; width: max-content; }++.oc-pic-form { margin-top: 8px; max-width: 520px; }++.oc-pic-form input[type="file"] { width: 100%; }++.oc-srv-main > .oc-pic-compose { margin: 0; padding: 0 18px 14px; }++@media (max-width: 760px) { .oc-srv-main > .oc-pic-compose { padding: 0 12px 12px; } }@@@ -215,6 +215,7 @@}li.appendChild(document.createTextNode(' '));var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);++ if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;var x = el('button', null, '×'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message');@@@ -222,6 +223,42 @@}return li;}++ // OpenVibe.Pics pictures a message links (server/rooms/pictures.js): thumbnails linked to the picture's page.++ function pictureThumbs(text) {++ var base = String(cfg.pics).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');++ var re = new RegExp(base + '/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(jpg|png|gif|webp|avif)(?![\\w/.-])', 'g');++ var seen = {}, wrap = null, m, n = 0;++ while ((m = re.exec(String(text || ''))) && n < 4) {++ if (seen[m[1]]) continue;++ seen[m[1]] = true; n++;++ if (!wrap) wrap = el('span', 'oc-pics');++ var a = el('a', 'oc-pic'); a.href = cfg.pics + '/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';++ var img = document.createElement('img'); img.src = cfg.pics + '/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture on OpenVibe.Pics'; img.loading = 'lazy'; img.decoding = 'async';++ a.appendChild(img); wrap.appendChild(a);++ }++ return wrap;++ }++ // The "Add a picture" form sends the file to the API; the message then arrives over the socket like any other.++ function startPictureForm() {++ var f = document.querySelector ? document.querySelector('.oc-pic-form') : null;++ if (!f || !window.fetch) return;++ f.addEventListener('submit', function (e) {++ var file = f.picture && f.picture.files && f.picture.files[0];++ if (!file) return;++ e.preventDefault();++ var btn = f.querySelector('button'); if (btn) { btn.disabled = true; btn.textContent = 'Sending\u2026'; }++ fetch('/api/chat/rooms/' + encodeURIComponent(f.getAttribute('data-room')) + '/pictures', {++ method: 'POST', credentials: 'same-origin', body: file,++ headers: { 'Content-Type': file.type || 'application/octet-stream', 'X-Caption': encodeURIComponent((f.caption && f.caption.value) || '') },++ }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })++ .then(function (x) {++ if (!x.ok) { showError((x.d && x.d.error) || 'The picture did not go. Try again.'); return; }++ f.reset(); var det = f.closest ? f.closest('details') : null; if (det) det.open = false;++ })++ .catch(function () { f.submit(); })++ .then(function () { if (btn) { btn.disabled = false; btn.textContent = 'Send picture'; } });++ });++ }// "Pantry is writing…" while a room's AI member answers (room_ai_typing), until its message arrives or 40 s pass.var thinking = null, thinkingTimer = null;function clearThinking() { if (thinking) { thinking.remove(); thinking = null; } if (thinkingTimer) { clearTimeout(thinkingTimer); thinkingTimer = null; } }@@@ -335,6 +372,6 @@toBottom();if (cfg.view === 'global') startGlobal();else if (cfg.view === 'dm') startDm();-- else if (cfg.view === 'room') startRoom();++ else if (cfg.view === 'room') { startRoom(); startPictureForm(); }else if (cfg.view === 'inbox') startInbox();})();@@@ -78,6 +78,13 @@ module.exports = {// Where a room's Community attachments link (a space is <communityUrl>/s/<slug>).communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),},++ // Pictures in rooms and channels (server/rooms/pictures.js): uploaded to OpenVibe.Pics as the person (their own++ // Network token) through MediaHub on this host, and shown inline. Off without CHAT_PICS_INTERNAL_URL.++ pics: {++ internalUrl: strip(process.env.CHAT_PICS_INTERNAL_URL || ''),++ publicUrl: strip(process.env.CHAT_PICS_PUBLIC_URL || 'https://openvibe.pics'),++ maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),++ },cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },// OpenVibe.Live — users, streams, channels, bans and every side effect chat triggers.nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++/**++ * Pictures in rooms and channels (the owner's ask for Chat: "files and pics", through OpenVibe.Pics). A person sends a++ * picture; Chat hands it to OpenVibe.Pics as that person — their own Network token, through MediaHub on this host++ * (CHAT_PICS_INTERNAL_URL, Host: openvibe.pics) — so the picture is theirs on Pics (re-encoded without its metadata,++ * reportable, deletable from their Pics page, inside their Pics limits), and the room gets a message with its link.++ *++ * A message holding OpenVibe.Pics direct links (https://openvibe.pics/i/<slug>.<ext>) shows them inline as thumbnails in++ * rooms and channels (pageHtml here, chat.js in the browser): only that host's pictures, at most four a message, linked++ * to the picture's page where it can be reported. Global and stream chat stay text.++ *++ * enabled() CHAT_PICS_INTERNAL_URL is set++ * upload({ token, buffer, contentType, title, ip }) → { ok, status, picture: { slug, direct, page } } | { ok: false, … }++ * linksIn(text) → [{ slug, ext }] the Pics pictures a message links++ */++const http = require('http');++const https = require('https');++const config = require('../config');++++const TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif']);++const MAX_INLINE = 4;++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++const cfg = () => config.pics || {};++const enabled = () => Boolean(cfg().internalUrl);++const publicUrl = () => cfg().publicUrl || 'https://openvibe.pics';++const picsHost = () => { try { return new URL(publicUrl()).host; } catch { return 'openvibe.pics'; } };++++function linkPattern() {++ const base = publicUrl().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');++ return new RegExp(`${base}/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(jpg|png|gif|webp|avif)(?![\\w/.-])`, 'g');++}++++function linksIn(text) {++ const out = [];++ const seen = new Set();++ for (const m of String(text || '').matchAll(linkPattern())) {++ if (seen.has(m[1])) continue;++ seen.add(m[1]);++ out.push({ slug: m[1], ext: m[2] });++ if (out.length >= MAX_INLINE) break;++ }++ return out;++}++++/** The inline thumbnails for a message, as HTML (empty when it links no Pics picture). */++function pageHtml(text) {++ const pics = linksIn(text);++ if (!pics.length) return '';++ return `<span class="oc-pics">${pics.map((p) => `<a class="oc-pic" href="${esc(publicUrl())}/i/${esc(p.slug)}" target="_blank" rel="noopener"><img src="${esc(publicUrl())}/i/${esc(p.slug)}/thumb.webp" alt="A picture on OpenVibe.Pics" loading="lazy" decoding="async"></a>`).join('')}</span>`;++}++++/** Send the picture to OpenVibe.Pics as the person. Never throws for a refusal. */++function upload({ token, buffer, contentType, title = '', ip = null }) {++ return new Promise((resolve) => {++ if (!enabled()) return resolve({ ok: false, status: 503, code: 'pictures.off', detail: 'Pictures are not available in chat right now.' });++ const type = String(contentType || '').split(';')[0].trim().toLowerCase();++ if (!TYPES.has(type)) return resolve({ ok: false, status: 415, code: 'pictures.type', detail: 'Send a JPEG, PNG, GIF, WebP or AVIF picture.' });++ if (!buffer || !buffer.length) return resolve({ ok: false, status: 422, code: 'pictures.empty', detail: 'Choose a picture first.' });++ if (buffer.length > (cfg().maxBytes || 20 * 1024 * 1024)) return resolve({ ok: false, status: 413, code: 'pictures.too_large', detail: `A picture can be up to ${Math.round((cfg().maxBytes || 20 * 1024 * 1024) / 1024 / 1024)} MB.` });++ if (!token) return resolve({ ok: false, status: 401, code: 'pictures.sign_in', detail: 'Sign in with OpenVibe to send pictures.' });++ let u;++ try { u = new URL('/api/v1/images', cfg().internalUrl); } catch { return resolve({ ok: false, status: 503, code: 'pictures.off', detail: 'Pictures are not available in chat right now.' }); }++ const lib = u.protocol === 'https:' ? https : http;++ const req = lib.request({++ hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname, method: 'POST',++ headers: {++ Host: picsHost(), Authorization: `Bearer ${token}`, 'Content-Type': type, 'Content-Length': buffer.length, Accept: 'application/json',++ ...(title ? { 'X-Title': encodeURIComponent(String(title).slice(0, 120)) } : {}),++ // MediaHub on loopback reads the person's address from here (one trusted hop), for its own limits.++ ...(ip ? { 'X-Forwarded-For': String(ip) } : {}),++ },++ timeout: 60_000,++ }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => {++ let body = null;++ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { body = null; }++ if (res.statusCode === 201 && body && body.slug && body.links) {++ return resolve({ ok: true, status: 201, picture: { slug: body.slug, direct: body.links.direct, page: body.links.page } });++ }++ const p = (body && (body.problem || body)) || {};++ resolve({ ok: false, status: res.statusCode >= 500 ? 502 : res.statusCode, code: p.code || 'pictures.refused', detail: p.detail || 'OpenVibe.Pics did not take the picture.' });++ });++ });++ req.on('timeout', () => req.destroy(new Error('timeout')));++ req.on('error', () => resolve({ ok: false, status: 502, code: 'pictures.unreachable', detail: 'OpenVibe.Pics did not answer. Try again in a minute.' }));++ req.end(buffer);++ });++}++++module.exports = { enabled, upload, linksIn, pageHtml, publicUrl, TYPES, MAX_INLINE };@@@ -68,6 +68,26 @@ function callOf(room) {try { people = enabled ? require('../calls/call-server').getParticipants(`room-${room.slug}`) : []; } catch { people = []; }return { enabled, channel: `room-${room.slug}`, participants: people.length, people: people.map((p) => ({ username: p.username, display_name: p.displayName, muted: !!p.muted, speaking: !!p.speaking, listening: !!p.forceMuted })) };}++/** The person's own Network token from the request (never an hbt_ API token). */++const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };++/** Pictures go to OpenVibe.Pics as the person: an API token cannot speak for them there. */++const personOnlyPictures = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Send pictures with your own sign-in, not an API token', code: 'pictures.person_only' }) : next());++/**++ * Check the person may post here, send the picture to OpenVibe.Pics as them, post its link (after the caption) and++ * deliver it. → { message, picture }. Shared by the API and the site's form.++ */++async function postPicture(room, user, { token, buffer, contentType, caption = '', ip = null }) {++ const pictures = require('./pictures');++ const a = await rooms.access(room, user);++ if (!a.post) throw new rooms.RoomError(a.read ? 403 : 404, a.read ? 'rooms.join_first' : 'rooms.not_found', a.read ? 'Join first to send pictures here' : 'No such room');++ const up = await pictures.upload({ token, buffer, contentType, title: caption || room.name, ip });++ if (!up.ok) throw new rooms.RoomError(up.status, up.code, up.detail);++ const text = [String(caption || '').replace(/\s+/g, ' ').trim(), up.picture.direct].filter(Boolean).join(' ');++ const message = await rooms.post(room, user, text);++ chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });++ return { message, picture: up.picture };++}++/** People only: attaching speaks for the person (API tokens and services cannot). */const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());@@@ -118,6 +138,21 @@ router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {} catch (err) { send(res, err); }});++// A picture (server/rooms/pictures.js): the body is the picture (Content-Type image/*), an optional caption in the++// X-Caption header (URI-encoded). It goes to OpenVibe.Pics as the person, and its link is posted as a message.++const pictureLimit = limits('chat.room.picture', { minute: 10, hour: 120 });++router.post('/:slug/pictures', requireAuth, personOnlyPictures, pictureLimit, (req, res, next) => express.raw({ type: () => true, limit: (require('../config').pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {++ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'That picture is too large', code: 'pictures.too_large' });++ return next(err);++}), async (req, res) => {++ const room = await readable(req, res); if (!room) return;++ try {++ const caption = (() => { try { return decodeURIComponent(String(req.get('x-caption') || '')).slice(0, 500); } catch { return ''; } })();++ const out = await postPicture(room, req.user, { token: bearerOf(req), buffer: Buffer.isBuffer(req.body) ? req.body : null, contentType: req.get('content-type'), caption, ip: req.ip });++ res.status(201).json(out);++ } catch (err) { send(res, err); }++});++router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res) => {const room = await readable(req, res); if (!room) return;try {@@@ -222,3 +257,4 @@ router.delete('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, asymodule.exports = router;module.exports.callOf = callOf;module.exports.applyRoomChange = applyRoomChange;++module.exports.postPicture = postPicture;@@@ -420,7 +420,18 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}// ── Rooms (server/rooms/) ──const rooms = require('../rooms/rooms');++ const pictures = require('../rooms/pictures');const aiMembers = require('../rooms/ai-members');++ // A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.++ const pictureForm = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: (require('../config').pics.maxBytes || 20 * 1024 * 1024), files: 1, fields: 4 } }).single('picture');++ /** The "Add a picture" form under a room's or channel's composer (rooms where the person may post). */++ const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose"><summary>Add a picture</summary>++<form class="oc-form oc-pic-form" method="post" action="/r/${esc(room.slug)}/picture" enctype="multipart/form-data" data-room="${esc(room.slug)}">++<input type="hidden" name="back" value="${esc(back)}">++<label>Picture <input type="file" name="picture" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required></label>++<label>Caption <input name="caption" maxlength="500" placeholder="Say something about it (optional)"></label>++<p class="oc-muted">It goes to <a href="${esc(pictures.publicUrl())}">OpenVibe.Pics</a> as yours (location and camera details removed), where you can delete it, and the room gets its link.</p>++<p><button type="submit">Send picture</button></p></form></details>` : '');const roomRoutes = require('../rooms/routes');const roomsOrError = (fn) => async (req, res, next) => {try { return await fn(req, res, next); } catch (err) {@@@ -439,7 +450,7 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}const who = ai? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;-- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;++ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} <span class="oc-text">${linkify(m.message)}</span>${ai ? '' : pictures.pageHtml(m.message)}${del}</li>`;};const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);@@@ -524,7 +535,7 @@ ${people}const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';let composer;if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;-- else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="${room.kind === 'system' ? 'Post an announcement' : `Message ${esc(room.name)}`}"></textarea><button type="submit">Send</button></form>`;++ else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="${room.kind === 'system' ? 'Post an announcement' : `Message ${esc(room.name)}`}"></textarea><button type="submit">Send</button></form>${room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`)}`;else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;@@@ -540,7 +551,7 @@ ${people}await page(req, res, 200, {actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,-- page: { view: 'room', room: room.slug, kind: room.kind, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,++ page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },body: `<p><a href="/rooms">← Rooms</a></p><h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>@@@ -562,6 +573,21 @@ ${composer}${tools}`,res.redirect(303, `/r/${room.slug}#oc-compose`);}));++ router.post('/r/:slug/picture', formLimit, sameSite, (req, res, next) => pictureForm(req, res, (err) => {++ if (!err) return next();++ const why = err.code === 'LIMIT_FILE_SIZE' ? `A picture can be up to ${Math.round((require('../config').pics.maxBytes || 20 * 1024 * 1024) / 1024 / 1024)} MB.` : 'That upload did not work.';++ return res.redirect(303, `/r/${encodeURIComponent(req.params.slug)}?error=${encodeURIComponent(why)}`);++ }), roomsOrError(async (req, res) => {++ const actor = await needUser(req, res); if (!actor) return;++ const room = await rooms.bySlug(req.params.slug);++ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');++ const back = String((req.body && req.body.back) || '');++ req.roomBack = /^\/(r|s)\/[a-z0-9-]+(\/[a-z0-9-]+)?$/.test(back) ? back : `/r/${room.slug}`;++ const f = req.file;++ await roomRoutes.postPicture(room, actor.user, { token: actor.token, buffer: f ? f.buffer : null, contentType: f ? f.mimetype : '', caption: req.body && req.body.caption, ip: req.ip });++ res.redirect(303, `${req.roomBack}#oc-compose`);++ }));++router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {const actor = await needUser(req, res); if (!actor) return;const room = await rooms.bySlug(req.params.slug);@@@ -705,7 +731,7 @@ ${attachList}`,// ── What shipped, robots, sitemap ──// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──-- require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config });++ require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });router.get('/updates', async (req, res) => await page(req, res, 200, {title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),@@@ -30,7 +30,7 @@ const initials = (name) => String(name || '?').split(/\s+/).filter(Boolean).slicconst hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };function mountServerPages(router, h) {-- const { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config } = h;++ const { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config } = h;const site = config.web.baseUrl;const serverRoutes = () => require('../rooms/server-routes');const orError = (fn) => async (req, res, next) => {@@@ -115,7 +115,7 @@ ${create}`,let composer;const back = `/s/${server.slug}/${pick.slug}`;if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;-- else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;++ else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>${pictureComposer ? pictureComposer(room, back) : ''}`;else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;else composer = notice('error', 'You cannot write here right now.');const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];@@@ -134,7 +134,7 @@ ${create}`,robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,page: {-- view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0,++ view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, pics: require('../rooms/pictures').enabled() ? require('../rooms/pictures').publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0,me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),},nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * Pictures in rooms and channels (server/rooms/pictures.js): a member's picture goes to OpenVibe.Pics as them (their++ * own Network token, Host: openvibe.pics, their address) and its link is posted; the room page and the channel page++ * show OpenVibe.Pics links as thumbnails (no other host's, never in global chat); non-members and other types are++ * refused before Pics is asked; a Pics refusal comes back as it was; the site's form works without JavaScript; with++ * CHAT_PICS_INTERNAL_URL unset there is no form and the API says so.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('room pictures');++let h, pics, ann, bob, cat;++const api = (method, path, u, body, headers) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body, headers });++const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8cfc0f01f0005000201a5d6a1f60000000049454e44ae426082', 'hex');++++function startPics() {++ const calls = [];++ let refuse = null;++ let n = 0;++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ calls.push({ host: req.headers.host, auth: req.headers.authorization, type: req.headers['content-type'], title: req.headers['x-title'], xff: req.headers['x-forwarded-for'], bytes: Buffer.concat(chunks).length, path: req.url });++ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };++ if (refuse) return send(refuse.status, { code: refuse.code, detail: refuse.detail });++ const slug = `Ab${String(++n).padStart(6, '2')}`.replace(/[01]/g, '3');++ return send(201, { slug, links: { direct: `https://openvibe.pics/i/${slug}.png`, page: `https://openvibe.pics/i/${slug}` } });++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({++ calls, url: `http://127.0.0.1:${server.address().port}`, refuse: (r) => { refuse = r; }, close: () => new Promise((r) => server.close(r)),++ })));++}++++const page = async (path, u) => { const r = await fetch(`${h.base}${path}`, { headers: u ? { cookie: `ov_token=${u.token}` } : {}, redirect: 'manual' }); return { status: r.status, text: await r.text(), location: r.headers.get('location') }; };++function multipart(fields, file) {++ const b = `----ocpic${Date.now()}`;++ const parts = Object.entries(fields).map(([k, v]) => Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));++ if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="picture"; filename="${file.name}"\r\nContent-Type: ${file.type}\r\n\r\n`), file.buffer, Buffer.from('\r\n'));++ parts.push(Buffer.from(`--${b}--\r\n`));++ return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };++}++++t('boot', async () => {++ pics = await startPics();++ h = await boot({ env: { CHAT_PICS_INTERNAL_URL: pics.url } });++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ cat = h.addUser('cat', { subject: ids.newId('user') });++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));++ assert.strictEqual((await api('POST', '/', ann, { name: 'Photo Walk' })).status, 201);++ await api('POST', '/photo-walk/join', bob);++});++++t('a member\'s picture goes to Pics as them, and its link is posted after the caption', async () => {++ const r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: bob.token, raw: PNG, headers: { 'content-type': 'image/png', 'x-caption': encodeURIComponent('Sunset at the pier') } });++ assert.strictEqual(r.status, 201, r.text);++ assert.match(r.body.message.message, /^Sunset at the pier https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.png$/);++ const call = pics.calls[0];++ assert.deepStrictEqual([call.path, call.host, call.auth, call.type, call.bytes], ['/api/v1/images', 'openvibe.pics', `Bearer ${bob.token}`, 'image/png', PNG.length]);++ assert.strictEqual(decodeURIComponent(call.title), 'Sunset at the pier');++ assert.ok(call.xff, 'the person\'s address goes along for Pics\' own limits');++});++++t('refused before Pics is asked: a non-member, another type; a Pics refusal comes back as it was', async () => {++ const before = pics.calls.length;++ let r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: cat.token, raw: PNG, headers: { 'content-type': 'image/png' } });++ assert.deepStrictEqual([r.status, r.body.code], [403, 'rooms.join_first']);++ r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: bob.token, raw: Buffer.from('<svg/>'), headers: { 'content-type': 'image/svg+xml' } });++ assert.deepStrictEqual([r.status, r.body.code], [415, 'pictures.type']);++ assert.strictEqual(pics.calls.length, before);++ pics.refuse({ status: 429, code: 'pics.daily_limit', detail: 'That is 200 pictures today.' });++ r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: bob.token, raw: PNG, headers: { 'content-type': 'image/png' } });++ assert.deepStrictEqual([r.status, r.body.code, r.body.error], [429, 'pics.daily_limit', 'That is 200 pictures today.']);++ pics.refuse(null);++});++++t('the page: Pics links show as thumbnails linked to the picture\'s page; other links stay links', async () => {++ await api('POST', '/photo-walk/messages', ann, { message: 'not a picture: https://example.com/i/Ab222222.png' });++ const r = await page('/r/photo-walk', ann);++ assert.match(r.text, /<a class="oc-pic" href="https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}"[^>]*><img src="https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\/thumb\.webp"/);++ assert.strictEqual((r.text.match(/class="oc-pic"/g) || []).length, 1, 'only the Pics picture');++ assert.match(r.text, /Add a picture/);++ assert.match(r.text, /"pics":"https:\/\/openvibe\.pics"/, 'chat.js gets the Pics address for live messages');++ assert.doesNotMatch((await page('/r/photo-walk', cat)).text, /Add a picture/, 'only people who may post get the form');++});++++t('the form works without JavaScript, and goes back to where it was sent from', async () => {++ const m = multipart({ caption: 'From the form', back: '/r/photo-walk' }, { name: 'p.png', type: 'image/png', buffer: PNG });++ const r = await fetch(`${h.base}/r/photo-walk/picture`, { method: 'POST', headers: { cookie: `ov_token=${bob.token}`, 'content-type': m.type }, body: m.body, redirect: 'manual' });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), '/r/photo-walk#oc-compose');++ const msgs = (await api('GET', '/photo-walk/messages', ann)).body.messages;++ assert.match(msgs[msgs.length - 1].message, /^From the form https:\/\/openvibe\.pics\/i\//);++ const bad = multipart({ back: 'https://evil.example/' }, { name: 'p.png', type: 'image/png', buffer: PNG });++ const r2 = await fetch(`${h.base}/r/photo-walk/picture`, { method: 'POST', headers: { cookie: `ov_token=${bob.token}`, 'content-type': bad.type }, body: bad.body, redirect: 'manual' });++ assert.strictEqual(r2.headers.get('location'), '/r/photo-walk#oc-compose', 'never back to another site');++});++++t('in a server\'s channel too', async () => {++ const s = (await h.http('POST', '/api/chat/servers', { token: ann.token, body: { name: 'Shutterbugs' } })).body.server;++ const room = (await h.http('GET', `/api/chat/servers/${s.slug}`, { token: ann.token })).body.channels[0].room;++ const r = await h.http('POST', `/api/chat/rooms/${room}/pictures`, { token: ann.token, raw: PNG, headers: { 'content-type': 'image/png' } });++ assert.strictEqual(r.status, 201, r.text);++ const p = await page(`/s/${s.slug}/general`, ann);++ assert.match(p.text, /class="oc-pic"/);++ assert.match(p.text, new RegExp(`action="/r/${room}/picture"`));++});++++t('global chat stays text', async () => {++ const r = await page('/', ann);++ assert.doesNotMatch(r.text, /class="oc-pic"/);++});++++t('off without CHAT_PICS_INTERNAL_URL: no form, and the API says so', async () => {++ const cfg = require('../server/config').pics;++ const was = cfg.internalUrl;++ cfg.internalUrl = '';++ try {++ assert.doesNotMatch((await page('/r/photo-walk', bob)).text, /Add a picture/);++ const r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: bob.token, raw: PNG, headers: { 'content-type': 'image/png' } });++ assert.deepStrictEqual([r.status, r.body.code], [503, 'pictures.off']);++ } finally { cfg.internalUrl = was; }++});++++t.run(async () => { if (h && h.close) await h.close(); if (pics) await pics.close(); });
@@ -85,6 +85,9 @@ SOUNDS_PATH=./data/sounds
@@ -85,6 +85,9 @@ SOUNDS_PATH=./data/sounds
# Voice/video calls (server/calls/, Live's call server moved): off until the calls cutover
# Voice/video calls (server/calls/, Live's call server moved): off until the calls cutover
# (docs/calls-cutover.md). Off = /api/streams/… 404, /ws/call refused, /internal/calls/* 409.
# (docs/calls-cutover.md). Off = /api/streams/… 404, /ws/call refused, /internal/calls/* 409.
#CHAT_CALLS=1
#CHAT_CALLS=1
+# Pictures in rooms and channels go to OpenVibe.Pics as the person, through MediaHub on this host (off when unset).
+#CHAT_PICS_INTERNAL_URL=http://127.0.0.1:4990
+#CHAT_PICS_MAX_BYTES=20971520
# A direct call nobody answers is missed after this long (ms); the caller hears "no answer".
# A direct call nobody answers is missed after this long (ms); the caller hears "no answer".
#CALL_RING_TIMEOUT_MS=45000
#CALL_RING_TIMEOUT_MS=45000
# ICE for calls (GET /api/chat/ice-servers; openvibe.chat's call rooms): STUN always, TURN when set.
# ICE for calls (GET /api/chat/ice-servers; openvibe.chat's call rooms): STUN always, TURN when set.
@@ -329,6 +329,18 @@ delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:co
@@ -329,6 +329,18 @@ delete, join `{ invite? }`, leave, members, channels, invites, `GET /invites/:co
memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when
memberships; a server it owned stays without an owner while anyone else is in it, and goes with its channels when
nobody is.
nobody is.
+**Pictures** (`server/rooms/pictures.js`; the owner's "files and pics" for Chat, through OpenVibe.Pics). A person who
+may post sends a picture from the room's or channel's "Add a picture" form (it works without JavaScript; with it, the
+file goes to `POST /api/chat/rooms/:slug/pictures`, the body the picture and an optional `X-Caption`) and Chat hands it
+to OpenVibe.Pics **as that person**: their own Network token, through MediaHub on this host
+(`CHAT_PICS_INTERNAL_URL`, `Host: openvibe.pics`, their address in X-Forwarded-For), so the picture is theirs on Pics
+(re-encoded without its metadata, reportable, deletable, inside their Pics limits). The room gets a message with the
+caption and the picture's link. Rooms and channels show OpenVibe.Pics direct links (`https://openvibe.pics/i/<slug>.<ext>`)
+as thumbnails (`/i/<slug>/thumb.webp`) linked to the picture's page, at most four a message, on the page and live
+(chat.js); no other host's images, and global and stream chat stay text. JPEG, PNG, GIF, WebP and AVIF up to
+`CHAT_PICS_MAX_BYTES` (20 MB); `chat.room.picture` 10 a minute and 120 an hour per person; API tokens cannot send
+pictures. Off (no form; the API answers 503 `pictures.off`) without `CHAT_PICS_INTERNAL_URL`.
+
**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/servers`, `/s/:slug[/:channel]`,
**openvibe.chat pages.** `/` global chat, `/rooms` and `/r/:slug` (above), `/servers`, `/s/:slug[/:channel]`,
`/s/:slug/settings` and `/join/:code` (servers, above), `/messages` the DM inbox
`/s/:slug/settings` and `/join/:code` (servers, above), `/messages` the DM inbox
(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),
(conversations with unread counts, refreshed every 15 s with JavaScript; who you blocked, with Unblock),
@@ -219,3 +219,15 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -219,3 +219,15 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-moves { display: inline-flex; gap: 4px; margin-left: auto; }
.oc-moves { display: inline-flex; gap: 4px; margin-left: auto; }
.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }
.oc-move { width: 30px; height: 30px; padding: 0; border-radius: 8px; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-weight: 700; line-height: 1; }
.oc-move:hover { border-color: var(--oc-accent); }
.oc-move:hover { border-color: var(--oc-accent); }
+
+/* Pictures in rooms and channels (server/rooms/pictures.js): thumbnails under a message, the "Add a picture" form. */
+.oc-pics { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
+.oc-pic { display: block; line-height: 0; border-radius: 10px; }
+.oc-pic img { display: block; width: auto; max-width: min(320px, 100%); max-height: 240px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel-2); object-fit: cover; }
+.oc-pic:hover img, .oc-pic:focus-visible img { border-color: var(--oc-accent); }
+.oc-pic-compose { margin: 8px 0 0; }
+.oc-pic-compose > summary { cursor: pointer; color: var(--oc-accent); font-size: .92rem; width: max-content; }
+.oc-pic-form { margin-top: 8px; max-width: 520px; }
+.oc-pic-form input[type="file"] { width: 100%; }
+.oc-srv-main > .oc-pic-compose { margin: 0; padding: 0 18px 14px; }
+@media (max-width: 760px) { .oc-srv-main > .oc-pic-compose { padding: 0 12px 12px; } }
@@ -215,6 +215,7 @@
@@ -215,6 +215,7 @@
}
}
li.appendChild(document.createTextNode(' '));
li.appendChild(document.createTextNode(' '));
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
+ if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
var x = el('button', null, '×'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message');
var x = el('button', null, '×'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message');
@@ -222,6 +223,42 @@
@@ -222,6 +223,42 @@
}
}
return li;
return li;
}
}
+ // OpenVibe.Pics pictures a message links (server/rooms/pictures.js): thumbnails linked to the picture's page.
+ function pictureThumbs(text) {
+ var base = String(cfg.pics).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ var re = new RegExp(base + '/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(jpg|png|gif|webp|avif)(?![\\w/.-])', 'g');
+ var seen = {}, wrap = null, m, n = 0;
+ while ((m = re.exec(String(text || ''))) && n < 4) {
+ if (seen[m[1]]) continue;
+ seen[m[1]] = true; n++;
+ if (!wrap) wrap = el('span', 'oc-pics');
+ var a = el('a', 'oc-pic'); a.href = cfg.pics + '/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';
+ var img = document.createElement('img'); img.src = cfg.pics + '/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture on OpenVibe.Pics'; img.loading = 'lazy'; img.decoding = 'async';
+ a.appendChild(img); wrap.appendChild(a);
+ }
+ return wrap;
+ }
+ // The "Add a picture" form sends the file to the API; the message then arrives over the socket like any other.
+ function startPictureForm() {
+ var f = document.querySelector ? document.querySelector('.oc-pic-form') : null;
+ if (!f || !window.fetch) return;
+ f.addEventListener('submit', function (e) {
+ var file = f.picture && f.picture.files && f.picture.files[0];
+ if (!file) return;
+ e.preventDefault();
+ var btn = f.querySelector('button'); if (btn) { btn.disabled = true; btn.textContent = 'Sending\u2026'; }
+ fetch('/api/chat/rooms/' + encodeURIComponent(f.getAttribute('data-room')) + '/pictures', {
+ method: 'POST', credentials: 'same-origin', body: file,
+ headers: { 'Content-Type': file.type || 'application/octet-stream', 'X-Caption': encodeURIComponent((f.caption && f.caption.value) || '') },
+ }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })
+ .then(function (x) {
+ if (!x.ok) { showError((x.d && x.d.error) || 'The picture did not go. Try again.'); return; }
+ f.reset(); var det = f.closest ? f.closest('details') : null; if (det) det.open = false;
+ })
+ .catch(function () { f.submit(); })
+ .then(function () { if (btn) { btn.disabled = false; btn.textContent = 'Send picture'; } });
+ });
+ }
// "Pantry is writing…" while a room's AI member answers (room_ai_typing), until its message arrives or 40 s pass.
// "Pantry is writing…" while a room's AI member answers (room_ai_typing), until its message arrives or 40 s pass.
var thinking = null, thinkingTimer = null;
var thinking = null, thinkingTimer = null;
function clearThinking() { if (thinking) { thinking.remove(); thinking = null; } if (thinkingTimer) { clearTimeout(thinkingTimer); thinkingTimer = null; } }
function clearThinking() { if (thinking) { thinking.remove(); thinking = null; } if (thinkingTimer) { clearTimeout(thinkingTimer); thinkingTimer = null; } }
@@ -335,6 +372,6 @@
@@ -335,6 +372,6 @@
toBottom();
toBottom();
if (cfg.view === 'global') startGlobal();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
else if (cfg.view === 'dm') startDm();
- else if (cfg.view === 'room') startRoom();
+ else if (cfg.view === 'room') { startRoom(); startPictureForm(); }
else if (cfg.view === 'inbox') startInbox();
else if (cfg.view === 'inbox') startInbox();
})();
})();
@@ -78,6 +78,13 @@ module.exports = {
@@ -78,6 +78,13 @@ module.exports = {
// Where a room's Community attachments link (a space is <communityUrl>/s/<slug>).
// Where a room's Community attachments link (a space is <communityUrl>/s/<slug>).
communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),
communityUrl: strip(process.env.OV_COMMUNITY_URL || 'https://openvibe.community'),
},
},
+ // Pictures in rooms and channels (server/rooms/pictures.js): uploaded to OpenVibe.Pics as the person (their own
+ // Network token) through MediaHub on this host, and shown inline. Off without CHAT_PICS_INTERNAL_URL.
+ pics: {
+ internalUrl: strip(process.env.CHAT_PICS_INTERNAL_URL || ''),
+ publicUrl: strip(process.env.CHAT_PICS_PUBLIC_URL || 'https://openvibe.pics'),
+ maxBytes: int(process.env.CHAT_PICS_MAX_BYTES, 20 * 1024 * 1024),
+ },
cookies: { secure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === 'true' : isProduction },
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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+/**
+ * Pictures in rooms and channels (the owner's ask for Chat: "files and pics", through OpenVibe.Pics). A person sends a
+ * picture; Chat hands it to OpenVibe.Pics as that person — their own Network token, through MediaHub on this host
+ * (CHAT_PICS_INTERNAL_URL, Host: openvibe.pics) — so the picture is theirs on Pics (re-encoded without its metadata,
+ * reportable, deletable from their Pics page, inside their Pics limits), and the room gets a message with its link.
+ *
+ * A message holding OpenVibe.Pics direct links (https://openvibe.pics/i/<slug>.<ext>) shows them inline as thumbnails in
+ * rooms and channels (pageHtml here, chat.js in the browser): only that host's pictures, at most four a message, linked
+ * to the picture's page where it can be reported. Global and stream chat stay text.
+ *
+ * enabled() CHAT_PICS_INTERNAL_URL is set
+ * upload({ token, buffer, contentType, title, ip }) → { ok, status, picture: { slug, direct, page } } | { ok: false, … }
+ * linksIn(text) → [{ slug, ext }] the Pics pictures a message links
+ */
+const http = require('http');
+const https = require('https');
+const config = require('../config');
+
+const TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif']);
+const MAX_INLINE = 4;
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+const cfg = () => config.pics || {};
+const enabled = () => Boolean(cfg().internalUrl);
+const publicUrl = () => cfg().publicUrl || 'https://openvibe.pics';
+const picsHost = () => { try { return new URL(publicUrl()).host; } catch { return 'openvibe.pics'; } };
+
+function linkPattern() {
+ const base = publicUrl().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return new RegExp(`${base}/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(jpg|png|gif|webp|avif)(?![\\w/.-])`, 'g');
+}
+
+function linksIn(text) {
+ const out = [];
+ const seen = new Set();
+ for (const m of String(text || '').matchAll(linkPattern())) {
+ if (seen.has(m[1])) continue;
+ seen.add(m[1]);
+ out.push({ slug: m[1], ext: m[2] });
+ if (out.length >= MAX_INLINE) break;
+ }
+ return out;
+}
+
+/** The inline thumbnails for a message, as HTML (empty when it links no Pics picture). */
+function pageHtml(text) {
+ const pics = linksIn(text);
+ if (!pics.length) return '';
+ return `<span class="oc-pics">${pics.map((p) => `<a class="oc-pic" href="${esc(publicUrl())}/i/${esc(p.slug)}" target="_blank" rel="noopener"><img src="${esc(publicUrl())}/i/${esc(p.slug)}/thumb.webp" alt="A picture on OpenVibe.Pics" loading="lazy" decoding="async"></a>`).join('')}</span>`;
+}
+
+/** Send the picture to OpenVibe.Pics as the person. Never throws for a refusal. */
+function upload({ token, buffer, contentType, title = '', ip = null }) {
+ return new Promise((resolve) => {
+ if (!enabled()) return resolve({ ok: false, status: 503, code: 'pictures.off', detail: 'Pictures are not available in chat right now.' });
+ const type = String(contentType || '').split(';')[0].trim().toLowerCase();
+ if (!TYPES.has(type)) return resolve({ ok: false, status: 415, code: 'pictures.type', detail: 'Send a JPEG, PNG, GIF, WebP or AVIF picture.' });
+ if (!buffer || !buffer.length) return resolve({ ok: false, status: 422, code: 'pictures.empty', detail: 'Choose a picture first.' });
+ if (buffer.length > (cfg().maxBytes || 20 * 1024 * 1024)) return resolve({ ok: false, status: 413, code: 'pictures.too_large', detail: `A picture can be up to ${Math.round((cfg().maxBytes || 20 * 1024 * 1024) / 1024 / 1024)} MB.` });
+ if (!token) return resolve({ ok: false, status: 401, code: 'pictures.sign_in', detail: 'Sign in with OpenVibe to send pictures.' });
+ let u;
+ try { u = new URL('/api/v1/images', cfg().internalUrl); } catch { return resolve({ ok: false, status: 503, code: 'pictures.off', detail: 'Pictures are not available in chat right now.' }); }
+ const lib = u.protocol === 'https:' ? https : http;
+ const req = lib.request({
+ hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), path: u.pathname, method: 'POST',
+ headers: {
+ Host: picsHost(), Authorization: `Bearer ${token}`, 'Content-Type': type, 'Content-Length': buffer.length, Accept: 'application/json',
+ ...(title ? { 'X-Title': encodeURIComponent(String(title).slice(0, 120)) } : {}),
+ // MediaHub on loopback reads the person's address from here (one trusted hop), for its own limits.
+ ...(ip ? { 'X-Forwarded-For': String(ip) } : {}),
+ },
+ timeout: 60_000,
+ }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ let body = null;
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { body = null; }
+ if (res.statusCode === 201 && body && body.slug && body.links) {
+ return resolve({ ok: true, status: 201, picture: { slug: body.slug, direct: body.links.direct, page: body.links.page } });
+ }
+ const p = (body && (body.problem || body)) || {};
+ resolve({ ok: false, status: res.statusCode >= 500 ? 502 : res.statusCode, code: p.code || 'pictures.refused', detail: p.detail || 'OpenVibe.Pics did not take the picture.' });
+ });
+ });
+ req.on('timeout', () => req.destroy(new Error('timeout')));
+ req.on('error', () => resolve({ ok: false, status: 502, code: 'pictures.unreachable', detail: 'OpenVibe.Pics did not answer. Try again in a minute.' }));
+ req.end(buffer);
+ });
+}
+
+module.exports = { enabled, upload, linksIn, pageHtml, publicUrl, TYPES, MAX_INLINE };
@@ -68,6 +68,26 @@ function callOf(room) {
@@ -68,6 +68,26 @@ function callOf(room) {
try { people = enabled ? require('../calls/call-server').getParticipants(`room-${room.slug}`) : []; } catch { people = []; }
try { people = enabled ? require('../calls/call-server').getParticipants(`room-${room.slug}`) : []; } catch { people = []; }
return { enabled, channel: `room-${room.slug}`, participants: people.length, people: people.map((p) => ({ username: p.username, display_name: p.displayName, muted: !!p.muted, speaking: !!p.speaking, listening: !!p.forceMuted })) };
return { enabled, channel: `room-${room.slug}`, participants: people.length, people: people.map((p) => ({ username: p.username, display_name: p.displayName, muted: !!p.muted, speaking: !!p.speaking, listening: !!p.forceMuted })) };
}
}
+/** The person's own Network token from the request (never an hbt_ API token). */
+const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };
+/** Pictures go to OpenVibe.Pics as the person: an API token cannot speak for them there. */
+const personOnlyPictures = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Send pictures with your own sign-in, not an API token', code: 'pictures.person_only' }) : next());
+/**
+ * Check the person may post here, send the picture to OpenVibe.Pics as them, post its link (after the caption) and
+ * deliver it. → { message, picture }. Shared by the API and the site's form.
+ */
+async function postPicture(room, user, { token, buffer, contentType, caption = '', ip = null }) {
+ const pictures = require('./pictures');
+ const a = await rooms.access(room, user);
+ if (!a.post) throw new rooms.RoomError(a.read ? 403 : 404, a.read ? 'rooms.join_first' : 'rooms.not_found', a.read ? 'Join first to send pictures here' : 'No such room');
+ const up = await pictures.upload({ token, buffer, contentType, title: caption || room.name, ip });
+ if (!up.ok) throw new rooms.RoomError(up.status, up.code, up.detail);
+ const text = [String(caption || '').replace(/\s+/g, ' ').trim(), up.picture.direct].filter(Boolean).join(' ');
+ const message = await rooms.post(room, user, text);
+ chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
+ return { message, picture: up.picture };
+}
+
/** People only: attaching speaks for the person (API tokens and services cannot). */
/** People only: attaching speaks for the person (API tokens and services cannot). */
const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());
const personOnly = (req, res, next) => (req.authSource === 'api_token' ? res.status(403).json({ error: 'Attach rooms with your own sign-in, not an API token', code: 'rooms.person_only' }) : next());
@@ -118,6 +138,21 @@ router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
@@ -118,6 +138,21 @@ router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
+// A picture (server/rooms/pictures.js): the body is the picture (Content-Type image/*), an optional caption in the
+// X-Caption header (URI-encoded). It goes to OpenVibe.Pics as the person, and its link is posted as a message.
+const pictureLimit = limits('chat.room.picture', { minute: 10, hour: 120 });
+router.post('/:slug/pictures', requireAuth, personOnlyPictures, pictureLimit, (req, res, next) => express.raw({ type: () => true, limit: (require('../config').pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {
+ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'That picture is too large', code: 'pictures.too_large' });
+ return next(err);
+}), async (req, res) => {
+ const room = await readable(req, res); if (!room) return;
+ try {
+ const caption = (() => { try { return decodeURIComponent(String(req.get('x-caption') || '')).slice(0, 500); } catch { return ''; } })();
+ const out = await postPicture(room, req.user, { token: bearerOf(req), buffer: Buffer.isBuffer(req.body) ? req.body : null, contentType: req.get('content-type'), caption, ip: req.ip });
+ res.status(201).json(out);
+ } catch (err) { send(res, err); }
+});
+
router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res) => {
router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
try {
try {
@@ -222,3 +257,4 @@ router.delete('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, asy
@@ -222,3 +257,4 @@ router.delete('/:slug/ai-members/:id', requireAuth, personOnly, manageLimit, asy
module.exports = router;
module.exports = router;
module.exports.callOf = callOf;
module.exports.callOf = callOf;
module.exports.applyRoomChange = applyRoomChange;
module.exports.applyRoomChange = applyRoomChange;
+module.exports.postPicture = postPicture;
@@ -420,7 +420,18 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
@@ -420,7 +420,18 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
// ── Rooms (server/rooms/) ──
// ── Rooms (server/rooms/) ──
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
+ const pictures = require('../rooms/pictures');
const aiMembers = require('../rooms/ai-members');
const aiMembers = require('../rooms/ai-members');
+ // A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.
+ const pictureForm = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: (require('../config').pics.maxBytes || 20 * 1024 * 1024), files: 1, fields: 4 } }).single('picture');
+ /** The "Add a picture" form under a room's or channel's composer (rooms where the person may post). */
+ const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose"><summary>Add a picture</summary>
+<form class="oc-form oc-pic-form" method="post" action="/r/${esc(room.slug)}/picture" enctype="multipart/form-data" data-room="${esc(room.slug)}">
+<input type="hidden" name="back" value="${esc(back)}">
+<label>Picture <input type="file" name="picture" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required></label>
+<label>Caption <input name="caption" maxlength="500" placeholder="Say something about it (optional)"></label>
+<p class="oc-muted">It goes to <a href="${esc(pictures.publicUrl())}">OpenVibe.Pics</a> as yours (location and camera details removed), where you can delete it, and the room gets its link.</p>
+<p><button type="submit">Send picture</button></p></form></details>` : '');
const roomRoutes = require('../rooms/routes');
const roomRoutes = require('../rooms/routes');
const roomsOrError = (fn) => async (req, res, next) => {
const roomsOrError = (fn) => async (req, res, next) => {
try { return await fn(req, res, next); } catch (err) {
try { return await fn(req, res, next); } catch (err) {
@@ -439,7 +450,7 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
@@ -439,7 +450,7 @@ ${volume('tts_sound_volume', 'Chat sound volume', tts.sound_volume)}
const who = ai
const who = ai
? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`
? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} <span class="oc-text">${linkify(m.message)}</span>${del}</li>`;
+ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} <span class="oc-text">${linkify(m.message)}</span>${ai ? '' : pictures.pageHtml(m.message)}${del}</li>`;
};
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -524,7 +535,7 @@ ${people}
@@ -524,7 +535,7 @@ ${people}
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
let composer;
let composer;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
- else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="${room.kind === 'system' ? 'Post an announcement' : `Message ${esc(room.name)}`}"></textarea><button type="submit">Send</button></form>`;
+ else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="${room.kind === 'system' ? 'Post an announcement' : `Message ${esc(room.name)}`}"></textarea><button type="submit">Send</button></form>${room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`)}`;
else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;
else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;
else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;
else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;
else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;
else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;
@@ -540,7 +551,7 @@ ${people}
@@ -540,7 +551,7 @@ ${people}
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
- page: { view: 'room', room: room.slug, kind: room.kind, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
+ page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
body: `<p><a href="/rooms">← Rooms</a></p>
body: `<p><a href="/rooms">← Rooms</a></p>
<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
@@ -562,6 +573,21 @@ ${composer}${tools}`,
@@ -562,6 +573,21 @@ ${composer}${tools}`,
res.redirect(303, `/r/${room.slug}#oc-compose`);
res.redirect(303, `/r/${room.slug}#oc-compose`);
}));
}));
+ router.post('/r/:slug/picture', formLimit, sameSite, (req, res, next) => pictureForm(req, res, (err) => {
+ if (!err) return next();
+ const why = err.code === 'LIMIT_FILE_SIZE' ? `A picture can be up to ${Math.round((require('../config').pics.maxBytes || 20 * 1024 * 1024) / 1024 / 1024)} MB.` : 'That upload did not work.';
+ return res.redirect(303, `/r/${encodeURIComponent(req.params.slug)}?error=${encodeURIComponent(why)}`);
+ }), roomsOrError(async (req, res) => {
+ const actor = await needUser(req, res); if (!actor) return;
+ const room = await rooms.bySlug(req.params.slug);
+ if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
+ const back = String((req.body && req.body.back) || '');
+ req.roomBack = /^\/(r|s)\/[a-z0-9-]+(\/[a-z0-9-]+)?$/.test(back) ? back : `/r/${room.slug}`;
+ const f = req.file;
+ await roomRoutes.postPicture(room, actor.user, { token: actor.token, buffer: f ? f.buffer : null, contentType: f ? f.mimetype : '', caption: req.body && req.body.caption, ip: req.ip });
+ res.redirect(303, `${req.roomBack}#oc-compose`);
+ }));
+
router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {
router.post('/r/:slug/join', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const room = await rooms.bySlug(req.params.slug);
const room = await rooms.bySlug(req.params.slug);
@@ -705,7 +731,7 @@ ${attachList}`,
@@ -705,7 +731,7 @@ ${attachList}`,
// ── What shipped, robots, sitemap ──
// ── What shipped, robots, sitemap ──
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
// ── Servers (server/rooms/servers.js): text and voice channels, roles, invites ──
- require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config });
+ require('./server-pages').mountServerPages(router, { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config });
router.get('/updates', async (req, res) => await page(req, res, 200, {
router.get('/updates', async (req, res) => await page(req, res, 200, {
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),
title: `What shipped on ${SITE_NAME}`, path: '/updates', robots: 'index, follow', cache: cache.htmlHeaders({ maxAge: 300 }),
@@ -30,7 +30,7 @@ const initials = (name) => String(name || '?').split(/\s+/).filter(Boolean).slic
@@ -30,7 +30,7 @@ const initials = (name) => String(name || '?').split(/\s+/).filter(Boolean).slic
const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
function mountServerPages(router, h) {
function mountServerPages(router, h) {
- const { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, config } = h;
+ const { page, needUser, viewerOf, sameSite, formLimit, notice, roomMessage, callPanel, applyRoomChange, pictureComposer, config } = h;
const site = config.web.baseUrl;
const site = config.web.baseUrl;
const serverRoutes = () => require('../rooms/server-routes');
const serverRoutes = () => require('../rooms/server-routes');
const orError = (fn) => async (req, res, next) => {
const orError = (fn) => async (req, res, next) => {
@@ -115,7 +115,7 @@ ${create}`,
@@ -115,7 +115,7 @@ ${create}`,
let composer;
let composer;
const back = `/s/${server.slug}/${pick.slug}`;
const back = `/s/${server.slug}/${pick.slug}`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(back)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
- else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>`;
+ else if (rcan.post) composer = `<form class="oc-compose" method="post" action="${esc(back)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message ${isVoice ? '' : '#'}${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="Message ${isVoice ? '' : '#'}${esc(room.name)}"></textarea><button type="submit">Send</button></form>${pictureComposer ? pictureComposer(room, back) : ''}`;
else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;
else if (!can.member) composer = `<form method="post" action="/s/${esc(server.slug)}/join"><button type="submit">Join ${esc(server.name)}</button></form>`;
else composer = notice('error', 'You cannot write here right now.');
else composer = notice('error', 'You cannot write here right now.');
const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
const ais = require('../rooms/ai-members').enabled() ? (await require('../rooms/ai-members').list(room)).filter((a) => Number(a.enabled)) : [];
@@ -134,7 +134,7 @@ ${create}`,
@@ -134,7 +134,7 @@ ${create}`,
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
page: {
- view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0,
+ view: 'room', room: room.slug, back: `/s/${server.slug}`, kind: room.kind, pics: require('../rooms/pictures').enabled() ? require('../rooms/pictures').publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0,
me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
me: me ? me.id : null, moderate: rcan.moderate, post: rcan.post, role: rcan.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
...(callReady ? { call: { channel: `room-${room.slug}`, join: rcan.join, talk: rcan.talk } } : {}),
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * Pictures in rooms and channels (server/rooms/pictures.js): a member's picture goes to OpenVibe.Pics as them (their
+ * own Network token, Host: openvibe.pics, their address) and its link is posted; the room page and the channel page
+ * show OpenVibe.Pics links as thumbnails (no other host's, never in global chat); non-members and other types are
+ * refused before Pics is asked; a Pics refusal comes back as it was; the site's form works without JavaScript; with
+ * CHAT_PICS_INTERNAL_URL unset there is no form and the API says so.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('room pictures');
+let h, pics, ann, bob, cat;
+const api = (method, path, u, body, headers) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body, headers });
+const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63f8cfc0f01f0005000201a5d6a1f60000000049454e44ae426082', 'hex');
+
+function startPics() {
+ const calls = [];
+ let refuse = null;
+ let n = 0;
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ calls.push({ host: req.headers.host, auth: req.headers.authorization, type: req.headers['content-type'], title: req.headers['x-title'], xff: req.headers['x-forwarded-for'], bytes: Buffer.concat(chunks).length, path: req.url });
+ const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
+ if (refuse) return send(refuse.status, { code: refuse.code, detail: refuse.detail });
+ const slug = `Ab${String(++n).padStart(6, '2')}`.replace(/[01]/g, '3');
+ return send(201, { slug, links: { direct: `https://openvibe.pics/i/${slug}.png`, page: `https://openvibe.pics/i/${slug}` } });
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
+ calls, url: `http://127.0.0.1:${server.address().port}`, refuse: (r) => { refuse = r; }, close: () => new Promise((r) => server.close(r)),
+ })));
+}
+
+const page = async (path, u) => { const r = await fetch(`${h.base}${path}`, { headers: u ? { cookie: `ov_token=${u.token}` } : {}, redirect: 'manual' }); return { status: r.status, text: await r.text(), location: r.headers.get('location') }; };
+function multipart(fields, file) {
+ const b = `----ocpic${Date.now()}`;
+ const parts = Object.entries(fields).map(([k, v]) => Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
+ if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="picture"; filename="${file.name}"\r\nContent-Type: ${file.type}\r\n\r\n`), file.buffer, Buffer.from('\r\n'));
+ parts.push(Buffer.from(`--${b}--\r\n`));
+ return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
+}
+
+t('boot', async () => {
+ pics = await startPics();
+ h = await boot({ env: { CHAT_PICS_INTERNAL_URL: pics.url } });
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ cat = h.addUser('cat', { subject: ids.newId('user') });
+ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ assert.strictEqual((await api('POST', '/', ann, { name: 'Photo Walk' })).status, 201);
+ await api('POST', '/photo-walk/join', bob);
+});
+
+t('a member\'s picture goes to Pics as them, and its link is posted after the caption', async () => {
+ const r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: bob.token, raw: PNG, headers: { 'content-type': 'image/png', 'x-caption': encodeURIComponent('Sunset at the pier') } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.match(r.body.message.message, /^Sunset at the pier https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.png$/);
+ const call = pics.calls[0];
+ assert.deepStrictEqual([call.path, call.host, call.auth, call.type, call.bytes], ['/api/v1/images', 'openvibe.pics', `Bearer ${bob.token}`, 'image/png', PNG.length]);
+ assert.strictEqual(decodeURIComponent(call.title), 'Sunset at the pier');
+ assert.ok(call.xff, 'the person\'s address goes along for Pics\' own limits');
+});
+
+t('refused before Pics is asked: a non-member, another type; a Pics refusal comes back as it was', async () => {
+ const before = pics.calls.length;
+ let r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: cat.token, raw: PNG, headers: { 'content-type': 'image/png' } });
+ assert.deepStrictEqual([r.status, r.body.code], [403, 'rooms.join_first']);
+ r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: bob.token, raw: Buffer.from('<svg/>'), headers: { 'content-type': 'image/svg+xml' } });
+ assert.deepStrictEqual([r.status, r.body.code], [415, 'pictures.type']);
+ assert.strictEqual(pics.calls.length, before);
+ pics.refuse({ status: 429, code: 'pics.daily_limit', detail: 'That is 200 pictures today.' });
+ r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: bob.token, raw: PNG, headers: { 'content-type': 'image/png' } });
+ assert.deepStrictEqual([r.status, r.body.code, r.body.error], [429, 'pics.daily_limit', 'That is 200 pictures today.']);
+ pics.refuse(null);
+});
+
+t('the page: Pics links show as thumbnails linked to the picture\'s page; other links stay links', async () => {
+ await api('POST', '/photo-walk/messages', ann, { message: 'not a picture: https://example.com/i/Ab222222.png' });
+ const r = await page('/r/photo-walk', ann);
+ assert.match(r.text, /<a class="oc-pic" href="https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}"[^>]*><img src="https:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\/thumb\.webp"/);
+ assert.strictEqual((r.text.match(/class="oc-pic"/g) || []).length, 1, 'only the Pics picture');
+ assert.match(r.text, /Add a picture/);
+ assert.match(r.text, /"pics":"https:\/\/openvibe\.pics"/, 'chat.js gets the Pics address for live messages');
+ assert.doesNotMatch((await page('/r/photo-walk', cat)).text, /Add a picture/, 'only people who may post get the form');
+});
+
+t('the form works without JavaScript, and goes back to where it was sent from', async () => {
+ const m = multipart({ caption: 'From the form', back: '/r/photo-walk' }, { name: 'p.png', type: 'image/png', buffer: PNG });
+ const r = await fetch(`${h.base}/r/photo-walk/picture`, { method: 'POST', headers: { cookie: `ov_token=${bob.token}`, 'content-type': m.type }, body: m.body, redirect: 'manual' });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), '/r/photo-walk#oc-compose');
+ const msgs = (await api('GET', '/photo-walk/messages', ann)).body.messages;
+ assert.match(msgs[msgs.length - 1].message, /^From the form https:\/\/openvibe\.pics\/i\//);
+ const bad = multipart({ back: 'https://evil.example/' }, { name: 'p.png', type: 'image/png', buffer: PNG });
+ const r2 = await fetch(`${h.base}/r/photo-walk/picture`, { method: 'POST', headers: { cookie: `ov_token=${bob.token}`, 'content-type': bad.type }, body: bad.body, redirect: 'manual' });
+ assert.strictEqual(r2.headers.get('location'), '/r/photo-walk#oc-compose', 'never back to another site');
+});
+
+t('in a server\'s channel too', async () => {
+ const s = (await h.http('POST', '/api/chat/servers', { token: ann.token, body: { name: 'Shutterbugs' } })).body.server;
+ const room = (await h.http('GET', `/api/chat/servers/${s.slug}`, { token: ann.token })).body.channels[0].room;
+ const r = await h.http('POST', `/api/chat/rooms/${room}/pictures`, { token: ann.token, raw: PNG, headers: { 'content-type': 'image/png' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const p = await page(`/s/${s.slug}/general`, ann);
+ assert.match(p.text, /class="oc-pic"/);
+ assert.match(p.text, new RegExp(`action="/r/${room}/picture"`));
+});
+
+t('global chat stays text', async () => {
+ const r = await page('/', ann);
+ assert.doesNotMatch(r.text, /class="oc-pic"/);
+});
+
+t('off without CHAT_PICS_INTERNAL_URL: no form, and the API says so', async () => {
+ const cfg = require('../server/config').pics;
+ const was = cfg.internalUrl;
+ cfg.internalUrl = '';
+ try {
+ assert.doesNotMatch((await page('/r/photo-walk', bob)).text, /Add a picture/);
+ const r = await h.http('POST', '/api/chat/rooms/photo-walk/pictures', { token: bob.token, raw: PNG, headers: { 'content-type': 'image/png' } });
+ assert.deepStrictEqual([r.status, r.body.code], [503, 'pictures.off']);
+ } finally { cfg.internalUrl = was; }
+});
+
+t.run(async () => { if (h && h.close) await h.close(); if (pics) await pics.close(); });