fluxer/fluxer_app/src/utils/PermissionUtils.tsx
2026-01-01 21:05:54 +00:00

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