diff --git a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts index e6929aa3e..c336de430 100644 --- a/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts +++ b/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts @@ -9,12 +9,11 @@ import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildEr import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; -import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {ChannelID, GuildID, UserID} from '../../BrandedTypes'; import {SYSTEM_USER_ID} from '../../constants/Core'; import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate'; import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement'; -import type {IGatewayService} from '../../infrastructure/IGatewayService'; +import type {GuildChannelAuthContext, IGatewayService} from '../../infrastructure/IGatewayService'; import type {Channel} from '../../models/Channel'; import type {GuildMember} from '../../models/GuildMember'; import type {User} from '../../models/User'; @@ -173,13 +172,14 @@ export abstract class BaseChannelAuthService { skipNsfwValidation?: boolean; }): Promise { const guildId = channel.guildId!; - const [guildDataResult, guildMemberResult] = await Promise.all([ - this.fetchGuildDataOrThrow({guildId, userId}), + const [authContextResult, guildMemberResult] = await Promise.all([ + this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}), this.gatewayService.getGuildMember({guildId, userId}), ]); - if (!guildDataResult) { + if (!authContextResult) { this.throwGuildAccessError(); } + const guildDataResult = authContextResult.guild; if (!guildMemberResult.success || !guildMemberResult.memberData) { this.throwGuildAccessError(); } @@ -190,7 +190,7 @@ export abstract class BaseChannelAuthService { }); const enforceGuildMfa = await createGuildMfaEnforcer({ userRepository: this.userRepository, - guildData: guildDataResult!, + guildData: guildDataResult, userId, }); const channelPermissions = await this.gatewayService.getUserPermissions({ @@ -210,12 +210,12 @@ export abstract class BaseChannelAuthService { await checkPermission(Permissions.VIEW_CHANNEL); const parentCategory = await this.getParentCategoryContentWarningView({ channel, - guild: guildDataResult!, + parentChannel: authContextResult.parentChannel, }); const requiresAgeVerification = computeEffectiveChannelNsfw( channelToContentWarningView(channel), parentCategory, - guildResponseToContentWarningView(guildDataResult!), + guildResponseToContentWarningView(guildDataResult), ); if ( this.options.validateNsfw && @@ -233,27 +233,32 @@ export abstract class BaseChannelAuthService { } return { channel, - guild: guildDataResult!, + guild: guildDataResult, member, hasPermission, checkPermission, }; } + private parentLookupChannelId(channel: Channel): ChannelID | undefined { + if (!channel.parentId || channel.type === ChannelTypes.GUILD_CATEGORY) { + return undefined; + } + return channel.parentId; + } + private async getParentCategoryContentWarningView({ channel, - guild, + parentChannel, }: { channel: Channel; - guild: GuildResponse; + parentChannel: GuildChannelAuthContext['parentChannel']; }): Promise { if (!channel.parentId || channel.type === ChannelTypes.GUILD_CATEGORY) { return null; } - const parentId = channel.parentId.toString(); - const parentFromGateway = guild.channels?.find((guildChannel) => guildChannel.id === parentId); - if (parentFromGateway) { - return channelResponseToContentWarningView(parentFromGateway); + if (parentChannel) { + return channelResponseToContentWarningView(parentChannel); } const parentCategory = await this.channelRepository.channelData.findUnique(channel.parentId); return parentCategory ? channelToContentWarningView(parentCategory) : null; @@ -266,10 +271,14 @@ export abstract class BaseChannelAuthService { throw new UnknownChannelError(); } - private async fetchGuildDataOrThrow(params: {guildId: GuildID; userId: UserID}): Promise { - const {guildId, userId} = params; + private async fetchGuildAuthContextOrThrow(params: { + guildId: GuildID; + userId: UserID; + channelId?: ChannelID; + }): Promise { + const {guildId, userId, channelId} = params; try { - return await this.gatewayService.getGuildData({guildId, userId}); + return await this.gatewayService.getGuildAuthContext({guildId, userId, channelId}); } catch (error) { await this.handleGuildAccessError(error, guildId); return null; diff --git a/fluxer_api/src/api/channel/tests/MessageSendPermissions.test.ts b/fluxer_api/src/api/channel/tests/MessageSendPermissions.test.ts index 25f1a97f8..75370ccf0 100644 --- a/fluxer_api/src/api/channel/tests/MessageSendPermissions.test.ts +++ b/fluxer_api/src/api/channel/tests/MessageSendPermissions.test.ts @@ -111,7 +111,7 @@ describe('Message send permissions', () => { }); await ensureSessionStarted(harness, member.token); const gatewayService = getGatewayService(); - const getGuildData = vi.spyOn(gatewayService, 'getGuildData'); + const getGuildAuthContext = vi.spyOn(gatewayService, 'getGuildAuthContext'); const getGuildMember = vi.spyOn(gatewayService, 'getGuildMember'); const getUserPermissions = vi.spyOn(gatewayService, 'getUserPermissions'); @@ -121,11 +121,11 @@ describe('Message send permissions', () => { .body({content: 'authenticate me once'}) .execute(); const sendCounts = { - guildData: getGuildData.mock.calls.length, + authContext: getGuildAuthContext.mock.calls.length, guildMember: getGuildMember.mock.calls.length, userPermissions: getUserPermissions.mock.calls.length, }; - getGuildData.mockClear(); + getGuildAuthContext.mockClear(); getGuildMember.mockClear(); getUserPermissions.mockClear(); await createBuilder(harness, member.token) @@ -133,15 +133,15 @@ describe('Message send permissions', () => { .body({content: 'authenticate me once again'}) .execute(); const editCounts = { - guildData: getGuildData.mock.calls.length, + authContext: getGuildAuthContext.mock.calls.length, guildMember: getGuildMember.mock.calls.length, userPermissions: getUserPermissions.mock.calls.length, }; - expect(sendCounts).toEqual({guildData: 1, guildMember: 1, userPermissions: 1}); - expect(editCounts).toEqual({guildData: 1, guildMember: 1, userPermissions: 1}); + expect(sendCounts).toEqual({authContext: 1, guildMember: 1, userPermissions: 1}); + expect(editCounts).toEqual({authContext: 1, guildMember: 1, userPermissions: 1}); } finally { - getGuildData.mockRestore(); + getGuildAuthContext.mockRestore(); getGuildMember.mockRestore(); getUserPermissions.mockRestore(); } diff --git a/fluxer_api/src/api/infrastructure/GatewayService.ts b/fluxer_api/src/api/infrastructure/GatewayService.ts index b55861765..00f39b7d7 100644 --- a/fluxer_api/src/api/infrastructure/GatewayService.ts +++ b/fluxer_api/src/api/infrastructure/GatewayService.ts @@ -10,6 +10,7 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import {ms} from 'itty-time'; @@ -30,6 +31,7 @@ import type { GatewayNodeStats, GatewayVoiceStateCounts, GatewayVoiceStateEntry, + GuildChannelAuthContext, } from './IGatewayService'; const PUSH_BADGE_COUNT_BATCH_SIZE = 100; @@ -82,6 +84,12 @@ interface GuildDataParams { userId: UserID; } +interface GuildAuthContextParams { + guildId: GuildID; + userId: UserID; + channelId?: ChannelID; +} + interface GuildMemberParams { guildId: GuildID; userId: UserID; @@ -232,6 +240,11 @@ interface GuildMemberRpcResponse { member_data?: GuildMemberResponse; } +interface GuildAuthContextRpcResponse { + guild: GuildResponse; + parent_channel?: ChannelResponse | null; +} + type PendingRequest = { resolve: (value: T) => void; reject: (error: Error) => void; @@ -252,17 +265,22 @@ export class GatewayService { > >(); private pendingPermissionRequests = new Map>>(); + private pendingAuthContextRequests = new Map>>(); private pendingBatchRequestCount = 0; private guildDataBatchTimeout: NodeJS.Timeout | null = null; private guildMemberBatchTimeout: NodeJS.Timeout | null = null; private permissionBatchTimeout: NodeJS.Timeout | null = null; + private authContextBatchTimeout: NodeJS.Timeout | null = null; private activeGuildDataRequests = 0; private activeGuildMemberRequests = 0; private activePermissionRequests = 0; + private activeAuthContextRequests = 0; + private authContextUnsupportedUntil = 0; private readonly BATCH_DELAY_MS = ms('5 milliseconds'); private readonly MAX_PENDING_BATCH_REQUESTS = 2000; private readonly MAX_BATCH_CONCURRENCY = 50; private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds'); + private readonly AUTH_CONTEXT_FALLBACK_MS = ms('5 minutes'); constructor() { this.rpcClient = GatewayRpcClient.getInstance(); @@ -376,6 +394,16 @@ export class GatewayService { }, this.BATCH_DELAY_MS); } + private scheduleAuthContextBatch(): void { + if (this.authContextBatchTimeout || this.activeAuthContextRequests >= this.MAX_BATCH_CONCURRENCY) { + return; + } + this.authContextBatchTimeout = setTimeout(() => { + this.authContextBatchTimeout = null; + this.processAuthContextQueue(); + }, this.BATCH_DELAY_MS); + } + private takePendingEntries( requests: Map>>, limit: number, @@ -482,12 +510,43 @@ export class GatewayService { for (const pendingRequests of this.pendingPermissionRequests.values()) { this.rejectPendingRequests(pendingRequests, error); } + for (const pendingRequests of this.pendingAuthContextRequests.values()) { + this.rejectPendingRequests(pendingRequests, error); + } this.pendingGuildDataRequests.clear(); this.pendingGuildMemberRequests.clear(); this.pendingPermissionRequests.clear(); + this.pendingAuthContextRequests.clear(); this.pendingBatchRequestCount = 0; } + private processAuthContextQueue(): void { + const availableSlots = this.MAX_BATCH_CONCURRENCY - this.activeAuthContextRequests; + if (availableSlots <= 0) { + return; + } + const entries = this.takePendingEntries(this.pendingAuthContextRequests, availableSlots); + if (entries.length === 0) { + return; + } + const totalAuthContextRequests = entries.reduce((sum, [, pending]) => sum + pending.length, 0); + Logger.debug( + `[gateway-batch] Processing guild.get_auth_context batch: ${entries.length} unique requests (${totalAuthContextRequests} total)`, + ); + for (const entry of entries) { + this.activeAuthContextRequests += 1; + void this.processAuthContextEntry(entry).finally(() => { + this.activeAuthContextRequests = Math.max(0, this.activeAuthContextRequests - 1); + if (this.pendingAuthContextRequests.size > 0) { + this.scheduleAuthContextBatch(); + } + }); + } + if (this.pendingAuthContextRequests.size > 0) { + this.scheduleAuthContextBatch(); + } + } + private async processGuildDataEntry([key, pending]: [string, Array>]): Promise { try { const [guildIdStr, userIdStr, skipCheck] = key.split('-'); @@ -559,6 +618,65 @@ export class GatewayService { } } + private async processAuthContextEntry([key, pending]: [ + string, + Array>, + ]): Promise { + const [guildIdStr, userIdStr, channelIdStr] = key.split('-'); + const guildId = BigInt(guildIdStr) as GuildID; + const userId = BigInt(userIdStr) as UserID; + const channelId = channelIdStr !== '0' ? (BigInt(channelIdStr) as ChannelID) : undefined; + try { + const result = await this.call('guild.get_auth_context', { + guild_id: guildId.toString(), + user_id: userId.toString(), + channel_id: channelId ? channelId.toString() : null, + }); + this.resolvePendingRequests(pending, { + guild: result.guild, + parentChannel: result.parent_channel ?? null, + }); + } catch (error) { + const transformedError = this.transformGatewayError(error); + if (!this.isAuthContextUnsupportedError(transformedError)) { + this.rejectPendingRequests(pending, transformedError); + this.logBatchFailures('guild.get_auth_context', [{status: 'rejected', reason: error}]); + return; + } + this.authContextUnsupportedUntil = Date.now() + this.AUTH_CONTEXT_FALLBACK_MS; + Logger.warn({error}, '[gateway-rpc] guild.get_auth_context unavailable, falling back to guild.get_data'); + await this.settleAuthContextFromGuildData({guildId, userId, channelId}, pending); + } + } + + private isAuthContextUnsupportedError(error: Error): boolean { + return error instanceof BadGatewayError || error instanceof GatewayTimeoutError; + } + + private async settleAuthContextFromGuildData( + params: GuildAuthContextParams, + pending: Array>, + ): Promise { + try { + this.resolvePendingRequests(pending, await this.getGuildAuthContextFromGuildData(params)); + } catch (error) { + const transformedError = this.transformGatewayError(error); + this.rejectPendingRequests(pending, transformedError); + this.logBatchFailures('guild.get_data', [{status: 'rejected', reason: error}]); + } + } + + private async getGuildAuthContextFromGuildData({ + guildId, + userId, + channelId, + }: GuildAuthContextParams): Promise { + const guild = await this.getGuildData({guildId, userId}); + const parentId = channelId?.toString(); + const parentChannel = parentId ? (guild.channels?.find((channel) => channel.id === parentId) ?? null) : null; + return {guild, parentChannel}; + } + async dispatchGuild({guildId, event, data}: DispatchGuildParams): Promise { await this.call('guild.dispatch', { guild_id: guildId.toString(), @@ -713,6 +831,48 @@ export class GatewayService { }); } + async getGuildAuthContext({guildId, userId, channelId}: GuildAuthContextParams): Promise { + if (Date.now() < this.authContextUnsupportedUntil) { + return await this.getGuildAuthContextFromGuildData({guildId, userId, channelId}); + } + const key = `${guildId.toString()}-${userId.toString()}-${channelId ? channelId.toString() : '0'}`; + return new Promise((resolve, reject) => { + if (this.pendingBatchRequestCount >= this.MAX_PENDING_BATCH_REQUESTS) { + reject(new ServiceUnavailableError()); + return; + } + const pendingRequest: PendingRequest = { + resolve, + reject, + settled: false, + timeoutId: null, + }; + pendingRequest.timeoutId = setTimeout(() => { + const error = new GatewayTimeoutError(); + this.rejectPendingRequests([pendingRequest], error); + this.removePendingAuthContextRequest(key, pendingRequest); + }, this.PENDING_REQUEST_TIMEOUT_MS); + const pending = this.pendingAuthContextRequests.get(key) || []; + pending.push(pendingRequest); + this.pendingAuthContextRequests.set(key, pending); + this.pendingBatchRequestCount += 1; + this.scheduleAuthContextBatch(); + }); + } + + private removePendingAuthContextRequest(key: string, request: PendingRequest): void { + const pending = this.pendingAuthContextRequests.get(key); + if (pending) { + const index = pending.indexOf(request); + if (index >= 0) { + pending.splice(index, 1); + if (pending.length === 0) { + this.pendingAuthContextRequests.delete(key); + } + } + } + } + private removePendingGuildDataRequest(key: string, request: PendingRequest): void { const pending = this.pendingGuildDataRequests.get(key); if (pending) { @@ -1649,6 +1809,10 @@ export class GatewayService { clearTimeout(this.permissionBatchTimeout); this.permissionBatchTimeout = null; } + if (this.authContextBatchTimeout) { + clearTimeout(this.authContextBatchTimeout); + this.authContextBatchTimeout = null; + } this.rejectAllPendingBatchRequests(new ServiceUnavailableError()); } } diff --git a/fluxer_api/src/api/infrastructure/IGatewayService.ts b/fluxer_api/src/api/infrastructure/IGatewayService.ts index 4c2a9efff..fde218bef 100644 --- a/fluxer_api/src/api/infrastructure/IGatewayService.ts +++ b/fluxer_api/src/api/infrastructure/IGatewayService.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {ChannelID, GuildID, MessageID, RoleID, UserID} from '../BrandedTypes'; @@ -106,6 +107,11 @@ export interface GatewayVoiceStateEntry { serverId?: string; } +export interface GuildChannelAuthContext { + guild: GuildResponse; + parentChannel: ChannelResponse | null; +} + export interface GatewayChannelMention { id: string; name: string; @@ -250,6 +256,12 @@ export abstract class IGatewayService { skipMembershipCheck?: boolean; }): Promise; + abstract getGuildAuthContext(params: { + guildId: GuildID; + userId: UserID; + channelId?: ChannelID; + }): Promise; + abstract getGuildMember(params: {guildId: GuildID; userId: UserID}): Promise<{ success: boolean; memberData?: GuildMemberResponse; diff --git a/fluxer_api/src/api/test/NoopGatewayService.ts b/fluxer_api/src/api/test/NoopGatewayService.ts index bbb1672b5..62a776512 100644 --- a/fluxer_api/src/api/test/NoopGatewayService.ts +++ b/fluxer_api/src/api/test/NoopGatewayService.ts @@ -25,6 +25,7 @@ import { type GatewayNodeStats, type GatewayVoiceStateCounts, type GatewayVoiceStateEntry, + type GuildChannelAuthContext, IGatewayService, } from '../infrastructure/IGatewayService'; import {UserRepository} from '../user/repositories/UserRepository'; @@ -562,6 +563,17 @@ export class NoopGatewayService extends IGatewayService { return createDummyGuildResponse({guildId: params.guildId, userId: params.userId}); } + async getGuildAuthContext(params: { + guildId: GuildID; + userId: UserID; + channelId?: ChannelID; + }): Promise { + const guild = await this.getGuildData({guildId: params.guildId, userId: params.userId}); + const parentId = params.channelId?.toString(); + const parentChannel = parentId ? (guild.channels?.find((channel) => channel.id === parentId) ?? null) : null; + return {guild, parentChannel}; + } + async getGuildMember(params: {guildId: GuildID; userId: UserID}): Promise<{ success: boolean; memberData?: GuildMemberResponse; diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild.erl index bd57aabfc..bffd36904 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild.erl @@ -37,6 +37,7 @@ route(M, P) -> -define(HANDLER_MAP, #{ <<"guild.dispatch">> => fun gateway_rpc_guild_lifecycle:handle/2, <<"guild.get_data">> => fun gateway_rpc_guild_lifecycle:handle/2, + <<"guild.get_auth_context">> => fun gateway_rpc_guild_lifecycle:handle/2, <<"guild.start">> => fun gateway_rpc_guild_lifecycle:handle/2, <<"guild.stop">> => fun gateway_rpc_guild_lifecycle:handle/2, <<"guild.reload">> => fun gateway_rpc_guild_lifecycle:handle/2, diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_lifecycle.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_lifecycle.erl index af794baeb..07741da8e 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_lifecycle.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_lifecycle.erl @@ -15,6 +15,7 @@ -spec handle(binary(), map()) -> term(). handle(<<"guild.dispatch">>, P) -> handle_dispatch(P); handle(<<"guild.get_data">>, P) -> handle_get_data(P); +handle(<<"guild.get_auth_context">>, P) -> handle_get_auth_context(P); handle(<<"guild.start">>, P) -> handle_start(P); handle(<<"guild.stop">>, P) -> handle_stop(P); handle(<<"guild.reload">>, P) -> handle_reload(P); @@ -47,6 +48,39 @@ handle_get_data(#{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) -> <<"guild_not_found">> ). +-spec handle_get_auth_context(map()) -> term(). +handle_get_auth_context(#{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin} = Params) -> + GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin), + UserId = optional_user_id(UserIdBin), + ChannelId = optional_channel_id(maps:get(<<"channel_id">>, Params, null)), + gateway_rpc_guild_infra:with_guild( + GuildId, + fun(Pid) -> + get_auth_context_from_guild(Pid, UserId, ChannelId) + end, + <<"guild_not_found">> + ). + +-spec optional_channel_id(term()) -> integer() | null. +optional_channel_id(Value) -> + case validation:validate_optional_snowflake(Value) of + {ok, null} -> null; + {ok, ChannelId} when is_integer(ChannelId) -> ChannelId; + _ -> gateway_rpc_error:raise(validation_invalid_params) + end. + +-spec get_auth_context_from_guild(pid(), integer() | null, integer() | null) -> term(). +get_auth_context_from_guild(Pid, UserId, ChannelId) -> + Request = {get_guild_auth_context, #{user_id => UserId, channel_id => ChannelId}}, + case gen_server:call(Pid, Request, ?GUILD_CALL_TIMEOUT) of + #{auth_context := null} -> + gateway_rpc_error:raise(<<"forbidden">>); + #{auth_context := AuthContext} -> + AuthContext; + _ -> + gateway_rpc_error:raise(<<"guild_data_error">>) + end. + -spec optional_user_id(term()) -> integer() | null. optional_user_id(Value) -> case validation:validate_optional_snowflake(Value) of @@ -194,4 +228,13 @@ optional_user_id_preserves_null_for_skip_membership_check_test() -> optional_user_id_accepts_snowflake_test() -> ?assertEqual(123, optional_user_id(<<"123">>)). + +optional_channel_id_defaults_to_null_test() -> + ?assertEqual(null, optional_channel_id(null)). + +optional_channel_id_accepts_snowflake_test() -> + ?assertEqual(456, optional_channel_id(<<"456">>)). + +optional_channel_id_rejects_garbage_test() -> + ?assertError({gateway_rpc_error, _}, optional_channel_id(<<"nope">>)). -endif. diff --git a/fluxer_gateway/src/guild/guild.erl b/fluxer_gateway/src/guild/guild.erl index fc0ab8c0d..00d190746 100644 --- a/fluxer_gateway/src/guild/guild.erl +++ b/fluxer_gateway/src/guild/guild.erl @@ -89,6 +89,7 @@ query_call_handler(get_user_permissions) -> query; query_call_handler(can_manage_roles) -> query; query_call_handler(can_manage_role) -> query; query_call_handler(get_guild_data) -> query; +query_call_handler(get_guild_auth_context) -> query; query_call_handler(get_assignable_roles) -> query; query_call_handler(get_user_max_role_position) -> query; query_call_handler(check_target_member) -> query; diff --git a/fluxer_gateway/src/guild/guild_data.erl b/fluxer_gateway/src/guild/guild_data.erl index d1208a2e6..c2cac25fc 100644 --- a/fluxer_gateway/src/guild/guild_data.erl +++ b/fluxer_gateway/src/guild/guild_data.erl @@ -4,6 +4,7 @@ -typing([eqwalizer]). -export([get_guild_data/2]). +-export([get_auth_context/2]). -export([get_guild_member/2]). -export([get_guild_members_batch/2]). -export([has_member/2]). @@ -33,6 +34,50 @@ get_guild_data(#{user_id := UserId}, State) -> get_guild_data_for_user(UserId, Data, State) end. +-spec get_auth_context(map(), guild_state()) -> guild_reply(map()). +get_auth_context(#{user_id := UserId, channel_id := ChannelId}, State) -> + Data = guild_data_index:ensure_data_map(State), + case UserId of + null -> + {reply, #{auth_context => build_auth_context(ChannelId, Data, State)}, State}; + _ -> + get_auth_context_for_member(UserId, ChannelId, Data, State) + end. + +-spec get_auth_context_for_member(user_id(), integer() | null, map(), guild_state()) -> + guild_reply(map()). +get_auth_context_for_member(UserId, ChannelId, Data, State) -> + case guild_data_index:get_member(UserId, Data) of + undefined -> + {reply, #{auth_context => null, error_reason => <<"forbidden">>}, State}; + _Member -> + {reply, #{auth_context => build_auth_context(ChannelId, Data, State)}, State} + end. + +-spec build_auth_context(integer() | null, map(), guild_state()) -> map(). +build_auth_context(ChannelId, Data, State) -> + #{ + <<"guild">> => build_auth_guild(Data, State), + <<"parent_channel">> => find_channel(ChannelId, Data) + }. + +-spec build_auth_guild(map(), guild_state()) -> map(). +build_auth_guild(Data, State) -> + GuildProperties = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})), + GuildProperties#{ + <<"roles">> => map_utils:ensure_list(maps:get(<<"roles">>, Data, [])), + <<"member_count">> => maps:get( + member_count, State, guild_data_index:member_count(Data) + ), + <<"online_count">> => guild_member_list:get_online_count(State) + }. + +-spec find_channel(integer() | null, map()) -> map() | null. +find_channel(null, _Data) -> + null; +find_channel(ChannelId, Data) -> + maps:get(ChannelId, guild_data_index:channel_index(Data), null). + -spec get_guild_member(map(), guild_state()) -> guild_reply(map()). get_guild_member(Request, State) -> guild_data_members:get_guild_member(Request, State). diff --git a/fluxer_gateway/src/guild/guild_query_handler.erl b/fluxer_gateway/src/guild/guild_query_handler.erl index 141df22d2..d0a2d4a24 100644 --- a/fluxer_gateway/src/guild/guild_query_handler.erl +++ b/fluxer_gateway/src/guild/guild_query_handler.erl @@ -241,6 +241,8 @@ handle_max_role_position(#{user_id := UserId}, State) -> -spec handle_call_data(term(), guild_state()) -> {reply, term(), guild_state()}. handle_call_data({get_guild_data, Req}, State) -> guild_data:get_guild_data(request_map(Req), State); +handle_call_data({get_guild_auth_context, Req}, State) -> + guild_data:get_auth_context(request_map(Req), State); handle_call_data({get_guild_member, Req}, State) -> guild_data:get_guild_member(request_map(Req), State); handle_call_data({get_guild_members_batch, Req}, State) -> diff --git a/fluxer_gateway/test/guild_data_tests.erl b/fluxer_gateway/test/guild_data_tests.erl index 6807bc3cd..1aac70036 100644 --- a/fluxer_gateway/test/guild_data_tests.erl +++ b/fluxer_gateway/test/guild_data_tests.erl @@ -16,6 +16,75 @@ get_guild_data_membership_gate_test() -> Roles = maps:get(<<"roles">>, Guild, []), ?assertMatch([_ | _], Roles). +get_auth_context_membership_gate_test() -> + State = test_state(), + {reply, Reply1, _} = guild_data:get_auth_context( + #{user_id => 999, channel_id => null}, State + ), + ?assertEqual(null, maps:get(auth_context, Reply1)), + ?assertEqual(<<"forbidden">>, maps:get(error_reason, Reply1)), + {reply, Reply2, _} = guild_data:get_auth_context( + #{user_id => 200, channel_id => null}, State + ), + Context = maps:get(auth_context, Reply2), + Guild = maps:get(<<"guild">>, Context), + ?assertEqual(<<"Fluxer">>, maps:get(<<"name">>, Guild)), + ?assertMatch([_ | _], maps:get(<<"roles">>, Guild, [])), + ?assertEqual(null, maps:get(<<"parent_channel">>, Context)). + +get_auth_context_omits_collections_test() -> + State = test_state(), + {reply, Reply, _} = guild_data:get_auth_context( + #{user_id => 200, channel_id => null}, State + ), + Guild = maps:get(<<"guild">>, maps:get(auth_context, Reply)), + ?assertNot(maps:is_key(<<"channels">>, Guild)), + ?assertNot(maps:is_key(<<"emojis">>, Guild)), + ?assertNot(maps:is_key(<<"stickers">>, Guild)). + +get_auth_context_resolves_requested_channel_test() -> + State = test_state(), + {reply, Reply, _} = guild_data:get_auth_context( + #{user_id => 200, channel_id => 500}, State + ), + Parent = maps:get(<<"parent_channel">>, maps:get(auth_context, Reply)), + ?assertEqual(500, maps:get(<<"id">>, Parent)), + ?assertEqual(0, maps:get(<<"type">>, Parent)). + +get_auth_context_unknown_channel_is_null_test() -> + State = test_state(), + {reply, Reply, _} = guild_data:get_auth_context( + #{user_id => 200, channel_id => 999}, State + ), + ?assertEqual(null, maps:get(<<"parent_channel">>, maps:get(auth_context, Reply))). + +get_auth_context_null_user_skips_membership_test() -> + State = test_state(), + {reply, Reply, _} = guild_data:get_auth_context( + #{user_id => null, channel_id => null}, State + ), + Guild = maps:get(<<"guild">>, maps:get(auth_context, Reply)), + ?assertEqual(<<"Fluxer">>, maps:get(<<"name">>, Guild)). + +get_auth_context_matches_get_data_scalars_test() -> + State = test_state(), + {reply, DataReply, _} = guild_data:get_guild_data(#{user_id => 200}, State), + {reply, ContextReply, _} = guild_data:get_auth_context( + #{user_id => 200, channel_id => null}, State + ), + Full = maps:get(guild_data, DataReply), + Lean = maps:get(<<"guild">>, maps:get(auth_context, ContextReply)), + Collections = [<<"channels">>, <<"emojis">>, <<"stickers">>], + Expected = maps:without(Collections, Full), + ?assertEqual(Expected, Lean). + +get_auth_context_is_reachable_through_the_query_handler_test() -> + State = test_state(), + Request = {get_guild_auth_context, #{user_id => 200, channel_id => 500}}, + {reply, Reply, _} = guild_query_handler:handle_call(Request, {self(), make_ref()}, State), + Context = maps:get(auth_context, Reply), + ?assertEqual(500, maps:get(<<"id">>, maps:get(<<"parent_channel">>, Context))). + get_guild_state_filters_channels_test() -> State = test_state(), GuildState = guild_data:get_guild_state(200, State),