571 lines
17 KiB
TypeScript
571 lines
17 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import type {I18n} from '@lingui/core';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {ALL_PERMISSIONS, DEFAULT_PERMISSIONS, ElevatedPermissions, GuildMFALevel, Permissions} from '~/Constants';
|
|
import type {Channel} from '~/records/ChannelRecord';
|
|
import type {Guild} from '~/records/GuildRecord';
|
|
import AuthenticationStore from '~/stores/AuthenticationStore';
|
|
import GuildMemberStore from '~/stores/GuildMemberStore';
|
|
import GuildStore from '~/stores/GuildStore';
|
|
import UserStore from '~/stores/UserStore';
|
|
|
|
export const NONE = 0n;
|
|
|
|
type UserId = string;
|
|
type RoleId = string;
|
|
|
|
interface PermissionOverwrite {
|
|
id: string;
|
|
type: 0 | 1;
|
|
allow: bigint;
|
|
deny: bigint;
|
|
}
|
|
|
|
interface Role {
|
|
id: RoleId;
|
|
permissions: bigint;
|
|
position: number;
|
|
}
|
|
|
|
export interface PermissionSpec {
|
|
title: string;
|
|
permissions: Array<{
|
|
title: string;
|
|
description?: string;
|
|
flag: bigint;
|
|
}>;
|
|
}
|
|
|
|
function calculateElevatedPermissions(permissions: bigint, guild: Guild, userId: UserId, checkElevated = true): bigint {
|
|
if (
|
|
checkElevated &&
|
|
(guild.mfa_level ?? 0) === GuildMFALevel.ELEVATED &&
|
|
userId === AuthenticationStore.currentUserId
|
|
) {
|
|
const currentUser = UserStore.getCurrentUser();
|
|
if (currentUser && !currentUser.mfaEnabled) {
|
|
permissions &= ~ElevatedPermissions;
|
|
}
|
|
}
|
|
return permissions;
|
|
}
|
|
|
|
export function computePermissions(
|
|
user: {id: UserId} | UserId,
|
|
context: Channel | Guild,
|
|
overwrites?: Record<string, PermissionOverwrite> | null,
|
|
roles?: Record<RoleId, Role> | null,
|
|
checkElevated = true,
|
|
): bigint {
|
|
const userId = typeof user === 'string' ? user : user.id;
|
|
let guild: Guild | null = null;
|
|
let guildRoles: Record<RoleId, Role> | null = null;
|
|
|
|
if ('guild_id' in context) {
|
|
const channel = context as Channel;
|
|
const channelOverwrites = channel.permission_overwrites ?? [];
|
|
const convertedOverwrites = Object.fromEntries(
|
|
channelOverwrites.map((ow) => [
|
|
ow.id,
|
|
{id: ow.id, type: ow.type, allow: BigInt(ow.allow), deny: BigInt(ow.deny)} as PermissionOverwrite,
|
|
]),
|
|
) as Record<string, PermissionOverwrite>;
|
|
overwrites = overwrites != null ? {...convertedOverwrites, ...overwrites} : convertedOverwrites;
|
|
const guildRecord = channel.guild_id != null ? GuildStore.getGuild(channel.guild_id) : null;
|
|
if (guildRecord) {
|
|
guild = guildRecord.toJSON();
|
|
guildRoles = Object.fromEntries(
|
|
Object.entries(guildRecord.roles).map(([id, roleRecord]) => [
|
|
id,
|
|
{
|
|
id: roleRecord.id,
|
|
permissions: roleRecord.permissions,
|
|
position: roleRecord.position,
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
} else {
|
|
overwrites = overwrites || {};
|
|
guild = context as Guild;
|
|
const guildRecord = GuildStore.getGuild(guild.id);
|
|
if (guildRecord) {
|
|
guildRoles = Object.fromEntries(
|
|
Object.entries(guildRecord.roles).map(([id, roleRecord]) => [
|
|
id,
|
|
{
|
|
id: roleRecord.id,
|
|
permissions: roleRecord.permissions,
|
|
position: roleRecord.position,
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (guild == null) {
|
|
return NONE;
|
|
}
|
|
|
|
if (guild.owner_id === userId) {
|
|
return calculateElevatedPermissions(ALL_PERMISSIONS, guild, userId, checkElevated);
|
|
}
|
|
|
|
roles = roles != null && guildRoles ? {...guildRoles, ...roles} : (guildRoles ?? roles ?? {});
|
|
|
|
const member = GuildMemberStore.getMember(guild.id, userId);
|
|
|
|
const roleEveryone = roles?.[guild.id];
|
|
let permissions = roleEveryone != null ? roleEveryone.permissions : DEFAULT_PERMISSIONS;
|
|
|
|
if (member != null && roles) {
|
|
for (const roleId of member.roles) {
|
|
const role = roles[roleId];
|
|
if (role !== undefined) {
|
|
permissions |= role.permissions;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ((permissions & Permissions.ADMINISTRATOR) === Permissions.ADMINISTRATOR) {
|
|
permissions = ALL_PERMISSIONS;
|
|
} else if (overwrites) {
|
|
const overwriteEveryone = overwrites[guild.id];
|
|
if (overwriteEveryone != null) {
|
|
permissions ^= permissions & overwriteEveryone.deny;
|
|
permissions |= overwriteEveryone.allow;
|
|
}
|
|
|
|
if (member != null) {
|
|
let allow = NONE;
|
|
let deny = NONE;
|
|
|
|
for (const roleId of member.roles) {
|
|
const overwriteRole = overwrites[roleId];
|
|
if (overwriteRole != null) {
|
|
allow |= overwriteRole.allow;
|
|
deny |= overwriteRole.deny;
|
|
}
|
|
}
|
|
|
|
permissions ^= permissions & deny;
|
|
permissions |= allow;
|
|
|
|
const overwriteMember = overwrites[userId];
|
|
if (overwriteMember != null) {
|
|
permissions ^= permissions & overwriteMember.deny;
|
|
permissions |= overwriteMember.allow;
|
|
}
|
|
}
|
|
}
|
|
|
|
return calculateElevatedPermissions(permissions, guild, userId, checkElevated);
|
|
}
|
|
|
|
export function isRoleHigher(guild: Guild, userId: UserId, a: Role | null, b: Role | null): boolean {
|
|
if (guild.owner_id === userId) return true;
|
|
if (a == null) return false;
|
|
|
|
const guildRecord = GuildStore.getGuild(guild.id);
|
|
if (!guildRecord) return false;
|
|
|
|
const rolesList = Object.values(guildRecord.roles)
|
|
.sort((r1, r2) => r1.position - r2.position)
|
|
.map((role) => role.id);
|
|
|
|
return rolesList.indexOf(a.id) > (b != null ? rolesList.indexOf(b.id) : -1);
|
|
}
|
|
|
|
export function getHighestRole(guild: Guild, userId: UserId): Role | null {
|
|
const member = GuildMemberStore.getMember(guild.id, userId);
|
|
if (member == null) return null;
|
|
|
|
const guildRecord = GuildStore.getGuild(guild.id);
|
|
if (!guildRecord) return null;
|
|
|
|
const memberRoles = Object.values(guildRecord.roles)
|
|
.filter((roleRecord) => Array.from(member.roles).includes(roleRecord.id))
|
|
.sort((a, b) => b.position - a.position)
|
|
.map((roleRecord) => ({
|
|
id: roleRecord.id,
|
|
permissions: roleRecord.permissions,
|
|
position: roleRecord.position,
|
|
}));
|
|
|
|
return memberRoles[0] ?? null;
|
|
}
|
|
|
|
export function can(
|
|
permission: bigint,
|
|
user: {id: UserId} | UserId,
|
|
context: Channel | Guild,
|
|
overwrites?: Record<string, PermissionOverwrite> | null,
|
|
roles?: Record<RoleId, Role> | null,
|
|
): boolean {
|
|
return (computePermissions(user, context, overwrites, roles) & permission) === permission;
|
|
}
|
|
|
|
function generateGuildGeneralPermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Community-wide`),
|
|
permissions: [
|
|
{
|
|
title: i18n._(msg`Administrator`),
|
|
description: i18n._(msg`Grants all permissions and bypasses channel restrictions. Highly sensitive.`),
|
|
flag: Permissions.ADMINISTRATOR,
|
|
},
|
|
{
|
|
title: i18n._(msg`View Activity Log`),
|
|
description: i18n._(msg`Read the community's audit log of changes and moderation actions.`),
|
|
flag: Permissions.VIEW_AUDIT_LOG,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Community`),
|
|
description: i18n._(msg`Edit global settings like name, description, and icon.`),
|
|
flag: Permissions.MANAGE_GUILD,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Roles`),
|
|
description: i18n._(
|
|
msg`Create, edit, or delete roles below your highest role. Also allows editing channel permission overwrites.`,
|
|
),
|
|
flag: Permissions.MANAGE_ROLES,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Channels`),
|
|
description: i18n._(msg`Create, edit, or delete channels and categories.`),
|
|
flag: Permissions.MANAGE_CHANNELS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Kick Members`),
|
|
flag: Permissions.KICK_MEMBERS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Ban Members`),
|
|
flag: Permissions.BAN_MEMBERS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Create Invite Links`),
|
|
flag: Permissions.CREATE_INSTANT_INVITE,
|
|
},
|
|
{
|
|
title: i18n._(msg`Change Own Nickname`),
|
|
description: i18n._(msg`Update your own nickname.`),
|
|
flag: Permissions.CHANGE_NICKNAME,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Nicknames`),
|
|
description: i18n._(msg`Change other members' nicknames.`),
|
|
flag: Permissions.MANAGE_NICKNAMES,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Emoji & Stickers`),
|
|
flag: Permissions.MANAGE_EXPRESSIONS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Webhooks`),
|
|
description: i18n._(msg`Create, edit, or delete webhooks.`),
|
|
flag: Permissions.MANAGE_WEBHOOKS,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function generateGuildTextPermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Messages & Media`),
|
|
permissions: [
|
|
{
|
|
title: i18n._(msg`Send Messages`),
|
|
flag: Permissions.SEND_MESSAGES,
|
|
},
|
|
{
|
|
title: i18n._(msg`Send TTS Messages`),
|
|
description: i18n._(msg`Send text-to-speech messages.`),
|
|
flag: Permissions.SEND_TTS_MESSAGES,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Messages`),
|
|
description: i18n._(msg`Delete others' messages. (Pinning is separate below.)`),
|
|
flag: Permissions.MANAGE_MESSAGES,
|
|
},
|
|
{
|
|
title: i18n._(msg`Pin Messages`),
|
|
flag: Permissions.PIN_MESSAGES,
|
|
},
|
|
{
|
|
title: i18n._(msg`Embed Links`),
|
|
flag: Permissions.EMBED_LINKS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Attach Files`),
|
|
flag: Permissions.ATTACH_FILES,
|
|
},
|
|
{
|
|
title: i18n._(msg`Read Message History`),
|
|
flag: Permissions.READ_MESSAGE_HISTORY,
|
|
},
|
|
{
|
|
title: i18n._(msg`Use @everyone/@here and @roles`),
|
|
description: i18n._(msg`Mention everyone or any role (even if the role isn't set to be mentionable).`),
|
|
flag: Permissions.MENTION_EVERYONE,
|
|
},
|
|
{
|
|
title: i18n._(msg`Use External Emoji`),
|
|
description: i18n._(msg`Use emoji from other communities.`),
|
|
flag: Permissions.USE_EXTERNAL_EMOJIS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Use External Stickers`),
|
|
flag: Permissions.USE_EXTERNAL_STICKERS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Add Reactions`),
|
|
description: i18n._(msg`Add new reactions to messages.`),
|
|
flag: Permissions.ADD_REACTIONS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Bypass Slowmode`),
|
|
description: i18n._(msg`Ignore per-channel message rate limits.`),
|
|
flag: Permissions.BYPASS_SLOWMODE,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function generateGuildModerationPermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Moderation`),
|
|
permissions: [
|
|
{
|
|
title: i18n._(msg`Timeout Members`),
|
|
description: i18n._(msg`Prevent members from sending messages, reacting, and joining voice for a duration.`),
|
|
flag: Permissions.MODERATE_MEMBERS,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function generateGuildAccessPermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Channel Access`),
|
|
permissions: [{title: i18n._(msg`View Channel`), flag: Permissions.VIEW_CHANNEL}],
|
|
};
|
|
}
|
|
|
|
function generateGuildVoicePermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Audio & Video`),
|
|
permissions: [
|
|
{
|
|
title: i18n._(msg`Connect (Join Voice)`),
|
|
flag: Permissions.CONNECT,
|
|
},
|
|
{
|
|
title: i18n._(msg`Speak`),
|
|
flag: Permissions.SPEAK,
|
|
},
|
|
{
|
|
title: i18n._(msg`Stream Video`),
|
|
flag: Permissions.STREAM,
|
|
},
|
|
{
|
|
title: i18n._(msg`Use Voice Activity`),
|
|
description: i18n._(msg`Otherwise Push-to-talk is required.`),
|
|
flag: Permissions.USE_VAD,
|
|
},
|
|
{
|
|
title: i18n._(msg`Priority Speaker`),
|
|
flag: Permissions.PRIORITY_SPEAKER,
|
|
},
|
|
{
|
|
title: i18n._(msg`Mute Members`),
|
|
flag: Permissions.MUTE_MEMBERS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Deafen Members`),
|
|
flag: Permissions.DEAFEN_MEMBERS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Move Members`),
|
|
description: i18n._(msg`Drag members between channels they can access.`),
|
|
flag: Permissions.MOVE_MEMBERS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Set Voice Region`),
|
|
flag: Permissions.UPDATE_RTC_REGION,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function generateChannelGeneralPermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Channel Management`),
|
|
permissions: [
|
|
{
|
|
title: i18n._(msg`Create Invite Links`),
|
|
flag: Permissions.CREATE_INSTANT_INVITE,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Channel`),
|
|
description: i18n._(msg`Rename and edit this channel's settings.`),
|
|
flag: Permissions.MANAGE_CHANNELS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Permissions`),
|
|
description: i18n._(msg`Edit overwrites for roles and members in this channel.`),
|
|
flag: Permissions.MANAGE_ROLES,
|
|
},
|
|
{
|
|
title: i18n._(msg`Manage Webhooks`),
|
|
description: i18n._(msg`Create, edit, or delete webhooks for this channel.`),
|
|
flag: Permissions.MANAGE_WEBHOOKS,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function generateChannelAccessPermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Channel Access`),
|
|
permissions: [{title: i18n._(msg`View Channel`), flag: Permissions.VIEW_CHANNEL}],
|
|
};
|
|
}
|
|
|
|
export function generateChannelTextPermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Messages & Media`),
|
|
permissions: [
|
|
{title: i18n._(msg`Send Messages`), flag: Permissions.SEND_MESSAGES},
|
|
{
|
|
title: i18n._(msg`Manage Messages`),
|
|
description: i18n._(msg`Delete others' messages. (Pinning is separate below.)`),
|
|
flag: Permissions.MANAGE_MESSAGES,
|
|
},
|
|
{title: i18n._(msg`Pin Messages`), flag: Permissions.PIN_MESSAGES},
|
|
{title: i18n._(msg`Embed Links`), flag: Permissions.EMBED_LINKS},
|
|
{title: i18n._(msg`Attach Files`), flag: Permissions.ATTACH_FILES},
|
|
{title: i18n._(msg`Read Message History`), flag: Permissions.READ_MESSAGE_HISTORY},
|
|
{
|
|
title: i18n._(msg`Use @everyone/@here and @roles`),
|
|
description: i18n._(msg`Mention everyone or any role (even if the role isn't set to be mentionable).`),
|
|
flag: Permissions.MENTION_EVERYONE,
|
|
},
|
|
{title: i18n._(msg`Use External Emoji`), flag: Permissions.USE_EXTERNAL_EMOJIS},
|
|
{title: i18n._(msg`Use External Stickers`), flag: Permissions.USE_EXTERNAL_STICKERS},
|
|
{
|
|
title: i18n._(msg`Add Reactions`),
|
|
description: i18n._(msg`Add new reactions to messages.`),
|
|
flag: Permissions.ADD_REACTIONS,
|
|
},
|
|
{
|
|
title: i18n._(msg`Bypass Slowmode`),
|
|
description: i18n._(msg`Ignore per-channel message rate limits.`),
|
|
flag: Permissions.BYPASS_SLOWMODE,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function generateChannelVoicePermissionSpec(i18n: I18n): PermissionSpec {
|
|
return {
|
|
title: i18n._(msg`Audio & Video`),
|
|
permissions: [
|
|
{title: i18n._(msg`Connect (Join Voice)`), flag: Permissions.CONNECT},
|
|
{title: i18n._(msg`Speak`), flag: Permissions.SPEAK},
|
|
{title: i18n._(msg`Stream Video`), flag: Permissions.STREAM},
|
|
{
|
|
title: i18n._(msg`Use Voice Activity`),
|
|
description: i18n._(msg`Otherwise Push-to-talk is required.`),
|
|
flag: Permissions.USE_VAD,
|
|
},
|
|
{title: i18n._(msg`Priority Speaker`), flag: Permissions.PRIORITY_SPEAKER},
|
|
{title: i18n._(msg`Mute Members`), flag: Permissions.MUTE_MEMBERS},
|
|
{title: i18n._(msg`Deafen Members`), flag: Permissions.DEAFEN_MEMBERS},
|
|
{
|
|
title: i18n._(msg`Move Members`),
|
|
description: i18n._(msg`Drag members between channels they can access.`),
|
|
flag: Permissions.MOVE_MEMBERS,
|
|
},
|
|
{title: i18n._(msg`Set Voice Region`), flag: Permissions.UPDATE_RTC_REGION},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function generatePermissionSpec(i18n: I18n): Array<PermissionSpec> {
|
|
return [
|
|
generateGuildGeneralPermissionSpec(i18n),
|
|
generateGuildAccessPermissionSpec(i18n),
|
|
generateGuildTextPermissionSpec(i18n),
|
|
generateGuildModerationPermissionSpec(i18n),
|
|
generateGuildVoicePermissionSpec(i18n),
|
|
];
|
|
}
|
|
|
|
export interface BotPermissionOption {
|
|
id: keyof typeof Permissions;
|
|
label: string;
|
|
}
|
|
|
|
export function getAllBotPermissions(i18n: I18n): Array<BotPermissionOption> {
|
|
return generatePermissionSpec(i18n).flatMap((spec) =>
|
|
spec.permissions.map((perm) => ({
|
|
id: Object.keys(Permissions).find(
|
|
(key) => Permissions[key as keyof typeof Permissions] === perm.flag,
|
|
) as keyof typeof Permissions,
|
|
label: perm.title,
|
|
})),
|
|
);
|
|
}
|
|
|
|
const permissionLabelCache = new Map<bigint, string>();
|
|
|
|
const populatePermissionLabels = (i18n: I18n): void => {
|
|
if (permissionLabelCache.size > 0) return;
|
|
for (const {permissions} of generatePermissionSpec(i18n)) {
|
|
for (const perm of permissions) {
|
|
permissionLabelCache.set(perm.flag, perm.title);
|
|
}
|
|
}
|
|
};
|
|
|
|
export function getPermissionLabel(i18n: I18n, permission: bigint): string | null {
|
|
populatePermissionLabels(i18n);
|
|
return permissionLabelCache.get(permission) ?? null;
|
|
}
|
|
|
|
export function formatPermissionLabel(i18n: I18n, permission: bigint, preferChannelSingular = false): string | null {
|
|
if (permission === Permissions.MANAGE_CHANNELS) {
|
|
return preferChannelSingular ? i18n._(msg`Manage Channel`) : i18n._(msg`Manage Channels`);
|
|
}
|
|
return getPermissionLabel(i18n, permission);
|
|
}
|
|
|
|
export function formatBotPermissionsQuery(permissions: Array<string>): string {
|
|
const total = permissions.reduce((acc, perm) => {
|
|
const key = perm as keyof typeof Permissions;
|
|
const value = Permissions[key];
|
|
return acc | (value ?? 0n);
|
|
}, 0n);
|
|
return total.toString();
|
|
}
|