diff --git a/.github/relase_body_template.md b/.github/relase_body_template.md index 34b468804..994e83d35 100644 --- a/.github/relase_body_template.md +++ b/.github/relase_body_template.md @@ -1,9 +1,10 @@ ## Notes ## Additions +- ## Fixes - +- ## Download - [Windows]() - [MacOS]() diff --git a/README.md b/README.md index cc831e627..1bcea1e32 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

Fosscord Server

diff --git a/api/assets/fosscord-login.css b/api/assets/fosscord-login.css index bc32b82ea..34cf542bf 100644 --- a/api/assets/fosscord-login.css +++ b/api/assets/fosscord-login.css @@ -22,10 +22,10 @@ h3.title-jXR8lp.marginBottom8-AtZOdT.base-1x0h_U.size24-RIRrxO::after { /* Logo in top left when bg removed */ #app-mount > div.app-1q1i1E > div > a { /* replace me: original dimensions: 130x36 */ - background: url(https://raw.githubusercontent.com/fosscord/fosscord/9900329e5ef2c17bdeb6893e04c0511f72027f97/assets/logo/temp.svg); + background: url(https://raw.githubusercontent.com/fosscord/fosscord/master/assets-rebrand/svg/Fosscord-Wordmark-Gradient.svg); + width: 130px; + height: 23px; background-size: contain; - width: 128px; - height: 128px; border-radius: 50%; } diff --git a/api/assets/fosscord.css b/api/assets/fosscord.css index 6a8b4c64e..6078fdeb4 100644 --- a/api/assets/fosscord.css +++ b/api/assets/fosscord.css @@ -13,10 +13,14 @@ /* home button icon */ #app-mount > div.app-1q1i1E > div > div.layers-3iHuyZ.layers-3q14ss > div > div > nav > ul > div.scroller-1Bvpku.none-2Eo-qx.scrollerBase-289Jih > div.tutorialContainer-2sGCg9 > div > div.listItemWrapper-KhRmzM > div > svg > foreignObject > div > div { - background-image: url(https://raw.githubusercontent.com/fosscord/fosscord/9900329e5ef2c17bdeb6893e04c0511f72027f97/assets/logo/temp.svg); + background-image: url(https://raw.githubusercontent.com/fosscord/fosscord/master/assets-rebrand/svg/Fosscord-Icon-Rounded-Subtract.svg); background-size: contain; border-radius: 50%; } + +#app-mount > div.app-1q1i1E > div > div.layers-3iHuyZ.layers-3q14ss > div > div > nav > ul > div.scroller-1Bvpku.none-2Eo-qx.scrollerBase-289Jih > div.tutorialContainer-2sGCg9 > div > div.listItemWrapper-KhRmzM > div > svg > foreignObject > div > div, #app-mount > div.app-1q1i1E > div > div.layers-3iHuyZ.layers-3q14ss > div > div > nav > ul > div.scroller-1Bvpku.none-2Eo-qx.scrollerBase-289Jih > div.tutorialContainer-2sGCg9 > div > div.listItemWrapper-KhRmzM > div > svg > foreignObject > div > div:hover { + background-color: white; +} /* Login QR */ #app-mount > div.app-1q1i1E > div > div > div > div > form > div > div > div.transitionGroup-aR7y1d.qrLogin-1AOZMt, #app-mount > div.app-1q1i1E > div > div > div > div > form > div > div > div.verticalSeparator-3huAjp, diff --git a/api/package-lock.json b/api/package-lock.json index aa0c07c5a..28af2e8b1 100644 Binary files a/api/package-lock.json and b/api/package-lock.json differ diff --git a/api/package.json b/api/package.json index 303d58716..f4614c90b 100644 --- a/api/package.json +++ b/api/package.json @@ -30,7 +30,7 @@ "discord-open-source" ], "author": "Fosscord", - "license": "ISC", + "license": "GPLV3", "bugs": { "url": "https://github.com/fosscord/fosscord-server/issues" }, @@ -86,7 +86,7 @@ "missing-native-js-functions": "^1.2.18", "morgan": "^1.10.0", "multer": "^1.4.2", - "node-fetch": "^2.6.1", + "node-fetch": "^3.1.1", "patch-package": "^6.4.7", "picocolors": "^1.0.0", "proxy-agent": "^5.0.0", diff --git a/api/scripts/stresstest/package-lock.json b/api/scripts/stresstest/package-lock.json index ca84a8cf4..81c9b817a 100644 Binary files a/api/scripts/stresstest/package-lock.json and b/api/scripts/stresstest/package-lock.json differ diff --git a/api/src/routes/channels/#channel_id/messages/index.ts b/api/src/routes/channels/#channel_id/messages/index.ts index b2fb615c3..1ae9d6761 100644 --- a/api/src/routes/channels/#channel_id/messages/index.ts +++ b/api/src/routes/channels/#channel_id/messages/index.ts @@ -37,7 +37,11 @@ export function isTextChannel(type: ChannelType): boolean { case ChannelType.GUILD_PUBLIC_THREAD: case ChannelType.GUILD_PRIVATE_THREAD: case ChannelType.GUILD_TEXT: + case ChannelType.ENCRYPTED: + case ChannelType.ENCRYPTED_THREAD: return true; + default: + throw new HTTPError("unimplemented", 400); } } @@ -87,7 +91,7 @@ router.get("/", async (req: Request, res: Response) => { permissions.hasThrow("VIEW_CHANNEL"); if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]); - var query: FindManyOptions & { where: { id?: any } } = { + var query: FindManyOptions & { where: { id?: any; }; } = { order: { id: "DESC" }, take: limit, where: { channel_id }, @@ -172,7 +176,7 @@ router.post( } const channel = await Channel.findOneOrFail({ where: { id: channel_id }, relations: ["recipients", "recipients.user"] }); - const embeds = []; + const embeds = body.embeds || []; if (body.embed) embeds.push(body.embed); let message = await handleMessage({ ...body, @@ -216,7 +220,7 @@ router.post( 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); } diff --git a/api/src/routes/guilds/#guild_id/roles.ts b/api/src/routes/guilds/#guild_id/roles.ts index b1875598b..b6894e3f2 100644 --- a/api/src/routes/guilds/#guild_id/roles.ts +++ b/api/src/routes/guilds/#guild_id/roles.ts @@ -8,7 +8,8 @@ import { GuildRoleDeleteEvent, emitEvent, Config, - DiscordApiErrors + DiscordApiErrors, + handleFile } from "@fosscord/util"; import { HTTPError } from "lambert-server"; import { route } from "@fosscord/api"; @@ -22,6 +23,8 @@ export interface RoleModifySchema { hoist?: boolean; // whether the role should be displayed separately in the sidebar mentionable?: boolean; // whether the role should be mentionable position?: number; + icon?: string; + unicode_emoji?: string; } export type RolePositionUpdateSchema = { @@ -58,7 +61,9 @@ router.post("/", route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }) guild_id: guild_id, managed: false, permissions: String(req.permission!.bitfield & BigInt(body.permissions || "0")), - tags: undefined + tags: undefined, + icon: null, + unicode_emoji: null }); await Promise.all([ @@ -105,6 +110,8 @@ router.patch("/:role_id", route({ body: "RoleModifySchema", permission: "MANAGE_ const { role_id, guild_id } = req.params; const body = req.body as RoleModifySchema; + if (body.icon) body.icon = await handleFile(`/role-icons/${role_id}`, body.icon as string); + const role = new Role({ ...body, id: role_id, diff --git a/api/src/routes/invites/index.ts b/api/src/routes/invites/index.ts index a2cf4cb5e..37e9e05a3 100644 --- a/api/src/routes/invites/index.ts +++ b/api/src/routes/invites/index.ts @@ -19,7 +19,8 @@ router.post("/:code", route({}), async (req: Request, res: Response) => { const { features } = await Guild.findOneOrFail({ id: guild_id}); const { public_flags } = await User.findOneOrFail({ id: req.user_id }); - if(features.includes("INTERNAL_EMPLOYEE_ONLY") && (public_flags & 1) !== 1) throw new HTTPError("You are not allowed to join this guild.", 401) + if(features.includes("INTERNAL_EMPLOYEE_ONLY") && (public_flags & 1) !== 1) throw new HTTPError("Only intended for the staff of this server.", 401); + if(features.includes("INVITES_CLOSED")) throw new HTTPError("Sorry, this guild has joins closed.", 403); const invite = await Invite.joinGuild(req.user_id, code); diff --git a/api/src/util/handlers/Message.ts b/api/src/util/handlers/Message.ts index ba6763948..216643686 100644 --- a/api/src/util/handlers/Message.ts +++ b/api/src/util/handlers/Message.ts @@ -2,6 +2,7 @@ import { Channel, Embed, emitEvent, + Guild, Message, MessageCreateEvent, MessageUpdateEvent, @@ -17,13 +18,14 @@ import { User, Application, Webhook, - Attachment + Attachment, + Config, } from "@fosscord/util"; import { HTTPError } from "lambert-server"; import fetch from "node-fetch"; import cheerio from "cheerio"; import { MessageCreateSchema } from "../../routes/channels/#channel_id/messages"; - +const allow_empty = false; // TODO: check webhook, application, system author, stickers // TODO: embed gifs/videos/images @@ -55,6 +57,10 @@ export async function handleMessage(opts: MessageOptions): Promise { type: opts.type ?? 0 }); + if (message.content && message.content.length > Config.get().limits.message.maxCharacters) { + throw new HTTPError("Content length over max character limit") + } + // TODO: are tts messages allowed in dm channels? should permission be checked? if (opts.author_id) { message.author = await User.getPublicUser(opts.author_id); @@ -67,7 +73,7 @@ export async function handleMessage(opts: MessageOptions): Promise { } const permission = await getPermission(opts.author_id, channel.guild_id, opts.channel_id); - permission.hasThrow("SEND_MESSAGES"); + permission.hasThrow("SEND_MESSAGES"); // TODO: add the rights check if (permission.cache.member) { message.member = permission.cache.member; } @@ -75,15 +81,19 @@ export async function handleMessage(opts: MessageOptions): Promise { if (opts.tts) permission.hasThrow("SEND_TTS_MESSAGES"); if (opts.message_reference) { permission.hasThrow("READ_MESSAGE_HISTORY"); - if (opts.message_reference.guild_id !== channel.guild_id) throw new HTTPError("You can only reference messages from this guild"); - if (opts.message_reference.channel_id !== opts.channel_id) throw new HTTPError("You can only reference messages from this channel"); + // code below has to be redone when we add custom message routing and cross-channel replies + const guild = await Guild.findOneOrFail({ id: channel.guild_id }); + if (!guild.features.includes("CROSS_CHANNEL_REPLIES")) { + if (opts.message_reference.guild_id !== channel.guild_id) throw new HTTPError("You can only reference messages from this guild"); + if (opts.message_reference.channel_id !== opts.channel_id) throw new HTTPError("You can only reference messages from this channel"); + } // TODO: should be checked if the referenced message exists? // @ts-ignore message.type = MessageType.REPLY; } // TODO: stickers/activity - if (!opts.content && !opts.embeds?.length && !opts.attachments?.length && !opts.sticker_ids?.length) { + if (!allow_empty && (!opts.content && !opts.embeds?.length && !opts.attachments?.length && !opts.sticker_ids?.length)) { throw new HTTPError("Empty messages are not allowed", 50006); } @@ -93,7 +103,7 @@ export async function handleMessage(opts: MessageOptions): Promise { var mention_user_ids = [] as string[]; var mention_everyone = false; - if (content) { + if (content) { // TODO: explicit-only mentions message.content = content.trim(); for (const [_, mention] of content.matchAll(CHANNEL_MENTION)) { if (!mention_channel_ids.includes(mention)) mention_channel_ids.push(mention); @@ -135,7 +145,7 @@ export async function postHandleMessage(message: Message) { const data = { ...message }; data.embeds = data.embeds.filter((x) => x.type !== "link"); - links = links.slice(0, 5); // embed max 5 links + links = links.slice(0, 20); // embed max 20 links — TODO: make this configurable with instance policies for (const link of links) { try { @@ -188,7 +198,7 @@ export async function sendMessage(opts: MessageOptions) { emitEvent({ event: "MESSAGE_CREATE", channel_id: opts.channel_id, data: message.toJSON() } as MessageCreateEvent) ]); - 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 should catch error non-blockingly return message; } diff --git a/bundle/package-lock.json b/bundle/package-lock.json index 294dfe1f9..1b1455487 100644 Binary files a/bundle/package-lock.json and b/bundle/package-lock.json differ diff --git a/bundle/package.json b/bundle/package.json index 7dc9a776b..9acbf6298 100644 --- a/bundle/package.json +++ b/bundle/package.json @@ -94,7 +94,7 @@ "morgan": "^1.10.0", "multer": "^1.4.2", "nanocolors": "^0.2.12", - "node-fetch": "^2.6.1", + "node-fetch": "^2.6.7", "node-os-utils": "^1.3.5", "patch-package": "^6.4.7", "pg": "^8.7.1", diff --git a/cdn/package-lock.json b/cdn/package-lock.json index 367f411e6..a6c2df2d4 100644 Binary files a/cdn/package-lock.json and b/cdn/package-lock.json differ diff --git a/cdn/package.json b/cdn/package.json index fec43785c..aedcc4bff 100644 --- a/cdn/package.json +++ b/cdn/package.json @@ -15,7 +15,7 @@ }, "keywords": [], "author": "", - "license": "ISC", + "license": "GPLV3", "bugs": { "url": "https://github.com/fosscord/fosscord-server/issues" }, @@ -54,7 +54,7 @@ "missing-native-js-functions": "^1.2.17", "multer": "^1.4.2", "nanocolors": "^0.2.12", - "node-fetch": "^2.6.1", + "node-fetch": "^2.6.7", "supertest": "^6.1.6", "typescript": "^4.1.2" }, diff --git a/cdn/src/Server.ts b/cdn/src/Server.ts index cac34a80d..b8d71fa95 100644 --- a/cdn/src/Server.ts +++ b/cdn/src/Server.ts @@ -2,6 +2,7 @@ import { Server, ServerOptions } from "lambert-server"; import { Config, initDatabase, registerRoutes } from "@fosscord/util"; import path from "path"; import avatarsRoute from "./routes/avatars"; +import iconsRoute from "./routes/role-icons"; import bodyParser from "body-parser"; export interface CDNServerOptions extends ServerOptions {} @@ -40,6 +41,9 @@ export class CDNServer extends Server { this.app.use("/icons/", avatarsRoute); this.log("verbose", "[Server] Route /icons registered"); + this.app.use("/role-icons/", iconsRoute); + this.log("verbose", "[Server] Route /role-icons registered"); + this.app.use("/emojis/", avatarsRoute); this.log("verbose", "[Server] Route /emojis registered"); diff --git a/cdn/src/routes/role-icons.ts b/cdn/src/routes/role-icons.ts new file mode 100644 index 000000000..12aae8a4e --- /dev/null +++ b/cdn/src/routes/role-icons.ts @@ -0,0 +1,101 @@ +import { Router, Response, Request } from "express"; +import { Config, Snowflake } from "@fosscord/util"; +import { storage } from "../util/Storage"; +import FileType from "file-type"; +import { HTTPError } from "lambert-server"; +import crypto from "crypto"; +import { multer } from "../util/multer"; + +//Role icons ---> avatars.ts modified + +// TODO: check user rights and perks and animated pfp are allowed in the policies +// TODO: generate different sizes of icon +// TODO: generate different image types of icon + +const STATIC_MIME_TYPES = [ + "image/png", + "image/jpeg", + "image/webp", + "image/svg+xml", + "image/svg", +]; +const ALLOWED_MIME_TYPES = [...STATIC_MIME_TYPES]; + +const router = Router(); + +router.post( + "/:role_id", + multer.single("file"), + async (req: Request, res: Response) => { + if (req.headers.signature !== Config.get().security.requestSignature) + throw new HTTPError("Invalid request signature"); + if (!req.file) throw new HTTPError("Missing file"); + const { buffer, mimetype, size, originalname, fieldname } = req.file; + const { role_id } = req.params; + + var hash = crypto + .createHash("md5") + .update(Snowflake.generate()) + .digest("hex"); + + const type = await FileType.fromBuffer(buffer); + if (!type || !ALLOWED_MIME_TYPES.includes(type.mime)) + throw new HTTPError("Invalid file type"); + + const path = `role-icons/${role_id}/${hash}.png`; + const endpoint = + Config.get().cdn.endpointPublic || "http://localhost:3003"; + + await storage.set(path, buffer); + + return res.json({ + id: hash, + content_type: type.mime, + size, + url: `${endpoint}${req.baseUrl}/${role_id}/${hash}`, + }); + } +); + +router.get("/:role_id", async (req: Request, res: Response) => { + var { role_id } = req.params; + //role_id = role_id.split(".")[0]; // remove .file extension + const path = `role-icons/${role_id}`; + + const file = await storage.get(path); + if (!file) throw new HTTPError("not found", 404); + const type = await FileType.fromBuffer(file); + + res.set("Content-Type", type?.mime); + res.set("Cache-Control", "public, max-age=31536000, must-revalidate"); + + return res.send(file); +}); + +router.get("/:role_id/:hash", async (req: Request, res: Response) => { + var { role_id, hash } = req.params; + //hash = hash.split(".")[0]; // remove .file extension + const path = `role-icons/${role_id}/${hash}`; + + const file = await storage.get(path); + if (!file) throw new HTTPError("not found", 404); + const type = await FileType.fromBuffer(file); + + res.set("Content-Type", type?.mime); + res.set("Cache-Control", "public, max-age=31536000, must-revalidate"); + + return res.send(file); +}); + +router.delete("/:role_id/:id", async (req: Request, res: Response) => { + if (req.headers.signature !== Config.get().security.requestSignature) + throw new HTTPError("Invalid request signature"); + const { role_id, id } = req.params; + const path = `role-icons/${role_id}/${id}`; + + await storage.delete(path); + + return res.send({ success: true }); +}); + +export default router; diff --git a/dashboard/LICENSE b/dashboard/LICENSE new file mode 100644 index 000000000..f19bf5202 --- /dev/null +++ b/dashboard/LICENSE @@ -0,0 +1,14 @@ +Copyright (C) 2021 Fosscord and 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 . \ No newline at end of file diff --git a/dashboard/package.json b/dashboard/package.json index 0967ef424..1009d658e 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1 +1,23 @@ -{} +{ + "name": "@fosscord/dashboard", + "version": "1.0.0", + "description": "Dashboard for Fosscord", + "main": "dist/index.js", + "types": "src/index.ts", + "scripts": { + "test": "npm run build && jest --coverage ./tests", + "build": "npx tsc -p .", + "start": "node dist/start.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/fosscord/fosscord-server.git" + }, + "keywords": [], + "author": "", + "license": "GPLV3", + "bugs": { + "url": "https://github.com/fosscord/fosscord-server/issues" + }, + "homepage": "https://github.com/fosscord/fosscord-server#readme" +} diff --git a/dashboard/src/index.ts b/dashboard/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/gateway/package-lock.json b/gateway/package-lock.json index a9813b6f2..d02106f80 100644 Binary files a/gateway/package-lock.json and b/gateway/package-lock.json differ diff --git a/gateway/package.json b/gateway/package.json index e02a0000f..7daddfc0d 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -13,7 +13,7 @@ }, "keywords": [], "author": "Fosscord", - "license": "ISC", + "license": "GPLV3", "devDependencies": { "@types/amqplib": "^0.8.1", "@types/jsonwebtoken": "^8.5.0", @@ -32,7 +32,7 @@ "jsonwebtoken": "^8.5.1", "lambert-server": "^1.2.11", "missing-native-js-functions": "^1.2.18", - "node-fetch": "^2.6.1", + "node-fetch": "^3.1.1", "proxy-agent": "^5.0.0", "typeorm": "^0.2.37", "ws": "^7.4.2" diff --git a/gateway/src/util/Send.ts b/gateway/src/util/Send.ts index 196d4205c..c8627b032 100644 --- a/gateway/src/util/Send.ts +++ b/gateway/src/util/Send.ts @@ -1,7 +1,9 @@ var erlpack: any; try { erlpack = require("@yukikaze-bot/erlpack"); -} catch (error) {} +} catch (error) { + console.log("Missing @yukikaze-bot/erlpack, electron-based desktop clients designed for discord.com will not be able to connect!"); +} import { Payload, WebSocket } from "@fosscord/gateway"; export async function Send(socket: WebSocket, data: Payload) { diff --git a/rtc/LICENSE b/rtc/LICENSE new file mode 100644 index 000000000..f19bf5202 --- /dev/null +++ b/rtc/LICENSE @@ -0,0 +1,14 @@ +Copyright (C) 2021 Fosscord and 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 . \ No newline at end of file diff --git a/util/package-lock.json b/util/package-lock.json index c6f2ed6fc..2b33b7de4 100644 Binary files a/util/package-lock.json and b/util/package-lock.json differ diff --git a/util/package.json b/util/package.json index aef5dcfc5..1ef3031e8 100644 --- a/util/package.json +++ b/util/package.json @@ -44,7 +44,7 @@ "lambert-server": "^1.2.12", "missing-native-js-functions": "^1.2.18", "multer": "^1.4.3", - "node-fetch": "^2.6.1", + "node-fetch": "^2.6.7", "patch-package": "^6.4.7", "pg": "^8.7.1", "picocolors": "^1.0.0", diff --git a/util/src/entities/Channel.ts b/util/src/entities/Channel.ts index 4036b5d66..1cc4a5386 100644 --- a/util/src/entities/Channel.ts +++ b/util/src/entities/Channel.ts @@ -1,332 +1,357 @@ -import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm"; -import { BaseClass } from "./BaseClass"; -import { Guild } from "./Guild"; -import { PublicUserProjection, User } from "./User"; -import { HTTPError } from "lambert-server"; -import { containsAll, emitEvent, getPermission, Snowflake, trimSpecial } from "../util"; -import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces"; -import { Recipient } from "./Recipient"; -import { Message } from "./Message"; -import { ReadState } from "./ReadState"; -import { Invite } from "./Invite"; -import { VoiceState } from "./VoiceState"; -import { Webhook } from "./Webhook"; -import { DmChannelDTO } from "../dtos"; - -export enum ChannelType { - GUILD_TEXT = 0, // a text channel within a server - DM = 1, // a direct message between users - GUILD_VOICE = 2, // a voice channel within a server - GROUP_DM = 3, // a direct message between multiple users - 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_STORE = 6, // a channel in which game developers can sell their game on Discord - // TODO: what are channel types between 7-9? - GUILD_NEWS_THREAD = 10, // a temporary sub-channel within a GUILD_NEWS channel - GUILD_PUBLIC_THREAD = 11, // a temporary sub-channel within a GUILD_TEXT 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_STAGE_VOICE = 13, // a voice channel for hosting events with an audience -} - -@Entity("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[]; - - @Column({ nullable: true }) - last_message_id: string; - - @Column({ nullable: true }) - @RelationId((channel: Channel) => channel.guild) - guild_id?: string; - - @JoinColumn({ name: "guild_id" }) - @ManyToOne(() => Guild, { - onDelete: "CASCADE", - }) - guild: Guild; - - @Column({ nullable: true }) - @RelationId((channel: Channel) => channel.parent) - parent_id: string; - - @JoinColumn({ name: "parent_id" }) - @ManyToOne(() => Channel) - parent?: Channel; - - // only for group dms - @Column({ nullable: true }) - @RelationId((channel: Channel) => channel.owner) - owner_id: string; - - @JoinColumn({ name: "owner_id" }) - @ManyToOne(() => User) - owner: User; - - @Column({ nullable: true }) - last_pin_timestamp?: number; - - @Column({ nullable: true }) - default_auto_archive_duration?: number; - - @Column({ nullable: true }) - position?: number; - - @Column({ type: "simple-json", nullable: true }) - permission_overwrites?: ChannelPermissionOverwrite[]; - - @Column({ nullable: true }) - video_quality_mode?: number; - - @Column({ nullable: true }) - bitrate?: number; - - @Column({ nullable: true }) - user_limit?: number; - - @Column({ nullable: true }) - nsfw?: boolean; - - @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[]; - - @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[]; - - // TODO: DM channel - static async createChannel( - channel: Partial, - user_id: string = "0", - opts?: { - keepId?: boolean; - skipExistsCheck?: boolean; - skipPermissionCheck?: boolean; - skipEventEmit?: boolean; - } - ) { - if (!opts?.skipPermissionCheck) { - // Always check if user has permission first - const permissions = await getPermission(user_id, channel.guild_id); - permissions.hasThrow("MANAGE_CHANNELS"); - } - - switch (channel.type) { - case ChannelType.GUILD_TEXT: - case ChannelType.GUILD_VOICE: - if (channel.parent_id && !opts?.skipExistsCheck) { - 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) - throw new HTTPError("The category channel needs to be in the guild"); - } - break; - case ChannelType.GUILD_CATEGORY: - break; - case ChannelType.DM: - case ChannelType.GROUP_DM: - throw new HTTPError("You can't create a dm channel in a guild"); - // TODO: check if guild is community server - case ChannelType.GUILD_STORE: - case ChannelType.GUILD_NEWS: - default: - throw new HTTPError("Not yet supported"); - } - - if (!channel.permission_overwrites) channel.permission_overwrites = []; - // TODO: auto generate position - - channel = { - ...channel, - ...(!opts?.keepId && { id: Snowflake.generate() }), - created_at: new Date(), - position: channel.position || 0, - }; - - await Promise.all([ - new Channel(channel).save(), - !opts?.skipEventEmit - ? emitEvent({ - event: "CHANNEL_CREATE", - data: channel, - guild_id: channel.guild_id, - } as ChannelCreateEvent) - : Promise.resolve(), - ]); - - return channel; - } - - static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) { - 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"); - } - - const type = recipients.length === 1 ? ChannelType.DM : ChannelType.GROUP_DM; - - let channel = null; - - const channelRecipients = [...recipients, creator_user_id]; - - const userRecipients = await Recipient.find({ - where: { user_id: creator_user_id }, - relations: ["channel", "channel.recipients"], - }); - - for (let ur of userRecipients) { - let re = ur.channel.recipients!.map((r) => r.user_id); - if (re.length === channelRecipients.length) { - if (containsAll(re, channelRecipients)) { - if (channel == null) { - channel = ur.channel; - await ur.assign({ closed: false }).save(); - } - } - } - } - - if (channel == null) { - name = trimSpecial(name); - - channel = await new Channel({ - name, - type, - owner_id: type === ChannelType.DM ? undefined : creator_user_id, - created_at: new Date(), - last_message_id: null, - recipients: channelRecipients.map( - (x) => - new Recipient({ user_id: x, closed: !(type === ChannelType.GROUP_DM || x === creator_user_id) }) - ), - }).save(); - } - - const channel_dto = await DmChannelDTO.from(channel); - - if (type === ChannelType.GROUP_DM) { - for (let 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 }); - } - - 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 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? - 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, - } 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, -} +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm"; +import { BaseClass } from "./BaseClass"; +import { Guild } from "./Guild"; +import { PublicUserProjection, User } from "./User"; +import { HTTPError } from "lambert-server"; +import { containsAll, emitEvent, getPermission, Snowflake, trimSpecial, InvisibleCharacters } from "../util"; +import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces"; +import { Recipient } from "./Recipient"; +import { Message } from "./Message"; +import { ReadState } from "./ReadState"; +import { Invite } from "./Invite"; +import { VoiceState } from "./VoiceState"; +import { Webhook } from "./Webhook"; +import { DmChannelDTO } from "../dtos"; + +export enum ChannelType { + GUILD_TEXT = 0, // a text channel within a server + DM = 1, // a direct message between users + GUILD_VOICE = 2, // a voice channel within a server + GROUP_DM = 3, // a direct message between multiple users + 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_STORE = 6, // a channel in which game developers can sell their game on Discord + ENCRYPTED = 7, // end-to-end encrypted channel + ENCRYPTED_THREAD = 8, // end-to-end encrypted thread channel + GUILD_NEWS_THREAD = 10, // a temporary sub-channel within a GUILD_NEWS channel + GUILD_PUBLIC_THREAD = 11, // a temporary sub-channel within a GUILD_TEXT 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_STAGE_VOICE = 13, // a voice channel for hosting events with an audience + CUSTOM_START = 64, // start custom channel types from here + UNHANDLED = 255 // unhandled unowned pass-through channel type +} + +@Entity("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[]; + + @Column({ nullable: true }) + last_message_id: string; + + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.guild) + guild_id?: string; + + @JoinColumn({ name: "guild_id" }) + @ManyToOne(() => Guild, { + onDelete: "CASCADE", + }) + guild: Guild; + + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.parent) + parent_id: string; + + @JoinColumn({ name: "parent_id" }) + @ManyToOne(() => Channel) + parent?: Channel; + + // only for group dms + @Column({ nullable: true }) + @RelationId((channel: Channel) => channel.owner) + owner_id: string; + + @JoinColumn({ name: "owner_id" }) + @ManyToOne(() => User) + owner: User; + + @Column({ nullable: true }) + last_pin_timestamp?: number; + + @Column({ nullable: true }) + default_auto_archive_duration?: number; + + @Column({ nullable: true }) + position?: number; + + @Column({ type: "simple-json", nullable: true }) + permission_overwrites?: ChannelPermissionOverwrite[]; + + @Column({ nullable: true }) + video_quality_mode?: number; + + @Column({ nullable: true }) + bitrate?: number; + + @Column({ nullable: true }) + user_limit?: number; + + @Column({ nullable: true }) + nsfw?: boolean; + + @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[]; + + @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[]; + + // TODO: DM channel + static async createChannel( + channel: Partial, + user_id: string = "0", + opts?: { + keepId?: boolean; + skipExistsCheck?: boolean; + skipPermissionCheck?: boolean; + skipEventEmit?: boolean; + skipNameChecks?: boolean; + } + ) { + if (!opts?.skipPermissionCheck) { + // Always check if user has permission first + const permissions = await getPermission(user_id, channel.guild_id); + permissions.hasThrow("MANAGE_CHANNELS"); + } + + if (!opts?.skipNameChecks) { + const guild = await Guild.findOneOrFail({ id: channel.guild_id }); + if (!guild.features.includes("ALLOW_INVALID_CHANNEL_NAMES") && channel.name) { + for (var character of InvisibleCharacters) + if (channel.name.includes(character)) + 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) + } + + if (!guild.features.includes("ALLOW_UNNAMED_CHANNELS")) { + if (!channel.name) + throw new HTTPError("Channel name cannot be empty.", 403); + } + } + + switch (channel.type) { + case ChannelType.GUILD_TEXT: + case ChannelType.GUILD_VOICE: + if (channel.parent_id && !opts?.skipExistsCheck) { + 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) + throw new HTTPError("The category channel needs to be in the guild"); + } + break; + case ChannelType.GUILD_CATEGORY: + break; + case ChannelType.DM: + case ChannelType.GROUP_DM: + throw new HTTPError("You can't create a dm channel in a guild"); + // TODO: check if guild is community server + case ChannelType.GUILD_STORE: + case ChannelType.GUILD_NEWS: + default: + throw new HTTPError("Not yet supported"); + } + + if (!channel.permission_overwrites) channel.permission_overwrites = []; + // TODO: auto generate position + + channel = { + ...channel, + ...(!opts?.keepId && { id: Snowflake.generate() }), + created_at: new Date(), + position: channel.position || 0, + }; + + await Promise.all([ + new Channel(channel).save(), + !opts?.skipEventEmit + ? emitEvent({ + event: "CHANNEL_CREATE", + data: channel, + guild_id: channel.guild_id, + } as ChannelCreateEvent) + : Promise.resolve(), + ]); + + return channel; + } + + static async createDMChannel(recipients: string[], creator_user_id: string, name?: string) { + 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"); + } + + const type = recipients.length === 1 ? ChannelType.DM : ChannelType.GROUP_DM; + + let channel = null; + + const channelRecipients = [...recipients, creator_user_id]; + + const userRecipients = await Recipient.find({ + where: { user_id: creator_user_id }, + relations: ["channel", "channel.recipients"], + }); + + for (let ur of userRecipients) { + let re = ur.channel.recipients!.map((r) => r.user_id); + if (re.length === channelRecipients.length) { + if (containsAll(re, channelRecipients)) { + if (channel == null) { + channel = ur.channel; + await ur.assign({ closed: false }).save(); + } + } + } + } + + if (channel == null) { + name = trimSpecial(name); + + channel = await new Channel({ + name, + type, + owner_id: type === ChannelType.DM ? undefined : null, // 1:1 DMs are ownerless in fosscord-server + created_at: new Date(), + last_message_id: null, + recipients: channelRecipients.map( + (x) => + new Recipient({ user_id: x, closed: !(type === ChannelType.GROUP_DM || x === creator_user_id) }) + ), + }).save(); + } + + const channel_dto = await DmChannelDTO.from(channel); + + if (type === ChannelType.GROUP_DM) { + for (let 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 }); + } + + 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, + } 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, +} diff --git a/util/src/entities/Config.ts b/util/src/entities/Config.ts index 9e25c7379..6993cc095 100644 --- a/util/src/entities/Config.ts +++ b/util/src/entities/Config.ts @@ -149,6 +149,7 @@ export interface ConfigValue { minUpperCase: number; minSymbols: number; }; + incrementingDiscriminators: boolean; // random otherwise }; regions: { default: string; @@ -335,6 +336,7 @@ export const DefaultConfigOptions: ConfigValue = { minUpperCase: 2, minSymbols: 0, }, + incrementingDiscriminators: false, }, regions: { default: "fosscord", diff --git a/util/src/entities/Emoji.ts b/util/src/entities/Emoji.ts index 03218375b..32d39234f 100644 --- a/util/src/entities/Emoji.ts +++ b/util/src/entities/Emoji.ts @@ -10,7 +10,7 @@ export class Emoji extends BaseClass { animated: boolean; @Column() - available: boolean; // whether this emoji can be used, may be false due to loss of Server Boosts + available: boolean; // whether this emoji can be used, may be false due to various reasons @Column() guild_id: string; @@ -40,4 +40,7 @@ export class Emoji extends BaseClass { @Column({ type: "simple-array" }) roles: string[]; // roles this emoji is whitelisted to (new discord feature?) + + @Column({ type: "simple-array" }) + groups: string[]; // user groups this emoji is whitelisted to (Fosscord extension) } diff --git a/util/src/entities/Guild.ts b/util/src/entities/Guild.ts index 157f0921b..6a1df4d6f 100644 --- a/util/src/entities/Guild.ts +++ b/util/src/entities/Guild.ts @@ -213,7 +213,7 @@ export class Guild extends BaseClass { owner: User; @Column({ nullable: true }) - preferred_locale?: string; // only community guilds can choose this + preferred_locale?: string; @Column({ nullable: true }) premium_subscription_count?: number; @@ -301,22 +301,22 @@ export class Guild extends BaseClass { name: body.name || "Fosscord", icon: await handleFile(`/icons/${guild_id}`, body.icon as string), region: Config.get().regions.default, - owner_id: body.owner_id, + owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds afk_timeout: 300, - default_message_notifications: 0, + default_message_notifications: 1, // defaults effect: setting the push default at mentions-only will save a lot explicit_content_filter: 0, features: [], id: guild_id, max_members: 250000, max_presences: 250000, - max_video_channel_users: 25, + max_video_channel_users: 200, presence_count: 0, member_count: 0, // will automatically be increased by addMember() mfa_level: 0, preferred_locale: "en-US", premium_subscription_count: 0, premium_tier: 0, - system_channel_flags: 0, + system_channel_flags: 4, // defaults effect: suppress the setup tips to save performance unavailable: false, nsfw: false, nsfw_level: 0, @@ -326,20 +326,24 @@ export class Guild extends BaseClass { description: "No description", welcome_channels: [], }, - widget_enabled: false, + widget_enabled: true, // NB: don't set it as false to prevent artificial restrictions }).save(); // we have to create the role _after_ the guild because else we would get a "SQLITE_CONSTRAINT: FOREIGN KEY constraint failed" error + // TODO: make the @everyone a pseudorole that is dynamically generated at runtime so we can save storage await new Role({ id: guild_id, guild_id: guild_id, color: 0, hoist: false, managed: false, + // NB: in Fosscord, every role will be non-managed, as we use user-groups instead of roles for managed groups mentionable: false, name: "@everyone", permissions: String("2251804225"), position: 0, + icon: null, + unicode_emoji: null }).save(); if (!body.channels || !body.channels.length) body.channels = [{ id: "01", type: 0, name: "general" }]; @@ -355,7 +359,6 @@ export class Guild extends BaseClass { for (const channel of body.channels?.sort((a, b) => (a.parent_id ? 1 : -1))) { var id = ids.get(channel.id) || Snowflake.generate(); - // TODO: should we abort if parent_id is a category? (to disallow sub category channels) var parent_id = ids.get(channel.parent_id); await Channel.createChannel({ ...channel, guild_id, id, parent_id }, body.owner_id, { diff --git a/util/src/entities/Role.ts b/util/src/entities/Role.ts index 9fca99a5b..4b721b5bb 100644 --- a/util/src/entities/Role.ts +++ b/util/src/entities/Role.ts @@ -36,6 +36,12 @@ export class Role extends BaseClass { @Column() position: number; + @Column({ nullable: true }) + icon: string; + + @Column({ nullable: true }) + unicode_emoji: string; + @Column({ type: "simple-json", nullable: true }) tags?: { bot_id?: string; diff --git a/util/src/entities/User.ts b/util/src/entities/User.ts index bc852616d..5f2618e0e 100644 --- a/util/src/entities/User.ts +++ b/util/src/entities/User.ts @@ -64,7 +64,7 @@ export class User extends BaseClass { setDiscriminator(val: string) { const number = Number(val); if (isNaN(number)) throw new Error("invalid discriminator"); - if (number <= 0 || number > 10000) throw new Error("discriminator must be between 1 and 9999"); + if (number <= 0 || number >= 10000) throw new Error("discriminator must be between 1 and 9999"); this.discriminator = val.toString().padStart(4, "0"); } @@ -178,6 +178,35 @@ export class User extends BaseClass { ); } + private static async generateDiscriminator(username: string): Promise { + if (Config.get().register.incrementingDiscriminators) { + // discriminator will be incrementally generated + + // First we need to figure out the currently highest discrimnator for the given username and then increment it + const users = await User.find({ where: { username }, select: ["discriminator"] }); + const highestDiscriminator = Math.max(0, ...users.map((u) => Number(u.discriminator))); + + const discriminator = highestDiscriminator + 1; + if (discriminator >= 10000) { + return undefined; + } + + return discriminator.toString().padStart(4, "0"); + } else { + // discriminator will be randomly generated + + // randomly generates a discriminator between 1 and 9999 and checks max five times if it already exists + // TODO: is there any better way to generate a random discriminator only once, without checking if it already exists in the database? + for (let tries = 0; tries < 5; tries++) { + const discriminator = Math.randomIntBetween(1, 9999).toString().padStart(4, "0"); + const exists = await User.findOne({ where: { discriminator, username: username }, select: ["id"] }); + if (!exists) return discriminator; + } + + return undefined; + } + } + static async register({ email, username, @@ -194,21 +223,9 @@ export class User extends BaseClass { // trim special uf8 control characters -> Backspace, Newline, ... username = trimSpecial(username); - // discriminator will be randomly generated - let discriminator = ""; - - let exists; - // randomly generates a discriminator between 1 and 9999 and checks max five times if it already exists - // if it all five times already exists, abort with USERNAME_TOO_MANY_USERS error - // else just continue - // TODO: is there any better way to generate a random discriminator only once, without checking if it already exists in the database? - for (let tries = 0; tries < 5; tries++) { - discriminator = Math.randomIntBetween(1, 9999).toString().padStart(4, "0"); - exists = await User.findOne({ where: { discriminator, username: username }, select: ["id"] }); - if (!exists) break; - } - - if (exists) { + const discriminator = await User.generateDiscriminator(username); + if (!discriminator) { + // We've failed to generate a valid and unused discriminator throw FieldErrors({ username: { code: "USERNAME_TOO_MANY_USERS", diff --git a/util/src/util/InvisibleCharacters.ts b/util/src/util/InvisibleCharacters.ts new file mode 100644 index 000000000..2b014e146 --- /dev/null +++ b/util/src/util/InvisibleCharacters.ts @@ -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 +]; \ No newline at end of file diff --git a/util/src/util/index.ts b/util/src/util/index.ts index c57034685..98e1146ca 100644 --- a/util/src/util/index.ts +++ b/util/src/util/index.ts @@ -18,3 +18,4 @@ export * from "./Snowflake"; export * from "./String"; export * from "./Array"; export * from "./TraverseDirectory"; +export * from "./InvisibleCharacters"; \ No newline at end of file diff --git a/webrtc/LICENSE b/webrtc/LICENSE index e8ea215a4..f19bf5202 100644 --- a/webrtc/LICENSE +++ b/webrtc/LICENSE @@ -1,21 +1,14 @@ -MIT License +Copyright (C) 2021 Fosscord and contributors -Copyright (c) 2021 Fosscord (former Discord Open Source) +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. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +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. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . \ No newline at end of file