mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
d671c86859
Add folder/direct-device/strategy assignment from User Management, clearer Pro vs Remote Operator labeling, optional restricted device scope mode, and Go/Node scope parity.
362 lines
13 KiB
JavaScript
362 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
const { isSuperAdminRole } = require('../middleware/auth');
|
|
const config = require('../config/config');
|
|
|
|
let scopeDefaultCache = { value: null, at: 0 };
|
|
|
|
async function isDeviceScopeRestrictedDefault(db) {
|
|
const envRestricted = String(config.deviceScopeDefault || 'open').toLowerCase() === 'restricted';
|
|
if (!db || typeof db.getSetting !== 'function') return envRestricted;
|
|
const now = Date.now();
|
|
if (scopeDefaultCache.value !== null && now - scopeDefaultCache.at < 30000) {
|
|
return scopeDefaultCache.value;
|
|
}
|
|
try {
|
|
const stored = await db.getSetting('device_scope_default');
|
|
const restricted = stored
|
|
? String(stored).toLowerCase() === 'restricted'
|
|
: envRestricted;
|
|
scopeDefaultCache = { value: restricted, at: now };
|
|
return restricted;
|
|
} catch (_) {
|
|
return envRestricted;
|
|
}
|
|
}
|
|
|
|
function invalidateDeviceScopeDefaultCache() {
|
|
scopeDefaultCache = { value: null, at: 0 };
|
|
}
|
|
|
|
function normalizeTags(value) {
|
|
if (!value) return [];
|
|
if (Array.isArray(value)) return value.map(String).map(t => t.trim()).filter(Boolean);
|
|
if (typeof value === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (Array.isArray(parsed)) return parsed.map(String).map(t => t.trim()).filter(Boolean);
|
|
} catch (_) {}
|
|
return value.split(',').map(t => t.trim()).filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function normalizeUsernames(value) {
|
|
const raw = Array.isArray(value) ? value : String(value || '').split(',');
|
|
return Array.from(new Set(raw.map(v => String(v || '').trim()).filter(Boolean))).slice(0, 100);
|
|
}
|
|
|
|
function normalizeGroupGuids(value) {
|
|
const raw = Array.isArray(value) ? value : String(value || '').split(',');
|
|
return Array.from(new Set(raw.map(item => {
|
|
if (item && typeof item === 'object') return String(item.guid || '').trim();
|
|
return String(item || '').trim();
|
|
}).filter(Boolean))).slice(0, 100);
|
|
}
|
|
|
|
function normalizeGroupPayload(body = {}) {
|
|
const sourceType = body.source_type === 'tag' || body.dynamic === true ? 'tag' : 'manual';
|
|
const tagFilter = sourceType === 'tag' ? String(body.tag_filter || body.tag || '').trim().slice(0, 50) : '';
|
|
const payload = {
|
|
guid: body.guid ? String(body.guid).trim().slice(0, 64) : '',
|
|
name: String(body.name || '').trim().slice(0, 80),
|
|
source_type: sourceType,
|
|
tag_filter: tagFilter,
|
|
allowed_users: normalizeUsernames(body.allowed_users),
|
|
allowed_groups: normalizeGroupGuids(body.allowed_groups || body.allowed_user_groups || body.user_group_guids)
|
|
};
|
|
if (Object.prototype.hasOwnProperty.call(body, 'note')) {
|
|
payload.note = String(body.note || '').trim().slice(0, 512);
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(body, 'team_id')) {
|
|
payload.team_id = String(body.team_id || '').trim().slice(0, 64);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function buildDeviceGroupCreateFields(payload) {
|
|
return {
|
|
name: payload.name,
|
|
note: Object.prototype.hasOwnProperty.call(payload, 'note') ? payload.note : '',
|
|
team_id: Object.prototype.hasOwnProperty.call(payload, 'team_id') ? payload.team_id : '',
|
|
source_type: payload.source_type,
|
|
tag_filter: payload.tag_filter
|
|
};
|
|
}
|
|
|
|
function buildDeviceGroupUpdateFields(payload) {
|
|
const data = {
|
|
name: payload.name,
|
|
source_type: payload.source_type,
|
|
tag_filter: payload.tag_filter
|
|
};
|
|
if (Object.prototype.hasOwnProperty.call(payload, 'note')) {
|
|
data.note = payload.note;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(payload, 'team_id')) {
|
|
data.team_id = payload.team_id;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function hasTag(device, tag) {
|
|
const expected = String(tag || '').trim().toLowerCase();
|
|
if (!expected) return false;
|
|
return normalizeTags(device && device.tags).some(t => t.toLowerCase() === expected);
|
|
}
|
|
|
|
function folderIdFromGroupGuid(value) {
|
|
const match = String(value || '').trim().match(/^folder_(\d+)$/i);
|
|
if (!match) return null;
|
|
const id = Number.parseInt(match[1], 10);
|
|
return Number.isFinite(id) ? id : null;
|
|
}
|
|
|
|
function getGroupFolderId(group) {
|
|
const explicit = group && group.folder_id;
|
|
if (explicit !== undefined && explicit !== null && explicit !== '') {
|
|
const id = Number.parseInt(explicit, 10);
|
|
if (Number.isFinite(id)) return id;
|
|
}
|
|
return folderIdFromGroupGuid(group && group.guid);
|
|
}
|
|
|
|
function groupAllowedForUser(group, user) {
|
|
if (!user || isSuperAdminRole(user.role) || user.role === 'global_admin' || user.role === 'server_admin') {
|
|
return true;
|
|
}
|
|
const allowedUsers = normalizeUsernames(group && group.allowed_users);
|
|
const allowedGroups = normalizeGroupGuids(group && (group.allowed_groups || group.allowed_user_groups));
|
|
if (allowedUsers.length === 0 && allowedGroups.length === 0) return true;
|
|
if (allowedUsers.includes(user.username)) return true;
|
|
|
|
const userGroups = new Set(normalizeGroupGuids(user.user_groups || user.group_guids || user.allowed_groups));
|
|
return allowedGroups.some(guid => userGroups.has(guid));
|
|
}
|
|
|
|
async function getUserAccessContext(db, user) {
|
|
if (!user || !user.id || isSuperAdminRole(user.role) || user.role === 'global_admin' || user.role === 'server_admin') {
|
|
return user;
|
|
}
|
|
if (Array.isArray(user.user_groups) || typeof db.getUserGroupsForUser !== 'function') return user;
|
|
try {
|
|
const groups = await db.getUserGroupsForUser(user.id);
|
|
return {
|
|
...user,
|
|
user_groups: (groups || []).map(group => group.guid).filter(Boolean)
|
|
};
|
|
} catch (_) {
|
|
return user;
|
|
}
|
|
}
|
|
|
|
async function getGroupPeerIds(db, group, devices = []) {
|
|
const ids = new Set();
|
|
const folderId = getGroupFolderId(group);
|
|
if (folderId !== null) {
|
|
for (const device of devices || []) {
|
|
const deviceFolderId = Number.parseInt(device && device.folder_id, 10);
|
|
if (Number.isFinite(deviceFolderId) && deviceFolderId === folderId) {
|
|
ids.add(String(device.id));
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
if (group && group.guid) {
|
|
try {
|
|
const staticIds = await db.getDeviceGroupMembers(group.guid);
|
|
for (const id of staticIds || []) ids.add(String(id));
|
|
} catch (_) {}
|
|
}
|
|
|
|
if ((group.source_type || 'manual') === 'tag' && group.tag_filter) {
|
|
for (const device of devices || []) {
|
|
if (hasTag(device, group.tag_filter)) ids.add(String(device.id));
|
|
}
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
async function enrichGroups(db, groups, devices = []) {
|
|
const enriched = [];
|
|
for (const group of groups || []) {
|
|
const memberIds = await getGroupPeerIds(db, group, devices);
|
|
enriched.push({
|
|
...group,
|
|
source_type: group.source_type || 'manual',
|
|
tag_filter: group.tag_filter || '',
|
|
allowed_users: Array.isArray(group.allowed_users) ? group.allowed_users : normalizeUsernames(group.allowed_users),
|
|
allowed_groups: Array.isArray(group.allowed_groups) ? group.allowed_groups : normalizeGroupGuids(group.allowed_groups),
|
|
allowed_user_groups: Array.isArray(group.allowed_user_groups) ? group.allowed_user_groups : [],
|
|
member_count: memberIds.size
|
|
});
|
|
}
|
|
return enriched;
|
|
}
|
|
|
|
async function getDeviceScopeForUser(db, user, devices = []) {
|
|
if (!user || !user.id || isSuperAdminRole(user.role) || user.role === 'global_admin' || user.role === 'server_admin') {
|
|
return null;
|
|
}
|
|
|
|
if (typeof db.getAllDeviceGroups !== 'function') return null;
|
|
|
|
const restrictedDefault = await isDeviceScopeRestrictedDefault(db);
|
|
const accessUser = await getUserAccessContext(db, user);
|
|
const groups = await db.getAllDeviceGroups();
|
|
const restrictedGroups = (groups || []).filter(group =>
|
|
normalizeUsernames(group.allowed_users).length > 0 ||
|
|
normalizeGroupGuids(group.allowed_groups || group.allowed_user_groups).length > 0
|
|
);
|
|
|
|
let peerGrants = [];
|
|
if (typeof db.getUserPeerGrants === 'function') {
|
|
try {
|
|
peerGrants = await db.getUserPeerGrants(user.id);
|
|
} catch (_) {
|
|
peerGrants = [];
|
|
}
|
|
}
|
|
|
|
if (restrictedGroups.length === 0 && peerGrants.length === 0) {
|
|
return restrictedDefault ? new Set() : null;
|
|
}
|
|
|
|
const allowedIds = new Set();
|
|
const restrictedIds = new Set();
|
|
for (const group of restrictedGroups) {
|
|
const ids = await getGroupPeerIds(db, group, devices);
|
|
const target = groupAllowedForUser(group, accessUser) ? allowedIds : restrictedIds;
|
|
for (const id of ids) target.add(id);
|
|
}
|
|
for (const id of peerGrants) allowedIds.add(String(id));
|
|
|
|
if (restrictedDefault) {
|
|
return allowedIds;
|
|
}
|
|
|
|
const visible = new Set();
|
|
for (const device of devices || []) {
|
|
const id = String(device && device.id || '');
|
|
if (!id) continue;
|
|
if (!restrictedIds.has(id) || allowedIds.has(id)) visible.add(id);
|
|
}
|
|
return visible;
|
|
}
|
|
|
|
function filterDevicesByScope(devices, allowedIds) {
|
|
if (!allowedIds) return devices;
|
|
return (devices || []).filter(device => allowedIds.has(String(device.id)));
|
|
}
|
|
|
|
async function userCanAccessDevice(db, user, device, allDevices) {
|
|
const scope = await getDeviceScopeForUser(db, user, allDevices || (device ? [device] : []));
|
|
if (!scope) return true;
|
|
return device && scope.has(String(device.id));
|
|
}
|
|
|
|
async function collectAclUsernames(db, group, targetSet) {
|
|
if (!group || !targetSet) return;
|
|
const allowedUsers = normalizeUsernames(group.allowed_users);
|
|
const allowedGroups = normalizeGroupGuids(group.allowed_groups || group.allowed_user_groups);
|
|
if (allowedUsers.length === 0 && allowedGroups.length === 0) return;
|
|
for (const username of allowedUsers) targetSet.add(username);
|
|
for (const guid of allowedGroups) {
|
|
if (typeof db.getUsernamesByUserGroupGuid !== 'function') continue;
|
|
const members = await db.getUsernamesByUserGroupGuid(guid);
|
|
for (const username of members || []) targetSet.add(username);
|
|
}
|
|
}
|
|
|
|
async function resolveOperatorUsernamesForDevice(db, deviceId) {
|
|
const cleanId = String(deviceId || '').trim();
|
|
if (!cleanId) return [];
|
|
|
|
const usernames = new Set();
|
|
const assignments = await db.getAllFolderAssignments();
|
|
let folderId = null;
|
|
for (const row of assignments || []) {
|
|
if (String(row.device_id) === cleanId) {
|
|
folderId = row.folder_id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (folderId != null && typeof db.getDeviceGroupByGuid === 'function') {
|
|
const folderGroup = await db.getDeviceGroupByGuid(`folder_${folderId}`);
|
|
await collectAclUsernames(db, folderGroup, usernames);
|
|
}
|
|
|
|
if (typeof db.getAllDeviceGroups === 'function') {
|
|
const groups = await db.getAllDeviceGroups();
|
|
let devices = [];
|
|
if (typeof db.getAllPeers === 'function') {
|
|
devices = await db.getAllPeers();
|
|
}
|
|
for (const group of groups || []) {
|
|
const peerIds = await getGroupPeerIds(db, group, devices);
|
|
if (!peerIds.has(cleanId)) continue;
|
|
await collectAclUsernames(db, group, usernames);
|
|
}
|
|
}
|
|
|
|
return [...usernames];
|
|
}
|
|
|
|
async function resolveOperatorEmailsForDevice(db, deviceId) {
|
|
const usernames = await resolveOperatorUsernamesForDevice(db, deviceId);
|
|
if (!usernames.length || typeof db.getUsersEmailsByUsernames !== 'function') return [];
|
|
const rows = await db.getUsersEmailsByUsernames(usernames);
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const row of rows || []) {
|
|
const email = String(row.email || '').trim();
|
|
if (!email || seen.has(email)) continue;
|
|
seen.add(email);
|
|
result.push({ username: row.username, email });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function resolveFolderNameForDevice(db, deviceId) {
|
|
const cleanId = String(deviceId || '').trim();
|
|
if (!cleanId || typeof db.getAllFolderAssignments !== 'function' || typeof db.getFolderById !== 'function') {
|
|
return '';
|
|
}
|
|
const assignments = await db.getAllFolderAssignments();
|
|
let folderId = null;
|
|
for (const row of assignments || []) {
|
|
if (String(row.device_id) === cleanId) {
|
|
folderId = row.folder_id;
|
|
break;
|
|
}
|
|
}
|
|
if (folderId == null) return '';
|
|
const folder = await db.getFolderById(folderId);
|
|
return folder && folder.name ? String(folder.name) : '';
|
|
}
|
|
|
|
module.exports = {
|
|
normalizeTags,
|
|
normalizeUsernames,
|
|
normalizeGroupGuids,
|
|
normalizeGroupPayload,
|
|
buildDeviceGroupCreateFields,
|
|
buildDeviceGroupUpdateFields,
|
|
folderIdFromGroupGuid,
|
|
getGroupFolderId,
|
|
groupAllowedForUser,
|
|
getUserAccessContext,
|
|
getGroupPeerIds,
|
|
enrichGroups,
|
|
getDeviceScopeForUser,
|
|
filterDevicesByScope,
|
|
userCanAccessDevice,
|
|
resolveOperatorUsernamesForDevice,
|
|
resolveOperatorEmailsForDevice,
|
|
resolveFolderNameForDevice,
|
|
invalidateDeviceScopeDefaultCache,
|
|
};
|