Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions acl/activity.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand All @@ -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 } ]
}
},

Expand Down
40 changes: 40 additions & 0 deletions acl/channel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -174,6 +175,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,
Expand Down Expand Up @@ -230,6 +232,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,
Expand Down Expand Up @@ -1047,6 +1050,43 @@
"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",
"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",
Expand Down
40 changes: 40 additions & 0 deletions acl/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,46 @@
}
]
},
"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": []
},
"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.",
Expand Down
107 changes: 72 additions & 35 deletions offline/media/chat-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
}

const node = toArray(
await db.await_proc("mfs_access_node", uid, file_nid),

Check warning on line 195 in offline/media/chat-export.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=drumee_server-team&issues=AaD6s7S0vddVYjqFRWLy&open=AaD6s7S0vddVYjqFRWLy&pullRequest=245
)[0] || {};
const privilege = Number(node.privilege || node.permission || 0);
if (
Expand Down Expand Up @@ -373,7 +373,7 @@
for (const ft of selectedFts) {
if (`${ft.folder_nid}` !== `${folder.id}`) continue;
emittedThreads.add(`${ft.file_thread_id}`);
await pushThreadSection(ft, folder);

Check warning on line 376 in offline/media/chat-export.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=drumee_server-team&issues=AaD6s7S0vddVYjqFRWLz&open=AaD6s7S0vddVYjqFRWLz&pullRequest=245
}
}
for (const ft of selectedFts) {
Expand Down Expand Up @@ -414,6 +414,14 @@
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])) {
Expand Down Expand Up @@ -481,16 +489,31 @@
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) {

Check warning on line 501 in offline/media/chat-export.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=drumee_server-team&issues=AaD6s7S0vddVYjqFRWL4&open=AaD6s7S0vddVYjqFRWL4&pullRequest=245
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 });
Expand All @@ -501,32 +524,46 @@
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({
Expand Down
28 changes: 28 additions & 0 deletions service/lib/media-duration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Seconds of a video node, read from <home_dir>/__storage__/<nid>/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");

Check warning on line 12 in service/lib/media-duration.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=drumee_server-team&issues=AaD6s7RnvddVYjqFRWLs&open=AaD6s7RnvddVYjqFRWLs&pullRequest=245
const { readFile } = require("fs").promises;

Check warning on line 13 in service/lib/media-duration.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=drumee_server-team&issues=AaD6s7RnvddVYjqFRWLt&open=AaD6s7RnvddVYjqFRWLt&pullRequest=245

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;
}

Check warning on line 25 in service/lib/media-duration.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception, don't catch it at all, or explain in a comment why it is ignored.

See more on https://sonarcloud.io/project/issues?id=drumee_server-team&issues=AaD6s7RnvddVYjqFRWLv&open=AaD6s7RnvddVYjqFRWLv&pullRequest=245
}

module.exports = { mediaInfoDuration };
51 changes: 51 additions & 0 deletions service/lib/p2p-export.js
Original file line number Diff line number Diff line change
@@ -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 (_page >= 1)

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,
};
}

/**
* 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 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 };
Loading
Loading