Merge branch 'fosscord:master' into master

This commit is contained in:
Featyre 2022-01-24 05:25:22 +00:00 committed by GitHub
commit edd5cf651c
4 changed files with 420 additions and 334 deletions

View File

@ -37,7 +37,11 @@ export function isTextChannel(type: ChannelType): boolean {
case ChannelType.GUILD_PUBLIC_THREAD: case ChannelType.GUILD_PUBLIC_THREAD:
case ChannelType.GUILD_PRIVATE_THREAD: case ChannelType.GUILD_PRIVATE_THREAD:
case ChannelType.GUILD_TEXT: case ChannelType.GUILD_TEXT:
case ChannelType.ENCRYPTED:
case ChannelType.ENCRYPTED_THREAD:
return true; return true;
default:
throw new HTTPError("unimplemented", 400);
} }
} }
@ -87,7 +91,7 @@ router.get("/", async (req: Request, res: Response) => {
permissions.hasThrow("VIEW_CHANNEL"); permissions.hasThrow("VIEW_CHANNEL");
if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]); if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]);
var query: FindManyOptions<Message> & { where: { id?: any } } = { var query: FindManyOptions<Message> & { where: { id?: any; }; } = {
order: { id: "DESC" }, order: { id: "DESC" },
take: limit, take: limit,
where: { channel_id }, where: { channel_id },
@ -216,7 +220,7 @@ router.post(
channel.save() channel.save()
]); ]);
postHandleMessage(message).catch((e) => {}); // no await as it shouldnt block the message send function and silently catch error postHandleMessage(message).catch((e) => { }); // no await as it shouldnt block the message send function and silently catch error
return res.json(message); return res.json(message);
} }

View File

@ -1,332 +1,357 @@
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm"; import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
import { BaseClass } from "./BaseClass"; import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild"; import { Guild } from "./Guild";
import { PublicUserProjection, User } from "./User"; import { PublicUserProjection, User } from "./User";
import { HTTPError } from "lambert-server"; import { HTTPError } from "lambert-server";
import { containsAll, emitEvent, getPermission, Snowflake, trimSpecial } from "../util"; import { containsAll, emitEvent, getPermission, Snowflake, trimSpecial, InvisibleCharacters } from "../util";
import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces"; import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces";
import { Recipient } from "./Recipient"; import { Recipient } from "./Recipient";
import { Message } from "./Message"; import { Message } from "./Message";
import { ReadState } from "./ReadState"; import { ReadState } from "./ReadState";
import { Invite } from "./Invite"; import { Invite } from "./Invite";
import { VoiceState } from "./VoiceState"; import { VoiceState } from "./VoiceState";
import { Webhook } from "./Webhook"; import { Webhook } from "./Webhook";
import { DmChannelDTO } from "../dtos"; import { DmChannelDTO } from "../dtos";
export enum ChannelType { export enum ChannelType {
GUILD_TEXT = 0, // a text channel within a server GUILD_TEXT = 0, // a text channel within a server
DM = 1, // a direct message between users DM = 1, // a direct message between users
GUILD_VOICE = 2, // a voice channel within a server GUILD_VOICE = 2, // a voice channel within a server
GROUP_DM = 3, // a direct message between multiple users GROUP_DM = 3, // a direct message between multiple users
GUILD_CATEGORY = 4, // an organizational category that contains up to 50 channels GUILD_CATEGORY = 4, // an organizational category that contains up to 50 channels
GUILD_NEWS = 5, // a channel that users can follow and crosspost into their own server GUILD_NEWS = 5, // a channel that users can follow and crosspost into their own server
GUILD_STORE = 6, // a channel in which game developers can sell their game on Discord GUILD_STORE = 6, // a channel in which game developers can sell their game on Discord
// TODO: what are channel types between 7-9? ENCRYPTED = 7, // end-to-end encrypted channel
GUILD_NEWS_THREAD = 10, // a temporary sub-channel within a GUILD_NEWS channel ENCRYPTED_THREAD = 8, // end-to-end encrypted thread channel
GUILD_PUBLIC_THREAD = 11, // a temporary sub-channel within a GUILD_TEXT channel GUILD_NEWS_THREAD = 10, // a temporary sub-channel within a GUILD_NEWS channel
GUILD_PRIVATE_THREAD = 12, // a temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission GUILD_PUBLIC_THREAD = 11, // a temporary sub-channel within a GUILD_TEXT channel
GUILD_STAGE_VOICE = 13, // a voice channel for hosting events with an audience GUILD_PRIVATE_THREAD = 12, // a temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission
} GUILD_STAGE_VOICE = 13, // a voice channel for hosting events with an audience
CUSTOM_START = 64, // start custom channel types from here
@Entity("channels") UNHANDLED = 255 // unhandled unowned pass-through channel type
export class Channel extends BaseClass { }
@Column()
created_at: Date; @Entity("channels")
export class Channel extends BaseClass {
@Column({ nullable: true }) @Column()
name?: string; created_at: Date;
@Column({ type: "text", nullable: true }) @Column({ nullable: true })
icon?: string | null; name?: string;
@Column({ type: "int" }) @Column({ type: "text", nullable: true })
type: ChannelType; icon?: string | null;
@OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, { @Column({ type: "int" })
cascade: true, type: ChannelType;
orphanedRowAction: "delete",
}) @OneToMany(() => Recipient, (recipient: Recipient) => recipient.channel, {
recipients?: Recipient[]; cascade: true,
orphanedRowAction: "delete",
@Column({ nullable: true }) })
last_message_id: string; recipients?: Recipient[];
@Column({ nullable: true }) @Column({ nullable: true })
@RelationId((channel: Channel) => channel.guild) last_message_id: string;
guild_id?: string;
@Column({ nullable: true })
@JoinColumn({ name: "guild_id" }) @RelationId((channel: Channel) => channel.guild)
@ManyToOne(() => Guild, { guild_id?: string;
onDelete: "CASCADE",
}) @JoinColumn({ name: "guild_id" })
guild: Guild; @ManyToOne(() => Guild, {
onDelete: "CASCADE",
@Column({ nullable: true }) })
@RelationId((channel: Channel) => channel.parent) guild: Guild;
parent_id: string;
@Column({ nullable: true })
@JoinColumn({ name: "parent_id" }) @RelationId((channel: Channel) => channel.parent)
@ManyToOne(() => Channel) parent_id: string;
parent?: Channel;
@JoinColumn({ name: "parent_id" })
// only for group dms @ManyToOne(() => Channel)
@Column({ nullable: true }) parent?: Channel;
@RelationId((channel: Channel) => channel.owner)
owner_id: string; // only for group dms
@Column({ nullable: true })
@JoinColumn({ name: "owner_id" }) @RelationId((channel: Channel) => channel.owner)
@ManyToOne(() => User) owner_id: string;
owner: User;
@JoinColumn({ name: "owner_id" })
@Column({ nullable: true }) @ManyToOne(() => User)
last_pin_timestamp?: number; owner: User;
@Column({ nullable: true }) @Column({ nullable: true })
default_auto_archive_duration?: number; last_pin_timestamp?: number;
@Column({ nullable: true }) @Column({ nullable: true })
position?: number; default_auto_archive_duration?: number;
@Column({ type: "simple-json", nullable: true }) @Column({ nullable: true })
permission_overwrites?: ChannelPermissionOverwrite[]; position?: number;
@Column({ nullable: true }) @Column({ type: "simple-json", nullable: true })
video_quality_mode?: number; permission_overwrites?: ChannelPermissionOverwrite[];
@Column({ nullable: true }) @Column({ nullable: true })
bitrate?: number; video_quality_mode?: number;
@Column({ nullable: true }) @Column({ nullable: true })
user_limit?: number; bitrate?: number;
@Column({ nullable: true }) @Column({ nullable: true })
nsfw?: boolean; user_limit?: number;
@Column({ nullable: true }) @Column({ nullable: true })
rate_limit_per_user?: number; nsfw?: boolean;
@Column({ nullable: true }) @Column({ nullable: true })
topic?: string; rate_limit_per_user?: number;
@OneToMany(() => Invite, (invite: Invite) => invite.channel, { @Column({ nullable: true })
cascade: true, topic?: string;
orphanedRowAction: "delete",
}) @OneToMany(() => Invite, (invite: Invite) => invite.channel, {
invites?: Invite[]; cascade: true,
orphanedRowAction: "delete",
@OneToMany(() => Message, (message: Message) => message.channel, { })
cascade: true, invites?: Invite[];
orphanedRowAction: "delete",
}) @OneToMany(() => Message, (message: Message) => message.channel, {
messages?: Message[]; cascade: true,
orphanedRowAction: "delete",
@OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, { })
cascade: true, messages?: Message[];
orphanedRowAction: "delete",
}) @OneToMany(() => VoiceState, (voice_state: VoiceState) => voice_state.channel, {
voice_states?: VoiceState[]; cascade: true,
orphanedRowAction: "delete",
@OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, { })
cascade: true, voice_states?: VoiceState[];
orphanedRowAction: "delete",
}) @OneToMany(() => ReadState, (read_state: ReadState) => read_state.channel, {
read_states?: ReadState[]; cascade: true,
orphanedRowAction: "delete",
@OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, { })
cascade: true, read_states?: ReadState[];
orphanedRowAction: "delete",
}) @OneToMany(() => Webhook, (webhook: Webhook) => webhook.channel, {
webhooks?: Webhook[]; cascade: true,
orphanedRowAction: "delete",
// TODO: DM channel })
static async createChannel( webhooks?: Webhook[];
channel: Partial<Channel>,
user_id: string = "0", // TODO: DM channel
opts?: { static async createChannel(
keepId?: boolean; channel: Partial<Channel>,
skipExistsCheck?: boolean; user_id: string = "0",
skipPermissionCheck?: boolean; opts?: {
skipEventEmit?: boolean; keepId?: boolean;
} skipExistsCheck?: boolean;
) { skipPermissionCheck?: boolean;
if (!opts?.skipPermissionCheck) { skipEventEmit?: boolean;
// Always check if user has permission first skipNameChecks?: boolean;
const permissions = await getPermission(user_id, channel.guild_id); }
permissions.hasThrow("MANAGE_CHANNELS"); ) {
} if (!opts?.skipPermissionCheck) {
// Always check if user has permission first
switch (channel.type) { const permissions = await getPermission(user_id, channel.guild_id);
case ChannelType.GUILD_TEXT: permissions.hasThrow("MANAGE_CHANNELS");
case ChannelType.GUILD_VOICE: }
if (channel.parent_id && !opts?.skipExistsCheck) {
const exists = await Channel.findOneOrFail({ id: channel.parent_id }); if (!opts?.skipNameChecks) {
if (!exists) throw new HTTPError("Parent id channel doesn't exist", 400); const guild = await Guild.findOneOrFail({ id: channel.guild_id });
if (exists.guild_id !== channel.guild_id) if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) {
throw new HTTPError("The category channel needs to be in the guild"); for (var character of InvisibleCharacters)
} if (channel.name.includes(character))
break; throw new HTTPError("Channel name cannot include invalid characters", 403);
case ChannelType.GUILD_CATEGORY:
break; if (channel.name.match(/\-\-+/g))
case ChannelType.DM: throw new HTTPError("Channel name cannot include multiple adjacent dashes.", 403)
case ChannelType.GROUP_DM:
throw new HTTPError("You can't create a dm channel in a guild"); if (channel.name.charAt(0) === "-" ||
// TODO: check if guild is community server channel.name.charAt(channel.name.length - 1) === "-")
case ChannelType.GUILD_STORE: throw new HTTPError("Channel name cannot start/end with dash.", 403)
case ChannelType.GUILD_NEWS: }
default:
throw new HTTPError("Not yet supported"); if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) {
} if (!channel.name)
throw new HTTPError("Channel name cannot be empty.", 403);
if (!channel.permission_overwrites) channel.permission_overwrites = []; }
// TODO: auto generate position }
channel = { switch (channel.type) {
...channel, case ChannelType.GUILD_TEXT:
...(!opts?.keepId && { id: Snowflake.generate() }), case ChannelType.GUILD_VOICE:
created_at: new Date(), if (channel.parent_id && !opts?.skipExistsCheck) {
position: channel.position || 0, const exists = await Channel.findOneOrFail({ id: channel.parent_id });
}; if (!exists) throw new HTTPError("Parent id channel doesn't exist", 400);
if (exists.guild_id !== channel.guild_id)
await Promise.all([ throw new HTTPError("The category channel needs to be in the guild");
new Channel(channel).save(), }
!opts?.skipEventEmit break;
? emitEvent({ case ChannelType.GUILD_CATEGORY:
event: "CHANNEL_CREATE", break;
data: channel, case ChannelType.DM:
guild_id: channel.guild_id, case ChannelType.GROUP_DM:
} as ChannelCreateEvent) throw new HTTPError("You can't create a dm channel in a guild");
: Promise.resolve(), // TODO: check if guild is community server
]); case ChannelType.GUILD_STORE:
case ChannelType.GUILD_NEWS:
return channel; default:
} throw new HTTPError("Not yet supported");
}
static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) {
recipients = recipients.unique().filter((x) => x !== creator_user_id); if (!channel.permission_overwrites) channel.permission_overwrites = [];
const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) }); // TODO: auto generate position
// TODO: check config for max number of recipients channel = {
if (otherRecipientsUsers.length !== recipients.length) { ...channel,
throw new HTTPError("Recipient/s not found"); ...(!opts?.keepId && { id: Snowflake.generate() }),
} created_at: new Date(),
position: channel.position || 0,
const type = recipients.length === 1 ? ChannelType.DM : ChannelType.GROUP_DM; };
let channel = null; await Promise.all([
new Channel(channel).save(),
const channelRecipients = [...recipients, creator_user_id]; !opts?.skipEventEmit
? emitEvent({
const userRecipients = await Recipient.find({ event: "CHANNEL_CREATE",
where: { user_id: creator_user_id }, data: channel,
relations: ["channel", "channel.recipients"], guild_id: channel.guild_id,
}); } as ChannelCreateEvent)
: Promise.resolve(),
for (let ur of userRecipients) { ]);
let re = ur.channel.recipients!.map((r) => r.user_id);
if (re.length === channelRecipients.length) { return channel;
if (containsAll(re, channelRecipients)) { }
if (channel == null) {
channel = ur.channel; static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) {
await ur.assign({ closed: false }).save(); recipients = recipients.unique().filter((x) => x !== creator_user_id);
} const otherRecipientsUsers = await User.find({ where: recipients.map((x) => ({ id: x })) });
}
} // TODO: check config for max number of recipients
} if (otherRecipientsUsers.length !== recipients.length) {
throw new HTTPError("Recipient/s not found");
if (channel == null) { }
name = trimSpecial(name);
const type = recipients.length === 1 ? ChannelType.DM : ChannelType.GROUP_DM;
channel = await new Channel({
name, let channel = null;
type,
owner_id: type === ChannelType.DM ? undefined : creator_user_id, const channelRecipients = [...recipients, creator_user_id];
created_at: new Date(),
last_message_id: null, const userRecipients = await Recipient.find({
recipients: channelRecipients.map( where: { user_id: creator_user_id },
(x) => relations: ["channel", "channel.recipients"],
new Recipient({ user_id: x, closed: !(type === ChannelType.GROUP_DM || x === creator_user_id) }) });
),
}).save(); for (let ur of userRecipients) {
} let re = ur.channel.recipients!.map((r) => r.user_id);
if (re.length === channelRecipients.length) {
const channel_dto = await DmChannelDTO.from(channel); if (containsAll(re, channelRecipients)) {
if (channel == null) {
if (type === ChannelType.GROUP_DM) { channel = ur.channel;
for (let recipient of channel.recipients!) { await ur.assign({ closed: false }).save();
await emitEvent({ }
event: "CHANNEL_CREATE", }
data: channel_dto.excludedRecipients([recipient.user_id]), }
user_id: recipient.user_id, }
});
} if (channel == null) {
} else { name = trimSpecial(name);
await emitEvent({ event: "CHANNEL_CREATE", data: channel_dto, user_id: creator_user_id });
} channel = await new Channel({
name,
return channel_dto.excludedRecipients([creator_user_id]); type,
} owner_id: type === ChannelType.DM ? undefined : null, // 1:1 DMs are ownerless in fosscord-server
created_at: new Date(),
static async removeRecipientFromChannel(channel: Channel, user_id: string) { last_message_id: null,
await Recipient.delete({ channel_id: channel.id, user_id: user_id }); recipients: channelRecipients.map(
channel.recipients = channel.recipients?.filter((r) => r.user_id !== user_id); (x) =>
new Recipient({ user_id: x, closed: !(type === ChannelType.GROUP_DM || x === creator_user_id) })
if (channel.recipients?.length === 0) { ),
await Channel.deleteChannel(channel); }).save();
await emitEvent({ }
event: "CHANNEL_DELETE",
data: await DmChannelDTO.from(channel, [user_id]), const channel_dto = await DmChannelDTO.from(channel);
user_id: user_id,
}); if (type === ChannelType.GROUP_DM) {
return; for (let recipient of channel.recipients!) {
} await emitEvent({
event: "CHANNEL_CREATE",
await emitEvent({ data: channel_dto.excludedRecipients([recipient.user_id]),
event: "CHANNEL_DELETE", user_id: recipient.user_id,
data: await DmChannelDTO.from(channel, [user_id]), });
user_id: user_id, }
}); } else {
await emitEvent({ event: "CHANNEL_CREATE", data: channel_dto, user_id: creator_user_id });
//If the owner leave we make the first recipient in the list the new owner }
if (channel.owner_id === user_id) {
channel.owner_id = channel.recipients!.find((r) => r.user_id !== user_id)!.user_id; //Is there a criteria to choose the new owner? return channel_dto.excludedRecipients([creator_user_id]);
await emitEvent({ }
event: "CHANNEL_UPDATE",
data: await DmChannelDTO.from(channel, [user_id]), static async removeRecipientFromChannel(channel: Channel, user_id: string) {
channel_id: channel.id, 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.save(); await Channel.deleteChannel(channel);
await emitEvent({
await emitEvent({ event: "CHANNEL_DELETE",
event: "CHANNEL_RECIPIENT_REMOVE", data: await DmChannelDTO.from(channel, [user_id]),
data: { user_id: user_id,
channel_id: channel.id, });
user: await User.findOneOrFail({ where: { id: user_id }, select: PublicUserProjection }), return;
}, }
channel_id: channel.id,
} as ChannelRecipientRemoveEvent); await emitEvent({
} event: "CHANNEL_DELETE",
data: await DmChannelDTO.from(channel, [user_id]),
static async deleteChannel(channel: Channel) { user_id: user_id,
await Message.delete({ channel_id: channel.id }); //TODO we should also delete the attachments from the cdn but to do that we need to move cdn.ts in util });
//TODO before deleting the channel we should check and delete other relations
await Channel.delete({ id: channel.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
isDm() { await emitEvent({
return this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM; event: "CHANNEL_UPDATE",
} data: await DmChannelDTO.from(channel, [user_id]),
} channel_id: channel.id,
});
export interface ChannelPermissionOverwrite { }
allow: string;
deny: string; await channel.save();
id: string;
type: ChannelPermissionOverwriteType; await emitEvent({
} event: "CHANNEL_RECIPIENT_REMOVE",
data: {
export enum ChannelPermissionOverwriteType { channel_id: channel.id,
role = 0, user: await User.findOneOrFail({ where: { id: user_id }, select: PublicUserProjection }),
member = 1, },
} channel_id: channel.id,
} as ChannelRecipientRemoveEvent);
}
static async deleteChannel(channel: Channel) {
await Message.delete({ channel_id: channel.id }); //TODO we should also delete the attachments from the cdn but to do that we need to move cdn.ts in util
//TODO before deleting the channel we should check and delete other relations
await Channel.delete({ id: channel.id });
}
isDm() {
return this.type === ChannelType.DM || this.type === ChannelType.GROUP_DM;
}
}
export interface ChannelPermissionOverwrite {
allow: string;
deny: string;
id: string;
type: ChannelPermissionOverwriteType;
}
export enum ChannelPermissionOverwriteType {
role = 0,
member = 1,
}

View File

@ -0,0 +1,56 @@
// List from https://invisible-characters.com/
export const InvisibleCharacters = [
'\u{9}', //Tab
'\u{20}', //Space
'\u{ad}', //Soft hyphen
'\u{34f}', //Combining grapheme joiner
'\u{61c}', //Arabic letter mark
'\u{115f}', //Hangul choseong filler
'\u{1160}', //Hangul jungseong filler
'\u{17b4}', //Khmer vowel inherent AQ
'\u{17b5}', //Khmer vowel inherent AA
'\u{180e}', //Mongolian vowel separator
'\u{2000}', //En quad
'\u{2001}', //Em quad
'\u{2002}', //En space
'\u{2003}', //Em space
'\u{2004}', //Three-per-em space
'\u{2005}', //Four-per-em space
'\u{2006}', //Six-per-em space
'\u{2007}', //Figure space
'\u{2008}', //Punctuation space
'\u{2009}', //Thin space
'\u{200a}', //Hair space
'\u{200b}', //Zero width space
'\u{200c}', //Zero width non-joiner
'\u{200d}', //Zero width joiner
'\u{200e}', //Left-to-right mark
'\u{200f}', //Right-to-left mark
'\u{202f}', //Narrow no-break space
'\u{205f}', //Medium mathematical space
'\u{2060}', //Word joiner
'\u{2061}', //Function application
'\u{2062}', //Invisible times
'\u{2063}', //Invisible separator
'\u{2064}', //Invisible plus
'\u{206a}', //Inhibit symmetric swapping
'\u{206b}', //Activate symmetric swapping
'\u{206c}', //Inhibit arabic form shaping
'\u{206d}', //Activate arabic form shaping
'\u{206e}', //National digit shapes
'\u{206f}', //Nominal digit shapes
'\u{3000}', //Ideographic space
'\u{2800}', //Braille pattern blank
'\u{3164}', //Hangul filler
'\u{feff}', //Zero width no-break space
'\u{ffa0}', //Haldwidth hangul filler
'\u{1d159}', //Musical symbol null notehead
'\u{1d173}', //Musical symbol begin beam
'\u{1d174}', //Musical symbol end beam
'\u{1d175}', //Musical symbol begin tie
'\u{1d176}', //Musical symbol end tie
'\u{1d177}', //Musical symbol begin slur
'\u{1d178}', //Musical symbol end slur
'\u{1d179}', //Musical symbol begin phrase
'\u{1d17a}' //Musical symbol end phrase
];

View File

@ -18,3 +18,4 @@ export * from "./Snowflake";
export * from "./String"; export * from "./String";
export * from "./Array"; export * from "./Array";
export * from "./TraverseDirectory"; export * from "./TraverseDirectory";
export * from "./InvisibleCharacters";