openvibers/openvibe.chat Commits
Chat: remove a message preview, and a room switch for previews (#490)
Changes
nnew file mode 100644@@@ -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;@@@ -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 = nullreturn 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; },@@@ -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, joinif (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, joinif (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));@@@ -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' });@@@ -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', asyncassert.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(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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;
@@ -444,13 +444,13 @@ function settled() {
@@ -444,13 +444,13 @@ function settled() {
}
}
async function store(job, position, preview) {
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 (?, ?, ?, ?, ?)
await db.run(`INSERT INTO link_previews (message_scope, message_id, position, url, data) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (message_scope, message_id, position)
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)]);
[job.scope, job.messageId, position, preview.url, JSON.stringify(preview)]);
}
}
@@ -503,22 +503,34 @@ function schedule({ scope = 'room', messageId, text, room = null, onReady = null
@@ -503,22 +503,34 @@ function schedule({ scope = 'room', messageId, text, room = null, onReady = null
return job;
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. */
/** The stored previews for one message, in the order their addresses appeared. */
async function embedsOf(scope, messageId) {
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);
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. */
/** 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;
if (!Array.isArray(rows)) return rows;
const list = rows.filter(Boolean);
const list = rows.filter(Boolean);
for (const row of list) row.embeds = [];
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))];
const ids = [...new Set(list.map((r) => Number(r.id)).filter((n) => n > 0))];
if (!ids.length) return rows;
if (!ids.length) return rows;
const marks = ids.map(() => '?').join(',');
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(() => []);
[scope, ...ids]).catch(() => []);
const byId = new Map();
const byId = new Map();
for (const rec of records) {
for (const rec of records) {
@@ -534,7 +546,7 @@ async function attach(rows, scope = 'room') {
@@ -534,7 +546,7 @@ async function attach(rows, scope = 'room') {
module.exports = {
module.exports = {
addresses, normalizeUrl, textOf, decodeEntities, pageFields, previewFromResponse, previewFromOembed, imageOf,
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,
CACHE_MS, MISS_MS, MAX_URLS, MAX_BYTES, FETCH_TIMEOUT_MS, MAX_REDIRECTS, PREVIEWS_ON,
_setRequest(fn) { _request = fn || null; },
_setRequest(fn) { _request = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
_setLookup(fn) { _lookup = fn || null; },
@@ -109,6 +109,7 @@ async function publicRoom(r, extra = {}) {
@@ -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,
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}` } : {}),
...(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,
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,
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 } : {}),
...(Number(r.assistant) === 1 ? { assistant: true } : {}),
...(isWorkspace(r) ? { workspace: true } : {}),
...(isWorkspace(r) ? { workspace: true } : {}),
@@ -321,7 +322,7 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
@@ -321,7 +322,7 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
const row = await messageById(id);
const row = await messageById(id);
if (pending) linkCards.fillLater('room_messages', id, pending);
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.
// 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
// Activity: the people this names, and the person it answers (server/chat/activity.js). A failure here never
// takes a message back.
// takes a message back.
const activity = require('../chat/activity');
const activity = require('../chat/activity');
@@ -353,7 +354,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
@@ -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);
await announceMessage(room, { subject_id: null, username: member.handle, display_name: member.name }, Number(r.lastInsertRowid), message, now);
return Number(r.lastInsertRowid);
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);
return await messageById(id);
}
}
@@ -369,7 +370,8 @@ async function messageById(id) {
@@ -369,7 +370,8 @@ async function messageById(id) {
if (row) {
if (row) {
row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));
row.attachments = messageFiles.attachmentRefs(messageFiles.filesOf(row));
await linkCards.hydrate([row], 'room_messages');
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;
return row;
}
}
@@ -385,7 +387,7 @@ async function historyPage(room, { before = null, limit = 50 } = {}) {
@@ -385,7 +387,7 @@ async function historyPage(room, { before = null, limit = 50 } = {}) {
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 linkCards.hydrate(rows, 'room_messages');
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 };
return { messages: rows, more };
}
}
@@ -399,7 +401,7 @@ async function history(room, { before = null, after = null, limit = 60 } = {}) {
@@ -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]);
: 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 linkCards.hydrate(rows, 'room_messages');
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();
return after != null ? rows : rows.reverse();
}
}
@@ -473,7 +475,7 @@ function screenUrlOf(v) {
@@ -473,7 +475,7 @@ function screenUrlOf(v) {
return s;
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');
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).
// 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');
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
@@ -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 (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 (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 (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 (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');
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;
next.join_role = join_role;
@@ -501,7 +504,7 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
@@ -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);
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');
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.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.
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
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));
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));
@@ -37,6 +37,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -37,6 +37,7 @@ const cache = require('openvibe-shared/cache-policy');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const rooms = require('./rooms');
const rooms = require('./rooms');
+const linkPreviews = require('../chat/link-previews');
const parties = require('./parties');
const parties = require('./parties');
const ctx = require('../live-context');
const ctx = require('../live-context');
const db = require('../db/database');
const db = require('../db/database');
@@ -273,6 +274,30 @@ router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res)
@@ -273,6 +274,30 @@ router.delete('/:slug/messages/:id', requireAuth, deleteLimit, async (req, res)
} catch (err) { send(res, err); }
} 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) => {
router.post('/:slug/join', requireAuth, manageLimit, async (req, res) => {
const room = await rooms.bySlug(req.params.slug);
const room = await rooms.bySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
if (!room) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
@@ -10,7 +10,7 @@ const { boot, suite } = require('./helpers');
@@ -10,7 +10,7 @@ const { boot, suite } = require('./helpers');
let previews;
let previews;
const t = suite('link-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 page = (head) => `<html><head>${head}</head><body>body</body></html>`;
const type = (body) => ({ status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body });
const type = (body) => ({ status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body });
@@ -19,8 +19,12 @@ t('boot', async () => {
@@ -19,8 +19,12 @@ t('boot', async () => {
h = await boot();
h = await boot();
previews = require('../server/chat/link-previews');
previews = require('../server/chat/link-previews');
alice = h.addUser('alice', { subject: ids.newId('user') });
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();
await h.ctx.sync();
const created = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Preview room' } });
const created = await h.http('POST', '/api/chat/rooms', { token: alice.token, body: { name: 'Preview room' } });
assert.strictEqual(created.status, 201, created.text);
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
@@ -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');
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(); });
t.run(async () => { previews._reset(); await h.close(); });