Migrate Old Dashboard to New
3
CREDITS.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Credits
|
||||
|
||||
## DClone Icon - @nwlandas
|
2
backend/.gitignore
vendored
@ -1,2 +1,4 @@
|
||||
.env
|
||||
|
||||
node_modules/
|
||||
lib/
|
20
backend/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/lib/app.js",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
12
backend/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"mochaExplorer.files": "test/unit/*.tests.ts",
|
||||
"mochaExplorer.require": "ts-node/register",
|
||||
"mochaExplorer.logpanel": true,
|
||||
"editor.tabSize": 2,
|
||||
"debug.javascript.warnOnLongPrediction": false,
|
||||
"cSpell.words": [
|
||||
"cooldown",
|
||||
"metascraper"
|
||||
],
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
18
backend/.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "typescript",
|
||||
"tsconfig": "tsconfig.json",
|
||||
"option": "watch",
|
||||
"problemMatcher": [
|
||||
"$tsc-watch"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"label": "tsc: watch - tsconfig.json"
|
||||
}
|
||||
]
|
||||
}
|
@ -1,54 +1,19 @@
|
||||
### There are 2 main components in this API.
|
||||
# Accord - API
|
||||
Tested code that brings Accord to life.
|
||||
|
||||
REST refers to the REST API (uses HTTP).
|
||||
WS refers to the WebSocket API (uses WS).
|
||||

|
||||
|
||||
### Dependency Injection
|
||||
> © All rights reserved. This repo is not (yet) open source, and is awaiting completion.
|
||||
|
||||
Due to the nature of the DI used in this project, dependencies cannot be circular:
|
||||
|
||||
> rest/server.ts
|
||||
|
||||
```ts
|
||||
import { WS } from '...';
|
||||
...
|
||||
Deps.get<WS>(WS);
|
||||
## `.env`
|
||||
```.env
|
||||
API_URL="http://localhost:3000/api"
|
||||
EMAIL_ADDRESS="example@gmail.com"
|
||||
EMAIL_PASSWORD="google_account_password"
|
||||
MONGO_URI="mongodb://localhost/accord"
|
||||
PORT=3000
|
||||
ROOT_ENDPOINT="http://localhost:3000"
|
||||
WEBSITE_URL="http://localhost:4200"
|
||||
```
|
||||
|
||||
> ws/websocket.ts
|
||||
|
||||
```ts
|
||||
import { REST } from '...';
|
||||
...
|
||||
Deps.get<REST>(REST);
|
||||
```
|
||||
|
||||
`Deps.add` -> create dep with custom object.
|
||||
`Deps.get` -> get dep, or create with no args constructor.
|
||||
|
||||
---
|
||||
|
||||
# WS
|
||||
|
||||
SNAKE_CASE is used as lowercase to avoid possible conflict with socket.io event names.
|
||||
|
||||
## Events
|
||||
|
||||
| NAME | DESCRIPTION |
|
||||
| :------------- | :--------------------------------- |
|
||||
| READY | When a user connects with browser. |
|
||||
| MESSAGE_CREATE | When user sends message. |
|
||||
| MESSAGE_DELETE | When user deletes message. |
|
||||
|
||||
---
|
||||
|
||||
## PORTS
|
||||
|
||||
`3000` -> REST API and WS
|
||||
`4200` -> React website
|
||||
|
||||
---
|
||||
|
||||
## Different Folders, One Big Repo
|
||||
|
||||
-> Branch versions will be used (e.g. v1,v2,...,v12)
|
||||
> For help on setting up gmail mailing, go here - https://nodemailer.com/usage/using-gmail.
|
0
backend/assets/avatars/avatar_aqua.png
Executable file → Normal file
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
0
backend/assets/avatars/avatar_coffee.png
Executable file → Normal file
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 181 KiB |
0
backend/assets/avatars/avatar_fire.png
Executable file → Normal file
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 167 KiB |
0
backend/assets/avatars/avatar_gold.png
Executable file → Normal file
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
0
backend/assets/avatars/avatar_grey.png
Executable file → Normal file
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
0
backend/assets/avatars/avatar_rainbow.png
Executable file → Normal file
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
0
backend/assets/avatars/avatar_sky.png
Executable file → Normal file
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
0
backend/assets/avatars/avatar_tree.png
Executable file → Normal file
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
0
backend/assets/avatars/bot.png
Executable file → Normal file
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
0
backend/assets/avatars/unknown.png
Executable file → Normal file
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 191 KiB |
12706
backend/package-lock.json
generated
@ -1,44 +1,78 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "1.0.0",
|
||||
"description": "REST refers to the REST API (uses HTTP). WS refers to the WebSocket API (uses WS).",
|
||||
"main": "index.js",
|
||||
"description": "",
|
||||
"main": "src/app.ts",
|
||||
"scripts": {
|
||||
"start": "ts-node src/app.ts",
|
||||
"dev": "ts-node-dev --transpile-only src/app.ts"
|
||||
"dev": "ts-node-dev src/app.ts",
|
||||
"test": "ts-mocha test/test.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"author": "youtube.com/ADAMJR",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.19.0",
|
||||
"chai-things": "^0.2.0",
|
||||
"colors": "^1.4.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^10.0.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"faker": "^5.5.3",
|
||||
"http-errors": "^1.7.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^5.2.6",
|
||||
"faker": "^5.4.0",
|
||||
"got": "^11.7.0",
|
||||
"helmet": "^4.4.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mongoose": "^5.13.3",
|
||||
"metascraper": "^5.14.14",
|
||||
"metascraper-description": "^5.14.14",
|
||||
"metascraper-image": "^5.14.14",
|
||||
"metascraper-title": "^5.14.14",
|
||||
"metascraper-url": "^5.14.14",
|
||||
"mongoose": "^5.10.7",
|
||||
"mongoose-unique-validator": "^2.0.3",
|
||||
"node-fetch": "^2.6.1",
|
||||
"nodemailer": "^6.5.0",
|
||||
"nodemailer-pug-engine": "^2.0.0",
|
||||
"passport": "^0.4.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-local-mongoose": "^6.1.0",
|
||||
"socket.io": "^4.1.3",
|
||||
"uuid": "^8.3.2"
|
||||
"passport-local-mongoose": "^6.0.1",
|
||||
"rate-limit-mongo": "^2.3.1",
|
||||
"re2": "^1.16.0",
|
||||
"socket.io": "^4.0.0",
|
||||
"socket.io-client": "^4.0.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.14",
|
||||
"@types/chai-as-promised": "^7.1.3",
|
||||
"@types/chai-spies": "^1.0.3",
|
||||
"@types/chai-things": "^0.0.34",
|
||||
"@types/colors": "^1.2.1",
|
||||
"@types/cors": "^2.8.7",
|
||||
"@types/dotenv": "^8.2.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/faker": "^5.5.7",
|
||||
"@types/http-errors": "^1.8.1",
|
||||
"@types/jsonwebtoken": "^8.5.4",
|
||||
"@types/mongoose": "^5.11.97",
|
||||
"@types/passport": "^1.0.7",
|
||||
"@types/passport-local": "^1.0.34",
|
||||
"@types/passport-local-mongoose": "^4.0.15",
|
||||
"@types/socket.io": "^3.0.2",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"ts-node-dev": "^1.1.8",
|
||||
"typescript": "^4.3.5"
|
||||
"@types/express": "^4.17.11",
|
||||
"@types/express-rate-limit": "^5.1.1",
|
||||
"@types/faker": "^5.1.6",
|
||||
"@types/jsonwebtoken": "^8.5.0",
|
||||
"@types/mocha": "^8.2.0",
|
||||
"@types/mongoose": "^5.7.36",
|
||||
"@types/node": "^14.11.2",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/nodemailer": "^6.4.1",
|
||||
"@types/passport": "^1.0.4",
|
||||
"@types/passport-local": "^1.0.33",
|
||||
"@types/socket.io": "^2.1.13",
|
||||
"@types/socket.io-client": "^1.4.36",
|
||||
"@types/supertest": "^2.0.10",
|
||||
"chai": "^4.2.0",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chai-spies": "^1.0.0",
|
||||
"i": "^0.3.6",
|
||||
"mocha": "^8.2.1",
|
||||
"npm": "^7.6.3",
|
||||
"sazerac": "^2.0.0",
|
||||
"supertest": "^6.1.3"
|
||||
}
|
||||
}
|
||||
|
16
backend/src/api/modules/api-error.ts
Normal file
@ -0,0 +1,16 @@
|
||||
const messages = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
402: 'Payment Required',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
405: 'Method Not Allowed',
|
||||
429: 'You are being rate limited',
|
||||
500: 'We made an oopsie',
|
||||
};
|
||||
|
||||
export class APIError extends TypeError {
|
||||
constructor(public code: number, message = messages[code]) {
|
||||
super(message);
|
||||
}
|
||||
}
|
36
backend/src/api/modules/email/email-functions.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { UserTypes } from '../../../data/types/entity-types';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { Email } from './email';
|
||||
import { Verification } from './verification';
|
||||
|
||||
export class EmailFunctions {
|
||||
constructor(
|
||||
private email = Deps.get<Email>(Email),
|
||||
private verification = Deps.get<Verification>(Verification),
|
||||
) {}
|
||||
|
||||
public async verifyCode(user: UserTypes.Self) {
|
||||
const expiresIn = 5 * 60 * 1000;
|
||||
await this.email.send('verify', {
|
||||
expiresIn,
|
||||
user,
|
||||
code: this.verification.create(user.email, 'LOGIN', { expiresIn, codeLength: 6 }),
|
||||
}, user.email as string);
|
||||
}
|
||||
public async verifyEmail(emailAddress: string, user: UserTypes.Self) {
|
||||
const expiresIn = 24 * 60 * 60 * 1000;
|
||||
await this.email.send('verify-email', {
|
||||
expiresIn,
|
||||
user,
|
||||
code: this.verification.create(emailAddress, 'VERIFY_EMAIL', { expiresIn }),
|
||||
}, emailAddress);
|
||||
}
|
||||
public async forgotPassword(emailAddress: string, user: UserTypes.Self) {
|
||||
const expiresIn = 1 * 60 * 60 * 1000;
|
||||
await this.email.send('forgot-password', {
|
||||
expiresIn,
|
||||
user,
|
||||
code: this.verification.create(emailAddress, 'FORGOT_PASSWORD', { expiresIn }),
|
||||
}, emailAddress);
|
||||
}
|
||||
}
|
56
backend/src/api/modules/email/email.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { createTransport } from 'nodemailer';
|
||||
import Mail from 'nodemailer/lib/mailer';
|
||||
import { pugEngine } from 'nodemailer-pug-engine';
|
||||
import Log from '../../../utils/log';
|
||||
import { UserTypes } from '../../../data/types/entity-types';
|
||||
|
||||
export class Email {
|
||||
private email: Mail;
|
||||
|
||||
private readonly templateDir = __dirname + '/templates';
|
||||
|
||||
constructor() {
|
||||
this.email = createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_ADDRESS,
|
||||
pass: process.env.EMAIL_PASSWORD,
|
||||
}
|
||||
});
|
||||
|
||||
this.email.verify((error) => (error)
|
||||
? Log.error(error, 'email')
|
||||
: Log.info('Logged in to email service', 'email'));
|
||||
|
||||
this.email.use('compile', pugEngine({
|
||||
templateDir: this.templateDir,
|
||||
pretty: true,
|
||||
}));
|
||||
}
|
||||
|
||||
public async send<K extends keyof EmailTemplate>(template: K, ctx: EmailTemplate[K], ...to: string[]) {
|
||||
await this.email.sendMail({
|
||||
from: process.env.EMAIL_ADDRESS,
|
||||
to: to.join(', '),
|
||||
subject: subjects[template],
|
||||
template,
|
||||
ctx,
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
'verify': {
|
||||
expiresIn: number;
|
||||
user: UserTypes.Self;
|
||||
code: string;
|
||||
};
|
||||
'verify-email': this['verify'];
|
||||
'forgot-password': this['verify'];
|
||||
}
|
||||
|
||||
const subjects: { [k in keyof EmailTemplate]: string } = {
|
||||
'forgot-password': 'Accord - Forgot Password',
|
||||
'verify': 'Accord - Login Verification Code',
|
||||
'verify-email': 'Accord - Verify Email',
|
||||
};
|
101
backend/src/api/modules/email/templates/email.css
Normal file
@ -0,0 +1,101 @@
|
||||
body {
|
||||
color: #CACBCD;
|
||||
font-family: 'Inter', 'Helvetica', sans-serif;
|
||||
padding-top: 25px;
|
||||
font-size: 16px;
|
||||
}
|
||||
header, section.body, footer {
|
||||
margin: 25px;
|
||||
}
|
||||
|
||||
p, span {
|
||||
color: #CACBCD;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding-top: 20px;
|
||||
font-size: 48px;
|
||||
}
|
||||
h1 + p.lead {
|
||||
margin-top: 0;
|
||||
}
|
||||
h1, h2, h3, h4, h5 {
|
||||
color: white;
|
||||
letter-spacing: -.025em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h2.action {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: #FAB795;
|
||||
}
|
||||
.text-tertiary {
|
||||
color: #B877DB;
|
||||
}
|
||||
|
||||
strong.logo {
|
||||
color: #B877DB
|
||||
}
|
||||
|
||||
p.lead {
|
||||
color: #09F7A0;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
pre code {
|
||||
font-size: 32px;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #B877DB;
|
||||
cursor: pointer;
|
||||
}
|
||||
button {
|
||||
font-size: 24px;
|
||||
padding: 7.5px;
|
||||
border-radius: 10px;
|
||||
color: white !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: #25B2BC;
|
||||
font-size: larger;
|
||||
|
||||
padding: 15px;
|
||||
border: 1px solid cyan;
|
||||
border-radius: 5px;
|
||||
background-color: #232530;
|
||||
display: inline-block;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.cool {
|
||||
color: #576067;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.trivia {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding-bottom: 25px;
|
||||
}
|
32
backend/src/api/modules/email/templates/forgot-password.pug
Normal file
@ -0,0 +1,32 @@
|
||||
head
|
||||
include ./includes.pug
|
||||
|
||||
style
|
||||
include email.css
|
||||
body(style='background-color: #2E303E')
|
||||
- const url = `${process.env.WEBSITE_URL}/login?code=${code}&redirect=/channels/@me/settings/account`;
|
||||
header
|
||||
h1.logo
|
||||
span accord
|
||||
span.text-tertiary .
|
||||
span.text-secondary app
|
||||
h2 Hello #{user.username}!
|
||||
p.lead Thank you for choosing us.
|
||||
|
||||
section.body
|
||||
div.summary A password reset was requested.
|
||||
|
||||
h2.action Here is your new password...
|
||||
p.lead You can change this at any time, in your user settings.
|
||||
pre
|
||||
code(style='background-color: #232530') #{code}
|
||||
|
||||
a(href=url)
|
||||
button(style='background-color: #E95678') Confirm Reset
|
||||
|
||||
div.trivia
|
||||
p This request expires in #{expiresIn / 60 / 1000} minutes.
|
||||
|
||||
footer
|
||||
hr
|
||||
span accord.app
|
2
backend/src/api/modules/email/templates/includes.pug
Normal file
@ -0,0 +1,2 @@
|
||||
- var greetings = ['Hola', 'Hello', 'Hi', 'Hey', 'Hello there'];
|
||||
- var i = Math.floor(Math.random() * greetings.length);
|
24
backend/src/api/modules/email/templates/verify-email.pug
Normal file
@ -0,0 +1,24 @@
|
||||
head
|
||||
include ./includes.pug
|
||||
|
||||
style
|
||||
include email.css
|
||||
body(style='background-color: #2E303E')
|
||||
header
|
||||
h1 #{greetings[i]}, #{user.username}!
|
||||
p.lead Thank you for choosing accord.app.
|
||||
|
||||
- var link = process.env.WEBSITE_URL + '/login?code=' + code;
|
||||
section.body
|
||||
div
|
||||
span.summary A request was sent to verify this email.
|
||||
|
||||
h2 #[a(href=link) Complete Verification]
|
||||
|
||||
p This request expires in #{expiresIn / 60 / 60 / 1000} hours.
|
||||
p Full Link - #[a(href=link) #{link}].
|
||||
p If this was not you, please contact support.
|
||||
|
||||
footer
|
||||
hr
|
||||
span accord.app
|
23
backend/src/api/modules/email/templates/verify.pug
Normal file
@ -0,0 +1,23 @@
|
||||
head
|
||||
include ./includes.pug
|
||||
|
||||
style
|
||||
include email.css
|
||||
body(style='background-color: #2E303E')
|
||||
- const url = `${process.env.WEBSITE_URL}/login?code=${code}`;
|
||||
header
|
||||
h1 Hello #{user.username}!
|
||||
p.lead Thank you for choosing accord.app.
|
||||
|
||||
section.body
|
||||
div
|
||||
span.summary A verification code has been requested to login to your account.
|
||||
|
||||
h2 Here it is... #[code.cool #{code}]
|
||||
|
||||
p This code expires in #{expiresIn / 60 / 1000} minutes.
|
||||
p You can use it to login with 2FA. #[br] Or #[a(href=url) click here] to login automatically.
|
||||
|
||||
footer
|
||||
hr
|
||||
span accord.app
|
45
backend/src/api/modules/email/verification.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { generateInviteCode } from '../../../data/models/invite';
|
||||
|
||||
export class Verification {
|
||||
private codes = new Map<string, VerifyCode>();
|
||||
|
||||
public create(email: string, type: VerifyCode['type'], options?: EmailOptions) {
|
||||
options = {
|
||||
...options,
|
||||
codeLength: 16,
|
||||
expiresIn: 5 * 60 * 1000,
|
||||
};
|
||||
|
||||
this.codes.delete(email);
|
||||
|
||||
const value = generateInviteCode(options.codeLength);
|
||||
this.codes.set(email, { type, value });
|
||||
|
||||
setTimeout(() => this.codes.delete(email), options.expiresIn);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public get(code: string) {
|
||||
return this.codes.get(code);
|
||||
}
|
||||
public delete(email: string) {
|
||||
this.codes.delete(email);
|
||||
}
|
||||
|
||||
public getEmailFromCode(code: string) {
|
||||
return Array
|
||||
.from(this.codes.entries())
|
||||
.find(([k,v]) => v.value === code)?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
interface VerifyCode {
|
||||
type: 'LOGIN' | 'VERIFY_EMAIL' | 'FORGOT_PASSWORD';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EmailOptions {
|
||||
codeLength?: number;
|
||||
expiresIn?: number;
|
||||
}
|
82
backend/src/api/modules/middleware.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { PermissionTypes } from '../../data/types/entity-types';
|
||||
import Guilds from '../../data/guilds';
|
||||
import { Guild, GuildDocument } from '../../data/models/guild';
|
||||
import Roles from '../../data/roles';
|
||||
import Users from '../../data/users';
|
||||
import Deps from '../../utils/deps';
|
||||
import { User } from '../../data/models/user';
|
||||
import { APIError } from './api-error';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
const guilds = Deps.get<Guilds>(Guilds);
|
||||
const roles = Deps.get<Roles>(Roles);
|
||||
const users = Deps.get<Users>(Users);
|
||||
|
||||
export async function fullyUpdateUser(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const key = req.get('Authorization') as string;
|
||||
const id = users.idFromAuth(key);
|
||||
|
||||
res.locals.user = await users.getSelf(id);
|
||||
} finally {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
export async function updateUser(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const key = req.get('Authorization') as string;
|
||||
const id = users.idFromAuth(key);
|
||||
|
||||
res.locals.user = await users.getSelf(id, false);
|
||||
} finally {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUsername(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.body.username) {
|
||||
const user = await User.findOne({ email: req.body.email });
|
||||
req.body.username = user?.username;
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
export function validateUser(req: Request, res: Response, next: NextFunction) {
|
||||
if (res.locals.user)
|
||||
return next();
|
||||
throw new APIError(401, 'User not logged in');
|
||||
}
|
||||
|
||||
export async function updateGuild(req: Request, res: Response, next: NextFunction) {
|
||||
res.locals.guild = await guilds.get(req.params.id);
|
||||
return next();
|
||||
}
|
||||
|
||||
export async function validateGuildExists(req: Request, res: Response, next: NextFunction) {
|
||||
const exists = await Guild.exists({ _id: req.params.id });
|
||||
return (exists)
|
||||
? next()
|
||||
: res.status(404).json({ message: 'Guild does not exist' });
|
||||
}
|
||||
|
||||
export async function validateGuildOwner(req: Request, res: Response, next: NextFunction) {
|
||||
const userOwnsGuild = res.locals.guild.ownerId === res.locals.user.id;
|
||||
if (userOwnsGuild)
|
||||
return next();
|
||||
throw new APIError(401, 'You do not own this guild!');
|
||||
}
|
||||
|
||||
export function validateHasPermission(permission: PermissionTypes.Permission) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const guild: GuildDocument = res.locals.guild;
|
||||
const member = guild.members.find(m => m.userId === res.locals.user.id);
|
||||
if (!member)
|
||||
throw new APIError(401, 'You are not a guild member');
|
||||
|
||||
const isOwner = guild.ownerId === res.locals.user.id;
|
||||
const hasPerm = await roles.hasPermission(guild, member, permission);
|
||||
if (hasPerm || isOwner) return next();
|
||||
|
||||
throw new APIError(401, 'Missing Permissions');
|
||||
};
|
||||
}
|
9
backend/src/api/modules/rate-limiter.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RateLimitStore from 'rate-limit-mongo';
|
||||
|
||||
export default rateLimit({
|
||||
max: 10 * 1000,
|
||||
message: JSON.stringify({ message: 'You are being rate limited.' }),
|
||||
store: new RateLimitStore({ uri: process.env.MONGO_URI }),
|
||||
windowMs: 10 * 60 * 1000,
|
||||
});
|
97
backend/src/api/modules/ws-guard.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { Guild } from '../../data/models/guild';
|
||||
import { GuildMember } from '../../data/models/guild-member';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import Deps from '../../utils/deps';
|
||||
import { WebSocket } from '../websocket/websocket';
|
||||
import { Socket } from 'socket.io';
|
||||
import Channels from '../../data/channels';
|
||||
import Roles from '../../data/roles';
|
||||
import { Lean, PermissionTypes } from '../../data/types/entity-types';
|
||||
import Users from '../../data/users';
|
||||
import Guilds from '../../data/guilds';
|
||||
import GuildMembers from '../../data/guild-members';
|
||||
import { Prohibited } from '../../data/types/ws-types';
|
||||
|
||||
export class WSGuard {
|
||||
constructor(
|
||||
private channels = Deps.get<Channels>(Channels),
|
||||
private guilds = Deps.get<Guilds>(Guilds),
|
||||
private guildMembers = Deps.get<GuildMembers>(GuildMembers),
|
||||
private roles = Deps.get<Roles>(Roles),
|
||||
private users = Deps.get<Users>(Users),
|
||||
private ws = Deps.get<WebSocket>(WebSocket),
|
||||
) {}
|
||||
|
||||
public userId(client: Socket) {
|
||||
return this.ws.sessions.get(client.id) ?? '';
|
||||
}
|
||||
|
||||
public validateIsUser(client: Socket, userId: string) {
|
||||
if (this.userId(client) !== userId)
|
||||
throw new TypeError('Unauthorized');
|
||||
}
|
||||
|
||||
public async validateIsOwner(client: Socket, guildId: string) {
|
||||
const isOwner = await Guild.exists({
|
||||
_id: guildId,
|
||||
ownerId: this.userId(client)
|
||||
});
|
||||
if (!isOwner)
|
||||
throw new TypeError('Only the guild owner can do this');
|
||||
}
|
||||
|
||||
public async canAccessChannel(client: Socket, channelId?: string, withUse = false) {
|
||||
const channel = await this.channels.get(channelId);
|
||||
await this.canAccess(channel, client, withUse);
|
||||
}
|
||||
private async canAccess(channel: Lean.Channel, client: Socket, withUse = false) {
|
||||
const userId = this.userId(client);
|
||||
if (channel.type === 'TEXT') {
|
||||
const perms = (!withUse)
|
||||
? PermissionTypes.Text.READ_MESSAGES
|
||||
: PermissionTypes.Text.READ_MESSAGES | PermissionTypes.Text.SEND_MESSAGES;
|
||||
await this.validateCan(client, channel.guildId, perms);
|
||||
return;
|
||||
} else if (channel.type === 'VOICE') {
|
||||
const perms = (!withUse)
|
||||
? PermissionTypes.Voice.CONNECT
|
||||
: PermissionTypes.Voice.CONNECT | PermissionTypes.Voice.SPEAK;
|
||||
await this.validateCan(client, channel.guildId, perms);
|
||||
return;
|
||||
}
|
||||
const inGroup = channel.memberIds?.includes(userId);
|
||||
if (!inGroup)
|
||||
throw new TypeError('Not DM Member');
|
||||
}
|
||||
|
||||
public async validateCan(client: Socket, guildId: string | undefined, permission: PermissionTypes.Permission) {
|
||||
const userId = this.userId(client);
|
||||
|
||||
const member = await this.guildMembers.getInGuild(guildId, userId);
|
||||
const guild = await this.guilds.get(guildId);
|
||||
|
||||
const can = await this.roles.hasPermission(guild, member, permission)
|
||||
|| guild.ownerId === userId;
|
||||
this.validate(can, permission);
|
||||
}
|
||||
private validate(can: boolean, permission: PermissionTypes.PermissionString) {
|
||||
if (!can)
|
||||
throw new TypeError(`Missing Permissions - ${PermissionTypes.All[permission]}`);
|
||||
}
|
||||
|
||||
public async decodeKey(key: string) {
|
||||
const id = this.users.verifyToken(key);
|
||||
return { id };
|
||||
}
|
||||
|
||||
public validateKeys<K extends keyof typeof Prohibited>(type: K, partial: any) {
|
||||
const contains = this.includesProhibited<K>(partial, type);
|
||||
if (contains)
|
||||
throw new TypeError('Contains readonly values');
|
||||
}
|
||||
|
||||
private includesProhibited<K extends keyof typeof Prohibited>(partial: any, type: K) {
|
||||
const keys = Object.keys(partial);
|
||||
return Prohibited[type].some(k => keys.includes(k));
|
||||
}
|
||||
}
|
9
backend/src/api/routes/api-routes.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.get('/', (req, res) => res.json({ hello: 'earth' }));
|
||||
|
||||
router.post('/error', (req, res) => {
|
||||
res.json({ message: 'Received' });
|
||||
});
|
107
backend/src/api/routes/auth-routes.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { Router } from 'express';
|
||||
import { SelfUserDocument, User } from '../../data/models/user';
|
||||
import passport from 'passport';
|
||||
import Deps from '../../utils/deps';
|
||||
import Users from '../../data/users';
|
||||
import { Verification } from '../modules/email/verification';
|
||||
import { fullyUpdateUser, updateUsername, validateUser } from '../modules/middleware';
|
||||
import { EmailFunctions } from '../modules/email/email-functions';
|
||||
import { APIError } from '../modules/api-error';
|
||||
import { generateInviteCode } from '../../data/models/invite';
|
||||
import { WebSocket } from '../websocket/websocket';
|
||||
import { Args } from '../websocket/ws-events/ws-event';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
const sendEmail = Deps.get<EmailFunctions>(EmailFunctions);
|
||||
const users = Deps.get<Users>(Users);
|
||||
const verification = Deps.get<Verification>(Verification);
|
||||
const ws = Deps.get<WebSocket>(WebSocket);
|
||||
|
||||
router.post('/login',
|
||||
updateUsername,
|
||||
passport.authenticate('local', { failWithError: true }),
|
||||
async (req, res) => {
|
||||
const user = await users.getByUsername(req.body.username);
|
||||
if (!user)
|
||||
throw new APIError(400, 'Invalid credentials');
|
||||
|
||||
if (user.verified) {
|
||||
await sendEmail.verifyCode(user as any);
|
||||
return res.status(200).json({ verify: true });
|
||||
} else if (req.body.email)
|
||||
throw new APIError(400, 'Email is unverified');
|
||||
|
||||
return res.status(200).json(users.createToken(user.id));
|
||||
});
|
||||
|
||||
router.get('/verify-code', async (req, res) => {
|
||||
const email = verification.getEmailFromCode(req.query.code as any);
|
||||
const user = await User.findOne({ email }) as any;
|
||||
if (!email || !user)
|
||||
throw new APIError(400, 'Invalid code');
|
||||
|
||||
verification.delete(email);
|
||||
|
||||
const code = verification.get(req.query.code as string);
|
||||
if (code?.type === 'FORGOT_PASSWORD') {
|
||||
await user.setPassword(req.body.newPassword);
|
||||
await user.save();
|
||||
}
|
||||
res.status(200).json(users.createToken(user.id));
|
||||
});
|
||||
|
||||
router.get('/send-verify-email', async (req, res) => {
|
||||
const email = req.query.email?.toString();
|
||||
if (!email)
|
||||
throw new APIError(400, 'Email not provided');
|
||||
|
||||
if (req.query.type === 'FORGOT_PASSWORD') {
|
||||
const user = await users.getByEmail(email);
|
||||
await sendEmail.forgotPassword(email, user);
|
||||
|
||||
return res.status(200).json({ verify: true });
|
||||
}
|
||||
const key = req.get('Authorization');
|
||||
const user = await users.getSelf(users.idFromAuth(key), false);
|
||||
|
||||
await sendEmail.verifyEmail(email, user);
|
||||
|
||||
user.email = email;
|
||||
await user.save();
|
||||
|
||||
ws.to(user.id)
|
||||
.emit('USER_UPDATE', { partialUser: { email: user.email } });
|
||||
|
||||
return res.status(200).json({ verify: true });
|
||||
});
|
||||
|
||||
router.get('/verify-email', async (req, res) => {
|
||||
const email = verification.getEmailFromCode(req.query.code as string);
|
||||
if (!email)
|
||||
throw new APIError(400, 'Invalid code');
|
||||
|
||||
await User.updateOne(
|
||||
{ email },
|
||||
{ verified: true },
|
||||
{ runValidators: true, context: 'query' },
|
||||
);
|
||||
|
||||
res.redirect(`${process.env.WEBSITE_URL}/channels/@me?success=Successfully verified your email.`);
|
||||
});
|
||||
|
||||
router.post('/change-password', async (req, res) => {
|
||||
const user = await User.findOne({
|
||||
email: req.body.email,
|
||||
verified: true,
|
||||
}) as any;
|
||||
if (!user)
|
||||
throw new APIError(400, 'User Not Found');
|
||||
|
||||
await user.changePassword(req.body.oldPassword, req.body.newPassword);
|
||||
await user.save();
|
||||
|
||||
return res.status(200).json(
|
||||
users.createToken(user.id)
|
||||
);
|
||||
});
|
66
backend/src/api/routes/channel-routes.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { Router } from 'express';
|
||||
import Channels from '../../data/channels';
|
||||
import Messages from '../../data/messages';
|
||||
import { SelfUserDocument } from '../../data/models/user';
|
||||
import Pings from '../../data/pings';
|
||||
import { Lean } from '../../data/types/entity-types';
|
||||
import Deps from '../../utils/deps';
|
||||
import { updateUser, validateUser } from '../modules/middleware';
|
||||
import { WebSocket } from '../websocket/websocket';
|
||||
import { Args } from '../websocket/ws-events/ws-event';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
const channels = Deps.get<Channels>(Channels);
|
||||
const messages = Deps.get<Messages>(Messages);
|
||||
const pings = Deps.get<Pings>(Pings);
|
||||
const ws = Deps.get<WebSocket>(WebSocket);
|
||||
|
||||
router.get('/', updateUser, validateUser, async (req, res) => {
|
||||
const dms: Lean.Channel[] = await channels.getDMChannels(res.locals.user.id);
|
||||
const guildsChannels = await channels.getGuildsChannels(res.locals.user);
|
||||
const all = dms.concat(guildsChannels);
|
||||
|
||||
res.json(all);
|
||||
});
|
||||
|
||||
router.get('/:channelId/messages', updateUser, validateUser, async (req, res) => {
|
||||
const channelId = req.params.channelId;
|
||||
|
||||
const user: SelfUserDocument = res.locals.user;
|
||||
const channelMsgs = (await messages
|
||||
.getChannelMessages(channelId) ?? await messages
|
||||
.getDMChannelMessages(channelId, res.locals.user.id));
|
||||
|
||||
const batchSize = 25;
|
||||
const back = Math.max(channelMsgs.length - parseInt(req.query.back as string)
|
||||
|| batchSize, 0);
|
||||
|
||||
const slicedMsgs = channelMsgs
|
||||
.slice(back)
|
||||
.map(m => {
|
||||
const isIgnored = user.ignored.userIds.includes(m.authorId);
|
||||
if (isIgnored)
|
||||
m.content = 'This user is blocked, and this message content has been hidden.';
|
||||
return m;
|
||||
});
|
||||
|
||||
const index = slicedMsgs.length - 1;
|
||||
const lastMessage = slicedMsgs[index];
|
||||
if (lastMessage) {
|
||||
await pings.markAsRead(user, lastMessage);
|
||||
ws.io
|
||||
.to(user.id)
|
||||
.emit('USER_UPDATE', {
|
||||
partialUser: { lastReadMessages: user.lastReadMessages },
|
||||
} as Args.UserUpdate);
|
||||
}
|
||||
|
||||
res.json(slicedMsgs);
|
||||
});
|
||||
|
||||
router.get('/:id', updateUser, validateUser, async (req, res) => {
|
||||
const channel = await channels.get(req.params.id);
|
||||
res.json(channel);
|
||||
});
|
||||
|
81
backend/src/api/routes/dev-routes.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { Router } from 'express';
|
||||
import { Application } from '../../data/models/application';
|
||||
import { generateInviteCode } from '../../data/models/invite';
|
||||
import Users from '../../data/users';
|
||||
import Deps from '../../utils/deps';
|
||||
import { fullyUpdateUser, validateUser } from '../modules/middleware';
|
||||
import { WSGuard } from '../modules/ws-guard';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
const users = Deps.get<Users>(Users);
|
||||
const guard = Deps.get<WSGuard>(WSGuard);
|
||||
|
||||
router.get('/apps', async (req, res) => {
|
||||
const start = parseInt(req.query.start as string);
|
||||
const end = parseInt(req.query.end as string);
|
||||
|
||||
const apps = (await Application
|
||||
.find()
|
||||
.select('-token')
|
||||
.populate('user')
|
||||
.populate('owner')
|
||||
.exec())
|
||||
.slice(start || 0, end || 25);
|
||||
|
||||
res.json(apps);
|
||||
});
|
||||
|
||||
router.use(fullyUpdateUser, validateUser);
|
||||
|
||||
router.get('/apps/user', async (req, res) => {
|
||||
const apps = await Application.find({ owner: res.locals.user });
|
||||
res.json(apps);
|
||||
});
|
||||
|
||||
router.get('/apps/new', async (req, res) => {
|
||||
const user = res.locals.user;
|
||||
const count = await Application.countDocuments({ owner: user });
|
||||
const maxApps = 16;
|
||||
if (count >= maxApps)
|
||||
return res.status(400).json({ message: 'Too many apps' });
|
||||
|
||||
const app = new Application({ owner: user.id as any });
|
||||
app.user = (await users.create(app.name, generateInviteCode(), true)).id as any;
|
||||
app.token = users.createToken(user.id, false);
|
||||
await app.save();
|
||||
|
||||
res.json(app);
|
||||
});
|
||||
|
||||
router.get('/apps/:id', async (req, res) => {
|
||||
const app = await Application
|
||||
.findById(req.params.id)
|
||||
.populate('user')
|
||||
.exec();
|
||||
|
||||
return (app?.owner !== res.locals.user?.id)
|
||||
? res.status(403).json({ message: 'Forbidden' })
|
||||
: res.json(app);
|
||||
});
|
||||
|
||||
router.patch('/apps/:id', async (req, res) => {
|
||||
const app = await Application.findById(req.params.id);
|
||||
if (!app || app.owner !== res.locals.user.id)
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
|
||||
guard.validateKeys('app', req.body);
|
||||
await app.update(req.body, { runValidators: true });
|
||||
res.json(app);
|
||||
});
|
||||
|
||||
router.get('/apps/:id/regen-token', async (req, res) => {
|
||||
const app = await Application.findById(req.params.id);
|
||||
if (!app || app.owner !== res.locals.user.id)
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
|
||||
app.token = users.createToken(app.user as any, false);
|
||||
await app.save();
|
||||
|
||||
res.json(app.token);
|
||||
});
|
47
backend/src/api/routes/guilds-routes.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Router } from 'express';
|
||||
import Deps from '../../utils/deps';
|
||||
import { updateGuild, fullyUpdateUser, validateHasPermission, validateUser } from '../modules/middleware';
|
||||
import Users from '../../data/users';
|
||||
import Guilds from '../../data/guilds';
|
||||
import { WebSocket } from '../websocket/websocket';
|
||||
import { Args } from '../websocket/ws-events/ws-event';
|
||||
import { PermissionTypes } from '../../data/types/entity-types';
|
||||
import GuildMembers from '../../data/guild-members';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
const members = Deps.get<GuildMembers>(GuildMembers);
|
||||
const guilds = Deps.get<Guilds>(Guilds);
|
||||
const users = Deps.get<Users>(Users);
|
||||
const ws = Deps.get<WebSocket>(WebSocket);
|
||||
|
||||
router.get('/', fullyUpdateUser, validateUser, async (req, res) => {
|
||||
const user = await users.getSelf(res.locals.user.id, true);
|
||||
res.json(user.guilds);
|
||||
});
|
||||
|
||||
router.get('/:id/authorize/:botId',
|
||||
fullyUpdateUser, validateUser, updateGuild,
|
||||
validateHasPermission(PermissionTypes.General.MANAGE_GUILD),
|
||||
async (req, res) => {
|
||||
const guild = res.locals.guild;
|
||||
const bot = await users.get(req.params.botId);
|
||||
const member = await members.create(guild, bot);
|
||||
|
||||
ws.io
|
||||
.to(guild.id)
|
||||
.emit('GUILD_MEMBER_ADD', { guildId: guild.id, member } as Args.GuildMemberAdd);
|
||||
ws.io
|
||||
.to(bot.id)
|
||||
.emit('GUILD_JOIN', { guild } as Args.GuildJoin);
|
||||
|
||||
res.json({ message: 'Success' });
|
||||
});
|
||||
|
||||
router.get('/:id/invites',
|
||||
fullyUpdateUser, validateUser, updateGuild,
|
||||
validateHasPermission(PermissionTypes.General.MANAGE_GUILD),
|
||||
async (req, res) => {
|
||||
const invites = await guilds.invites(req.params.id);
|
||||
res.json(invites);
|
||||
});
|
11
backend/src/api/routes/invites-routes.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import Invites from '../../data/invites';
|
||||
import Deps from '../../utils/deps';
|
||||
|
||||
export const router = Router();
|
||||
const invites = Deps.get<Invites>(Invites);
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const invite = await invites.get(req.params.id);
|
||||
res.json(invite);
|
||||
});
|
82
backend/src/api/routes/users-routes.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Router } from 'express';
|
||||
import { User } from '../../data/models/user';
|
||||
import Users from '../../data/users';
|
||||
import Deps from '../../utils/deps';
|
||||
import { fullyUpdateUser, updateUser, validateUser } from '../modules/middleware';
|
||||
import Channels from '../../data/channels';
|
||||
import { SystemBot } from '../../system/bot';
|
||||
import { generateInviteCode } from '../../data/models/invite';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
const bot = Deps.get<SystemBot>(SystemBot);
|
||||
const channels = Deps.get<Channels>(Channels);
|
||||
const users = Deps.get<Users>(Users);
|
||||
|
||||
router.get('/', updateUser, validateUser, async (req, res) => {
|
||||
const knownUsers = await users.getKnown(res.locals.user.id);
|
||||
res.json(knownUsers);
|
||||
});
|
||||
|
||||
router.delete('/:id', updateUser, validateUser, async (req, res) => {
|
||||
const user = res.locals.user;
|
||||
user.username = `deleted-user-${generateInviteCode(6)}`;
|
||||
delete user.salt;
|
||||
delete user.hash;
|
||||
await user.save();
|
||||
|
||||
res.status(201).json({ message: 'Modified' });
|
||||
});
|
||||
|
||||
router.get('/check-username', async (req, res) => {
|
||||
const username = req.query.value?.toString().toLowerCase();
|
||||
const exists = await User.exists({
|
||||
username: {
|
||||
$regex: new RegExp(`^${username}$`, 'i')
|
||||
},
|
||||
});
|
||||
res.json(exists);
|
||||
});
|
||||
|
||||
router.get('/self', fullyUpdateUser, async (req, res) => res.json(res.locals.user));
|
||||
|
||||
router.get('/check-email', async (req, res) => {
|
||||
const email = req.query.value?.toString().toLowerCase();
|
||||
const exists = await User.exists({
|
||||
email: {
|
||||
$regex: new RegExp(`^${email}$`, 'i')
|
||||
},
|
||||
verified: true,
|
||||
});
|
||||
res.json(exists);
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const userCount = await User.countDocuments();
|
||||
if (userCount >= 75)
|
||||
throw new TypeError('Max alpha tester limit reached');
|
||||
|
||||
const user = await users.create(req.body.username, req.body.password);
|
||||
const dm = await channels.createDM(bot.self.id, user.id);
|
||||
await bot.message(dm,
|
||||
'Hello there new user :smile:!\n' +
|
||||
'**Alpha Testing Info** - https://docs.accord.app/legal/alpha'
|
||||
);
|
||||
|
||||
res.status(201).json(users.createToken(user.id));
|
||||
});
|
||||
|
||||
router.get('/dm-channels', fullyUpdateUser, async (req, res) => {
|
||||
const dmChannels = await channels.getDMChannels(res.locals.user.id);
|
||||
res.json(dmChannels);
|
||||
});
|
||||
|
||||
router.get('/bots', async (req, res) => {
|
||||
const bots = await User.find({ bot: true });
|
||||
res.json(bots);
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const user = await users.get(req.params.id);
|
||||
res.json(user);
|
||||
});
|
89
backend/src/api/server.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import 'express-async-errors';
|
||||
|
||||
import bodyParser from 'body-parser';
|
||||
import Log from '../utils/log';
|
||||
import LocalStrategy from 'passport-local';
|
||||
import passport from 'passport';
|
||||
import { router as apiRoutes } from './routes/api-routes';
|
||||
import { router as authRoutes } from './routes/auth-routes';
|
||||
import { router as channelsRoutes } from './routes/channel-routes';
|
||||
import { router as guildsRoutes } from './routes/guilds-routes';
|
||||
import { router as usersRoutes } from './routes/users-routes';
|
||||
import { router as invitesRoutes } from './routes/invites-routes';
|
||||
import { User } from '../data/models/user';
|
||||
import cors from 'cors';
|
||||
import { resolve } from 'path';
|
||||
import Deps from '../utils/deps';
|
||||
import { WebSocket } from './websocket/websocket';
|
||||
import { APIError } from './modules/api-error';
|
||||
import rateLimiter from './modules/rate-limiter';
|
||||
|
||||
export class API {
|
||||
public app = express();
|
||||
private prefix = `/api/v1`;
|
||||
|
||||
constructor(private ws = Deps.get<WebSocket>(WebSocket)) {
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
this.setupErrorHandling();
|
||||
this.serveWebsite();
|
||||
this.listen();
|
||||
}
|
||||
|
||||
private setupMiddleware() {
|
||||
passport.use(new LocalStrategy.Strategy((User as any).authenticate()));
|
||||
passport.serializeUser((User as any).serializeUser());
|
||||
passport.deserializeUser((User as any).deserializeUser());
|
||||
|
||||
this.app.use(bodyParser.json());
|
||||
this.app.use(passport.initialize());
|
||||
this.app.use(cors());
|
||||
this.app.use(rateLimiter);
|
||||
}
|
||||
|
||||
private setupRoutes() {
|
||||
this.app.use(`${this.prefix}`, express.static(resolve('./assets')));
|
||||
this.app.use(`${this.prefix}`, apiRoutes, authRoutes);
|
||||
|
||||
this.app.use(`${this.prefix}/invites`, invitesRoutes);
|
||||
// this.app.use(`${this.prefix}/devs`, devRoutes);
|
||||
this.app.use(`${this.prefix}/channels`, channelsRoutes);
|
||||
this.app.use(`${this.prefix}/guilds`, guildsRoutes);
|
||||
this.app.use(`${this.prefix}/users`, usersRoutes);
|
||||
}
|
||||
|
||||
private setupErrorHandling() {
|
||||
this.app.all(`${this.prefix}/*`, (req, res, next) => next(new APIError(404)));
|
||||
|
||||
this.app.use(`/api`, () => {
|
||||
throw new TypeError('Invalid API version number');
|
||||
});
|
||||
|
||||
this.app.use((error: APIError, req: Request, res: Response, next: NextFunction) => {
|
||||
if (res.headersSent)
|
||||
return next(error);
|
||||
|
||||
const code = error.code || 400;
|
||||
return res
|
||||
.status(code)
|
||||
.json({ message: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
private serveWebsite() {
|
||||
const distPath = resolve('./dist/browser');
|
||||
this.app.use(express.static(distPath));
|
||||
this.app.all('*', (req, res) => res
|
||||
.status(200)
|
||||
.sendFile(`${distPath}/index.html`));
|
||||
}
|
||||
|
||||
private listen() {
|
||||
const port = process.env.PORT || 8080;
|
||||
const server = this.app.listen(port, async () => {
|
||||
Log.info(`API is running on port ${port}`);
|
||||
await this.ws.init(server);
|
||||
});
|
||||
}
|
||||
}
|
18
backend/src/api/websocket/modules/session-manager.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { patterns } from '../../../data/types/entity-types';
|
||||
|
||||
export class SessionManager extends Map<string, string> {
|
||||
public get(key: string): string {
|
||||
const userId = super.get(key);
|
||||
if (!userId)
|
||||
throw new TypeError('User Not Logged In');
|
||||
|
||||
if (!patterns.snowflake.test(userId))
|
||||
throw new TypeError('Spoofed ID Not Allowed');
|
||||
return userId;
|
||||
}
|
||||
|
||||
public userId(client: Socket) {
|
||||
return this.get(client.id);
|
||||
}
|
||||
}
|
49
backend/src/api/websocket/modules/ws-cooldowns.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { WSEventParams } from '../ws-events/ws-event';
|
||||
|
||||
// if bot user -> users should not be too fast
|
||||
// for guild events:
|
||||
// -> separate cooldowns for each guild / room ID
|
||||
|
||||
export class WSCooldowns {
|
||||
public readonly active = new Map<string, EventLog[]>();
|
||||
|
||||
// TODO: handle(userId, eventName, guildId)
|
||||
public handle(userId: string, eventName: keyof WSEventParams) {
|
||||
this.prune(userId);
|
||||
this.add(userId, eventName);
|
||||
|
||||
const clientEvents = this.get(userId).length;
|
||||
const maxEvents = 60;
|
||||
if (clientEvents > maxEvents)
|
||||
throw new TypeError('You are doing too many things at once!');
|
||||
}
|
||||
|
||||
private get(clientId: string) {
|
||||
return this.active.get(clientId)
|
||||
?? this.active
|
||||
.set(clientId, [])
|
||||
.get(clientId) as EventLog[];
|
||||
}
|
||||
|
||||
private add(clientId: string, eventName: keyof WSEventParams) {
|
||||
this
|
||||
.get(clientId)
|
||||
.push({ eventName, timestamp: new Date().getTime() });
|
||||
}
|
||||
|
||||
private prune(clientId: string) {
|
||||
const logs = this.get(clientId);
|
||||
const lastLog = logs[logs.length - 1];
|
||||
|
||||
const timeToDelete = 60 * 1000;
|
||||
const expirationMs = lastLog?.timestamp + timeToDelete;
|
||||
if (new Date().getTime() < expirationMs) return;
|
||||
|
||||
this.active.delete(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
interface EventLog {
|
||||
eventName: keyof WSEventParams;
|
||||
timestamp: number;
|
||||
}
|
54
backend/src/api/websocket/modules/ws-rooms.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { SelfUserDocument } from '../../../data/models/user';
|
||||
import { Lean } from '../../../data/types/entity-types';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
|
||||
export class WSRooms {
|
||||
constructor(
|
||||
private users = Deps.get<Users>(Users),
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
) {}
|
||||
|
||||
public async join(client: Socket, user: SelfUserDocument) {
|
||||
const alreadyJoinedRooms = client.rooms.size > 1;
|
||||
if (alreadyJoinedRooms) return;
|
||||
|
||||
await client.join(await this.users.getRoomIds(user));
|
||||
|
||||
await this.joinGuildRooms(user, client);
|
||||
await this.joinDMRooms(user, client);
|
||||
}
|
||||
|
||||
private async joinDMRooms(user: SelfUserDocument, client: Socket) {
|
||||
const dms = await this.users.getDMChannels(user.id);
|
||||
if (!dms) return;
|
||||
|
||||
const ids = dms.map(c => c.id);
|
||||
await client.join(ids);
|
||||
}
|
||||
|
||||
public async joinGuildRooms(user: SelfUserDocument, client: Socket) {
|
||||
if (!user.guilds) return;
|
||||
|
||||
const guildIds = user.guilds.map(g => g.id);
|
||||
await client.join(guildIds);
|
||||
|
||||
const channelIds = await this.getChannelIds(client, user.guilds as any);
|
||||
await client.join(channelIds);
|
||||
}
|
||||
|
||||
private async getChannelIds(client: Socket, guilds: Lean.Guild[]) {
|
||||
const ids: string[] = [];
|
||||
const channelIds = guilds
|
||||
.flatMap(g => g.channels.map(c => c.id));
|
||||
|
||||
for (const id of channelIds)
|
||||
try {
|
||||
await this.guard.canAccessChannel(client, id);
|
||||
ids.push(id);
|
||||
} catch {}
|
||||
return ids;
|
||||
}
|
||||
}
|
72
backend/src/api/websocket/websocket.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { Server } from 'http';
|
||||
import { Server as SocketServer } from 'socket.io';
|
||||
import Log from '../../utils/log';
|
||||
import { WSEvent, WSEventParams } from './ws-events/ws-event';
|
||||
import { resolve } from 'path';
|
||||
import { readdirSync } from 'fs';
|
||||
import { WSCooldowns } from './modules/ws-cooldowns';
|
||||
import Deps from '../../utils/deps';
|
||||
import { SessionManager } from './modules/session-manager';
|
||||
import { WSEventAsyncArgs } from '../../data/types/ws-types';
|
||||
|
||||
export class WebSocket {
|
||||
public events = new Map<keyof WSEventParams, WSEvent<keyof WSEventParams>>();
|
||||
public io: SocketServer;
|
||||
public sessions = new SessionManager();
|
||||
|
||||
public get connectedUserIds() {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
constructor(private cooldowns = Deps.get<WSCooldowns>(WSCooldowns)) {}
|
||||
|
||||
public async init(server: Server) {
|
||||
this.io = new SocketServer(server, {
|
||||
cors: {
|
||||
origin: process.env.WEBSITE_URL,
|
||||
methods: ['GET', 'POST'],
|
||||
allowedHeaders: ['Authorization'],
|
||||
credentials: true,
|
||||
},
|
||||
path: '/ws',
|
||||
serveClient: false,
|
||||
});
|
||||
|
||||
const dir = resolve(`${__dirname}/ws-events`);
|
||||
const files = readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
const Event = require(`./ws-events/${file}`).default;
|
||||
try {
|
||||
const event = new Event();
|
||||
this.events.set(event.on, event);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Log.info(`Loaded ${this.events.size} handlers`, 'ws');
|
||||
|
||||
this.io.on('connection', (client) => {
|
||||
for (const event of this.events.values())
|
||||
client.on(event.on, async (data: any) => {
|
||||
try {
|
||||
await event.invoke.bind(event)(this, client, data);
|
||||
} catch (error) {
|
||||
client.send(`Server error on executing: ${event.on}\n${error.message}`);
|
||||
} finally {
|
||||
try {
|
||||
const userId = this.sessions.userId(client);
|
||||
this.cooldowns.handle(userId, event.on);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Log.info('Started WebSocket', 'ws');
|
||||
}
|
||||
|
||||
public to(...rooms: string[]) {
|
||||
return this.io.to(rooms) as {
|
||||
emit: <K extends keyof WSEventAsyncArgs>(name: K, args: WSEventAsyncArgs[K]) => any,
|
||||
};
|
||||
}
|
||||
}
|
81
backend/src/api/websocket/ws-events/add-friend.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import Channels from '../../../data/channels';
|
||||
import { Channel, DMChannelDocument } from '../../../data/models/channel';
|
||||
import { SelfUserDocument, User, UserDocument } from '../../../data/models/user';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'ADD_FRIEND'> {
|
||||
on = 'ADD_FRIEND' as const;
|
||||
|
||||
constructor(
|
||||
private channels = Deps.get<Channels>(Channels),
|
||||
private users = Deps.get<Users>(Users),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { username }: Params.AddFriend) {
|
||||
const senderId = ws.sessions.userId(client);
|
||||
let sender = await this.users.getSelf(senderId);
|
||||
let friend = await this.users.getByUsername(username);
|
||||
|
||||
if (sender.friendRequestIds.includes(friend.id))
|
||||
throw new TypeError('Friend request already sent');
|
||||
else if (sender.friendIds.includes(friend.id))
|
||||
throw new TypeError('You are already friends');
|
||||
|
||||
const isBlocking = friend.ignored.userIds.includes(sender.id);
|
||||
console.log(friend.ignored.userIds);
|
||||
if (isBlocking)
|
||||
throw new TypeError('This user is blocking you');
|
||||
|
||||
let dmChannel: DMChannelDocument;
|
||||
({ sender, friend, dmChannel } = await this.handle(sender, friend) as any);
|
||||
|
||||
if (dmChannel)
|
||||
await client.join(dmChannel.id);
|
||||
|
||||
ws.io
|
||||
.to(senderId)
|
||||
.to(friend.id)
|
||||
.emit('ADD_FRIEND', {
|
||||
sender: this.users.secure(sender),
|
||||
friend: this.users.secure(friend),
|
||||
} as Args.AddFriend);
|
||||
}
|
||||
|
||||
private async handle(sender: SelfUserDocument, friend: SelfUserDocument): Promise<Args.AddFriend> {
|
||||
if (sender.id === friend.id)
|
||||
throw new TypeError('You cannot add yourself as a friend');
|
||||
|
||||
const hasReturnedRequest = friend.friendRequestIds.includes(sender.id);
|
||||
if (hasReturnedRequest) return {
|
||||
friend: await this.acceptRequest(friend, sender),
|
||||
sender: await this.acceptRequest(sender, friend),
|
||||
dmChannel: await this.channels.getDMByMembers(sender.id, friend.id)
|
||||
?? await this.channels.createDM(sender.id, friend.id),
|
||||
}
|
||||
|
||||
const hasSentRequest = sender.friendRequestIds.includes(friend.id);
|
||||
if (!hasSentRequest)
|
||||
await this.sendRequest(sender, friend);
|
||||
|
||||
return { sender, friend };
|
||||
}
|
||||
|
||||
private async sendRequest(sender: SelfUserDocument, friend: UserDocument) {
|
||||
sender.friendRequestIds.push(friend.id);
|
||||
return sender.save();
|
||||
}
|
||||
|
||||
private async acceptRequest(sender: SelfUserDocument, friend: SelfUserDocument) {
|
||||
const friendExists = sender.friendIds.includes(friend.id);
|
||||
if (friendExists) return friend;
|
||||
|
||||
const index = sender.friendRequestIds.indexOf(friend.id);
|
||||
sender.friendRequestIds.splice(index, 1);
|
||||
sender.friendIds.push(friend.id);
|
||||
return sender.save();
|
||||
}
|
||||
}
|
42
backend/src/api/websocket/ws-events/channel-create.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import { Channel } from '../../../data/models/channel';
|
||||
import { Guild } from '../../../data/models/guild';
|
||||
import { generateSnowflake } from '../../../data/snowflake-entity';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params, WSEventParams } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'CHANNEL_CREATE'> {
|
||||
on = 'CHANNEL_CREATE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard)
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { partialChannel, guildId }: Params.ChannelCreate) {
|
||||
await this.guard.validateCan(client, guildId, PermissionTypes.General.MANAGE_CHANNELS);
|
||||
|
||||
const channel = await Channel.create({
|
||||
_id: generateSnowflake(),
|
||||
name: partialChannel.name,
|
||||
summary: partialChannel.summary,
|
||||
guildId,
|
||||
type: partialChannel.type as any,
|
||||
memberIds: []
|
||||
});
|
||||
|
||||
await Guild.updateOne(
|
||||
{ _id: guildId },
|
||||
{ $push: { channels: channel } },
|
||||
{ runValidators: true },
|
||||
);
|
||||
|
||||
await client.join(channel.id);
|
||||
|
||||
ws.io
|
||||
.to(guildId)
|
||||
.emit('CHANNEL_CREATE', { channel, guildId } as Args.ChannelCreate);
|
||||
}
|
||||
}
|
32
backend/src/api/websocket/ws-events/channel-delete.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import { TextChannelDocument } from '../../../data/models/channel';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
import Channels from '../../../data/channels';
|
||||
|
||||
export default class implements WSEvent<'CHANNEL_DELETE'> {
|
||||
on = 'CHANNEL_DELETE' as const;
|
||||
|
||||
constructor(
|
||||
private channels = Deps.get<Channels>(Channels),
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { channelId }: Params.ChannelDelete) {
|
||||
const channel = await this.channels.get(channelId) as TextChannelDocument;
|
||||
await this.guard.validateCan(client, channel.guildId, PermissionTypes.General.MANAGE_CHANNELS);
|
||||
|
||||
await client.leave(channel.id);
|
||||
await channel.deleteOne();
|
||||
|
||||
ws.io
|
||||
.to(channel.guildId)
|
||||
.emit('CHANNEL_DELETE', {
|
||||
channelId: channel.id,
|
||||
guildId: channel.guildId,
|
||||
} as Args.ChannelDelete);
|
||||
}
|
||||
}
|
44
backend/src/api/websocket/ws-events/disconnect.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import Channels from '../../../data/channels';
|
||||
import { Channel } from '../../../data/models/channel';
|
||||
import { UserDocument } from '../../../data/models/user';
|
||||
import { Lean } from '../../../data/types/entity-types';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'disconnect'> {
|
||||
on = 'disconnect' as const;
|
||||
|
||||
constructor(
|
||||
private users = Deps.get<Users>(Users),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket) {
|
||||
const userId = ws.sessions.get(client.id);
|
||||
const user = await this.users.get(userId);
|
||||
|
||||
ws.sessions.delete(client.id);
|
||||
await this.setOfflineStatus(ws, client, user);
|
||||
|
||||
client.rooms.clear();
|
||||
}
|
||||
|
||||
public async setOfflineStatus(ws: WebSocket, client: Socket, user: UserDocument) {
|
||||
const userConnected = ws.connectedUserIds.includes(user.id);
|
||||
if (userConnected) return;
|
||||
|
||||
user.status = 'OFFLINE';
|
||||
await user.save();
|
||||
|
||||
const guildIds = user.guilds.map(g => g.id);
|
||||
|
||||
ws.io
|
||||
.to(guildIds.concat(user.friendIds))
|
||||
.emit('PRESENCE_UPDATE', {
|
||||
userId: user.id,
|
||||
status: user.status
|
||||
} as Args.PresenceUpdate);
|
||||
}
|
||||
}
|
31
backend/src/api/websocket/ws-events/guild-create.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import Guilds from '../../../data/guilds';
|
||||
import { User } from '../../../data/models/user';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSRooms } from '../modules/ws-rooms';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Params } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'GUILD_CREATE'> {
|
||||
on = 'GUILD_CREATE' as const;
|
||||
|
||||
constructor(
|
||||
private guilds = Deps.get<Guilds>(Guilds),
|
||||
private rooms = Deps.get<WSRooms>(WSRooms),
|
||||
private users = Deps.get<Users>(Users),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { partialGuild }: Params.GuildCreate) {
|
||||
const userId = ws.sessions.userId(client);
|
||||
|
||||
const user = await this.users.getSelf(userId, true);
|
||||
const guild = await this.guilds.create(partialGuild.name as any, user);
|
||||
|
||||
await this.rooms.joinGuildRooms(user, client);
|
||||
|
||||
ws.io
|
||||
.to(userId)
|
||||
.emit('GUILD_JOIN', { guild });
|
||||
}
|
||||
}
|
42
backend/src/api/websocket/ws-events/guild-delete.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { Channel } from '../../../data/models/channel';
|
||||
import { Guild } from '../../../data/models/guild';
|
||||
import { GuildMember } from '../../../data/models/guild-member';
|
||||
import { Invite } from '../../../data/models/invite';
|
||||
import { Message } from '../../../data/models/message';
|
||||
import { Role } from '../../../data/models/role';
|
||||
import { User } from '../../../data/models/user';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Params, Args } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'GUILD_DELETE'> {
|
||||
on = 'GUILD_DELETE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { guildId }: Params.GuildDelete) {
|
||||
await this.guard.validateIsOwner(client, guildId);
|
||||
|
||||
await User.updateMany(
|
||||
{ guilds: guildId },
|
||||
{ $pull: { guilds: guildId } },
|
||||
);
|
||||
|
||||
const guildChannels = await Channel.find({ guildId });
|
||||
await Message.deleteMany({ channelId: guildChannels.map(c => c.id) as any })
|
||||
|
||||
await Guild.deleteOne({ _id: guildId });
|
||||
await GuildMember.deleteMany({ guildId });
|
||||
await Invite.deleteMany({ guildId });
|
||||
await Role.deleteMany({ guildId });
|
||||
await Channel.deleteMany({ guildId });
|
||||
|
||||
ws.io
|
||||
.to(guildId)
|
||||
.emit('GUILD_DELETE', { guildId } as Args.GuildDelete);
|
||||
}
|
||||
}
|
60
backend/src/api/websocket/ws-events/guild-member-add.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import GuildMembers from '../../../data/guild-members';
|
||||
import Guilds from '../../../data/guilds';
|
||||
import Invites from '../../../data/invites';
|
||||
import { InviteDocument } from '../../../data/models/invite';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSRooms } from '../modules/ws-rooms';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'GUILD_MEMBER_ADD'> {
|
||||
on = 'GUILD_MEMBER_ADD' as const;
|
||||
|
||||
constructor(
|
||||
private guilds = Deps.get<Guilds>(Guilds),
|
||||
private members = Deps.get<GuildMembers>(GuildMembers),
|
||||
private invites = Deps.get<Invites>(Invites),
|
||||
private rooms = Deps.get<WSRooms>(WSRooms),
|
||||
private users = Deps.get<Users>(Users),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { inviteCode }: Params.GuildMemberAdd) {
|
||||
const invite = await this.invites.get(inviteCode);
|
||||
const guild = await this.guilds.get(invite.guildId);
|
||||
const userId = ws.sessions.userId(client);
|
||||
|
||||
const inGuild = guild.members.some(m => m.userId === userId);
|
||||
if (inGuild)
|
||||
throw new TypeError('User already in guild');
|
||||
|
||||
const user = await this.users.getSelf(userId);
|
||||
if (inviteCode && user.bot)
|
||||
throw new TypeError('Bot users cannot accept invites');
|
||||
|
||||
await this.handleInvite(invite);
|
||||
const member = await this.members.create(guild, user);
|
||||
await this.rooms.joinGuildRooms(user, client);
|
||||
|
||||
ws.io
|
||||
.to(guild.id)
|
||||
.emit('GUILD_MEMBER_ADD', { guildId: guild.id, member } as Args.GuildMemberAdd);
|
||||
|
||||
ws.io
|
||||
.to(user.id)
|
||||
.emit('GUILD_JOIN', { guild } as Args.GuildJoin);
|
||||
}
|
||||
|
||||
private async handleInvite(invite: InviteDocument) {
|
||||
const inviteExpired = Number(invite.options?.expiresAt?.getTime()) < new Date().getTime();
|
||||
if (inviteExpired)
|
||||
throw new TypeError('Invite expired');
|
||||
|
||||
invite.uses++;
|
||||
|
||||
(invite.options?.maxUses && invite.uses >= invite.options.maxUses)
|
||||
? await invite.deleteOne()
|
||||
: await invite.save();
|
||||
}
|
||||
}
|
56
backend/src/api/websocket/ws-events/guild-member-remove.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import Guilds from '../../../data/guilds';
|
||||
import { GuildDocument } from '../../../data/models/guild';
|
||||
import { GuildMember } from '../../../data/models/guild-member';
|
||||
import { User } from '../../../data/models/user';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'GUILD_MEMBER_REMOVE'> {
|
||||
on = 'GUILD_MEMBER_REMOVE' as const;
|
||||
|
||||
constructor(
|
||||
private guilds = Deps.get<Guilds>(Guilds),
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { guildId, memberId }: Params.GuildMemberRemove) {
|
||||
const guild = await this.guilds.get(guildId, true);
|
||||
const member = guild.members.find(m => m.id === memberId);
|
||||
if (!member)
|
||||
throw new TypeError('Member does not exist');
|
||||
|
||||
const selfUserId = ws.sessions.get(client.id);
|
||||
if (guild.ownerId === member.userId)
|
||||
throw new TypeError('You cannot leave a guild you own');
|
||||
|
||||
else if (selfUserId !== member.userId)
|
||||
await this.guard.validateCan(client, guildId, PermissionTypes.General.KICK_MEMBERS);
|
||||
|
||||
const user = await User.findById(member.userId) as any;
|
||||
const index = user.guilds.indexOf(guildId);
|
||||
user.guilds.splice(index, 1);
|
||||
await user.save();
|
||||
|
||||
await GuildMember.deleteOne({ _id: memberId });
|
||||
|
||||
await this.leaveGuildRooms(client, guild);
|
||||
|
||||
ws.io
|
||||
.to(member.userId)
|
||||
.emit('GUILD_LEAVE', { guildId } as Args.GuildLeave);
|
||||
|
||||
ws.io
|
||||
.to(guildId)
|
||||
.emit('GUILD_MEMBER_REMOVE', { guildId, memberId: member.id } as Args.GuildMemberRemove);
|
||||
}
|
||||
|
||||
private async leaveGuildRooms(client: Socket, guild: GuildDocument) {
|
||||
await client.leave(guild.id);
|
||||
for (const channel of guild.channels)
|
||||
await client.leave(channel.id);
|
||||
}
|
||||
}
|
57
backend/src/api/websocket/ws-events/guild-member-update.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import GuildMembers from '../../../data/guild-members';
|
||||
import Guilds from '../../../data/guilds';
|
||||
import { GuildMember } from '../../../data/models/guild-member';
|
||||
import { Role } from '../../../data/models/role';
|
||||
import Roles from '../../../data/roles';
|
||||
import { Lean, PermissionTypes } from '../../../data/types/entity-types';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { array } from '../../../utils/utils';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'GUILD_MEMBER_UPDATE'> {
|
||||
on = 'GUILD_MEMBER_UPDATE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
private guilds = Deps.get<Guilds>(Guilds),
|
||||
private guildMembers = Deps.get<GuildMembers>(GuildMembers),
|
||||
private roles = Deps.get<Roles>(Roles),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { memberId, partialMember }: Params.GuildMemberUpdate) {
|
||||
const member = await this.guildMembers.get(memberId);
|
||||
|
||||
const selfUserId = ws.sessions.userId(client);
|
||||
const selfMember = await this.guildMembers.getInGuild(member.guildId, selfUserId);
|
||||
|
||||
await this.guard.validateCan(client, selfMember.guildId, PermissionTypes.General.MANAGE_ROLES);
|
||||
this.guard.validateKeys('guildMember', partialMember);
|
||||
|
||||
const guild = await this.guilds.get(member.guildId);
|
||||
const selfIsHigher = await this.roles.isHigher(guild, selfMember, member.roleIds);
|
||||
|
||||
const isSelf = selfMember.id === memberId;
|
||||
if (!isSelf && !selfIsHigher)
|
||||
throw new TypeError('Member has higher roles');
|
||||
|
||||
const everyoneRole = guild.roles.find(r => r.name === '@everyone') as Lean.Role;
|
||||
await member.updateOne({
|
||||
...partialMember,
|
||||
roleIds: [everyoneRole.id].concat(partialMember.roleIds ?? []),
|
||||
},
|
||||
{ runValidators: true },
|
||||
);
|
||||
|
||||
ws.io
|
||||
.to(member.guildId)
|
||||
.emit('GUILD_MEMBER_UPDATE', {
|
||||
guildId: member.guildId,
|
||||
memberId,
|
||||
partialMember,
|
||||
} as Args.GuildMemberUpdate);
|
||||
}
|
||||
}
|
34
backend/src/api/websocket/ws-events/guild-role-create.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import { Guild } from '../../../data/models/guild';
|
||||
import { Role } from '../../../data/models/role';
|
||||
import { generateSnowflake } from '../../../data/snowflake-entity';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
import Roles from '../../../data/roles';
|
||||
|
||||
export default class implements WSEvent<'GUILD_ROLE_CREATE'> {
|
||||
on = 'GUILD_ROLE_CREATE' as const;
|
||||
|
||||
constructor(
|
||||
private roles = Deps.get<Roles>(Roles),
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { guildId, partialRole }: Params.GuildRoleCreate) {
|
||||
await this.guard.validateCan(client, guildId, PermissionTypes.General.MANAGE_ROLES);
|
||||
|
||||
const role = await this.roles.create(guildId, partialRole);
|
||||
await Guild.updateOne(
|
||||
{ _id: guildId },
|
||||
{ $push: { roles: role } },
|
||||
{ runValidators: true },
|
||||
);
|
||||
|
||||
ws.io
|
||||
.to(guildId)
|
||||
.emit('GUILD_ROLE_CREATE', { guildId, role } as Args.GuildRoleCreate);
|
||||
}
|
||||
}
|
31
backend/src/api/websocket/ws-events/guild-role-delete.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import { Role } from '../../../data/models/role';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
import { GuildMember } from '../../../data/models/guild-member';
|
||||
|
||||
export default class implements WSEvent<'GUILD_ROLE_DELETE'> {
|
||||
on = 'GUILD_ROLE_DELETE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard)
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { roleId, guildId }: Params.GuildRoleDelete) {
|
||||
await this.guard.validateCan(client, guildId, PermissionTypes.General.MANAGE_ROLES);
|
||||
|
||||
await Role.deleteOne({ _id: roleId });
|
||||
await GuildMember.updateMany(
|
||||
{ guildId },
|
||||
{ $pull: { roleIds: roleId } },
|
||||
{ runValidators: true },
|
||||
);
|
||||
|
||||
ws.io
|
||||
.to(guildId)
|
||||
.emit('GUILD_ROLE_DELETE', { guildId, roleId } as Args.GuildRoleDelete);
|
||||
}
|
||||
}
|
30
backend/src/api/websocket/ws-events/guild-role-update.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import { Role } from '../../../data/models/role';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params, WSEventParams } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'GUILD_ROLE_UPDATE'> {
|
||||
on = 'GUILD_ROLE_UPDATE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { roleId, partialRole, guildId }: Params.GuildRoleUpdate) {
|
||||
await this.guard.validateCan(client, guildId, PermissionTypes.General.MANAGE_ROLES);
|
||||
this.guard.validateKeys('role', partialRole);
|
||||
|
||||
await Role.updateOne(
|
||||
{ _id: roleId },
|
||||
partialRole as any,
|
||||
{ runValidators: true },
|
||||
);
|
||||
|
||||
ws.io
|
||||
.to(guildId)
|
||||
.emit('GUILD_ROLE_UPDATE', { guildId, partialRole } as Args.GuildRoleUpdate);
|
||||
}
|
||||
}
|
56
backend/src/api/websocket/ws-events/guild-update.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { Lean, PermissionTypes } from '../../../data/types/entity-types';
|
||||
import { Partial } from '../../../data/types/ws-types';
|
||||
import { Guild } from '../../../data/models/guild';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
import Guilds from '../../../data/guilds';
|
||||
|
||||
export default class implements WSEvent<'GUILD_UPDATE'> {
|
||||
on = 'GUILD_UPDATE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
private guilds = Deps.get<Guilds>(Guilds),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { guildId, partialGuild }: Params.GuildUpdate) {
|
||||
await this.guard.validateCan(client, guildId, PermissionTypes.General.MANAGE_GUILD);
|
||||
|
||||
this.guard.validateKeys('guild', partialGuild);
|
||||
|
||||
const guild = await this.guilds.get(guildId);
|
||||
this.validateChannels(guild, partialGuild);
|
||||
this.validateRoles(guild, partialGuild);
|
||||
|
||||
await Guild.updateOne(
|
||||
{ _id: guildId },
|
||||
partialGuild as any,
|
||||
{ runValidators: true },
|
||||
);
|
||||
|
||||
ws.io
|
||||
.to(guildId)
|
||||
.emit('GUILD_UPDATE', { guildId, partialGuild } as Args.GuildUpdate);
|
||||
}
|
||||
|
||||
private validateChannels(guild: Lean.Guild, partialGuild: Partial.Guild) {
|
||||
if (!partialGuild.channels) return;
|
||||
if (guild.channels.length !== partialGuild.channels.length)
|
||||
throw new TypeError('Cannot add or remove channels this way');
|
||||
}
|
||||
|
||||
private validateRoles(guild: Lean.Guild, partialGuild: Partial.Guild) {
|
||||
if (!partialGuild.roles) return;
|
||||
if (guild.roles.length !== partialGuild.roles.length)
|
||||
throw new TypeError('Cannot add or remove roles this way');
|
||||
|
||||
const oldEveryoneRoleId = guild.roles[0].id;
|
||||
const newEveryoneRoleId: string = partialGuild?.roles?.[0] as any;
|
||||
|
||||
if (oldEveryoneRoleId !== newEveryoneRoleId)
|
||||
throw new TypeError('You cannot reorder the @everyone role');
|
||||
}
|
||||
}
|
29
backend/src/api/websocket/ws-events/invite-create.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import Invites from '../../../data/invites';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params, WSEventParams } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'INVITE_CREATE'> {
|
||||
on = 'INVITE_CREATE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
private invites = Deps.get<Invites>(Invites),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, params: Params.InviteCreate) {
|
||||
await this.guard.validateCan(client, params.guildId, PermissionTypes.General.CREATE_INVITE);
|
||||
|
||||
const invite = await this.invites.create(params, ws.sessions.userId(client));
|
||||
|
||||
ws.io
|
||||
.to(params.guildId)
|
||||
.emit('INVITE_CREATE', {
|
||||
guildId: params.guildId,
|
||||
invite,
|
||||
} as Args.InviteCreate);
|
||||
}
|
||||
}
|
31
backend/src/api/websocket/ws-events/invite-delete.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import Invites from '../../../data/invites';
|
||||
import { Guild } from '../../../data/models/guild';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params, WSEventParams } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'INVITE_DELETE'> {
|
||||
on = 'INVITE_DELETE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
private invites = Deps.get<Invites>(Invites),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { inviteCode }: Params.InviteDelete) {
|
||||
const invite = await this.invites.get(inviteCode);
|
||||
await this.guard.validateCan(client, invite.guildId, PermissionTypes.General.MANAGE_GUILD);
|
||||
|
||||
await invite.deleteOne();
|
||||
|
||||
ws.io
|
||||
.to(invite.guildId)
|
||||
.emit('INVITE_DELETE', {
|
||||
guildId: invite.guildId,
|
||||
inviteCode,
|
||||
} as Args.InviteDelete);
|
||||
}
|
||||
}
|
49
backend/src/api/websocket/ws-events/message-create.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { Message } from '../../../data/models/message';
|
||||
import { generateSnowflake } from '../../../data/snowflake-entity';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import Messages from '../../../data/messages';
|
||||
import Pings from '../../../data/pings';
|
||||
import Channels from '../../../data/channels';
|
||||
import Users from '../../../data/users';
|
||||
import { Channel } from '../../../data/models/channel';
|
||||
import { User } from '../../../data/models/user';
|
||||
|
||||
export default class implements WSEvent<'MESSAGE_CREATE'> {
|
||||
on = 'MESSAGE_CREATE' as const;
|
||||
|
||||
constructor(
|
||||
private messages = Deps.get<Messages>(Messages),
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
private users = Deps.get<Users>(Users),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { channelId, partialMessage }: Params.MessageCreate) {
|
||||
await this.guard.canAccessChannel(client, channelId, true);
|
||||
|
||||
const authorId = ws.sessions.userId(client);
|
||||
const message = await this.messages.create(authorId, channelId, partialMessage);
|
||||
|
||||
if (!client.rooms.has(channelId))
|
||||
await client.join(channelId);
|
||||
|
||||
await Channel.updateOne(
|
||||
{ _id: channelId },
|
||||
{ lastMessageId: message.id }
|
||||
);
|
||||
|
||||
const user = await this.users.getSelf(authorId, false);
|
||||
user.lastReadMessages = {
|
||||
...user.lastReadMessages,
|
||||
[channelId]: message.id,
|
||||
};
|
||||
await user.save();
|
||||
|
||||
ws.io
|
||||
.to(channelId)
|
||||
.emit('MESSAGE_CREATE', { message } as Args.MessageCreate);
|
||||
}
|
||||
}
|
46
backend/src/api/websocket/ws-events/message-delete.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import Channels from '../../../data/channels';
|
||||
import Messages from '../../../data/messages';
|
||||
import { Message } from '../../../data/models/message';
|
||||
import { PermissionTypes } from '../../../data/types/entity-types';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Params, Args } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'MESSAGE_DELETE'> {
|
||||
on = 'MESSAGE_DELETE' as const;
|
||||
|
||||
constructor(
|
||||
private channels = Deps.get<Channels>(Channels),
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
private messages = Deps.get<Messages>(Messages),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { messageId }: Params.MessageDelete) {
|
||||
const message = await this.messages.get(messageId);
|
||||
const channel = await this.channels.get(message.channelId);
|
||||
|
||||
try {
|
||||
this.guard.validateIsUser(client, message.authorId);
|
||||
} catch {
|
||||
if (channel.type === 'DM')
|
||||
throw new TypeError('Only message author can do this');
|
||||
await this.guard.validateCan(client, channel.guildId, PermissionTypes.Text.MANAGE_MESSAGES);
|
||||
}
|
||||
await message.deleteOne();
|
||||
|
||||
if (message.id === channel.lastMessageId) {
|
||||
const previousMessage = await Message.findOne({ channelId: channel.id });
|
||||
channel.lastMessageId = previousMessage?.id;
|
||||
await (channel as any).save();
|
||||
}
|
||||
|
||||
ws.io
|
||||
.to(message.channelId)
|
||||
.emit('MESSAGE_DELETE', {
|
||||
channelId: message.channelId,
|
||||
messageId: messageId,
|
||||
} as Args.MessageDelete);
|
||||
}
|
||||
}
|
50
backend/src/api/websocket/ws-events/message-update.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { MessageDocument } from '../../../data/models/message';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params, WSEventParams } from './ws-event';
|
||||
import got from 'got';
|
||||
import { MessageTypes } from '../../../data/types/entity-types';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import Messages from '../../../data/messages';
|
||||
|
||||
const metascraper = require('metascraper')([
|
||||
require('metascraper-description')(),
|
||||
require('metascraper-image')(),
|
||||
require('metascraper-title')(),
|
||||
require('metascraper-url')()
|
||||
]);
|
||||
|
||||
export default class implements WSEvent<'MESSAGE_UPDATE'> {
|
||||
on = 'MESSAGE_UPDATE' as const;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
private messages = Deps.get<Messages>(Messages),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { messageId, partialMessage, withEmbed }: Params.MessageUpdate) {
|
||||
let message = await this.messages.get(messageId);
|
||||
this.guard.validateIsUser(client, message.authorId);
|
||||
this.guard.validateKeys('message', partialMessage);
|
||||
|
||||
message = await message.update({
|
||||
content: partialMessage.content,
|
||||
embed: (withEmbed) ? await this.getEmbed(message) : undefined,
|
||||
updatedAt: new Date()
|
||||
}, { runValidators: true });
|
||||
|
||||
ws.to(message.channelId)
|
||||
.emit('MESSAGE_UPDATE', { message });
|
||||
}
|
||||
|
||||
public async getEmbed(message: MessageDocument): Promise<MessageTypes.Embed | undefined> {
|
||||
try {
|
||||
const targetURL = /([https://].*)/.exec(message.content)?.[0];
|
||||
if (!targetURL) return;
|
||||
|
||||
const { body: html, url } = await got(targetURL);
|
||||
return await metascraper({ html, url });
|
||||
} catch {}
|
||||
}
|
||||
}
|
47
backend/src/api/websocket/ws-events/ready.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { User } from '../../../data/models/user';
|
||||
import { Lean, UserTypes } from '../../../data/types/entity-types';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WSRooms } from '../modules/ws-rooms';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'READY'> {
|
||||
public on = 'READY' as const;
|
||||
public cooldown = 5;
|
||||
|
||||
constructor(
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
private rooms = Deps.get<WSRooms>(WSRooms),
|
||||
private users = Deps.get<Users>(Users),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { key }: Params.Ready) {
|
||||
const { id: userId } = await this.guard.decodeKey(key);
|
||||
if (!userId)
|
||||
throw new TypeError('Invalid User ID');
|
||||
|
||||
ws.sessions.set(client.id, userId);
|
||||
|
||||
const user = await this.users.getSelf(userId);
|
||||
await this.rooms.join(client, user);
|
||||
|
||||
user.status = 'ONLINE';
|
||||
await user.save();
|
||||
|
||||
const guildIds = user.guilds.map(g => g.id);
|
||||
|
||||
ws.io
|
||||
.to(guildIds.concat(user.friendIds))
|
||||
.emit('PRESENCE_UPDATE', {
|
||||
userId,
|
||||
status: user.status
|
||||
} as Args.PresenceUpdate);
|
||||
|
||||
ws.io
|
||||
.to(client.id)
|
||||
.emit('READY', { user } as Args.Ready);
|
||||
}
|
||||
}
|
50
backend/src/api/websocket/ws-events/remove-friend.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { SelfUserDocument, UserDocument } from '../../../data/models/user';
|
||||
import { Params } from '../../../data/types/ws-types';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'REMOVE_FRIEND'> {
|
||||
on = 'REMOVE_FRIEND' as const;
|
||||
|
||||
constructor(
|
||||
private users = Deps.get<Users>(Users),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { friendId }: Params.RemoveFriend) {
|
||||
const senderId = ws.sessions.userId(client);
|
||||
let sender = await this.users.getSelf(senderId);
|
||||
let friend = await this.users.getSelf(friendId);
|
||||
|
||||
({ sender, friend } = await this.handle(sender, friend) as any);
|
||||
|
||||
ws.io
|
||||
.to(senderId)
|
||||
.to(friendId)
|
||||
.emit('REMOVE_FRIEND', {
|
||||
sender: this.users.secure(sender),
|
||||
friend: this.users.secure(friend),
|
||||
} as Args.RemoveFriend);
|
||||
}
|
||||
|
||||
private async handle(sender: SelfUserDocument, friend: SelfUserDocument): Promise<Args.RemoveFriend> {
|
||||
if (sender.id === friend.id)
|
||||
throw new TypeError('You cannot remove yourself as a friend');
|
||||
|
||||
await this.removeFriend(sender, friend);
|
||||
await this.removeFriend(friend, sender);
|
||||
|
||||
return { sender, friend };
|
||||
}
|
||||
|
||||
private async removeFriend(sender: SelfUserDocument, friend: SelfUserDocument) {
|
||||
const friendIndex = sender.friendIds.indexOf(friend.id);
|
||||
sender.friendIds.splice(friendIndex, 1);
|
||||
|
||||
const requestIndex = sender.friendRequestIds.indexOf(friend.id);
|
||||
sender.friendRequestIds.splice(requestIndex, 1);
|
||||
return sender.save();
|
||||
}
|
||||
}
|
19
backend/src/api/websocket/ws-events/typing-start.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params, WSEventParams } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'TYPING_START'> {
|
||||
on = 'TYPING_START' as const;
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { channelId }: Params.TypingStart) {
|
||||
if (!client.rooms.has(channelId))
|
||||
await client.join(channelId);
|
||||
|
||||
client.broadcast
|
||||
.to(channelId)
|
||||
.emit('TYPING_START', {
|
||||
userId: ws.sessions.userId(client),
|
||||
channelId,
|
||||
} as Args.TypingStart);
|
||||
}
|
||||
}
|
32
backend/src/api/websocket/ws-events/user-update.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import Users from '../../../data/users';
|
||||
import Deps from '../../../utils/deps';
|
||||
import { WSGuard } from '../../modules/ws-guard';
|
||||
import { WebSocket } from '../websocket';
|
||||
import { WSEvent, Args, Params } from './ws-event';
|
||||
|
||||
export default class implements WSEvent<'USER_UPDATE'> {
|
||||
on = 'USER_UPDATE' as const;
|
||||
|
||||
constructor(
|
||||
private users = Deps.get<Users>(Users),
|
||||
private guard = Deps.get<WSGuard>(WSGuard),
|
||||
) {}
|
||||
|
||||
public async invoke(ws: WebSocket, client: Socket, { key, partialUser }: Params.UserUpdate) {
|
||||
const { id: userId } = await this.guard.decodeKey(key);
|
||||
|
||||
const user = await this.users.get(userId);
|
||||
if (partialUser.guilds?.length !== user.guilds.length)
|
||||
throw new TypeError('You add or remove user guilds this way');
|
||||
|
||||
this.guard.validateKeys('user', partialUser);
|
||||
|
||||
await user.updateOne(
|
||||
partialUser,
|
||||
{ runValidators: true, context: 'query' },
|
||||
);
|
||||
|
||||
client.emit('USER_UPDATE', { partialUser } as Args.UserUpdate);
|
||||
}
|
||||
}
|
12
backend/src/api/websocket/ws-events/ws-event.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Socket } from 'socket.io';
|
||||
import { WSEventParams } from '../../../data/types/ws-types';
|
||||
import { WebSocket } from '../websocket';
|
||||
|
||||
export interface WSEvent<K extends keyof WSEventParams> {
|
||||
on: K;
|
||||
cooldown?: number;
|
||||
|
||||
invoke: (ws: WebSocket, client: Socket, params: WSEventParams[K]) => Promise<any>;
|
||||
}
|
||||
|
||||
export { Args, Params, WSEventParams } from '../../../data/types/ws-types';
|
@ -1,12 +1,23 @@
|
||||
import './data/types/env';
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '.env' });
|
||||
config();
|
||||
|
||||
import { connect } from 'mongoose';
|
||||
import { Deps } from './utils/deps';
|
||||
import { WS } from './ws/websocket';
|
||||
import { API } from './api/server';
|
||||
import { SystemBot } from './system/bot';
|
||||
import Deps from './utils/deps';
|
||||
import Log from './utils/log';
|
||||
|
||||
connect(process.env.MONGO_URI,
|
||||
{ useNewUrlParser: true, useUnifiedTopology: true },
|
||||
() => console.log(`Connected to MongoDB`));
|
||||
connect(process.env.MONGO_URI, {
|
||||
useUnifiedTopology: true,
|
||||
useNewUrlParser: true,
|
||||
useFindAndModify: false,
|
||||
useCreateIndex: true,
|
||||
serverSelectionTimeoutMS: 0,
|
||||
}, (error) => (error)
|
||||
? Log.error(error.message, 'db')
|
||||
: Log.info('Connected to database.')
|
||||
);
|
||||
|
||||
Deps.add<WS>(WS, new WS());
|
||||
Deps.get<SystemBot>(SystemBot).init();
|
||||
Deps.get<API>(API);
|
||||
|
70
backend/src/data/channels.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import DBWrapper from './db-wrapper';
|
||||
import { Channel, ChannelDocument, DMChannelDocument, TextChannelDocument, VoiceChannelDocument } from './models/channel';
|
||||
import { SelfUserDocument } from './models/user';
|
||||
import { generateSnowflake } from './snowflake-entity';
|
||||
import { Lean } from './types/entity-types';
|
||||
|
||||
export default class Channels extends DBWrapper<string, ChannelDocument> {
|
||||
public async get(id: string | undefined) {
|
||||
const channel = await Channel.findById(id);
|
||||
if (!channel)
|
||||
throw new TypeError('Channel Not Found');
|
||||
return channel;
|
||||
}
|
||||
|
||||
public async getDMByMembers(...memberIds: string[]) {
|
||||
return await Channel.findOne({ memberIds }) as DMChannelDocument;
|
||||
}
|
||||
|
||||
public async getDM(id: string) {
|
||||
return await Channel.findById(id) as DMChannelDocument;
|
||||
}
|
||||
public async getText(id: string) {
|
||||
return await Channel.findById(id) as TextChannelDocument;
|
||||
}
|
||||
public async getVoice(id: string) {
|
||||
return await Channel.findById(id) as VoiceChannelDocument;
|
||||
}
|
||||
|
||||
public async getDMChannels(userId: string): Promise<DMChannelDocument[]> {
|
||||
return await Channel.find({ memberIds: userId }) as DMChannelDocument[];
|
||||
}
|
||||
public async getGuildsChannels(user: SelfUserDocument): Promise<ChannelDocument[]> {
|
||||
const guildIds = user.guilds.map(c => c.id);
|
||||
return await Channel.find({
|
||||
guildId: { $in: guildIds },
|
||||
}) as ChannelDocument[];
|
||||
}
|
||||
|
||||
public create(options?: Partial<Lean.Channel>): Promise<ChannelDocument> {
|
||||
return Channel.create({
|
||||
_id: generateSnowflake(),
|
||||
name: 'chat',
|
||||
memberIds: [],
|
||||
type: 'TEXT',
|
||||
...options as any,
|
||||
});
|
||||
}
|
||||
|
||||
public createDM(senderId: string, friendId: string) {
|
||||
return this.create({
|
||||
memberIds: [senderId, friendId],
|
||||
name: 'DM Channel',
|
||||
type: 'DM',
|
||||
}) as Promise<DMChannelDocument>;
|
||||
}
|
||||
public async createText(guildId: string) {
|
||||
return this.create({ guildId }) as Promise<TextChannelDocument>;
|
||||
}
|
||||
public createVoice(guildId: string) {
|
||||
return this.create({
|
||||
name: 'Talk',
|
||||
guildId,
|
||||
type: 'VOICE',
|
||||
}) as Promise<VoiceChannelDocument>;
|
||||
}
|
||||
|
||||
public async getSystem(guildId: string) {
|
||||
return await Channel.findOne({ guildId, type: 'TEXT' });
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import { Document } from 'mongoose';
|
||||
|
||||
export function useId(this: Document) {
|
||||
const obj = this.toObject();
|
||||
|
||||
this.id = this._id;
|
||||
delete this._id;
|
||||
|
||||
return obj;
|
||||
}
|
9
backend/src/data/db-wrapper.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Document } from 'mongoose';
|
||||
|
||||
export default abstract class DBWrapper<K, T extends Document> {
|
||||
public abstract get(identifier: K | undefined): Promise<T | null | undefined>;
|
||||
|
||||
save(savedType: T) {
|
||||
return savedType?.save();
|
||||
}
|
||||
}
|
50
backend/src/data/guild-members.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import DBWrapper from './db-wrapper';
|
||||
import { GuildDocument } from './models/guild';
|
||||
import { GuildMember, GuildMemberDocument } from './models/guild-member';
|
||||
import { Role } from './models/role';
|
||||
import { UserDocument } from './models/user';
|
||||
import { generateSnowflake } from './snowflake-entity';
|
||||
import { Lean } from './types/entity-types';
|
||||
|
||||
export default class GuildMembers extends DBWrapper<string, GuildMemberDocument> {
|
||||
public async get(id: string | undefined) {
|
||||
const member = await GuildMember.findById(id);
|
||||
if (!member)
|
||||
throw new TypeError('Guild Member Not Found');
|
||||
return member;
|
||||
}
|
||||
|
||||
public async getInGuild(guildId: string | undefined, userId: string | undefined) {
|
||||
const member = await GuildMember.findOne({ guildId, userId });
|
||||
if (!member)
|
||||
throw new TypeError('Guild Member Not Found');
|
||||
return member;
|
||||
}
|
||||
|
||||
public async create(guild: GuildDocument, user: UserDocument, ...roles: Lean.Role[]) {
|
||||
const member = await GuildMember.create({
|
||||
_id: generateSnowflake(),
|
||||
guildId: guild.id,
|
||||
userId: user.id,
|
||||
roleIds: (roles.length > 0)
|
||||
? roles.map(r => r.id)
|
||||
: [await this.getEveryoneRoleId(guild.id) as string],
|
||||
});
|
||||
await this.joinGuild(user, guild, member);
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
private async joinGuild(user: UserDocument, guild: GuildDocument, member: GuildMemberDocument) {
|
||||
user.guilds.push(guild as any);
|
||||
await user.save();
|
||||
|
||||
guild.members.push(member as any);
|
||||
await guild.save();
|
||||
}
|
||||
|
||||
private async getEveryoneRoleId(guildId: string) {
|
||||
const role = await Role.findOne({ guildId, name: '@everyone' });
|
||||
return role?.id;
|
||||
}
|
||||
}
|
65
backend/src/data/guilds.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Guild, GuildDocument } from './models/guild';
|
||||
import DBWrapper from './db-wrapper';
|
||||
import { generateSnowflake } from './snowflake-entity';
|
||||
import Deps from '../utils/deps';
|
||||
import Channels from './channels';
|
||||
import GuildMembers from './guild-members';
|
||||
import Roles from './roles';
|
||||
import { UserDocument } from './models/user';
|
||||
import { Invite } from './models/invite';
|
||||
import { APIError } from '../api/modules/api-error';
|
||||
import { getNameAcronym } from '../utils/utils';
|
||||
|
||||
export default class Guilds extends DBWrapper<string, GuildDocument> {
|
||||
constructor(
|
||||
private channels = Deps.get<Channels>(Channels),
|
||||
private members = Deps.get<GuildMembers>(GuildMembers),
|
||||
private roles = Deps.get<Roles>(Roles),
|
||||
) { super(); }
|
||||
|
||||
public async get(id: string | undefined, populate = true) {
|
||||
const guild = (populate)
|
||||
? await Guild
|
||||
.findById(id)
|
||||
?.populate('members')
|
||||
.populate('roles')
|
||||
.populate('channels')
|
||||
.exec()
|
||||
: await Guild.findById(id);
|
||||
if (!guild)
|
||||
throw new APIError(404, 'Guild Not Found');
|
||||
|
||||
return guild;
|
||||
}
|
||||
|
||||
public async getFromChannel(id: string) {
|
||||
return await Guild.findOne({ channels: { $in: id } as any });
|
||||
}
|
||||
|
||||
public async create(name: string, owner: UserDocument): Promise<GuildDocument> {
|
||||
const guildId = generateSnowflake();
|
||||
const everyoneRole = await this.roles.create(guildId, {
|
||||
name: '@everyone',
|
||||
});
|
||||
|
||||
const guild = await Guild.create({
|
||||
_id: guildId,
|
||||
name,
|
||||
ownerId: owner.id,
|
||||
roles: [ everyoneRole ],
|
||||
nameAcronym: getNameAcronym(name),
|
||||
members: [],
|
||||
channels: [
|
||||
await this.channels.createText(guildId),
|
||||
await this.channels.createVoice(guildId),
|
||||
],
|
||||
});
|
||||
await this.members.create(guild, owner, everyoneRole);
|
||||
|
||||
return guild;
|
||||
}
|
||||
|
||||
public async invites(guildId: string) {
|
||||
return await Invite.find({ guildId });
|
||||
}
|
||||
}
|
23
backend/src/data/invites.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { APIError } from '../api/modules/api-error';
|
||||
import DBWrapper from './db-wrapper';
|
||||
import { generateInviteCode, Invite, InviteDocument } from './models/invite';
|
||||
import { Params } from './types/ws-types';
|
||||
|
||||
export default class Invites extends DBWrapper<string, InviteDocument> {
|
||||
public async get(code: string | undefined): Promise<InviteDocument> {
|
||||
const invite = await Invite.findById(code);
|
||||
if (!invite)
|
||||
throw new APIError(404, 'Invite Not Found');
|
||||
return invite;
|
||||
}
|
||||
|
||||
public async create({ guildId, options }: Params.InviteCreate, userId: string) {
|
||||
return Invite.create({
|
||||
_id: generateInviteCode(),
|
||||
guildId,
|
||||
inviterId: userId,
|
||||
options,
|
||||
uses: 0,
|
||||
});
|
||||
}
|
||||
}
|
54
backend/src/data/messages.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import got from 'got/dist/source';
|
||||
import DBWrapper from './db-wrapper';
|
||||
import { Channel } from './models/channel';
|
||||
import { Message, MessageDocument } from './models/message';
|
||||
import { generateSnowflake } from './snowflake-entity';
|
||||
import { MessageTypes } from './types/entity-types';
|
||||
import { Partial } from './types/ws-types';
|
||||
|
||||
const metascraper = require('metascraper')([
|
||||
require('metascraper-description')(),
|
||||
require('metascraper-image')(),
|
||||
require('metascraper-title')(),
|
||||
require('metascraper-url')()
|
||||
]);
|
||||
|
||||
export default class Messages extends DBWrapper<string, MessageDocument> {
|
||||
public async get(id: string | undefined) {
|
||||
const message = await Message.findById(id);
|
||||
if (!message)
|
||||
throw new TypeError('Message Not Found');
|
||||
return message;
|
||||
}
|
||||
|
||||
public async create(authorId: string, channelId: string, partialMessage: Partial.Message) {
|
||||
return await Message.create({
|
||||
_id: generateSnowflake(),
|
||||
authorId,
|
||||
channelId,
|
||||
content: partialMessage.content as string,
|
||||
embed: await this.getEmbed(partialMessage),
|
||||
});
|
||||
}
|
||||
|
||||
public async getEmbed(message: Partial.Message): Promise<MessageTypes.Embed | undefined> {
|
||||
try {
|
||||
const targetURL = /([https://].*)/.exec(message.content as string)?.[0];
|
||||
if (!targetURL) return;
|
||||
|
||||
const { body: html, url } = await got(targetURL);
|
||||
return await metascraper({ html, url });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public async getChannelMessages(channelId: string) {
|
||||
return await Message.find({ channelId });
|
||||
}
|
||||
|
||||
public async getDMChannelMessages(channelId: string, memberId: string) {
|
||||
const isMember = await Channel.exists({ _id: channelId, memberIds: memberId });
|
||||
if (isMember)
|
||||
throw new TypeError('You cannot access this channel');
|
||||
return await Message.find({ channelId });
|
||||
}
|
||||
}
|
47
backend/src/data/models/application.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { Document, model, Schema } from 'mongoose';
|
||||
import { generateSnowflake } from '../snowflake-entity';
|
||||
import { Lean, patterns } from '../types/entity-types';
|
||||
import { createdAtToDate, generateUsername, useId } from '../../utils/utils';
|
||||
|
||||
export interface ApplicationDocument extends Document, Lean.App {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const Application = model<ApplicationDocument>('application', new Schema({
|
||||
_id: {
|
||||
type: String,
|
||||
default: generateSnowflake,
|
||||
},
|
||||
user: {
|
||||
type: String,
|
||||
ref: 'user',
|
||||
required: [true, 'User is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
get: createdAtToDate,
|
||||
},
|
||||
description: {
|
||||
default: 'A new bot, that can do cool things.',
|
||||
type: String,
|
||||
required: [true, 'Description is required'],
|
||||
maxlength: [1000, 'Description too long'],
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: generateUsername,
|
||||
required: [true, 'Name is required'],
|
||||
maxlength: [32, 'Name is too long'],
|
||||
validate: [patterns.username, 'Name contains invalid characters'],
|
||||
},
|
||||
token: String,
|
||||
owner: {
|
||||
type: String,
|
||||
ref: 'user',
|
||||
required: [true, 'Owner is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
@ -1,13 +1,81 @@
|
||||
import { model, Schema } from 'mongoose';
|
||||
import { generateSnowflake } from '../../utils/snowflake';
|
||||
import { useId } from '../data-utils';
|
||||
import { Document, model, Schema } from 'mongoose';
|
||||
import { createdAtToDate, useId, validators } from '../../utils/utils';
|
||||
import { generateSnowflake } from '../snowflake-entity';
|
||||
import { ChannelTypes } from '../types/entity-types';
|
||||
|
||||
export interface ChannelDocument extends Entity.Channel, Document {}
|
||||
export interface DMChannelDocument extends Document, ChannelTypes.DM {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
}
|
||||
export interface TextChannelDocument extends Document, ChannelTypes.Text {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
guildId: string;
|
||||
}
|
||||
export interface VoiceChannelDocument extends Document, ChannelTypes.Voice {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
guildId: string;
|
||||
}
|
||||
export type ChannelDocument = DMChannelDocument | TextChannelDocument | VoiceChannelDocument;
|
||||
|
||||
export const Channel = model<ChannelDocument>('channel', new Schema({
|
||||
_id: { type: String, default: generateSnowflake },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
channelId: String,
|
||||
guildId: String,
|
||||
name: String,
|
||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
||||
_id: {
|
||||
type: String,
|
||||
default: generateSnowflake,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
get: createdAtToDate,
|
||||
},
|
||||
guildId: {
|
||||
type: String,
|
||||
validate: {
|
||||
validator: validators.optionalSnowflake,
|
||||
message: 'Invalid Snowflake ID',
|
||||
},
|
||||
},
|
||||
memberIds: {
|
||||
type: [String],
|
||||
default: [],
|
||||
validate: {
|
||||
validator: validators.maxLength(50),
|
||||
message: 'Channel member limit reached',
|
||||
}
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: [true, 'Name is required'],
|
||||
maxlength: [32, 'Name too long'],
|
||||
validate: {
|
||||
validator: function(val: string) {
|
||||
const type = (this as any).type;
|
||||
const pattern = /^[A-Za-z\-\d]+$/;
|
||||
return type === 'TEXT'
|
||||
&& pattern.test(val)
|
||||
|| type !== 'TEXT';
|
||||
},
|
||||
message: 'Invalid name'
|
||||
}
|
||||
},
|
||||
lastMessageId: {
|
||||
type: String,
|
||||
validate: {
|
||||
validator: validators.optionalSnowflake,
|
||||
message: 'Invalid Snowflake ID'
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
type: String,
|
||||
maxlength: [128, 'Summary too long'],
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: [true, 'Type is required'],
|
||||
validate: [/^TEXT$|^VOICE$|^DM$/, 'Invalid type'],
|
||||
},
|
||||
}, { toJSON: { getters: true } })
|
||||
.method('toClient', useId));
|
||||
|
37
backend/src/data/models/guild-member.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Document, model, Schema } from 'mongoose';
|
||||
import { useId, validators } from '../../utils/utils';
|
||||
import { generateSnowflake } from '../snowflake-entity';
|
||||
import { Lean, patterns } from '../types/entity-types';
|
||||
|
||||
export interface GuildMemberDocument extends Document, Lean.GuildMember {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
}
|
||||
|
||||
export const GuildMember = model<GuildMemberDocument>('guildMember', new Schema({
|
||||
_id: {
|
||||
type: String,
|
||||
default: generateSnowflake,
|
||||
},
|
||||
guildId: {
|
||||
type: String,
|
||||
required: [true, 'Guild ID is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
required: [true, 'User ID is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
roleIds: {
|
||||
type: [String],
|
||||
default: [],
|
||||
required: [true, 'Role IDs is required'],
|
||||
validate: {
|
||||
validator: validators.minLength(1),
|
||||
message: 'At least 1 role is required',
|
||||
}
|
||||
},
|
||||
}, { toJSON: { getters: true } })
|
||||
.method('toClient', useId));
|
@ -1,17 +1,63 @@
|
||||
import { model, Schema } from 'mongoose';
|
||||
import { generateSnowflake } from '../../utils/snowflake';
|
||||
import { useId } from '../data-utils';
|
||||
import { Document, model, Schema } from 'mongoose';
|
||||
import { createdAtToDate, getNameAcronym, useId, validators } from '../../utils/utils';
|
||||
import { generateSnowflake } from '../snowflake-entity';
|
||||
import { Lean, patterns } from '../types/entity-types';
|
||||
|
||||
export interface GuildDocument extends Entity.Guild, Document {}
|
||||
export interface GuildDocument extends Document, Lean.Guild {
|
||||
id: string;
|
||||
createdAt: never;
|
||||
}
|
||||
|
||||
export const Guild = model<GuildDocument>('guild', new Schema({
|
||||
_id: { type: String, default: generateSnowflake },
|
||||
channels: { type: [String], ref: 'channel' },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
_id: {
|
||||
type: String,
|
||||
default: generateSnowflake,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: [true, 'Name is required'],
|
||||
maxlength: [32, 'Name is too long'],
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
get: createdAtToDate,
|
||||
},
|
||||
nameAcronym: {
|
||||
type: String,
|
||||
get: function(this: GuildDocument) {
|
||||
return getNameAcronym(this.name);
|
||||
}
|
||||
},
|
||||
iconURL: String,
|
||||
members: { type: [String], ref: 'user' },
|
||||
invites: { type: [String], ref: 'invite' },
|
||||
// roles: { type: [String], ref: 'role' },
|
||||
name: String,
|
||||
ownerId: String,
|
||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
||||
ownerId: {
|
||||
type: String,
|
||||
required: true,
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
channels: {
|
||||
type: [{
|
||||
type: String,
|
||||
ref: 'channel',
|
||||
}],
|
||||
validate: {
|
||||
validator: validators.maxLength(250),
|
||||
message: 'Channel limit reached',
|
||||
},
|
||||
},
|
||||
members: [{
|
||||
type: String,
|
||||
ref: 'guildMember',
|
||||
}],
|
||||
roles: {
|
||||
type: [{
|
||||
type: String,
|
||||
ref: 'role',
|
||||
}],
|
||||
validate: {
|
||||
validator: validators.minLength(1),
|
||||
message: 'Guild must have at least one role',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
toJSON: { getters: true }
|
||||
}).method('toClient', useId));
|
@ -1,14 +1,49 @@
|
||||
import { model, Schema } from 'mongoose';
|
||||
import generateInvite from '../../utils/generate-invite';
|
||||
import { useId } from '../data-utils';
|
||||
import { Document, model, Schema } from 'mongoose';
|
||||
import { useId } from '../../utils/utils';
|
||||
import { Lean, InviteTypes, patterns } from '../types/entity-types';
|
||||
|
||||
export interface InviteDocument extends Entity.Invite, Document {}
|
||||
export interface InviteDocument extends Document, Lean.Invite {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
}
|
||||
|
||||
export function generateInviteCode(codeLength = 7) {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charactersLength = characters.length;
|
||||
|
||||
let result = '';
|
||||
for (let i = 0; i < codeLength; i++)
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
return result;
|
||||
}
|
||||
|
||||
export const Invite = model<InviteDocument>('invite', new Schema({
|
||||
_id: { type: String, default: generateInvite },
|
||||
creatorId: String,
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
guildId: String,
|
||||
options: Object,
|
||||
_id: {
|
||||
type: String,
|
||||
default: generateInviteCode,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: new Date(),
|
||||
},
|
||||
options: new Schema<InviteTypes.Options>({
|
||||
expiresAt: Date,
|
||||
maxUses: {
|
||||
type: Number,
|
||||
min: [1, 'Max uses too low'],
|
||||
max: [1000, 'Max uses too high'],
|
||||
},
|
||||
}),
|
||||
inviterId: {
|
||||
type: String,
|
||||
required: [true, 'Inviter ID is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
guildId: {
|
||||
type: String,
|
||||
required: [true, 'Guild ID is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
uses: Number,
|
||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
||||
|
@ -1,14 +1,38 @@
|
||||
import { model, Schema } from 'mongoose';
|
||||
import { useId } from '../data-utils';
|
||||
import { generateSnowflake } from '../../utils/snowflake';
|
||||
import { Document, model, Schema } from 'mongoose';
|
||||
import { createdAtToDate, useId } from '../../utils/utils';
|
||||
import { generateSnowflake } from '../snowflake-entity';
|
||||
import { Lean, patterns } from '../types/entity-types';
|
||||
|
||||
export interface MessageDocument extends Entity.Message, Document {}
|
||||
export interface MessageDocument extends Document, Lean.Message {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
}
|
||||
|
||||
export const Message = model<MessageDocument>('message', new Schema({
|
||||
_id: { type: String, default: generateSnowflake },
|
||||
authorId: String,
|
||||
content: String,
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
channelId: String,
|
||||
_id: {
|
||||
type: String,
|
||||
default: generateSnowflake,
|
||||
},
|
||||
authorId: {
|
||||
type: String,
|
||||
required: [true, 'Author ID is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
channelId: {
|
||||
type: String,
|
||||
required: [true, 'Channel ID is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
minlength: [1, 'Content too short'],
|
||||
maxlength: [3000, 'Content too long'],
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
get: createdAtToDate,
|
||||
},
|
||||
embed: Object, // TODO: make, and unit test embed schema
|
||||
updatedAt: Date,
|
||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
||||
|
62
backend/src/data/models/role.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Document, model, Schema } from 'mongoose';
|
||||
import { createdAtToDate, useId } from '../../utils/utils';
|
||||
import { generateSnowflake } from '../snowflake-entity';
|
||||
import { Lean, patterns, PermissionTypes } from '../types/entity-types';
|
||||
|
||||
export function hasPermission(current: number, required: number) {
|
||||
return Boolean(current & required)
|
||||
|| Boolean(current & PermissionTypes.General.ADMINISTRATOR);
|
||||
}
|
||||
|
||||
const everyoneColor = '#ffffff';
|
||||
|
||||
export interface RoleDocument extends Document, Lean.Role {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
}
|
||||
|
||||
export const Role = model<RoleDocument>('role', new Schema({
|
||||
_id: {
|
||||
type: String,
|
||||
default: generateSnowflake,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: everyoneColor,
|
||||
validate: {
|
||||
validator: function(this: RoleDocument, val: string) {
|
||||
return this?.name !== '@everyone'
|
||||
|| val === everyoneColor
|
||||
|| !val;
|
||||
},
|
||||
message: 'Cannot change @everyone role color',
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
get: createdAtToDate,
|
||||
},
|
||||
guildId: {
|
||||
type: String,
|
||||
required: [true, 'Owner ID is required'],
|
||||
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
|
||||
},
|
||||
hoisted: Boolean,
|
||||
mentionable: Boolean,
|
||||
name: {
|
||||
type: String,
|
||||
required: [true, 'Name is required'],
|
||||
maxlength: [32, 'Name too long'],
|
||||
},
|
||||
permissions: {
|
||||
type: Number,
|
||||
default: PermissionTypes.defaultPermissions,
|
||||
required: [true, 'Permissions is required'],
|
||||
validate: {
|
||||
validator: (val: number) => Number.isInteger(val) && val >= 0,
|
||||
message: 'Invalid permissions integer',
|
||||
},
|
||||
}
|
||||
}, { toJSON: { getters: true } })
|
||||
.method('toClient', useId));
|
@ -1,24 +1,120 @@
|
||||
import { model, Schema } from 'mongoose';
|
||||
import { useId } from '../data-utils';
|
||||
import { generateSnowflake } from '../../utils/snowflake';
|
||||
import { Document, model, Schema } from 'mongoose';
|
||||
import passportLocalMongoose from 'passport-local-mongoose';
|
||||
import { createdAtToDate, useId, validators } from '../../utils/utils';
|
||||
import { Lean, patterns, UserTypes } from '../types/entity-types';
|
||||
import uniqueValidator from 'mongoose-unique-validator';
|
||||
import { generateSnowflake } from '../snowflake-entity';
|
||||
|
||||
export interface UserDocument extends Entity.User, Document {
|
||||
locked: boolean;
|
||||
export interface UserDocument extends Document, Lean.User {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
}
|
||||
export interface SelfUserDocument extends Document, UserTypes.Self {
|
||||
_id: string | never;
|
||||
id: string;
|
||||
createdAt: never;
|
||||
|
||||
changePassword: (...args) => Promise<any>;
|
||||
register: (...args) => Promise<any>;
|
||||
}
|
||||
|
||||
const UserSchema = new Schema({
|
||||
_id: { type: String, default: generateSnowflake },
|
||||
avatarURL: { type: String, default: `/assets/avatars/avatar_grey.png` },
|
||||
createdAt: { type: Date, default: () => new Date() },
|
||||
discriminator: Number,
|
||||
email: { type: String, required: true },
|
||||
locked: Boolean,
|
||||
username: { type: String, required: true },
|
||||
updatedAt: Date,
|
||||
guildIds: [String],
|
||||
export const User = model<UserDocument>('user', new Schema({
|
||||
_id: {
|
||||
type: String,
|
||||
default: generateSnowflake,
|
||||
},
|
||||
avatarURL: {
|
||||
type: String,
|
||||
required: [true, 'Avatar URL is required'],
|
||||
},
|
||||
badges: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
bot: Boolean,
|
||||
createdAt: {
|
||||
type: Date,
|
||||
get: createdAtToDate,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
unique: [true, 'Email is already in use'],
|
||||
uniqueCaseInsensitive: true,
|
||||
validate: {
|
||||
validator: (val: string) => !val || patterns.email.test(val),
|
||||
message: 'Invalid email address'
|
||||
},
|
||||
},
|
||||
friendIds: {
|
||||
type: Array,
|
||||
ref: 'user',
|
||||
default: [],
|
||||
validate: {
|
||||
validator: validators.maxLength(100),
|
||||
message: 'Clout limit reached',
|
||||
},
|
||||
},
|
||||
friendRequestIds: {
|
||||
type: Array,
|
||||
ref: 'user',
|
||||
default: [],
|
||||
validate: {
|
||||
validator: validators.maxLength(100),
|
||||
message: 'Max friend requests reached',
|
||||
},
|
||||
},
|
||||
guilds: {
|
||||
type: Array,
|
||||
ref: 'guild',
|
||||
validate: {
|
||||
validator: validators.maxLength(100),
|
||||
message: 'Guild limit reached',
|
||||
},
|
||||
},
|
||||
ignored: {
|
||||
type: Object,
|
||||
default: new UserTypes.Ignored(),
|
||||
validate: {
|
||||
validator: function (this: UserDocument, val) {
|
||||
return !val || !val.userIds?.includes(this.id);
|
||||
},
|
||||
message: 'Cannot block self',
|
||||
},
|
||||
channelIds: {
|
||||
type: [String],
|
||||
default: []
|
||||
},
|
||||
guildIds: {
|
||||
type: [String],
|
||||
default: []
|
||||
},
|
||||
userIds: {
|
||||
type: [String],
|
||||
default: []
|
||||
},
|
||||
},
|
||||
lastReadMessages: {
|
||||
type: Object,
|
||||
default: {}
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
required: [true, 'Status is required'],
|
||||
validate: [patterns.status, 'Invalid status'],
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: [true, 'Username is required'],
|
||||
unique: [true, 'Username is taken'],
|
||||
uniqueCaseInsensitive: true,
|
||||
validate: {
|
||||
validator: patterns.username,
|
||||
message: `Invalid username`,
|
||||
},
|
||||
},
|
||||
verified: Boolean,
|
||||
}, { toJSON: { getters: true } })
|
||||
.method('toClient', useId)
|
||||
.plugin(passportLocalMongoose, { usernameField: 'email' });
|
||||
|
||||
export const User = model<UserDocument>('user', UserSchema);
|
||||
.plugin(passportLocalMongoose)
|
||||
.plugin(uniqueValidator)
|
||||
.method('toClient', useId));
|
||||
|
16
backend/src/data/pings.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { SelfUserDocument } from './models/user';
|
||||
import { Lean, UserTypes } from './types/entity-types';
|
||||
|
||||
export default class Pings {
|
||||
public markAsRead(user: SelfUserDocument, message: Lean.Message) {
|
||||
user.lastReadMessages[message.channelId] = message.id;
|
||||
return user.updateOne(user);
|
||||
}
|
||||
|
||||
public isIgnored(self: UserTypes.Self, channel: Lean.Channel, message: Lean.Message) {
|
||||
return self.id === message.authorId
|
||||
|| self.ignored.channelIds.includes(channel.id)
|
||||
|| self.ignored.guildIds.includes(channel.guildId as string)
|
||||
|| self.ignored.userIds.includes(message.authorId);
|
||||
}
|
||||
}
|
45
backend/src/data/roles.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import DBWrapper from './db-wrapper';
|
||||
import { Lean, PermissionTypes } from './types/entity-types';
|
||||
import { Partial } from './types/ws-types';
|
||||
import { hasPermission, Role, RoleDocument } from './models/role';
|
||||
import { generateSnowflake } from './snowflake-entity';
|
||||
|
||||
export default class Roles extends DBWrapper<string, RoleDocument> {
|
||||
public async get(id: string | undefined) {
|
||||
const role = await Role.findById(id);
|
||||
if (!role)
|
||||
throw new TypeError('Role Not Found');
|
||||
return role;
|
||||
}
|
||||
|
||||
public async isHigher(guild: Lean.Guild, selfMember: Lean.GuildMember, roleIds: string[]) {
|
||||
const highestRole: Lean.Role = guild.roles[guild.roles.length - 1];
|
||||
|
||||
return selfMember.userId === guild?.ownerId
|
||||
|| (selfMember.roleIds.includes(highestRole?.id)
|
||||
&& !roleIds.includes(highestRole.id));
|
||||
}
|
||||
|
||||
public async hasPermission(guild: Lean.Guild, member: Lean.GuildMember, permission: PermissionTypes.PermissionString) {
|
||||
const totalPerms = guild.roles
|
||||
.filter(r => member.roleIds.includes(r.id))
|
||||
.reduce((acc, value) => value.permissions | acc, 0);
|
||||
|
||||
const permNumber = (typeof permission === 'string')
|
||||
? PermissionTypes.All[PermissionTypes.All[permission as string]]
|
||||
: permission;
|
||||
return hasPermission(totalPerms, permNumber as any);
|
||||
}
|
||||
|
||||
public create(guildId: string, options?: Partial.Role) {
|
||||
return Role.create({
|
||||
_id: generateSnowflake(),
|
||||
guildId,
|
||||
mentionable: false,
|
||||
hoisted: false,
|
||||
name: 'New Role',
|
||||
permissions: PermissionTypes.defaultPermissions,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
49
backend/src/data/snowflake-entity.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import cluster from 'cluster';
|
||||
|
||||
let inc = 0;
|
||||
let lastSnowflake: string;
|
||||
const accordEpoch = 1577836800000;
|
||||
|
||||
export function generateSnowflake() {
|
||||
const msSince = (new Date().getTime() - accordEpoch)
|
||||
.toString(2)
|
||||
.padStart(42, '0');
|
||||
const pid = process.pid
|
||||
.toString(2)
|
||||
.slice(0, 5)
|
||||
.padStart(5, '0');
|
||||
const wid = (cluster.worker?.id ?? 0)
|
||||
.toString(2)
|
||||
.slice(0, 5)
|
||||
.padStart(5, '0');
|
||||
const getInc = (add: number) => (inc + add)
|
||||
.toString(2)
|
||||
.padStart(12, '0');
|
||||
|
||||
let snowflake = `0b${msSince}${wid}${pid}${getInc(0)}`;
|
||||
(snowflake === lastSnowflake)
|
||||
? snowflake = `0b${msSince}${wid}${pid}${getInc(1)}`
|
||||
: inc = 0;
|
||||
|
||||
lastSnowflake = snowflake;
|
||||
return BigInt(snowflake).toString();
|
||||
}
|
||||
|
||||
function binary64(val: string) {
|
||||
try {
|
||||
return `0b${BigInt(val)
|
||||
.toString(2)
|
||||
.padStart(64, '0')}`;
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// what this method does
|
||||
// -> https://discord.com/developers/docs/reference#convert-snowflake-to-datetime
|
||||
export function snowflakeToDate(snowflake: string) {
|
||||
const sinceEpochMs = Number(
|
||||
binary64(snowflake).slice(0, 42 + 2)
|
||||
);
|
||||
return new Date(sinceEpochMs + accordEpoch);
|
||||
}
|
206
backend/src/data/types/entity-types.ts
Normal file
@ -0,0 +1,206 @@
|
||||
// REMEMBER: Sync types below with Website project.
|
||||
// -> in entity-types.ts
|
||||
export namespace Lean {
|
||||
export interface App {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
name: string;
|
||||
owner: User;
|
||||
user: User;
|
||||
token: string | never;
|
||||
}
|
||||
export interface Channel {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
guildId?: string;
|
||||
memberIds?: string[];
|
||||
name?: string;
|
||||
summary?: string;
|
||||
lastMessageId?: null | string;
|
||||
type: ChannelTypes.Type;
|
||||
}
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
nameAcronym: string;
|
||||
iconURL?: string;
|
||||
ownerId: string;
|
||||
channels: Channel[];
|
||||
members: GuildMember[];
|
||||
roles: Role[];
|
||||
}
|
||||
export interface GuildMember {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
guildId: string;
|
||||
roleIds: string[];
|
||||
userId: string;
|
||||
}
|
||||
export interface Invite {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
options?: InviteTypes.Options;
|
||||
inviterId: string;
|
||||
guildId: string;
|
||||
uses: number;
|
||||
}
|
||||
export interface Message {
|
||||
id: string;
|
||||
authorId: string;
|
||||
channelId: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
embed?: MessageTypes.Embed;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
export interface Role {
|
||||
id: string;
|
||||
color?: string;
|
||||
createdAt: Date;
|
||||
guildId: string;
|
||||
hoisted: boolean;
|
||||
mentionable: boolean;
|
||||
name: string;
|
||||
permissions: number;
|
||||
}
|
||||
export interface User {
|
||||
id: string;
|
||||
avatarURL: string;
|
||||
badges: UserTypes.Badge[];
|
||||
bot: boolean;
|
||||
createdAt: Date;
|
||||
friendIds: string[];
|
||||
friendRequestIds: string[];
|
||||
guilds: string[] | Lean.Guild[];
|
||||
status: UserTypes.StatusType;
|
||||
username: string;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ChannelTypes {
|
||||
export type Type = 'DM' | 'TEXT' | 'VOICE';
|
||||
|
||||
export interface DM extends Lean.Channel {
|
||||
memberIds: string[];
|
||||
guildId: never;
|
||||
summary: never;
|
||||
type: 'DM';
|
||||
}
|
||||
export interface Text extends Lean.Channel {
|
||||
memberIds: never;
|
||||
type: 'TEXT';
|
||||
}
|
||||
export interface Voice extends Lean.Channel {
|
||||
memberIds: string[];
|
||||
summary: never;
|
||||
type: 'VOICE';
|
||||
}
|
||||
}
|
||||
|
||||
export namespace GeneralTypes {
|
||||
export interface SnowflakeEntity {
|
||||
id: string;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace InviteTypes {
|
||||
export interface Options {
|
||||
expiresAt?: Date;
|
||||
maxUses?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace MessageTypes {
|
||||
export interface Embed {
|
||||
description: string;
|
||||
image: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace PermissionTypes {
|
||||
export enum General {
|
||||
VIEW_CHANNELS = 1024,
|
||||
MANAGE_NICKNAMES = 512,
|
||||
CHANGE_NICKNAME = 256,
|
||||
CREATE_INVITE = 128,
|
||||
KICK_MEMBERS = 64,
|
||||
BAN_MEMBERS = 32,
|
||||
MANAGE_CHANNELS = 16,
|
||||
MANAGE_ROLES = 8,
|
||||
MANAGE_GUILD = 4,
|
||||
VIEW_AUDIT_LOG = 2,
|
||||
ADMINISTRATOR = 1
|
||||
}
|
||||
export enum Text {
|
||||
ADD_REACTIONS = 2048 * 16,
|
||||
MENTION_EVERYONE = 2048 * 8,
|
||||
READ_MESSAGES = 2048 * 4,
|
||||
MANAGE_MESSAGES = 2048 * 2,
|
||||
SEND_MESSAGES = 2048
|
||||
}
|
||||
export enum Voice {
|
||||
MOVE_MEMBERS = 32768 * 8,
|
||||
MUTE_MEMBERS = 32768 * 4,
|
||||
SPEAK = 32768 * 2,
|
||||
CONNECT = 32768
|
||||
}
|
||||
export const All = {
|
||||
...General,
|
||||
...Text,
|
||||
...Voice,
|
||||
}
|
||||
export type Permission = General | Text | Voice;
|
||||
|
||||
export type PermissionString = keyof typeof All;
|
||||
|
||||
export const defaultPermissions =
|
||||
PermissionTypes.General.VIEW_CHANNELS
|
||||
| PermissionTypes.General.CREATE_INVITE
|
||||
| PermissionTypes.Text.SEND_MESSAGES
|
||||
| PermissionTypes.Text.READ_MESSAGES
|
||||
| PermissionTypes.Text.ADD_REACTIONS
|
||||
| PermissionTypes.Voice.CONNECT
|
||||
| PermissionTypes.Voice.SPEAK;
|
||||
}
|
||||
|
||||
export namespace UserTypes {
|
||||
export type Badge =
|
||||
| 'BUG_1'
|
||||
| 'BUG_2'
|
||||
| 'BUG_3'
|
||||
| 'OG'
|
||||
| 'STAFF';
|
||||
export class Ignored {
|
||||
channelIds: string[] = [];
|
||||
guildIds: string[] = [];
|
||||
userIds: string[] = [];
|
||||
}
|
||||
export type StatusType = 'ONLINE' | 'OFFLINE';
|
||||
export interface Self extends Lean.User {
|
||||
guilds: Lean.Guild[];
|
||||
email: string;
|
||||
verified: true;
|
||||
lastReadMessages: {
|
||||
[k: string]: string
|
||||
};
|
||||
ignored: {
|
||||
channelIds: string[];
|
||||
guildIds: string[];
|
||||
userIds: string[];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const patterns = {
|
||||
email: /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/,
|
||||
hexColor: /^#(?:[0-9a-fA-F]{3}){1,2}$/,
|
||||
password: /(?=.*[a-zA-Z0-9!@#$%^&*])/,
|
||||
snowflake: /^\d{18}$/,
|
||||
status: /^ONLINE|^BUSY$|^AFK$|^OFFLINE$/,
|
||||
textChannelName: /^[A-Za-z\-\d]{2,32}$/,
|
||||
username: /(^(?! |^everyone$|^here$|^me$|^someone$|^discordtag$)[A-Za-z\d\-\_]{2,32}(?<! )$)/,
|
||||
}
|
15
backend/src/data/types/env.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
API_URL: string;
|
||||
EMAIL_ADDRESS: string;
|
||||
EMAIL_PASSWORD: string;
|
||||
MONGO_URI: string;
|
||||
PORT: string;
|
||||
ROOT_ENDPOINT: string;
|
||||
WEBSITE_URL: string;
|
||||
}
|
||||
}
|
||||
}
|
441
backend/src/data/types/ws-types.ts
Normal file
@ -0,0 +1,441 @@
|
||||
// REMEMBER: Sync types below with Website project.
|
||||
// -> in ws.service.ts
|
||||
import { ChannelTypes, Lean, UserTypes, InviteTypes } from './entity-types';
|
||||
|
||||
/** WS Params are what is sent to the websocket. */
|
||||
export interface WSEventParams {
|
||||
/** Add a friend, by username, by sending an outgoing request or accepting an incoming request. */
|
||||
'ADD_FRIEND': Params.AddFriend;
|
||||
/** Create a channel in a guild. */
|
||||
'CHANNEL_CREATE': Params.ChannelCreate;
|
||||
/** Delete a channel in a guild. */
|
||||
'CHANNEL_DELETE': Params.ChannelDelete;
|
||||
/** Create a guild. */
|
||||
'GUILD_CREATE': Params.GuildCreate;
|
||||
/** Delete a guild. */
|
||||
'GUILD_DELETE': Params.GuildDelete;
|
||||
/** Accept a guild invite. */
|
||||
'GUILD_MEMBER_ADD': Params.GuildMemberAdd;
|
||||
/** Remove a member from a guild. */
|
||||
'GUILD_MEMBER_REMOVE': Params.GuildMemberRemove;
|
||||
/** Update a members roles or other properties on a member. */
|
||||
'GUILD_MEMBER_UPDATE': Params.GuildMemberUpdate;
|
||||
/** Create a role in a guild. */
|
||||
'GUILD_ROLE_CREATE': Params.GuildRoleCreate;
|
||||
/** Delete a role in a guild. */
|
||||
'GUILD_ROLE_DELETE': Params.GuildRoleDelete;
|
||||
/** Update a guild role permissions or other properties. */
|
||||
'GUILD_ROLE_UPDATE': Params.GuildRoleUpdate;
|
||||
/** Update the settings of a guild. */
|
||||
'GUILD_UPDATE': Params.GuildUpdate;
|
||||
/** Create an invite in a guild */
|
||||
'INVITE_CREATE': Params.InviteCreate;
|
||||
/** Delete an existing invite in a guild. */
|
||||
'INVITE_DELETE': Params.InviteDelete;
|
||||
/** Create a message in a text-based channel. */
|
||||
'MESSAGE_CREATE': Params.MessageCreate;
|
||||
/** Delete an existing message in a text-based channel. */
|
||||
'MESSAGE_DELETE': Params.MessageDelete;
|
||||
/** Update an existing message in a text-based channel. */
|
||||
'MESSAGE_UPDATE': Params.MessageUpdate;
|
||||
/** Bootstrap your websocket client to be able to use other websocket events.
|
||||
* - Associate ws client ID with user ID.
|
||||
* - Join user rooms.
|
||||
* - Set online status. */
|
||||
'READY': Params.Ready;
|
||||
/** Cancel a friend request, or remove an existing friend. */
|
||||
'REMOVE_FRIEND': Params.RemoveFriend;
|
||||
/** Indicate that you are typing in a text-based channel. */
|
||||
'TYPING_START': Params.TypingStart;
|
||||
/** Update user settings. */
|
||||
'USER_UPDATE': Params.UserUpdate;
|
||||
/** Manually disconnect from the websocket; logout. */
|
||||
'disconnect': any;
|
||||
}
|
||||
|
||||
export interface WSEventAsyncArgs {
|
||||
/** Called after you sent an outgoing friend request, or of an incoming friend request. */
|
||||
'ADD_FRIEND': Args.AddFriend;
|
||||
/** Called when a guild channel is created. */
|
||||
'CHANNEL_CREATE': Args.ChannelCreate;
|
||||
/** Callled when a guild channel is deleted. */
|
||||
'CHANNEL_DELETE': Args.ChannelDelete;
|
||||
/** Called when a guild is deleted. */
|
||||
'GUILD_DELETE': Args.GuildDelete;
|
||||
/** Called when the client joins a guild. */
|
||||
'GUILD_JOIN': Args.GuildJoin;
|
||||
/** Called when the client leaves a guild. */
|
||||
'GUILD_LEAVE': Args.GuildLeave;
|
||||
/** Called when someone joins a guild by an invite, or a bot is added. */
|
||||
'GUILD_MEMBER_ADD': Args.GuildMemberAdd;
|
||||
/** Called when member roles are updated, or other properties. */
|
||||
'GUILD_MEMBER_UPDATE': Args.GuildMemberUpdate;
|
||||
/** Called when a guild member is removed, or leaves the guild. */
|
||||
'GUILD_MEMBER_REMOVE': Args.GuildMemberRemove;
|
||||
/** Called when a guild role is created. */
|
||||
'GUILD_ROLE_CREATE': Args.GuildRoleCreate;
|
||||
/** Called when a guild role is deleted. */
|
||||
'GUILD_ROLE_DELETE': Args.GuildRoleDelete;
|
||||
/** Called when properties on a guild role are updated. */
|
||||
'GUILD_ROLE_UPDATE': Args.GuildRoleUpdate;
|
||||
/** Called when guild settings are updated. */
|
||||
'GUILD_UPDATE': Args.GuildUpdate;
|
||||
/** Called when a guild invite is created. */
|
||||
'INVITE_CREATE': Args.InviteCreate;
|
||||
/** Called when an existing guild invite is deleted. */
|
||||
'INVITE_DELETE': Args.InviteDelete;
|
||||
/** Called when a message is created in a text-based channel. */
|
||||
'MESSAGE_CREATE': Args.MessageCreate;
|
||||
/** Called when a message is deleted in a text-based channel. */
|
||||
'MESSAGE_DELETE': Args.MessageDelete;
|
||||
/** Called when an existing message is updated in a text-based channel. */
|
||||
'MESSAGE_UPDATE': Args.MessageUpdate;
|
||||
/** Called when a message is sent in a channel you are not ignoring. */
|
||||
'PING': Args.Ping;
|
||||
/** Called when a user goes online or offline. */
|
||||
'PRESENCE_UPDATE': Args.PresenceUpdate;
|
||||
/** Called when the websocket accepts that you are ready. */
|
||||
'READY': Args.Ready;
|
||||
/** Called when you are removed as a friend, or you remove a friend request, or an existing friend. */
|
||||
'REMOVE_FRIEND': Args.RemoveFriend;
|
||||
/** Called when someone is typing in a text-based channel. */
|
||||
'TYPING_START': Args.TypingStart;
|
||||
/** Called the client user settings are updated. */
|
||||
'USER_UPDATE': Args.UserUpdate;
|
||||
}
|
||||
|
||||
/** WS Args are what is received from the websocket. */
|
||||
export interface WSEventArgs {
|
||||
/** Called after you sent an outgoing friend request, or of an incoming friend request. */
|
||||
'ADD_FRIEND': (args: Args.AddFriend) => any;
|
||||
/** Called when a guild channel is created. */
|
||||
'CHANNEL_CREATE': (args: Args.ChannelCreate) => any;
|
||||
/** Callled when a guild channel is deleted. */
|
||||
'CHANNEL_DELETE': (args: Args.ChannelDelete) => any;
|
||||
/** Called when a guild is deleted. */
|
||||
'GUILD_DELETE': (args: Args.GuildDelete) => any;
|
||||
/** Called when the client joins a guild. */
|
||||
'GUILD_JOIN': (args: Args.GuildJoin) => any;
|
||||
/** Called when the client leaves a guild. */
|
||||
'GUILD_LEAVE': (args: Args.GuildLeave) => any;
|
||||
/** Called when someone joins a guild by an invite, or a bot is added. */
|
||||
'GUILD_MEMBER_ADD': (args: Args.GuildMemberAdd) => any;
|
||||
/** Called when a guild member is removed, or leaves the guild. */
|
||||
'GUILD_MEMBER_REMOVE': (args: Args.GuildMemberRemove) => any;
|
||||
/** Called when member roles are updated, or other properties. */
|
||||
'GUILD_MEMBER_UPDATE': (args: Args.GuildMemberUpdate) => any;
|
||||
/** Called when a guild role is created. */
|
||||
'GUILD_ROLE_CREATE': (args: Args.GuildRoleCreate) => any;
|
||||
/** Called when a guild role is deleted. */
|
||||
'GUILD_ROLE_DELETE': (args: Args.GuildRoleDelete) => any;
|
||||
/** Called when properties on a guild role are updated. */
|
||||
'GUILD_ROLE_UPDATE': (args: Args.GuildRoleUpdate) => any;
|
||||
/** Called when guild settings are updated. */
|
||||
'GUILD_UPDATE': (args: Args.GuildUpdate) => any;
|
||||
/** Called when a guild invite is created. */
|
||||
'INVITE_CREATE': (args: Args.InviteCreate) => any;
|
||||
/** Called when an existing guild invite is deleted. */
|
||||
'INVITE_DELETE': (args: Args.InviteDelete) => any;
|
||||
/** Called when a message is created in a text-based channel. */
|
||||
'MESSAGE_CREATE': (args: Args.MessageCreate) => any;
|
||||
/** Called when a message is deleted in a text-based channel. */
|
||||
'MESSAGE_DELETE': (args: Args.MessageDelete) => any;
|
||||
/** Called when an existing message is updated in a text-based channel. */
|
||||
'MESSAGE_UPDATE': (args: Args.MessageUpdate) => any;
|
||||
/** Called when a message is sent in a channel you are not ignoring. */
|
||||
'PING': (args: Args.Ping) => any;
|
||||
/** Called when a user goes online or offline. */
|
||||
'PRESENCE_UPDATE': (args: Args.PresenceUpdate) => any;
|
||||
/** Called when the websocket accepts that you are ready. */
|
||||
'READY': (args: Args.Ready) => any;
|
||||
/** Called when you are removed as a friend, or you remove a friend request, or an existing friend. */
|
||||
'REMOVE_FRIEND': (args: Args.RemoveFriend) => any;
|
||||
/** Called when someone is typing in a text-based channel. */
|
||||
'TYPING_START': (args: Args.TypingStart) => any;
|
||||
/** Called the client user settings are updated. */
|
||||
'USER_UPDATE': (args: Args.UserUpdate) => any;
|
||||
/** Called when a websocket message is sent. */
|
||||
'message': (message: string) => any;
|
||||
}
|
||||
|
||||
export namespace Params {
|
||||
export interface AddFriend {
|
||||
/** Username of user (case insensitive). */
|
||||
username: string;
|
||||
}
|
||||
export interface ChannelCreate {
|
||||
guildId: string;
|
||||
partialChannel: Partial.Channel;
|
||||
}
|
||||
export interface ChannelDelete {
|
||||
/** ID of the channel to delete. */
|
||||
channelId: string;
|
||||
}
|
||||
export interface GuildCreate {
|
||||
/** Properties with the guild. */
|
||||
partialGuild: Partial.Guild;
|
||||
}
|
||||
export interface GuildDelete {
|
||||
guildId: string;
|
||||
}
|
||||
export interface GuildMemberAdd {
|
||||
inviteCode: string;
|
||||
}
|
||||
export interface GuildMemberRemove {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** ID of the member, not the same as a user ID. */
|
||||
memberId: string;
|
||||
}
|
||||
export interface GuildMemberUpdate {
|
||||
/** ID of the member, not the same as a user ID. */
|
||||
memberId: string;
|
||||
partialMember: Partial.GuildMember;
|
||||
}
|
||||
export interface GuildRoleCreate {
|
||||
guildId: string;
|
||||
partialRole: Partial.Role;
|
||||
}
|
||||
export interface GuildRoleDelete {
|
||||
roleId: string;
|
||||
guildId: string;
|
||||
}
|
||||
export interface GuildRoleUpdate {
|
||||
roleId: string;
|
||||
guildId: string;
|
||||
partialRole: Partial.Role;
|
||||
}
|
||||
export interface GuildUpdate {
|
||||
guildId: string;
|
||||
partialGuild: Partial.Guild;
|
||||
}
|
||||
export interface InviteCreate {
|
||||
guildId: string;
|
||||
options: InviteTypes.Options;
|
||||
}
|
||||
export interface InviteDelete {
|
||||
inviteCode: string;
|
||||
}
|
||||
export interface MessageCreate {
|
||||
channelId: string;
|
||||
partialMessage: Partial.Message;
|
||||
}
|
||||
export interface MessageDelete {
|
||||
messageId: string;
|
||||
}
|
||||
export interface MessageUpdate {
|
||||
messageId: string;
|
||||
partialMessage: Partial.Message;
|
||||
withEmbed: boolean;
|
||||
}
|
||||
export interface MessageCreate {
|
||||
partialMessage: Partial.Message;
|
||||
}
|
||||
export interface Ready {
|
||||
key: string;
|
||||
}
|
||||
export interface RemoveFriend {
|
||||
friendId: string;
|
||||
}
|
||||
export interface TypingStart {
|
||||
channelId: string;
|
||||
}
|
||||
export interface UserUpdate {
|
||||
partialUser: Partial.User;
|
||||
key: string;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Args {
|
||||
export interface AddFriend {
|
||||
/** The recipient who received the friend request. */
|
||||
friend: Lean.User;
|
||||
/** User who sent or accepted the friend request. */
|
||||
sender: Lean.User;
|
||||
/** Only available if both users add each other as a friend. */
|
||||
dmChannel?: ChannelTypes.DM;
|
||||
}
|
||||
/** */
|
||||
export interface ChannelCreate {
|
||||
/** ID of guild that channel is in. */
|
||||
guildId: string;
|
||||
/** The full object fo the channel that was created. */
|
||||
channel: Lean.Channel;
|
||||
}
|
||||
export interface ChannelDelete {
|
||||
/** ID of guild that channel is in. */
|
||||
guildId: string;
|
||||
/** The ID of the channel that is deleted. */
|
||||
channelId: string;
|
||||
}
|
||||
export interface GuildJoin {
|
||||
/** The full object of the guild that was joined. */
|
||||
guild: Lean.Guild;
|
||||
}
|
||||
export interface GuildLeave {
|
||||
/** ID of the guild that was left. */
|
||||
guildId: string;
|
||||
}
|
||||
export interface GuildDelete {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
}
|
||||
/** Called when a member accepts an invite, or a bot was added to a guild. */
|
||||
export interface GuildMemberAdd {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** Full object of the member that was added to the guild. */
|
||||
member: Lean.GuildMember;
|
||||
}
|
||||
export interface GuildMemberRemove {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** ID of member that was removed. */
|
||||
memberId: string;
|
||||
}
|
||||
export interface GuildMemberUpdate {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** Properties of updated guild member. */
|
||||
partialMember: Partial.GuildMember;
|
||||
/** ID of the guild member. Not the same as a user ID. */
|
||||
memberId: string;
|
||||
}
|
||||
export interface GuildRoleCreate {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** Full object of the role that was created. */
|
||||
role: Lean.Role;
|
||||
}
|
||||
export interface GuildRoleDelete {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** The ID of the role that was deleted. */
|
||||
roleId: string;
|
||||
}
|
||||
export interface GuildRoleUpdate {
|
||||
/** Guild ID associated with role. */
|
||||
guildId: string;
|
||||
/** Properties to update the role. */
|
||||
partialRole: Partial.Role;
|
||||
/** The ID of the role that was updated. */
|
||||
roleId: string;
|
||||
}
|
||||
export interface GuildUpdate {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** Properties to update a guild. */
|
||||
partialGuild: Partial.Guild;
|
||||
}
|
||||
export interface InviteCreate {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** Full object of the invite. */
|
||||
invite: Lean.Invite;
|
||||
}
|
||||
/** Called when a guild invite is delted. */
|
||||
export interface InviteDelete {
|
||||
/** ID of the guild. */
|
||||
guildId: string;
|
||||
/** The ID or the code of the invite. */
|
||||
inviteCode: string;
|
||||
}
|
||||
export interface MessageCreate {
|
||||
/** Full object of the message that was created. */
|
||||
message: Lean.Message;
|
||||
}
|
||||
export interface MessageDelete {
|
||||
/** ID of the channel with the message. */
|
||||
channelId: string;
|
||||
/** The ID of the message that was deleted. */
|
||||
messageId: string;
|
||||
}
|
||||
export interface MessageUpdate {
|
||||
/** Full object of the message that was updated. */
|
||||
message: Lean.Message;
|
||||
}
|
||||
export interface Ping {
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
}
|
||||
export interface PresenceUpdate {
|
||||
userId: string;
|
||||
status: UserTypes.StatusType;
|
||||
}
|
||||
export interface Ready {
|
||||
user: UserTypes.Self;
|
||||
}
|
||||
export interface RemoveFriend {
|
||||
friend: Lean.User;
|
||||
sender: Lean.User;
|
||||
}
|
||||
export interface TypingStart {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
}
|
||||
/** PRIVATE - contains private data */
|
||||
export interface UserUpdate {
|
||||
partialUser: Partial.User;
|
||||
}
|
||||
}
|
||||
|
||||
/** Partial classes involved in updating things.
|
||||
* Some properties (e.g. id) cannot be updated.
|
||||
*
|
||||
* **Tip**: Only provide what properties are being updated. */
|
||||
export namespace Partial {
|
||||
export type Application = Partial<Lean.App>;
|
||||
export type Channel = Partial<Lean.Channel>;
|
||||
export type Guild = Partial<Lean.Guild>;
|
||||
export type GuildMember = Partial<Lean.GuildMember>;
|
||||
export type Message = Partial<Lean.Message>;
|
||||
export type Role = Partial<Lean.Role>;
|
||||
export type User = Partial<UserTypes.Self>;
|
||||
}
|
||||
|
||||
/** Keys of objects that cannot be updated. */
|
||||
export namespace Prohibited {
|
||||
export const general: any = ['id', 'createdAt'];
|
||||
|
||||
export const app: (keyof Lean.App)[] = [
|
||||
...general,
|
||||
'owner',
|
||||
'user',
|
||||
];
|
||||
export const channel: (keyof Lean.Channel)[] = [
|
||||
...general,
|
||||
'guildId',
|
||||
'lastMessageId',
|
||||
'memberIds',
|
||||
'type',
|
||||
];
|
||||
export const guild: (keyof Lean.Guild)[] = [
|
||||
...general,
|
||||
'members',
|
||||
'nameAcronym',
|
||||
];
|
||||
export const guildMember: (keyof Lean.GuildMember)[] = [
|
||||
...general,
|
||||
'guildId',
|
||||
'userId',
|
||||
];
|
||||
export const message: (keyof Lean.Message)[] = [
|
||||
...general,
|
||||
'authorId',
|
||||
'channelId',
|
||||
'updatedAt',
|
||||
];
|
||||
export const role: (keyof Lean.Role)[] = [
|
||||
...general,
|
||||
'guildId',
|
||||
];
|
||||
export const user: (keyof UserTypes.Self)[] = [
|
||||
...general,
|
||||
'badges',
|
||||
'bot',
|
||||
'email',
|
||||
'friendIds',
|
||||
'friendRequestIds',
|
||||
'verified',
|
||||
];
|
||||
}
|
180
backend/src/data/users.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import DBWrapper from './db-wrapper';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { SelfUserDocument, User, UserDocument } from './models/user';
|
||||
import { generateSnowflake } from './snowflake-entity';
|
||||
import { readdirSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { Lean, UserTypes } from './types/entity-types';
|
||||
import { Guild } from './models/guild';
|
||||
import { APIError } from '../api/modules/api-error';
|
||||
import Deps from '../utils/deps';
|
||||
import Guilds from './guilds';
|
||||
import { Channel } from './models/channel';
|
||||
|
||||
export default class Users extends DBWrapper<string, UserDocument> {
|
||||
private avatarNames: string[] = [];
|
||||
private systemUser: UserDocument;
|
||||
|
||||
constructor(private guilds = Deps.get<Guilds>(Guilds)) {
|
||||
super();
|
||||
|
||||
this.avatarNames = readdirSync(resolve('assets/avatars'))
|
||||
.filter(n => n.startsWith('avatar'));
|
||||
}
|
||||
|
||||
public async get(id: string | undefined): Promise<UserDocument> {
|
||||
const user = await User.findById(id);
|
||||
if (!user)
|
||||
throw new APIError(404, 'User Not Found');
|
||||
|
||||
return this.secure(user);
|
||||
}
|
||||
|
||||
// TODO: test that this is fully secure
|
||||
public secure(user: UserDocument): UserDocument {
|
||||
delete user['email'];
|
||||
delete user['verified'];
|
||||
delete user['ignored'];
|
||||
delete user['lastReadMessages'];
|
||||
return user;
|
||||
}
|
||||
|
||||
public async getSelf(id: string | undefined, populateGuilds = true): Promise<SelfUserDocument> {
|
||||
const user = await this.get(id) as SelfUserDocument;
|
||||
if (populateGuilds)
|
||||
user.guilds = (await this.populateGuilds(user)).guilds as Lean.Guild[];
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async populateGuilds(user: UserDocument) {
|
||||
const guilds: Lean.Guild[] = [];
|
||||
for (const id of user.guilds) {
|
||||
const isDuplicate = guilds.some(g => g.id === id);
|
||||
if (isDuplicate) continue;
|
||||
|
||||
try {
|
||||
const guild = await this.guilds.get(id as string, true);
|
||||
guilds.push(new Guild(guild).toJSON());
|
||||
} catch {}
|
||||
}
|
||||
user.guilds = guilds as any;
|
||||
return user;
|
||||
}
|
||||
|
||||
public async getByUsername(username: string): Promise<SelfUserDocument> {
|
||||
const user = await User.findOne({ username }) as SelfUserDocument;
|
||||
if (!user)
|
||||
throw new APIError(404, 'User Not Found');
|
||||
return user;
|
||||
}
|
||||
public async getByEmail(email: string): Promise<SelfUserDocument> {
|
||||
const user = await User.findOne({ email }) as SelfUserDocument;
|
||||
if (!user)
|
||||
throw new APIError(404, 'User Not Found');
|
||||
return user;
|
||||
}
|
||||
|
||||
public async getKnown(userId: string) {
|
||||
const user = await this.getSelf(userId);
|
||||
|
||||
return await User.find({
|
||||
_id: await this.getKnownIds(user) as any,
|
||||
}) as UserDocument[];
|
||||
}
|
||||
|
||||
public async getRoomIds(user: UserTypes.Self) {
|
||||
const dmUsers = await Channel.find({ memberIds: user.id });
|
||||
const dmUserIds = dmUsers.flatMap(u => u.memberIds);
|
||||
|
||||
return Array.from(new Set([
|
||||
user.id,
|
||||
this.systemUser?.id,
|
||||
...dmUserIds,
|
||||
...user.friendRequestIds,
|
||||
...user.friendIds,
|
||||
]));
|
||||
}
|
||||
|
||||
public async getKnownIds(user: UserTypes.Self) {
|
||||
const incomingUsers = await User.find({
|
||||
friendIds: user.id,
|
||||
friendRequestIds: user.id,
|
||||
});
|
||||
const incomingUserIds = incomingUsers.map(u => u.id);
|
||||
|
||||
const guildUserIds = user.guilds
|
||||
.flatMap(g => g.members.map(g => g.userId));
|
||||
|
||||
const dmUsers = await Channel.find({ memberIds: user.id });
|
||||
const dmUserIds = dmUsers.flatMap(u => u.memberIds);
|
||||
|
||||
return Array.from(new Set([
|
||||
user.id,
|
||||
this.systemUser?.id,
|
||||
...dmUserIds,
|
||||
...guildUserIds,
|
||||
...incomingUserIds,
|
||||
...user.friendRequestIds,
|
||||
...user.friendIds,
|
||||
]));
|
||||
}
|
||||
|
||||
public async getDMChannels(userId: string) {
|
||||
return await Channel.find({ memberIds: userId });
|
||||
}
|
||||
|
||||
public async updateSystemUser() {
|
||||
const username = '2PG';
|
||||
return this.systemUser = await User.findOne({ username })
|
||||
?? await User.create({
|
||||
_id: generateSnowflake(),
|
||||
avatarURL: `${process.env.API_URL ?? 'http://localhost:3000'}/avatars/bot.png`,
|
||||
friendRequestIds: [],
|
||||
badges: [],
|
||||
bot: true,
|
||||
status: 'ONLINE',
|
||||
username,
|
||||
friendIds: [],
|
||||
guilds: [],
|
||||
});
|
||||
}
|
||||
|
||||
public createToken(userId: string, expire = true) {
|
||||
return jwt.sign(
|
||||
{ _id: userId },
|
||||
'secret',
|
||||
(expire) ? { expiresIn: '7d' } : {}
|
||||
);
|
||||
}
|
||||
|
||||
public idFromAuth(auth: string | undefined): string {
|
||||
const token = auth?.slice('Bearer '.length);
|
||||
return this.verifyToken(token);
|
||||
}
|
||||
public verifyToken(token: string | undefined): string {
|
||||
const key = jwt.verify(token as string, 'secret') as UserToken;
|
||||
return key?._id;
|
||||
}
|
||||
|
||||
public create(username: string, password: string, bot = false): Promise<UserDocument> {
|
||||
const randomAvatar = this.getRandomAvatar();
|
||||
return (User as any).register({
|
||||
_id: generateSnowflake(),
|
||||
username,
|
||||
avatarURL: `${process.env.API_URL ?? 'http://localhost:3000'}/avatars/${randomAvatar}`,
|
||||
badges: [],
|
||||
bot,
|
||||
email: `${generateSnowflake()}@avoid-mongodb-error.com`, // FIXME
|
||||
friends: [],
|
||||
status: 'ONLINE',
|
||||
}, password);
|
||||
}
|
||||
|
||||
private getRandomAvatar() {
|
||||
const randomIndex = Math.floor(Math.random() * this.avatarNames.length);
|
||||
return this.avatarNames[randomIndex];
|
||||
}
|
||||
}
|
||||
|
||||
interface UserToken { _id: string };
|
@ -1,19 +0,0 @@
|
||||
import cors from 'cors';
|
||||
import passport from 'passport';
|
||||
import { User } from '../data/models/user';
|
||||
import { Strategy as LocalStrategy } from 'passport-local';
|
||||
import { Express } from 'express-serve-static-core';
|
||||
import bodyParser from 'body-parser';
|
||||
|
||||
export default (app: Express) => {
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
app.use(passport.initialize(), passport.session());
|
||||
|
||||
passport.use(new LocalStrategy(
|
||||
{ usernameField: 'email' },
|
||||
(User as any).authenticate(),
|
||||
));
|
||||
passport.serializeUser((User as any).serializeUser());
|
||||
passport.deserializeUser((User as any).deserializeUser());
|
||||
};
|
@ -1,85 +0,0 @@
|
||||
import { Express } from 'express-serve-static-core';
|
||||
import express from 'express';
|
||||
import { Guild } from '../data/models/guild';
|
||||
import { Message } from '../data/models/message';
|
||||
import { router as authRoutes } from './routes/auth-routes';
|
||||
import path from 'path';
|
||||
import { loggedIn, updateUser } from './middleware';
|
||||
import createError from 'http-errors';
|
||||
|
||||
export default (app: Express) => {
|
||||
const prefix = process.env.API_PREFIX;
|
||||
|
||||
app.get(`${prefix}/channels/:channelId/messages`, async (req, res, next) => {
|
||||
// validate has access to the channel
|
||||
const userInGuild = await Guild.findOne({ channels: req.params.channelId as any });
|
||||
if (!userInGuild)
|
||||
return next(createError(401, 'Insufficient access'));
|
||||
|
||||
const messages = await Message.find({ channelId: req.params.channelId });
|
||||
res.json(messages);
|
||||
});
|
||||
|
||||
/* user.guilds:
|
||||
+ can be populated easily to get user guilds
|
||||
- extra baggage
|
||||
- confusing to store guilds on user
|
||||
|
||||
GET .../guilds
|
||||
+ guilds are separate to user
|
||||
+ http is faster than ws for larger objects
|
||||
- an extra http call needed to fetch items
|
||||
|
||||
= guild reordering can still be done either way
|
||||
*/
|
||||
app.get(`${prefix}/guilds`, loggedIn, updateUser, async (req, res) => {
|
||||
const user: Entity.User = res.locals.user;
|
||||
const guilds = await Guild
|
||||
.find({ _id: user.guildIds })
|
||||
.populate({ path: 'channels' })
|
||||
.populate({ path: 'invites' })
|
||||
.populate({ path: 'members' })
|
||||
// .populate({ path: 'roles' })
|
||||
.exec();
|
||||
|
||||
res.json(guilds);
|
||||
});
|
||||
|
||||
// v7: guild members
|
||||
app.get(`${prefix}/users`, loggedIn, updateUser, async (req, res) => {
|
||||
const user: Entity.User = res.locals.user;
|
||||
const guilds = await Guild
|
||||
.find({ _id: user.guildIds })
|
||||
.populate({ path: 'members' });
|
||||
|
||||
const members = guilds
|
||||
.flatMap(g => g.members)
|
||||
.map((u: any) => {
|
||||
u.email = undefined;
|
||||
u.locked = undefined;
|
||||
u.guildIds = undefined;
|
||||
return u;
|
||||
});
|
||||
|
||||
res.json([
|
||||
...new Set(members.filter(u => u.id))
|
||||
]);
|
||||
});
|
||||
|
||||
app.use(`${prefix}/auth`, authRoutes);
|
||||
app.all(`${prefix}/*`, (req, res) => res
|
||||
.status(404)
|
||||
.json({ message: 'Not Found' }));
|
||||
|
||||
app.use((err, req, res, next) => res.json(err));
|
||||
|
||||
// no prefix -> does not change with api versions
|
||||
// not part of api, but cdn
|
||||
const assetPath = path.resolve(`${__dirname}/../../assets`);
|
||||
app.use(`/assets`, express.static(assetPath));
|
||||
app.use(`/assets/*`, (req, res) => res.sendFile(`${assetPath}/avatars/unknown.png`));
|
||||
|
||||
const buildPath = path.resolve(`${__dirname}/../../../frontend/build`);
|
||||
app.use(express.static(buildPath));
|
||||
app.all('*', (req, res) => res.sendFile(`${buildPath}/index.html`));
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import createError from 'http-errors';
|
||||
import { User } from '../data/models/user';
|
||||
|
||||
export const loggedIn = (req, res, next) => {
|
||||
const payload = jwt.verify(
|
||||
req.headers.authorization,
|
||||
process.env.JWT_SECRET_KEY,
|
||||
) as Auth.Payload;
|
||||
|
||||
if (!payload.userId)
|
||||
throw next(createError(401, 'Unauthorized'));
|
||||
|
||||
res.locals.userId = payload.userId;
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
export const updateUser = async (req, res, next) => {
|
||||
const user = res.locals.user = await User.findById(res.locals.userId);
|
||||
if (!user)
|
||||
throw next(createError(404, 'Unauthorized'));
|
||||
|
||||
return next();
|
||||
};
|
@ -1,43 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from 'passport';
|
||||
import { User, UserDocument } from '../../data/models/user';
|
||||
import createError from 'http-errors';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.post('/login', authenticate('local'), (req, res, next) => {
|
||||
const user = req.user as UserDocument;
|
||||
const userId = user.id;
|
||||
const token = jwt.sign({ userId }, process.env.JWT_SECRET_KEY);
|
||||
|
||||
if (user.locked)
|
||||
next(createError(401, 'This account is locked'));
|
||||
|
||||
res.json(token);
|
||||
});
|
||||
|
||||
router.post('/register', async (req, res, next) => {
|
||||
const username = req.body.username;
|
||||
const usernameCount = await User.countDocuments({ username });
|
||||
|
||||
const maxDiscriminator = 9999;
|
||||
if (usernameCount >= maxDiscriminator)
|
||||
next(createError('Username is unavailable'));
|
||||
|
||||
try {
|
||||
const user = await (User as any).register({
|
||||
username,
|
||||
email: req.body.email,
|
||||
discriminator: usernameCount + 1,
|
||||
}, req.body.password);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id },
|
||||
process.env.JWT_SECRET_KEY,
|
||||
);
|
||||
res.json(token);
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
});
|
@ -1,15 +0,0 @@
|
||||
import express from 'express';
|
||||
import applyMiddleware from './apply-middleware';
|
||||
import applyRoutes from './apply-routes';
|
||||
|
||||
export class REST {
|
||||
public readonly app = express();
|
||||
|
||||
public listen() {
|
||||
applyMiddleware(this.app);
|
||||
applyRoutes(this.app);
|
||||
|
||||
const port = process.env.API_PORT;
|
||||
return this.app.listen(port, () => console.log(`Connected to server on port ${port}`));
|
||||
}
|
||||
}
|
46
backend/src/system/bot.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Channel } from '../data/models/channel';
|
||||
import { UserDocument } from '../data/models/user';
|
||||
import { generateSnowflake } from '../data/snowflake-entity';
|
||||
import Log from '../utils/log';
|
||||
import Deps from '../utils/deps';
|
||||
import Users from '../data/users';
|
||||
import { WSService } from './ws-service';
|
||||
import { Lean } from '../data/types/entity-types';
|
||||
import Channels from '../data/channels';
|
||||
|
||||
export class SystemBot {
|
||||
private _self: UserDocument;
|
||||
get self() { return this._self; }
|
||||
|
||||
constructor(
|
||||
private channels = Deps.get<Channels>(Channels),
|
||||
private users = Deps.get<Users>(Users),
|
||||
private ws = Deps.get<WSService>(WSService),
|
||||
) {}
|
||||
|
||||
public async init() {
|
||||
if (this.self) return;
|
||||
|
||||
this._self = await this.users.updateSystemUser();
|
||||
await this.readyUp();
|
||||
}
|
||||
|
||||
private async readyUp() {
|
||||
const key = this.users.createToken(this.self?.id);
|
||||
const { user } = await this.ws.emitAsync('READY', { key });
|
||||
this._self = user as any;
|
||||
|
||||
Log.info('Initialized bot', 'bot');
|
||||
}
|
||||
|
||||
public message(channel: Lean.Channel, content: string) {
|
||||
return this.ws.emitAsync('MESSAGE_CREATE', {
|
||||
channelId: channel.id,
|
||||
partialMessage: { content },
|
||||
});
|
||||
}
|
||||
|
||||
public async getDMChannel(user: UserDocument) {
|
||||
return this.channels.getDMByMembers(this.self.id, user.id);
|
||||
}
|
||||
}
|
29
backend/src/system/ws-service.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { WSEventArgs, WSEventAsyncArgs, WSEventParams } from '../data/types/ws-types';
|
||||
import io from 'socket.io-client';
|
||||
|
||||
export class WSService {
|
||||
public readonly socket = io.connect(`${process.env.ROOT_ENDPOINT}`);
|
||||
|
||||
public on<K extends keyof WSEventArgs>(name: K, callback: WSEventArgs[K]): this {
|
||||
this.socket.on(name, callback);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public emit<K extends keyof WSEventParams>(name: K, params: WSEventParams[K]) {
|
||||
this.socket.emit(name, params);
|
||||
}
|
||||
|
||||
public emitAsync<P extends keyof WSEventParams, A extends keyof WSEventAsyncArgs>(name: P, params: WSEventParams[P]): Promise<WSEventAsyncArgs[A & P]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.on('message', (message: string) => {
|
||||
if (!message.includes('Server error')) return;
|
||||
|
||||
return reject(message);
|
||||
});
|
||||
|
||||
this.on(name as keyof WSEventArgs, (args) => resolve(args));
|
||||
this.emit(name, params);
|
||||
});
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
../../types
|
@ -1,4 +1,4 @@
|
||||
export class Deps {
|
||||
export default class Deps {
|
||||
private static deps = new Map<any, any>();
|
||||
|
||||
public static get<T>(type: any): T {
|
||||
|
@ -1,7 +0,0 @@
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export default function (length = 6) {
|
||||
return v4()
|
||||
.replace(/-/g, '')
|
||||
.slice(0, length);
|
||||
}
|
25
backend/src/utils/log.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import 'colors';
|
||||
|
||||
export default class Log {
|
||||
static getSource(src?: string) {
|
||||
return src?.toUpperCase() || 'OTHER';
|
||||
}
|
||||
static info(message?: any, src?: string) {
|
||||
console.log(`[${
|
||||
this.toHHMMSS(new Date()).cyan
|
||||
}] INFO [${this.getSource(src).blue}] ${message?.toString().blue}`)
|
||||
}
|
||||
static error(err?: any, src?: string) {
|
||||
const message: string = err?.message || err || 'Unknown error';
|
||||
console.error(`[${
|
||||
this.toHHMMSS(new Date()).cyan
|
||||
}] ERROR [${this.getSource(src).blue}] ${message.red}`)
|
||||
}
|
||||
|
||||
private static toHHMMSS(time: Date) {
|
||||
let hours = time.getHours().toString().padStart(2, '0');
|
||||
let minutes = time.getMinutes().toString().padStart(2, '0');
|
||||
let seconds = time.getSeconds().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import cluster from 'cluster';
|
||||
|
||||
let inc = 0;
|
||||
let lastSnowflake: string;
|
||||
const dcloneEpoch = 1609459200000;
|
||||
|
||||
export function generateSnowflake() {
|
||||
const pad = (num: number, by: number) =>
|
||||
num.toString(2).padStart(by, '0');
|
||||
|
||||
const msSince = pad(new Date().getTime() - dcloneEpoch, 42);
|
||||
const pid = pad(process.pid, 5).slice(0, 5);
|
||||
const wid = pad(cluster.worker?.id ?? 0, 5);
|
||||
const getInc = (add: number) => pad(inc + add, 12);
|
||||
|
||||
let snowflake = `0b${msSince}${wid}${pid}${getInc(0)}`;
|
||||
(snowflake === lastSnowflake)
|
||||
? snowflake = `0b${msSince}${wid}${pid}${getInc(1)}`
|
||||
: inc = 0;
|
||||
|
||||
lastSnowflake = snowflake;
|
||||
return BigInt(snowflake).toString();
|
||||
}
|