openvibers/openvibe.chat Commits

Chat: pictures in rooms and channels, through OpenVibe.Pics as the person (#105)

OP OpenVibers committed 28 days ago · 73e9477f51 · 10 files, +364 −8

The owner's "files and pics" for Chat. A person who may post sends a picture from the room's or channel's form
(no JavaScript needed; with it, POST /api/chat/rooms/:slug/pictures); Chat hands it to OpenVibe.Pics as them, with
their own Network token through MediaHub on this host (CHAT_PICS_INTERNAL_URL, Host openvibe.pics), so it is theirs
on Pics, and posts its link. Rooms and channels show OpenVibe.Pics links as thumbnails linked to the picture's page,
on the page and live; global and stream chat stay text. Off without CHAT_PICS_INTERNAL_URL.

Changes

ddiff --git a/.env.example b/.env.example
iindex 41120ce..f72ede0 100644
---- a/.env.example
++++ b/.env.example
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 12e0d2a..a75f0f8 100644
---- a/README.md
++++ b/README.md
@@@ -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
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]`,
`/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),
ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex e27a7ba..1a3d8b5 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -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; } }
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 3dcb851..dc7c92b 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -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();
})();
ddiff --git a/server/config.js b/server/config.js
iindex 56f7ca8..ba5dd2f 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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.
ddiff --git a/server/rooms/pictures.js b/server/rooms/pictures.js
nnew file mode 100644
iindex 0000000..61b9840
---- /dev/null
++++ b/server/rooms/pictures.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex 49846e6..9653591 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -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, asy
module.exports = router;
module.exports.callOf = callOf;
module.exports.applyRoomChange = applyRoomChange;
++module.exports.postPicture = postPicture;
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 0d7ddb4..b6d6866 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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 }),
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex 039d5a8..a5a3e6b 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -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; };
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 } } : {}),
},
ddiff --git a/test/room-pictures.test.js b/test/room-pictures.test.js
nnew file mode 100644
iindex 0000000..920e7ec
---- /dev/null
++++ b/test/room-pictures.test.js
@@@ -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(); });