Files
fluxer/fluxer_api/src/channel/services/BaseChannelAuthService.ts
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

237 lines
7.0 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type ChannelID, channelIdToUserId, type UserID, userIdToChannelId} from '~/BrandedTypes';
import {ChannelTypes, Permissions} from '~/Constants';
import {
MissingPermissionsError,
NsfwContentRequiresAgeVerificationError,
UnknownChannelError,
UnknownUserError,
} from '~/Errors';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {Channel, type User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import {isUserAdult} from '~/utils/AgeUtils';
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from './AuthenticatedChannel';
import {DMPermissionValidator} from './DMPermissionValidator';
export interface ChannelAuthOptions {
errorOnMissingGuild: 'unknown_channel' | 'missing_permissions';
validateNsfw: boolean;
useVirtualPersonalNotes: boolean;
}
export abstract class BaseChannelAuthService {
protected abstract readonly options: ChannelAuthOptions;
protected dmPermissionValidator: DMPermissionValidator;
constructor(
protected channelRepository: IChannelRepositoryAggregate,
protected userRepository: IUserRepository,
protected guildRepository: IGuildRepository,
protected gatewayService: IGatewayService,
) {
this.dmPermissionValidator = new DMPermissionValidator({
userRepository: this.userRepository,
guildRepository: this.guildRepository,
});
}
async getChannelAuthenticated({
userId,
channelId,
}: {
userId: UserID;
channelId: ChannelID;
}): Promise<AuthenticatedChannel> {
if (this.isPersonalNotesChannel({userId, channelId})) {
if (this.options.useVirtualPersonalNotes) {
return this.getVirtualPersonalNotesChannelAuth(channelId);
}
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
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});
}
isPersonalNotesChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): boolean {
return userIdToChannelId(userId) === channelId;
}
protected createVirtualPersonalNotesChannel(userId: UserID): Channel {
return new Channel({
channel_id: userIdToChannelId(userId),
guild_id: null,
type: ChannelTypes.DM_PERSONAL_NOTES,
name: '',
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 0,
owner_id: null,
recipient_ids: new Set(),
nsfw: false,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
});
}
protected getVirtualPersonalNotesChannelAuth(channelId: ChannelID): AuthenticatedChannel {
const channel = this.createVirtualPersonalNotesChannel(channelIdToUserId(channelId));
return {
channel,
guild: null,
member: null,
hasPermission: async () => true,
checkPermission: async () => {},
};
}
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> {
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({channelId, userId}: {channelId: ChannelID; userId: UserID}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
await this.dmPermissionValidator.validate({recipients, userId});
}
protected async getGuildChannelAuth({
channel,
userId,
}: {
channel: Channel;
userId: UserID;
}): Promise<AuthenticatedChannel> {
const guildId = channel.guildId!;
const [guildDataResult, guildMemberResult] = await Promise.all([
this.gatewayService.getGuildData({guildId, userId}),
this.gatewayService.getGuildMember({guildId, userId}),
]);
if (!guildDataResult) {
this.throwGuildAccessError();
}
if (!guildMemberResult.success || !guildMemberResult.memberData) {
this.throwGuildAccessError();
}
const hasPermission = async (permission: bigint): Promise<boolean> => {
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
};
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
if (!allowed) throw new MissingPermissionsError();
};
await checkPermission(Permissions.VIEW_CHANNEL);
if (this.options.validateNsfw && channel.type === ChannelTypes.GUILD_TEXT && channel.isNsfw) {
const user = await this.userRepository.findUnique(userId);
if (!user) throw new UnknownUserError();
if (!isUserAdult(user.dateOfBirth)) {
throw new NsfwContentRequiresAgeVerificationError();
}
}
return {
channel,
guild: guildDataResult!,
member: guildMemberResult.memberData!,
hasPermission,
checkPermission,
};
}
protected throwGuildAccessError(): never {
if (this.options.errorOnMissingGuild === 'missing_permissions') {
throw new MissingPermissionsError();
}
throw new UnknownChannelError();
}
}