Migrate Old Dashboard to New

This commit is contained in:
ADAMJR 2021-08-22 19:25:02 +01:00
parent eeb7519d6b
commit d7930dd1d3
161 changed files with 18807 additions and 2016 deletions

3
CREDITS.md Normal file
View File

@ -0,0 +1,3 @@
# Credits
## DClone Icon - @nwlandas

6
backend/.gitignore vendored
View File

@ -1,2 +1,4 @@
.env
node_modules/
.env
node_modules/
lib/

20
backend/.vscode/launch.json vendored Normal file
View 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
View 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
View 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"
}
]
}

View File

@ -1,54 +1,19 @@
### There are 2 main components in this API.
REST refers to the REST API (uses HTTP).
WS refers to the WebSocket API (uses WS).
### Dependency Injection
Due to the nature of the DI used in this project, dependencies cannot be circular:
> rest/server.ts
```ts
import { WS } from '...';
...
Deps.get<WS>(WS);
```
> ws/websocket.ts
```ts
import { REST } from '...';
...
Deps.get<REST>(REST);
```
`Deps.add` -> create dep with custom object.
`Deps.get` -> get dep, or create with no args constructor.
---
# WS
SNAKE_CASE is used as lowercase to avoid possible conflict with socket.io event names.
## Events
| NAME | DESCRIPTION |
| :------------- | :--------------------------------- |
| READY | When a user connects with browser. |
| MESSAGE_CREATE | When user sends message. |
| MESSAGE_DELETE | When user deletes message. |
---
## PORTS
`3000` -> REST API and WS
`4200` -> React website
---
## Different Folders, One Big Repo
-> Branch versions will be used (e.g. v1,v2,...,v12)
# Accord - API
Tested code that brings Accord to life.
![Lines of Code](https://img.shields.io/tokei/lines/github/d-clone/API?color=46828d&style=for-the-badge)
> © All rights reserved. This repo is not (yet) open source, and is awaiting completion.
## `.env`
```.env
API_URL="http://localhost:3000/api"
EMAIL_ADDRESS="example@gmail.com"
EMAIL_PASSWORD="google_account_password"
MONGO_URI="mongodb://localhost/accord"
PORT=3000
ROOT_ENDPOINT="http://localhost:3000"
WEBSITE_URL="http://localhost:4200"
```
> For help on setting up gmail mailing, go here - https://nodemailer.com/usage/using-gmail.

0
backend/assets/avatars/avatar_aqua.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

0
backend/assets/avatars/avatar_coffee.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 181 KiB

0
backend/assets/avatars/avatar_fire.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

0
backend/assets/avatars/avatar_gold.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

0
backend/assets/avatars/avatar_grey.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

0
backend/assets/avatars/avatar_rainbow.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

0
backend/assets/avatars/avatar_sky.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

0
backend/assets/avatars/avatar_tree.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 179 KiB

0
backend/assets/avatars/bot.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

0
backend/assets/avatars/unknown.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 191 KiB

12738
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +1,78 @@
{
"name": "api",
"version": "1.0.0",
"description": "REST refers to the REST API (uses HTTP). WS refers to the WebSocket API (uses WS).",
"main": "index.js",
"description": "",
"main": "src/app.ts",
"scripts": {
"start": "ts-node src/app.ts",
"dev": "ts-node-dev --transpile-only src/app.ts"
"dev": "ts-node-dev src/app.ts",
"test": "ts-mocha test/test.ts"
},
"keywords": [],
"author": "",
"author": "youtube.com/ADAMJR",
"license": "ISC",
"dependencies": {
"body-parser": "^1.19.0",
"chai-things": "^0.2.0",
"colors": "^1.4.0",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"faker": "^5.5.3",
"http-errors": "^1.7.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^5.2.6",
"faker": "^5.4.0",
"got": "^11.7.0",
"helmet": "^4.4.1",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.13.3",
"metascraper": "^5.14.14",
"metascraper-description": "^5.14.14",
"metascraper-image": "^5.14.14",
"metascraper-title": "^5.14.14",
"metascraper-url": "^5.14.14",
"mongoose": "^5.10.7",
"mongoose-unique-validator": "^2.0.3",
"node-fetch": "^2.6.1",
"nodemailer": "^6.5.0",
"nodemailer-pug-engine": "^2.0.0",
"passport": "^0.4.1",
"passport-local": "^1.0.0",
"passport-local-mongoose": "^6.1.0",
"socket.io": "^4.1.3",
"uuid": "^8.3.2"
"passport-local-mongoose": "^6.0.1",
"rate-limit-mongo": "^2.3.1",
"re2": "^1.16.0",
"socket.io": "^4.0.0",
"socket.io-client": "^4.0.0",
"ts-node": "^9.1.1",
"typescript": "^4.2.3"
},
"devDependencies": {
"@types/chai": "^4.2.14",
"@types/chai-as-promised": "^7.1.3",
"@types/chai-spies": "^1.0.3",
"@types/chai-things": "^0.0.34",
"@types/colors": "^1.2.1",
"@types/cors": "^2.8.7",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.13",
"@types/faker": "^5.5.7",
"@types/http-errors": "^1.8.1",
"@types/jsonwebtoken": "^8.5.4",
"@types/mongoose": "^5.11.97",
"@types/passport": "^1.0.7",
"@types/passport-local": "^1.0.34",
"@types/passport-local-mongoose": "^4.0.15",
"@types/socket.io": "^3.0.2",
"@types/uuid": "^8.3.1",
"ts-node": "^9.1.1",
"ts-node-dev": "^1.1.8",
"typescript": "^4.3.5"
"@types/express": "^4.17.11",
"@types/express-rate-limit": "^5.1.1",
"@types/faker": "^5.1.6",
"@types/jsonwebtoken": "^8.5.0",
"@types/mocha": "^8.2.0",
"@types/mongoose": "^5.7.36",
"@types/node": "^14.11.2",
"@types/node-fetch": "^2.5.7",
"@types/nodemailer": "^6.4.1",
"@types/passport": "^1.0.4",
"@types/passport-local": "^1.0.33",
"@types/socket.io": "^2.1.13",
"@types/socket.io-client": "^1.4.36",
"@types/supertest": "^2.0.10",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1",
"chai-spies": "^1.0.0",
"i": "^0.3.6",
"mocha": "^8.2.1",
"npm": "^7.6.3",
"sazerac": "^2.0.0",
"supertest": "^6.1.3"
}
}

View 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);
}
}

View 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);
}
}

View 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',
};

View 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;
}

View 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

View File

@ -0,0 +1,2 @@
- var greetings = ['Hola', 'Hello', 'Hi', 'Hey', 'Hello there'];
- var i = Math.floor(Math.random() * greetings.length);

View 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

View 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

View 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;
}

View 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');
};
}

View 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,
});

View 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));
}
}

View 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' });
});

View 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)
);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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
View 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);
});
}
}

View 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);
}
}

View 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;
}

View 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;
}
}

View 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,
};
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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 });
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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');
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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 {}
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View 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';

View File

@ -1,12 +1,23 @@
import { config } from 'dotenv';
config({ path: '.env' });
import { connect } from 'mongoose';
import { Deps } from './utils/deps';
import { WS } from './ws/websocket';
connect(process.env.MONGO_URI,
{ useNewUrlParser: true, useUnifiedTopology: true },
() => console.log(`Connected to MongoDB`));
Deps.add<WS>(WS, new WS());
import './data/types/env';
import { config } from 'dotenv';
config();
import { connect } from 'mongoose';
import { API } from './api/server';
import { SystemBot } from './system/bot';
import Deps from './utils/deps';
import Log from './utils/log';
connect(process.env.MONGO_URI, {
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);

View 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' });
}
}

View File

@ -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;
}

View 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();
}
}

View 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;
}
}

View 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 });
}
}

View 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,
});
}
}

View 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 });
}
}

View 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));

View File

@ -1,13 +1,81 @@
import { model, Schema } from 'mongoose';
import { generateSnowflake } from '../../utils/snowflake';
import { useId } from '../data-utils';
export interface ChannelDocument extends Entity.Channel, Document {}
export const Channel = model<ChannelDocument>('channel', new Schema({
_id: { type: String, default: generateSnowflake },
createdAt: { type: Date, default: () => new Date() },
channelId: String,
guildId: String,
name: String,
}, { toJSON: { getters: true } }).method('toClient', useId));
import { Document, model, Schema } from 'mongoose';
import { createdAtToDate, useId, validators } from '../../utils/utils';
import { generateSnowflake } from '../snowflake-entity';
import { ChannelTypes } from '../types/entity-types';
export interface DMChannelDocument extends Document, ChannelTypes.DM {
_id: string | never;
id: string;
createdAt: never;
}
export interface TextChannelDocument extends Document, ChannelTypes.Text {
_id: string | never;
id: string;
createdAt: never;
guildId: string;
}
export interface VoiceChannelDocument extends Document, ChannelTypes.Voice {
_id: string | never;
id: string;
createdAt: never;
guildId: string;
}
export type ChannelDocument = DMChannelDocument | TextChannelDocument | VoiceChannelDocument;
export const Channel = model<ChannelDocument>('channel', new Schema({
_id: {
type: String,
default: generateSnowflake,
},
createdAt: {
type: Date,
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));

View 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));

View File

@ -1,17 +1,63 @@
import { model, Schema } from 'mongoose';
import { generateSnowflake } from '../../utils/snowflake';
import { useId } from '../data-utils';
export interface GuildDocument extends Entity.Guild, Document {}
export const Guild = model<GuildDocument>('guild', new Schema({
_id: { type: String, default: generateSnowflake },
channels: { type: [String], ref: 'channel' },
createdAt: { type: Date, default: () => new Date() },
iconURL: String,
members: { type: [String], ref: 'user' },
invites: { type: [String], ref: 'invite' },
// roles: { type: [String], ref: 'role' },
name: String,
ownerId: String,
}, { toJSON: { getters: true } }).method('toClient', useId));
import { Document, model, Schema } from 'mongoose';
import { createdAtToDate, getNameAcronym, useId, validators } from '../../utils/utils';
import { generateSnowflake } from '../snowflake-entity';
import { Lean, patterns } from '../types/entity-types';
export interface GuildDocument extends Document, Lean.Guild {
id: string;
createdAt: never;
}
export const Guild = model<GuildDocument>('guild', new Schema({
_id: {
type: String,
default: generateSnowflake,
},
name: {
type: String,
required: [true, 'Name is required'],
maxlength: [32, 'Name is too long'],
},
createdAt: {
type: Date,
get: createdAtToDate,
},
nameAcronym: {
type: String,
get: function(this: GuildDocument) {
return getNameAcronym(this.name);
}
},
iconURL: String,
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));

View File

@ -1,14 +1,49 @@
import { model, Schema } from 'mongoose';
import generateInvite from '../../utils/generate-invite';
import { useId } from '../data-utils';
export interface InviteDocument extends Entity.Invite, Document {}
export const Invite = model<InviteDocument>('invite', new Schema({
_id: { type: String, default: generateInvite },
creatorId: String,
createdAt: { type: Date, default: () => new Date() },
guildId: String,
options: Object,
uses: Number,
}, { toJSON: { getters: true } }).method('toClient', useId));
import { Document, model, Schema } from 'mongoose';
import { useId } from '../../utils/utils';
import { Lean, InviteTypes, patterns } from '../types/entity-types';
export interface InviteDocument extends Document, Lean.Invite {
_id: string | never;
id: string;
createdAt: never;
}
export function generateInviteCode(codeLength = 7) {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let result = '';
for (let i = 0; i < codeLength; i++)
result += characters.charAt(Math.floor(Math.random() * charactersLength));
return result;
}
export const Invite = model<InviteDocument>('invite', new Schema({
_id: {
type: String,
default: 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));

View File

@ -1,14 +1,38 @@
import { model, Schema } from 'mongoose';
import { useId } from '../data-utils';
import { generateSnowflake } from '../../utils/snowflake';
export interface MessageDocument extends Entity.Message, Document {}
export const Message = model<MessageDocument>('message', new Schema({
_id: { type: String, default: generateSnowflake },
authorId: String,
content: String,
createdAt: { type: Date, default: () => new Date() },
channelId: String,
updatedAt: Date,
}, { toJSON: { getters: true } }).method('toClient', useId));
import { Document, model, Schema } from 'mongoose';
import { createdAtToDate, useId } from '../../utils/utils';
import { generateSnowflake } from '../snowflake-entity';
import { Lean, patterns } from '../types/entity-types';
export interface MessageDocument extends Document, Lean.Message {
_id: string | never;
id: string;
createdAt: never;
}
export const Message = model<MessageDocument>('message', new Schema({
_id: {
type: String,
default: generateSnowflake,
},
authorId: {
type: String,
required: [true, 'Author ID is required'],
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
},
channelId: {
type: String,
required: [true, 'Channel ID is required'],
validate: [patterns.snowflake, 'Invalid Snowflake ID'],
},
content: {
type: String,
minlength: [1, 'Content too short'],
maxlength: [3000, 'Content too long'],
},
createdAt: {
type: Date,
get: createdAtToDate,
},
embed: Object, // TODO: make, and unit test embed schema
updatedAt: Date,
}, { toJSON: { getters: true } }).method('toClient', useId));

View 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));

View File

@ -1,24 +1,120 @@
import { model, Schema } from 'mongoose';
import { useId } from '../data-utils';
import { generateSnowflake } from '../../utils/snowflake';
import passportLocalMongoose from 'passport-local-mongoose';
export interface UserDocument extends Entity.User, Document {
locked: boolean;
}
const UserSchema = new Schema({
_id: { type: String, default: generateSnowflake },
avatarURL: { type: String, default: `/assets/avatars/avatar_grey.png` },
createdAt: { type: Date, default: () => new Date() },
discriminator: Number,
email: { type: String, required: true },
locked: Boolean,
username: { type: String, required: true },
updatedAt: Date,
guildIds: [String],
}, { toJSON: { getters: true } })
.method('toClient', useId)
.plugin(passportLocalMongoose, { usernameField: 'email' });
export const User = model<UserDocument>('user', UserSchema);
import { Document, model, Schema } from 'mongoose';
import passportLocalMongoose from 'passport-local-mongoose';
import { createdAtToDate, useId, validators } from '../../utils/utils';
import { Lean, patterns, UserTypes } from '../types/entity-types';
import uniqueValidator from 'mongoose-unique-validator';
import { generateSnowflake } from '../snowflake-entity';
export interface UserDocument extends Document, Lean.User {
_id: string | never;
id: string;
createdAt: never;
}
export interface SelfUserDocument extends Document, UserTypes.Self {
_id: string | never;
id: string;
createdAt: never;
changePassword: (...args) => Promise<any>;
register: (...args) => Promise<any>;
}
export const User = model<UserDocument>('user', new Schema({
_id: {
type: String,
default: generateSnowflake,
},
avatarURL: {
type: String,
required: [true, 'Avatar URL is required'],
},
badges: {
type: [String],
default: [],
},
bot: Boolean,
createdAt: {
type: Date,
get: createdAtToDate,
},
email: {
type: String,
unique: [true, 'Email is already in use'],
uniqueCaseInsensitive: true,
validate: {
validator: (val: string) => !val || patterns.email.test(val),
message: 'Invalid email address'
},
},
friendIds: {
type: Array,
ref: 'user',
default: [],
validate: {
validator: validators.maxLength(100),
message: 'Clout limit reached',
},
},
friendRequestIds: {
type: Array,
ref: 'user',
default: [],
validate: {
validator: validators.maxLength(100),
message: 'Max friend requests reached',
},
},
guilds: {
type: Array,
ref: 'guild',
validate: {
validator: validators.maxLength(100),
message: 'Guild limit reached',
},
},
ignored: {
type: Object,
default: new UserTypes.Ignored(),
validate: {
validator: function (this: UserDocument, val) {
return !val || !val.userIds?.includes(this.id);
},
message: 'Cannot block self',
},
channelIds: {
type: [String],
default: []
},
guildIds: {
type: [String],
default: []
},
userIds: {
type: [String],
default: []
},
},
lastReadMessages: {
type: Object,
default: {}
},
status: {
type: String,
required: [true, 'Status is required'],
validate: [patterns.status, 'Invalid status'],
},
username: {
type: String,
required: [true, 'Username is required'],
unique: [true, 'Username is taken'],
uniqueCaseInsensitive: true,
validate: {
validator: patterns.username,
message: `Invalid username`,
},
},
verified: Boolean,
}, { toJSON: { getters: true } })
.plugin(passportLocalMongoose)
.plugin(uniqueValidator)
.method('toClient', useId));

16
backend/src/data/pings.ts Normal file
View 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
View 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,
});
}
}

View 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);
}

View 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}(?<! )$)/,
}

View 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;
}
}
}

View 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
View 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 };

View File

@ -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());
};

View File

@ -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`));
}

View File

@ -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();
};

View File

@ -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 });
}
});

View File

@ -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
View 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);
}
}

View 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);
});
}
}

View File

@ -1 +0,0 @@
../../types

View File

@ -1,14 +1,14 @@
export class Deps {
private static deps = new Map<any, any>();
public static get<T>(type: any): T {
return this.deps.get(type)
?? this.add(type, new type());
}
public static add<T>(type: any, instance: T): T {
return this.deps
.set(type, instance)
.get(type);
}
export default class Deps {
private static deps = new Map<any, any>();
public static get<T>(type: any): T {
return this.deps.get(type)
?? this.add(type, new type());
}
public static add<T>(type: any, instance: T): T {
return this.deps
.set(type, instance)
.get(type);
}
}

View File

@ -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
View 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}`;
}
}

View File

@ -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();
}

Some files were not shown because too many files have changed in this diff Show More