openvibers/openvibe.chat Commits
Rooms: a message carries a file Media stored for the sender (plan §Z.9) (#578)
Changes
@@@ -17,7 +17,10 @@ build step**.## What it owns- Rooms: global, stream, channel, community, DM/group DM, call, system; servers (groups of rooms as-- text and voice channels, with roles and invites).++ text and voice channels, with roles and invites). A room message may carry up to four files the person++ picked in the room's box: they go to OpenVibe.Media's composer attachments (composer.attachment@1) as++ they are picked, Chat checks them with Media (owner-checked), keeps each with the message and draws them++ (a video in the network's player, a picture, a sound, a file link).- Messages, replies/rich payloads, edit history, membership/roles, unread/read state, presence andtyping (ephemeral).- Moderation state: bans, timeouts, delete/purge, filters, slow/member-only modes, the moderation@@@ -88,7 +91,9 @@ tests use embedded PGlite, PostgreSQL in production through PgBouncer. Migration`migrations/NNNN_*.sql`, applied at boot with the owner connection (`DATABASE_DIRECT_URL`), then theapp serves through the pooled runtime role (`DATABASE_URL`, DML only). Never edit an appliedmigration. The moved tables keep Live's ids and columns plus Network subject columns--(`subject_id`, `sender_subject_id`, …). `docs/parity.md` records the parity method.++(`subject_id`, `sender_subject_id`, …). `docs/parity.md` records the parity method. The files a room++message carries live in Media, not here: `room_message_attachments` keeps what Media answered (the++kind, the type, the poster, the playback, the length, the size) beside the message.## Auth and the WebSocket rulesnnew file mode 100644@@@ -0,0 +1,27 @@++-- phase: expand++-- The files a room message carries from OpenVibe.Media's composer attachments (composer.attachment@1): a video, a++-- picture, a sound, a PDF or a text file, picked in the room's box and sent with the message. Media owns the bytes++-- and answers what plays it, so the message keeps the kind, the type, the poster, the playback (url, mime, ready),++-- the length and the size after the rotation is applied. The bytes never live in Chat's database.++CREATE TABLE room_message_attachments (++ message_id bigint NOT NULL REFERENCES room_messages(id) ON DELETE CASCADE,++ attachment_id text COLLATE "C" NOT NULL,++ position integer NOT NULL DEFAULT 0,++ owner_subject text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ mime text COLLATE "C" NOT NULL,++ size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('image', 'video', 'audio', 'file')),++ url text COLLATE "C",++ poster text COLLATE "C",++ playback_url text COLLATE "C",++ playback_mime text COLLATE "C",++ playback_ready integer NOT NULL DEFAULT 0,++ duration_s double precision,++ width bigint,++ height bigint,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (message_id, attachment_id)++);++CREATE INDEX room_message_attachments_id ON room_message_attachments(attachment_id);++CREATE INDEX room_message_attachments_message ON room_message_attachments(message_id, position);@@@ -290,6 +290,18 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-card-go { align-self: flex-start; margin-top: 6px; padding: 4px 10px; border-radius: 999px; background: var(--oc-accent); color: var(--oc-on-accent); text-decoration: none; font-weight: 600; font-size: .86em; }.oc-card-go:hover { filter: brightness(1.08); }.ovt-bubble .oc-card, .ovt-reply .oc-card { margin-top: 8px; }++/* The files a message carries (Media's composer attachments, plan §Z.9): a video in the network's player, a++ picture, a sound bar, a file link. Quiet and small: the message stays the thing that was said. */++.oc-attach { display: flex; flex-direction: column; gap: 6px; max-width: 420px; margin: 6px 0 2px; }++.oc-attach-item { display: block; margin: 0; color: var(--oc-text); }++.oc-attach-video video { display: block; width: 100%; max-width: 420px; border-radius: 12px; background: #000; }++.oc-attach-video figcaption, .oc-attach-audio figcaption { margin-top: 4px; color: var(--oc-muted); font-size: .86em; line-height: 1.4; overflow-wrap: anywhere; }++.oc-attach-video.is-preparing { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel-2); color: var(--oc-muted); font-size: .88em; text-decoration: none; }++.oc-attach-video.is-preparing img { display: block; width: 100%; max-width: 400px; border-radius: 8px; background: var(--oc-panel); }++.oc-attach-image img { display: block; width: auto; max-width: min(320px, 100%); max-height: 320px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel-2); object-fit: cover; }++.oc-attach-audio audio { width: 100%; max-width: 380px; height: 40px; }++.oc-attach-file { display: inline-block; padding: 6px 12px; border: 1px solid var(--oc-line); border-radius: 999px; background: var(--oc-panel-2); color: var(--oc-text); font-size: .9em; text-decoration: none; overflow-wrap: anywhere; }++.oc-attach-file:hover { border-color: var(--oc-accent); }.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; }@@@ -761,6 +761,85 @@}// ── A room ──++ // The files a message carries (Media's composer attachments): a video plays in the network's player (Media's++ // web copy, its poster, its size), a video whose copy is still being made shows its first frame and a link to++ // the file, a picture shows, a sound gets a bar, a file is a link. Only https on openvibe.media is ever shown.++ function mediaUrl(v) {++ try {++ var u = new URL(String(v || ''));++ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;++ if (u.hostname.toLowerCase() !== 'openvibe.media') return null;++ if (u.pathname.indexOf('/a/') !== 0) return null;++ return u.href;++ } catch (e) { return null; }++ }++ function mediaAttachments(list) {++ var items = Array.isArray(list) ? list : [];++ if (!items.length) return null;++ var box = el('div', 'oc-attach');++ items.forEach(function (a) {++ var url = mediaUrl(a && a.url);++ if (!url) return;++ var poster = mediaUrl(a.poster);++ var type = String(a.media_type || '');++ var kind = a.kind || (type.indexOf('video/') === 0 ? 'video' : type.indexOf('image/') === 0 ? 'image' : type.indexOf('audio/') === 0 ? 'audio' : 'file');++ var title = a.name || (kind === 'video' ? 'Video' : kind === 'image' ? 'Picture' : kind === 'audio' ? 'Sound' : 'Attached file');++ if (kind === 'video') {++ var pb = a.playback && a.playback.ready ? mediaUrl(a.playback.url) : null;++ if (!pb) {++ var prep = el('a', 'oc-attach-item oc-attach-video is-preparing');++ prep.href = url; prep.target = '_blank'; prep.rel = 'noopener noreferrer';++ if (poster) { var first = document.createElement('img'); first.src = poster; first.alt = ''; first.loading = 'lazy'; prep.appendChild(first); }++ prep.appendChild(el('span', null, 'Getting the video ready to play here. Open the file'));++ box.appendChild(prep);++ return;++ }++ var fig = el('figure', 'oc-attach-item oc-attach-video');++ var video = document.createElement('video');++ video.setAttribute('data-ov-player', ''); video.controls = true; video.preload = 'none'; video.playsInline = true;++ video.src = pb; if (poster) video.poster = poster;++ var w = Number(a.width) || 0, h = Number(a.height) || 0;++ if (w > 0 && h > 0 && w <= 10000 && h <= 10000) { video.width = Math.round(w); video.height = Math.round(h); }++ video.setAttribute('aria-label', title);++ fig.appendChild(video);++ var secs = Number(a.duration_s);++ if (isFinite(secs) && secs > 0) fig.appendChild(el('figcaption', null, title + ' · ' + Math.floor(secs / 60) + ':' + String(Math.round(secs % 60)).padStart(2, '0')));++ box.appendChild(fig);++ // The network's player wraps a video that arrives later (Shared's player.js).++ if (window.OpenVibePlayer && window.OpenVibePlayer.enhance) { try { window.OpenVibePlayer.enhance(video); } catch (e) { /* the browser's own controls stay */ } }++ return;++ }++ if (kind === 'image') {++ var link = el('a', 'oc-attach-item oc-attach-image'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer';++ var img = document.createElement('img'); img.src = poster || url; img.alt = title; img.loading = 'lazy'; img.width = 160;++ link.appendChild(img); box.appendChild(link); return;++ }++ if (kind === 'audio') {++ var afig = el('figure', 'oc-attach-item oc-attach-audio');++ var audio = document.createElement('audio'); audio.controls = true; audio.preload = 'none'; audio.src = url; audio.setAttribute('aria-label', title);++ afig.appendChild(audio); afig.appendChild(el('figcaption', null, title)); box.appendChild(afig); return;++ }++ var flink = el('a', 'oc-attach-item oc-attach-file', title); flink.href = url; flink.target = '_blank'; flink.rel = 'noopener noreferrer';++ box.appendChild(flink);++ });++ return box.childNodes.length ? box : null;++ }++ // The files the box picked: Media stored them and the form carries their ids (one hidden field per file).++ function pickedFiles(form) {++ var ids = [];++ if (!form) return ids;++ Array.prototype.forEach.call(form.querySelectorAll('input[name="attachments"]'), function (input) {++ var v = String(input.value || '').trim();++ if (/^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(v) && ids.indexOf(v) < 0) ids.push(v);++ });++ return ids;++ }++ // The files the box picked, put back on the tray (they travel with the message).++ function clearPickedFiles(form) {++ var c = form && form.__ovcComposer;++ if (c && typeof c.clearFiles === 'function') { try { c.clearFiles(); } catch (e) { /* the tray stays */ } }++ Array.prototype.forEach.call((form || document).querySelectorAll('input[name="attachments"]'), function (input) { input.remove(); });++ }function roomItem(m) {var ai = m.ai_member_id != null;var li = el('li', 'oc-msg' + (ai ? ' oc-msg-ai' : '')); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';@@@ -787,6 +866,8 @@if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }++ // The files the message carries: a video plays, a picture shows, a sound gets a bar, a file is a link.++ var files = mediaAttachments(m.media_attachments); if (files) li.appendChild(files);// Cards that were taken back are not sent with the message: the line says so instead.if (m.embeds_hidden) li.appendChild(hiddenNote(li, Number(m.id) || 0));else addCard(li, m);@@@ -896,11 +977,14 @@if (form && input) {form.addEventListener('submit', function (e) {var text = input.value.trim();-- if (!text) { e.preventDefault(); return; }++ // The files the box picked travel with the message (the room's server checks them with Media).++ var files = pickedFiles(form);++ if (!text && !files.length) { e.preventDefault(); return; }if (!ws || ws.readyState !== 1) return;e.preventDefault();-- ws.send(JSON.stringify({ type: 'room_message', message: text }));++ ws.send(JSON.stringify({ type: 'room_message', message: text, attachments: files }));input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus();++ clearPickedFiles(form);});}if (feed) feed.addEventListener('submit', function (e) {@@@ -13,10 +13,15 @@var ta = form.querySelector('textarea');if (!ta) return;var add = form.querySelector('[data-oc-attach]');++ // 4.247.0 (plan §Z.9): a room's box sends the files it takes to Media's composer attachments as they are++ // picked (any video, picture, sound, PDF or text, at Media's limits) and puts their ids on the form. The++ // room's own server checks them with Media and keeps them with the message. A box that says nothing keeps++ // the plain upload.++ var media = form.hasAttribute('data-ovc-media');var c = window.OpenVibeComposer.mount(document.createElement('div'), {preset: 'chat', name: ta.name || 'message', maxLength: Number(ta.getAttribute('maxlength')) || 4000,placeholder: ta.placeholder || 'Write a message', label: ta.getAttribute('aria-label') || form.getAttribute('aria-label') || 'Message',-- tools: ['emoji'],++ tools: ['emoji'], media: !!media, maxFiles: media ? 4 : undefined,});if (!c) return;c.text.id = ta.id || 'oc-input';@@@ -761,7 +761,10 @@ class ChatServer {try {const command = await require('./room-slash').parse(room, client.user, msg.message);if (command.handled && !command.text) { this.sendTo(ws, { type: 'system', message: command.reply }); break; }-- const message = await rooms.post(room, client.user, command.handled ? command.text : msg.message);++ const message = await rooms.post(room, client.user, command.handled ? command.text : msg.message, {++ // The files Media stored for the sender (the room's box): at most four, each one theirs.++ attachments: require('../rooms/attachments').namedAttachments(msg.attachments),++ });this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });} catch (err) {this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++/**++ * OpenVibe.Media's composer attachments (composer.attachment@1) — the files a person picks in a box and++ * sends with a message. Media owns the bytes and the answer; the site keeps the attachment with the++ * message it belongs to.++ *++ * GET /api/v1/attachments/:id where the attachment stands, for the person it belongs to++ * POST /api/v1/attachments/:id/keep { message_id } — kept with that message (the kept window)++ * DELETE /api/v1/attachments/:id the person who uploaded it → 204++ *++ * Credentials: Chat's own service token (client 'chat', audience openvibe.media, scope++ * media.object.upload) with the person named in X-OV-Subject. Media checks the owner: an attachment that++ * is not the named person's is a 404, exactly like one that does not exist.++ *++ * The shape Media answers: { id, kind, name, size, mime, url, poster, playback { url, mime, ready },++ * duration_s, width, height }. A video a browser can play as it is keeps its own bytes; one it cannot++ * (an MKV, an AVI, an HEVC MOV) gets a web copy made in the background, and until that copy is there++ * playback.ready is false.++ *++ * The same call the link preview job makes for a card's picture (server/chat/media-import.js), with the++ * read and the delete the files a message carries need.++ */++const config = require('../config');++const serviceAuth = require('../net/service-auth');++++const AUDIENCE = 'openvibe.media';++const TIMEOUT_MS = 8000;++const ATTACHMENT_ID = /^att_[0-9A-HJKMNP-TV-Z]{26}$/;++++let _fetch = null;++++/** Media is asked through its internal address; the caller needs a service token to do that. */++function enabled() {++ return !!(config.media && config.media.baseUrl && config.oauth.clientSecret);++}++++/** One call to Media: the answer as an object, or a throw with the status Media answered. */++async function request(method, path, { json, subject } = {}) {++ if (!enabled()) {++ const err = new Error('Attachments need Chat\'s service principal (OV_OAUTH_CLIENT_SECRET)');++ err.status = 503; err.code = 'attachment.unavailable';++ throw err;++ }++ const headers = await serviceAuth.headers(AUDIENCE, config.media.scope);++ const send = _fetch || globalThis.fetch;++ const res = await send(config.media.baseUrl + path, {++ method,++ headers: {++ ...headers,++ accept: 'application/json',++ ...(json !== undefined ? { 'content-type': 'application/json' } : {}),++ ...(subject ? { 'X-OV-Subject': String(subject) } : {}),++ },++ body: json !== undefined ? JSON.stringify(json) : undefined,++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ const body = await res.json().catch(() => null);++ if (!res.ok) {++ const err = new Error('Media ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));++ err.status = Number(res.status) || 502;++ err.code = (body && body.code) || null;++ throw err;++ }++ return body || {};++}++++/**++ * One composer attachment, for the person it belongs to. Media checks the owner: an attachment that is++ * not the named person's is a 404, exactly like one that does not exist.++ */++async function attachment(id, subject) {++ const att = String(id || '').trim();++ if (!ATTACHMENT_ID.test(att)) {++ const err = new Error('That is not a Media attachment id');++ err.status = 400; err.code = 'attachment.invalid';++ throw err;++ }++ return await request('GET', '/api/v1/attachments/' + encodeURIComponent(att), { subject });++}++++/**++ * Keep the attachment with the message it belongs to, so it lives as long as that message (Media's kept++ * window, 30 days) instead of the 24 hours an unsent file has.++ */++async function keepAttachment(id, subject, messageId) {++ const att = String(id || '').trim();++ const message = String(messageId == null ? '' : messageId).trim();++ if (!ATTACHMENT_ID.test(att) || !message || message.length > 200) return false;++ try {++ await request('POST', '/api/v1/attachments/' + encodeURIComponent(att) + '/keep', { json: { message_id: message }, subject });++ return true;++ } catch (err) {++ if (err && err.status === 404) return false;++ throw err;++ }++}++++/**++ * Remove the attachment from Media: the message it belonged to is gone, so the file goes with it. The++ * person who uploaded it is named, so Media knows whose file this is.++ */++async function deleteAttachment(id, subject) {++ const att = String(id || '').trim();++ if (!ATTACHMENT_ID.test(att)) return false;++ try {++ await request('DELETE', '/api/v1/attachments/' + encodeURIComponent(att), { subject });++ return true;++ } catch (err) {++ if (err && err.status === 404) return true; // already gone: the message keeps nothing either++ throw err;++ }++}++++module.exports = { attachment, keepAttachment, deleteAttachment, enabled, AUDIENCE, ATTACHMENT_ID, _setFetch(fn) { _fetch = fn || null; } };nnew file mode 100644@@@ -0,0 +1,190 @@++'use strict';++/**++ * The files a room message carries from OpenVibe.Media's composer attachments (composer.attachment@1).++ *++ * A person picks a file in the room's box; Shared's composer sends it to Media as it is picked and puts++ * the id (att_…) on the form. The message names that id, Chat asks Media (with its own service token,++ * naming the sender) whether the file exists, is the sender's and may be carried, keeps it with the++ * message, and stores what Media answered: the kind, the type, the poster, the playback (url, mime,++ * ready), the length and the size. A video whose web copy Media is still making is asked again on a read++ * (freshen), so it plays as soon as the copy is there.++ *++ * The bytes live in Media, never in Chat: the row here is what a reader needs to draw the file.++ */++const db = require('../db/database');++++/** Media's composer attachment id. */++const ATTACHMENT_ID = /^att_[0-9A-HJKMNP-TV-Z]{26}$/;++/** The kinds a message may carry: a picture, a video, a sound, or a file (a PDF or a text). */++const KINDS = new Set(['image', 'video', 'audio', 'file']);++/** At most four files on one message. */++const MAX_ATTACHMENTS = 4;++const NAME_MAX = 255;++/** Media serves the bytes under /a/: a picture, a video's copy, a sound. Nothing else is ever drawn. */++const MEDIA_HOST = 'openvibe.media';++++/**++ * The kind a message may carry. A picture, a video and a sound are fine; a file only when it is a PDF++ * or text. Everything else (code, an executable, a binary nobody can read) is refused.++ */++function allowedKind(kind, mime) {++ const k = String(kind || '').trim();++ if (KINDS.has(k)) {++ if (k !== 'file') return k;++ const m = String(mime || '').split(';')[0].trim().toLowerCase();++ return m === 'application/pdf' || m.startsWith('text/') ? 'file' : null;++ }++ return null;++}++++/**++ * The composer attachment a message names: { media_attachment_id: 'att_…' }. Throws a 400 when the id++ * is not one, so a message never carries something Chat cannot ask Media about.++ */++function composerReference(raw) {++ const value = raw && typeof raw === 'object' ? raw : {};++ const id = String(value.media_attachment_id || '').trim();++ if (!ATTACHMENT_ID.test(id)) {++ const err = new Error('Each Media attachment must be named by its media_attachment_id');++ err.status = 400; err.code = 'rooms.attachment_invalid'; throw err;++ }++ return { media_attachment_id: id };++}++++/**++ * The ids a body carries: Shared's composer puts one plain att_ id per file on the form (the field++ * repeated), and a service may send a JSON list of { media_attachment_id }. Both are read here.++ */++function namedAttachments(raw) {++ if (raw == null || raw === '') return [];++ let value = raw;++ if (typeof value === 'string' && ATTACHMENT_ID.test(value.trim())) value = [value];++ else if (typeof value === 'string') {++ try { value = JSON.parse(value); }++ catch { const err = new Error('That attachment list is not valid'); err.status = 400; err.code = 'rooms.attachment_invalid'; throw err; }++ }++ if (!Array.isArray(value)) value = [value];++ const out = [];++ for (let item of value) {++ if (typeof item === 'string') {++ const t = item.trim();++ if (ATTACHMENT_ID.test(t)) { out.push({ media_attachment_id: t }); continue; }++ try { item = JSON.parse(t); }++ catch { const err = new Error('That attachment list is not valid'); err.status = 400; err.code = 'rooms.attachment_invalid'; throw err; }++ for (const x of (Array.isArray(item) ? item : [item])) if (x && typeof x === 'object' && x.media_attachment_id) out.push(x);++ continue;++ }++ if (item && typeof item === 'object' && item.media_attachment_id) out.push(item);++ }++ return out;++}++++/**++ * An address a reader may be sent to: https on openvibe.media, under /a/ (the bytes Media serves).++ * Anything else — another host, another scheme, another path — is no address at all.++ */++function mediaUrl(value) {++ if (typeof value !== 'string' || !value) return null;++ try {++ const u = new URL(value);++ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;++ if (u.hostname.toLowerCase() !== MEDIA_HOST) return null;++ if (!u.pathname.startsWith('/a/')) return null;++ return u.href;++ } catch { return null; }++}++++/**++ * What a message's attachment looks like to everyone who may read the message: the kind and what plays++ * it, so the room can draw a player for a video, a picture, a sound bar or a file link.++ */++function publicReference(row) {++ if (!row) return null;++ const url = mediaUrl(row.url);++ if (!url) return null;++ const poster = mediaUrl(row.poster);++ const out = {++ id: row.attachment_id,++ name: row.name || 'file',++ media_type: row.mime || 'application/octet-stream',++ size: Number(row.size_bytes) || 0,++ kind: row.kind || 'file',++ url,++ poster: poster || null,++ playback: null,++ duration_s: row.duration_s != null ? Number(row.duration_s) : null,++ width: row.width != null ? Number(row.width) : null,++ height: row.height != null ? Number(row.height) : null,++ };++ const pb = mediaUrl(row.playback_url);++ if (pb) out.playback = { url: pb, mime: row.playback_mime || out.media_type, ready: !!row.playback_ready };++ return out;++}++++/** The row a kept attachment is stored as. */++function rowFor(messageId, item, position) {++ const att = item.attachment || item;++ const playback = att.playback || null;++ return {++ message_id: Number(messageId) || 0,++ attachment_id: String(att.id || att.media_attachment_id || ''),++ position: Number(position) || 0,++ owner_subject: String(att.owner_subject || ''),++ name: String(att.name || 'file').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, NAME_MAX) || 'file',++ mime: String(att.mime || 'application/octet-stream').split(';')[0].trim().slice(0, NAME_MAX) || 'application/octet-stream',++ size_bytes: Math.max(0, Math.floor(Number(att.size) || 0)),++ kind: String(att.kind || 'file'),++ url: mediaUrl(att.url),++ poster: mediaUrl(att.poster),++ playback_url: playback ? mediaUrl(playback.url) : null,++ playback_mime: playback && playback.mime ? String(playback.mime) : null,++ playback_ready: playback && playback.ready ? 1 : 0,++ duration_s: att.duration_s != null ? Number(att.duration_s) : null,++ width: att.width != null ? Number(att.width) : null,++ height: att.height != null ? Number(att.height) : null,++ };++}++++/** Store the attachments of one message (the rows a reader draws them from). */++async function createRows(messageId, items) {++ const out = [];++ for (let i = 0; i < items.length; i++) {++ const row = rowFor(messageId, items[i], i);++ if (!row.attachment_id || !row.url) continue;++ await db.run(++ 'INSERT INTO room_message_attachments (message_id, attachment_id, position, owner_subject, name, mime, size_bytes, kind, url, poster, playback_url, playback_mime, playback_ready, duration_s, width, height)'++ + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',++ [row.message_id, row.attachment_id, row.position, row.owner_subject, row.name, row.mime, row.size_bytes, row.kind,++ row.url, row.poster, row.playback_url, row.playback_mime, row.playback_ready, row.duration_s, row.width, row.height]);++ out.push(row);++ }++ return out;++}++++/** The stored attachments of these messages, by message id. */++async function rowsFor(messageIds) {++ const ids = (Array.isArray(messageIds) ? messageIds : [messageIds]).map((n) => Number(n) || 0).filter(Boolean);++ if (!ids.length) return new Map();++ const rows = await db.all(++ 'SELECT * FROM room_message_attachments WHERE message_id IN (' + ids.map(() => '?').join(',') + ') ORDER BY message_id, position, attachment_id',++ ids);++ const out = new Map();++ for (const row of rows) {++ const list = out.get(Number(row.message_id)) || [];++ list.push(row);++ out.set(Number(row.message_id), list);++ }++ return out;++}++++/** The stored attachments of one message. */++async function rowsOf(messageId) {++ return await db.all('SELECT * FROM room_message_attachments WHERE message_id = ? ORDER BY position, attachment_id', [Number(messageId) || 0]);++}++++/** Forget the stored attachments of a message (the message is gone, or the row is replaced). */++async function dropRows(messageId) {++ await db.run('DELETE FROM room_message_attachments WHERE message_id = ?', [Number(messageId) || 0]);++}++++module.exports = { ATTACHMENT_ID, KINDS, MAX_ATTACHMENTS, allowedKind, composerReference, namedAttachments, mediaUrl, publicReference, createRows, rowsFor, rowsOf, dropRows };@@@ -50,7 +50,15 @@ function cleanFiles(files, reader = null) {/** The message API calls these attachments; the AI pipeline keeps its historical `files` name. */function attachmentsFromBody(body) {if (!body || typeof body !== 'object') return [];-- return Array.isArray(body.attachments) ? body.attachments : (Array.isArray(body.files) ? body.files : []);++ const list = Array.isArray(body.attachments) ? body.attachments : (Array.isArray(body.files) ? body.files : []);++ // A body may carry two kinds of file: the ones the AI reads (Media objects) and the ones Media stored for++ // the sender (composer attachments, att_…). Only the first kind belongs here; the other is read by++ // server/rooms/attachments.js.++ return list.filter((f) => {++ if (f && typeof f === 'object' && f.media_attachment_id) return false;++ if (typeof f === 'string' && /^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(f.trim())) return false;++ return true;++ });}/** The files stored on a message row (jsonb comes back as an array; a text column is tolerated too). */@@@ -46,6 +46,8 @@ const messageFiles = require('./files');*/const db = require('../db/database');const ctx = require('../live-context');++const messageAttachments = require('./attachments');++const mediaAttachments = require('../media/attachments');const wordFilter = require('../chat/word-filter');const permissions = require('../auth/permissions');const linkCards = require('../chat/link-cards');@@@ -323,7 +325,37 @@ async function editMessage(room, user, messageId, text, { now = Date.now() } = {return updated;}--async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [], agentView = false } = {}) {++/**++ * The files a message carries from Media (composer.attachment@1), checked as the sender: at most four, each++ * one the sender's, each one a kind a message may carry. Media is asked with Chat's own service token and++ * the sender named in X-OV-Subject, so another person's file is a 404, exactly like one that is not there.++ */++async function checkedAttachments(attachments, user) {++ const list = Array.isArray(attachments) ? attachments : [];++ if (!list.length) return [];++ if (list.length > messageAttachments.MAX_ATTACHMENTS) {++ fail(400, 'rooms.attachments_too_many', `At most ${messageAttachments.MAX_ATTACHMENTS} files can go with a message`);++ }++ if (!user || !user.subject_id) fail(400, 'rooms.attachment_invalid', 'A file can only be attached by the person who uploaded it');++ const out = [];++ for (const raw of list) {++ let ref;++ try { ref = messageAttachments.composerReference(raw); }++ catch (err) { fail(err.status || 400, err.code || 'rooms.attachment_invalid', err.message); }++ let att;++ try { att = await mediaAttachments.attachment(ref.media_attachment_id, user.subject_id); }++ catch (err) {++ if (err && err.status === 404) fail(404, 'rooms.attachment_not_found', 'One of the attached files is not there');++ fail(502, 'rooms.attachment_unavailable', 'One of the attached files could not be checked');++ }++ const kind = messageAttachments.allowedKind(att && att.kind, att && att.mime);++ if (!kind) fail(415, 'rooms.attachment_type', 'A message may carry a picture, a video, a sound, a PDF or a text file');++ out.push({ ...att, id: ref.media_attachment_id, kind, owner_subject: user.subject_id });++ }++ return out;++}++++async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [], attachments = [], agentView = false } = {}) {const a = await access(room, user);if (agentView && kindOf(room) === 'agent_stream' && a.role !== 'blocked' && user && !siteBanned(user)) {a.read = true;@@@ -337,7 +369,8 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =fail(403, 'rooms.read_only', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Your role in this room is read-only');}const body = String(text || '').replace(/\r\n/g, '\n').trim();-- if (!body) fail(422, 'rooms.empty', 'Write something first');++ // A message may be a file with no words (a picture alone is a message, as on any social app).++ if (!body && !(Array.isArray(attachments) && attachments.length)) fail(422, 'rooms.empty', 'Write something first');// A person's own Vibe chat has no other reader: longer messages, and no spam guess (pasted text has rows of = and links).const ownVibe = Number(room.assistant) === 1 && Number(room.owner_id) === Number(user.id);const max = ownVibe ? MAX_TEXT_VIBE : MAX_TEXT;@@@ -366,6 +399,9 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =const filtered = wordFilter.check(body);const message = filtered.safe ? body : filtered.filtered;const attached = messageFiles.cleanFiles(files, user.subject_id || null);++ // The files Media stored for this person (composer.attachment@1): Media says whether they exist, are the++ // sender's and may be carried. Another person's file is a 404, exactly like one that is not there.++ const mediaFiles = await checkedAttachments(attachments, user);// The message's known OpenVibe link, with a second's budget: the card is stored on the row and arrives with the// broadcast when it is ready; a slower one fills the row later.const { card, pending } = await linkCards.forSend(message);@@@ -373,9 +409,24 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, card, reply_to_id, files) VALUES (?, ?, ?, ?, ?, ?, ?::jsonb)', [room.id, user.id, user.subject_id || null, message, linkCards.toJson(card), replyToId, JSON.stringify(attached)]);await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);await db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);++ // What Media answered about the files, kept beside the message (the bytes live in Media).++ if (mediaFiles.length) await messageAttachments.createRows(Number(r.lastInsertRowid), mediaFiles);await announceMessage(room, user, Number(r.lastInsertRowid), message, now);return Number(r.lastInsertRowid);});++ // The files live as long as the message: Media keeps them with it (the kept window, not the 24 hours++ // an unsent file has). A file that cannot be kept takes the message with it, so a room never shows a file++ // that is already gone.++ for (const att of mediaFiles) {++ let kept = false;++ try { kept = await mediaAttachments.keepAttachment(att.id, user.subject_id, String(id)); }++ catch (err) { console.warn('[Chat] could not keep the attachment ' + att.id + ': ' + err.message); }++ if (kept) continue;++ await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, id]);++ await messageAttachments.dropRows(id);++ if (room.visibility === 'public') await announceDeleted(room, [id]);++ fail(502, 'rooms.attachment_keep_failed', 'One of the attached files could not be kept with the message');++ }const row = await messageById(id);if (pending) linkCards.fillLater('room_messages', id, pending, message);// General address previews are a background job: the message and its response never wait on another site.@@@ -415,6 +466,59 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null, cardreturn await messageById(id);}++/**++ * A video whose web copy Media was still making when the message was posted: ask Media again when the room++ * is read, and keep the answer once it is ready (the room draws a player only for a ready copy). At most six a++ * read, each asked again no sooner than 30 s, never longer than 1.5 s: a slow Media leaves the link as it is.++ */++const askedAt = new Map();++async function freshenPlayback(rows) {++ const now = Date.now();++ const due = [];++ for (const row of rows) {++ if (due.length >= 6) break;++ if (row.kind !== 'video' || row.playback_ready || !messageAttachments.ATTACHMENT_ID.test(String(row.attachment_id || '')) || !row.owner_subject) continue;++ if (now - (askedAt.get(row.attachment_id) || 0) < 30_000) continue;++ askedAt.set(row.attachment_id, now);++ due.push(row);++ }++ if (askedAt.size > 5000) { for (const [k, at] of askedAt) if (now - at > 60_000) askedAt.delete(k); }++ await Promise.all(due.map(async (row) => {++ let att = null;++ try {++ att = await Promise.race([mediaAttachments.attachment(row.attachment_id, row.owner_subject), new Promise((r) => setTimeout(() => r(null), 1500))]);++ } catch { att = null; }++ const pb = att && att.playback;++ if (!pb || !pb.ready || typeof pb.url !== 'string') return;++ const url = messageAttachments.mediaUrl(pb.url);++ if (!url) return;++ row.playback_url = url; row.playback_mime = pb.mime || 'video/mp4'; row.playback_ready = 1;++ const poster = messageAttachments.mediaUrl(att.poster);++ if (poster) row.poster = poster;++ try {++ await db.run('UPDATE room_message_attachments SET playback_url = ?, playback_mime = ?, playback_ready = 1, poster = COALESCE(?, poster) WHERE message_id = ? AND attachment_id = ?',++ [row.playback_url, row.playback_mime, poster || null, row.message_id, row.attachment_id]);++ } catch { /* the next read asks again */ }++ }));++}++++/**++ * The files a message carries, on the message itself (media_attachments): what a reader draws them from.++ * Only a read asks Media again (a message just posted has the answer Media gave a moment ago).++ */++async function attachMedia(rows, { freshen = false } = {}) {++ const list = (Array.isArray(rows) ? rows : [rows]).filter(Boolean);++ if (!list.length) return;++ const map = await messageAttachments.rowsFor(list.map((r) => Number(r.id) || 0));++ const all = [];++ for (const row of list) for (const stored of (map.get(Number(row.id)) || [])) all.push(stored);++ // Only a read asks Media again: a message just posted has the answer Media gave a moment ago.++ if (freshen) await freshenPlayback(all);++ for (const row of list) {++ row.media_attachments = (map.get(Number(row.id)) || []).map((stored) => messageAttachments.publicReference(stored)).filter(Boolean);++ }++}++// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model, m.embeds_hidden,CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,@@@ -428,6 +532,7 @@ async function messageById(id) {const row = await db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null;if (row) {row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));++ await attachMedia([row]);await linkCards.hydrate([row], 'room_messages');const setting = await db.get('SELECT link_previews FROM rooms WHERE id = ?', [row.room_id]);await linkPreviews.attach([row], 'room', { enabled: !setting || setting.link_previews !== false });@@@ -445,6 +550,7 @@ async function historyPage(room, { before = null, limit = 50 } = {}) {const rows = await db.all(`${MSG_SELECT} WHERE ${where} ORDER BY m.id DESC LIMIT ?`, params);const more = rows.length > lim;rows.length = Math.min(rows.length, lim);++ await attachMedia(rows, { freshen: true });await linkCards.hydrate(rows, 'room_messages');await linkPreviews.attach(rows, 'room', { enabled: room.link_previews !== false });return { messages: rows, more };@@@ -459,6 +565,7 @@ async function history(room, { before = null, after = null, limit = 60 } = {}) {? await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim]): await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);for (const row of rows) row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));++ await attachMedia(rows, { freshen: true });await linkCards.hydrate(rows, 'room_messages');await linkPreviews.attach(rows, 'room', { enabled: room.link_previews !== false });return after != null ? rows : rows.reverse();@@@ -475,6 +582,20 @@ async function markRead(room, user, lastId) {await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);}++/**++ * The files a message carried, removed: Media is asked to delete them (the person who uploaded them is++ * named, so Media knows whose file this is) and the rows go with the message.++ */++async function dropAttachments(messageId) {++ let rows = [];++ try { rows = await messageAttachments.rowsOf(messageId); } catch { rows = []; }++ for (const row of rows) {++ try { await mediaAttachments.deleteAttachment(row.attachment_id, row.owner_subject); }++ catch (err) { console.warn('[Chat] could not remove the attachment ' + row.attachment_id + ': ' + err.message); }++ }++ try { await messageAttachments.dropRows(messageId); } catch { /* the rows go with the message anyway */ }++}++async function deleteMessage(room, user, messageId) {const m = await db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');@@@ -485,6 +606,8 @@ async function deleteMessage(room, user, messageId) {await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);if (room.visibility === 'public') await announceDeleted(room, [m.id]);});++ // The files the message carried go with it: Media keeps nothing for a message that is gone.++ await dropAttachments(m.id);if (!own) await db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });return m.id;}@@@ -222,6 +222,8 @@ router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {const message = await rooms.post(room, req.user, command.handled ? command.text : req.body && req.body.message, {timezone: req.body && req.body.timezone,files: require('./files').attachmentsFromBody(req.body),++ // The files Media stored for the sender (the room's box): at most four, each one theirs.++ attachments: require('./attachments').namedAttachments(req.body && req.body.attachments),reply_to_id: req.body && req.body.reply_to_id,});chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });@@@ -117,7 +117,8 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat', partyPro* on Vibe's chat pages: the agent's panel beside the chat, plan HO9), appScript and appStyle (the app's own* script and stylesheet instead of vibe.js and vibe.css: inbox.js and inbox.css), feedView* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of-- * public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;++ * public/web's scripts by name, e.g. support.js), player (Shared's player.css + player.js: the network's video++ * player, for the pages that carry a video), footer ('full', the default, on front pages, directories and docs;* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),* inlineStyles (CSS the page needs and the site's stylesheet cannot hold: the rule that shows the picked block of a* pick block is written for the page, because there is one per block), site ({ name, service, links }: a page served@@@ -173,6 +174,7 @@ ${o.kit ? `<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}${o.app || o.appStyle ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}++${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>` : ''}${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}${o.script || o.app ? `<script src="${asset('agent-run-card.js')}" defer></script>` : ''}${String(o.body || '').includes('id="oc-compose"') || o.app ? `<script src="${ovServe.url('messages.js')}" defer></script>` : ''}@@@ -944,7 +944,10 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a? `<span class="oc-card-hidden-note">Previews hidden</span>${mayHide ? ui.showForm(hideAction) : ''}`: ui.messageCards(m, { hide: hideAction });const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;-- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${cards}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;++ // The files the message carries (Media's composer attachments): a video plays, a picture shows, a sound++ // gets a bar, a file is a link.++ const files = ui.messageAttachments(m);++ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${files}${cards}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;};const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);@@@ -1072,7 +1075,9 @@ ${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 = shell.composer({ action: `/r/${room.slug}`, label: `Message to ${room.name}`, placeholder: room.kind === 'system' ? 'Post an announcement' : 'Write a message', add: room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`) });++ // A room's box takes files: they go to Media's composer attachments as they are picked (any video,++ // picture, sound, PDF or text, at Media's limits) and the room's server keeps them with the message.++ else if (can.post) composer = shell.composer({ action: `/r/${room.slug}`, label: `Message to ${room.name}`, placeholder: room.kind === 'system' ? 'Post an announcement' : 'Write a message', add: room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`), media: true });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>`;@@@ -1106,7 +1111,7 @@ ${people}const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;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, feedView: !!feed,-- footer: 'none', shell: true, upload: pictures.enabled(), scripts: [...(ais.length ? ['vibe-md.js'] : []), ...(pictures.enabled() ? ['picture-upload.js'] : [])],++ footer: 'none', shell: true, upload: pictures.enabled(), player: true, scripts: [...(ais.length ? ['vibe-md.js'] : []), ...(pictures.enabled() ? ['picture-upload.js'] : [])],description,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, historyMore: roomPage.more, 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 } } : {}) },@@@ -1142,7 +1147,10 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,const room = await rooms.bySlug(req.params.slug);if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');req.roomBack = `/r/${room.slug}`;-- const message = await rooms.post(room, actor.user, req.body && req.body.message);++ // The form may name the files Media stored for the sender (the room's box), one plain att_ id per file.++ const message = await rooms.post(room, actor.user, req.body && req.body.message, {++ attachments: require('../rooms/attachments').namedAttachments(req.body && req.body.attachments),++ });require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });res.redirect(303, `/r/${room.slug}#oc-compose`);}));@@@ -230,7 +230,10 @@ ${youBar(o.you || {})}`;function composer(o = {}) {const questHook = o.action === '/send' ? ' data-ov-guide="chat.message"' : '';const add = o.add || '';-- return `<div class="oc-dock${add ? ' has-add' : ''}"><form class="oc-compose" method="post" action="${esc(o.action || '')}" id="oc-compose" data-ovc-compose data-ovc-preset="chat"><label for="oc-input" class="oc-sr">${esc(o.label || 'Message')}</label>++ // 4.247.0 (plan §Z.9): a room's box sends the files it takes to Media's composer attachments as they are++ // picked (any video, picture, sound, PDF or text, at Media's limits) and puts their ids on the form.++ const media = o.media ? ' data-ovc-media="1" data-ovc-max-files="4"' : '';++ return `<div class="oc-dock${add ? ' has-add' : ''}"><form class="oc-compose" method="post" action="${esc(o.action || '')}" id="oc-compose" data-ovc-compose data-ovc-preset="chat"${media}><label for="oc-input" class="oc-sr">${esc(o.label || 'Message')}</label><div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden></div><div class="oc-compose-row">${add ? `<button type="button" class="oc-compose-add" data-oc-attach aria-expanded="false" aria-controls="oc-add-panel" aria-label="Add a picture" title="Add a picture">${IC.plus}</button>` : ''}<textarea id="oc-input" name="message" maxlength="${Number(o.max) || 2000}" rows="1" required placeholder="${esc(o.placeholder || 'Write a message')}"></textarea><button type="submit" class="oc-send"${questHook} aria-label="Send" title="Send">${IC.send}</button></div></div></form>${add}</div>`;}@@@ -192,8 +192,60 @@ function messageCards(m, opts) {return m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m.card);}++/**++ * An address a message's file may point at: https on openvibe.media, under /a/ (the bytes Media serves).++ * Anything else — another host, another scheme, another path — is no address at all.++ */++function mediaUrl(value) {++ try {++ const u = new URL(String(value || ''));++ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;++ if (u.hostname.toLowerCase() !== 'openvibe.media') return null;++ if (!u.pathname.startsWith('/a/')) return null;++ return u.href;++ } catch { return null; }++}++++/**++ * One file a message carries (Media's composer attachments): a video plays in the network's player (Media's++ * web copy, its poster, its size), a video whose copy is still being made shows its first frame and a link to++ * the file, a picture shows, a sound gets a bar, a file is a link. Every string is data: escaped here.++ */++function mediaAttachment(a) {++ if (!a || typeof a !== 'object') return '';++ const url = mediaUrl(a.url);++ if (!url) return '';++ const poster = mediaUrl(a.poster);++ const type = String(a.media_type || '');++ const kind = a.kind || (type.startsWith('video/') ? 'video' : type.startsWith('image/') ? 'image' : type.startsWith('audio/') ? 'audio' : 'file');++ const title = esc(a.name || (kind === 'video' ? 'Video' : kind === 'image' ? 'Picture' : kind === 'audio' ? 'Sound' : 'Attached file'));++ const w = Number(a.width) || 0, h = Number(a.height) || 0;++ const size = w > 0 && h > 0 && w <= 10000 && h <= 10000 ? ` width="${Math.round(w)}" height="${Math.round(h)}"` : '';++ if (kind === 'video') {++ const pb = a.playback && a.playback.ready ? mediaUrl(a.playback.url) : null;++ const secs = Number(a.duration_s);++ const length = Number.isFinite(secs) && secs > 0 ? `${Math.floor(secs / 60)}:${String(Math.round(secs % 60)).padStart(2, '0')}` : '';++ if (!pb) {++ return `<a class="oc-attach-item oc-attach-video is-preparing" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${poster ? `<img src="${esc(poster)}" alt="" loading="lazy"${size}>` : ''}<span>Getting the video ready to play here. Open the file</span></a>`;++ }++ return `<figure class="oc-attach-item oc-attach-video"><video data-ov-player controls preload="none" playsinline src="${esc(pb)}"${poster ? ` poster="${esc(poster)}"` : ''}${size} aria-label="${title}"></video>${length ? `<figcaption>${title} · ${length}</figcaption>` : ''}</figure>`;++ }++ if (kind === 'image') return `<a class="oc-attach-item oc-attach-image" href="${esc(url)}" target="_blank" rel="noopener noreferrer"><img src="${esc(poster || url)}" alt="${title}" loading="lazy" width="160"></a>`;++ if (kind === 'audio') return `<figure class="oc-attach-item oc-attach-audio"><audio controls preload="none" src="${esc(url)}" aria-label="${title}"></audio><figcaption>${title}</figcaption></figure>`;++ return `<a class="oc-attach-item oc-attach-file" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${title}</a>`;++}++++/** The files a message carries (at most four), or nothing. */++function messageAttachments(m) {++ const list = Array.isArray(m && m.media_attachments) ? m.media_attachments : [];++ if (!list.length) return '';++ const items = list.map(mediaAttachment).filter(Boolean);++ if (!items.length) return '';++ return `<div class="oc-attach">${items.join('')}</div>`;++}++const hueOf = (slug) => { let h = 0; for (const c of String(slug)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;--module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards, hideForm, showForm };++module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards, messageAttachments, mediaAttachment, mediaUrl, hideForm, showForm };@@@ -43,6 +43,7 @@ const TABLES = ['dm_link_preview_settings', // 0070: a person's link previews in DMs (on unless they say otherwise)'chat_server_timeouts', 'chat_command_polls', 'chat_command_poll_votes', 'chat_command_reminders', 'chat_registered_commands','room_memory', // 0071: what a room keeps between summaries++ 'room_message_attachments', // 0081: the files a room message carries (Media's composer attachments)];const INDEXES = ['idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',nnew file mode 100644@@@ -0,0 +1,279 @@++'use strict';++/**++ * A room message carries a file Media stored for the sender (composer.attachment@1, plan §Z.9): the room's box++ * sends the file to Media as it is picked, the message names it, Chat asks Media (with its own service token++ * and the sender named) whether it exists, is the sender's and may be carried, keeps it with the message and++ * stores what Media answered. The room then draws it: a video in the network's player, a picture, a sound, a++ * file link — never an address off openvibe.media.++ *++ * Another person's file is a 404, exactly like one that is not there; a file nobody may carry is refused; a++ * video whose web copy Media is still making is asked again on a read, so it plays as soon as it is there.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('room-message-attachments');++let h, ann, bob, media;++const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });++const page = async (path, u) => await fetch(`${h.base}${path}`, { headers: { cookie: `ov_token=${u.token}` } }).then((r) => r.text());++// A room of its own for every step: a room's rate limit (six messages in ten seconds) would stop the next step.++const newRoom = async (name) => {++ const r = await api('POST', '/', ann, { name });++ assert.strictEqual(r.status, 201, r.text);++ return r.body.room.slug;++};++++t('boot', async () => {++ h = await boot();++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));++ // The stand-in for OpenVibe.Media: the composer attachments, the person each belongs to, and the keeps.++ const files = new Map();++ const keeps = [];++ const deletes = [];++ const calls = [];++ const attachments = require('../server/media/attachments');++ attachments._setFetch(async (url, opts) => {++ const u = new URL(url);++ const headers = opts.headers || {};++ const subject = headers['X-OV-Subject'] || headers['x-ov-subject'] || null;++ const auth = headers.authorization || headers.Authorization || '';++ const m = /^\/api\/v1\/attachments\/(att_[0-9A-HJKMNP-TV-Z]{26})(\/keep)?$/.exec(u.pathname);++ calls.push({ method: opts.method, path: u.pathname, subject, auth });++ const answer = (status, body) => ({ ok: status < 400, status, json: async () => body });++ if (!m) return answer(404, { detail: 'not found' });++ const one = files.get(m[1]);++ if (!one || (subject && one.subject !== subject)) return answer(404, { code: 'media.attachment.not_found', detail: 'no such attachment for this person' });++ if (opts.method === 'DELETE') { files.delete(m[1]); deletes.push(m[1]); return answer(204, {}); }++ if (opts.method === 'POST' && m[2] === '/keep') {++ const body = JSON.parse(opts.body || '{}');++ keeps.push({ id: m[1], subject, message_id: body.message_id });++ return answer(200, { id: m[1], message_id: body.message_id, kept_for: [body.message_id] });++ }++ return answer(200, {++ id: one.id, kind: one.kind, name: one.name, size: one.size, mime: one.mime, url: one.url,++ poster: one.poster || null, playback: one.playback || null,++ duration_s: one.duration_s != null ? one.duration_s : null,++ width: one.width != null ? one.width : null, height: one.height != null ? one.height : null,++ });++ });++ media = { files, keeps, deletes, calls, add: (a) => { files.set(a.id, a); return a; } };++});++++t('the kinds a message may carry, and at most four files', async () => {++ const room = await newRoom('Owls One');++ const kinds = [++ { kind: 'video', name: 'clip.mp4', mime: 'video/mp4', url: 'https://openvibe.media/a/att_x/clip.mp4' },++ { kind: 'image', name: 'look.png', mime: 'image/png', url: 'https://openvibe.media/a/att_y/look.png' },++ { kind: 'audio', name: 'hello.mp3', mime: 'audio/mpeg', url: 'https://openvibe.media/a/att_z/hello.mp3' },++ { kind: 'file', name: 'notes.txt', mime: 'text/plain', url: 'https://openvibe.media/a/att_w/notes.txt' },++ ];++ const picked = [];++ for (const one of kinds) {++ const id = `att_${ids.ulid()}`;++ picked.push(id);++ media.add({ id, subject: ann.subject_id, size: 12, poster: null, playback: null, ...one });++ }++ const r = await api('POST', `/${room}/messages`, ann, { message: 'here they are', attachments: picked.map((id) => ({ media_attachment_id: id })) });++ assert.strictEqual(r.status, 201, r.text);++ const files = r.body.message.media_attachments;++ assert.strictEqual(files.length, 4, 'four files go with one message');++ assert.deepStrictEqual(files.map((f) => f.kind).sort(), ['audio', 'file', 'image', 'video']);++ // A file nobody may carry (code, an executable) is refused.++ const code = `att_${ids.ulid()}`;++ media.add({ id: code, subject: ann.subject_id, kind: 'code', name: 'run.sh', mime: 'text/x-sh', size: 12, url: `https://openvibe.media/a/${code}/run.sh` });++ const refused = await api('POST', `/${room}/messages`, ann, { message: 'no', attachments: [{ media_attachment_id: code }] });++ assert.strictEqual(refused.status, 415, refused.text);++ assert.strictEqual(refused.body.code, 'rooms.attachment_type');++ // Five files are too many.++ const five = [];++ for (let i = 0; i < 5; i++) {++ const id = `att_${ids.ulid()}`;++ five.push(id);++ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'p.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/p.png` });++ }++ const many = await api('POST', `/${room}/messages`, ann, { message: 'many', attachments: five.map((id) => ({ media_attachment_id: id })) });++ assert.strictEqual(many.status, 400, many.text);++ assert.strictEqual(many.body.code, 'rooms.attachments_too_many');++});++++t("another person's file is refused, exactly like one that is not there", async () => {++ const room = await newRoom('Owls Two');++ const id = `att_${ids.ulid()}`;++ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'mine.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/mine.png` });++ await api('POST', `/${room}/join`, bob);++ const other = await api('POST', `/${room}/messages`, bob, { message: 'not mine', attachments: [{ media_attachment_id: id }] });++ assert.strictEqual(other.status, 404, other.text);++ const missing = await api('POST', `/${room}/messages`, ann, { message: 'nobody\'s', attachments: [{ media_attachment_id: `att_${ids.ulid()}` }] });++ assert.strictEqual(missing.status, 404, missing.text);++ assert.strictEqual(missing.body.code, other.body.code, 'the same answer either way');++});++++t('the file is kept with the message, in Media', async () => {++ const room = await newRoom('Owls Three');++ const id = `att_${ids.ulid()}`;++ media.add({ id, subject: ann.subject_id, kind: 'video', name: '7224.mp4', mime: 'video/mp4', size: 24819344,++ url: `https://openvibe.media/a/${id}/7224.mp4`, poster: `https://openvibe.media/a/${id}/thumb.jpg`,++ playback: { url: `https://openvibe.media/a/${id}/playback.mp4`, mime: 'video/mp4', ready: true },++ duration_s: 73.4, width: 1920, height: 1080 });++ const r = await api('POST', `/${room}/messages`, ann, { message: 'here it is', attachments: [{ media_attachment_id: id }] });++ assert.strictEqual(r.status, 201, r.text);++ const file = r.body.message.media_attachments[0];++ assert.strictEqual(file.id, id);++ assert.strictEqual(file.kind, 'video');++ assert.strictEqual(file.media_type, 'video/mp4');++ assert.strictEqual(file.size, 24819344);++ assert.deepStrictEqual(file.playback, { url: `https://openvibe.media/a/${id}/playback.mp4`, mime: 'video/mp4', ready: true });++ assert.strictEqual(file.poster, `https://openvibe.media/a/${id}/thumb.jpg`);++ assert.strictEqual(file.duration_s, 73.4);++ assert.deepStrictEqual([file.width, file.height], [1920, 1080]);++ const keep = media.keeps.find((k) => k.id === id);++ assert.ok(keep, 'Media was asked to keep the file');++ assert.strictEqual(keep.subject, ann.subject_id, 'as the sender');++ assert.strictEqual(keep.message_id, String(r.body.message.id), 'it is kept with the message');++ const row = await h.db.get('SELECT * FROM room_message_attachments WHERE message_id = ? AND attachment_id = ?', [r.body.message.id, id]);++ assert.strictEqual(row.kind, 'video');++ assert.strictEqual(Number(row.playback_ready), 1);++ // The service token Chat used carries the capability Media asks for, and the sender is named.++ const call = media.calls.find((c) => c.path === `/api/v1/attachments/${id}` && c.method === 'GET');++ assert.ok(call, 'Media was asked about the file');++ assert.match(String(call.auth), /^Bearer /, 'with a service token');++ assert.strictEqual(call.subject, ann.subject_id, 'naming the sender');++});++++t('a video whose web copy became ready plays on the next read', async () => {++ const room = await newRoom('Owls Four');++ const id = `att_${ids.ulid()}`;++ const one = media.add({ id, subject: ann.subject_id, kind: 'video', name: 'clip.mkv', mime: 'video/x-matroska', size: 900,++ url: `https://openvibe.media/a/${id}/clip.mkv`, poster: `https://openvibe.media/a/${id}/thumb.jpg`,++ playback: { url: `https://openvibe.media/a/${id}/clip.mkv`, mime: 'video/x-matroska', ready: false } });++ const r = await api('POST', `/${room}/messages`, ann, { message: 'still copying', attachments: [{ media_attachment_id: id }] });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.message.media_attachments[0].playback.ready, false, 'the copy is not there yet');++ // Media finishes the copy.++ one.playback = { url: `https://openvibe.media/a/${id}/playback.mp4`, mime: 'video/mp4', ready: true };++ const list = await api('GET', `/${room}/messages`, ann);++ const found = list.body.messages.find((m) => m.message === 'still copying');++ assert.deepStrictEqual(found.media_attachments[0].playback, { url: `https://openvibe.media/a/${id}/playback.mp4`, mime: 'video/mp4', ready: true });++ const row = await h.db.get('SELECT playback_ready, playback_url FROM room_message_attachments WHERE message_id = ? AND attachment_id = ?', [found.id, id]);++ assert.strictEqual(Number(row.playback_ready), 1, 'kept, so the next read does not ask again');++ assert.strictEqual(row.playback_url, `https://openvibe.media/a/${id}/playback.mp4`);++});++++t("the room's box asks Media for its files", async () => {++ const room = await newRoom('Owls Five');++ const html = await page(`/r/${room}`, ann);++ assert.match(html, /data-ovc-media="1"/, 'the box sends its files to Media');++ assert.match(html, /data-ovc-max-files="4"/, 'at most four');++});++++t('a ready video draws the player, a copy still being made draws the link', async () => {++ const room = await newRoom('Owls Six');++ const ready = `att_${ids.ulid()}`;++ media.add({ id: ready, subject: ann.subject_id, kind: 'video', name: 'ready.mp4', mime: 'video/mp4', size: 900,++ url: `https://openvibe.media/a/${ready}/ready.mp4`, poster: `https://openvibe.media/a/${ready}/thumb.jpg`,++ playback: { url: `https://openvibe.media/a/${ready}/playback.mp4`, mime: 'video/mp4', ready: true },++ duration_s: 12, width: 1280, height: 720 });++ const waiting = `att_${ids.ulid()}`;++ media.add({ id: waiting, subject: ann.subject_id, kind: 'video', name: 'waiting.mkv', mime: 'video/x-matroska', size: 900,++ url: `https://openvibe.media/a/${waiting}/waiting.mkv`, poster: `https://openvibe.media/a/${waiting}/thumb.jpg`,++ playback: { url: `https://openvibe.media/a/${waiting}/waiting.mkv`, mime: 'video/x-matroska', ready: false } });++ const r = await api('POST', `/${room}/messages`, ann, { message: 'watch this', attachments: [{ media_attachment_id: ready }, { media_attachment_id: waiting }] });++ assert.strictEqual(r.status, 201, r.text);++ const html = await page(`/r/${room}`, ann);++ assert.match(html, /<video data-ov-player[^>]*src="https:\/\/openvibe\.media\/a\/[^"]*\/playback\.mp4"/, 'the ready copy plays in the network\'s player');++ assert.ok(html.includes(`poster="https://openvibe.media/a/${ready}/thumb.jpg"`), 'the poster is its first frame');++ assert.match(html, /is-preparing/, 'a copy still being made is a link');++ assert.ok(html.includes(`href="https://openvibe.media/a/${waiting}/waiting.mkv"`), 'the link is the file itself');++ assert.match(html, /Getting the video ready to play here/, 'and it says so');++});++++t('nothing is drawn off openvibe.media', async () => {++ const room = await newRoom('Owls Seven');++ const id = `att_${ids.ulid()}`;++ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'elsewhere.png', mime: 'image/png', size: 3, url: 'https://evil.example/a/elsewhere.png' });++ const r = await api('POST', `/${room}/messages`, ann, { message: 'not here', attachments: [{ media_attachment_id: id }] });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(r.body.message.media_attachments, [], 'an address off openvibe.media is no address');++ const html = await page(`/r/${room}`, ann);++ assert.ok(!html.includes('evil.example'), 'the room never points at another host');++});++++t('the files go when the message is deleted', async () => {++ const room = await newRoom('Owls Eight');++ const id = `att_${ids.ulid()}`;++ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'gone.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/gone.png` });++ const r = await api('POST', `/${room}/messages`, ann, { message: 'delete me', attachments: [{ media_attachment_id: id }] });++ assert.strictEqual(r.status, 201, r.text);++ const del = await api('DELETE', `/${room}/messages/${r.body.message.id}`, ann);++ assert.strictEqual(del.status, 200, del.text);++ assert.ok(media.deletes.includes(id), 'Media was asked to remove the file');++ const row = await h.db.get('SELECT * FROM room_message_attachments WHERE message_id = ?', [r.body.message.id]);++ assert.ok(!row, 'and the room keeps nothing of it');++});++++t('the room hears a message with its files over the socket', async () => {++ const room = await newRoom('Owls Nine');++ const id = `att_${ids.ulid()}`;++ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'live.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/live.png` });++ const annWs = await h.ws({ token: ann.token });++ annWs.sendJson({ type: 'join_room', room });++ await annWs.next((m) => m.type === 'room_joined');++ annWs.sendJson({ type: 'room_message', message: 'over the socket', attachments: [id] });++ const got = await annWs.next((m) => m.type === 'room_message' && m.message.message === 'over the socket');++ assert.strictEqual(got.message.media_attachments.length, 1);++ assert.strictEqual(got.message.media_attachments[0].id, id);++});++++t('the plain form may name the files too', async () => {++ const room = await newRoom('Owls Ten');++ const id = `att_${ids.ulid()}`;++ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'form.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/form.png` });++ const body = new URLSearchParams();++ body.set('message', 'from the form');++ body.append('attachments', id);++ const r = await fetch(`${h.base}/r/${room}`, { method: 'POST', redirect: 'manual', headers: { cookie: `ov_token=${ann.token}`, 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() });++ assert.strictEqual(r.status, 303, await r.text());++ const list = await api('GET', `/${room}/messages`, ann);++ const found = list.body.messages.find((m) => m.message === 'from the form');++ assert.ok(found, 'the message is there');++ assert.strictEqual(found.media_attachments[0].id, id, 'with its file');++});++t('the body\'s list of files is read as a list of Media attachments', () => {++ const { namedAttachments, allowedKind, mediaUrl } = require('../server/rooms/attachments');++ const one = `att_${ids.ulid()}`;++ const two = `att_${ids.ulid()}`;++ assert.deepStrictEqual(namedAttachments(one), [{ media_attachment_id: one }]);++ assert.deepStrictEqual(namedAttachments([one, two]), [{ media_attachment_id: one }, { media_attachment_id: two }]);++ assert.deepStrictEqual(namedAttachments(JSON.stringify([{ media_attachment_id: one }])), [{ media_attachment_id: one }]);++ assert.deepStrictEqual(namedAttachments(''), []);++ assert.deepStrictEqual(namedAttachments(undefined), []);++ assert.throws(() => namedAttachments('not json'), { status: 400, code: 'rooms.attachment_invalid' });++ // The kinds a message may carry: a picture, a video, a sound, and a file that is a PDF or text.++ assert.strictEqual(allowedKind('video', 'video/mp4'), 'video');++ assert.strictEqual(allowedKind('image', 'image/png'), 'image');++ assert.strictEqual(allowedKind('audio', 'audio/mpeg'), 'audio');++ assert.strictEqual(allowedKind('file', 'application/pdf'), 'file');++ assert.strictEqual(allowedKind('file', 'text/plain'), 'file');++ assert.strictEqual(allowedKind('file', 'application/octet-stream'), null);++ assert.strictEqual(allowedKind('code', 'text/x-sh'), null);++ // Only an address on openvibe.media, under /a/, is ever used.++ assert.strictEqual(mediaUrl(`https://openvibe.media/a/${one}/clip.mp4`), `https://openvibe.media/a/${one}/clip.mp4`);++ assert.strictEqual(mediaUrl('https://evil.example/a/clip.mp4'), null);++ assert.strictEqual(mediaUrl('http://openvibe.media/a/clip.mp4'), null);++ assert.strictEqual(mediaUrl('https://openvibe.media/o/med_x'), null);++});++++t("the room's box is mounted with Media's files", () => {++ const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'composer.js'), 'utf8');++ assert.match(source, /data-ovc-media/, 'the box asks for Media\'s files');++ assert.match(source, /media: !!media/, 'and the shared composer is mounted with them');++});++t.run(async () => {++ try { require('../server/media/attachments')._setFetch(null); } catch { /* */ }++ await h.close();++});
@@ -17,7 +17,10 @@ build step**.
@@ -17,7 +17,10 @@ build step**.
## What it owns
## What it owns
- Rooms: global, stream, channel, community, DM/group DM, call, system; servers (groups of rooms as
- Rooms: global, stream, channel, community, DM/group DM, call, system; servers (groups of rooms as
- text and voice channels, with roles and invites).
+ text and voice channels, with roles and invites). A room message may carry up to four files the person
+ picked in the room's box: they go to OpenVibe.Media's composer attachments (composer.attachment@1) as
+ they are picked, Chat checks them with Media (owner-checked), keeps each with the message and draws them
+ (a video in the network's player, a picture, a sound, a file link).
- Messages, replies/rich payloads, edit history, membership/roles, unread/read state, presence and
- Messages, replies/rich payloads, edit history, membership/roles, unread/read state, presence and
typing (ephemeral).
typing (ephemeral).
- Moderation state: bans, timeouts, delete/purge, filters, slow/member-only modes, the moderation
- Moderation state: bans, timeouts, delete/purge, filters, slow/member-only modes, the moderation
@@ -88,7 +91,9 @@ tests use embedded PGlite, PostgreSQL in production through PgBouncer. Migration
@@ -88,7 +91,9 @@ tests use embedded PGlite, PostgreSQL in production through PgBouncer. Migration
`migrations/NNNN_*.sql`, applied at boot with the owner connection (`DATABASE_DIRECT_URL`), then the
`migrations/NNNN_*.sql`, applied at boot with the owner connection (`DATABASE_DIRECT_URL`), then the
app serves through the pooled runtime role (`DATABASE_URL`, DML only). Never edit an applied
app serves through the pooled runtime role (`DATABASE_URL`, DML only). Never edit an applied
migration. The moved tables keep Live's ids and columns plus Network subject columns
migration. The moved tables keep Live's ids and columns plus Network subject columns
-(`subject_id`, `sender_subject_id`, …). `docs/parity.md` records the parity method.
+(`subject_id`, `sender_subject_id`, …). `docs/parity.md` records the parity method. The files a room
+message carries live in Media, not here: `room_message_attachments` keeps what Media answered (the
+kind, the type, the poster, the playback, the length, the size) beside the message.
## Auth and the WebSocket rules
## Auth and the WebSocket rules
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+-- phase: expand
+-- The files a room message carries from OpenVibe.Media's composer attachments (composer.attachment@1): a video, a
+-- picture, a sound, a PDF or a text file, picked in the room's box and sent with the message. Media owns the bytes
+-- and answers what plays it, so the message keeps the kind, the type, the poster, the playback (url, mime, ready),
+-- the length and the size after the rotation is applied. The bytes never live in Chat's database.
+CREATE TABLE room_message_attachments (
+ message_id bigint NOT NULL REFERENCES room_messages(id) ON DELETE CASCADE,
+ attachment_id text COLLATE "C" NOT NULL,
+ position integer NOT NULL DEFAULT 0,
+ owner_subject text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ mime text COLLATE "C" NOT NULL,
+ size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('image', 'video', 'audio', 'file')),
+ url text COLLATE "C",
+ poster text COLLATE "C",
+ playback_url text COLLATE "C",
+ playback_mime text COLLATE "C",
+ playback_ready integer NOT NULL DEFAULT 0,
+ duration_s double precision,
+ width bigint,
+ height bigint,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (message_id, attachment_id)
+);
+CREATE INDEX room_message_attachments_id ON room_message_attachments(attachment_id);
+CREATE INDEX room_message_attachments_message ON room_message_attachments(message_id, position);
@@ -290,6 +290,18 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -290,6 +290,18 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-card-go { align-self: flex-start; margin-top: 6px; padding: 4px 10px; border-radius: 999px; background: var(--oc-accent); color: var(--oc-on-accent); text-decoration: none; font-weight: 600; font-size: .86em; }
.oc-card-go { align-self: flex-start; margin-top: 6px; padding: 4px 10px; border-radius: 999px; background: var(--oc-accent); color: var(--oc-on-accent); text-decoration: none; font-weight: 600; font-size: .86em; }
.oc-card-go:hover { filter: brightness(1.08); }
.oc-card-go:hover { filter: brightness(1.08); }
.ovt-bubble .oc-card, .ovt-reply .oc-card { margin-top: 8px; }
.ovt-bubble .oc-card, .ovt-reply .oc-card { margin-top: 8px; }
+/* The files a message carries (Media's composer attachments, plan §Z.9): a video in the network's player, a
+ picture, a sound bar, a file link. Quiet and small: the message stays the thing that was said. */
+.oc-attach { display: flex; flex-direction: column; gap: 6px; max-width: 420px; margin: 6px 0 2px; }
+.oc-attach-item { display: block; margin: 0; color: var(--oc-text); }
+.oc-attach-video video { display: block; width: 100%; max-width: 420px; border-radius: 12px; background: #000; }
+.oc-attach-video figcaption, .oc-attach-audio figcaption { margin-top: 4px; color: var(--oc-muted); font-size: .86em; line-height: 1.4; overflow-wrap: anywhere; }
+.oc-attach-video.is-preparing { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel-2); color: var(--oc-muted); font-size: .88em; text-decoration: none; }
+.oc-attach-video.is-preparing img { display: block; width: 100%; max-width: 400px; border-radius: 8px; background: var(--oc-panel); }
+.oc-attach-image img { display: block; width: auto; max-width: min(320px, 100%); max-height: 320px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel-2); object-fit: cover; }
+.oc-attach-audio audio { width: 100%; max-width: 380px; height: 40px; }
+.oc-attach-file { display: inline-block; padding: 6px 12px; border: 1px solid var(--oc-line); border-radius: 999px; background: var(--oc-panel-2); color: var(--oc-text); font-size: .9em; text-decoration: none; overflow-wrap: anywhere; }
+.oc-attach-file:hover { border-color: var(--oc-accent); }
.oc-pics { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 2px; }
.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 { 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 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; }
@@ -761,6 +761,85 @@
@@ -761,6 +761,85 @@
}
}
// ── A room ──
// ── A room ──
+ // The files a message carries (Media's composer attachments): a video plays in the network's player (Media's
+ // web copy, its poster, its size), a video whose copy is still being made shows its first frame and a link to
+ // the file, a picture shows, a sound gets a bar, a file is a link. Only https on openvibe.media is ever shown.
+ function mediaUrl(v) {
+ try {
+ var u = new URL(String(v || ''));
+ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;
+ if (u.hostname.toLowerCase() !== 'openvibe.media') return null;
+ if (u.pathname.indexOf('/a/') !== 0) return null;
+ return u.href;
+ } catch (e) { return null; }
+ }
+ function mediaAttachments(list) {
+ var items = Array.isArray(list) ? list : [];
+ if (!items.length) return null;
+ var box = el('div', 'oc-attach');
+ items.forEach(function (a) {
+ var url = mediaUrl(a && a.url);
+ if (!url) return;
+ var poster = mediaUrl(a.poster);
+ var type = String(a.media_type || '');
+ var kind = a.kind || (type.indexOf('video/') === 0 ? 'video' : type.indexOf('image/') === 0 ? 'image' : type.indexOf('audio/') === 0 ? 'audio' : 'file');
+ var title = a.name || (kind === 'video' ? 'Video' : kind === 'image' ? 'Picture' : kind === 'audio' ? 'Sound' : 'Attached file');
+ if (kind === 'video') {
+ var pb = a.playback && a.playback.ready ? mediaUrl(a.playback.url) : null;
+ if (!pb) {
+ var prep = el('a', 'oc-attach-item oc-attach-video is-preparing');
+ prep.href = url; prep.target = '_blank'; prep.rel = 'noopener noreferrer';
+ if (poster) { var first = document.createElement('img'); first.src = poster; first.alt = ''; first.loading = 'lazy'; prep.appendChild(first); }
+ prep.appendChild(el('span', null, 'Getting the video ready to play here. Open the file'));
+ box.appendChild(prep);
+ return;
+ }
+ var fig = el('figure', 'oc-attach-item oc-attach-video');
+ var video = document.createElement('video');
+ video.setAttribute('data-ov-player', ''); video.controls = true; video.preload = 'none'; video.playsInline = true;
+ video.src = pb; if (poster) video.poster = poster;
+ var w = Number(a.width) || 0, h = Number(a.height) || 0;
+ if (w > 0 && h > 0 && w <= 10000 && h <= 10000) { video.width = Math.round(w); video.height = Math.round(h); }
+ video.setAttribute('aria-label', title);
+ fig.appendChild(video);
+ var secs = Number(a.duration_s);
+ if (isFinite(secs) && secs > 0) fig.appendChild(el('figcaption', null, title + ' · ' + Math.floor(secs / 60) + ':' + String(Math.round(secs % 60)).padStart(2, '0')));
+ box.appendChild(fig);
+ // The network's player wraps a video that arrives later (Shared's player.js).
+ if (window.OpenVibePlayer && window.OpenVibePlayer.enhance) { try { window.OpenVibePlayer.enhance(video); } catch (e) { /* the browser's own controls stay */ } }
+ return;
+ }
+ if (kind === 'image') {
+ var link = el('a', 'oc-attach-item oc-attach-image'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer';
+ var img = document.createElement('img'); img.src = poster || url; img.alt = title; img.loading = 'lazy'; img.width = 160;
+ link.appendChild(img); box.appendChild(link); return;
+ }
+ if (kind === 'audio') {
+ var afig = el('figure', 'oc-attach-item oc-attach-audio');
+ var audio = document.createElement('audio'); audio.controls = true; audio.preload = 'none'; audio.src = url; audio.setAttribute('aria-label', title);
+ afig.appendChild(audio); afig.appendChild(el('figcaption', null, title)); box.appendChild(afig); return;
+ }
+ var flink = el('a', 'oc-attach-item oc-attach-file', title); flink.href = url; flink.target = '_blank'; flink.rel = 'noopener noreferrer';
+ box.appendChild(flink);
+ });
+ return box.childNodes.length ? box : null;
+ }
+ // The files the box picked: Media stored them and the form carries their ids (one hidden field per file).
+ function pickedFiles(form) {
+ var ids = [];
+ if (!form) return ids;
+ Array.prototype.forEach.call(form.querySelectorAll('input[name="attachments"]'), function (input) {
+ var v = String(input.value || '').trim();
+ if (/^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(v) && ids.indexOf(v) < 0) ids.push(v);
+ });
+ return ids;
+ }
+ // The files the box picked, put back on the tray (they travel with the message).
+ function clearPickedFiles(form) {
+ var c = form && form.__ovcComposer;
+ if (c && typeof c.clearFiles === 'function') { try { c.clearFiles(); } catch (e) { /* the tray stays */ } }
+ Array.prototype.forEach.call((form || document).querySelectorAll('input[name="attachments"]'), function (input) { input.remove(); });
+ }
function roomItem(m) {
function roomItem(m) {
var ai = m.ai_member_id != null;
var ai = m.ai_member_id != null;
var li = el('li', 'oc-msg' + (ai ? ' oc-msg-ai' : '')); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';
var li = el('li', 'oc-msg' + (ai ? ' oc-msg-ai' : '')); li.dataset.id = m.id || ''; li.dataset.user = m.user_id || '';
@@ -787,6 +866,8 @@
@@ -787,6 +866,8 @@
if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
if (m.edited_at && window.OVMessages) window.OVMessages.edited(t, m.edited_at);
if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }
if (cfg.moderate && m.edited_at) { var versions = el('button', 'ovk-btn ovk-btn--quiet oc-edits', 'Earlier versions'); versions.type = 'button'; li.appendChild(versions); }
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
+ // The files the message carries: a video plays, a picture shows, a sound gets a bar, a file is a link.
+ var files = mediaAttachments(m.media_attachments); if (files) li.appendChild(files);
// Cards that were taken back are not sent with the message: the line says so instead.
// Cards that were taken back are not sent with the message: the line says so instead.
if (m.embeds_hidden) li.appendChild(hiddenNote(li, Number(m.id) || 0));
if (m.embeds_hidden) li.appendChild(hiddenNote(li, Number(m.id) || 0));
else addCard(li, m);
else addCard(li, m);
@@ -896,11 +977,14 @@
@@ -896,11 +977,14 @@
if (form && input) {
if (form && input) {
form.addEventListener('submit', function (e) {
form.addEventListener('submit', function (e) {
var text = input.value.trim();
var text = input.value.trim();
- if (!text) { e.preventDefault(); return; }
+ // The files the box picked travel with the message (the room's server checks them with Media).
+ var files = pickedFiles(form);
+ if (!text && !files.length) { e.preventDefault(); return; }
if (!ws || ws.readyState !== 1) return;
if (!ws || ws.readyState !== 1) return;
e.preventDefault();
e.preventDefault();
- ws.send(JSON.stringify({ type: 'room_message', message: text }));
+ ws.send(JSON.stringify({ type: 'room_message', message: text, attachments: files }));
input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus();
input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus();
+ clearPickedFiles(form);
});
});
}
}
if (feed) feed.addEventListener('submit', function (e) {
if (feed) feed.addEventListener('submit', function (e) {
@@ -13,10 +13,15 @@
@@ -13,10 +13,15 @@
var ta = form.querySelector('textarea');
var ta = form.querySelector('textarea');
if (!ta) return;
if (!ta) return;
var add = form.querySelector('[data-oc-attach]');
var add = form.querySelector('[data-oc-attach]');
+ // 4.247.0 (plan §Z.9): a room's box sends the files it takes to Media's composer attachments as they are
+ // picked (any video, picture, sound, PDF or text, at Media's limits) and puts their ids on the form. The
+ // room's own server checks them with Media and keeps them with the message. A box that says nothing keeps
+ // the plain upload.
+ var media = form.hasAttribute('data-ovc-media');
var c = window.OpenVibeComposer.mount(document.createElement('div'), {
var c = window.OpenVibeComposer.mount(document.createElement('div'), {
preset: 'chat', name: ta.name || 'message', maxLength: Number(ta.getAttribute('maxlength')) || 4000,
preset: 'chat', name: ta.name || 'message', maxLength: Number(ta.getAttribute('maxlength')) || 4000,
placeholder: ta.placeholder || 'Write a message', label: ta.getAttribute('aria-label') || form.getAttribute('aria-label') || 'Message',
placeholder: ta.placeholder || 'Write a message', label: ta.getAttribute('aria-label') || form.getAttribute('aria-label') || 'Message',
- tools: ['emoji'],
+ tools: ['emoji'], media: !!media, maxFiles: media ? 4 : undefined,
});
});
if (!c) return;
if (!c) return;
c.text.id = ta.id || 'oc-input';
c.text.id = ta.id || 'oc-input';
@@ -761,7 +761,10 @@ class ChatServer {
@@ -761,7 +761,10 @@ class ChatServer {
try {
try {
const command = await require('./room-slash').parse(room, client.user, msg.message);
const command = await require('./room-slash').parse(room, client.user, msg.message);
if (command.handled && !command.text) { this.sendTo(ws, { type: 'system', message: command.reply }); break; }
if (command.handled && !command.text) { this.sendTo(ws, { type: 'system', message: command.reply }); break; }
- const message = await rooms.post(room, client.user, command.handled ? command.text : msg.message);
+ const message = await rooms.post(room, client.user, command.handled ? command.text : msg.message, {
+ // The files Media stored for the sender (the room's box): at most four, each one theirs.
+ attachments: require('../rooms/attachments').namedAttachments(msg.attachments),
+ });
this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
this.broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
} catch (err) {
} catch (err) {
this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });
this.sendTo(ws, { type: 'room_error', room: room.slug, code: err.code || 'rooms.error', message: err.code ? err.message : 'Your message could not be sent' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+/**
+ * OpenVibe.Media's composer attachments (composer.attachment@1) — the files a person picks in a box and
+ * sends with a message. Media owns the bytes and the answer; the site keeps the attachment with the
+ * message it belongs to.
+ *
+ * GET /api/v1/attachments/:id where the attachment stands, for the person it belongs to
+ * POST /api/v1/attachments/:id/keep { message_id } — kept with that message (the kept window)
+ * DELETE /api/v1/attachments/:id the person who uploaded it → 204
+ *
+ * Credentials: Chat's own service token (client 'chat', audience openvibe.media, scope
+ * media.object.upload) with the person named in X-OV-Subject. Media checks the owner: an attachment that
+ * is not the named person's is a 404, exactly like one that does not exist.
+ *
+ * The shape Media answers: { id, kind, name, size, mime, url, poster, playback { url, mime, ready },
+ * duration_s, width, height }. A video a browser can play as it is keeps its own bytes; one it cannot
+ * (an MKV, an AVI, an HEVC MOV) gets a web copy made in the background, and until that copy is there
+ * playback.ready is false.
+ *
+ * The same call the link preview job makes for a card's picture (server/chat/media-import.js), with the
+ * read and the delete the files a message carries need.
+ */
+const config = require('../config');
+const serviceAuth = require('../net/service-auth');
+
+const AUDIENCE = 'openvibe.media';
+const TIMEOUT_MS = 8000;
+const ATTACHMENT_ID = /^att_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+let _fetch = null;
+
+/** Media is asked through its internal address; the caller needs a service token to do that. */
+function enabled() {
+ return !!(config.media && config.media.baseUrl && config.oauth.clientSecret);
+}
+
+/** One call to Media: the answer as an object, or a throw with the status Media answered. */
+async function request(method, path, { json, subject } = {}) {
+ if (!enabled()) {
+ const err = new Error('Attachments need Chat\'s service principal (OV_OAUTH_CLIENT_SECRET)');
+ err.status = 503; err.code = 'attachment.unavailable';
+ throw err;
+ }
+ const headers = await serviceAuth.headers(AUDIENCE, config.media.scope);
+ const send = _fetch || globalThis.fetch;
+ const res = await send(config.media.baseUrl + path, {
+ method,
+ headers: {
+ ...headers,
+ accept: 'application/json',
+ ...(json !== undefined ? { 'content-type': 'application/json' } : {}),
+ ...(subject ? { 'X-OV-Subject': String(subject) } : {}),
+ },
+ body: json !== undefined ? JSON.stringify(json) : undefined,
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = new Error('Media ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));
+ err.status = Number(res.status) || 502;
+ err.code = (body && body.code) || null;
+ throw err;
+ }
+ return body || {};
+}
+
+/**
+ * One composer attachment, for the person it belongs to. Media checks the owner: an attachment that is
+ * not the named person's is a 404, exactly like one that does not exist.
+ */
+async function attachment(id, subject) {
+ const att = String(id || '').trim();
+ if (!ATTACHMENT_ID.test(att)) {
+ const err = new Error('That is not a Media attachment id');
+ err.status = 400; err.code = 'attachment.invalid';
+ throw err;
+ }
+ return await request('GET', '/api/v1/attachments/' + encodeURIComponent(att), { subject });
+}
+
+/**
+ * Keep the attachment with the message it belongs to, so it lives as long as that message (Media's kept
+ * window, 30 days) instead of the 24 hours an unsent file has.
+ */
+async function keepAttachment(id, subject, messageId) {
+ const att = String(id || '').trim();
+ const message = String(messageId == null ? '' : messageId).trim();
+ if (!ATTACHMENT_ID.test(att) || !message || message.length > 200) return false;
+ try {
+ await request('POST', '/api/v1/attachments/' + encodeURIComponent(att) + '/keep', { json: { message_id: message }, subject });
+ return true;
+ } catch (err) {
+ if (err && err.status === 404) return false;
+ throw err;
+ }
+}
+
+/**
+ * Remove the attachment from Media: the message it belonged to is gone, so the file goes with it. The
+ * person who uploaded it is named, so Media knows whose file this is.
+ */
+async function deleteAttachment(id, subject) {
+ const att = String(id || '').trim();
+ if (!ATTACHMENT_ID.test(att)) return false;
+ try {
+ await request('DELETE', '/api/v1/attachments/' + encodeURIComponent(att), { subject });
+ return true;
+ } catch (err) {
+ if (err && err.status === 404) return true; // already gone: the message keeps nothing either
+ throw err;
+ }
+}
+
+module.exports = { attachment, keepAttachment, deleteAttachment, enabled, AUDIENCE, ATTACHMENT_ID, _setFetch(fn) { _fetch = fn || null; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+'use strict';
+/**
+ * The files a room message carries from OpenVibe.Media's composer attachments (composer.attachment@1).
+ *
+ * A person picks a file in the room's box; Shared's composer sends it to Media as it is picked and puts
+ * the id (att_…) on the form. The message names that id, Chat asks Media (with its own service token,
+ * naming the sender) whether the file exists, is the sender's and may be carried, keeps it with the
+ * message, and stores what Media answered: the kind, the type, the poster, the playback (url, mime,
+ * ready), the length and the size. A video whose web copy Media is still making is asked again on a read
+ * (freshen), so it plays as soon as the copy is there.
+ *
+ * The bytes live in Media, never in Chat: the row here is what a reader needs to draw the file.
+ */
+const db = require('../db/database');
+
+/** Media's composer attachment id. */
+const ATTACHMENT_ID = /^att_[0-9A-HJKMNP-TV-Z]{26}$/;
+/** The kinds a message may carry: a picture, a video, a sound, or a file (a PDF or a text). */
+const KINDS = new Set(['image', 'video', 'audio', 'file']);
+/** At most four files on one message. */
+const MAX_ATTACHMENTS = 4;
+const NAME_MAX = 255;
+/** Media serves the bytes under /a/: a picture, a video's copy, a sound. Nothing else is ever drawn. */
+const MEDIA_HOST = 'openvibe.media';
+
+/**
+ * The kind a message may carry. A picture, a video and a sound are fine; a file only when it is a PDF
+ * or text. Everything else (code, an executable, a binary nobody can read) is refused.
+ */
+function allowedKind(kind, mime) {
+ const k = String(kind || '').trim();
+ if (KINDS.has(k)) {
+ if (k !== 'file') return k;
+ const m = String(mime || '').split(';')[0].trim().toLowerCase();
+ return m === 'application/pdf' || m.startsWith('text/') ? 'file' : null;
+ }
+ return null;
+}
+
+/**
+ * The composer attachment a message names: { media_attachment_id: 'att_…' }. Throws a 400 when the id
+ * is not one, so a message never carries something Chat cannot ask Media about.
+ */
+function composerReference(raw) {
+ const value = raw && typeof raw === 'object' ? raw : {};
+ const id = String(value.media_attachment_id || '').trim();
+ if (!ATTACHMENT_ID.test(id)) {
+ const err = new Error('Each Media attachment must be named by its media_attachment_id');
+ err.status = 400; err.code = 'rooms.attachment_invalid'; throw err;
+ }
+ return { media_attachment_id: id };
+}
+
+/**
+ * The ids a body carries: Shared's composer puts one plain att_ id per file on the form (the field
+ * repeated), and a service may send a JSON list of { media_attachment_id }. Both are read here.
+ */
+function namedAttachments(raw) {
+ if (raw == null || raw === '') return [];
+ let value = raw;
+ if (typeof value === 'string' && ATTACHMENT_ID.test(value.trim())) value = [value];
+ else if (typeof value === 'string') {
+ try { value = JSON.parse(value); }
+ catch { const err = new Error('That attachment list is not valid'); err.status = 400; err.code = 'rooms.attachment_invalid'; throw err; }
+ }
+ if (!Array.isArray(value)) value = [value];
+ const out = [];
+ for (let item of value) {
+ if (typeof item === 'string') {
+ const t = item.trim();
+ if (ATTACHMENT_ID.test(t)) { out.push({ media_attachment_id: t }); continue; }
+ try { item = JSON.parse(t); }
+ catch { const err = new Error('That attachment list is not valid'); err.status = 400; err.code = 'rooms.attachment_invalid'; throw err; }
+ for (const x of (Array.isArray(item) ? item : [item])) if (x && typeof x === 'object' && x.media_attachment_id) out.push(x);
+ continue;
+ }
+ if (item && typeof item === 'object' && item.media_attachment_id) out.push(item);
+ }
+ return out;
+}
+
+/**
+ * An address a reader may be sent to: https on openvibe.media, under /a/ (the bytes Media serves).
+ * Anything else — another host, another scheme, another path — is no address at all.
+ */
+function mediaUrl(value) {
+ if (typeof value !== 'string' || !value) return null;
+ try {
+ const u = new URL(value);
+ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;
+ if (u.hostname.toLowerCase() !== MEDIA_HOST) return null;
+ if (!u.pathname.startsWith('/a/')) return null;
+ return u.href;
+ } catch { return null; }
+}
+
+/**
+ * What a message's attachment looks like to everyone who may read the message: the kind and what plays
+ * it, so the room can draw a player for a video, a picture, a sound bar or a file link.
+ */
+function publicReference(row) {
+ if (!row) return null;
+ const url = mediaUrl(row.url);
+ if (!url) return null;
+ const poster = mediaUrl(row.poster);
+ const out = {
+ id: row.attachment_id,
+ name: row.name || 'file',
+ media_type: row.mime || 'application/octet-stream',
+ size: Number(row.size_bytes) || 0,
+ kind: row.kind || 'file',
+ url,
+ poster: poster || null,
+ playback: null,
+ duration_s: row.duration_s != null ? Number(row.duration_s) : null,
+ width: row.width != null ? Number(row.width) : null,
+ height: row.height != null ? Number(row.height) : null,
+ };
+ const pb = mediaUrl(row.playback_url);
+ if (pb) out.playback = { url: pb, mime: row.playback_mime || out.media_type, ready: !!row.playback_ready };
+ return out;
+}
+
+/** The row a kept attachment is stored as. */
+function rowFor(messageId, item, position) {
+ const att = item.attachment || item;
+ const playback = att.playback || null;
+ return {
+ message_id: Number(messageId) || 0,
+ attachment_id: String(att.id || att.media_attachment_id || ''),
+ position: Number(position) || 0,
+ owner_subject: String(att.owner_subject || ''),
+ name: String(att.name || 'file').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, NAME_MAX) || 'file',
+ mime: String(att.mime || 'application/octet-stream').split(';')[0].trim().slice(0, NAME_MAX) || 'application/octet-stream',
+ size_bytes: Math.max(0, Math.floor(Number(att.size) || 0)),
+ kind: String(att.kind || 'file'),
+ url: mediaUrl(att.url),
+ poster: mediaUrl(att.poster),
+ playback_url: playback ? mediaUrl(playback.url) : null,
+ playback_mime: playback && playback.mime ? String(playback.mime) : null,
+ playback_ready: playback && playback.ready ? 1 : 0,
+ duration_s: att.duration_s != null ? Number(att.duration_s) : null,
+ width: att.width != null ? Number(att.width) : null,
+ height: att.height != null ? Number(att.height) : null,
+ };
+}
+
+/** Store the attachments of one message (the rows a reader draws them from). */
+async function createRows(messageId, items) {
+ const out = [];
+ for (let i = 0; i < items.length; i++) {
+ const row = rowFor(messageId, items[i], i);
+ if (!row.attachment_id || !row.url) continue;
+ await db.run(
+ 'INSERT INTO room_message_attachments (message_id, attachment_id, position, owner_subject, name, mime, size_bytes, kind, url, poster, playback_url, playback_mime, playback_ready, duration_s, width, height)'
+ + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ [row.message_id, row.attachment_id, row.position, row.owner_subject, row.name, row.mime, row.size_bytes, row.kind,
+ row.url, row.poster, row.playback_url, row.playback_mime, row.playback_ready, row.duration_s, row.width, row.height]);
+ out.push(row);
+ }
+ return out;
+}
+
+/** The stored attachments of these messages, by message id. */
+async function rowsFor(messageIds) {
+ const ids = (Array.isArray(messageIds) ? messageIds : [messageIds]).map((n) => Number(n) || 0).filter(Boolean);
+ if (!ids.length) return new Map();
+ const rows = await db.all(
+ 'SELECT * FROM room_message_attachments WHERE message_id IN (' + ids.map(() => '?').join(',') + ') ORDER BY message_id, position, attachment_id',
+ ids);
+ const out = new Map();
+ for (const row of rows) {
+ const list = out.get(Number(row.message_id)) || [];
+ list.push(row);
+ out.set(Number(row.message_id), list);
+ }
+ return out;
+}
+
+/** The stored attachments of one message. */
+async function rowsOf(messageId) {
+ return await db.all('SELECT * FROM room_message_attachments WHERE message_id = ? ORDER BY position, attachment_id', [Number(messageId) || 0]);
+}
+
+/** Forget the stored attachments of a message (the message is gone, or the row is replaced). */
+async function dropRows(messageId) {
+ await db.run('DELETE FROM room_message_attachments WHERE message_id = ?', [Number(messageId) || 0]);
+}
+
+module.exports = { ATTACHMENT_ID, KINDS, MAX_ATTACHMENTS, allowedKind, composerReference, namedAttachments, mediaUrl, publicReference, createRows, rowsFor, rowsOf, dropRows };
@@ -50,7 +50,15 @@ function cleanFiles(files, reader = null) {
@@ -50,7 +50,15 @@ function cleanFiles(files, reader = null) {
/** The message API calls these attachments; the AI pipeline keeps its historical `files` name. */
/** The message API calls these attachments; the AI pipeline keeps its historical `files` name. */
function attachmentsFromBody(body) {
function attachmentsFromBody(body) {
if (!body || typeof body !== 'object') return [];
if (!body || typeof body !== 'object') return [];
- return Array.isArray(body.attachments) ? body.attachments : (Array.isArray(body.files) ? body.files : []);
+ const list = Array.isArray(body.attachments) ? body.attachments : (Array.isArray(body.files) ? body.files : []);
+ // A body may carry two kinds of file: the ones the AI reads (Media objects) and the ones Media stored for
+ // the sender (composer attachments, att_…). Only the first kind belongs here; the other is read by
+ // server/rooms/attachments.js.
+ return list.filter((f) => {
+ if (f && typeof f === 'object' && f.media_attachment_id) return false;
+ if (typeof f === 'string' && /^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(f.trim())) return false;
+ return true;
+ });
}
}
/** The files stored on a message row (jsonb comes back as an array; a text column is tolerated too). */
/** The files stored on a message row (jsonb comes back as an array; a text column is tolerated too). */
@@ -46,6 +46,8 @@ const messageFiles = require('./files');
@@ -46,6 +46,8 @@ const messageFiles = require('./files');
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const ctx = require('../live-context');
const ctx = require('../live-context');
+const messageAttachments = require('./attachments');
+const mediaAttachments = require('../media/attachments');
const wordFilter = require('../chat/word-filter');
const wordFilter = require('../chat/word-filter');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const linkCards = require('../chat/link-cards');
const linkCards = require('../chat/link-cards');
@@ -323,7 +325,37 @@ async function editMessage(room, user, messageId, text, { now = Date.now() } = {
@@ -323,7 +325,37 @@ async function editMessage(room, user, messageId, text, { now = Date.now() } = {
return updated;
return updated;
}
}
-async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [], agentView = false } = {}) {
+/**
+ * The files a message carries from Media (composer.attachment@1), checked as the sender: at most four, each
+ * one the sender's, each one a kind a message may carry. Media is asked with Chat's own service token and
+ * the sender named in X-OV-Subject, so another person's file is a 404, exactly like one that is not there.
+ */
+async function checkedAttachments(attachments, user) {
+ const list = Array.isArray(attachments) ? attachments : [];
+ if (!list.length) return [];
+ if (list.length > messageAttachments.MAX_ATTACHMENTS) {
+ fail(400, 'rooms.attachments_too_many', `At most ${messageAttachments.MAX_ATTACHMENTS} files can go with a message`);
+ }
+ if (!user || !user.subject_id) fail(400, 'rooms.attachment_invalid', 'A file can only be attached by the person who uploaded it');
+ const out = [];
+ for (const raw of list) {
+ let ref;
+ try { ref = messageAttachments.composerReference(raw); }
+ catch (err) { fail(err.status || 400, err.code || 'rooms.attachment_invalid', err.message); }
+ let att;
+ try { att = await mediaAttachments.attachment(ref.media_attachment_id, user.subject_id); }
+ catch (err) {
+ if (err && err.status === 404) fail(404, 'rooms.attachment_not_found', 'One of the attached files is not there');
+ fail(502, 'rooms.attachment_unavailable', 'One of the attached files could not be checked');
+ }
+ const kind = messageAttachments.allowedKind(att && att.kind, att && att.mime);
+ if (!kind) fail(415, 'rooms.attachment_type', 'A message may carry a picture, a video, a sound, a PDF or a text file');
+ out.push({ ...att, id: ref.media_attachment_id, kind, owner_subject: user.subject_id });
+ }
+ return out;
+}
+
+async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, reply_to_id = null, files = [], attachments = [], agentView = false } = {}) {
const a = await access(room, user);
const a = await access(room, user);
if (agentView && kindOf(room) === 'agent_stream' && a.role !== 'blocked' && user && !siteBanned(user)) {
if (agentView && kindOf(room) === 'agent_stream' && a.role !== 'blocked' && user && !siteBanned(user)) {
a.read = true;
a.read = true;
@@ -337,7 +369,8 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
@@ -337,7 +369,8 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
fail(403, 'rooms.read_only', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Your role in this room is read-only');
fail(403, 'rooms.read_only', a.kind === 'system' ? 'Only OpenVibe staff post announcements here' : 'Your role in this room is read-only');
}
}
const body = String(text || '').replace(/\r\n/g, '\n').trim();
const body = String(text || '').replace(/\r\n/g, '\n').trim();
- if (!body) fail(422, 'rooms.empty', 'Write something first');
+ // A message may be a file with no words (a picture alone is a message, as on any social app).
+ if (!body && !(Array.isArray(attachments) && attachments.length)) fail(422, 'rooms.empty', 'Write something first');
// A person's own Vibe chat has no other reader: longer messages, and no spam guess (pasted text has rows of = and links).
// A person's own Vibe chat has no other reader: longer messages, and no spam guess (pasted text has rows of = and links).
const ownVibe = Number(room.assistant) === 1 && Number(room.owner_id) === Number(user.id);
const ownVibe = Number(room.assistant) === 1 && Number(room.owner_id) === Number(user.id);
const max = ownVibe ? MAX_TEXT_VIBE : MAX_TEXT;
const max = ownVibe ? MAX_TEXT_VIBE : MAX_TEXT;
@@ -366,6 +399,9 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
@@ -366,6 +399,9 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
const filtered = wordFilter.check(body);
const filtered = wordFilter.check(body);
const message = filtered.safe ? body : filtered.filtered;
const message = filtered.safe ? body : filtered.filtered;
const attached = messageFiles.cleanFiles(files, user.subject_id || null);
const attached = messageFiles.cleanFiles(files, user.subject_id || null);
+ // The files Media stored for this person (composer.attachment@1): Media says whether they exist, are the
+ // sender's and may be carried. Another person's file is a 404, exactly like one that is not there.
+ const mediaFiles = await checkedAttachments(attachments, user);
// The message's known OpenVibe link, with a second's budget: the card is stored on the row and arrives with the
// The message's known OpenVibe link, with a second's budget: the card is stored on the row and arrives with the
// broadcast when it is ready; a slower one fills the row later.
// broadcast when it is ready; a slower one fills the row later.
const { card, pending } = await linkCards.forSend(message);
const { card, pending } = await linkCards.forSend(message);
@@ -373,9 +409,24 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
@@ -373,9 +409,24 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, card, reply_to_id, files) VALUES (?, ?, ?, ?, ?, ?, ?::jsonb)', [room.id, user.id, user.subject_id || null, message, linkCards.toJson(card), replyToId, JSON.stringify(attached)]);
const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, card, reply_to_id, files) VALUES (?, ?, ?, ?, ?, ?, ?::jsonb)', [room.id, user.id, user.subject_id || null, message, linkCards.toJson(card), replyToId, JSON.stringify(attached)]);
await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
await db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
await db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
+ // What Media answered about the files, kept beside the message (the bytes live in Media).
+ if (mediaFiles.length) await messageAttachments.createRows(Number(r.lastInsertRowid), mediaFiles);
await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
return Number(r.lastInsertRowid);
});
});
+ // The files live as long as the message: Media keeps them with it (the kept window, not the 24 hours
+ // an unsent file has). A file that cannot be kept takes the message with it, so a room never shows a file
+ // that is already gone.
+ for (const att of mediaFiles) {
+ let kept = false;
+ try { kept = await mediaAttachments.keepAttachment(att.id, user.subject_id, String(id)); }
+ catch (err) { console.warn('[Chat] could not keep the attachment ' + att.id + ': ' + err.message); }
+ if (kept) continue;
+ await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, id]);
+ await messageAttachments.dropRows(id);
+ if (room.visibility === 'public') await announceDeleted(room, [id]);
+ fail(502, 'rooms.attachment_keep_failed', 'One of the attached files could not be kept with the message');
+ }
const row = await messageById(id);
const row = await messageById(id);
if (pending) linkCards.fillLater('room_messages', id, pending, message);
if (pending) linkCards.fillLater('room_messages', id, pending, message);
// General address previews are a background job: the message and its response never wait on another site.
// General address previews are a background job: the message and its response never wait on another site.
@@ -415,6 +466,59 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null, card
@@ -415,6 +466,59 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null, card
return await messageById(id);
return await messageById(id);
}
}
+/**
+ * A video whose web copy Media was still making when the message was posted: ask Media again when the room
+ * is read, and keep the answer once it is ready (the room draws a player only for a ready copy). At most six a
+ * read, each asked again no sooner than 30 s, never longer than 1.5 s: a slow Media leaves the link as it is.
+ */
+const askedAt = new Map();
+async function freshenPlayback(rows) {
+ const now = Date.now();
+ const due = [];
+ for (const row of rows) {
+ if (due.length >= 6) break;
+ if (row.kind !== 'video' || row.playback_ready || !messageAttachments.ATTACHMENT_ID.test(String(row.attachment_id || '')) || !row.owner_subject) continue;
+ if (now - (askedAt.get(row.attachment_id) || 0) < 30_000) continue;
+ askedAt.set(row.attachment_id, now);
+ due.push(row);
+ }
+ if (askedAt.size > 5000) { for (const [k, at] of askedAt) if (now - at > 60_000) askedAt.delete(k); }
+ await Promise.all(due.map(async (row) => {
+ let att = null;
+ try {
+ att = await Promise.race([mediaAttachments.attachment(row.attachment_id, row.owner_subject), new Promise((r) => setTimeout(() => r(null), 1500))]);
+ } catch { att = null; }
+ const pb = att && att.playback;
+ if (!pb || !pb.ready || typeof pb.url !== 'string') return;
+ const url = messageAttachments.mediaUrl(pb.url);
+ if (!url) return;
+ row.playback_url = url; row.playback_mime = pb.mime || 'video/mp4'; row.playback_ready = 1;
+ const poster = messageAttachments.mediaUrl(att.poster);
+ if (poster) row.poster = poster;
+ try {
+ await db.run('UPDATE room_message_attachments SET playback_url = ?, playback_mime = ?, playback_ready = 1, poster = COALESCE(?, poster) WHERE message_id = ? AND attachment_id = ?',
+ [row.playback_url, row.playback_mime, poster || null, row.message_id, row.attachment_id]);
+ } catch { /* the next read asks again */ }
+ }));
+}
+
+/**
+ * The files a message carries, on the message itself (media_attachments): what a reader draws them from.
+ * Only a read asks Media again (a message just posted has the answer Media gave a moment ago).
+ */
+async function attachMedia(rows, { freshen = false } = {}) {
+ const list = (Array.isArray(rows) ? rows : [rows]).filter(Boolean);
+ if (!list.length) return;
+ const map = await messageAttachments.rowsFor(list.map((r) => Number(r.id) || 0));
+ const all = [];
+ for (const row of list) for (const stored of (map.get(Number(row.id)) || [])) all.push(stored);
+ // Only a read asks Media again: a message just posted has the answer Media gave a moment ago.
+ if (freshen) await freshenPlayback(all);
+ for (const row of list) {
+ row.media_attachments = (map.get(Number(row.id)) || []).map((stored) => messageAttachments.publicReference(stored)).filter(Boolean);
+ }
+}
+
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model, m.embeds_hidden,
const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.reply_to_id, m.created_at, m.edited_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model, m.embeds_hidden,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
@@ -428,6 +532,7 @@ async function messageById(id) {
@@ -428,6 +532,7 @@ async function messageById(id) {
const row = await db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null;
const row = await db.get(`${MSG_SELECT} WHERE m.id = ?`, [id]) || null;
if (row) {
if (row) {
row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));
row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));
+ await attachMedia([row]);
await linkCards.hydrate([row], 'room_messages');
await linkCards.hydrate([row], 'room_messages');
const setting = await db.get('SELECT link_previews FROM rooms WHERE id = ?', [row.room_id]);
const setting = await db.get('SELECT link_previews FROM rooms WHERE id = ?', [row.room_id]);
await linkPreviews.attach([row], 'room', { enabled: !setting || setting.link_previews !== false });
await linkPreviews.attach([row], 'room', { enabled: !setting || setting.link_previews !== false });
@@ -445,6 +550,7 @@ async function historyPage(room, { before = null, limit = 50 } = {}) {
@@ -445,6 +550,7 @@ async function historyPage(room, { before = null, limit = 50 } = {}) {
const rows = await db.all(`${MSG_SELECT} WHERE ${where} ORDER BY m.id DESC LIMIT ?`, params);
const rows = await db.all(`${MSG_SELECT} WHERE ${where} ORDER BY m.id DESC LIMIT ?`, params);
const more = rows.length > lim;
const more = rows.length > lim;
rows.length = Math.min(rows.length, lim);
rows.length = Math.min(rows.length, lim);
+ await attachMedia(rows, { freshen: true });
await linkCards.hydrate(rows, 'room_messages');
await linkCards.hydrate(rows, 'room_messages');
await linkPreviews.attach(rows, 'room', { enabled: room.link_previews !== false });
await linkPreviews.attach(rows, 'room', { enabled: room.link_previews !== false });
return { messages: rows, more };
return { messages: rows, more };
@@ -459,6 +565,7 @@ async function history(room, { before = null, after = null, limit = 60 } = {}) {
@@ -459,6 +565,7 @@ async function history(room, { before = null, after = null, limit = 60 } = {}) {
? await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])
? await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.id < ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, Number(before) || 0, lim])
: await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);
: await db.all(`${MSG_SELECT} WHERE m.room_id = ? AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT ?`, [room.id, lim]);
for (const row of rows) row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));
for (const row of rows) row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));
+ await attachMedia(rows, { freshen: true });
await linkCards.hydrate(rows, 'room_messages');
await linkCards.hydrate(rows, 'room_messages');
await linkPreviews.attach(rows, 'room', { enabled: room.link_previews !== false });
await linkPreviews.attach(rows, 'room', { enabled: room.link_previews !== false });
return after != null ? rows : rows.reverse();
return after != null ? rows : rows.reverse();
@@ -475,6 +582,20 @@ async function markRead(room, user, lastId) {
@@ -475,6 +582,20 @@ async function markRead(room, user, lastId) {
await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
await db.run('UPDATE room_members SET last_read_id = GREATEST(last_read_id, ?), last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [to, room.id, user.id]);
}
}
+/**
+ * The files a message carried, removed: Media is asked to delete them (the person who uploaded them is
+ * named, so Media knows whose file this is) and the rows go with the message.
+ */
+async function dropAttachments(messageId) {
+ let rows = [];
+ try { rows = await messageAttachments.rowsOf(messageId); } catch { rows = []; }
+ for (const row of rows) {
+ try { await mediaAttachments.deleteAttachment(row.attachment_id, row.owner_subject); }
+ catch (err) { console.warn('[Chat] could not remove the attachment ' + row.attachment_id + ': ' + err.message); }
+ }
+ try { await messageAttachments.dropRows(messageId); } catch { /* the rows go with the message anyway */ }
+}
+
async function deleteMessage(room, user, messageId) {
async function deleteMessage(room, user, messageId) {
const m = await db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);
const m = await db.get('SELECT * FROM room_messages WHERE id = ? AND room_id = ?', [messageId, room.id]);
if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');
if (!m || m.is_deleted) fail(404, 'rooms.no_message', 'No such message');
@@ -485,6 +606,8 @@ async function deleteMessage(room, user, messageId) {
@@ -485,6 +606,8 @@ async function deleteMessage(room, user, messageId) {
await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);
await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ?', [user.id, m.id]);
if (room.visibility === 'public') await announceDeleted(room, [m.id]);
if (room.visibility === 'public') await announceDeleted(room, [m.id]);
});
});
+ // The files the message carried go with it: Media keeps nothing for a message that is gone.
+ await dropAttachments(m.id);
if (!own) await db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });
if (!own) await db.logModerationAction({ scope_type: 'room', scope_id: room.slug, actor_user_id: user.id, target_user_id: m.user_id, action_type: 'delete_message', details: { message_id: m.id } });
return m.id;
return m.id;
}
}
@@ -222,6 +222,8 @@ router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
@@ -222,6 +222,8 @@ router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
const message = await rooms.post(room, req.user, command.handled ? command.text : req.body && req.body.message, {
const message = await rooms.post(room, req.user, command.handled ? command.text : req.body && req.body.message, {
timezone: req.body && req.body.timezone,
timezone: req.body && req.body.timezone,
files: require('./files').attachmentsFromBody(req.body),
files: require('./files').attachmentsFromBody(req.body),
+ // The files Media stored for the sender (the room's box): at most four, each one theirs.
+ attachments: require('./attachments').namedAttachments(req.body && req.body.attachments),
reply_to_id: req.body && req.body.reply_to_id,
reply_to_id: req.body && req.body.reply_to_id,
});
});
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
@@ -117,7 +117,8 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat', partyPro
@@ -117,7 +117,8 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat', partyPro
* on Vibe's chat pages: the agent's panel beside the chat, plan HO9), appScript and appStyle (the app's own
* on Vibe's chat pages: the agent's panel beside the chat, plan HO9), appScript and appStyle (the app's own
* script and stylesheet instead of vibe.js and vibe.css: inbox.js and inbox.css), feedView
* script and stylesheet instead of vibe.js and vibe.css: inbox.js and inbox.css), feedView
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
- * public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
+ * public/web's scripts by name, e.g. support.js), player (Shared's player.css + player.js: the network's video
+ * player, for the pages that carry a video), footer ('full', the default, on front pages, directories and docs;
* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
* inlineStyles (CSS the page needs and the site's stylesheet cannot hold: the rule that shows the picked block of a
* inlineStyles (CSS the page needs and the site's stylesheet cannot hold: the rule that shows the picked block of a
* pick block is written for the page, because there is one per block), site ({ name, service, links }: a page served
* pick block is written for the page, because there is one per block), site ({ name, service, links }: a page served
@@ -173,6 +174,7 @@ ${o.kit ? `<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
@@ -173,6 +174,7 @@ ${o.kit ? `<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
${o.app || o.appStyle ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
${o.app || o.appStyle ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
+${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>` : ''}
${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
${o.script || o.app ? `<script src="${asset('agent-run-card.js')}" defer></script>` : ''}
${o.script || o.app ? `<script src="${asset('agent-run-card.js')}" defer></script>` : ''}
${String(o.body || '').includes('id="oc-compose"') || o.app ? `<script src="${ovServe.url('messages.js')}" defer></script>` : ''}
${String(o.body || '').includes('id="oc-compose"') || o.app ? `<script src="${ovServe.url('messages.js')}" defer></script>` : ''}
@@ -944,7 +944,10 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
@@ -944,7 +944,10 @@ ${key ? `<form class="oc-inline oc-danger-row" method="post" action="/settings/a
? `<span class="oc-card-hidden-note">Previews hidden</span>${mayHide ? ui.showForm(hideAction) : ''}`
? `<span class="oc-card-hidden-note">Previews hidden</span>${mayHide ? ui.showForm(hideAction) : ''}`
: ui.messageCards(m, { hide: hideAction });
: ui.messageCards(m, { hide: hideAction });
const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;
const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${emojis ? require('../chat/emoji-text').render(m.message, emojis, linkify, esc) : linkify(m.message)}</span>`;
- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${cards}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;
+ // The files the message carries (Media's composer attachments): a video plays, a picture shows, a sound
+ // gets a bar, a file is a link.
+ const files = ui.messageAttachments(m);
+ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}${agent ? ' oc-msg-agent' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${m.edited_at ? ` <span class="oc-muted oc-edited" data-edited-at="${esc(m.edited_at)}">edited · ${esc(hhmm(m.edited_at).text)}</span>` : ''}${ai ? '' : pictures.pageHtml(m.message)}${files}${cards}${can.moderate && m.edited_at ? '<button type="button" class="ovk-btn ovk-btn--quiet oc-edits">Earlier versions</button>' : ''}${del}</li>`;
};
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -1072,7 +1075,9 @@ ${people}
@@ -1072,7 +1075,9 @@ ${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 = shell.composer({ action: `/r/${room.slug}`, label: `Message to ${room.name}`, placeholder: room.kind === 'system' ? 'Post an announcement' : 'Write a message', add: room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`) });
+ // A room's box takes files: they go to Media's composer attachments as they are picked (any video,
+ // picture, sound, PDF or text, at Media's limits) and the room's server keeps them with the message.
+ else if (can.post) composer = shell.composer({ action: `/r/${room.slug}`, label: `Message to ${room.name}`, placeholder: room.kind === 'system' ? 'Post an announcement' : 'Write a message', add: room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`), media: true });
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>`;
@@ -1106,7 +1111,7 @@ ${people}
@@ -1106,7 +1111,7 @@ ${people}
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
const dmUnread = me ? await require('../chat/dm').getTotalUnread(me.id) : 0;
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, feedView: !!feed,
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady, feedView: !!feed,
- footer: 'none', shell: true, upload: pictures.enabled(), scripts: [...(ais.length ? ['vibe-md.js'] : []), ...(pictures.enabled() ? ['picture-upload.js'] : [])],
+ footer: 'none', shell: true, upload: pictures.enabled(), player: true, scripts: [...(ais.length ? ['vibe-md.js'] : []), ...(pictures.enabled() ? ['picture-upload.js'] : [])],
description,
description,
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, historyMore: roomPage.more, 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, historyMore: roomPage.more, 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 } } : {}) },
@@ -1142,7 +1147,10 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
@@ -1142,7 +1147,10 @@ ${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
const room = await rooms.bySlug(req.params.slug);
const room = await rooms.bySlug(req.params.slug);
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}`;
req.roomBack = `/r/${room.slug}`;
- const message = await rooms.post(room, actor.user, req.body && req.body.message);
+ // The form may name the files Media stored for the sender (the room's box), one plain att_ id per file.
+ const message = await rooms.post(room, actor.user, req.body && req.body.message, {
+ attachments: require('../rooms/attachments').namedAttachments(req.body && req.body.attachments),
+ });
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
res.redirect(303, `/r/${room.slug}#oc-compose`);
res.redirect(303, `/r/${room.slug}#oc-compose`);
}));
}));
@@ -230,7 +230,10 @@ ${youBar(o.you || {})}`;
@@ -230,7 +230,10 @@ ${youBar(o.you || {})}`;
function composer(o = {}) {
function composer(o = {}) {
const questHook = o.action === '/send' ? ' data-ov-guide="chat.message"' : '';
const questHook = o.action === '/send' ? ' data-ov-guide="chat.message"' : '';
const add = o.add || '';
const add = o.add || '';
- return `<div class="oc-dock${add ? ' has-add' : ''}"><form class="oc-compose" method="post" action="${esc(o.action || '')}" id="oc-compose" data-ovc-compose data-ovc-preset="chat"><label for="oc-input" class="oc-sr">${esc(o.label || 'Message')}</label>
+ // 4.247.0 (plan §Z.9): a room's box sends the files it takes to Media's composer attachments as they are
+ // picked (any video, picture, sound, PDF or text, at Media's limits) and puts their ids on the form.
+ const media = o.media ? ' data-ovc-media="1" data-ovc-max-files="4"' : '';
+ return `<div class="oc-dock${add ? ' has-add' : ''}"><form class="oc-compose" method="post" action="${esc(o.action || '')}" id="oc-compose" data-ovc-compose data-ovc-preset="chat"${media}><label for="oc-input" class="oc-sr">${esc(o.label || 'Message')}</label>
<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden></div>
<div class="oc-compose-box"><div class="oc-compose-tray" data-oc-tray hidden></div>
<div class="oc-compose-row">${add ? `<button type="button" class="oc-compose-add" data-oc-attach aria-expanded="false" aria-controls="oc-add-panel" aria-label="Add a picture" title="Add a picture">${IC.plus}</button>` : ''}<textarea id="oc-input" name="message" maxlength="${Number(o.max) || 2000}" rows="1" required placeholder="${esc(o.placeholder || 'Write a message')}"></textarea><button type="submit" class="oc-send"${questHook} aria-label="Send" title="Send">${IC.send}</button></div></div></form>${add}</div>`;
<div class="oc-compose-row">${add ? `<button type="button" class="oc-compose-add" data-oc-attach aria-expanded="false" aria-controls="oc-add-panel" aria-label="Add a picture" title="Add a picture">${IC.plus}</button>` : ''}<textarea id="oc-input" name="message" maxlength="${Number(o.max) || 2000}" rows="1" required placeholder="${esc(o.placeholder || 'Write a message')}"></textarea><button type="submit" class="oc-send"${questHook} aria-label="Send" title="Send">${IC.send}</button></div></div></form>${add}</div>`;
}
}
@@ -192,8 +192,60 @@ function messageCards(m, opts) {
@@ -192,8 +192,60 @@ function messageCards(m, opts) {
return m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m.card);
return m.card && m.card.kind === 'agent-run' ? embedCard(m.card) : linkCard(m.card);
}
}
+/**
+ * An address a message's file may point at: https on openvibe.media, under /a/ (the bytes Media serves).
+ * Anything else — another host, another scheme, another path — is no address at all.
+ */
+function mediaUrl(value) {
+ try {
+ const u = new URL(String(value || ''));
+ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;
+ if (u.hostname.toLowerCase() !== 'openvibe.media') return null;
+ if (!u.pathname.startsWith('/a/')) return null;
+ return u.href;
+ } catch { return null; }
+}
+
+/**
+ * One file a message carries (Media's composer attachments): a video plays in the network's player (Media's
+ * web copy, its poster, its size), a video whose copy is still being made shows its first frame and a link to
+ * the file, a picture shows, a sound gets a bar, a file is a link. Every string is data: escaped here.
+ */
+function mediaAttachment(a) {
+ if (!a || typeof a !== 'object') return '';
+ const url = mediaUrl(a.url);
+ if (!url) return '';
+ const poster = mediaUrl(a.poster);
+ const type = String(a.media_type || '');
+ const kind = a.kind || (type.startsWith('video/') ? 'video' : type.startsWith('image/') ? 'image' : type.startsWith('audio/') ? 'audio' : 'file');
+ const title = esc(a.name || (kind === 'video' ? 'Video' : kind === 'image' ? 'Picture' : kind === 'audio' ? 'Sound' : 'Attached file'));
+ const w = Number(a.width) || 0, h = Number(a.height) || 0;
+ const size = w > 0 && h > 0 && w <= 10000 && h <= 10000 ? ` width="${Math.round(w)}" height="${Math.round(h)}"` : '';
+ if (kind === 'video') {
+ const pb = a.playback && a.playback.ready ? mediaUrl(a.playback.url) : null;
+ const secs = Number(a.duration_s);
+ const length = Number.isFinite(secs) && secs > 0 ? `${Math.floor(secs / 60)}:${String(Math.round(secs % 60)).padStart(2, '0')}` : '';
+ if (!pb) {
+ return `<a class="oc-attach-item oc-attach-video is-preparing" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${poster ? `<img src="${esc(poster)}" alt="" loading="lazy"${size}>` : ''}<span>Getting the video ready to play here. Open the file</span></a>`;
+ }
+ return `<figure class="oc-attach-item oc-attach-video"><video data-ov-player controls preload="none" playsinline src="${esc(pb)}"${poster ? ` poster="${esc(poster)}"` : ''}${size} aria-label="${title}"></video>${length ? `<figcaption>${title} · ${length}</figcaption>` : ''}</figure>`;
+ }
+ if (kind === 'image') return `<a class="oc-attach-item oc-attach-image" href="${esc(url)}" target="_blank" rel="noopener noreferrer"><img src="${esc(poster || url)}" alt="${title}" loading="lazy" width="160"></a>`;
+ if (kind === 'audio') return `<figure class="oc-attach-item oc-attach-audio"><audio controls preload="none" src="${esc(url)}" aria-label="${title}"></audio><figcaption>${title}</figcaption></figure>`;
+ return `<a class="oc-attach-item oc-attach-file" href="${esc(url)}" target="_blank" rel="noopener noreferrer">${title}</a>`;
+}
+
+/** The files a message carries (at most four), or nothing. */
+function messageAttachments(m) {
+ const list = Array.isArray(m && m.media_attachments) ? m.media_attachments : [];
+ if (!list.length) return '';
+ const items = list.map(mediaAttachment).filter(Boolean);
+ if (!items.length) return '';
+ return `<div class="oc-attach">${items.join('')}</div>`;
+}
+
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; };
const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
const serverLetters = (name) => String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase() || '?';
const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;
const serverMark = (s) => `<span class="oc-srv-icon oc-srv-icon--row" style="--oc-hue:${hueOf(s.slug)}" aria-hidden="true">${esc(serverLetters(s.name))}</span>`;
-module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards, hideForm, showForm };
+module.exports = { head, when, initials, presenceState, presenceDot, presenceOrder, presenceLine, hueOf, serverLetters, serverMark, linkCard, embedCard, messageCards, messageAttachments, mediaAttachment, mediaUrl, hideForm, showForm };
@@ -43,6 +43,7 @@ const TABLES = [
@@ -43,6 +43,7 @@ const TABLES = [
'dm_link_preview_settings', // 0070: a person's link previews in DMs (on unless they say otherwise)
'dm_link_preview_settings', // 0070: a person's link previews in DMs (on unless they say otherwise)
'chat_server_timeouts', 'chat_command_polls', 'chat_command_poll_votes', 'chat_command_reminders', 'chat_registered_commands',
'chat_server_timeouts', 'chat_command_polls', 'chat_command_poll_votes', 'chat_command_reminders', 'chat_registered_commands',
'room_memory', // 0071: what a room keeps between summaries
'room_memory', // 0071: what a room keeps between summaries
+ 'room_message_attachments', // 0081: the files a room message carries (Media's composer attachments)
];
];
const INDEXES = [
const INDEXES = [
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
new file mode 100644
new file mode 100644
@@ -0,0 +1,279 @@
@@ -0,0 +1,279 @@
+'use strict';
+/**
+ * A room message carries a file Media stored for the sender (composer.attachment@1, plan §Z.9): the room's box
+ * sends the file to Media as it is picked, the message names it, Chat asks Media (with its own service token
+ * and the sender named) whether it exists, is the sender's and may be carried, keeps it with the message and
+ * stores what Media answered. The room then draws it: a video in the network's player, a picture, a sound, a
+ * file link — never an address off openvibe.media.
+ *
+ * Another person's file is a 404, exactly like one that is not there; a file nobody may carry is refused; a
+ * video whose web copy Media is still making is asked again on a read, so it plays as soon as it is there.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('room-message-attachments');
+let h, ann, bob, media;
+const api = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
+const page = async (path, u) => await fetch(`${h.base}${path}`, { headers: { cookie: `ov_token=${u.token}` } }).then((r) => r.text());
+// A room of its own for every step: a room's rate limit (six messages in ten seconds) would stop the next step.
+const newRoom = async (name) => {
+ const r = await api('POST', '/', ann, { name });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.body.room.slug;
+};
+
+t('boot', async () => {
+ h = await boot();
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
+ // The stand-in for OpenVibe.Media: the composer attachments, the person each belongs to, and the keeps.
+ const files = new Map();
+ const keeps = [];
+ const deletes = [];
+ const calls = [];
+ const attachments = require('../server/media/attachments');
+ attachments._setFetch(async (url, opts) => {
+ const u = new URL(url);
+ const headers = opts.headers || {};
+ const subject = headers['X-OV-Subject'] || headers['x-ov-subject'] || null;
+ const auth = headers.authorization || headers.Authorization || '';
+ const m = /^\/api\/v1\/attachments\/(att_[0-9A-HJKMNP-TV-Z]{26})(\/keep)?$/.exec(u.pathname);
+ calls.push({ method: opts.method, path: u.pathname, subject, auth });
+ const answer = (status, body) => ({ ok: status < 400, status, json: async () => body });
+ if (!m) return answer(404, { detail: 'not found' });
+ const one = files.get(m[1]);
+ if (!one || (subject && one.subject !== subject)) return answer(404, { code: 'media.attachment.not_found', detail: 'no such attachment for this person' });
+ if (opts.method === 'DELETE') { files.delete(m[1]); deletes.push(m[1]); return answer(204, {}); }
+ if (opts.method === 'POST' && m[2] === '/keep') {
+ const body = JSON.parse(opts.body || '{}');
+ keeps.push({ id: m[1], subject, message_id: body.message_id });
+ return answer(200, { id: m[1], message_id: body.message_id, kept_for: [body.message_id] });
+ }
+ return answer(200, {
+ id: one.id, kind: one.kind, name: one.name, size: one.size, mime: one.mime, url: one.url,
+ poster: one.poster || null, playback: one.playback || null,
+ duration_s: one.duration_s != null ? one.duration_s : null,
+ width: one.width != null ? one.width : null, height: one.height != null ? one.height : null,
+ });
+ });
+ media = { files, keeps, deletes, calls, add: (a) => { files.set(a.id, a); return a; } };
+});
+
+t('the kinds a message may carry, and at most four files', async () => {
+ const room = await newRoom('Owls One');
+ const kinds = [
+ { kind: 'video', name: 'clip.mp4', mime: 'video/mp4', url: 'https://openvibe.media/a/att_x/clip.mp4' },
+ { kind: 'image', name: 'look.png', mime: 'image/png', url: 'https://openvibe.media/a/att_y/look.png' },
+ { kind: 'audio', name: 'hello.mp3', mime: 'audio/mpeg', url: 'https://openvibe.media/a/att_z/hello.mp3' },
+ { kind: 'file', name: 'notes.txt', mime: 'text/plain', url: 'https://openvibe.media/a/att_w/notes.txt' },
+ ];
+ const picked = [];
+ for (const one of kinds) {
+ const id = `att_${ids.ulid()}`;
+ picked.push(id);
+ media.add({ id, subject: ann.subject_id, size: 12, poster: null, playback: null, ...one });
+ }
+ const r = await api('POST', `/${room}/messages`, ann, { message: 'here they are', attachments: picked.map((id) => ({ media_attachment_id: id })) });
+ assert.strictEqual(r.status, 201, r.text);
+ const files = r.body.message.media_attachments;
+ assert.strictEqual(files.length, 4, 'four files go with one message');
+ assert.deepStrictEqual(files.map((f) => f.kind).sort(), ['audio', 'file', 'image', 'video']);
+ // A file nobody may carry (code, an executable) is refused.
+ const code = `att_${ids.ulid()}`;
+ media.add({ id: code, subject: ann.subject_id, kind: 'code', name: 'run.sh', mime: 'text/x-sh', size: 12, url: `https://openvibe.media/a/${code}/run.sh` });
+ const refused = await api('POST', `/${room}/messages`, ann, { message: 'no', attachments: [{ media_attachment_id: code }] });
+ assert.strictEqual(refused.status, 415, refused.text);
+ assert.strictEqual(refused.body.code, 'rooms.attachment_type');
+ // Five files are too many.
+ const five = [];
+ for (let i = 0; i < 5; i++) {
+ const id = `att_${ids.ulid()}`;
+ five.push(id);
+ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'p.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/p.png` });
+ }
+ const many = await api('POST', `/${room}/messages`, ann, { message: 'many', attachments: five.map((id) => ({ media_attachment_id: id })) });
+ assert.strictEqual(many.status, 400, many.text);
+ assert.strictEqual(many.body.code, 'rooms.attachments_too_many');
+});
+
+t("another person's file is refused, exactly like one that is not there", async () => {
+ const room = await newRoom('Owls Two');
+ const id = `att_${ids.ulid()}`;
+ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'mine.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/mine.png` });
+ await api('POST', `/${room}/join`, bob);
+ const other = await api('POST', `/${room}/messages`, bob, { message: 'not mine', attachments: [{ media_attachment_id: id }] });
+ assert.strictEqual(other.status, 404, other.text);
+ const missing = await api('POST', `/${room}/messages`, ann, { message: 'nobody\'s', attachments: [{ media_attachment_id: `att_${ids.ulid()}` }] });
+ assert.strictEqual(missing.status, 404, missing.text);
+ assert.strictEqual(missing.body.code, other.body.code, 'the same answer either way');
+});
+
+t('the file is kept with the message, in Media', async () => {
+ const room = await newRoom('Owls Three');
+ const id = `att_${ids.ulid()}`;
+ media.add({ id, subject: ann.subject_id, kind: 'video', name: '7224.mp4', mime: 'video/mp4', size: 24819344,
+ url: `https://openvibe.media/a/${id}/7224.mp4`, poster: `https://openvibe.media/a/${id}/thumb.jpg`,
+ playback: { url: `https://openvibe.media/a/${id}/playback.mp4`, mime: 'video/mp4', ready: true },
+ duration_s: 73.4, width: 1920, height: 1080 });
+ const r = await api('POST', `/${room}/messages`, ann, { message: 'here it is', attachments: [{ media_attachment_id: id }] });
+ assert.strictEqual(r.status, 201, r.text);
+ const file = r.body.message.media_attachments[0];
+ assert.strictEqual(file.id, id);
+ assert.strictEqual(file.kind, 'video');
+ assert.strictEqual(file.media_type, 'video/mp4');
+ assert.strictEqual(file.size, 24819344);
+ assert.deepStrictEqual(file.playback, { url: `https://openvibe.media/a/${id}/playback.mp4`, mime: 'video/mp4', ready: true });
+ assert.strictEqual(file.poster, `https://openvibe.media/a/${id}/thumb.jpg`);
+ assert.strictEqual(file.duration_s, 73.4);
+ assert.deepStrictEqual([file.width, file.height], [1920, 1080]);
+ const keep = media.keeps.find((k) => k.id === id);
+ assert.ok(keep, 'Media was asked to keep the file');
+ assert.strictEqual(keep.subject, ann.subject_id, 'as the sender');
+ assert.strictEqual(keep.message_id, String(r.body.message.id), 'it is kept with the message');
+ const row = await h.db.get('SELECT * FROM room_message_attachments WHERE message_id = ? AND attachment_id = ?', [r.body.message.id, id]);
+ assert.strictEqual(row.kind, 'video');
+ assert.strictEqual(Number(row.playback_ready), 1);
+ // The service token Chat used carries the capability Media asks for, and the sender is named.
+ const call = media.calls.find((c) => c.path === `/api/v1/attachments/${id}` && c.method === 'GET');
+ assert.ok(call, 'Media was asked about the file');
+ assert.match(String(call.auth), /^Bearer /, 'with a service token');
+ assert.strictEqual(call.subject, ann.subject_id, 'naming the sender');
+});
+
+t('a video whose web copy became ready plays on the next read', async () => {
+ const room = await newRoom('Owls Four');
+ const id = `att_${ids.ulid()}`;
+ const one = media.add({ id, subject: ann.subject_id, kind: 'video', name: 'clip.mkv', mime: 'video/x-matroska', size: 900,
+ url: `https://openvibe.media/a/${id}/clip.mkv`, poster: `https://openvibe.media/a/${id}/thumb.jpg`,
+ playback: { url: `https://openvibe.media/a/${id}/clip.mkv`, mime: 'video/x-matroska', ready: false } });
+ const r = await api('POST', `/${room}/messages`, ann, { message: 'still copying', attachments: [{ media_attachment_id: id }] });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.message.media_attachments[0].playback.ready, false, 'the copy is not there yet');
+ // Media finishes the copy.
+ one.playback = { url: `https://openvibe.media/a/${id}/playback.mp4`, mime: 'video/mp4', ready: true };
+ const list = await api('GET', `/${room}/messages`, ann);
+ const found = list.body.messages.find((m) => m.message === 'still copying');
+ assert.deepStrictEqual(found.media_attachments[0].playback, { url: `https://openvibe.media/a/${id}/playback.mp4`, mime: 'video/mp4', ready: true });
+ const row = await h.db.get('SELECT playback_ready, playback_url FROM room_message_attachments WHERE message_id = ? AND attachment_id = ?', [found.id, id]);
+ assert.strictEqual(Number(row.playback_ready), 1, 'kept, so the next read does not ask again');
+ assert.strictEqual(row.playback_url, `https://openvibe.media/a/${id}/playback.mp4`);
+});
+
+t("the room's box asks Media for its files", async () => {
+ const room = await newRoom('Owls Five');
+ const html = await page(`/r/${room}`, ann);
+ assert.match(html, /data-ovc-media="1"/, 'the box sends its files to Media');
+ assert.match(html, /data-ovc-max-files="4"/, 'at most four');
+});
+
+t('a ready video draws the player, a copy still being made draws the link', async () => {
+ const room = await newRoom('Owls Six');
+ const ready = `att_${ids.ulid()}`;
+ media.add({ id: ready, subject: ann.subject_id, kind: 'video', name: 'ready.mp4', mime: 'video/mp4', size: 900,
+ url: `https://openvibe.media/a/${ready}/ready.mp4`, poster: `https://openvibe.media/a/${ready}/thumb.jpg`,
+ playback: { url: `https://openvibe.media/a/${ready}/playback.mp4`, mime: 'video/mp4', ready: true },
+ duration_s: 12, width: 1280, height: 720 });
+ const waiting = `att_${ids.ulid()}`;
+ media.add({ id: waiting, subject: ann.subject_id, kind: 'video', name: 'waiting.mkv', mime: 'video/x-matroska', size: 900,
+ url: `https://openvibe.media/a/${waiting}/waiting.mkv`, poster: `https://openvibe.media/a/${waiting}/thumb.jpg`,
+ playback: { url: `https://openvibe.media/a/${waiting}/waiting.mkv`, mime: 'video/x-matroska', ready: false } });
+ const r = await api('POST', `/${room}/messages`, ann, { message: 'watch this', attachments: [{ media_attachment_id: ready }, { media_attachment_id: waiting }] });
+ assert.strictEqual(r.status, 201, r.text);
+ const html = await page(`/r/${room}`, ann);
+ assert.match(html, /<video data-ov-player[^>]*src="https:\/\/openvibe\.media\/a\/[^"]*\/playback\.mp4"/, 'the ready copy plays in the network\'s player');
+ assert.ok(html.includes(`poster="https://openvibe.media/a/${ready}/thumb.jpg"`), 'the poster is its first frame');
+ assert.match(html, /is-preparing/, 'a copy still being made is a link');
+ assert.ok(html.includes(`href="https://openvibe.media/a/${waiting}/waiting.mkv"`), 'the link is the file itself');
+ assert.match(html, /Getting the video ready to play here/, 'and it says so');
+});
+
+t('nothing is drawn off openvibe.media', async () => {
+ const room = await newRoom('Owls Seven');
+ const id = `att_${ids.ulid()}`;
+ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'elsewhere.png', mime: 'image/png', size: 3, url: 'https://evil.example/a/elsewhere.png' });
+ const r = await api('POST', `/${room}/messages`, ann, { message: 'not here', attachments: [{ media_attachment_id: id }] });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(r.body.message.media_attachments, [], 'an address off openvibe.media is no address');
+ const html = await page(`/r/${room}`, ann);
+ assert.ok(!html.includes('evil.example'), 'the room never points at another host');
+});
+
+t('the files go when the message is deleted', async () => {
+ const room = await newRoom('Owls Eight');
+ const id = `att_${ids.ulid()}`;
+ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'gone.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/gone.png` });
+ const r = await api('POST', `/${room}/messages`, ann, { message: 'delete me', attachments: [{ media_attachment_id: id }] });
+ assert.strictEqual(r.status, 201, r.text);
+ const del = await api('DELETE', `/${room}/messages/${r.body.message.id}`, ann);
+ assert.strictEqual(del.status, 200, del.text);
+ assert.ok(media.deletes.includes(id), 'Media was asked to remove the file');
+ const row = await h.db.get('SELECT * FROM room_message_attachments WHERE message_id = ?', [r.body.message.id]);
+ assert.ok(!row, 'and the room keeps nothing of it');
+});
+
+t('the room hears a message with its files over the socket', async () => {
+ const room = await newRoom('Owls Nine');
+ const id = `att_${ids.ulid()}`;
+ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'live.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/live.png` });
+ const annWs = await h.ws({ token: ann.token });
+ annWs.sendJson({ type: 'join_room', room });
+ await annWs.next((m) => m.type === 'room_joined');
+ annWs.sendJson({ type: 'room_message', message: 'over the socket', attachments: [id] });
+ const got = await annWs.next((m) => m.type === 'room_message' && m.message.message === 'over the socket');
+ assert.strictEqual(got.message.media_attachments.length, 1);
+ assert.strictEqual(got.message.media_attachments[0].id, id);
+});
+
+t('the plain form may name the files too', async () => {
+ const room = await newRoom('Owls Ten');
+ const id = `att_${ids.ulid()}`;
+ media.add({ id, subject: ann.subject_id, kind: 'image', name: 'form.png', mime: 'image/png', size: 3, url: `https://openvibe.media/a/${id}/form.png` });
+ const body = new URLSearchParams();
+ body.set('message', 'from the form');
+ body.append('attachments', id);
+ const r = await fetch(`${h.base}/r/${room}`, { method: 'POST', redirect: 'manual', headers: { cookie: `ov_token=${ann.token}`, 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() });
+ assert.strictEqual(r.status, 303, await r.text());
+ const list = await api('GET', `/${room}/messages`, ann);
+ const found = list.body.messages.find((m) => m.message === 'from the form');
+ assert.ok(found, 'the message is there');
+ assert.strictEqual(found.media_attachments[0].id, id, 'with its file');
+});
+t('the body\'s list of files is read as a list of Media attachments', () => {
+ const { namedAttachments, allowedKind, mediaUrl } = require('../server/rooms/attachments');
+ const one = `att_${ids.ulid()}`;
+ const two = `att_${ids.ulid()}`;
+ assert.deepStrictEqual(namedAttachments(one), [{ media_attachment_id: one }]);
+ assert.deepStrictEqual(namedAttachments([one, two]), [{ media_attachment_id: one }, { media_attachment_id: two }]);
+ assert.deepStrictEqual(namedAttachments(JSON.stringify([{ media_attachment_id: one }])), [{ media_attachment_id: one }]);
+ assert.deepStrictEqual(namedAttachments(''), []);
+ assert.deepStrictEqual(namedAttachments(undefined), []);
+ assert.throws(() => namedAttachments('not json'), { status: 400, code: 'rooms.attachment_invalid' });
+ // The kinds a message may carry: a picture, a video, a sound, and a file that is a PDF or text.
+ assert.strictEqual(allowedKind('video', 'video/mp4'), 'video');
+ assert.strictEqual(allowedKind('image', 'image/png'), 'image');
+ assert.strictEqual(allowedKind('audio', 'audio/mpeg'), 'audio');
+ assert.strictEqual(allowedKind('file', 'application/pdf'), 'file');
+ assert.strictEqual(allowedKind('file', 'text/plain'), 'file');
+ assert.strictEqual(allowedKind('file', 'application/octet-stream'), null);
+ assert.strictEqual(allowedKind('code', 'text/x-sh'), null);
+ // Only an address on openvibe.media, under /a/, is ever used.
+ assert.strictEqual(mediaUrl(`https://openvibe.media/a/${one}/clip.mp4`), `https://openvibe.media/a/${one}/clip.mp4`);
+ assert.strictEqual(mediaUrl('https://evil.example/a/clip.mp4'), null);
+ assert.strictEqual(mediaUrl('http://openvibe.media/a/clip.mp4'), null);
+ assert.strictEqual(mediaUrl('https://openvibe.media/o/med_x'), null);
+});
+
+t("the room's box is mounted with Media's files", () => {
+ const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'composer.js'), 'utf8');
+ assert.match(source, /data-ovc-media/, 'the box asks for Media\'s files');
+ assert.match(source, /media: !!media/, 'and the shared composer is mounted with them');
+});
+t.run(async () => {
+ try { require('../server/media/attachments')._setFetch(null); } catch { /* */ }
+ await h.close();
+});