diff --git a/src/Client.ts b/src/Client.ts index 86faa1e7..f5b40c5c 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -217,6 +217,12 @@ export class Client extends AsyncEventEmitter { #setEntitlements: 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. @@ -265,6 +271,8 @@ export class Client extends AsyncEventEmitter { baseURL: this.options.baseURL, }); + this.#guardAPI(); + const [configured, setConfigured] = createSignal( configuration !== undefined, ); @@ -279,6 +287,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; + const [entitlements, setEntitlements] = createSignal< ProtocolV1["types"]["entitlement"][] >([]); @@ -349,6 +363,55 @@ 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 + */ + setViewAs(server?: string, roles: string[] = []): void { + this.#setViewAs(server ? { server, roles } : undefined); + } + + /** + * Whether a request would make changes in the server we are previewing + */ + #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") { + const channel = this.channels.get(id); + return channel ? channel.serverId === server : true; + } + 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 + ? webhook.channel.serverId === server + : true + : true; + } + if (type === "bots" && sub === "invite") { + return (data as { server?: string })?.server === server; + } + if (type === "invites") return method === "delete"; + + return false; + } + /** * Connect to Revolt */ @@ -396,6 +459,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; } /** @@ -444,6 +524,7 @@ export class Client extends AsyncEventEmitter { this.removeAllListeners(); this.events.disconnect(); this.#setEntitlements([]); + this.#setViewAs(undefined); } /** diff --git a/src/classes/Server.ts b/src/classes/Server.ts index ca48bb9e..aaee6033 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,26 @@ export class Server { }); } + /** + * Whether a channel is visible to the roles we are previewing, if any + */ + #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 */ @@ -491,6 +515,10 @@ export class Server { } this.#collection.delete(this.id); + + if (this.#collection.client.viewAs()?.server === this.id) { + this.#collection.client.setViewAs(); + } } // TODO: delete members, emoji, etc }); 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..171c5137 100644 --- a/src/permissions/calculator.ts +++ b/src/permissions/calculator.ts @@ -18,6 +18,19 @@ export function bitwiseAndEq(a: bigint, ...b: bigint[]): boolean { return (value & a) === value; } +/** + * Build a stand-in member holding only the roles being previewed + */ +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 +46,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;