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
15 changes: 15 additions & 0 deletions acl/dmz.json
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,21 @@
}
]
},
"meeting_files": {
"doc": "List a meeting share's attachments, authorised by the token alone. Answers names and sizes only; downloads go through file/orig under the link identity's grant. A password-protected link answers REQUIRED_PASSWORD until the session has passed the password.",
"scope": "hub",
"permission": {
"src": "anonymous",
"fast_check": "public-api"
},
"params": {
"token": {
"type": "string",
"required": true,
"description": "Meeting share token"
}
}
},
"list_by_token": {
"doc": "Read-only listing of a secure share's contents, authorised by the TOKEN alone. Exists because dmz.login refuses to bind a share identity onto a main-domain session (regsid guard), so a page served from the main domain can never obtain the grant media.show_node_by needs. This never touches the caller's session: no cookie_touch, no grant, no identity change. The listing target is derived from the token, never from client input. Refuses any share that is not plainly open — revoked, expired, invalid, locked, password-gated or email-gated all return a status and no items, because this endpoint performs no gate. A file share lists its parent folder hard-filtered to that file. Privileges are the anonymous guest's, clamped to the share's capabilities; sender-only fields are never echoed.",
"scope": "hub",
Expand Down
20 changes: 20 additions & 0 deletions acl/room.json
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,26 @@
"errors": []
},

"link_files": {
"doc": "Attach already-uploaded files (media nids in this hub) to a meeting the caller created. Appends to the meeting's attachment list (deduped, max 20) and, when the meeting already has a public link, grants that link download access to the new files. Returns the full list and any nids refused for the cap.",
"scope": "hub",
"permission": {
"src": "write",
"fast_check": "user_permission"
},
"params": {
"nid": {
"type": "string",
"required": true,
"description": "Meeting (schedule) node id"
},
"file_nids": {
"type": "array",
"required": true,
"description": "Media node ids to attach"
}
}
},
"public_link": {
"doc": "Generate a public shareable link for a meeting room node. Creates a DMZ grant token for the specified node, grants permission to the public_id system user, and returns a shareable URL containing the access token.",
"scope": "hub",
Expand Down
60 changes: 60 additions & 0 deletions service/dmz.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
} = Constants;
const { verifyPassword: verifySecureSharePassword } = require('./lib/secure-share-password');
const { secureShareCapPrivilege } = require('./lib/secure-share-write-guard');
const { attachmentsOf, isMeetingNode } = require('./lib/meeting-attachments');
const Jwt = require('jsonwebtoken');
const { resolve: _resolvePath } = require('path');
const { existsSync, readFileSync, statSync } = require('fs');
Expand Down Expand Up @@ -1159,7 +1160,7 @@
async reset_sessions() {
let members = await this.db.await_proc('dmz_notify_list', `all`) || [];
for (let m of members) {
await this.yp.await_proc('session_logout_by_admin', m.recipient_id);

Check warning on line 1163 in service/dmz.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=AaD878po83VE3RrOD3ln&open=AaD878po83VE3RrOD3ln&pullRequest=246
}
this.output.data(members);
}
Expand Down Expand Up @@ -1474,6 +1475,65 @@
});
}

/**
* A meeting link's attachments, authorised by the TOKEN alone — the
* companion to list_by_token for a `schedule` share. Names only; the bytes
* come from file/orig, which room.public_link / room.link_files opened to the
* link identity.
*
* A password-protected (or locked, or email-gated) link answers nothing:
* _shareByToken refuses gated shares outright, so the token alone never
* leaks the file names the password is meant to protect.
*
* Every nid comes through attachmentsOf, which only admits /^[0-9a-f]{16}$/,
* so interpolating it into the forward_proc argument string is safe.
*
* Input: token {String} required
* Output: { status, hub_id?, items[] }
*/
async meeting_files() {
const token = this.input.need(Attr.token);
const deny = (status) => this.output.data({ status, items: [] });
const share = await this._shareByToken(token, 'dmz.meeting_files');
if (share.status) return deny(share.status);
const { info } = share;
const nid = info.node_id || info.nid;
const attr = async (id) =>
toArray(await this.yp.await_proc('forward_proc', info.hub_id, 'mfs_node_attr', `'${id}'`))[0] || {};
let node;
try {
node = await attr(nid);
} catch (e) {
this.warn('[dmz.meeting_files] meeting lookup failed:', e && e.message);

Check warning on line 1507 in service/dmz.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=AaD878po83VE3RrOD3lo&open=AaD878po83VE3RrOD3lo&pullRequest=246
return deny('TICKET_INVALID');
}
if (!isMeetingNode(node)) return deny('NOT_A_MEETING');
let meta = {};
try {
meta = JSON.parse(node.metadata || '{}') || {};
} catch (e) {
meta = {};
}
const items = [];
for (const id of attachmentsOf(meta.content)) {
let a;
try {
a = await attr(id);

Check warning on line 1521 in service/dmz.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=AaD878po83VE3RrOD3lp&open=AaD878po83VE3RrOD3lp&pullRequest=246
} catch (e) {
continue;
}
if (!a || !(a.id || a.nid)) continue;
items.push({
nid: a.id || a.nid,
filename: a.filename || a.user_filename || '',
ext: a.ext || a.extension || '',
filetype: a.filetype || a.ftype || '',
filesize: a.filesize || 0,
});
}
this.output.data({ status: 'TICKET_OK', hub_id: info.hub_id, items });
}

/**
* Read-only workspace chat for a share, authorised BY THE TOKEN ALONE.
*
Expand Down Expand Up @@ -1559,9 +1619,9 @@
if (!id || authors[id] !== undefined) continue;
authors[id] = '';
try {
const row = toArray(await this.yp.await_proc(
'forward_proc', id, 'shareroom_contact_get', `'${id}'`
))[0] || {};

Check warning on line 1624 in service/dmz.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=AaD878po83VE3RrOD3lq&open=AaD878po83VE3RrOD3lq&pullRequest=246
// A real name when the account has one. Otherwise the LOCAL PART of
// the address the proc returns in `surname` — enough to tell two
// participants apart, which a conversation needs, without handing an
Expand Down
56 changes: 56 additions & 0 deletions service/lib/meeting-attachments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// The nid list a meeting (`schedule` node) keeps in metadata.content.attachments.
//
// Pure: no DB, no session. room.js and dmz.js both read through here, so the
// rule for what counts as a valid attachment id lives in one place — and
// because every nid is checked against NID_RE, callers may interpolate them
// into forward_proc argument strings (dmz.js does).
const NID_RE = /^[0-9a-f]{16}$/;
const MAX_ATTACHMENTS = 20;

function parse(v) {
if (typeof v !== "string") return v;
try {
return JSON.parse(v);
} catch (e) {
return v;
}
}

function normalizeNids(input) {
let v = parse(input);
if (v == null) return [];
if (!Array.isArray(v)) v = [v];
const out = [];
for (const n of v) {
if (typeof n !== "string" || !NID_RE.test(n) || out.includes(n)) continue;
out.push(n);
}
return out;
}

function attachmentsOf(content) {
const c = parse(content);
return normalizeNids(c && typeof c === "object" ? c.attachments : null);
}

function mergeAttachments(existing, incoming) {
const list = normalizeNids(existing);
const added = [];
const overflow = [];
for (const n of normalizeNids(incoming)) {
if (list.includes(n)) continue;
if (list.length >= MAX_ATTACHMENTS) {
overflow.push(n);
continue;
}
list.push(n);
added.push(n);
}
return { list, added, overflow };
}

function isMeetingNode(node) {
return !!(node && (node.filetype === "schedule" || node.category === "schedule"));
}

module.exports = { NID_RE, MAX_ATTACHMENTS, normalizeNids, attachmentsOf, mergeAttachments, isMeetingNode };
75 changes: 73 additions & 2 deletions service/private/room.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
const __public_room = require("../room");
const { Attr, Privilege, Cache, sysEnv, RedisStore, toArray } = require("@drumee/server-essentials")
const { memberCan, CAN_WRITE } = require("../lib/member-capability");
const { normalizeNids, attachmentsOf, mergeAttachments, isMeetingNode } = require("../lib/meeting-attachments");

//########################################
class __private_room extends __public_room {
Expand Down Expand Up @@ -54,13 +55,13 @@
recipients = [recipients];
}
for (var r of recipients) {
let g = await this.yp.await_proc('dmz_add_user', r.email, r.name);

Check warning on line 58 in service/private/room.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=AaD878e583VE3RrOD3lb&open=AaD878e583VE3RrOD3lb&pullRequest=246
let p = await this.yp.await_proc('dmz_grant_next', hub_id, nid,
g.id, this.randomString(), pw
);

Check warning on line 61 in service/private/room.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=AaD878e583VE3RrOD3lc&open=AaD878e583VE3RrOD3lc&pullRequest=246
await this.db.await_proc('permission_grant',
nid, g.id, expiry, permission, 'no_traversal', r.email
);

Check warning on line 64 in service/private/room.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=AaD878e583VE3RrOD3ld&open=AaD878e583VE3RrOD3ld&pullRequest=246

let mail_title = `${Cache.message('_video_meeting_invitation', this.lang).format(this.username)}`;

Expand All @@ -77,7 +78,7 @@
link: this._getShareLink(p.token),
}
if (notify) {
await this.notify_by_email({ hub_id: node.hub_id, ...opt });

Check warning on line 81 in service/private/room.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=AaD878e583VE3RrOD3le&open=AaD878e583VE3RrOD3le&pullRequest=246
}
}
}
Expand All @@ -102,6 +103,16 @@
await this.db.await_proc('permission_grant',
nid, public_id, expiry, permission, 'link', ''
);
// The link opens the meeting; its attachments have to open too, or a guest
// sees file names they cannot download. Same identity and expiry as the
// meeting grant, download tier only (Privilege.download, never write).
const meeting = await this.db.await_proc('mfs_node_attr', nid);
const meta = this.parseJSON((meeting && meeting.metadata) || '{}') || {};

Check warning on line 110 in service/private/room.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=AaD878e583VE3RrOD3lf&open=AaD878e583VE3RrOD3lf&pullRequest=246
for (const file_nid of attachmentsOf(meta.content)) {
await this.db.await_proc('permission_grant',
file_nid, public_id, expiry, Privilege.download, 'link', ''
);

Check warning on line 114 in service/private/room.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=AaD878e583VE3RrOD3lg&open=AaD878e583VE3RrOD3lg&pullRequest=246
}
this.debug("AAAA:104", p)
let link = this._getShareLink(p.token)
this.output.data({ link });
Expand Down Expand Up @@ -202,6 +213,10 @@
let stime = content.stime
let etime = content.etime
let recur = content.recur
// Not editable through update() — room.link_files owns the list — but it
// has to SURVIVE an edit: the Meet tab saves with flag 'all' and the
// content object below is rebuilt field by field.
const attachments = attachmentsOf(content);

// The start time BEFORE this edit, so a real reschedule can be told apart
// from a save that left the time alone. The scheduler posts the whole form
Expand Down Expand Up @@ -262,7 +277,7 @@
nid,
{
content: {
attendees, title, message, date, stime, etime, recur,
attendees, title, message, date, stime, etime, recur, attachments,
created_by: content.created_by, room_id: nid
},
room_status: 'booked'
Expand All @@ -282,28 +297,28 @@
// BOTH invited and rescheduled in one edit, which is rare enough not to
// be worth threading a shared value through two different conditions.
const folder_name = await this._meeting_folder_name(node && node.parent_id);
this._notify_invitees(addedUids, {
type: 'meeting_scheduled',
nid,
title,
date,
stime,
recur,
from: name,
folder_name,
// The card's "View Calendar" button opens this meeting in its
// workspace calendar. `nid` alone cannot do that — it is a node id
// inside ONE hub's database — so the hub travels with it, and the
// parent folder so the pane lands where the meeting is filed (the same
// hub_id/pid the durable meeting_notice row carries).
hub_id: this.hub.get(Attr.id),
pid: (node && node.parent_id) || null,
// The card's meta line counts who is invited ("N invited") and shows
// their faces, exactly as the reminder's does. `attendees` is already
// the normalised { uid, name } list built above, so this costs
// nothing extra — it simply was never sent.
attendees,
});

Check warning on line 321 in service/private/room.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.

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

// Durable notifications. The live push above is socket-only: it is gone on
Expand Down Expand Up @@ -339,7 +354,7 @@
}
}
content = {
attendees, title, message, date, stime, etime, recur,
attendees, title, message, date, stime, etime, recur, attachments,
created_by: content.created_by
};
// Keep the global reminder index in lockstep with the edited meeting
Expand All @@ -348,6 +363,55 @@
await this.output.data((content));
}

/**
* Attach already-uploaded files to a meeting. Owner-only, like update().
* Appends to metadata.content.attachments (deduped, capped — see
* lib/meeting-attachments). When the meeting already has a public link, the
* new files get the same download grant public_link gives (public_link
* itself grants whatever is attached at the time it runs).
* Params: nid (meeting), file_nids (array of media nids in this hub).
*/
async link_files() {
// Same gate as book(): the ACL's fast_check skips its declared `src`.
if (!(await memberCan(this, CAN_WRITE))) {
return this.exception.forbiden();
}
const nid = this.input.need(Attr.nid);
const incoming = normalizeNids(this.input.need('file_nids'));
const node = await this.db.await_proc('mfs_node_attr', nid);
if (!isMeetingNode(node)) return this.exception.user("MEETING_NOT_FOUND");
const metadata = this.parseJSON(node.metadata || '{}') || {};
const content = this.parseJSON(metadata.content || '{}') || {};
// Stricter than update(): attaching opens files to the link, so a legacy
// meeting with no recorded creator has nobody entitled to do it.
if (content.created_by !== this.uid) {
return this.exception.user("NOT_MEETING_OWNER");
}
const files = [];
for (const f of incoming) {
const a = await this.db.await_proc('mfs_node_attr', f);

Check warning on line 392 in service/private/room.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=AaD878e583VE3RrOD3li&open=AaD878e583VE3RrOD3li&pullRequest=246
if (!a || !(a.id || a.nid)) continue;
if (['folder', 'hub', 'root', 'schedule'].includes(a.filetype)) continue;
// The link will be able to download it, so the caller must be able to.
const mine = await this.db.await_proc('mfs_access_node', this.uid, f);

Check warning on line 396 in service/private/room.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=AaD878e583VE3RrOD3lj&open=AaD878e583VE3RrOD3lj&pullRequest=246
if (!mine || Number(mine.privilege) < Privilege.download) continue;
files.push(f);
}
const { list, added, overflow } = mergeAttachments(attachmentsOf(content), files);
await this.db.await_proc('mfs_set_metadata', nid, {
content: { ...content, attachments: list },
room_status: metadata.room_status || 'booked',
}, 1);
const public_id = Cache.getSysConf('public_id');
const pub = await this.db.await_proc('mfs_access_node', public_id, nid);
if (pub && Number(pub.privilege) > 0) {
for (const f of added) {
await this.db.await_proc('permission_grant', f, public_id, 0, Privilege.download, 'link', '');

Check warning on line 409 in service/private/room.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=AaD878e583VE3RrOD3lk&open=AaD878e583VE3RrOD3lk&pullRequest=246
}
}
this.output.data({ nid, attachments: list, overflow });
}

/**
* Persist a scheduled-meeting notice for each recipient (Duy 2026-08-21,
* issues 9 and 11).
Expand Down Expand Up @@ -398,9 +462,9 @@
if (seen.has(target_uid)) continue;
seen.add(target_uid);
try {
await this.yp.await_proc(
'contact_log_activity', this.uid, target_uid, 'meeting_notice', meta,
);

Check warning on line 467 in service/private/room.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=AaD878e583VE3RrOD3ll&open=AaD878e583VE3RrOD3ll&pullRequest=246
} catch (e) {
this.warn && this.warn('room._meeting_notice log failed', e && e.message);
}
Expand Down Expand Up @@ -588,6 +652,13 @@
folder_name: await this._meeting_folder_name(node && node.parent_id),
});
}
// The link identity's download grant on each attachment goes with the
// meeting. The files themselves stay in the hidden task folder (no
// reference counting for meetings yet).
const public_id = Cache.getSysConf('public_id');
for (const file_nid of attachmentsOf(content)) {
await this.db.await_proc('permission_revoke', file_nid, public_id);

Check warning on line 660 in service/private/room.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=AaD878e583VE3RrOD3lm&open=AaD878e583VE3RrOD3lm&pullRequest=246
}
await this.db.await_proc('permission_revoke', nid, "meeting");
await this._unindex_meeting(nid);
this.output.data({ nid });
Expand Down
75 changes: 75 additions & 0 deletions test/dmz-meeting-files.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Cover for dmz.meeting_files.
* Run: node test/dmz-meeting-files.test.js
*/
const assert = require("assert");
const path = require("path");
const fs = require("fs");

// dmz.js has a heavy require graph; slice the one method out and run it
// against a stub `this` (the ui-team tests/helpers/slice-method technique).
const SRC = fs.readFileSync(path.join(__dirname, "../service/dmz.js"), "utf8");
function slice(sig) {
const start = SRC.indexOf(`\n ${sig} {`);
if (start < 0) throw new Error(`${sig} not found`);
const end = SRC.indexOf("\n }\n", start);
return SRC.slice(start, end + 4).trim().replace(/^async\s+([A-Za-z_]\w*)\s*\(/, "async function $1(");
}
const lib = require("../service/lib/meeting-attachments");
const toArray = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
const meeting_files = new Function("toArray", "attachmentsOf", "isMeetingNode", "Attr",
`return ${slice("async meeting_files()")}`)(toArray, lib.attachmentsOf, lib.isMeetingNode, { token: "token" });

// The real share resolver, so the password gate is the one production runs.
const shareByToken = new Function("toArray", `return ${slice("async _shareByToken(token, tag)")}`)(toArray);

const MEET = "aaaaaaaaaaaaaaaa", F1 = "1111111111111111";
function make({ share, legacy, nodes = {}, viewerPriv = 0 }) {
const out = {};
const self = {
uid: "vvvvvvvvvvvvvvvv",
input: { need: () => "tok" },
_shareByToken: legacy ? shareByToken : async () => share,
yp: { await_proc: async (name, _hub, proc, args) => {
if (name === "secure_share_info") return [];
if (name === "dmz_info_next") return legacy;
const id = String(args).replace(/'/g, "");
return proc === "mfs_node_attr" ? [nodes[id] || {}] : [];
} },
db: { await_proc: async () => ({ privilege: viewerPriv }) },
output: { data: (d) => { out.data = d; } },
warn: () => {},
};
return { run: () => meeting_files.call(self), out };
}
const meetingNode = (attachments) => ({ id: MEET, filetype: "schedule",
metadata: JSON.stringify({ content: JSON.stringify({ attachments }) }) });

(async () => {
{
const t = make({ share: { info: { hub_id: "h", nid: MEET } },
nodes: { [MEET]: meetingNode([F1]), [F1]: { id: F1, filename: "a", ext: "pdf", filetype: "document", filesize: 9, owner_id: "secret" } } });
await t.run();
assert.deepStrictEqual(t.out.data, { status: "TICKET_OK", hub_id: "h",
items: [{ nid: F1, filename: "a", ext: "pdf", filetype: "document", filesize: 9 }] });
}
{
const t = make({ share: { status: "TICKET_INVALID" } });
await t.run();
assert.deepStrictEqual(t.out.data, { status: "TICKET_INVALID", items: [] });
}
{
const t = make({ share: { info: { hub_id: "h", nid: F1 } }, nodes: { [F1]: { id: F1, filetype: "folder" } } });
await t.run();
assert.deepStrictEqual(t.out.data, { status: "NOT_A_MEETING", items: [] });
}
// Review Focus 4: password-protected link without access lists nothing
{
// viewerPriv 15: even a session holding the meeting grant gets nothing.
const t = make({ legacy: { hub_id: "h", nid: MEET, validity: "TICKET_OK", require_password: 1 },
nodes: { [MEET]: meetingNode([F1]) }, viewerPriv: 15 });
await t.run();
assert.deepStrictEqual(t.out.data, { status: "REQUIRED_PASSWORD", items: [] });
}
console.log("dmz-meeting-files: ok");
})().catch((e) => { console.error(e); process.exit(1); });
Loading
Loading