mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-20 16:10:49 +08:00
210 lines
7.6 KiB
JavaScript
210 lines
7.6 KiB
JavaScript
// Shared by the Host and settings UI; keep this module browser-compatible.
|
|
export const ACCESS_POLICY_MODES = Object.freeze(['open', 'allowlist']);
|
|
export const ACCESS_POLICY_CONVERSATION_TYPES = Object.freeze(['direct', 'group']);
|
|
export const ACCESS_POLICY_USER_ID_MAX_LENGTH = 256;
|
|
|
|
const POLICY_KEYS = ['direct', 'group'];
|
|
const SCOPE_KEYS = ['mode', 'open', 'allowlist'];
|
|
const OPEN_KEYS = ['defaultCanExecuteCommands', 'commandPermissionOverrides'];
|
|
const ALLOWLIST_KEYS = ['users'];
|
|
const USER_KEYS = ['id', 'canExecuteCommands'];
|
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
|
|
|
|
function invalidAccessPolicy(message) {
|
|
const error = new TypeError(message);
|
|
error.code = 'access-policy-invalid';
|
|
return error;
|
|
}
|
|
|
|
function hasExactKeys(input, keys) {
|
|
return input && typeof input === 'object' && !Array.isArray(input)
|
|
&& [Object.prototype, null].includes(Object.getPrototypeOf(input))
|
|
&& Reflect.ownKeys(input).length === keys.length
|
|
&& keys.every((key) => Object.hasOwn(input, key));
|
|
}
|
|
|
|
/** Normalize one opaque channel identity without interpreting its contents. */
|
|
export function normalizeAccessPolicyUserId(value) {
|
|
if (typeof value === 'number') {
|
|
if (!Number.isFinite(value)) throw invalidAccessPolicy('用户标识无效。');
|
|
value = String(value);
|
|
} else if (typeof value === 'bigint') {
|
|
value = String(value);
|
|
}
|
|
if (typeof value !== 'string') throw invalidAccessPolicy('用户标识必须是字符串。');
|
|
const id = value.trim();
|
|
if (!id || id.length > ACCESS_POLICY_USER_ID_MAX_LENGTH || CONTROL_CHARACTERS.test(id)) {
|
|
throw invalidAccessPolicy(`用户标识不能为空、包含控制字符或超过 ${ACCESS_POLICY_USER_ID_MAX_LENGTH} 个字符。`);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function validateAccessPolicyUser(input) {
|
|
if (!hasExactKeys(input, USER_KEYS)) {
|
|
throw invalidAccessPolicy('用户条目必须包含用户标识和命令权限。');
|
|
}
|
|
if (typeof input.canExecuteCommands !== 'boolean') {
|
|
throw invalidAccessPolicy('命令权限必须是布尔值。');
|
|
}
|
|
return Object.freeze({
|
|
id: normalizeAccessPolicyUserId(input.id),
|
|
canExecuteCommands: input.canExecuteCommands,
|
|
});
|
|
}
|
|
|
|
function validateUsers(input, { listMessage, duplicateMessage }) {
|
|
if (!Array.isArray(input)) throw invalidAccessPolicy(listMessage);
|
|
const users = input.map(validateAccessPolicyUser);
|
|
if (new Set(users.map(({ id }) => id)).size !== users.length) {
|
|
throw invalidAccessPolicy(duplicateMessage);
|
|
}
|
|
return Object.freeze(users);
|
|
}
|
|
|
|
function validateOpenSettings(input) {
|
|
if (!hasExactKeys(input, OPEN_KEYS)) {
|
|
throw invalidAccessPolicy('开放模式设置必须完整。');
|
|
}
|
|
if (typeof input.defaultCanExecuteCommands !== 'boolean') {
|
|
throw invalidAccessPolicy('开放模式默认命令权限必须是布尔值。');
|
|
}
|
|
return Object.freeze({
|
|
defaultCanExecuteCommands: input.defaultCanExecuteCommands,
|
|
commandPermissionOverrides: validateUsers(input.commandPermissionOverrides, {
|
|
listMessage: '开放模式命令权限覆盖用户必须是数组。',
|
|
duplicateMessage: '开放模式命令权限覆盖用户不能包含重复的用户标识。',
|
|
}),
|
|
});
|
|
}
|
|
|
|
function validateAllowlistSettings(input) {
|
|
if (!hasExactKeys(input, ALLOWLIST_KEYS)) {
|
|
throw invalidAccessPolicy('白名单模式设置必须完整。');
|
|
}
|
|
return Object.freeze({
|
|
users: validateUsers(input.users, {
|
|
listMessage: '白名单模式用户必须是数组。',
|
|
duplicateMessage: '白名单模式用户不能包含重复的用户标识。',
|
|
}),
|
|
});
|
|
}
|
|
|
|
function validateAccessPolicyScope(input) {
|
|
if (!hasExactKeys(input, SCOPE_KEYS)) {
|
|
throw invalidAccessPolicy('访问场景设置必须同时包含模式、开放模式设置和白名单模式设置。');
|
|
}
|
|
if (!ACCESS_POLICY_MODES.includes(input.mode)) {
|
|
throw invalidAccessPolicy('访问模式只能是 open 或 allowlist。');
|
|
}
|
|
return Object.freeze({
|
|
mode: input.mode,
|
|
open: validateOpenSettings(input.open),
|
|
allowlist: validateAllowlistSettings(input.allowlist),
|
|
});
|
|
}
|
|
|
|
/** Validate one canonical direct + group atomic save. */
|
|
export function validateAccessPolicy(input) {
|
|
if (!hasExactKeys(input, POLICY_KEYS)) {
|
|
throw invalidAccessPolicy('请同时提交完整的私聊和群聊访问设置。');
|
|
}
|
|
return Object.freeze({
|
|
direct: validateAccessPolicyScope(input.direct),
|
|
group: validateAccessPolicyScope(input.group),
|
|
});
|
|
}
|
|
|
|
/** Normalize canonical persisted/runtime data; damaged settings fail closed. */
|
|
export function normalizeAccessPolicy(input) {
|
|
try {
|
|
return validateAccessPolicy(input);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Small canonical constructor used by channel initialization. */
|
|
export function createAccessPolicyScope(options) {
|
|
return validateAccessPolicyScope(options === undefined ? {
|
|
mode: 'allowlist',
|
|
open: {
|
|
defaultCanExecuteCommands: false,
|
|
commandPermissionOverrides: [],
|
|
},
|
|
allowlist: { users: [] },
|
|
} : options);
|
|
}
|
|
|
|
const DENY_SCOPE = createAccessPolicyScope();
|
|
|
|
export const DEFAULT_ACCESS_POLICY = Object.freeze({
|
|
direct: DENY_SCOPE,
|
|
group: DENY_SCOPE,
|
|
});
|
|
|
|
export const DENY_ALL_ACCESS_POLICY = DEFAULT_ACCESS_POLICY;
|
|
|
|
export function createAccessPolicy({
|
|
direct = DEFAULT_ACCESS_POLICY.direct,
|
|
group = DEFAULT_ACCESS_POLICY.group,
|
|
} = {}) {
|
|
return validateAccessPolicy({ direct, group });
|
|
}
|
|
|
|
const ALLOWED = Object.freeze({ allowed: true, reason: 'allowed' });
|
|
const POLICY_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'policy-unavailable' });
|
|
const INVALID_CONTEXT = Object.freeze({ allowed: false, reason: 'invalid-context' });
|
|
const SENDER_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'sender-unavailable' });
|
|
const SENDER_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'sender-not-allowed' });
|
|
const COMMAND_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'command-not-allowed' });
|
|
|
|
/**
|
|
* Decide access for one ordinary message or one already-recognized command.
|
|
* `senderIds` accepts multiple identities so WhatsApp can reuse its JID aliases.
|
|
* Privileged/owner bypass is intentionally handled by the inbound adapter.
|
|
*/
|
|
export function evaluateAccessPolicy(policy, {
|
|
conversationType,
|
|
senderIds,
|
|
isCommand = false,
|
|
equals = (left, right) => left === right,
|
|
} = {}) {
|
|
const normalizedPolicy = normalizeAccessPolicy(policy);
|
|
if (!normalizedPolicy) return POLICY_UNAVAILABLE;
|
|
if (!ACCESS_POLICY_CONVERSATION_TYPES.includes(conversationType)
|
|
|| typeof isCommand !== 'boolean' || typeof equals !== 'function') {
|
|
return INVALID_CONTEXT;
|
|
}
|
|
const candidates = (Array.isArray(senderIds) ? senderIds : [senderIds])
|
|
.flatMap((candidate) => {
|
|
try {
|
|
return [normalizeAccessPolicyUserId(candidate)];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
if (candidates.length === 0) return SENDER_UNAVAILABLE;
|
|
|
|
const scope = normalizedPolicy[conversationType];
|
|
const users = scope.mode === 'open'
|
|
? scope.open.commandPermissionOverrides
|
|
: scope.allowlist.users;
|
|
let matchedUsers;
|
|
try {
|
|
matchedUsers = users.filter((user) => (
|
|
candidates.some((candidate) => equals(candidate, user.id) === true)
|
|
));
|
|
} catch {
|
|
return INVALID_CONTEXT;
|
|
}
|
|
if (scope.mode === 'allowlist' && matchedUsers.length === 0) return SENDER_NOT_ALLOWED;
|
|
if (isCommand) {
|
|
const canExecuteCommands = scope.mode === 'open'
|
|
? (matchedUsers.length > 0
|
|
? matchedUsers.every((user) => user.canExecuteCommands)
|
|
: scope.open.defaultCanExecuteCommands)
|
|
: matchedUsers.every((user) => user.canExecuteCommands);
|
|
if (!canExecuteCommands) return COMMAND_NOT_ALLOWED;
|
|
}
|
|
return ALLOWED;
|
|
}
|