diff --git a/package.json b/package.json index 024dd0ad..b53d93db 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@solid-primitives/set": "^0.4.3", "eventemitter3": "^5.0.0", "isomorphic-ws": "^5.0.0", + "long": "^5.2.1", "revolt-api": "^0.5.17", "solid-js": "^1.7.2", "ulid": "^2.3.0", diff --git a/src/classes/Channel.ts b/src/classes/Channel.ts index 60a0314d..8bb69448 100644 --- a/src/classes/Channel.ts +++ b/src/classes/Channel.ts @@ -2,9 +2,11 @@ import { ReactiveMap } from "@solid-primitives/map"; import type { Channel as ApiChannel } from "revolt-api"; import { decodeTime } from "ulid"; -import { Client, FileArgs } from "../Client"; +import { Client } from "../Client"; import { hydrate } from "../hydration"; import { HydratedChannel } from "../hydration/channel"; +import { bitwiseAndEq, calculatePermission } from "../permissions/calculator"; +import { Permission } from "../permissions/definitions"; import { ObjectStorage } from "../storage/ObjectStorage"; export default (client: Client) => @@ -164,6 +166,13 @@ export default (client: Client) => return Channel.#storage.get(this.id).active; } + /** + * User Ids of recipients of the group + */ + get recipientIds() { + return Channel.#storage.get(this.id).recipientIds; + } + /** * Recipients of the group */ @@ -313,4 +322,23 @@ export default (client: Client) => true ); } + + /** + * Permission the currently authenticated user has against this channel + */ + get permission() { + return calculatePermission(client, this); + } + + /** + * Check whether we have a given permission in a channel + * @param permission Permission Names + * @returns Whether we have this permission + */ + havePermission(...permission: (keyof typeof Permission)[]) { + return bitwiseAndEq( + this.permission, + ...permission.map((x) => Permission[x]) + ); + } }; diff --git a/src/classes/Server.ts b/src/classes/Server.ts index 0180708a..4de4e948 100644 --- a/src/classes/Server.ts +++ b/src/classes/Server.ts @@ -4,6 +4,8 @@ import { decodeTime } from "ulid"; import { Channel, Client } from "../Client"; import { HydratedServer } from "../hydration/server"; +import { bitwiseAndEq, calculatePermission } from "../permissions/calculator"; +import { Permission } from "../permissions/definitions"; import { ObjectStorage } from "../storage/ObjectStorage"; export default (client: Client) => @@ -113,6 +115,13 @@ export default (client: Client) => return new Date(decodeTime(this.id)); } + /** + * Owner's user ID + */ + get ownerId() { + return Server.#storage.get(this.id).ownerId; + } + /** * Owner */ @@ -322,4 +331,33 @@ export default (client: Client) => max_side: 256, }); } + + /** + * Own member object for this server + */ + get member() { + return client.serverMembers.get({ + server: this.id, + user: client.user!.id, + }); + } + + /** + * Permission the currently authenticated user has against this server + */ + get permission() { + return calculatePermission(client, this); + } + + /** + * Check whether we have a given permission in a server + * @param permission Permission Names + * @returns Whether we have this permission + */ + havePermission(...permission: (keyof typeof Permission)[]) { + return bitwiseAndEq( + this.permission, + ...permission.map((x) => Permission[x]) + ); + } }; diff --git a/src/classes/ServerMember.ts b/src/classes/ServerMember.ts index 8526bfb2..f3c7a765 100644 --- a/src/classes/ServerMember.ts +++ b/src/classes/ServerMember.ts @@ -1,8 +1,10 @@ import { ReactiveMap } from "@solid-primitives/map"; import type { Member as ApiMember, MemberCompositeKey } from "revolt-api"; -import { Client } from "../Client"; +import { Channel, Client, Server } from "../Client"; import { HydratedServerMember } from "../hydration/serverMember"; +import { bitwiseAndEq, calculatePermission } from "../permissions/calculator"; +import { Permission } from "../permissions/definitions"; import { ObjectStorage } from "../storage/ObjectStorage"; /** @@ -173,4 +175,94 @@ export default (client: Client) => get timeout() { return ServerMember.#storage.get(key(this.id)).timeout; } + + /** + * Ordered list of roles for this member, from lowest to highest priority. + */ + get orderedRoles() { + const server = this.server!; + return ( + this.roles + ?.map((id) => ({ + id, + ...server.roles?.get(id), + })) + .sort((a, b) => b.rank! - a.rank!) ?? [] + ); + } + + /** + * Member's currently hoisted role. + */ + get hoistedRole() { + const roles = this.orderedRoles.filter((x) => x.hoist); + if (roles.length > 0) { + return roles[roles.length - 1]; + } else { + return null; + } + } + + /** + * Member's current role colour. + */ + get roleColour() { + const roles = this.orderedRoles.filter((x) => x.colour); + if (roles.length > 0) { + return roles[roles.length - 1].colour; + } else { + return null; + } + } + + /** + * Member's ranking. + * Smaller values are ranked as higher priotity. + */ + get ranking() { + if (this.id.user === this.server?.ownerId) { + return -Infinity; + } + + const roles = this.orderedRoles; + if (roles.length > 0) { + return roles[roles.length - 1].rank!; + } else { + return Infinity; + } + } + + /** + * Get the permissions that this member has against a certain object + * @param target Target object to check permissions against + * @returns Permissions that this member has + */ + getPermissions(target: Server | Channel) { + return calculatePermission(client, target, { member: this }); + } + + /** + * Check whether a member has a certain permission against a certain object + * @param target Target object to check permissions against + * @param permission Permission names to check for + * @returns Whether the member has this permission + */ + hasPermission( + target: Server | Channel, + ...permission: (keyof typeof Permission)[] + ) { + return bitwiseAndEq( + this.getPermissions(target), + ...permission.map((x) => Permission[x]) + ); + } + + /** + * Checks whether the target member has a higher rank than this member. + * @param target The member to compare against + * @returns Whether this member is inferior to the target + */ + inferiorTo(target: ServerMember) { + return target.ranking < this.ranking; + } }; diff --git a/src/classes/User.ts b/src/classes/User.ts index 16607fbf..4f2dcd34 100644 --- a/src/classes/User.ts +++ b/src/classes/User.ts @@ -4,6 +4,7 @@ import { decodeTime } from "ulid"; import { Client, FileArgs } from "../Client"; import { HydratedUser } from "../hydration/user"; +import { U32_MAX, UserPermission } from "../permissions/definitions"; import { ObjectStorage } from "../storage/ObjectStorage"; export default (client: Client) => @@ -207,4 +208,43 @@ export default (client: Client) => client.generateFileURL(this.avatar, ...args) ?? this.defaultAvatarURL ); } + + /** + * Permissions against this user + */ + get permission() { + let permissions = 0; + switch (this.relationship) { + case "Friend": + case "User": + return U32_MAX; + case "Blocked": + case "BlockedOther": + return UserPermission.Access; + case "Incoming": + case "Outgoing": + permissions = UserPermission.Access; + } + + if ( + client.channels + .toList() + .find( + (channel) => + (channel.type === "Group" || channel.type === "DirectMessage") && + channel.recipientIds?.includes(client.user!.id) + ) || + client.serverMembers + .toList() + .find((member) => member.id.user === client.user!.id) + ) { + if (client.user?.bot || this.bot) { + permissions |= UserPermission.SendMessage; + } + + permissions |= UserPermission.Access | UserPermission.ViewProfile; + } + + return permissions; + } }; diff --git a/src/permissions/calculator.ts b/src/permissions/calculator.ts new file mode 100644 index 00000000..aca6e6ac --- /dev/null +++ b/src/permissions/calculator.ts @@ -0,0 +1,160 @@ +import Long from "long"; + +import { Channel, Client, Server, ServerMember } from ".."; + +import { + ALLOW_IN_TIMEOUT, + DEFAULT_PERMISSION_DIRECT_MESSAGE, + DEFAULT_PERMISSION_VIEW_ONLY, + Permission, + UserPermission, +} from "./definitions"; + +/** + * Check whether `b` is present in `a` + * @param a Input A + * @param b Inputs (OR'd together) + */ +export function bitwiseAndEq(a: number, ...b: number[]) { + const value = b.reduce((prev, cur) => prev.or(cur), Long.fromNumber(0)); + return value.and(a).eq(value); +} + +/** + * Calculate permissions against a given object + * @param target Target object to check permissions against + * @param options Additional options to use when calculating + */ +export function calculatePermission( + client: Client, + target: Channel | Server, + options?: { + /** + * Pretend to be another ServerMember + */ + member?: ServerMember; + } +): number { + const user = options?.member ? options?.member.user : client.user; + if (user?.privileged) { + return Permission.GrantAllSafe; + } + + if (target instanceof client.servers) { + // 1. Check if owner. + if (target.owner === user?.id) { + return Permission.GrantAllSafe; + } else { + // 2. Get ServerMember. + const member = options?.member ?? + client.serverMembers.get({ + user: user!.id, + server: target.id, + }) ?? { roles: null, timeout: null }; + + if (!member) return 0; + + // 3. Apply allows from default_permissions. + let perm = Long.fromNumber(target.defaultPermissions); + + // 4. If user has roles, iterate in order. + if (member.roles && target.roles) { + // 5. Apply allows and denies from roles. + const permissions = member.orderedRoles.map( + (role) => role.permissions ?? { a: 0, d: 0 } + ); + + for (const permission of permissions) { + perm = perm.or(permission.a).and(Long.fromNumber(permission.d).not()); + } + } + + // 5. Revoke permissions if ServerMember is timed out. + if (member.timeout && member.timeout > new Date()) { + perm = perm.and(ALLOW_IN_TIMEOUT); + } + + return perm.toNumber(); + } + } else { + // 1. Check channel type. + switch (target.type) { + case "SavedMessages": + return Permission.GrantAllSafe; + case "DirectMessage": { + // 2. Determine user permissions. + const user_permissions = target.recipient?.permission || 0; + + // 3. Check if the user can send messages. + if (user_permissions & UserPermission.SendMessage) { + return DEFAULT_PERMISSION_DIRECT_MESSAGE; + } else { + return DEFAULT_PERMISSION_VIEW_ONLY; + } + } + case "Group": { + // 2. Check if user is owner. + if (target.ownerId === user!.id) { + return DEFAULT_PERMISSION_DIRECT_MESSAGE; + } else { + // 3. Pull out group permissions. + return target.permissions ?? DEFAULT_PERMISSION_DIRECT_MESSAGE; + } + } + case "TextChannel": + case "VoiceChannel": { + // 2. Get server. + const server = target.server; + if (typeof server === "undefined") return 0; + + // 3. If server owner, just grant all permissions. + if (server.owner === user?.id) { + return Permission.GrantAllSafe; + } else { + // 4. Get ServerMember. + const member = options?.member ?? + client.serverMembers.get({ + user: user!.id, + server: server.id, + }) ?? { roles: null, timeout: null }; + + if (!member) return 0; + + // 5. Calculate server base permissions. + let perm = Long.fromNumber( + calculatePermission(client, server, options) + ); + + // 6. Apply default allows and denies for channel. + if (target.defaultPermissions) { + perm = perm + .or(target.defaultPermissions.a) + .and(Long.fromNumber(target.defaultPermissions.d).not()); + } + + // 7. If user has roles, iterate in order. + if (member.roles && target.rolePermissions && server.roles) { + // 5. Apply allows and denies from roles. + const roles = member.orderedRoles.map(({ id }) => id); + + for (const id of roles) { + const override = target.rolePermissions[id]; + if (override) { + perm = perm + .or(override.a) + .and(Long.fromNumber(override.d).not()); + } + } + } + + // 8. Revoke permissions if ServerMember is timed out. + if (member.timeout && member.timeout > new Date()) { + perm = perm.and(ALLOW_IN_TIMEOUT); + } + + return perm.toNumber(); + } + } + } + } +} diff --git a/src/permissions/definitions.ts b/src/permissions/definitions.ts new file mode 100644 index 00000000..f711adf3 --- /dev/null +++ b/src/permissions/definitions.ts @@ -0,0 +1,141 @@ +/** + * Permission against User + */ +export const UserPermission = { + Access: 1 << 0, + ViewProfile: 1 << 1, + SendMessage: 1 << 2, + Invite: 1 << 3, +}; + +/** + * Permission against Server / Channel + */ +export const Permission = { + // * Generic permissions + /// Manage the channel or channels on the server + ManageChannel: 2 ** 0, + /// Manage the server + ManageServer: 2 ** 1, + /// Manage permissions on servers or channels + ManagePermissions: 2 ** 2, + /// Manage roles on server + ManageRole: 2 ** 3, + /// Manage server customisation (includes emoji) + ManageCustomisation: 2 ** 4, + + // % 1 bits reserved + + // * Member permissions + /// Kick other members below their ranking + KickMembers: 2 ** 6, + /// Ban other members below their ranking + BanMembers: 2 ** 7, + /// Timeout other members below their ranking + TimeoutMembers: 2 ** 8, + /// Assign roles to members below their ranking + AssignRoles: 2 ** 9, + /// Change own nickname + ChangeNickname: 2 ** 10, + /// Change or remove other's nicknames below their ranking + ManageNicknames: 2 ** 11, + /// Change own avatar + ChangeAvatar: 2 ** 12, + /// Remove other's avatars below their ranking + RemoveAvatars: 2 ** 13, + + // % 7 bits reserved + + // * Channel permissions + /// View a channel + ViewChannel: 2 ** 20, + /// Read a channel's past message history + ReadMessageHistory: 2 ** 21, + /// Send a message in a channel + SendMessage: 2 ** 22, + /// Delete messages in a channel + ManageMessages: 2 ** 23, + /// Manage webhook entries on a channel + ManageWebhooks: 2 ** 24, + /// Create invites to this channel + InviteOthers: 2 ** 25, + /// Send embedded content in this channel + SendEmbeds: 2 ** 26, + /// Send attachments and media in this channel + UploadFiles: 2 ** 27, + /// Masquerade messages using custom nickname and avatar + Masquerade: 2 ** 28, + /// React to messages with emoji + React: 2 ** 29, + + // * Voice permissions + /// Connect to a voice channel + Connect: 2 ** 30, + /// Speak in a voice call + Speak: 2 ** 31, + /// Share video in a voice call + Video: 2 ** 32, + /// Mute other members with lower ranking in a voice call + MuteMembers: 2 ** 33, + /// Deafen other members with lower ranking in a voice call + DeafenMembers: 2 ** 34, + /// Move members between voice channels + MoveMembers: 2 ** 35, + + // * Misc. permissions + // % Bits 36 to 52: free area + // % Bits 53 to 64: do not use + + // * Grant all permissions + /// Safely grant all permissions + GrantAllSafe: 0x000f_ffff_ffff_ffff, +}; + +/** + * Maximum safe value + */ +export const U32_MAX = 2 ** 32 - 1; // 4294967295 + +/** + * Permissions allowed for a user while in timeout + */ +export const ALLOW_IN_TIMEOUT = + Permission.ViewChannel + Permission.ReadMessageHistory; + +/** + * Default permissions if we can only view + */ +export const DEFAULT_PERMISSION_VIEW_ONLY = + Permission.ViewChannel + Permission.ReadMessageHistory; + +/** + * Default base permissions for channels + */ +export const DEFAULT_PERMISSION = + DEFAULT_PERMISSION_VIEW_ONLY + + Permission.SendMessage + + Permission.InviteOthers + + Permission.SendEmbeds + + Permission.UploadFiles + + Permission.Connect + + Permission.Speak; + +/** + * Permissions in saved messages channel + */ +export const DEFAULT_PERMISSION_SAVED_MESSAGES = Permission.GrantAllSafe; + +/** + * Permissions in direct message channels / default permissions for group DMs + */ +export const DEFAULT_PERMISSION_DIRECT_MESSAGE = + DEFAULT_PERMISSION + Permission.React + Permission.ManageChannel; + +/** + * Permissions in server text / voice channel + */ +export const DEFAULT_PERMISSION_SERVER = + DEFAULT_PERMISSION + + Permission.React + + Permission.ChangeNickname + + Permission.ChangeAvatar;