summary refs log tree commit diff
path: root/src/database/entities/Channel.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/database/entities/Channel.ts')
-rw-r--r--src/database/entities/Channel.ts736
1 files changed, 736 insertions, 0 deletions
diff --git a/src/database/entities/Channel.ts b/src/database/entities/Channel.ts
new file mode 100644

index 00000000..17a2a628 --- /dev/null +++ b/src/database/entities/Channel.ts
@@ -0,0 +1,736 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program 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. + + This program 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 this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { HTTPError } from "lambert-server/HTTPError"; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm"; +import { DmChannelDTO } from "../../util/dtos"; +import { ChannelCreateEvent, ChannelRecipientRemoveEvent, ThreadCreateEvent, ThreadMembersUpdateEvent } from "../../util/interfaces"; +import { InvisibleCharacters, Snowflake, emitEvent, getPermission, trimSpecial, Permissions, Config, DiscordApiErrors } from "../../util/util"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { Invite } from "./Invite"; +import { Message } from "./Message"; +import { Tag } from "./Tag"; +import { ReadState } from "./ReadState"; +import { Recipient } from "./Recipient"; +import { User } from "./User"; +import { VoiceState } from "./VoiceState"; +import { Webhook } from "./Webhook"; +import { Member } from "./Member"; +import { ChannelPermissionOverwrite, ChannelType, PublicChannel, PublicUserProjection, ThreadMetadata } from "@spacebar/schemas"; +import { OrmUtils } from "../../util/imports"; +import { ThreadMember } from "./ThreadMember"; + +@Entity({ + name: "channels", +}) +export class Channel extends BaseClass { + @Column() + created_at: Date; + + @Column({ nullable: true }) + name?: string; + + @Column({ type: "text", nullable: true }) + icon?: string | null; + + @Column({ type: "int" }) + type: ChannelType; + + @OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + recipients?: Recipient[]; + + @OneToMany(() => ThreadMember, (member: ThreadMember) => member.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + thread_members?: ThreadMember[]; + + @Column({ nullable: true }) + last_message_id?: string; + + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.guild) + guild_id?: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, (guild) => guild.channels, { + onDelete: "CASCADE", + nullable: true, + }) + guild?: Guild; + + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.parent) + parent_id: string | null; + + @JoinColumn({ name: "parent_id" }) + @ManyToOne(() => Channel) + parent?: Channel; + + // for group DMs and owned custom channel types + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.owner) + owner_id?: string; + + @JoinColumn({ name: "owner_id" }) + @ManyToOne(() => User) + owner: User; + + @Column({ nullable: true, type: "timestamp with time zone" }) + last_pin_timestamp?: Date | null; // ISO8601 + + @Column({ nullable: true }) + default_auto_archive_duration?: number; + + @Column({ type: "jsonb", nullable: true }) + permission_overwrites?: ChannelPermissionOverwrite[]; + + @Column({ nullable: true }) + video_quality_mode?: number; + + @Column({ nullable: true }) + bitrate?: number; + + @Column({ nullable: true }) + user_limit?: number; + + @Column() + nsfw: boolean = false; + + @Column({ nullable: true }) + rate_limit_per_user?: number; + + @Column({ nullable: true }) + topic?: string; + + @OneToMany(() => Invite, (invite: Invite) => invite.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + invites?: Invite[]; + + @Column({ nullable: true }) + retention_policy_id?: string; + + @OneToMany(() => Message, (message: Message) => message.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + messages?: Message[]; + + @OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + voice_states?: VoiceState[]; + + @OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + read_states?: ReadState[]; + + @OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + webhooks?: Webhook[]; + + @Column() + flags: number = 0; + + @Column({ nullable: true }) + default_thread_rate_limit_per_user?: number = 0; + + @Column({ type: "jsonb", nullable: true }) + thread_metadata?: ThreadMetadata; + + @Column({ nullable: true }) + member_count?: number; + + @Column({ nullable: true }) + message_count?: number; + + @Column({ nullable: true }) + total_message_sent?: number; + + @JoinColumn({ name: "available_tags_ids" }) + @OneToMany(() => Tag, (tag: Tag) => tag.channel, { + cascade: true, + orphanedRowAction: "delete", + }) + available_tags?: Tag[]; + + @Column("text", { array: true, nullable: true }) + applied_tags?: string[]; + + @Column("text", { nullable: true }) + status?: string | null; + + /** Must be calculated Channel.calculatePosition */ + position: number; + + // TODO: DM channel + static async createChannel( + channel: Partial<Channel>, + user_id: string = "0", + opts?: { + keepId?: boolean; + skipExistsCheck?: boolean; + skipPermissionCheck?: boolean; + skipEventEmit?: boolean; + skipNameChecks?: boolean; + }, + ): Promise<Channel> { + if (!opts?.skipPermissionCheck) { + // Always check if user has permission first + const permissions = await getPermission(user_id, channel.guild_id); + permissions.hasThrow("MANAGE_CHANNELS"); + } + + const guild = await Guild.findOneOrFail({ + where: { id: channel.guild_id }, + select: { + features: !opts?.skipNameChecks, + channel_ordering: true, + id: true, + }, + }); + + if (!opts?.skipNameChecks) { + if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) { + for (const character of InvisibleCharacters) if (channel.name.includes(character)) throw new HTTPError("Channel name cannot include invalid characters", 403); + + // Categories skip these checks on discord.com + if ( + (channel.type !== ChannelType.GUILD_CATEGORY && channel.type !== ChannelType.GUILD_STAGE_VOICE && channel.type !== ChannelType.GUILD_VOICE) || + guild.features.includes("IRC_LIKE_CATEGORY_NAMES") + ) { + if (channel.name.includes(" ")) throw new HTTPError("Channel name cannot include invalid characters", 403); + + if (channel.name.match(/--+/g)) throw new HTTPError("Channel name cannot include multiple adjacent dashes.", 403); + + if (channel.name.charAt(0) === "-" || channel.name.charAt(channel.name.length - 1) === "-") + throw new HTTPError("Channel name cannot start/end with dash.", 403); + } else channel.name = channel.name.trim(); //category names are trimmed client side on discord.com + } + + if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) { + if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403); + } + } + + switch (channel.type) { + // TODO: should threads even be routed through this function instead of createThreadChannel? + case ChannelType.GUILD_PUBLIC_THREAD: + case ChannelType.GUILD_PRIVATE_THREAD: + case ChannelType.GUILD_NEWS_THREAD: + case ChannelType.GUILD_TEXT: + case ChannelType.GUILD_FORUM: + case ChannelType.GUILD_MEDIA: + case ChannelType.GUILD_NEWS: + case ChannelType.GUILD_VOICE: + if (channel.parent_id && !opts?.skipExistsCheck) { + const exists = await Channel.findOneOrFail({ + where: { id: channel.parent_id }, + }); + if (!exists) throw new HTTPError("Parent id channel doesn't exist", 400); + if (exists.guild_id !== channel.guild_id) throw new HTTPError("The category channel needs to be in the guild"); + } + break; + case ChannelType.GUILD_CATEGORY: + case ChannelType.UNHANDLED: + break; + case ChannelType.DM: + case ChannelType.GROUP_DM: + throw new HTTPError("You can't create a dm channel in a guild"); + case ChannelType.GUILD_STORE: + default: + throw new HTTPError("Not yet supported"); + } + + if (!channel.permission_overwrites) channel.permission_overwrites = []; + // TODO: eagerly auto generate position of all guild channels + + const position = (channel.type === ChannelType.UNHANDLED ? 0 : channel.position) || 0; + + channel = { + ...channel, + ...(!opts?.keepId && { id: Snowflake.generate() }), + created_at: new Date(), + position, + // from #876 (threads): shouldnt these be undefined? + // message_count: 0, + // member_count: 0, + // total_message_sent: 0, + }; + + // TODO: figure out why the generic is required here + const ret = Channel.create<Channel>(channel); + + await Promise.all([ + ret.save(), + !opts?.skipEventEmit + ? emitEvent({ + event: "CHANNEL_CREATE", + data: ret.toJSON(), + guild_id: channel.guild_id, + } satisfies ChannelCreateEvent) + : Promise.resolve(), + Guild.insertChannelInOrder(guild.id, ret.id, position, guild), + ]); + + return ret; + } + threadOnly() { + return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA; + } + + static async createThreadChannel( + channel: Partial<Channel>, + metadata: Partial<ThreadMetadata>, + user_id: string = "0", + opts?: { + keepId?: boolean; + skipExistsCheck?: boolean; + skipParentExistsCheck?: boolean; + skipPermissionCheck?: boolean; + skipEventEmit?: boolean; + skipNameChecks?: boolean; + }, + ): Promise<Channel> { + channel = { + // set the default type to private + type: ChannelType.GUILD_PRIVATE_THREAD, + ...channel, + ...(!opts?.keepId && { id: Snowflake.generate() }), + created_at: new Date(), + position: 0, // TODO: + message_count: 0, + member_count: 1, + total_message_sent: 0, + }; + + const exists = await Channel.findOne({ + where: { + id: channel.id, + }, + }); + + const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } }); + + if (!opts?.skipExistsCheck && !guild.features.includes("ALLOW_EXISTING_THREAD_FOR_MESSAGE") && exists) throw DiscordApiErrors.THREAD_ALREADY_CREATED_FOR_THIS_MESSAGE; + + if (!channel.parent_id) throw new HTTPError("Parent id not set", 400); + const parent = await Channel.findOneOrFail({ where: { id: channel.parent_id } }); + + if (!opts?.skipPermissionCheck) { + // Always check if user has permission first + const permissions = await getPermission(user_id, parent.guild_id); + permissions.hasThrow(channel.type === ChannelType.GUILD_PRIVATE_THREAD ? "CREATE_PRIVATE_THREADS" : "CREATE_PUBLIC_THREADS"); + } + + channel = { + ...channel, + permission_overwrites: parent.permission_overwrites, + nsfw: parent.nsfw, + owner_id: user_id, + guild_id: parent.guild_id, + thread_metadata: { + create_timestamp: new Date().toISOString(), + archive_timestamp: new Date().toISOString(), + archived: false, + auto_archive_duration: 0, + invitable: channel.type === ChannelType.GUILD_NEWS_THREAD || channel.type === ChannelType.GUILD_PUBLIC_THREAD ? Config.get().guild.publicThreadsInvitable : false, + locked: false, + ...metadata, + }, + }; + + if (!opts?.skipParentExistsCheck) { + if (!parent) throw new HTTPError("Parent channel doesn't exist", 400); + if (parent.guild_id !== channel.guild_id) throw new HTTPError("The category channel needs to be in the guild"); + } + + if (!opts?.skipNameChecks) { + const guild = await Guild.findOneOrFail({ where: { id: channel.guild_id } }); + if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) { + for (const character of InvisibleCharacters) if (channel.name.includes(character)) throw new HTTPError("Channel name cannot include invalid characters", 403); + + channel.name = channel.name.trim(); //category names are trimmed client side on discord.com + } + + if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) { + if (!channel.name) throw new HTTPError("Channel name cannot be empty.", 403); + } + } + + // TODO: eagerly auto generate position of all guild channels + + const thread = await OrmUtils.mergeDeep(new Channel(), channel).save(); + + const member = { + id: thread.id, + user_id, + join_timestamp: new Date(), + muted: false, + mute_config: null, + flags: 0, + }; + if (channel.member_count) channel.member_count++; + + const threadMember = await OrmUtils.mergeDeep(new ThreadMember(), member).save(); + + if (!opts?.skipEventEmit) { + await Promise.all([ + emitEvent({ + event: "THREAD_CREATE", + data: { + ...thread, + newly_created: true, + }, + guild_id: channel.guild_id, + } satisfies ThreadCreateEvent), + emitEvent({ + event: "THREAD_MEMBERS_UPDATE", + data: { + guild_id: channel.guild_id!, // TODO: is this the right fix? + id: thread.id, + member_count: channel.member_count ?? 0, //TODO: is this the right fix? + added_members: [threadMember], + removed_member_ids: [], + }, + guild_id: channel.guild_id, + } satisfies ThreadMembersUpdateEvent), + ]); + } + + return thread; + } + + static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) { + recipients = [...new Set(recipients)].filter((x) => x !== creator_user_id); + // TODO: check config for max number of recipients + /** if you want to disallow note to self channels, uncomment the conditional below + + const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) }); + if (otherRecipientsUsers.length !== recipients.length) { + throw new HTTPError("Recipient/s not found"); + } + **/ + + const type = recipients.length > 1 ? ChannelType.GROUP_DM : ChannelType.DM; + + let channel = null; + let needsTx = true; + + const channelRecipients = [...recipients, creator_user_id]; + + const userRecipients = await Recipient.find({ + where: { user_id: creator_user_id }, + relations: { channel: { recipients: true } }, + }); + + for (const ur of userRecipients) { + if (!ur.channel.recipients) continue; + const re = ur.channel.recipients.map((r) => r.user_id); + if (re.length === channelRecipients.length) { + if (channelRecipients.every((_) => re.includes(_))) { + if (channel == null) { + channel = ur.channel; + if (!ur.closed) needsTx = false; + await ur.assign({ closed: false }).save(); + } + } + } + } + + if (channel == null) { + name = trimSpecial(name); + + channel = await Channel.create({ + name, + type, + owner_id: type === ChannelType.GROUP_DM ? creator_user_id : undefined, + created_at: new Date(), + last_message_id: undefined, + recipients: channelRecipients.map((x) => + Recipient.create({ + user_id: x, + closed: !(type === ChannelType.GROUP_DM || x === creator_user_id), + }), + ), + nsfw: false, + }).save(); + } + + const channel_dto = await DmChannelDTO.from(channel); + + if (!needsTx) { + /*ignored*/ + } else if (type === ChannelType.GROUP_DM && channel.recipients) { + for (const recipient of channel.recipients) { + await emitEvent({ + event: "CHANNEL_CREATE", + data: channel_dto.excludedRecipients([recipient.user_id]), + user_id: recipient.user_id, + }); + } + } else { + await emitEvent({ + event: "CHANNEL_CREATE", + data: channel_dto, + user_id: creator_user_id, + }); + } + + if (recipients.length === 1) return channel_dto; + else return channel_dto.excludedRecipients([creator_user_id]); + } + + static async removeRecipientFromChannel(channel: Channel, user_id: string) { + await Recipient.delete({ channel_id: channel.id, user_id: user_id }); + channel.recipients = channel.recipients?.filter((r) => r.user_id !== user_id); + + if (channel.recipients?.length === 0) { + await Channel.deleteChannel(channel); + await emitEvent({ + event: "CHANNEL_DELETE", + data: await DmChannelDTO.from(channel, [user_id]), + user_id: user_id, + }); + return; + } + + await emitEvent({ + event: "CHANNEL_DELETE", + data: await DmChannelDTO.from(channel, [user_id]), + user_id: user_id, + }); + + //If the owner leave the server user is the new owner + if (channel.owner_id === user_id) { + channel.owner_id = "1"; // The channel is now owned by the server user + await emitEvent({ + event: "CHANNEL_UPDATE", + data: await DmChannelDTO.from(channel, [user_id]), + channel_id: channel.id, + }); + } + + await channel.save(); + + await emitEvent({ + event: "CHANNEL_RECIPIENT_REMOVE", + data: { + channel_id: channel.id, + user: await User.findOneOrFail({ + where: { id: user_id }, + select: PublicUserProjection, + }), + }, + channel_id: channel.id, + } satisfies ChannelRecipientRemoveEvent); + } + + static async deleteChannel(channel: Channel) { + // TODO Delete attachments from the CDN for messages in the channel + await Channel.delete({ id: channel.id }); + + if (channel.guild_id) { + const guild = await Guild.findOneOrFail({ + where: { id: channel.guild_id }, + select: { channel_ordering: true }, + }); + + const updatedOrdering = guild.channel_ordering.filter((id) => id != channel.id); + await Guild.update({ id: channel.guild_id }, { channel_ordering: updatedOrdering }); + } + } + + static async calculatePosition(channel_id: string, guild_id: string, guild?: Guild) { + if (!guild) + guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: { channel_ordering: true }, + }); + + return guild.channel_ordering.findIndex((id) => channel_id == id); + } + + static async getOrderedChannels(guild_id: string, guild?: Guild) { + if (!guild) + guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: { channel_ordering: true }, + }); + + const channels = await Promise.all(guild.channel_ordering.map((id) => Channel.findOne({ where: { id } }))); + + return channels + .filter((channel) => channel !== null) + .reduce((r, v) => { + v = v as Channel; + + v.position = (guild as Guild).channel_ordering.indexOf(v.id); + r[v.position] = v; + return r; + }, [] as Array<Channel>); + } + + isDm() { + return this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM; + } + + isThread() { + return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD || this.type === ChannelType.GUILD_PRIVATE_THREAD; + } + isForum() { + return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA; + } + + isPrivateThread() { + return this.type === ChannelType.GUILD_PRIVATE_THREAD; + } + + isPublicThread() { + return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD; + } + + // Does the channel support sending messages ( eg categories do not ) + isWritable() { + const disallowedChannelTypes = [ChannelType.GUILD_CATEGORY, ChannelType.GUILD_STAGE_VOICE]; + return disallowedChannelTypes.indexOf(this.type) == -1; + } + + async getUserPermissions(opts: { user_id?: string; user?: User; member?: Member; guild?: Guild }): Promise<Permissions> { + if (this.isDm()) return this.owner_id == (opts.user_id ?? opts.user?.id) ? Permissions.ALL : Permissions.DEFAULT_DM_PERMISSIONS; + let guild = opts.guild; + if (!guild) { + if (this.guild) guild = this.guild; + else if (this.guild_id) guild = await Guild.findOneOrFail({ where: { id: this.guild_id } }); + else { + console.error("Channel.getUserPermissions: called without guild for non-DM channel."); + return Permissions.NONE; + } + } + + // check if we can resolve here to short-circuit possibly calling the database unnecessarily + // TODO: do we want to have an instance-wide opt out of this behavior? It would just be an extra if statement here + const ownerId = guild?.owner?.id ?? guild?.owner_id; + if (!!opts.user_id && ownerId === opts.user_id) return Permissions.ALL; + if (!!opts.user?.id && ownerId === opts.user?.id) return Permissions.ALL; + if (!!opts.member?.id && ownerId === opts.member?.id) return Permissions.ALL; + + let member = opts.member; + if (!member) { + if (opts.user) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user.id }, relations: { roles: true } }); + else if (opts.user_id) member = await Member.findOneOrFail({ where: { guild_id: guild.id, id: opts.user_id }, relations: { roles: true } }); + else { + console.error("Channel.getUserPermissions: called without user or member for non-DM channel."); + return Permissions.NONE; + } + } + + const roles = ( + member.roles || + ( + await Member.findOneOrFail({ + where: { guild_id: guild.id, index: member.index }, + relations: { roles: true }, + select: { + roles: { + id: true, + permissions: true, + position: true, + }, + }, + loadEagerRelations: false, + }) + ).roles + ).sort((a, b) => a.position - b.position); // ascending by position + + return Permissions.finalPermission({ + user: { + ...member, + roles: roles.map((r) => r.id), + flags: member.user?.flags ?? (await User.findOneOrFail({ where: { id: member.id }, select: { flags: true } })).flags, + }, + guild: { id: guild.id, owner_id: guild.owner_id!, roles }, // We don't care about including *all* guild roles, as not all of them are relevant... + channel: this, + }); + } + + // TODO: should we throw for missing args? + async canViewChannel(opts: { user_id?: string; user?: User; member?: Member; guild?: Guild }): Promise<boolean> { + if (this.isDm()) return await this.canViewDmChannel(opts.user_id, opts.user); + + const userPerms = await this.getUserPermissions(opts); + return userPerms.has("VIEW_CHANNEL"); + } + + private async canViewDmChannel(user_id?: string, user?: User): Promise<boolean> { + const userId = user_id ?? user?.id; + if (!userId) { + console.error("Channel.canViewChannel: called without user for DM channel."); + return false; + } + if (!user) return false; + if (this.recipients) return this.recipients.some((r) => r.user_id === user.id && !r.closed); + else { + // we dont have recipients on hand + const recipient = await Recipient.findOne({ where: { channel_id: this.id, user_id: user.id } }); + return recipient == null ? false : !recipient.closed; + } + } + + toJSON(): PublicChannel { + return { + ...this, + last_pin_timestamp: this.last_pin_timestamp?.toISOString(), + guild_id: this.guild_id ?? undefined, + recipients: undefined, //this.recipients?.map(x=>x.user.toPublicUser()), // TODO: fix me + owner: undefined, // TODO: fix me - this is thread owner + + // these fields are not returned depending on the type of channel + bitrate: this.bitrate || undefined, + user_limit: this.user_limit || undefined, + rate_limit_per_user: this.rate_limit_per_user || undefined, + owner_id: this.owner_id || undefined, + ...(this.isThread() && this.thread_members ? { member_ids_preview: this.thread_members.map((_) => _.member.id) } : {}), + default_auto_archive_duration: this.default_auto_archive_duration ?? undefined, + retention_policy_id: undefined, + thread_metadata: this.thread_metadata + ? { + ...this.thread_metadata, + archive_timestamp: new Date(this.thread_metadata.archive_timestamp).toISOString().replace("Z", "+00:00"), + create_timestamp: new Date(this.thread_metadata.create_timestamp).toISOString().replace("Z", "+00:00"), + } + : undefined, + member_count: this.member_count ?? undefined, + message_count: this.message_count ?? undefined, + total_message_sent: this.total_message_sent ?? undefined, + applied_tags: this.applied_tags ?? undefined, + permission_overwrites: this.isThread() ? undefined : this.permission_overwrites, + }; + } +}