diff --git a/src/Client.ts b/src/Client.ts index a1a3602d..9bcb6b41 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -208,6 +208,12 @@ export class Client extends AsyncEventEmitter { #setConnectionFailureCount: Setter; #reconnectTimeout: number | undefined; + /** + * Server and roles to calculate permissions as, for previewing a role + */ + readonly viewAs: Accessor<{ server: string; roles: string[] } | undefined>; + #setViewAs: Setter<{ server: string; roles: string[] } | undefined>; + /** * Create Stoat.js Client * @param configuration Deprecated - Please use `Client.initConfig` if you need to override config. @@ -256,6 +262,8 @@ export class Client extends AsyncEventEmitter { baseURL: this.options.baseURL, }); + this.#guardAPI(); + const [configured, setConfigured] = createSignal( configuration !== undefined, ); @@ -270,6 +278,12 @@ export class Client extends AsyncEventEmitter { this.connectionFailureCount = connectionFailureCount; this.#setConnectionFailureCount = setConnectionFailureCount; + const [viewAs, setViewAs] = createSignal< + { server: string; roles: string[] } | undefined + >(); + this.viewAs = viewAs; + this.#setViewAs = setViewAs; + this.account = new AccountCollection(this); this.bots = new BotCollection(this); this.channels = new ChannelCollection(this); @@ -334,6 +348,48 @@ export class Client extends AsyncEventEmitter { : ["X-Session-Token", this.#session?.token as string]; } + /** + * Calculate permissions in a server as if we only had the given roles + * @param server Server ID, or nothing to stop previewing + * @param roles Role IDs + */ + setViewAs(server?: string, roles: string[] = []): void { + this.#setViewAs(server ? { server, roles } : undefined); + } + + /** + * Whether a request would make changes in the server we are previewing + * @param path Request path + */ + #blockedWhilePreviewing( + method: string, + path: string, + data?: unknown, + ): boolean { + const server = this.viewAs()?.server; + if (!server || /\/(ack|search)(\/|$)/.test(path)) return false; + + const [, type, id, sub] = path.split("/"); + if (type === "servers") return id === server; + if (type === "channels") return this.channels.get(id)?.serverId === server; + if (type === "custom" && id === "emoji") { + if (method === "put") { + return (data as { parent?: { id?: string } })?.parent?.id === server; + } + const emoji = this.emojis.get(sub); + return emoji + ? emoji.parent.type === "Server" && emoji.parent.id === server + : true; + } + if (type === "webhooks") { + const webhook = this.channelWebhooks.get(id); + return webhook ? webhook.channel?.serverId === server : true; + } + if (type === "invites") return method === "delete"; + + return false; + } + /** * Connect to Revolt */ @@ -381,6 +437,23 @@ export class Client extends AsyncEventEmitter { revolt: this.#session, }, }); + + this.#guardAPI(); + } + + /** + * Block changes to a server while previewing it as a role + */ + #guardAPI(): void { + const req = this.api.req.bind(this.api); + this.api.req = ((method: string, path: string, ...args: unknown[]) => + method !== "get" && this.#blockedWhilePreviewing(method, path, args[0]) + ? Promise.reject({ type: "ViewingAsRole" }) + : (req as (...args: unknown[]) => Promise)( + method, + path, + ...args, + )) as typeof this.api.req; } /** diff --git a/src/classes/Server.ts b/src/classes/Server.ts index ca48bb9e..0ce16c84 100644 --- a/src/classes/Server.ts +++ b/src/classes/Server.ts @@ -273,7 +273,9 @@ export class Server { * Default channel for this server */ get defaultChannel(): Channel | undefined { - return this.orderedChannels.find((cat) => cat.channels.length)?.channels[0]; + return this.orderedChannels + .flatMap((cat) => cat.channels) + .find((channel) => this.#previewCanView(channel)); } /** @@ -294,7 +296,9 @@ export class Server { * @returns Whether the server is unread */ get unread(): boolean { - return !!this.channels.find((channel) => channel.unread); + return !!this.channels.find( + (channel) => channel.unread && this.#previewCanView(channel), + ); } /** @@ -302,9 +306,9 @@ export class Server { * @returns Array of message IDs which are unread */ get mentions(): string[] { - const arr = this.channels.map((channel) => - Array.from(channel.mentions?.values() ?? []), - ); + const arr = this.channels + .filter((channel) => this.#previewCanView(channel)) + .map((channel) => Array.from(channel.mentions?.values() ?? [])); return ([] as string[]).concat(...arr); } @@ -340,6 +344,27 @@ export class Server { }); } + /** + * Whether a channel is visible to the roles we are previewing, if any + * @param channel Channel + */ + #previewCanView(channel: Channel): boolean { + return !this.previewRoles || channel.havePermission("ViewChannel"); + } + + /** + * Roles we are previewing in this server, from lowest to highest priority + */ + get previewRoles(): ServerRole[] | undefined { + const viewAs = this.#collection.client.viewAs(); + if (viewAs?.server !== this.id) return; + + return viewAs.roles + .map((id) => this.roles.get(id)!) + .filter((role) => role) + .sort((a, b) => b.rank! - a.rank!); + } + /** * Permission the currently authenticated user has against this server */ diff --git a/src/classes/ServerMember.ts b/src/classes/ServerMember.ts index 7e33585d..ce16c384 100644 --- a/src/classes/ServerMember.ts +++ b/src/classes/ServerMember.ts @@ -162,10 +162,10 @@ export class ServerMember { } /** - * Member's ranking + * Member's ranking, ignoring any role preview * Smaller values are ranked as higher priority */ - get ranking(): number { + get realRanking(): number { if (this.id.user === this.server?.ownerId) { return -Infinity; } @@ -178,6 +178,22 @@ export class ServerMember { } } + /** + * Member's ranking + * Smaller values are ranked as higher priority + */ + get ranking(): number { + // Our own member is ranked as the previewed roles, if any + const preview = + this.id.user === this.#collection.client.user?.id + ? this.server?.previewRoles + : undefined; + + if (!preview) return this.realRanking; + + return preview.length > 0 ? preview[preview.length - 1].rank! : Infinity; + } + /** * Get the permissions that this member has against a certain object * @param target Target object to check permissions against diff --git a/src/permissions/calculator.ts b/src/permissions/calculator.ts index 1d88ca54..56901431 100644 --- a/src/permissions/calculator.ts +++ b/src/permissions/calculator.ts @@ -18,6 +18,20 @@ export function bitwiseAndEq(a: bigint, ...b: bigint[]): boolean { return (value & a) === value; } +/** + * Build a stand-in member holding only the roles being previewed + * @param server Server the target belongs to + */ +function previewMember(server?: Server): ServerMember | undefined { + const orderedRoles = server?.previewRoles; + if (!orderedRoles) return; + + return { + roles: orderedRoles.map((role) => role.id), + orderedRoles, + } as unknown as ServerMember; +} + /** * Calculate permissions against a given object * @param target Target object to check permissions against @@ -33,6 +47,11 @@ export function calculatePermission( member?: ServerMember; }, ): bigint { + const preview = + !options?.member && + previewMember(target instanceof Server ? target : target.server); + if (preview) options = { member: preview }; + const user = options?.member ? options?.member.user : client.user; if (user?.privileged) { return Permission.GrantAllSafe;