openvibers/openvibe.chat Commits

Chat: remove a message preview, and a room switch for previews (#490)

OP OpenVibers committed 4 days ago · 2c3bb5a40b · 5 files, +130 −20

* Chat: preview the addresses in a message

A message with up to three https addresses now gets link previews, fetched by a
background job after the message is stored (never by the request) through
openvibe-shared/egress: public addresses only, redirects judged hop by hop, 4 s
and 512 KiB caps, no cookies, a plain user agent. The page's Open Graph and
Twitter fields are read, and YouTube and Vimeo resolve through their oEmbed
endpoint. Previews live in link_previews apart from the message and are cached
by address for a day in link_preview_cache.

The message's read carries embeds: [] at once and the stored previews after the
job finishes; a finished job announces room_message_embeds (rooms) or
chat_embeds (channel/global chat). A page's picture is kept only when it is
already on openvibe.media, so a preview never hot-links another site's image.
Text is capped and stored as data, never markup.

* Chat: register the preview tables, review the fetch, and keep tests off the network

The schema test lists every Chat table and the SSRF ratchet lists every file that makes
its own outbound request; both now name the new preview module. A test that does not stub
the network never starts a preview job, so the suite does not fetch the addresses its
fixtures contain.

* Chat: OpenVibe's own links are preview kinds from their public APIs

A Live channel (/@name) resolves through Live's public channel API and a Video watch
page through Video's watchable resolver; neither page is fetched. The preview keeps the
service's title, by, picture (only on openvibe.media) and, for Live, whether the channel
is live. Every other own site (pics, design, community, network, games) is recognised
and not page-fetched yet: it is the list the later kinds add to.

* Merge branch 'chat-embeds-part1-1110' into chat-embeds-part2-1110

* Chat: number the preview migration 0064

0063 went to the room insight migration on main before this branch merged.

* Merge remote-tracking branch 'origin/main' into chat-embeds-part1-1110

* Merge branch 'chat-embeds-part1-1110' into chat-embeds-part2-1110

* Chat: number the preview migration 0065

0064 went to the party pool migration on main in the same window.

* Merge main into the preview branch (party pool migration 0064)

* Merge branch 'chat-embeds-part1-1110' into chat-embeds-part2-1110

* Merge main into the own-kinds branch

* Chat: draw address previews under a message

The message renderer draws up to three previews from a message's embeds: the picture
when it is on openvibe.media, the address's title as the link, its description, and by /
LIVE for OpenVibe's own kinds. All text is escaped server-side and set with textContent in
the client; the markup reuses the existing oc-card classes (no new CSS). A message with no
previews still draws its one known-link card. The room and global sockets take the
room_message_embeds / chat_embeds update and redraw that line's cards.

* Merge main into the renderer branch

* Chat: remove a message preview, and a room switch for previews

A message's author or a room moderator can take one preview (position 0-2) or all of them
back: the row becomes a tombstone, so a job still running cannot restore it, and the room
socket gets the new list. A room's new link_previews switch (on by default) stops the job
from fetching new addresses and hides the stored previews on every read.

* Merge main into the preview controls branch

Changes

ddiff --git a/migrations/0066_link_preview_controls.sql b/migrations/0066_link_preview_controls.sql
nnew file mode 100644
iindex 0000000..a384b19
---- /dev/null
++++ b/migrations/0066_link_preview_controls.sql
@@@ -0,0 +1,14 @@
++-- phase: expand
++-- Who may take a preview back, and the room setting that turns previews off (server/chat/link-previews.js,
++-- server/rooms/routes.js, server/rooms/rooms.js):
++--
++-- link_previews.removed_at / removed_by a preview the author (or a room moderator) removed. The row stays as a
++-- tombstone, so a job that was still running cannot put the preview back.
++-- rooms.link_previews the room's switch, on by default. Off: no new address in the room is
++-- fetched, and the reads show no previews.
++--
++-- Expand only: two nullable columns and one defaulted column; older releases never name them.
++ALTER TABLE link_previews ADD COLUMN IF NOT EXISTS removed_at text COLLATE "C";
++ALTER TABLE link_previews ADD COLUMN IF NOT EXISTS removed_by bigint;
++
++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS link_previews boolean NOT NULL DEFAULT true;
ddiff --git a/server/chat/link-previews.js b/server/chat/link-previews.js
iindex 9d5774c..2a93231 100644
---- a/server/chat/link-previews.js
++++ b/server/chat/link-previews.js
@@@ -444,13 +444,13 @@ function settled() {
}
async function store(job, position, preview) {
-- if (!preview) {
-- await db.run('DELETE FROM link_previews WHERE message_scope = ? AND message_id = ? AND position = ?', [job.scope, job.messageId, position]).catch(() => {});
-- return;
-- }
++ // Nothing found is nothing written (a retry never leaves a stale preview); a removed row is a tombstone the job
++ // must not overwrite, so a preview removed while its job was still running never comes back.
++ if (!preview) return;
await db.run(`INSERT INTO link_previews (message_scope, message_id, position, url, data) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (message_scope, message_id, position)
-- DO UPDATE SET url = EXCLUDED.url, data = EXCLUDED.data`,
++ DO UPDATE SET url = EXCLUDED.url, data = EXCLUDED.data
++ WHERE link_previews.removed_at IS NULL`,
[job.scope, job.messageId, position, preview.url, JSON.stringify(preview)]);
}
@@@ -503,22 +503,34 @@ function schedule({ scope = 'room', messageId, text, room = null, onReady = null
return job;
}
++/**
++ * Take a preview back: one address (position) or all of them on the message. The author or a room moderator decides
++ * that in the route; the row stays as a tombstone so a running job cannot restore it. Returns the previews left.
++ */
++async function remove(scope, messageId, position = null, userId = null) {
++ const params = [userId || null, scope, Number(messageId) || 0];
++ let sql = 'UPDATE link_previews SET removed_at = ov_now(), removed_by = ? WHERE message_scope = ? AND message_id = ? AND removed_at IS NULL';
++ if (position != null) { sql += ' AND position = ?'; params.push(Number(position)); }
++ await db.run(sql, params);
++ return await embedsOf(scope, messageId);
++}
++
/** The stored previews for one message, in the order their addresses appeared. */
async function embedsOf(scope, messageId) {
-- const rows = await db.all('SELECT data FROM link_previews WHERE message_scope = ? AND message_id = ? ORDER BY position', [scope, Number(messageId) || 0]).catch(() => []);
++ const rows = await db.all('SELECT data FROM link_previews WHERE message_scope = ? AND message_id = ? AND removed_at IS NULL ORDER BY position', [scope, Number(messageId) || 0]).catch(() => []);
return rows.map((r) => parsePreview(r.data)).filter(Boolean);
}
/** Put `embeds` on every row ([] when there are none yet), for the read the client makes. */
--async function attach(rows, scope = 'room') {
++async function attach(rows, scope = 'room', { enabled = true } = {}) {
if (!Array.isArray(rows)) return rows;
const list = rows.filter(Boolean);
for (const row of list) row.embeds = [];
-- if (!list.length) return rows;
++ if (!enabled || !list.length) return rows;
const ids = [...new Set(list.map((r) => Number(r.id)).filter((n) => n > 0))];
if (!ids.length) return rows;
const marks = ids.map(() => '?').join(',');
-- const records = await db.all(`SELECT message_id, data FROM link_previews WHERE message_scope = ? AND message_id IN (${marks}) ORDER BY message_id, position`,
++ const records = await db.all(`SELECT message_id, data FROM link_previews WHERE message_scope = ? AND message_id IN (${marks}) AND removed_at IS NULL ORDER BY message_id, position`,
[scope, ...ids]).catch(() => []);
const byId = new Map();
for (const rec of records) {
@@@ -534,7 +546,7 @@ async function attach(rows, scope = 'room') {
module.exports = {
addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,
-- previewOf, schedule, embedsOf, attach, idle, ownLink,
++ previewOf, schedule, embedsOf, attach, remove, idle, ownLink,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
_setRequest(fn) { _request = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 0ef1ba5..a8dd8fa 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -109,6 +109,7 @@ async function publicRoom(r, extra = {}) {
id: r.id, slug: r.slug, name: r.name, topic: r.topic || null, kind: kindOf(r), visibility: r.visibility,
...(kindOf(r) === 'call' ? { join_role: joinRoleOf(r), call_channel: `room-${r.slug}` } : {}),
slow_seconds: r.slow_seconds, message_count: r.message_count, last_message_at: r.last_message_at,
++ link_previews: r.link_previews !== false,
created_at: r.created_at, members: (await db.get("SELECT COUNT(*) AS n FROM room_members WHERE room_id = ? AND role != 'blocked'", [r.id]) || {}).n || 0,
...(Number(r.assistant) === 1 ? { assistant: true } : {}),
...(isWorkspace(r) ? { workspace: true } : {}),
@@@ -321,7 +322,7 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
const row = await messageById(id);
if (pending) linkCards.fillLater('room_messages', id, pending);
// General address previews are a background job: the message and its response never wait on another site.
-- linkPreviews.schedule({ scope: 'room', messageId: id, text: message, room: { id: room.id, slug: room.slug } });
++ if (room.link_previews !== false) linkPreviews.schedule({ scope: 'room', messageId: id, text: message, room: { id: room.id, slug: room.slug } });
// Activity: the people this names, and the person it answers (server/chat/activity.js). A failure here never
// takes a message back.
const activity = require('../chat/activity');
@@@ -353,7 +354,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
await announceMessage(room, { subject_id: null, username: member.handle, display_name: member.name }, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
});
-- linkPreviews.schedule({ scope: 'room', messageId: id, text: message, room: { id: room.id, slug: room.slug } });
++ if (room.link_previews !== false) linkPreviews.schedule({ scope: 'room', messageId: id, text: message, room: { id: room.id, slug: room.slug } });
return await messageById(id);
}
@@@ -369,7 +370,8 @@ async function messageById(id) {
if (row) {
row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));
await linkCards.hydrate([row], 'room_messages');
-- await linkPreviews.attach([row], 'room');
++ 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 });
}
return row;
}
@@@ -385,7 +387,7 @@ async function historyPage(room, { before = null, limit = 50 } = {}) {
const more = rows.length > lim;
rows.length = Math.min(rows.length, lim);
await linkCards.hydrate(rows, 'room_messages');
-- await linkPreviews.attach(rows, 'room');
++ await linkPreviews.attach(rows, 'room', { enabled: room.link_previews !== false });
return { messages: rows, more };
}
@@@ -399,7 +401,7 @@ async function history(room, { before = null, after = null, limit = 60 } = {}) {
: 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 linkCards.hydrate(rows, 'room_messages');
-- await linkPreviews.attach(rows, 'room');
++ await linkPreviews.attach(rows, 'room', { enabled: room.link_previews !== false });
return after != null ? rows : rows.reverse();
}
@@@ -473,7 +475,7 @@ function screenUrlOf(v) {
return s;
}
--async function update(room, actor, { name, topic, visibility, slow_seconds, join_role, screen_url, workspace } = {}, { fromServer = false, minName = 3 } = {}) {
++async function update(room, actor, { name, topic, visibility, slow_seconds, join_role, screen_url, workspace, link_previews } = {}, { fromServer = false, minName = 3 } = {}) {
if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
// A server's channel follows the server's visibility, and is changed through the server (servers.js).
if (room.server_id && !fromServer && visibility != null && visibility !== room.visibility) fail(409, 'rooms.server_channel', 'A channel is as public as its server: change the server instead');
@@@ -483,6 +485,7 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
if (visibility != null) { if (!['public', 'private'].includes(visibility)) fail(422, 'rooms.visibility', 'Visibility is public or private'); next.visibility = visibility; }
if (Number(room.assistant) === 1 && next.visibility !== 'private') fail(409, 'vibe.private', 'A chat with Vibe stays private');
if (slow_seconds != null) { const v = Number(slow_seconds); if (!Number.isInteger(v) || v < 0 || v > 600) fail(422, 'rooms.slow', 'Slow mode is 0 to 600 seconds'); next.slow_seconds = v; }
++ if (link_previews != null) next.link_previews = link_previews === true || link_previews === 1 || link_previews === '1' || link_previews === 'on';
if (join_role != null && join_role !== '') {
if (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
next.join_role = join_role;
@@@ -501,7 +504,7 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
if (screen_url !== undefined && isWorkspace(next)) next.screen_url = screenUrlOf(screen_url);
else if (screen_url !== undefined && screen_url !== '' && screen_url !== null) fail(409, 'rooms.not_workspace', 'Only a workspace room shows a shared screen');
await db.tx(async () => {
-- await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ?, screen_url = ?, workspace = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, next.screen_url == null ? null : next.screen_url, Number(next.workspace) === 1 ? 1 : 0, room.id]);
++ await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ?, screen_url = ?, workspace = ?, link_previews = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, next.screen_url == null ? null : next.screen_url, Number(next.workspace) === 1 ? 1 : 0, next.link_previews !== false, room.id]);
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
await announceDeleted(room, (await db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id])).map((r) => r.id));
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex e77999e..bb10366 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -37,6 +37,7 @@ const cache = require('openvibe-shared/cache-policy');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
++const linkPreviews = require('../chat/link-previews');
const parties = require('./parties');
const ctx = require('../live-context');
const db = require('../db/database');
@@@ -273,6 +274,30 @@ router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res)
} catch (err) { send(res, err); }
});
++/**
++ * A preview on a room message, taken back by its author or a room moderator (server/chat/link-previews.js): with a
++ * position, that one address; without, every preview on the line. The row becomes a tombstone, so a job still
++ * running cannot put it back.
++ */
++router.delete('/:slug/messages/:id/embeds/:position?', requireAuth, deleteLimit, async (req, res) => {
++ const room = await readable(req, res); if (!room) return;
++ try {
++ const id = parseInt(req.params.id, 10);
++ const message = await db.get('SELECT id, user_id FROM room_messages WHERE id = ? AND room_id = ?', [id, room.id]);
++ if (!message) throw new rooms.RoomError(404, 'rooms.no_message', 'No such message');
++ const a = await rooms.access(room, req.user);
++ if (Number(message.user_id) !== Number(req.user.id) && !a.moderate) throw new rooms.RoomError(403, 'rooms.embeds', 'Only the author or a room moderator removes a preview');
++ let position = null;
++ if (req.params.position != null) {
++ position = Number(req.params.position);
++ if (!Number.isInteger(position) || position < 0 || position > 2) throw new rooms.RoomError(422, 'rooms.embed_position', 'A preview is 0, 1 or 2');
++ }
++ const embeds = await linkPreviews.remove('room', id, position, req.user.id);
++ chatServer().broadcastToRoom(room.id, { type: 'room_message_embeds', room: room.slug, id, embeds });
++ res.json({ embeds });
++ } catch (err) { send(res, err); }
++});
++
router.post('/:slug/join', requireAuth, manageLimit, async (req, res) => {
const room = await rooms.bySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
ddiff --git a/test/link-previews.test.js b/test/link-previews.test.js
iindex 944928a..9317b0d 100644
---- a/test/link-previews.test.js
++++ b/test/link-previews.test.js
@@@ -10,7 +10,7 @@ const { boot, suite } = require('./helpers');
let previews;
const t = suite('link-previews');
--let h, alice, room;
++let h, alice, mod, bob, room;
const page = (head) => `<html><head>${head}</head><body>body</body></html>`;
const type = (body) => ({ status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body });
@@@ -19,8 +19,12 @@ t('boot', async () => {
h = await boot();
previews = require('../server/chat/link-previews');
alice = h.addUser('alice', { subject: ids.newId('user') });
-- await h.ctx.upsertUser(h.live.users.get(alice.id));
-- h.netModules.subjects.add(alice.subject_id);
++ mod = h.addUser('mod', { subject: ids.newId('user'), role: 'global_mod' });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ for (const u of [alice, mod, bob]) {
++ await h.ctx.upsertUser(h.live.users.get(u.id));
++ h.netModules.subjects.add(u.subject_id);
++ }
await h.ctx.sync();
const created = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Preview room' } });
assert.strictEqual(created.status, 201, created.text);
@@@ -224,4 +228,56 @@ t('the room page draws a preview with its text escaped, never as markup', async
assert.ok(!html.includes('<img src=x onerror='), 'never an injected tag');
});
++t('a room can turn previews off, and its switch changes the answer', async () => {
++ previews._setRequest(async () => { throw new Error('off must not fetch'); });
++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Switch room' } });
++ const slug = made.body.room.slug;
++ assert.strictEqual(made.body.room.link_previews, true, 'on by default');
++ const off = await h.http('PATCH', `/api/chat/rooms/${slug}`, { token: alice.token, body: { link_previews: false } });
++ assert.strictEqual(off.status, 200, off.text);
++ assert.strictEqual(off.body.room.link_previews, false);
++ const sent = await h.http('POST', `/api/chat/rooms/${slug}/messages`, { token: alice.token, body: { message: 'https://93.184.216.34/off-switch' } });
++ assert.strictEqual(sent.status, 201, sent.text);
++ await previews.idle();
++ let got = await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: alice.token });
++ assert.deepStrictEqual(got.body.messages.find((m) => m.id === sent.body.message.id).embeds, [], 'no fetch and no preview while off');
++
++ const on = await h.http('PATCH', `/api/chat/rooms/${slug}`, { token: alice.token, body: { link_previews: true } });
++ assert.strictEqual(on.body.room.link_previews, true);
++ previews._setRequest(async () => type(page('<meta property="og:title" content="Back on">')));
++ const again = await h.http('POST', `/api/chat/rooms/${slug}/messages`, { token: alice.token, body: { message: 'https://93.184.216.34/back-on' } });
++ await previews.idle();
++ got = await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: alice.token });
++ assert.strictEqual(got.body.messages.find((m) => m.id === again.body.message.id).embeds[0].title, 'Back on');
++});
++
++t('the author removes one preview, and the tombstone keeps a running job from putting it back', async () => {
++ previews._setRequest(async () => type(page('<meta property="og:title" content="Remove me">')));
++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Remove room' } });
++ const sent = await h.http('POST', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token, body: { message: 'https://93.184.216.34/remove-me https://93.184.216.34/keep-me' } });
++ await previews.idle();
++ const removed = await h.http('DELETE', `/api/chat/rooms/${made.body.room.slug}/messages/${sent.body.message.id}/embeds/0`, { token: alice.token });
++ assert.strictEqual(removed.status, 200, removed.text);
++ assert.strictEqual(removed.body.embeds.length, 1);
++ assert.strictEqual(removed.body.embeds[0].url, 'https://93.184.216.34/keep-me');
++ await previews.schedule({ scope: 'room', messageId: sent.body.message.id, text: 'https://93.184.216.34/remove-me', room: { id: made.body.room.id, slug: made.body.room.slug } });
++ await previews.idle();
++ const got = await h.http('GET', `/api/chat/rooms/${made.body.room.slug}/messages`, { token: alice.token });
++ const embeds = got.body.messages.find((m) => m.id === sent.body.message.id).embeds;
++ assert.deepStrictEqual(embeds.map((e) => e.url), ['https://93.184.216.34/keep-me'], 'the removed one stays gone');
++});
++
++t('a moderator removes any preview; another member may not', async () => {
++ previews._setRequest(async () => type(page('<meta property="og:title" content="Moderated">')));
++ const made = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Moderator room' } });
++ const slug = made.body.room.slug;
++ await h.http('POST', `/api/chat/rooms/${slug}/join`, { token: bob.token });
++ const sent = await h.http('POST', `/api/chat/rooms/${slug}/messages`, { token: alice.token, body: { message: 'https://93.184.216.34/moderated' } });
++ await previews.idle();
++ assert.strictEqual((await h.http('DELETE', `/api/chat/rooms/${slug}/messages/${sent.body.message.id}/embeds/0`, { token: bob.token })).status, 403, 'a member is not the author or a moderator');
++ const removed = await h.http('DELETE', `/api/chat/rooms/${slug}/messages/${sent.body.message.id}/embeds/0`, { token: mod.token });
++ assert.strictEqual(removed.status, 200, removed.text);
++ assert.deepStrictEqual(removed.body.embeds, []);
++});
++
t.run(async () => { previews._reset(); await h.close(); });