Files
fluxer/fluxer_api/src/api/channel/services/BaseChannelAuthService.ts
T

345 lines
11 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
import {DMPermissionValidator} from '@app/api/channel/services/DMPermissionValidator';
import {
ensurePersonalNotesChannelExists,
isPersonalNotesChannelId,
} from '@app/api/channel/services/PersonalNotesChannelRepair';
import {
type ContentWarningChannelLike,
channelResponseToContentWarningView,
channelToContentWarningView,
computeEffectiveChannelNsfw,
guildResponseToContentWarningView,
} from '@app/api/channel/utils/EffectiveContentWarning';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
import type {GuildChannelAuthContext, IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {Channel} from '@app/api/models/Channel';
import type {GuildMember} from '@app/api/models/GuildMember';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {CannotSendMessagesToUserError} from '@fluxer/errors/src/domains/channel/CannotSendMessagesToUserError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
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';
export interface ChannelAuthOptions {
errorOnMissingGuild: 'unknown_channel' | 'missing_permissions';
validateNsfw: boolean;
}
interface DMSendPermissionsByChannelParams {
channel: Channel;
userId: UserID;
}
interface DMSendPermissionsByChannelIdParams {
channelId: ChannelID;
userId: UserID;
}
type DMSendPermissionsParams = DMSendPermissionsByChannelParams | DMSendPermissionsByChannelIdParams;
export abstract class BaseChannelAuthService {
protected abstract readonly options: ChannelAuthOptions;
protected dmPermissionValidator: DMPermissionValidator;
constructor(
protected channelRepository: IChannelRepositoryAggregate,
protected userRepository: IUserRepository,
protected guildRepository: IGuildRepositoryAggregate,
protected gatewayService: IGatewayService,
) {
this.dmPermissionValidator = new DMPermissionValidator({
userRepository: this.userRepository,
guildRepository: this.guildRepository,
});
}
async getChannelAuthenticated({
userId,
channelId,
skipNsfwValidation,
}: {
userId: UserID;
channelId: ChannelID;
skipNsfwValidation?: boolean;
}): Promise<AuthenticatedChannel> {
if (this.isPersonalNotesChannel({userId, channelId})) {
const channel = await ensurePersonalNotesChannelExists({
channelRepository: this.channelRepository.channelData,
userId,
});
return this.getRealPersonalNotesChannelAuth({channel, userId});
}
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (!channel.guildId) {
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
return this.getDMChannelAuth({channel, recipients, userId});
}
return this.getGuildChannelAuth({channel, userId, skipNsfwValidation});
}
isPersonalNotesChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): boolean {
return isPersonalNotesChannelId({userId, channelId});
}
protected async getRealPersonalNotesChannelAuth({
channel,
userId,
}: {
channel: Channel;
userId: UserID;
}): Promise<AuthenticatedChannel> {
if (!this.isPersonalNotesChannel({userId, channelId: channel.id})) {
throw new UnknownChannelError();
}
if (channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
throw new UnknownChannelError();
}
return {
channel,
guild: null,
member: null,
hasPermission: async () => true,
checkPermission: async () => {},
};
}
protected async getDMChannelAuth({
channel,
recipients,
userId,
}: {
channel: Channel;
recipients: Array<User>;
userId: UserID;
}): Promise<AuthenticatedChannel> {
if (userId === SYSTEM_USER_ID) {
return {
channel,
guild: null,
member: null,
hasPermission: async () => true,
checkPermission: async () => {},
};
}
if (channel.type === ChannelTypes.DM && channel.ownerId != null && channel.ownerId !== userId) {
throw new UnknownChannelError();
}
const isRecipient = recipients.some((recipient) => recipient.id === userId);
if (!isRecipient) throw new UnknownChannelError();
return {
channel,
guild: null,
member: null,
hasPermission: async () => true,
checkPermission: async () => {},
};
}
async validateDMSendPermissions(params: DMSendPermissionsParams): Promise<void> {
const {userId} = params;
const channel =
'channel' in params ? params.channel : await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel) throw new UnknownChannelError();
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return;
}
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== userId);
if (recipientIds.length !== 1) {
throw new CannotSendMessagesToUserError();
}
await this.dmPermissionValidator.validate({senderId: userId, recipientId: recipientIds[0]});
}
protected async getGuildChannelAuth({
channel,
userId,
skipNsfwValidation,
}: {
channel: Channel;
userId: UserID;
skipNsfwValidation?: boolean;
}): Promise<AuthenticatedChannel> {
const guildId = channel.guildId!;
const [authContextResult, guildMemberResult] = await Promise.all([
this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}),
this.fetchGuildMemberOrThrow({guildId, userId}),
]);
if (!authContextResult) {
this.throwGuildAccessError();
}
const guildDataResult = authContextResult.guild;
if (!guildMemberResult.success || !guildMemberResult.memberData) {
this.throwGuildAccessError();
}
const member = await this.fillMissingMemberTimeout({
guildId,
userId,
memberData: guildMemberResult.memberData!,
});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData: guildDataResult,
userId,
});
const channelPermissions = await this.gatewayService.getUserPermissions({
guildId,
userId,
channelId: channel.id,
});
const hasPermission = async (permission: bigint): Promise<boolean> => {
const allowed = (channelPermissions & permission) === permission;
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
if (!allowed) throw new MissingPermissionsError();
};
await checkPermission(Permissions.VIEW_CHANNEL);
const parentCategory = await this.getParentCategoryContentWarningView({
channel,
parentChannel: authContextResult.parentChannel,
});
const requiresAgeVerification = computeEffectiveChannelNsfw(
channelToContentWarningView(channel),
parentCategory,
guildResponseToContentWarningView(guildDataResult),
);
if (
this.options.validateNsfw &&
!skipNsfwValidation &&
(channel.type === ChannelTypes.GUILD_TEXT ||
channel.type === ChannelTypes.GUILD_ANNOUNCEMENT ||
channel.type === ChannelTypes.GUILD_VOICE ||
channel.type === ChannelTypes.GUILD_LINK) &&
requiresAgeVerification
) {
const user = await this.userRepository.findUnique(userId);
if (!user) throw new UnknownUserError();
if (!canUserAccessNsfwContent(user)) {
throw new NsfwContentRequiresAgeVerificationError();
}
}
return {
channel,
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,
parentChannel,
}: {
channel: Channel;
parentChannel: GuildChannelAuthContext['parentChannel'];
}): Promise<ContentWarningChannelLike | null> {
if (!channel.parentId || channel.type === ChannelTypes.GUILD_CATEGORY) {
return null;
}
if (parentChannel) {
return channelResponseToContentWarningView(parentChannel);
}
const parentCategory = await this.channelRepository.channelData.findUnique(channel.parentId);
return parentCategory ? channelToContentWarningView(parentCategory) : null;
}
protected throwGuildAccessError(): never {
if (this.options.errorOnMissingGuild === 'missing_permissions') {
throw new MissingPermissionsError();
}
throw new UnknownChannelError();
}
private async fetchGuildAuthContextOrThrow(params: {
guildId: GuildID;
userId: UserID;
channelId?: ChannelID;
}): Promise<GuildChannelAuthContext | null> {
const {guildId, userId, channelId} = params;
try {
return await this.gatewayService.getGuildAuthContext({guildId, userId, channelId});
} catch (error) {
await this.handleGuildAccessError(error, guildId);
return null;
}
}
private async fetchGuildMemberOrThrow(params: {guildId: GuildID; userId: UserID}): Promise<{
success: boolean;
memberData?: GuildMemberResponse;
}> {
try {
return await this.gatewayService.getGuildMember(params);
} catch (error) {
await this.handleGuildAccessError(error, params.guildId);
throw error;
}
}
private async handleGuildAccessError(error: unknown, guildId: GuildID): Promise<void> {
if (error instanceof UnknownGuildError) {
if (await this.guildExists(guildId)) {
throw new AccessDeniedError();
}
throw new UnknownGuildError();
}
throw error;
}
private async guildExists(guildId: GuildID): Promise<boolean> {
const guild = await this.guildRepository.findUnique(guildId);
return guild !== null;
}
private async fillMissingMemberTimeout({
guildId,
userId,
memberData,
}: {
guildId: GuildID;
userId: UserID;
memberData: GuildMemberResponse;
}): Promise<GuildMemberResponse> {
if (memberData.communication_disabled_until !== undefined) {
return memberData;
}
const persistedMember = await this.guildRepository.getMember(guildId, userId);
if (!persistedMember) {
this.throwGuildAccessError();
}
return {
...memberData,
communication_disabled_until: this.formatCommunicationDisabledUntil(persistedMember),
};
}
private formatCommunicationDisabledUntil(member: GuildMember): string | null {
return member.communicationDisabledUntil?.toISOString() ?? null;
}
}