From 5f480ebbc6f04d4604d863d56a5e04b2416195ae Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Sun, 27 Sep 2026 23:27:29 -0700 Subject: [PATCH 01/10] feat(channel): details + media_list endpoints for the chat details panel Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/channel.json | 19 ++++++++ service/private/channel.js | 41 ++++++++++++++++ test/channel-chat-details.test.js | 80 +++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 test/channel-chat-details.test.js diff --git a/acl/channel.json b/acl/channel.json index 08934f50..2e30a7b6 100644 --- a/acl/channel.json +++ b/acl/channel.json @@ -1047,6 +1047,25 @@ "returns": { "type": "array", "doc": "File-thread summaries with current file metadata" }, "errors": [] }, + "details": { + "doc": "Chat details overview for the workspace team chat: photo/video/file/link counts and the hub's members with presence (online, last_seen). Team chat only (file threads excluded).", + "scope": "hub", + "permission": { "src": "read" }, + "params": {}, + "returns": { "type": "object", "doc": "{ stats: {photos, videos, files, links}, members: [...] }" }, + "errors": [] + }, + "media_list": { + "doc": "One page of the team chat's shared photos, videos, files (60/page) or link messages (30/page), newest first.", + "scope": "hub", + "permission": { "src": "read" }, + "params": { + "kind": { "type": "string", "required": true, "doc": "photo | video | file | link" }, + "page": { "type": "number", "required": false, "doc": "Page number (default 1)" } + }, + "returns": { "type": "array", "doc": "Media rows {nid, category, filename, extension, filesize, duration, ctime, message_id} or link rows {message_id, ctime, preview, url}" }, + "errors": [] + }, "file_thread_acknowledge": { "doc": "Mark a canonical file thread seen up to a message after validating the message/thread pair and current file access. Broadcasts an acknowledgement DTO without message content.", "scope": "hub", diff --git a/service/private/channel.js b/service/private/channel.js index 1f479841..811f04aa 100644 --- a/service/private/channel.js +++ b/service/private/channel.js @@ -94,6 +94,8 @@ class __private_channel extends Entity { this.file_thread_messages = this.file_thread_messages.bind(this); this.file_thread_post = this.file_thread_post.bind(this); this.file_thread_list_by_folder = this.file_thread_list_by_folder.bind(this); + this.details = this.details.bind(this); + this.media_list = this.media_list.bind(this); this.file_thread_acknowledge = this.file_thread_acknowledge.bind(this); this.export_scope = this.export_scope.bind(this); this.export = this.export.bind(this); @@ -1940,6 +1942,45 @@ class __private_channel extends Entity { this.output.list(allowed); } + /** + * Chat details overview (Figma 775:132186): media counts for the team chat + * and the workspace members with presence. One round trip for the panel. + */ + async details() { + const [stats, members] = await Promise.all([ + this.db.await_proc("channel_media_stats", this.uid), + this.db.await_proc("hub_member_presence"), + ]); + const s = (Array.isArray(stats) ? stats[0] : stats) || {}; + this.output.data({ + stats: { + photos: Number(s.photos) || 0, + videos: Number(s.videos) || 0, + files: Number(s.files) || 0, + links: Number(s.links) || 0, + }, + members: Array.isArray(members) ? members : members ? [members] : [], + }); + } + + /** + * One page of the team chat's photos / videos / files / links. + */ + async media_list() { + const kind = `${this.input.need("kind")}`; + if (!["photo", "video", "file", "link"].includes(kind)) { + return this.output.list([]); + } + const page = Number(this.input.use(Attr.page)) || 1; + const rows = await this.db.await_proc( + "channel_media_list", + this.uid, + kind, + page, + ); + this.output.list(Array.isArray(rows) ? rows : rows ? [rows] : []); + } + /** * Mark a single file thread seen up to message_id (scoped to that thread). * Broadcasts the acknowledgement to other participants. diff --git a/test/channel-chat-details.test.js b/test/channel-chat-details.test.js new file mode 100644 index 00000000..c52a6cca --- /dev/null +++ b/test/channel-chat-details.test.js @@ -0,0 +1,80 @@ +// channel.details / channel.media_list — the Chat details panel's two reads. +// +// node --test test/channel-chat-details.test.js +// +// details() is one round trip: team-chat media counts + the hub's members +// with presence, both procs in parallel, numbers coerced (MariaDB SUMs come +// back as strings) and a single-row driver result normalised to an array. +// media_list() refuses an unknown kind without touching the database. +const assert = require('node:assert/strict'); +const test = require('node:test'); + +global.myDrumee = { arch: 'pod', useEmail: 0 }; +global.verbosity = 0; +global.debug = {}; + +const ChannelPrivate = require('../service/private/channel'); + +function ctx(procs, input = {}) { + const calls = []; + const seen = {}; + return { + calls, + seen, + uid: 'me00000000000001', + input: { + need: (k) => { + if (input[k] == null) throw new Error(`missing ${k}`); + return input[k]; + }, + use: (k, d) => (input[k] == null ? d : input[k]), + }, + db: { + await_proc: async (name, ...args) => { + calls.push([name, ...args]); + return procs[name]; + }, + }, + output: { + data: (d) => { seen.data = d; return d; }, + list: (l) => { seen.list = l; return l; }, + }, + }; +} + +test('details: counts coerced to numbers, members passed through', async () => { + const c = ctx({ + channel_media_stats: [{ photos: '752', videos: '33', files: '175', links: '721' }], + hub_member_presence: [{ id: 'a', online: 1, last_seen: 0 }, { id: 'b', online: 0, last_seen: 9 }], + }); + await ChannelPrivate.prototype.details.call(c); + assert.deepEqual(c.seen.data.stats, { photos: 752, videos: 33, files: 175, links: 721 }); + assert.deepEqual(c.seen.data.members.map((m) => m.id), ['a', 'b']); + assert.deepEqual(c.calls.find((x) => x[0] === 'channel_media_stats'), ['channel_media_stats', 'me00000000000001']); + assert.deepEqual(c.calls.find((x) => x[0] === 'hub_member_presence'), ['hub_member_presence']); +}); + +test('details: empty stats and a single-row member result', async () => { + const c = ctx({ channel_media_stats: undefined, hub_member_presence: { id: 'solo' } }); + await ChannelPrivate.prototype.details.call(c); + assert.deepEqual(c.seen.data.stats, { photos: 0, videos: 0, files: 0, links: 0 }); + assert.deepEqual(c.seen.data.members, [{ id: 'solo' }]); +}); + +test('media_list: forwards uid, kind and page', async () => { + const c = ctx({ channel_media_list: [{ nid: 'i1' }] }, { kind: 'photo', page: '2' }); + await ChannelPrivate.prototype.media_list.call(c); + assert.deepEqual(c.calls, [['channel_media_list', 'me00000000000001', 'photo', 2]]); + assert.deepEqual(c.seen.list, [{ nid: 'i1' }]); +}); + +test('media_list: page defaults to 1, unknown kind never reaches the db', async () => { + const ok = ctx({ channel_media_list: [] }, { kind: 'link' }); + await ChannelPrivate.prototype.media_list.call(ok); + assert.equal(ok.calls[0][3], 1); + + const bad = ctx({}, { kind: 'drop table' }); + await ChannelPrivate.prototype.media_list.call(bad); + assert.deepEqual(bad.calls, []); + assert.deepEqual(bad.seen.list, []); +}); From d286bdb438cdeac3189377a3d34195f59669fb2a Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 02:03:42 -0700 Subject: [PATCH 02/10] feat(channel): media_list fills video duration from the node's info.json The media table stores no duration, so the Chat details Videos page had none. For kind=video, rows without a duration read /__storage__//info.json (orig.format.duration, ffprobe), rounded to seconds; null when absent/unreadable. The nid must be a plain id, so the read never leaves __storage__. Co-Authored-By: Claude Opus 5.5 (1M context) --- service/private/channel.js | 35 +++++++++++++++++++++++- test/channel-chat-details.test.js | 44 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/service/private/channel.js b/service/private/channel.js index 811f04aa..f74da777 100644 --- a/service/private/channel.js +++ b/service/private/channel.js @@ -96,6 +96,7 @@ class __private_channel extends Entity { this.file_thread_list_by_folder = this.file_thread_list_by_folder.bind(this); this.details = this.details.bind(this); this.media_list = this.media_list.bind(this); + this._mediaInfoDuration = this._mediaInfoDuration.bind(this); this.file_thread_acknowledge = this.file_thread_acknowledge.bind(this); this.export_scope = this.export_scope.bind(this); this.export = this.export.bind(this); @@ -1978,7 +1979,39 @@ class __private_channel extends Entity { kind, page, ); - this.output.list(Array.isArray(rows) ? rows : rows ? [rows] : []); + const list = Array.isArray(rows) ? rows : rows ? [rows] : []; + // The media table carries no video duration (the Videos page's pill needs + // it): it is in the node's info.json, written at transcode time. + if (kind === "video") { + await Promise.all( + list.map(async (row) => { + if (Number(row.duration) > 0) return; + const d = await this._mediaInfoDuration(row.nid); + row.duration = d ? Math.round(d) : null; + }), + ); + } + this.output.list(list); + } + + /** + * Seconds of a video node, from /__storage__//info.json + * (orig.format.duration, ffprobe output). null when absent or unreadable. + * The nid is a plain id — anything with a path separator or dot is refused, + * so the read can never leave __storage__. + */ + async _mediaInfoDuration(nid) { + try { + if (!/^[\w-]+$/.test(`${nid || ""}`)) return null; + const home = this.hub && this.hub.get(Attr.home_dir); + if (!home) return null; + const file = require("path").join(home, "__storage__", `${nid}`, "info.json"); + const info = JSON.parse(await require("fs").promises.readFile(file, "utf8")); + const d = Number(info && info.orig && info.orig.format && info.orig.format.duration); + return Number.isFinite(d) && d > 0 ? d : null; + } catch (e) { + return null; + } } /** diff --git a/test/channel-chat-details.test.js b/test/channel-chat-details.test.js index c52a6cca..630775e5 100644 --- a/test/channel-chat-details.test.js +++ b/test/channel-chat-details.test.js @@ -78,3 +78,47 @@ test('media_list: page defaults to 1, unknown kind never reaches the db', async assert.deepEqual(bad.calls, []); assert.deepEqual(bad.seen.list, []); }); + +// Video rows: the media table has no duration (it lives in the node's +// __storage__//info.json, orig.format.duration), so media_list fills it in. +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +test('media_list: video rows get their duration from info.json', async () => { + const c = ctx({ + channel_media_list: [ + { nid: 'v1', category: 'video', duration: null }, + { nid: 'v2', category: 'video', duration: null }, + { nid: 'v3', category: 'video', duration: 12 }, + ], + }, { kind: 'video' }); + const asked = []; + c._mediaInfoDuration = async (nid) => { asked.push(nid); return nid === 'v1' ? 540.129524 : null; }; + await ChannelPrivate.prototype.media_list.call(c); + assert.deepEqual(c.seen.list.map((r) => r.duration), [540, null, 12]); + assert.deepEqual(asked, ['v1', 'v2']); // a known duration is not re-read +}); + +test('media_list: photo rows are not touched', async () => { + const c = ctx({ channel_media_list: [{ nid: 'i1', category: 'image' }] }, { kind: 'photo' }); + c._mediaInfoDuration = async () => { throw new Error('should not read'); }; + await ChannelPrivate.prototype.media_list.call(c); + assert.deepEqual(c.seen.list, [{ nid: 'i1', category: 'image' }]); +}); + +test('_mediaInfoDuration reads orig.format.duration, null when absent or unreadable', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cd-home-')); + const dir = path.join(home, '__storage__', 'v1'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'info.json'), JSON.stringify({ orig: { format: { duration: '540.129524' } } })); + fs.mkdirSync(path.join(home, '__storage__', 'v2'), { recursive: true }); + fs.writeFileSync(path.join(home, '__storage__', 'v2', 'info.json'), '{not json'); + const self = { hub: { get: (k) => (k === 'home_dir' ? home : undefined) } }; + const read = (nid) => ChannelPrivate.prototype._mediaInfoDuration.call(self, nid); + assert.equal(await read('v1'), 540.129524); + assert.equal(await read('v2'), null); + assert.equal(await read('missing'), null); + assert.equal(await read('../../etc'), null); // never leaves __storage__ + fs.rmSync(home, { recursive: true, force: true }); +}); From 584ec5bb5d53fba016a9da648157d30fac2ff46b Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 03:43:33 -0700 Subject: [PATCH 03/10] feat(chat): p2p_details + p2p_media_list for the chat details panel Chat details for a DIRECT conversation: - chat.p2p_details {peer_id}: media counts from both participants' p2p_channel (p2p_media_stats in the viewer's DB, like p2p_list_messages) and the two participants with presence (yp drumate_presence). - chat.p2p_media_list {peer_id, kind, page}: rows carry their own hub_id; a video's missing duration is read from that hub's info.json. The info.json reader moves to service/lib/media-duration.js, shared with channel.media_list. Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/chat.json | 22 +++++++++ service/lib/media-duration.js | 28 +++++++++++ service/private/channel.js | 16 ++----- service/private/chat.js | 65 +++++++++++++++++++++++++ test/chat-p2p-details.test.js | 89 +++++++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 service/lib/media-duration.js create mode 100644 test/chat-p2p-details.test.js diff --git a/acl/chat.json b/acl/chat.json index 3d0503f2..ffa313fa 100644 --- a/acl/chat.json +++ b/acl/chat.json @@ -416,6 +416,28 @@ } ] }, + "p2p_details": { + "doc": "Chat details overview for a DIRECT conversation: photo/video/file/link counts from both participants' p2p_channel, and the two participants with presence (online, last_seen).", + "scope": "hub", + "permission": { "src": "write" }, + "params": { + "peer_id": { "type": "string", "required": true, "doc": "The other participant's user id" } + }, + "returns": { "type": "object", "doc": "{ stats: {photos, videos, files, links}, members: [...] }" }, + "errors": [] + }, + "p2p_media_list": { + "doc": "One page of a DIRECT conversation's shared photos, videos, files (60/page) or link messages (30/page), newest first. Media rows carry their own hub_id (the sender's wicket).", + "scope": "hub", + "permission": { "src": "write" }, + "params": { + "peer_id": { "type": "string", "required": true, "doc": "The other participant's user id" }, + "kind": { "type": "string", "required": true, "doc": "photo | video | file | link" }, + "page": { "type": "number", "required": false, "doc": "Page number (default 1)" } + }, + "returns": { "type": "array", "doc": "Media rows {nid, hub_id, category, filename, extension, filesize, duration, ctime, message_id} or link rows {message_id, ctime, preview, url}" }, + "errors": [] + }, "forward": { "doc": "Forward messages to multiple recipients. Takes existing messages and sends copies to specified entities. Preserves original message content and attachments. Supports bulk forwarding.", diff --git a/service/lib/media-duration.js b/service/lib/media-duration.js new file mode 100644 index 00000000..f42fff1c --- /dev/null +++ b/service/lib/media-duration.js @@ -0,0 +1,28 @@ +/** + * Seconds of a video node, read from /__storage__//info.json + * (orig.format.duration — ffprobe output written at transcode time; the media + * table itself stores no duration). null when absent or unreadable. + * + * The nid must be a plain id: anything with a path separator or a dot is + * refused, so the read can never leave __storage__. + * + * Shared by channel.media_list (workspace chats) and chat.p2p_media_list + * (direct chats), for the Chat details Videos page. + */ +const { join } = require("path"); +const { readFile } = require("fs").promises; + +async function mediaInfoDuration(home_dir, nid) { + try { + if (!home_dir || !/^[\w-]+$/.test(`${nid || ""}`)) return null; + const info = JSON.parse( + await readFile(join(home_dir, "__storage__", `${nid}`, "info.json"), "utf8"), + ); + const d = Number(info && info.orig && info.orig.format && info.orig.format.duration); + return Number.isFinite(d) && d > 0 ? d : null; + } catch (e) { + return null; + } +} + +module.exports = { mediaInfoDuration }; diff --git a/service/private/channel.js b/service/private/channel.js index f74da777..76780673 100644 --- a/service/private/channel.js +++ b/service/private/channel.js @@ -2001,17 +2001,11 @@ class __private_channel extends Entity { * so the read can never leave __storage__. */ async _mediaInfoDuration(nid) { - try { - if (!/^[\w-]+$/.test(`${nid || ""}`)) return null; - const home = this.hub && this.hub.get(Attr.home_dir); - if (!home) return null; - const file = require("path").join(home, "__storage__", `${nid}`, "info.json"); - const info = JSON.parse(await require("fs").promises.readFile(file, "utf8")); - const d = Number(info && info.orig && info.orig.format && info.orig.format.duration); - return Number.isFinite(d) && d > 0 ? d : null; - } catch (e) { - return null; - } + // Shared with chat.p2p_media_list (service/lib/media-duration). + return require("../lib/media-duration").mediaInfoDuration( + this.hub && this.hub.get(Attr.home_dir), + nid, + ); } /** diff --git a/service/private/chat.js b/service/private/chat.js index b3209af1..8e8d33cc 100644 --- a/service/private/chat.js +++ b/service/private/chat.js @@ -46,6 +46,8 @@ class privateChat extends Entity { this.pages_to_read = this.pages_to_read.bind(this); this.delete = this.delete.bind(this); this.messages = this.messages.bind(this); + this.p2p_details = this.p2p_details.bind(this); + this.p2p_media_list = this.p2p_media_list.bind(this); this.remove_attachment = this.remove_attachment.bind(this); this.count_all = this.count_all.bind(this); this.attachment = this.attachment.bind(this); @@ -1144,6 +1146,69 @@ class privateChat extends Entity { this.output.data(res); } + /** + * Chat details overview for a DIRECT conversation: media counts from both + * participants' p2p_channel (p2p_media_stats, run in the viewer's DB like + * p2p_list_messages) and the two participants with presence. + */ + async p2p_details() { + const peer_id = `${this.input.need(Attr.peer_id)}`; + const [stats, members] = await Promise.all([ + this.db.await_proc("p2p_media_stats", peer_id), + this.yp.await_proc("drumate_presence", JSON.stringify([this.uid, peer_id])), + ]); + const s = (Array.isArray(stats) ? stats[0] : stats) || {}; + this.output.data({ + stats: { + photos: Number(s.photos) || 0, + videos: Number(s.videos) || 0, + files: Number(s.files) || 0, + links: Number(s.links) || 0, + }, + members: Array.isArray(members) ? members : members ? [members] : [], + }); + } + + /** + * One page of a DIRECT conversation's photos / videos / files / links. Media + * rows carry their own hub_id (the sender's wicket); a video's missing + * duration is read from that hub's info.json, one home lookup per hub. + */ + async p2p_media_list() { + const peer_id = `${this.input.need(Attr.peer_id)}`; + const kind = `${this.input.need("kind")}`; + if (!["photo", "video", "file", "link"].includes(kind)) { + return this.output.list([]); + } + const page = Number(this.input.use(Attr.page)) || 1; + const rows = await this.db.await_proc("p2p_media_list", peer_id, kind, page); + const list = Array.isArray(rows) ? rows : rows ? [rows] : []; + if (kind === "video") { + const { mediaInfoDuration } = require("../lib/media-duration"); + const homes = new Map(); + const homeOf = (hub_id) => { + if (!homes.has(hub_id)) { + homes.set( + hub_id, + this.yp + .await_query("SELECT home_dir FROM entity WHERE id=?", `${hub_id}`) + .then((r) => ((toArray(r)[0] || {}).home_dir) || null) + .catch(() => null), + ); + } + return homes.get(hub_id); + }; + await Promise.all( + list.map(async (row) => { + if (Number(row.duration) > 0) return; + const d = await mediaInfoDuration(await homeOf(row.hub_id), row.nid); + row.duration = d ? Math.round(d) : null; + }), + ); + } + this.output.list(list); + } + /** * */ diff --git a/test/chat-p2p-details.test.js b/test/chat-p2p-details.test.js new file mode 100644 index 00000000..9a8ea6e5 --- /dev/null +++ b/test/chat-p2p-details.test.js @@ -0,0 +1,89 @@ +// chat.p2p_details / chat.p2p_media_list — the Chat details panel for a +// DIRECT conversation, plus the shared video-duration helper. +// +// node --test test/chat-p2p-details.test.js +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +global.myDrumee = { arch: 'pod', useEmail: 0 }; +global.verbosity = 0; +global.debug = {}; + +const ChatPrivate = require('../service/private/chat'); +const { mediaInfoDuration } = require('../service/lib/media-duration'); + +function ctx(db = {}, yp = {}, input = {}, homes = {}) { + const calls = { db: [], yp: [], q: [] }; + const seen = {}; + return { + calls, seen, + uid: 'me00000000000001', + input: { + need: (k) => { if (input[k] == null) throw new Error(`missing ${k}`); return input[k]; }, + use: (k, d) => (input[k] == null ? d : input[k]), + get: (k) => input[k], + }, + db: { await_proc: async (n, ...a) => { calls.db.push([n, ...a]); return db[n]; } }, + yp: { + await_proc: async (n, ...a) => { calls.yp.push([n, ...a]); return yp[n]; }, + await_query: async (sql, id) => { calls.q.push(id); return homes[id] ? [{ home_dir: homes[id] }] : []; }, + }, + output: { data: (d) => (seen.data = d), list: (l) => (seen.list = l) }, + }; +} + +test('p2p_details: counts coerced, members are the two participants with presence', async () => { + const c = ctx( + { p2p_media_stats: [{ photos: '2', videos: '0', files: '5', links: '1' }] }, + { drumate_presence: [{ id: 'me00000000000001', online: 1 }, { id: 'peer', online: 0, last_seen: 9 }] }, + { peer_id: 'peer' }, + ); + await ChatPrivate.prototype.p2p_details.call(c); + assert.deepEqual(c.seen.data.stats, { photos: 2, videos: 0, files: 5, links: 1 }); + assert.deepEqual(c.seen.data.members.map((m) => m.id), ['me00000000000001', 'peer']); + assert.deepEqual(c.calls.db, [['p2p_media_stats', 'peer']]); + assert.deepEqual(c.calls.yp, [['drumate_presence', JSON.stringify(['me00000000000001', 'peer'])]]); +}); + +test('p2p_media_list: forwards peer, kind, page; unknown kind never reaches the db', async () => { + const ok = ctx({ p2p_media_list: [{ nid: 'i1', hub_id: 'h1' }] }, {}, { peer_id: 'peer', kind: 'photo', page: '2' }); + await ChatPrivate.prototype.p2p_media_list.call(ok); + assert.deepEqual(ok.calls.db, [['p2p_media_list', 'peer', 'photo', 2]]); + assert.deepEqual(ok.seen.list, [{ nid: 'i1', hub_id: 'h1' }]); + const bad = ctx({}, {}, { peer_id: 'peer', kind: 'drop' }); + await ChatPrivate.prototype.p2p_media_list.call(bad); + assert.deepEqual(bad.calls.db, []); + assert.deepEqual(bad.seen.list, []); +}); + +test('p2p_media_list: video durations come from each row hub info.json, one home lookup per hub', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'p2p-home-')); + fs.mkdirSync(path.join(home, '__storage__', 'v1'), { recursive: true }); + fs.writeFileSync(path.join(home, '__storage__', 'v1', 'info.json'), JSON.stringify({ orig: { format: { duration: '75.4' } } })); + const c = ctx( + { p2p_media_list: [ + { nid: 'v1', hub_id: 'hA', duration: null }, + { nid: 'v2', hub_id: 'hA', duration: null }, + { nid: 'v3', hub_id: 'hB', duration: 12 }, + ] }, + {}, { peer_id: 'peer', kind: 'video' }, { hA: home }, + ); + await ChatPrivate.prototype.p2p_media_list.call(c); + assert.deepEqual(c.seen.list.map((r) => r.duration), [75, null, 12]); + assert.deepEqual(c.calls.q, ['hA']); + fs.rmSync(home, { recursive: true, force: true }); +}); + +test('mediaInfoDuration: seconds from orig.format.duration, null when absent, never outside __storage__', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'md-home-')); + fs.mkdirSync(path.join(home, '__storage__', 'v1'), { recursive: true }); + fs.writeFileSync(path.join(home, '__storage__', 'v1', 'info.json'), JSON.stringify({ orig: { format: { duration: 540.1 } } })); + assert.equal(await mediaInfoDuration(home, 'v1'), 540.1); + assert.equal(await mediaInfoDuration(home, 'missing'), null); + assert.equal(await mediaInfoDuration(home, '../x'), null); + assert.equal(await mediaInfoDuration(null, 'v1'), null); + fs.rmSync(home, { recursive: true, force: true }); +}); From b80460e85e796fead87e743d1e65ffe43cc4e649 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 04:49:13 -0700 Subject: [PATCH 04/10] fix(chat): p2p_details / p2p_media_list only for a contact or conversation partner Any authenticated caller could walk peer ids and read each user's email, online flag and last-seen through p2p_details. The peer now counts only when the viewer has them as a contact or has a conversation with them (p2p_time holds one row per partner, either side wrote); otherwise the panel gets the viewer alone and no media. drumate_presence keeps its arity (shared stage yp). Co-Authored-By: Claude Opus 5.5 (1M context) --- service/private/chat.js | 25 ++++++++++++++++-- test/chat-p2p-details.test.js | 49 ++++++++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/service/private/chat.js b/service/private/chat.js index 8e8d33cc..b56b55b9 100644 --- a/service/private/chat.js +++ b/service/private/chat.js @@ -1151,11 +1151,31 @@ class privateChat extends Entity { * participants' p2p_channel (p2p_media_stats, run in the viewer's DB like * p2p_list_messages) and the two participants with presence. */ + /** + * Whether `peer_id` is someone the viewer may see in a direct conversation's + * details: a contact, or a conversation partner (p2p_time holds one row per + * partner, whichever side wrote). Anyone else's presence and email stay out + * of reach of a caller walking ids. + */ + async _p2pRelated(peer_id) { + const [contact, talked] = await Promise.all([ + this.db.await_proc("my_contact_exists", "entity", peer_id, null, null), + this.db.await_query("SELECT 1 AS ok FROM p2p_time WHERE peer_id=? LIMIT 1", peer_id), + ]); + const c = Array.isArray(contact) ? contact[0] : contact; + if (c && `${c.uid}` === peer_id) return true; + return Array.isArray(talked) ? talked.length > 0 : !isEmpty(talked); + } + async p2p_details() { const peer_id = `${this.input.need(Attr.peer_id)}`; + const related = await this._p2pRelated(peer_id); const [stats, members] = await Promise.all([ - this.db.await_proc("p2p_media_stats", peer_id), - this.yp.await_proc("drumate_presence", JSON.stringify([this.uid, peer_id])), + related ? this.db.await_proc("p2p_media_stats", peer_id) : null, + this.yp.await_proc( + "drumate_presence", + JSON.stringify(related ? [this.uid, peer_id] : [this.uid]), + ), ]); const s = (Array.isArray(stats) ? stats[0] : stats) || {}; this.output.data({ @@ -1180,6 +1200,7 @@ class privateChat extends Entity { if (!["photo", "video", "file", "link"].includes(kind)) { return this.output.list([]); } + if (!(await this._p2pRelated(peer_id))) return this.output.list([]); const page = Number(this.input.use(Attr.page)) || 1; const rows = await this.db.await_proc("p2p_media_list", peer_id, kind, page); const list = Array.isArray(rows) ? rows : rows ? [rows] : []; diff --git a/test/chat-p2p-details.test.js b/test/chat-p2p-details.test.js index 9a8ea6e5..081730e3 100644 --- a/test/chat-p2p-details.test.js +++ b/test/chat-p2p-details.test.js @@ -20,13 +20,18 @@ function ctx(db = {}, yp = {}, input = {}, homes = {}) { const seen = {}; return { calls, seen, + _p2pRelated: ChatPrivate.prototype._p2pRelated, uid: 'me00000000000001', input: { need: (k) => { if (input[k] == null) throw new Error(`missing ${k}`); return input[k]; }, use: (k, d) => (input[k] == null ? d : input[k]), get: (k) => input[k], }, - db: { await_proc: async (n, ...a) => { calls.db.push([n, ...a]); return db[n]; } }, + db: { + await_proc: async (n, ...a) => { calls.db.push([n, ...a]); return db[n]; }, + // p2p_time: one row per conversation partner, whichever side wrote. + await_query: async (sql, id) => (/p2p_time/.test(sql) && (db.p2p_time || []).includes(id) ? [{ ok: 1 }] : []), + }, yp: { await_proc: async (n, ...a) => { calls.yp.push([n, ...a]); return yp[n]; }, await_query: async (sql, id) => { calls.q.push(id); return homes[id] ? [{ home_dir: homes[id] }] : []; }, @@ -37,21 +42,21 @@ function ctx(db = {}, yp = {}, input = {}, homes = {}) { test('p2p_details: counts coerced, members are the two participants with presence', async () => { const c = ctx( - { p2p_media_stats: [{ photos: '2', videos: '0', files: '5', links: '1' }] }, + { p2p_media_stats: [{ photos: '2', videos: '0', files: '5', links: '1' }], p2p_time: ['peer'] }, { drumate_presence: [{ id: 'me00000000000001', online: 1 }, { id: 'peer', online: 0, last_seen: 9 }] }, { peer_id: 'peer' }, ); await ChatPrivate.prototype.p2p_details.call(c); assert.deepEqual(c.seen.data.stats, { photos: 2, videos: 0, files: 5, links: 1 }); assert.deepEqual(c.seen.data.members.map((m) => m.id), ['me00000000000001', 'peer']); - assert.deepEqual(c.calls.db, [['p2p_media_stats', 'peer']]); + assert.deepEqual(c.calls.db.filter((x) => x[0] === 'p2p_media_stats'), [['p2p_media_stats', 'peer']]); assert.deepEqual(c.calls.yp, [['drumate_presence', JSON.stringify(['me00000000000001', 'peer'])]]); }); test('p2p_media_list: forwards peer, kind, page; unknown kind never reaches the db', async () => { - const ok = ctx({ p2p_media_list: [{ nid: 'i1', hub_id: 'h1' }] }, {}, { peer_id: 'peer', kind: 'photo', page: '2' }); + const ok = ctx({ p2p_media_list: [{ nid: 'i1', hub_id: 'h1' }], p2p_time: ['peer'] }, {}, { peer_id: 'peer', kind: 'photo', page: '2' }); await ChatPrivate.prototype.p2p_media_list.call(ok); - assert.deepEqual(ok.calls.db, [['p2p_media_list', 'peer', 'photo', 2]]); + assert.deepEqual(ok.calls.db.filter((x) => x[0] === 'p2p_media_list'), [['p2p_media_list', 'peer', 'photo', 2]]); assert.deepEqual(ok.seen.list, [{ nid: 'i1', hub_id: 'h1' }]); const bad = ctx({}, {}, { peer_id: 'peer', kind: 'drop' }); await ChatPrivate.prototype.p2p_media_list.call(bad); @@ -68,7 +73,7 @@ test('p2p_media_list: video durations come from each row hub info.json, one home { nid: 'v1', hub_id: 'hA', duration: null }, { nid: 'v2', hub_id: 'hA', duration: null }, { nid: 'v3', hub_id: 'hB', duration: 12 }, - ] }, + ], p2p_time: ['peer'] }, {}, { peer_id: 'peer', kind: 'video' }, { hA: home }, ); await ChatPrivate.prototype.p2p_media_list.call(c); @@ -87,3 +92,35 @@ test('mediaInfoDuration: seconds from orig.format.duration, null when absent, ne assert.equal(await mediaInfoDuration(null, 'v1'), null); fs.rmSync(home, { recursive: true, force: true }); }); + +// Review: p2p_details must not hand out a stranger's email / presence for any +// id: only a contact or someone the viewer has a conversation with. +test('p2p_details: a stranger gets neither presence nor counts — only the viewer', async () => { + const c = ctx( + { p2p_media_stats: [{ photos: '3' }] }, + { drumate_presence: [{ id: 'me00000000000001', online: 1 }] }, + { peer_id: 'stranger' }, + ); + await ChatPrivate.prototype.p2p_details.call(c); + assert.deepEqual(c.calls.yp, [['drumate_presence', JSON.stringify(['me00000000000001'])]]); + assert.ok(!c.calls.db.some((x) => x[0] === 'p2p_media_stats')); + assert.deepEqual(c.seen.data.stats, { photos: 0, videos: 0, files: 0, links: 0 }); + assert.deepEqual(c.seen.data.members.map((m) => m.id), ['me00000000000001']); +}); + +test('p2p_details: a contact with no conversation yet is a participant', async () => { + const c = ctx( + { p2p_media_stats: [{}], my_contact_exists: [{ uid: 'friend' }] }, + { drumate_presence: [{ id: 'me00000000000001' }, { id: 'friend' }] }, + { peer_id: 'friend' }, + ); + await ChatPrivate.prototype.p2p_details.call(c); + assert.deepEqual(c.calls.yp, [['drumate_presence', JSON.stringify(['me00000000000001', 'friend'])]]); +}); + +test('p2p_media_list: a stranger gets an empty page and the proc never runs', async () => { + const c = ctx({ p2p_media_list: [{ nid: 'i1', hub_id: 'h1' }] }, {}, { peer_id: 'stranger', kind: 'photo' }); + await ChatPrivate.prototype.p2p_media_list.call(c); + assert.ok(!c.calls.db.some((x) => x[0] === 'p2p_media_list')); + assert.deepEqual(c.seen.list, []); +}); From 8728a273bdda39c0f93602cfb7de2975b04825ac Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 20:31:31 -0700 Subject: [PATCH 05/10] feat(activity): per-person DM popup mute (mute_peer_set, peers in mute_state) Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/activity.json | 28 ++++++++++++ service/private/activity.js | 67 +++++++++++++++++++++++----- test/activity-mute-peer.test.js | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 test/activity-mute-peer.test.js diff --git a/acl/activity.json b/acl/activity.json index ea8a7ca1..0f919a4e 100644 --- a/acl/activity.json +++ b/acl/activity.json @@ -888,6 +888,11 @@ "description": "1 when popups are muted for every workspace, 0 otherwise", "example": 0 }, + "peers": { + "type": "array", + "description": "Person ids whose DM popups are muted (activity.mute_peer_set)", + "items": { "type": "string" } + }, "hubs": { "type": "array", "description": "Workspace ids muted individually. Empty when global is 1 - the two do not layer.", @@ -940,6 +945,11 @@ "description": "Resulting global flag", "example": 1 }, + "peers": { + "type": "array", + "description": "Person ids whose DM popups are muted (activity.mute_peer_set)", + "items": { "type": "string" } + }, "hubs": { "type": "array", "description": "Resulting list of individually muted workspace ids", @@ -948,6 +958,24 @@ } }, "errors": [] + }, + "mute_peer_set": { + "doc": "Mute or unmute the direct-message POPUPS from one person. Popup channel only - the feed and the unread badge are never affected. Writes the caller's own preference row (notification_mute_peer). Answers the full state, like mute_set. The caller sends their own hub (Visitor.id) as hub_id.", + "scope": "hub", + "permission": { "src": "read" }, + "params": { + "peer_id": { "type": "string", "required": true, "description": "The person to mute (a user id, never the caller)." }, + "muted": { "type": "boolean", "required": false, "default": true, "description": "true or absent mutes; explicit false, 0 or the string false unmutes." } + }, + "returns": { "type": "object", "properties": { + "status": { "type": "string", "example": "ok" }, + "muted": { "type": "integer", "example": 1 }, + "peer_id": { "type": "string" }, + "global": { "type": "integer" }, + "hubs": { "type": "array", "items": { "type": "string" } }, + "peers": { "type": "array", "description": "Person ids whose DM popups are muted", "items": { "type": "string" } } + } }, + "errors": [ { "code": "INVALID_PEER", "message": "peer_id is missing, malformed or the caller", "http_status": 400 } ] } }, diff --git a/service/private/activity.js b/service/private/activity.js index f6967bf1..baf23519 100644 --- a/service/private/activity.js +++ b/service/private/activity.js @@ -2973,6 +2973,29 @@ class MfsActivity extends Entity { * legitimately arrive together, but this does not assert on that — it * reports what is there. */ + // Session-uid person mutes (notification_mute_peer). Fails open: an old + // schema without the routine answers none. + async _peerMutes(rows) { + const list = rows !== undefined ? rows : await this._optionalYpProc('notification_mute_peer_state', this.uid); + const peers = []; + for (const r of toArray(list)) { + const id = r && r.peer_id != null ? String(r.peer_id) : ''; + if (id && !peers.includes(id)) peers.push(id); + } + return peers; + } + + // '1'/'0'/true/false/absent → boolean. Absent means mute: the endpoints are + // named for what they usually do, and only the explicit falsey values + // unmute. Strings are checked because form and query payloads arrive as + // strings, where '0' and 'false' are both truthy in JS and would silently + // invert the caller's intent. + _muteFlag(raw) { + return raw === undefined || raw === null || raw === '' + ? true + : !(raw === 0 || raw === '0' || raw === false || raw === 'false'); + } + _muteState(rows) { let global = 0; const hubs = []; @@ -2995,7 +3018,7 @@ class MfsActivity extends Entity { */ async mute_state() { const rows = await this._optionalYpProc('notification_mute_state', this.uid); - this.output.data(this._muteState(rows)); + this.output.data({ ...this._muteState(rows), peers: await this._peerMutes() }); } /** @@ -3015,22 +3038,46 @@ class MfsActivity extends Entity { */ async mute_set() { const hub_id = String(this.input.use('hub_id') || ''); - const raw = this.input.use('muted'); - // Absent means mute: the endpoint is named for what it usually does, and - // only the explicit falsey values unmute. Strings are checked because form - // and query payloads arrive as strings, where '0' and 'false' are both - // truthy in JS and would silently invert the caller's intent. - const muted = - raw === undefined || raw === null || raw === '' - ? true - : !(raw === 0 || raw === '0' || raw === false || raw === 'false'); + const muted = this._muteFlag(this.input.use('muted')); const proc = muted ? 'notification_mute_set' : 'notification_mute_unset'; const { ok, rows } = await this._optionalYpProcResult(proc, this.uid, hub_id); + // "Unmute all" clears everything — person mutes included. + let peers; + if (ok && !muted && hub_id === '') { + peers = await this._peerMutes(await this._optionalYpProc('notification_mute_peer_unset', this.uid, '')); + } else { + peers = await this._peerMutes(); + } this.output.data({ status: ok ? 'ok' : 'error', muted: muted ? 1 : 0, hub_id, ...this._muteState(rows), + peers, + }); + } + + /** + * Mute or unmute the DM popups from ONE person. + * Endpoint: POST /activity.mute_peer_set + * Input: peer_id (a user id, not the caller), muted (default true) + * Popup channel only, like mute_set; answers the full state. + */ + async mute_peer_set() { + const peer_id = String(this.input.use('peer_id') || ''); + if (!/^[0-9a-zA-Z]{1,16}$/.test(peer_id) || peer_id === String(this.uid)) { + return this.exception.user('INVALID_PEER'); + } + const muted = this._muteFlag(this.input.use('muted')); + const proc = muted ? 'notification_mute_peer_set' : 'notification_mute_peer_unset'; + const { ok, rows } = await this._optionalYpProcResult(proc, this.uid, peer_id); + const hubRows = await this._optionalYpProc('notification_mute_state', this.uid); + this.output.data({ + status: ok ? 'ok' : 'error', + muted: muted ? 1 : 0, + peer_id, + ...this._muteState(hubRows), + peers: await this._peerMutes(rows), }); } // ── Daily reminder card (Round 3 / Sprint 1 row 7) ─────────────── diff --git a/test/activity-mute-peer.test.js b/test/activity-mute-peer.test.js new file mode 100644 index 00000000..ca373072 --- /dev/null +++ b/test/activity-mute-peer.test.js @@ -0,0 +1,79 @@ +// activity-mute-peer.test.js — per-person DM popup mute. +// node --test test/activity-mute-peer.test.js +const assert = require('node:assert/strict'); +const test = require('node:test'); +global.myDrumee = { arch: 'pod', useEmail: 0 }; +global.verbosity = 0; +global.debug = {}; +const Activity = require('../service/private/activity'); + +function ctx(input = {}, yp = {}) { + const calls = []; + const seen = {}; + const c = { + calls, seen, uid: 'me00000000000001', + input: { use: (k) => input[k], need: (k) => input[k], get: (k) => input[k] }, + yp: { await_proc: async (n, ...a) => { calls.push([n, ...a]); return typeof yp[n] === 'function' ? yp[n](...a) : yp[n]; } }, + output: { data: (d) => (seen.data = d) }, + debug() {}, + exception: { user: (code) => (seen.error = code) }, + }; + for (const m of ['_muteState', '_optionalYpProc', '_optionalYpProcResult', '_peerMutes', '_muteFlag']) { + if (Activity.prototype[m]) c[m] = Activity.prototype[m]; + } + return c; +} + +test('mute_peer_set mutes a person and answers the full state with peers', async () => { + const c = ctx({ peer_id: 'peer000000000002', muted: '1' }, { + notification_mute_peer_set: [{ peer_id: 'peer000000000002', ctime: 5 }], + notification_mute_state: [{ hub_id: 'hub0000000000001', ctime: 1 }], + }); + await Activity.prototype.mute_peer_set.call(c); + assert.deepEqual(c.calls[0], ['notification_mute_peer_set', 'me00000000000001', 'peer000000000002']); + assert.equal(c.seen.data.status, 'ok'); + assert.equal(c.seen.data.muted, 1); + assert.deepEqual(c.seen.data.peers, ['peer000000000002']); + assert.deepEqual(c.seen.data.hubs, ['hub0000000000001']); +}); + +test('mute_peer_set with muted 0 unmutes that person', async () => { + const c = ctx({ peer_id: 'peer000000000002', muted: '0' }, { notification_mute_peer_unset: [], notification_mute_state: [] }); + await Activity.prototype.mute_peer_set.call(c); + assert.deepEqual(c.calls[0], ['notification_mute_peer_unset', 'me00000000000001', 'peer000000000002']); + assert.deepEqual(c.seen.data.peers, []); +}); + +test('mute_peer_set rejects self and bad ids', async () => { + for (const peer_id of ['me00000000000001', '', 'x;DROP', 'a'.repeat(17)]) { + const c = ctx({ peer_id, muted: '1' }); + await Activity.prototype.mute_peer_set.call(c); + assert.equal(c.seen.error, 'INVALID_PEER', peer_id); + assert.equal(c.calls.length, 0, peer_id); + } +}); + +test('mute_state carries peers (empty on an old schema)', async () => { + const c = ctx({}, { notification_mute_state: [{ hub_id: '', ctime: 1 }], notification_mute_peer_state: [{ peer_id: 'p1', ctime: 2 }] }); + await Activity.prototype.mute_state.call(c); + assert.deepEqual(c.seen.data, { global: 1, hubs: [], peers: ['p1'] }); +}); + +test('global unmute also clears person mutes', async () => { + const c = ctx({ hub_id: '', muted: '0' }, { notification_mute_unset: [], notification_mute_peer_unset: [] }); + await Activity.prototype.mute_set.call(c); + assert.ok(c.calls.some((x) => x[0] === 'notification_mute_peer_unset' && x[2] === '')); + assert.deepEqual(c.seen.data.peers, []); + const one = ctx({ hub_id: 'hub0000000000001', muted: '0' }, { notification_mute_unset: [], notification_mute_peer_state: [{ peer_id: 'p1' }] }); + await Activity.prototype.mute_set.call(one); + assert.ok(!one.calls.some((x) => x[0] === 'notification_mute_peer_unset'), 'a single workspace unmute keeps person mutes'); + assert.deepEqual(one.seen.data.peers, ['p1']); +}); + +// Last on purpose: a routine that answers undefined is remembered as missing +// (module-level MISSING_PROCS cooldown) for the rest of this process. +test('mute_state on an old schema (no person-mute routine) answers peers []', async () => { + const old = ctx({}, { notification_mute_state: [], notification_mute_peer_state: undefined }); + await Activity.prototype.mute_state.call(old); + assert.deepEqual(old.seen.data.peers, []); +}); From 8f73ea4c7880142b20fb58f5652a727987874df2 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 20:34:09 -0700 Subject: [PATCH 06/10] feat(chat): export a direct conversation (p2p_export_scope, p2p_export, worker p2p mode) Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/chat.json | 18 +++++ offline/media/chat-export.js | 107 ++++++++++++++++++--------- service/lib/p2p-export.js | 51 +++++++++++++ service/private/chat.js | 137 ++++++++++++++++++++++++++++++++++- test/chat-p2p-export.test.js | 97 +++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 37 deletions(-) create mode 100644 service/lib/p2p-export.js create mode 100644 test/chat-p2p-export.test.js diff --git a/acl/chat.json b/acl/chat.json index ffa313fa..1e4e0df2 100644 --- a/acl/chat.json +++ b/acl/chat.json @@ -438,6 +438,24 @@ "returns": { "type": "array", "doc": "Media rows {nid, hub_id, category, filename, extension, filesize, duration, ctime, message_id} or link rows {message_id, ctime, preview, url}" }, "errors": [] }, + "p2p_export_scope": { + "doc": "Export dialog card for a DIRECT conversation (peer's name, message count). Same shape as channel.export_scope. Empty for a peer who is neither a contact nor a conversation partner.", + "scope": "hub", + "permission": { "src": "write" }, + "params": { "peer_id": { "type": "string", "required": true } } + }, + "p2p_export": { + "doc": "Export ONE direct conversation as json (inline) or pdf (offline worker, progress over socket_id). Staged like channel.export; fetch with channel.export_fetch using the caller's own hub_id.", + "scope": "hub", + "permission": { "src": "write" }, + "params": { + "peer_id": { "type": "string", "required": true }, + "format": { "type": "string", "required": false, "default": "json" }, + "start_date": { "type": "integer", "required": false }, + "end_date": { "type": "integer", "required": false }, + "socket_id": { "type": "string", "required": false } + } + }, "forward": { "doc": "Forward messages to multiple recipients. Takes existing messages and sends copies to specified entities. Preserves original message content and attachments. Supports bulk forwarding.", diff --git a/offline/media/chat-export.js b/offline/media/chat-export.js index d64c8f12..78b1dba6 100755 --- a/offline/media/chat-export.js +++ b/offline/media/chat-export.js @@ -414,6 +414,14 @@ class __chat_export_job extends Offline { this.zipname = data.zipname; this.socket_id = data.socket_id || null; this.lang = data.lang || "en"; + // "p2p": a direct conversation (chat.p2p_export) — gathered from the + // viewer's own drumate DB, no folders or file threads. + this.mode = data.mode === "p2p" ? "p2p" : "hub"; + this.peer_id = data.peer_id || null; + if (this.mode === "p2p" && isEmpty(this.peer_id)) { + console.error("chat-export: required arg 'peer_id' is missing (p2p)"); + exit(1); + } for (const name of ["uid", "hub_id", "zipid", "zipname"]) { if (isEmpty(this[name])) { @@ -481,16 +489,31 @@ class __chat_export_job extends Offline { zipid: this.zipid, zipname: this.zipname, message: "IN_PREPARATION", }); - // Resolve hub DB name - const hub = await this.yp.await_proc("get_hub", this.hub_id); - if (!hub || !hub.db_name) { - throw new Error(`chat-export: hub not found for hub_id=${this.hub_id}`); + let db; + if (this.mode === "p2p") { + // A DM lives in the viewer's own drumate DB (p2p_export_messages reads + // the peer's side from there). + // Plain entity lookup: get_entity inner-joins domain/vhost and can + // answer nothing for a real user. + const me = toArray( + await this.yp.await_query("SELECT db_name FROM entity WHERE id=?", `${this.uid}`), + )[0]; + if (!me || !me.db_name) { + throw new Error(`chat-export: no drumate db for uid=${this.uid}`); + } + db = new Mariadb({ name: me.db_name, user: process.env.USER }); + } else { + // Resolve hub DB name + const hub = await this.yp.await_proc("get_hub", this.hub_id); + if (!hub || !hub.db_name) { + throw new Error(`chat-export: hub not found for hub_id=${this.hub_id}`); + } + // Mariadb reads the target schema from the `name` key (Attr.name); the `db` + // key is ignored and the connection silently falls back to YELLOW_PAGE (yp), + // where the channel_export_* procs do not exist. Mirror the working workers + // (expiryWorker / backfill-posters): pass the hub schema as `name`. + db = new Mariadb({ name: hub.db_name, user: process.env.USER }); } - // Mariadb reads the target schema from the `name` key (Attr.name); the `db` - // key is ignored and the connection silently falls back to YELLOW_PAGE (yp), - // where the channel_export_* procs do not exist. Mirror the working workers - // (expiryWorker / backfill-posters): pass the hub schema as `name`. - const db = new Mariadb({ name: hub.db_name, user: process.env.USER }); const stageDir = pathResolve(tmp_dir, DOWNLOAD_FOLDER, this.uid, this.zipid); mkdirSync(stageDir, { recursive: true }); @@ -501,32 +524,46 @@ class __chat_export_job extends Offline { zipid: this.zipid, message: "GATHERING_MESSAGES", }); - const file_threads = await revalidateAuthorizedFileThreads( - db, - this.uid, - this.authorized_file_threads, - ); - writeFileSync( - pathJoin(stageDir, EXPORT_ACCESS_MANIFEST), - stringify({ - schema_version: 1, - hub_id: `${this.hub_id}`, - file_threads: file_threads.map((ft) => ({ - file_thread_id: ft.file_thread_id, - file_nid: ft.file_nid, - })), - }), - "utf8", - ); - const folder_tree = toArray( - await db.await_proc("channel_export_folder_tree", this.hub_id, this.root_nid), - ); - - const sections = await gatherSections( - db, this.uid, this.hub_id, this.root_nid, - this.folder_sel, this.thread_sel, this.date_start, this.date_end, - file_threads, folder_tree, - ); + let sections; + if (this.mode === "p2p") { + // Pinned to the viewer's own hub, as chat.p2p_export stages it. + writeFileSync( + pathJoin(stageDir, EXPORT_ACCESS_MANIFEST), + stringify({ schema_version: 1, hub_id: `${this.uid}`, file_threads: [] }), + "utf8", + ); + const { gatherP2pSection } = require("../../service/lib/p2p-export"); + sections = [ + await gatherP2pSection(db, this.peer_id, this.hub_name, this.date_start, this.date_end), + ]; + } else { + const file_threads = await revalidateAuthorizedFileThreads( + db, + this.uid, + this.authorized_file_threads, + ); + writeFileSync( + pathJoin(stageDir, EXPORT_ACCESS_MANIFEST), + stringify({ + schema_version: 1, + hub_id: `${this.hub_id}`, + file_threads: file_threads.map((ft) => ({ + file_thread_id: ft.file_thread_id, + file_nid: ft.file_nid, + })), + }), + "utf8", + ); + const folder_tree = toArray( + await db.await_proc("channel_export_folder_tree", this.hub_id, this.root_nid), + ); + + sections = await gatherSections( + db, this.uid, this.hub_id, this.root_nid, + this.folder_sel, this.thread_sel, this.date_start, this.date_end, + file_threads, folder_tree, + ); + } // ── Build the Flat ODT document ───────────────────────────────────────── await this._send({ diff --git a/service/lib/p2p-export.js b/service/lib/p2p-export.js new file mode 100644 index 00000000..0713467c --- /dev/null +++ b/service/lib/p2p-export.js @@ -0,0 +1,51 @@ +/** + * Direct-conversation export rows → the chat-export section shape, shared by + * chat.p2p_export (JSON, inline) and offline/media/chat-export.js (PDF, + * mode "p2p"). Mirrors that worker's normalizeRow / parseAttachments for + * channel rows, so a DM export reads like a workspace one. + */ +const { toArray } = require("@drumee/server-essentials"); + +const PAGE_SIZE = 45; // p2p_export_messages' page + +function parseAttachments(attachment) { + if (!attachment) return []; + try { + const raw = typeof attachment === "string" ? JSON.parse(attachment) : attachment; + return toArray(raw) + .map((a) => { + if (!a) return null; + const nid = a.nid || (typeof a === "string" ? a : null); + if (!nid) return null; + // A DM attachment lives in its sender's wicket hub: always named on it. + return { name: a.filename || nid, link: `/-/svc/media.orig?nid=${nid}&hub_id=${a.hub_id}` }; + }) + .filter(Boolean); + } catch (_) { + return []; + } +} + +function normalizeP2pRow(row) { + const name = row.fullname || `${row.firstname || ""} ${row.lastname || ""}`.trim() || row.author_id; + return { + id: row.message_id, + author: { id: row.author_id, name }, + time: row.ctime, + text: row.message || "", + attachments: parseAttachments(row.attachment), + reply_to: row.thread_id || null, + }; +} + +async function gatherP2pSection(db, peer_id, name, start, end) { + const messages = []; + for (let page = 1; ; page++) { + const rows = toArray(await db.await_proc("p2p_export_messages", peer_id, start || null, end || null, page)); + for (const r of rows) messages.push(normalizeP2pRow(r)); + if (rows.length < PAGE_SIZE) break; + } + return { type: "direct_chat", name, messages }; +} + +module.exports = { normalizeP2pRow, gatherP2pSection, PAGE_SIZE }; diff --git a/service/private/chat.js b/service/private/chat.js index b56b55b9..9c4bffdd 100644 --- a/service/private/chat.js +++ b/service/private/chat.js @@ -14,12 +14,17 @@ * limitations under the License. * ============================================================================= */ -const { Attr, RedisStore, toArray } = require("@drumee/server-essentials"); +const { Attr, RedisStore, toArray, Constants, sysEnv } = require("@drumee/server-essentials"); const { Entity, MfsTools } = require("@drumee/server-core"); const { remove_node, move_node, copy_node } = MfsTools; const { stringify } = JSON; -const { mkdirSync } = require("fs"); +const { mkdirSync, writeFileSync } = require("fs"); +const { resolve: pathResolve, join: pathJoin } = require("path"); +const Spawn = require("child_process").spawn; +const { gatherP2pSection } = require("../lib/p2p-export"); +const { DOWNLOAD_FOLDER } = Constants; +const { tmp_dir } = sysEnv(); const { isEmpty, isArray, map, includes } = require("lodash"); const { CAN_CHAT, privilegeAllows } = require("../lib/member-capability"); const {admit: admitMobilePush} = require('../lib/mobile-push'); @@ -1189,6 +1194,134 @@ class privateChat extends Entity { }); } + /** Display name of a DM peer (drumate_presence), '' when unknown. */ + async _p2pPeerName(peer_id) { + const rows = toArray(await this.yp.await_proc("drumate_presence", JSON.stringify([peer_id]))); + const r = rows.find((x) => x && `${x.id}` === `${peer_id}`) || rows[0] || {}; + return r.fullname || `${r.firstname || ""} ${r.lastname || ""}`.trim() || ""; + } + + /** + * The export dialog's card for a DIRECT conversation — the export_scope + * shape widget_chat_export reads. A stranger gets an empty card. + */ + async p2p_export_scope() { + const peer_id = `${this.input.need(Attr.peer_id)}`; + if (!(await this._p2pRelated(peer_id))) { + return this.output.data({ hub: { name: "", message_count: 0, mtime: null }, folders: [], file_threads: [] }); + } + const [count, name] = await Promise.all([ + this.db.await_proc("p2p_export_count", peer_id, null, null), + this._p2pPeerName(peer_id), + ]); + const c = toArray(count)[0]; + this.output.data({ + hub: { name, message_count: c ? Number(c.message_count) || 0 : 0, mtime: null }, + folders: [], + file_threads: [], + }); + } + + /** + * Export ONE direct conversation — JSON inline, PDF through the offline + * worker (mode "p2p"). Staged exactly like channel.export, with the + * manifest pinned to the viewer's own hub, so channel.export_fetch (called + * with hub_id = the viewer's id) serves it and nothing else can. + */ + async p2p_export() { + const EXPORT_CAP = 10000; + const format = this.input.use("format") || "json"; + if (!["json", "pdf"].includes(format)) return this.output.data({ status: "INVALID_FORMAT" }); + const peer_id = `${this.input.need(Attr.peer_id)}`; + if (!(await this._p2pRelated(peer_id))) return this.output.data({ status: "INVALID_PEER" }); + const socket_id = this.input.use(Attr.socket_id) || null; + if (format === "pdf" && !socket_id) return this.output.data({ status: "MISSING_SOCKET_ID" }); + const num = (v) => (v === null || v === undefined || v === "" ? null : Number(v)); + const start = num(this.input.use("start_date")); + const end = num(this.input.use("end_date")); + + const c = toArray(await this.db.await_proc("p2p_export_count", peer_id, start, end))[0]; + const message_count = c ? Number(c.message_count) || 0 : 0; + if (message_count > EXPORT_CAP) { + return this.output.data({ + status: "EXPORT_TOO_LARGE", + message_count, + hint: "Narrow the date range to reduce the export size.", + }); + } + + const name = (await this._p2pPeerName(peer_id)) || peer_id; + const zipid = this.randomString(); + const base = name.replace(/[^0-9a-zA-Z_.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80) || "chat-export"; + const zipname = `${base}.${format}`; + // _exportStageRoot: test seam (tests/chat-p2p-export) — unset in service. + const root = this._exportStageRoot || pathResolve(tmp_dir, DOWNLOAD_FOLDER); + const stageDir = pathResolve(root, `${this.uid}`, zipid); + mkdirSync(stageDir, { recursive: true }); + writeFileSync( + pathJoin(stageDir, ".file-thread-access.json"), + stringify({ schema_version: 1, hub_id: `${this.uid}`, file_threads: [] }), + "utf8", + ); + + if (format === "json") { + const section = await gatherP2pSection(this.db, peer_id, name, start, end); + const Moment = require("moment"); + writeFileSync( + pathJoin(stageDir, zipname), + stringify( + { + meta: { + schema_version: 2, + kind: "direct", + peer_id, + peer_name: name, + exported_by: this.uid, + exported_at: Moment(Moment.now() / 1000, "X").format("YYYY-MM-DD HH:mm"), + date_start: start, + date_end: end, + format: "json", + }, + sections: [section], + }, + null, + 2, + ), + "utf8", + ); + return this.output.data({ wait: 0, zipid, zipname, format }); + } + + const args = { + mode: "p2p", + uid: this.uid, + hub_id: `${this.uid}`, + hub_name: name, + root_name: name, + peer_id, + // No folders / file threads in a DM; the worker requires explicit + // (empty) scopes and fails closed otherwise. + folder_sel: "none", + thread_sel: "none", + start_date: start, + end_date: end, + format: "pdf", + zipid, + zipname, + socket_id, + lang: this.client_language ? this.client_language() : "en", + authorized_file_threads: [], + }; + const cmd = pathResolve(__dirname, "..", "..", "offline", "media", "chat-export.js"); + const child = Spawn(cmd, [stringify(args)], { detached: true, stdio: ["ignore", "ignore", "ignore"] }); + // As channel.export: a spawn failure must not crash the REST service. + child.on("error", (e) => { + this.warn(`chat-export (p2p) spawn failed: ${e && e.message}`); + }); + child.unref(); + return this.output.data({ wait: 1, zipid, zipname, format }); + } + /** * One page of a DIRECT conversation's photos / videos / files / links. Media * rows carry their own hub_id (the sender's wicket); a video's missing diff --git a/test/chat-p2p-export.test.js b/test/chat-p2p-export.test.js new file mode 100644 index 00000000..c2879f37 --- /dev/null +++ b/test/chat-p2p-export.test.js @@ -0,0 +1,97 @@ +// chat-p2p-export.test.js — chat.p2p_export_scope / chat.p2p_export. +// node --test test/chat-p2p-export.test.js +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +global.myDrumee = { arch: 'pod', useEmail: 0 }; +global.verbosity = 0; +global.debug = {}; +const ChatPrivate = require('../service/private/chat'); +const { normalizeP2pRow, gatherP2pSection } = require('../service/lib/p2p-export'); + +function ctx({ input = {}, related = true, count = 2, pages = [[ + { message_id: 'm1', author_id: 'me', message: 'hello', ctime: 100, fullname: 'Me Mine' }, + { message_id: 'm4', author_id: 'peer', message: 'hi back', ctime: 200, fullname: 'Ann Peer' }, +]] } = {}) { + const calls = []; + const seen = {}; + return { + calls, seen, uid: 'me', + input: { need: (k) => input[k], use: (k) => input[k] }, + _p2pRelated: async () => related, + _p2pPeerName: ChatPrivate.prototype._p2pPeerName, + randomString: () => 'zip123', + db: { await_proc: async (n, ...a) => { + calls.push([n, ...a]); + if (n === 'p2p_export_count') return [{ message_count: count }]; + if (n === 'p2p_export_messages') return pages[(a[3] || 1) - 1] || []; + return []; + } }, + yp: { await_proc: async (n, ...a) => (calls.push([n, ...a]), [{ id: 'peer', fullname: 'Ann Peer' }]) }, + output: { data: (d) => (seen.data = d) }, + }; +} + +test('normalizeP2pRow maps a p2p row onto the export message shape', () => { + const m = normalizeP2pRow({ message_id: 'm1', author_id: 'a', message: 'x', ctime: 5, fullname: 'An A', attachment: '[{"nid":"n","hub_id":"h","filename":"f.pdf"}]' }); + assert.deepEqual(m.author, { id: 'a', name: 'An A' }); + assert.equal(m.text, 'x'); + assert.equal(m.attachments.length, 1); +}); + +test('gatherP2pSection pages until a short page', async () => { + const full = Array.from({ length: 45 }, (_, i) => ({ message_id: `a${i}`, author_id: 'me', message: 'x', ctime: i })); + const c = ctx({ pages: [full, [{ message_id: 'z', author_id: 'peer', message: 'y', ctime: 99 }]] }); + const s = await gatherP2pSection(c.db, 'peer', 'Ann Peer', null, null); + assert.equal(s.type, 'direct_chat'); + assert.equal(s.name, 'Ann Peer'); + assert.equal(s.messages.length, 46); +}); + +test('p2p_export_scope answers the export_scope shape', async () => { + const c = ctx({ input: { peer_id: 'peer' } }); + await ChatPrivate.prototype.p2p_export_scope.call(c); + assert.deepEqual(c.seen.data, { hub: { name: 'Ann Peer', message_count: 2, mtime: null }, folders: [], file_threads: [] }); +}); + +test("a stranger's DM is not exported (scope or export)", async () => { + const s = ctx({ input: { peer_id: 'x' }, related: false }); + await ChatPrivate.prototype.p2p_export_scope.call(s); + assert.equal(s.seen.data.hub.message_count, 0); + assert.ok(!s.calls.some((x) => x[0] === 'p2p_export_count')); + const e = ctx({ input: { peer_id: 'x', format: 'json' }, related: false }); + await ChatPrivate.prototype.p2p_export.call(e); + assert.equal(e.seen.data.status, 'INVALID_PEER'); + assert.ok(!e.calls.some((x) => x[0] === 'p2p_export_messages')); +}); + +test('export guards: format, socket for pdf, 10k cap', async () => { + const bad = ctx({ input: { peer_id: 'peer', format: 'docx' } }); + await ChatPrivate.prototype.p2p_export.call(bad); + assert.equal(bad.seen.data.status, 'INVALID_FORMAT'); + const pdf = ctx({ input: { peer_id: 'peer', format: 'pdf' } }); + await ChatPrivate.prototype.p2p_export.call(pdf); + assert.equal(pdf.seen.data.status, 'MISSING_SOCKET_ID'); + const big = ctx({ input: { peer_id: 'peer', format: 'json' }, count: 10001 }); + await ChatPrivate.prototype.p2p_export.call(big); + assert.equal(big.seen.data.status, 'EXPORT_TOO_LARGE'); +}); + +test('the DM manifest pins the viewer\'s own hub; JSON is staged inline', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'p2p-exp-')); + const c = ctx({ input: { peer_id: 'peer', format: 'json', start_date: '50' } }); + c._exportStageRoot = tmp; // test seam: chat.js resolves stage dirs under this when set + await ChatPrivate.prototype.p2p_export.call(c); + assert.equal(c.seen.data.wait, 0); + assert.equal(c.seen.data.zipid, 'zip123'); + assert.match(c.seen.data.zipname, /\.json$/); + const dir = path.join(tmp, 'me', 'zip123'); + const manifest = JSON.parse(fs.readFileSync(path.join(dir, '.file-thread-access.json'), 'utf8')); + assert.deepEqual(manifest, { schema_version: 1, hub_id: 'me', file_threads: [] }); + const out = JSON.parse(fs.readFileSync(path.join(dir, c.seen.data.zipname), 'utf8')); + assert.equal(out.sections[0].type, 'direct_chat'); + assert.equal(out.sections[0].messages.length, 2); + assert.ok(c.calls.some((x) => x[0] === 'p2p_export_count' && x[2] === 50)); +}); From 4e735be14da637bdaa015b35e038153fcd568809 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 20:45:48 -0700 Subject: [PATCH 07/10] fix(chat): direct-chat endpoints only on the caller's own hub this.db is the DB of the hub the request names. A caller with write on someone else's shared folder could name that hub and read or export the owner's DMs with a contact. _p2pRelated (p2p_details, p2p_media_list, p2p_export_scope, p2p_export) now refuses unless the hub is the caller's. Co-Authored-By: Claude Opus 5.5 (1M context) --- service/private/chat.js | 6 ++++++ test/chat-p2p-details.test.js | 18 +++++++++++++++++- test/chat-p2p-export.test.js | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/service/private/chat.js b/service/private/chat.js index 9c4bffdd..56e1cd77 100644 --- a/service/private/chat.js +++ b/service/private/chat.js @@ -1163,6 +1163,12 @@ class privateChat extends Entity { * of reach of a caller walking ids. */ async _p2pRelated(peer_id) { + // this.db is the DB of the hub the REQUEST names (acl scope: hub). A + // direct conversation is only ever the caller's own: naming someone + // else's hub (e.g. a folder they shared with write) must not reach their + // DMs with their contacts. + const hub_id = this.hub && typeof this.hub.get === "function" ? this.hub.get(Attr.id) : null; + if (!hub_id || `${hub_id}` !== `${this.uid}`) return false; const [contact, talked] = await Promise.all([ this.db.await_proc("my_contact_exists", "entity", peer_id, null, null), this.db.await_query("SELECT 1 AS ok FROM p2p_time WHERE peer_id=? LIMIT 1", peer_id), diff --git a/test/chat-p2p-details.test.js b/test/chat-p2p-details.test.js index 081730e3..491889a5 100644 --- a/test/chat-p2p-details.test.js +++ b/test/chat-p2p-details.test.js @@ -15,13 +15,15 @@ global.debug = {}; const ChatPrivate = require('../service/private/chat'); const { mediaInfoDuration } = require('../service/lib/media-duration'); -function ctx(db = {}, yp = {}, input = {}, homes = {}) { +function ctx(db = {}, yp = {}, input = {}, homes = {}, hub = 'me00000000000001') { const calls = { db: [], yp: [], q: [] }; const seen = {}; return { calls, seen, _p2pRelated: ChatPrivate.prototype._p2pRelated, uid: 'me00000000000001', + // The hub the request names (acl scope: hub) — normally the caller's own. + hub: { get: () => hub }, input: { need: (k) => { if (input[k] == null) throw new Error(`missing ${k}`); return input[k]; }, use: (k, d) => (input[k] == null ? d : input[k]), @@ -124,3 +126,17 @@ test('p2p_media_list: a stranger gets an empty page and the proc never runs', as assert.ok(!c.calls.db.some((x) => x[0] === 'p2p_media_list')); assert.deepEqual(c.seen.list, []); }); + +// Review: this.db is the DB of the hub the REQUEST names. A caller with write +// on someone else's shared folder could name that hub and read the owner's +// DMs with a contact. A direct conversation is only ever the caller's own. +test("someone else's hub: no DM data, even for that hub owner's contact", async () => { + const d = ctx({ p2p_media_stats: [{ photos: '3' }], p2p_time: ['peer'] }, { drumate_presence: [{ id: 'me00000000000001' }] }, { peer_id: 'peer' }, {}, 'owner0000000000a'); + await ChatPrivate.prototype.p2p_details.call(d); + assert.ok(!d.calls.db.some((x) => x[0] === 'p2p_media_stats')); + assert.deepEqual(d.calls.yp, [['drumate_presence', JSON.stringify(['me00000000000001'])]]); + const l = ctx({ p2p_media_list: [{ nid: 'i1' }], p2p_time: ['peer'] }, {}, { peer_id: 'peer', kind: 'photo' }, {}, 'owner0000000000a'); + await ChatPrivate.prototype.p2p_media_list.call(l); + assert.ok(!l.calls.db.some((x) => x[0] === 'p2p_media_list')); + assert.deepEqual(l.seen.list, []); +}); diff --git a/test/chat-p2p-export.test.js b/test/chat-p2p-export.test.js index c2879f37..b9268cff 100644 --- a/test/chat-p2p-export.test.js +++ b/test/chat-p2p-export.test.js @@ -95,3 +95,22 @@ test('the DM manifest pins the viewer\'s own hub; JSON is staged inline', async assert.equal(out.sections[0].messages.length, 2); assert.ok(c.calls.some((x) => x[0] === 'p2p_export_count' && x[2] === 50)); }); + +// Review: the export endpoints refuse a hub that is not the caller's own +// (this.db would be that hub owner's DB, and their DMs with a contact). +test("someone else's hub: no DM export or scope", async () => { + const mk = (input) => { + const c = ctx({ input }); + c._p2pRelated = ChatPrivate.prototype._p2pRelated; + c.hub = { get: () => 'owner' }; + c.db.await_query = async () => [{ ok: 1 }]; // the owner talked to the peer + return c; + }; + const e = mk({ peer_id: 'peer', format: 'json' }); + await ChatPrivate.prototype.p2p_export.call(e); + assert.equal(e.seen.data.status, 'INVALID_PEER'); + assert.ok(!e.calls.some((x) => /^p2p_export_/.test(x[0]))); + const s = mk({ peer_id: 'peer' }); + await ChatPrivate.prototype.p2p_export_scope.call(s); + assert.equal(s.seen.data.hub.message_count, 0); +}); From 9b4b40a35213ed867117af832a3dfd67a65556f2 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 20:47:54 -0700 Subject: [PATCH 08/10] perf(chat): DM export reads the whole conversation in one call Co-Authored-By: Claude Opus 5.5 (1M context) --- service/lib/p2p-export.js | 16 ++++++++-------- test/chat-p2p-export.test.js | 15 ++++++++++----- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/service/lib/p2p-export.js b/service/lib/p2p-export.js index 0713467c..6a3bc710 100644 --- a/service/lib/p2p-export.js +++ b/service/lib/p2p-export.js @@ -6,7 +6,7 @@ */ const { toArray } = require("@drumee/server-essentials"); -const PAGE_SIZE = 45; // p2p_export_messages' page +const PAGE_SIZE = 45; // p2p_export_messages' page (_page >= 1) function parseAttachments(attachment) { if (!attachment) return []; @@ -38,14 +38,14 @@ function normalizeP2pRow(row) { }; } +/** + * The whole DM in ONE call: page 0 = every message. The export is capped by + * p2p_export_count (10k), and paging would re-copy the conversation into the + * procedure's temp table once per 45-row page. + */ async function gatherP2pSection(db, peer_id, name, start, end) { - const messages = []; - for (let page = 1; ; page++) { - const rows = toArray(await db.await_proc("p2p_export_messages", peer_id, start || null, end || null, page)); - for (const r of rows) messages.push(normalizeP2pRow(r)); - if (rows.length < PAGE_SIZE) break; - } - return { type: "direct_chat", name, messages }; + const rows = toArray(await db.await_proc("p2p_export_messages", peer_id, start || null, end || null, 0)); + return { type: "direct_chat", name, messages: rows.map(normalizeP2pRow) }; } module.exports = { normalizeP2pRow, gatherP2pSection, PAGE_SIZE }; diff --git a/test/chat-p2p-export.test.js b/test/chat-p2p-export.test.js index b9268cff..7ccde9d3 100644 --- a/test/chat-p2p-export.test.js +++ b/test/chat-p2p-export.test.js @@ -41,13 +41,18 @@ test('normalizeP2pRow maps a p2p row onto the export message shape', () => { assert.equal(m.attachments.length, 1); }); -test('gatherP2pSection pages until a short page', async () => { - const full = Array.from({ length: 45 }, (_, i) => ({ message_id: `a${i}`, author_id: 'me', message: 'x', ctime: i })); - const c = ctx({ pages: [full, [{ message_id: 'z', author_id: 'peer', message: 'y', ctime: 99 }]] }); - const s = await gatherP2pSection(c.db, 'peer', 'Ann Peer', null, null); +// Review: paging re-copied the whole DM into a temp table per 45-row page +// (≈223 round trips near the 10k cap). The export is count-capped, so the +// section is read in ONE call: p2p_export_messages page 0 = every message. +test('gatherP2pSection reads the whole DM in one call (page 0)', async () => { + const all = Array.from({ length: 120 }, (_, i) => ({ message_id: `a${i}`, author_id: 'me', message: 'x', ctime: i })); + const calls = []; + const db = { await_proc: async (n, ...a) => (calls.push([n, ...a]), a[3] === 0 ? all : all.slice(0, 45)) }; + const s = await gatherP2pSection(db, 'peer', 'Ann Peer', 5, 9); assert.equal(s.type, 'direct_chat'); assert.equal(s.name, 'Ann Peer'); - assert.equal(s.messages.length, 46); + assert.equal(s.messages.length, 120); + assert.deepEqual(calls, [['p2p_export_messages', 'peer', 5, 9, 0]]); }); test('p2p_export_scope answers the export_scope shape', async () => { From fc5eac3aca233949c21ddb051b7a108e5f65bc16 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 21:18:36 -0700 Subject: [PATCH 09/10] feat(channel): folder chat topics (topic_list, topic_create, topic_id on messages/post) Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/channel.json | 20 +++++ service/private/channel.js | 138 +++++++++++++++++++++++++------- test/channel-topics.test.js | 153 ++++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 27 deletions(-) create mode 100644 test/channel-topics.test.js diff --git a/acl/channel.json b/acl/channel.json index 2e30a7b6..f5bf4f58 100644 --- a/acl/channel.json +++ b/acl/channel.json @@ -174,6 +174,7 @@ "src": "read" }, "params": { + "topic_id": { "type": "string", "required": false, "doc": "Folder chat topic: absent or all = the whole folder chat, general = without topic messages, a topic id = that topic (must belong to nid)" }, "order": { "type": "string", "required": false, @@ -230,6 +231,7 @@ "src": "read" }, "params": { + "topic_id": { "type": "string", "required": false, "doc": "Post into this folder chat topic (must exist and belong to nid); absent = no topic" }, "message": { "type": "string", "required": false, @@ -1047,6 +1049,24 @@ "returns": { "type": "array", "doc": "File-thread summaries with current file metadata" }, "errors": [] }, + "topic_list": { + "doc": "Folder chat topics of one folder, oldest first, each with the caller's unread (other people's messages after the caller's per-topic read cursor). Empty for a member without chat access.", + "scope": "hub", + "permission": { "src": "read" }, + "params": { + "folder_nid": { "type": "string", "required": true, "doc": "Media node ID of the folder" } + } + }, + "topic_create": { + "doc": "Create a topic in a folder's team chat. Name 1-60 characters after trim, unique per folder (case-insensitive) -> TOPIC_EXISTS; emoji one picker glyph (<= 16 bytes). Requires chat access (CAN_CHAT) -> FORBIDDEN. Invalid input -> INVALID_TOPIC.", + "scope": "hub", + "permission": { "src": "read" }, + "params": { + "folder_nid": { "type": "string", "required": true, "doc": "Media node ID of the folder" }, + "name": { "type": "string", "required": true, "doc": "Topic name" }, + "emoji": { "type": "string", "required": true, "doc": "Topic icon (an emoji)" } + } + }, "details": { "doc": "Chat details overview for the workspace team chat: photo/video/file/link counts and the hub's members with presence (online, last_seen). Team chat only (file threads excluded).", "scope": "hub", diff --git a/service/private/channel.js b/service/private/channel.js index 76780673..ffb443da 100644 --- a/service/private/channel.js +++ b/service/private/channel.js @@ -43,6 +43,11 @@ const { tmp_dir, mfs_dir } = sysEnv(); const SPAWN_OPT = { detached: true, stdio: ["ignore", "ignore", "ignore"] }; const OFFLINE_DIR = pathResolve(__dirname, "..", "..", "offline", "media"); const EXPORT_CAP = 10000; +// Folder chat topic ids (channel_topic.id, 16 chars). They reach a proc-call +// string through channel.post's metadata, so the pattern is also a guard. +const TOPIC_ID_RE = /^[0-9a-zA-Z]{1,16}$/; +const TOPIC_SCOPES = new Set(["all", "general"]); +const isTopicId = (t) => !!t && !TOPIC_SCOPES.has(`${t}`) && TOPIC_ID_RE.test(`${t}`); const EXPORT_MIME = { json: "application/json", pdf: "application/pdf" }; const MFS_PERMISSION_READ = (Constants.permission && Constants.permission.read) || 0b0000010; @@ -125,23 +130,31 @@ class __private_channel extends Entity { /** * */ - async messages() { - const order = this.input.use(Attr.order, "asc"); - const page = this.input.use(Attr.page) || 1; - const nid = this.input.use(Attr.nid); - // `mark_read: 0` — the client is showing the history, not reading it (a - // workspace team chat mounted beside the file grid). Absent = the old - // behaviour, so every other caller still marks read on load. - const markRead = `${this.input.use("mark_read", 1)}` !== "0"; - let data = await this.db.await_proc( - "channel_list_messages", - this.uid, - "date", - order, - page, + // ── Folder chat topics (channel_topic) ───────────────────────────────── + // A topic is a named sub-conversation of one folder's team chat; a message + // belongs to it through metadata._topic_id, as it belongs to a folder + // through _scope_nid. + + /** The active topic `topic_id` when it belongs to folder `nid`, else null. */ + async _topicInFolder(topic_id, nid) { + if (!isTopicId(topic_id) || isEmpty(nid)) return null; + const row = toArray(await this.db.await_proc("channel_topic_get", `${topic_id}`))[0]; + return row && `${row.folder_nid}` === `${nid}` ? row : null; + } + + /** Rows for a chat scope: the folder chat, its General part, or a topic. */ + async _listRowsForScope(nid, topic_id, order, page) { + if (topic_id != null && topic_id !== "" && !TOPIC_SCOPES.has(`${topic_id}`)) { + if (!(await this._topicInFolder(topic_id, nid))) return []; + return toArray( + await this.db.await_proc("channel_topic_messages", this.uid, `${topic_id}`, order, page), + ); + } + let data = toArray( + await this.db.await_proc("channel_list_messages", this.uid, "date", order, page), ); - data = toArray(data); - if (!isEmpty(nid)) { + const general = `${topic_id}` === "general"; + if (!isEmpty(nid) || general) { // Legacy messages (no _scope_nid) appear in every folder context for // backward compatibility. New messages scoped via _scope_nid stay isolated. data = data.filter((msg) => { @@ -150,12 +163,84 @@ class __private_channel extends Entity { typeof msg.metadata === "string" ? JSON.parse(msg.metadata) : msg.metadata || {}; - return !meta._scope_nid || meta._scope_nid === `${nid}`; + if (general && meta._topic_id) return false; + return isEmpty(nid) || !meta._scope_nid || meta._scope_nid === `${nid}`; } catch (e) { return true; } }); } + return data; + } + + /** Mark the scope read: a topic its own cursor, else the hub's. */ + async _markScopeRead(topic_id, newest, markRead) { + if (!markRead) return; + if (isTopicId(topic_id)) { + await this.db.await_proc("channel_topic_mark_read", this.uid, `${topic_id}`); + return; + } + if (newest && newest.message_id) { + await this.db.await_proc("channel_read_messages", newest.message_id, this.uid); + } + } + + /** channel.post: no topic, or one that exists in the posted folder. */ + async _postTopicGuard(topic_id, nid) { + if (topic_id == null || topic_id === "" || TOPIC_SCOPES.has(`${topic_id}`)) return { ok: true }; + if (!(await this._topicInFolder(topic_id, nid))) return { ok: false, status: "INVALID_TOPIC" }; + return { ok: true }; + } + + /** The metadata that scopes a posted message (folder, and topic if any). */ + _scopeMetadata(nid, topic_id) { + if (isEmpty(nid)) return undefined; + const meta = { _scope_nid: `${nid}` }; + if (isTopicId(topic_id)) meta._topic_id = `${topic_id}`; + return meta; + } + + /** + * A folder's topics with the caller's unread (Topics section of the thread + * menu). A member without chat access gets none. + */ + async topic_list() { + if (!(await memberCan(this, CAN_CHAT))) return this.output.list([]); + const folder_nid = `${this.input.use("folder_nid") || ""}`; + if (!folder_nid) return this.output.list([]); + this.output.list(toArray(await this.db.await_proc("channel_topic_list", this.uid, folder_nid))); + } + + /** New topic in a folder's team chat (name 1–60, one emoji). */ + async topic_create() { + if (!(await memberCan(this, CAN_CHAT))) return this.output.data({ status: "FORBIDDEN" }); + const folder_nid = `${this.input.use("folder_nid") || ""}`; + const name = `${this.input.use("name") || ""}`.trim(); + const emoji = `${this.input.use("emoji") || ""}`; + const len = [...name].length; + if (!folder_nid || len < 1 || len > 60 || !emoji || Buffer.byteLength(emoji) > 16) { + return this.output.data({ status: "INVALID_TOPIC" }); + } + const row = toArray( + await this.db.await_proc("channel_topic_create", this.uid, folder_nid, name, emoji), + )[0]; + if (!row) return this.output.data({ status: "ERROR" }); + if (row.error) return this.output.data({ status: `${row.error}` }); + this.output.data(row); + } + + async messages() { + const order = this.input.use(Attr.order, "asc"); + const page = this.input.use(Attr.page) || 1; + const nid = this.input.use(Attr.nid); + // `mark_read: 0` — the client is showing the history, not reading it (a + // workspace team chat mounted beside the file grid). Absent = the old + // behaviour, so every other caller still marks read on load. + const markRead = `${this.input.use("mark_read", 1)}` !== "0"; + // Folder chat topic: absent / "all" = the whole folder chat, "general" = + // without topic messages, an id = that topic (_listRowsForScope). + const topic_id = this.input.use("topic_id"); + let data = await this._listRowsForScope(nid, topic_id, order, page); let messages = []; let cache = {}; @@ -213,13 +298,7 @@ class __private_channel extends Entity { for (const m of messages) { if (!newest || (m.ctime || 0) > (newest.ctime || 0)) newest = m; } - if (markRead && newest && newest.message_id) { - await this.db.await_proc( - "channel_read_messages", - newest.message_id, - this.uid, - ); - } + await this._markScopeRead(topic_id, newest, markRead); // Former members stay in _seen_ forever; show only current readers. await pruneToCurrentReaders(this, messages); let dest = await this.yp.await_proc("entity_sockets", hub_id); @@ -1586,6 +1665,12 @@ class __private_channel extends Entity { } } + // A topic must exist and belong to the posted folder — checked before + // anything is minted or moved. + const topic_id = this.input.use("topic_id"); + const topicCheck = await this._postTopicGuard(topic_id, this.input.use(Attr.nid)); + if (!topicCheck.ok) return this.output.data({ status: topicCheck.status }); + // Existence guard BEFORE minting message_id (~:1070 below) — a folder // that moved/vanished cross-hub must not burn an id nor write anything; // checked ahead of the staging-promote block so a gone scope also skips @@ -1705,9 +1790,8 @@ class __private_channel extends Entity { if (!isEmpty(thread_id)) { input.thread_id = thread_id; } - if (!isEmpty(nid)) { - input.metadata = { _scope_nid: `${nid}` }; - } + const scopeMeta = this._scopeMetadata(nid, topic_id); + if (scopeMeta) input.metadata = scopeMeta; // Persist @-mentions so the message shows in the recipient's Mentions tab — // channel_list_notifications filters on mention_ids, and channel_post_message // reads `$.mention_ids` from the input JSON and stores it on the row. Without diff --git a/test/channel-topics.test.js b/test/channel-topics.test.js new file mode 100644 index 00000000..32fec779 --- /dev/null +++ b/test/channel-topics.test.js @@ -0,0 +1,153 @@ +// channel-topics.test.js — folder chat topics: channel.topic_list / +// topic_create, and the scope helpers channel.messages and channel.post use +// for a topic_id. +// +// node --test test/channel-topics.test.js +const assert = require('node:assert/strict'); +const test = require('node:test'); + +global.myDrumee = { arch: 'pod', useEmail: 0 }; +global.verbosity = 0; +global.debug = {}; + +const Channel = require('../service/private/channel'); +const P = Channel.prototype; + +const HELPERS = ['_topicInFolder', '_listRowsForScope', '_markScopeRead', '_postTopicGuard', '_scopeMetadata']; + +// procs: name → value | (…args) => value. priv: the caller's privilege on the +// hub root (memberCan reads it through mfs_access_node; 7 = chat, 3 = view). +function ctx({ input = {}, procs = {}, priv = 7 } = {}) { + const calls = []; + const seen = {}; + const c = { + calls, seen, uid: 'me00000000000001', + input: { + use: (k, d) => (input[k] == null ? d : input[k]), + need: (k) => input[k], + get: (k) => input[k], + }, + db: { + await_proc: async (n, ...a) => { + if (n === 'mfs_home') return { home_id: 'root000000000001' }; + if (n === 'mfs_access_node') return { privilege: priv }; + calls.push([n, ...a]); + const v = procs[n]; + return typeof v === 'function' ? v(...a) : v; + }, + }, + output: { data: (d) => (seen.data = d), list: (l) => (seen.list = l) }, + exception: { forbiden: () => (seen.forbidden = 1) }, + warn() {}, + }; + for (const m of HELPERS) if (P[m]) c[m] = P[m]; + return c; +} +const TOPIC = { id: 't000000000000001', folder_nid: 'fA', name: 'Design', emoji: '😀' }; + +test('topic_list passes the folder and answers the rows', async () => { + const c = ctx({ input: { folder_nid: 'fA' }, procs: { channel_topic_list: [{ ...TOPIC, unread: 2 }] } }); + await P.topic_list.call(c); + assert.deepEqual(c.calls, [['channel_topic_list', 'me00000000000001', 'fA']]); + assert.deepEqual(c.seen.list, [{ ...TOPIC, unread: 2 }]); +}); + +test('topic_create validates name (1–60 after trim) and emoji (non-empty, ≤16 bytes)', async () => { + for (const input of [ + { folder_nid: 'fA', name: '', emoji: '😀' }, + { folder_nid: 'fA', name: ' ', emoji: '😀' }, + { folder_nid: 'fA', name: 'x'.repeat(61), emoji: '😀' }, + { folder_nid: 'fA', name: 'Design', emoji: '' }, + { folder_nid: 'fA', name: 'Design', emoji: '😀'.repeat(5) }, + { folder_nid: '', name: 'Design', emoji: '😀' }, + ]) { + const c = ctx({ input }); + await P.topic_create.call(c); + assert.equal(c.seen.data.status, 'INVALID_TOPIC', JSON.stringify(input)); + assert.equal(c.calls.length, 0); + } + const ok = ctx({ input: { folder_nid: 'fA', name: ' Design ', emoji: '😀' }, procs: { channel_topic_create: [TOPIC] } }); + await P.topic_create.call(ok); + assert.deepEqual(ok.calls, [['channel_topic_create', 'me00000000000001', 'fA', 'Design', '😀']]); + assert.deepEqual(ok.seen.data, TOPIC); +}); + +test('topic_create answers TOPIC_EXISTS from the proc', async () => { + const c = ctx({ input: { folder_nid: 'fA', name: 'Design', emoji: '😀' }, procs: { channel_topic_create: [{ error: 'TOPIC_EXISTS' }] } }); + await P.topic_create.call(c); + assert.deepEqual(c.seen.data, { status: 'TOPIC_EXISTS' }); +}); + +test('no chat access: topic_list empty, topic_create refused', async () => { + const l = ctx({ input: { folder_nid: 'fA' }, priv: 3, procs: { channel_topic_list: [TOPIC] } }); + await P.topic_list.call(l); + assert.deepEqual(l.seen.list, []); + assert.equal(l.calls.length, 0); + const c = ctx({ input: { folder_nid: 'fA', name: 'Design', emoji: '😀' }, priv: 3 }); + await P.topic_create.call(c); + assert.deepEqual(c.seen.data, { status: 'FORBIDDEN' }); + assert.equal(c.calls.length, 0); +}); + +const rows = [ + { message_id: 'm1', metadata: JSON.stringify({ _scope_nid: 'fA' }) }, + { message_id: 'm2', metadata: JSON.stringify({ _scope_nid: 'fA', _topic_id: TOPIC.id }) }, + { message_id: 'm3', metadata: JSON.stringify({ _scope_nid: 'fB' }) }, + { message_id: 'm4', metadata: null }, +]; + +test('messages scope: absent / all keep topic messages; general drops them', async () => { + for (const topic_id of [undefined, 'all']) { + const c = ctx({ procs: { channel_list_messages: rows } }); + const got = await c._listRowsForScope('fA', topic_id, 'desc', 1); + assert.deepEqual(got.map((r) => r.message_id), ['m1', 'm2', 'm4'], String(topic_id)); + } + const g = ctx({ procs: { channel_list_messages: rows } }); + assert.deepEqual((await g._listRowsForScope('fA', 'general', 'desc', 1)).map((r) => r.message_id), ['m1', 'm4']); +}); + +test('messages scope: a topic id lists that topic; marks it read (mark_read=0 does not)', async () => { + const c = ctx({ procs: { channel_topic_get: [TOPIC], channel_topic_messages: [rows[1]] } }); + const got = await c._listRowsForScope('fA', TOPIC.id, 'desc', 2); + assert.deepEqual(got.map((r) => r.message_id), ['m2']); + assert.ok(c.calls.some((x) => x[0] === 'channel_topic_messages' && x[2] === TOPIC.id && x[4] === 2)); + await c._markScopeRead(TOPIC.id, { message_id: 'm2' }, true); + assert.ok(c.calls.some((x) => x[0] === 'channel_topic_mark_read' && x[2] === TOPIC.id)); + assert.ok(!c.calls.some((x) => x[0] === 'channel_read_messages'), 'a topic read never moves the hub cursor'); + const n = ctx(); + await n._markScopeRead(TOPIC.id, { message_id: 'm2' }, false); + assert.equal(n.calls.length, 0); + const all = ctx(); + await all._markScopeRead(undefined, { message_id: 'm9' }, true); + assert.deepEqual(all.calls, [['channel_read_messages', 'm9', 'me00000000000001']]); +}); + +test('topic of another folder is refused (post and messages)', async () => { + const c = ctx({ procs: { channel_topic_get: [{ ...TOPIC, folder_nid: 'fB' }] } }); + assert.deepEqual(await c._listRowsForScope('fA', TOPIC.id, 'desc', 1), []); + assert.ok(!c.calls.some((x) => x[0] === 'channel_topic_messages')); + assert.deepEqual(await c._postTopicGuard(TOPIC.id, 'fA'), { ok: false, status: 'INVALID_TOPIC' }); + const gone = ctx({ procs: { channel_topic_get: [] } }); + assert.deepEqual(await gone._postTopicGuard(TOPIC.id, 'fA'), { ok: false, status: 'INVALID_TOPIC' }); + const ok = ctx({ procs: { channel_topic_get: [TOPIC] } }); + assert.deepEqual(await ok._postTopicGuard(TOPIC.id, 'fA'), { ok: true }); + const none = ctx(); + assert.deepEqual(await none._postTopicGuard(undefined, 'fA'), { ok: true }); + assert.equal(none.calls.length, 0); +}); + +test('post metadata: _topic_id next to _scope_nid; none without a topic', () => { + const c = ctx(); + assert.deepEqual(c._scopeMetadata('fA', TOPIC.id), { _scope_nid: 'fA', _topic_id: TOPIC.id }); + assert.deepEqual(c._scopeMetadata('fA', undefined), { _scope_nid: 'fA' }); + assert.equal(c._scopeMetadata('', undefined), undefined); +}); + +test('topic_id must be [0-9a-zA-Z]{1,16} (it reaches a proc-call string)', async () => { + for (const bad of ["x';DROP", 'a'.repeat(17), 'a-b']) { + const c = ctx({ procs: { channel_topic_get: [TOPIC] } }); + assert.deepEqual(await c._postTopicGuard(bad, 'fA'), { ok: false, status: 'INVALID_TOPIC' }, bad); + assert.deepEqual(await c._listRowsForScope('fA', bad, 'desc', 1), [], bad); + assert.equal(c.calls.length, 0, bad); + } +}); From 965e81357b5bdaa17a8ae2a213f100a704665d54 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Tue, 29 Sep 2026 21:43:24 -0700 Subject: [PATCH 10/10] fix(channel): topic reads move only the topic cursor; # General paged in SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - channel.acknowledge with topic_id (the team chat reads on interaction): channel_topic_mark_read only, never the hub read_channel cursor that would mark General and other topics read; a message outside the topic is refused. - messages topic_id=general → channel_general_messages (full SQL pages). Co-Authored-By: Claude Opus 5.5 (1M context) --- acl/channel.json | 1 + service/private/channel.js | 51 +++++++++++++++++++++++++++++++++---- test/channel-topics.test.js | 39 +++++++++++++++++++++++++--- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/acl/channel.json b/acl/channel.json index f5bf4f58..bcccbc12 100644 --- a/acl/channel.json +++ b/acl/channel.json @@ -7,6 +7,7 @@ "src": "read" }, "params": { + "topic_id": { "type": "string", "required": false, "doc": "Read inside a folder chat topic: moves only that topic's cursor (and receipts); the message must belong to it" }, "message_id": { "type": "string", "required": false, diff --git a/service/private/channel.js b/service/private/channel.js index ffb443da..793c11c4 100644 --- a/service/private/channel.js +++ b/service/private/channel.js @@ -150,11 +150,16 @@ class __private_channel extends Entity { await this.db.await_proc("channel_topic_messages", this.uid, `${topic_id}`, order, page), ); } + // # General is filtered in SQL (channel_general_messages, on the indexed + // channel.topic_id): a JS filter shortened pages and the list stopped + // paging at the first short one. + const general = `${topic_id}` === "general"; let data = toArray( - await this.db.await_proc("channel_list_messages", this.uid, "date", order, page), + general + ? await this.db.await_proc("channel_general_messages", this.uid, order, page) + : await this.db.await_proc("channel_list_messages", this.uid, "date", order, page), ); - const general = `${topic_id}` === "general"; - if (!isEmpty(nid) || general) { + if (!isEmpty(nid)) { // Legacy messages (no _scope_nid) appear in every folder context for // backward compatibility. New messages scoped via _scope_nid stay isolated. data = data.filter((msg) => { @@ -163,8 +168,7 @@ class __private_channel extends Entity { typeof msg.metadata === "string" ? JSON.parse(msg.metadata) : msg.metadata || {}; - if (general && meta._topic_id) return false; - return isEmpty(nid) || !meta._scope_nid || meta._scope_nid === `${nid}`; + return !meta._scope_nid || meta._scope_nid === `${nid}`; } catch (e) { return true; } @@ -185,6 +189,29 @@ class __private_channel extends Entity { } } + /** + * channel.acknowledge inside a topic: move that topic's cursor (and its + * receipts) only — never the hub-wide read_channel cursor, which would mark + * General and other topics read. `handled: false` when no topic is named + * (the hub path runs as before); a message outside the named topic is + * refused and nothing moves. + */ + async _acknowledgeTopic(topic_id, message_id) { + if (!isTopicId(topic_id) || !message_id) return { handled: false }; + const message = await this.db.await_proc("channel_get", message_id); + let meta = message && message.metadata; + try { + meta = typeof meta === "string" ? JSON.parse(meta) : meta || {}; + } catch (e) { + meta = {}; + } + if (!message || `${(meta && meta._topic_id) || ""}` !== `${topic_id}`) { + return { handled: true, status: "INVALID_TOPIC" }; + } + await this.db.await_proc("channel_topic_mark_read", this.uid, `${topic_id}`); + return { handled: true, message }; + } + /** channel.post: no topic, or one that exists in the posted folder. */ async _postTopicGuard(topic_id, nid) { if (topic_id == null || topic_id === "" || TOPIC_SCOPES.has(`${topic_id}`)) return { ok: true }; @@ -2850,6 +2877,20 @@ class __private_channel extends Entity { } } + // Read inside a folder chat topic: that topic's cursor only. + const topicAck = await this._acknowledgeTopic(this.input.use("topic_id"), message_id); + if (topicAck.handled) { + if (topicAck.status) return this.output.data({ status: topicAck.status }); + const message = topicAck.message; + message.key_id = this.hub.get(Attr.id); + const recipients = await this.yp.await_proc("entity_sockets", { + hub_id: message.key_id, + exclude, + }); + await RedisStore.sendData(this.payload(message), recipients); + return this.output.data({}); + } + let res = {}; res = await this.db.await_proc("acknowledge_message", message_id, this.uid); // Persist the reader into metadata._seen_ for every message up to message_id diff --git a/test/channel-topics.test.js b/test/channel-topics.test.js index 32fec779..e0156f51 100644 --- a/test/channel-topics.test.js +++ b/test/channel-topics.test.js @@ -13,7 +13,7 @@ global.debug = {}; const Channel = require('../service/private/channel'); const P = Channel.prototype; -const HELPERS = ['_topicInFolder', '_listRowsForScope', '_markScopeRead', '_postTopicGuard', '_scopeMetadata']; +const HELPERS = ['_topicInFolder', '_listRowsForScope', '_markScopeRead', '_postTopicGuard', '_scopeMetadata', '_acknowledgeTopic']; // procs: name → value | (…args) => value. priv: the caller's privilege on the // hub root (memberCan reads it through mfs_access_node; 7 = chat, 3 = view). @@ -96,14 +96,21 @@ const rows = [ { message_id: 'm4', metadata: null }, ]; -test('messages scope: absent / all keep topic messages; general drops them', async () => { +test('messages scope: absent / all keep topic messages; general is paged in SQL', async () => { for (const topic_id of [undefined, 'all']) { const c = ctx({ procs: { channel_list_messages: rows } }); const got = await c._listRowsForScope('fA', topic_id, 'desc', 1); assert.deepEqual(got.map((r) => r.message_id), ['m1', 'm2', 'm4'], String(topic_id)); } - const g = ctx({ procs: { channel_list_messages: rows } }); - assert.deepEqual((await g._listRowsForScope('fA', 'general', 'desc', 1)).map((r) => r.message_id), ['m1', 'm4']); + // Review: filtering General in JS shortened pages and ui-core stopped + // paging — channel_general_messages filters in SQL (full pages). + const general = rows.filter((r) => !/_topic_id/.test(r.metadata || '')); + const g = ctx({ procs: { channel_general_messages: general } }); + assert.deepEqual((await g._listRowsForScope('fA', 'general', 'desc', 3)).map((r) => r.message_id), ['m1', 'm4']); + assert.deepEqual(g.calls, [['channel_general_messages', 'me00000000000001', 'desc', 3]]); + // Workspace team chat (no folder scope): General is the whole hub's. + const w = ctx({ procs: { channel_general_messages: general.concat([{ message_id: 'm7', metadata: JSON.stringify({ _scope_nid: 'fB' }) }]) } }); + assert.deepEqual((await w._listRowsForScope(undefined, 'general', 'desc', 1)).map((r) => r.message_id), ['m1', 'm3', 'm4', 'm7']); }); test('messages scope: a topic id lists that topic; marks it read (mark_read=0 does not)', async () => { @@ -151,3 +158,27 @@ test('topic_id must be [0-9a-zA-Z]{1,16} (it reaches a proc-call string)', async assert.equal(c.calls.length, 0, bad); } }); + +// Review: the team chat reads through channel.acknowledge (read on +// interaction), so a topic's cursor must move there — and ONLY it: the +// hub-wide cursor would mark General read and send false receipts. +test('acknowledge inside a topic moves only that topic cursor', async () => { + const msg = { message_id: 'm2', metadata: JSON.stringify({ _scope_nid: 'fA', _topic_id: TOPIC.id }) }; + const c = ctx({ procs: { channel_get: msg } }); + const r = await c._acknowledgeTopic(TOPIC.id, 'm2'); + assert.equal(r.handled, true); + assert.equal(r.message, msg); + assert.ok(c.calls.some((x) => x[0] === 'channel_topic_mark_read' && x[2] === TOPIC.id)); + assert.ok(!c.calls.some((x) => x[0] === 'acknowledge_message' || x[0] === 'channel_read_messages')); + // A message of another topic / General under a topic_id: refused, nothing moves. + const other = ctx({ procs: { channel_get: { message_id: 'm1', metadata: JSON.stringify({ _scope_nid: 'fA' }) } } }); + const r2 = await other._acknowledgeTopic(TOPIC.id, 'm1'); + assert.deepEqual({ handled: r2.handled, status: r2.status }, { handled: true, status: 'INVALID_TOPIC' }); + assert.ok(!other.calls.some((x) => /mark_read|acknowledge_message|read_messages/.test(x[0]))); + // No topic (All / General): not handled here — the hub path runs as before. + for (const t of [undefined, '', 'all', 'general', "x';DROP"]) { + const n = ctx(); + assert.equal((await n._acknowledgeTopic(t, 'm1')).handled, false, String(t)); + assert.equal(n.calls.length, 0); + } +});