Migrate Old Dashboard to New
3
CREDITS.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Credits
|
||||||
|
|
||||||
|
## DClone Icon - @nwlandas
|
6
backend/.gitignore
vendored
@ -1,2 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
node_modules/
|
|
||||||
|
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:
|
## `.env`
|
||||||
|
```.env
|
||||||
> rest/server.ts
|
API_URL="http://localhost:3000/api"
|
||||||
|
EMAIL_ADDRESS="example@gmail.com"
|
||||||
```ts
|
EMAIL_PASSWORD="google_account_password"
|
||||||
import { WS } from '...';
|
MONGO_URI="mongodb://localhost/accord"
|
||||||
...
|
PORT=3000
|
||||||
Deps.get<WS>(WS);
|
ROOT_ENDPOINT="http://localhost:3000"
|
||||||
```
|
WEBSITE_URL="http://localhost:4200"
|
||||||
|
```
|
||||||
> ws/websocket.ts
|
|
||||||
|
> For help on setting up gmail mailing, go here - https://nodemailer.com/usage/using-gmail.
|
||||||
```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)
|
|
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 |
12738
backend/package-lock.json
generated
@ -1,44 +1,78 @@
|
|||||||
{
|
{
|
||||||
"name": "api",
|
"name": "api",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "REST refers to the REST API (uses HTTP). WS refers to the WebSocket API (uses WS).",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "src/app.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "ts-node src/app.ts",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "youtube.com/ADAMJR",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
|
"chai-things": "^0.2.0",
|
||||||
|
"colors": "^1.4.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^8.2.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"faker": "^5.5.3",
|
"express-async-errors": "^3.1.1",
|
||||||
"http-errors": "^1.7.2",
|
"express-rate-limit": "^5.2.6",
|
||||||
|
"faker": "^5.4.0",
|
||||||
|
"got": "^11.7.0",
|
||||||
|
"helmet": "^4.4.1",
|
||||||
"jsonwebtoken": "^8.5.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": "^0.4.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"passport-local-mongoose": "^6.1.0",
|
"passport-local-mongoose": "^6.0.1",
|
||||||
"socket.io": "^4.1.3",
|
"rate-limit-mongo": "^2.3.1",
|
||||||
"uuid": "^8.3.2"
|
"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": {
|
"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/dotenv": "^8.2.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.11",
|
||||||
"@types/faker": "^5.5.7",
|
"@types/express-rate-limit": "^5.1.1",
|
||||||
"@types/http-errors": "^1.8.1",
|
"@types/faker": "^5.1.6",
|
||||||
"@types/jsonwebtoken": "^8.5.4",
|
"@types/jsonwebtoken": "^8.5.0",
|
||||||
"@types/mongoose": "^5.11.97",
|
"@types/mocha": "^8.2.0",
|
||||||
"@types/passport": "^1.0.7",
|
"@types/mongoose": "^5.7.36",
|
||||||
"@types/passport-local": "^1.0.34",
|
"@types/node": "^14.11.2",
|
||||||
"@types/passport-local-mongoose": "^4.0.15",
|
"@types/node-fetch": "^2.5.7",
|
||||||
"@types/socket.io": "^3.0.2",
|
"@types/nodemailer": "^6.4.1",
|
||||||
"@types/uuid": "^8.3.1",
|
"@types/passport": "^1.0.4",
|
||||||
"ts-node": "^9.1.1",
|
"@types/passport-local": "^1.0.33",
|
||||||
"ts-node-dev": "^1.1.8",
|
"@types/socket.io": "^2.1.13",
|
||||||
"typescript": "^4.3.5"
|
"@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 { config } from 'dotenv';
|
import './data/types/env';
|
||||||
config({ path: '.env' });
|
import { config } from 'dotenv';
|
||||||
|
config();
|
||||||
import { connect } from 'mongoose';
|
|
||||||
import { Deps } from './utils/deps';
|
import { connect } from 'mongoose';
|
||||||
import { WS } from './ws/websocket';
|
import { API } from './api/server';
|
||||||
|
import { SystemBot } from './system/bot';
|
||||||
connect(process.env.MONGO_URI,
|
import Deps from './utils/deps';
|
||||||
{ useNewUrlParser: true, useUnifiedTopology: true },
|
import Log from './utils/log';
|
||||||
() => console.log(`Connected to MongoDB`));
|
|
||||||
|
connect(process.env.MONGO_URI, {
|
||||||
Deps.add<WS>(WS, new WS());
|
useUnifiedTopology: true,
|
||||||
|
useNewUrlParser: true,
|
||||||
|
useFindAndModify: false,
|
||||||
|
useCreateIndex: true,
|
||||||
|
serverSelectionTimeoutMS: 0,
|
||||||
|
}, (error) => (error)
|
||||||
|
? Log.error(error.message, 'db')
|
||||||
|
: Log.info('Connected to database.')
|
||||||
|
);
|
||||||
|
|
||||||
|
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 { Document, model, Schema } from 'mongoose';
|
||||||
import { generateSnowflake } from '../../utils/snowflake';
|
import { createdAtToDate, useId, validators } from '../../utils/utils';
|
||||||
import { useId } from '../data-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 {
|
||||||
export const Channel = model<ChannelDocument>('channel', new Schema({
|
_id: string | never;
|
||||||
_id: { type: String, default: generateSnowflake },
|
id: string;
|
||||||
createdAt: { type: Date, default: () => new Date() },
|
createdAt: never;
|
||||||
channelId: String,
|
}
|
||||||
guildId: String,
|
export interface TextChannelDocument extends Document, ChannelTypes.Text {
|
||||||
name: String,
|
_id: string | never;
|
||||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
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,
|
||||||
|
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 { Document, model, Schema } from 'mongoose';
|
||||||
import { generateSnowflake } from '../../utils/snowflake';
|
import { createdAtToDate, getNameAcronym, useId, validators } from '../../utils/utils';
|
||||||
import { useId } from '../data-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 {
|
||||||
export const Guild = model<GuildDocument>('guild', new Schema({
|
id: string;
|
||||||
_id: { type: String, default: generateSnowflake },
|
createdAt: never;
|
||||||
channels: { type: [String], ref: 'channel' },
|
}
|
||||||
createdAt: { type: Date, default: () => new Date() },
|
|
||||||
iconURL: String,
|
export const Guild = model<GuildDocument>('guild', new Schema({
|
||||||
members: { type: [String], ref: 'user' },
|
_id: {
|
||||||
invites: { type: [String], ref: 'invite' },
|
type: String,
|
||||||
// roles: { type: [String], ref: 'role' },
|
default: generateSnowflake,
|
||||||
name: String,
|
},
|
||||||
ownerId: String,
|
name: {
|
||||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
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,
|
||||||
|
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 { Document, model, Schema } from 'mongoose';
|
||||||
import generateInvite from '../../utils/generate-invite';
|
import { useId } from '../../utils/utils';
|
||||||
import { useId } from '../data-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;
|
||||||
export const Invite = model<InviteDocument>('invite', new Schema({
|
id: string;
|
||||||
_id: { type: String, default: generateInvite },
|
createdAt: never;
|
||||||
creatorId: String,
|
}
|
||||||
createdAt: { type: Date, default: () => new Date() },
|
|
||||||
guildId: String,
|
export function generateInviteCode(codeLength = 7) {
|
||||||
options: Object,
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
uses: Number,
|
const charactersLength = characters.length;
|
||||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
|
||||||
|
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: 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 { Document, model, Schema } from 'mongoose';
|
||||||
import { useId } from '../data-utils';
|
import { createdAtToDate, useId } from '../../utils/utils';
|
||||||
import { generateSnowflake } from '../../utils/snowflake';
|
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 {
|
||||||
export const Message = model<MessageDocument>('message', new Schema({
|
_id: string | never;
|
||||||
_id: { type: String, default: generateSnowflake },
|
id: string;
|
||||||
authorId: String,
|
createdAt: never;
|
||||||
content: String,
|
}
|
||||||
createdAt: { type: Date, default: () => new Date() },
|
|
||||||
channelId: String,
|
export const Message = model<MessageDocument>('message', new Schema({
|
||||||
updatedAt: Date,
|
_id: {
|
||||||
}, { toJSON: { getters: true } }).method('toClient', useId));
|
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 { Document, model, Schema } from 'mongoose';
|
||||||
import { useId } from '../data-utils';
|
import passportLocalMongoose from 'passport-local-mongoose';
|
||||||
import { generateSnowflake } from '../../utils/snowflake';
|
import { createdAtToDate, useId, validators } from '../../utils/utils';
|
||||||
import passportLocalMongoose from 'passport-local-mongoose';
|
import { Lean, patterns, UserTypes } from '../types/entity-types';
|
||||||
|
import uniqueValidator from 'mongoose-unique-validator';
|
||||||
export interface UserDocument extends Entity.User, Document {
|
import { generateSnowflake } from '../snowflake-entity';
|
||||||
locked: boolean;
|
|
||||||
}
|
export interface UserDocument extends Document, Lean.User {
|
||||||
|
_id: string | never;
|
||||||
const UserSchema = new Schema({
|
id: string;
|
||||||
_id: { type: String, default: generateSnowflake },
|
createdAt: never;
|
||||||
avatarURL: { type: String, default: `/assets/avatars/avatar_grey.png` },
|
}
|
||||||
createdAt: { type: Date, default: () => new Date() },
|
export interface SelfUserDocument extends Document, UserTypes.Self {
|
||||||
discriminator: Number,
|
_id: string | never;
|
||||||
email: { type: String, required: true },
|
id: string;
|
||||||
locked: Boolean,
|
createdAt: never;
|
||||||
username: { type: String, required: true },
|
|
||||||
updatedAt: Date,
|
changePassword: (...args) => Promise<any>;
|
||||||
guildIds: [String],
|
register: (...args) => Promise<any>;
|
||||||
}, { toJSON: { getters: true } })
|
}
|
||||||
.method('toClient', useId)
|
|
||||||
.plugin(passportLocalMongoose, { usernameField: 'email' });
|
export const User = model<UserDocument>('user', new Schema({
|
||||||
|
_id: {
|
||||||
export const User = model<UserDocument>('user', UserSchema);
|
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 } })
|
||||||
|
.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,14 +1,14 @@
|
|||||||
export class Deps {
|
export default class Deps {
|
||||||
private static deps = new Map<any, any>();
|
private static deps = new Map<any, any>();
|
||||||
|
|
||||||
public static get<T>(type: any): T {
|
public static get<T>(type: any): T {
|
||||||
return this.deps.get(type)
|
return this.deps.get(type)
|
||||||
?? this.add(type, new type());
|
?? this.add(type, new type());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static add<T>(type: any, instance: T): T {
|
public static add<T>(type: any, instance: T): T {
|
||||||
return this.deps
|
return this.deps
|
||||||
.set(type, instance)
|
.set(type, instance)
|
||||||
.get(type);
|
.get(type);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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();
|
|
||||||
}
|
|