Compare commits
3 Commits
e976c8d47f
...
e1b60f3c3e
Author | SHA1 | Date | |
---|---|---|---|
e1b60f3c3e | |||
63f7c763ef | |||
7b94be6915 |
@ -53,7 +53,7 @@ Main component that runs on the forked processes. Expects a message with a `_sta
|
|||||||
|
|
||||||
## "Lesser" components
|
## "Lesser" components
|
||||||
**Authenticator:** `/src/server/middleware/Authenticator.js`
|
**Authenticator:** `/src/server/middleware/Authenticator.js`
|
||||||
Takes care of sessions, authentication and authorisation, relies on an implementation of `AbstractUserDatabase.js`.
|
Takes care of sessions, authentication and authorisation, relies on an implementation of `UserDatabaseInterface.js`.
|
||||||
|
|
||||||
**UserDatabase:** `/src/server/components/UserDatabase.js`
|
**UserDatabase:** `/src/server/components/UserDatabase.js`
|
||||||
Implementation of `AbstractUserDatabase.js`, takes care of user management.
|
Implementation of `UserDatabaseInterface.js`, takes care of user management.
|
@ -15,6 +15,7 @@
|
|||||||
"@navy.gif/logger": "^1.0.0",
|
"@navy.gif/logger": "^1.0.0",
|
||||||
"@navy.gif/passport-discord": "^0.2.2-b",
|
"@navy.gif/passport-discord": "^0.2.2-b",
|
||||||
"argon2": "^0.30.2",
|
"argon2": "^0.30.2",
|
||||||
|
"commandparser": "^1.0.24",
|
||||||
"connect-mongo": "^4.6.0",
|
"connect-mongo": "^4.6.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
|
10
src/controller/BaseCommand.js
Normal file
10
src/controller/BaseCommand.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const { Command } = require("commandparser");
|
||||||
|
|
||||||
|
class BaseCommand extends Command {
|
||||||
|
constructor (controller, opts) {
|
||||||
|
super(opts);
|
||||||
|
this.controller = controller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseCommand;
|
@ -5,10 +5,12 @@ const path = require('node:path');
|
|||||||
|
|
||||||
// External
|
// External
|
||||||
const { MasterLogger } = require('@navy.gif/logger');
|
const { MasterLogger } = require('@navy.gif/logger');
|
||||||
|
const { Parser: CommandParser } = require('commandparser');
|
||||||
const { Collection } = require('@discordjs/collection');
|
const { Collection } = require('@discordjs/collection');
|
||||||
|
|
||||||
// Local
|
// Local
|
||||||
const Shard = require('./Shard');
|
const Shard = require('./Shard');
|
||||||
|
const { Util } = require('../util');
|
||||||
|
|
||||||
class Controller extends EventEmitter {
|
class Controller extends EventEmitter {
|
||||||
|
|
||||||
@ -20,6 +22,7 @@ class Controller extends EventEmitter {
|
|||||||
this._debug = options.debug || false;
|
this._debug = options.debug || false;
|
||||||
|
|
||||||
this.logger = new MasterLogger(options.logger);
|
this.logger = new MasterLogger(options.logger);
|
||||||
|
this.parser = null;
|
||||||
|
|
||||||
// Path to the file that gets forked into shards -- relative to process.cwd() (e.g. the directory in which the process was started)
|
// Path to the file that gets forked into shards -- relative to process.cwd() (e.g. the directory in which the process was started)
|
||||||
this.serverFilePath = path.resolve(options.serverFilePath);
|
this.serverFilePath = path.resolve(options.serverFilePath);
|
||||||
@ -54,6 +57,25 @@ class Controller extends EventEmitter {
|
|||||||
const { shardCount = 1, shardOptions = {}, serverOptions = {}, logger = {}, discord = {}, databases = {} } = this._options;
|
const { shardCount = 1, shardOptions = {}, serverOptions = {}, logger = {}, discord = {}, databases = {} } = this._options;
|
||||||
this.logger.info(`Spawning ${shardCount} shards`);
|
this.logger.info(`Spawning ${shardCount} shards`);
|
||||||
|
|
||||||
|
this.commands = Util.readdirRecursive(path.resolve(__dirname, './commands')).map(file => {
|
||||||
|
const cmd = require(file);
|
||||||
|
if (typeof cmd !== 'function') {
|
||||||
|
this.logger.warn(`Attempted to load an invalid command: ${file}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new cmd(this);
|
||||||
|
return command;
|
||||||
|
|
||||||
|
}).filter(cmd => cmd);
|
||||||
|
|
||||||
|
this.parser = new CommandParser({ commands: this.commands, prefix: '' });
|
||||||
|
process.stdin.on('data', (data) => {
|
||||||
|
const raw = data.toString('utf-8');
|
||||||
|
const words = raw.split(' ').map(word => word.trim());
|
||||||
|
this._handleStdin(words);
|
||||||
|
});
|
||||||
|
|
||||||
const promises = [];
|
const promises = [];
|
||||||
for (let i = 0; i < shardCount; i++) {
|
for (let i = 0; i < shardCount; i++) {
|
||||||
const shard = new Shard(this, i, { serverOptions: { ...serverOptions, logger, discord, databases }, ...shardOptions, env: this._options.env, path: this.serverFilePath });
|
const shard = new Shard(this, i, { serverOptions: { ...serverOptions, logger, discord, databases }, ...shardOptions, env: this._options.env, path: this.serverFilePath });
|
||||||
@ -92,10 +114,48 @@ class Controller extends EventEmitter {
|
|||||||
shard.on('message', (msg) => this._handleMessage(shard, msg));
|
shard.on('message', (msg) => this._handleMessage(shard, msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _handleStdin (words) {
|
||||||
|
|
||||||
|
words = words.filter(word => word.length);
|
||||||
|
if (!words.length)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
try {
|
||||||
|
result = await this.parser.parseMessage(words.join(' '));
|
||||||
|
if (!result)
|
||||||
|
return this.logger.error(`${words[0]} is not a valid command`);
|
||||||
|
} catch (err) {
|
||||||
|
return this.logger.error(err.message);
|
||||||
|
}
|
||||||
|
// this.logger.info(inspect(rest));
|
||||||
|
|
||||||
|
const { command, ...rest } = result;
|
||||||
|
|
||||||
|
if (rest.args.help)
|
||||||
|
return this.logger.info(`COMMAND HELP\n${command.help}`);
|
||||||
|
|
||||||
|
let response = null;
|
||||||
|
try {
|
||||||
|
response = await command.execute(rest);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.constructor.name === 'CommandError')
|
||||||
|
return this.logger.error(err);
|
||||||
|
this.logger.error(err.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(response);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
async #shutdown () {
|
async #shutdown () {
|
||||||
this.logger.info('Received SIGINT, shutting down');
|
this.logger.info('Received SIGINT, shutting down');
|
||||||
const promises = this.shards.map(shard => shard.awaitShutdown());
|
setTimeout(process.exit, 90_000);
|
||||||
await Promise.all(promises);
|
const promises = this.shards.filter(shard => shard.ready).map(shard => shard.awaitShutdown());
|
||||||
|
if (promises.length)
|
||||||
|
await Promise.all(promises);
|
||||||
this.logger.info(`Shutdown completed, goodbye`);
|
this.logger.info(`Shutdown completed, goodbye`);
|
||||||
|
|
||||||
// eslint-disable-next-line no-process-exit
|
// eslint-disable-next-line no-process-exit
|
||||||
|
@ -34,6 +34,8 @@ class Shard extends EventEmitter {
|
|||||||
|
|
||||||
this._awaitingShutdown = null;
|
this._awaitingShutdown = null;
|
||||||
|
|
||||||
|
this.awaitingResponse = new Map();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async spawn (waitForReady = false) {
|
async spawn (waitForReady = false) {
|
||||||
@ -106,18 +108,30 @@ class Shard extends EventEmitter {
|
|||||||
this._handleExit(null, null, false);
|
this._handleExit(null, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
send (message) {
|
send (message, expectResponse = false) {
|
||||||
|
|
||||||
|
if (!this.ready || !process)
|
||||||
|
return Promise.reject(new Error(`[shard-${this.id}] Cannot send message to dead shard.`));
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (this.ready && this.process) {
|
|
||||||
this.process.send(message, err => {
|
if (expectResponse) {
|
||||||
if (err)
|
message._id = Util.randomUUID();
|
||||||
reject(err);
|
const to = setTimeout(reject, 10_000, [ new Error('Message timeout') ]);
|
||||||
else
|
this.awaitingResponse.set(message._id, (...args) => {
|
||||||
resolve();
|
clearTimeout(to);
|
||||||
|
resolve(...args);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
reject(new Error(`[shard-${this.id}] Cannot send message to dead shard.`));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.process.send(message, err => {
|
||||||
|
if (err)
|
||||||
|
return reject(err);
|
||||||
|
|
||||||
|
if (!expectResponse)
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,7 +161,11 @@ class Shard extends EventEmitter {
|
|||||||
this.ready = false;
|
this.ready = false;
|
||||||
this.fatal = true;
|
this.fatal = true;
|
||||||
this._handleExit(null, null, false);
|
this._handleExit(null, null, false);
|
||||||
this.emit('fatal', message);
|
return this.emit('fatal', message);
|
||||||
|
} else if (message._id) {
|
||||||
|
const promise = this.awaitingResponse.get(message._id);
|
||||||
|
if (promise)
|
||||||
|
return promise(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
40
src/controller/commands/Create.js
Normal file
40
src/controller/commands/Create.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
const { OptionType } = require("commandparser");
|
||||||
|
const BaseCommand = require("../BaseCommand");
|
||||||
|
|
||||||
|
class CreateCommand extends BaseCommand {
|
||||||
|
|
||||||
|
constructor (controller) {
|
||||||
|
super(controller, {
|
||||||
|
name: 'create',
|
||||||
|
options: [{
|
||||||
|
name: 'registration-code',
|
||||||
|
aliases: [ 'code' ],
|
||||||
|
type: OptionType.SUB_COMMAND,
|
||||||
|
options: [{
|
||||||
|
name: 'amount',
|
||||||
|
flag: true,
|
||||||
|
type: OptionType.INTEGER,
|
||||||
|
valueOptional: true,
|
||||||
|
defaultValue: 1
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute ({ subcommand, args }) {
|
||||||
|
const amount = args.amount?.value || 1;
|
||||||
|
const shard = this.controller.shards.random();
|
||||||
|
|
||||||
|
const msg = { amount };
|
||||||
|
if (subcommand === 'registration-code')
|
||||||
|
msg.type = 'reqregcode';
|
||||||
|
|
||||||
|
const { code } = await shard.send(msg, true);
|
||||||
|
return `Code ${code.code}, valid until ${new Date(code.created + code.validFor).toUTCString()}`;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CreateCommand;
|
@ -19,6 +19,8 @@ const { Registry, UserDatabase } = require('./components');
|
|||||||
const { MariaDB, MongoDB, MemoryCache } = require('./database');
|
const { MariaDB, MongoDB, MemoryCache } = require('./database');
|
||||||
const Authenticator = require('./middleware/Authenticator');
|
const Authenticator = require('./middleware/Authenticator');
|
||||||
const pkg = require('../../package.json');
|
const pkg = require('../../package.json');
|
||||||
|
const { RateLimiter } = require('./middleware');
|
||||||
|
const MongoStore = require('connect-mongo');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meant to be deployed behind a proxy (e.g. nginx) instance that takes care of certs and load balancing, trusts the first proxy
|
* Meant to be deployed behind a proxy (e.g. nginx) instance that takes care of certs and load balancing, trusts the first proxy
|
||||||
@ -81,6 +83,7 @@ class Server extends EventEmitter {
|
|||||||
// Takes care of loading endpoints, all endpoints need to inherit the endpoint class in /interfaces
|
// Takes care of loading endpoints, all endpoints need to inherit the endpoint class in /interfaces
|
||||||
this.registry = new Registry(this, { path: path.join(__dirname, 'endpoints') });
|
this.registry = new Registry(this, { path: path.join(__dirname, 'endpoints') });
|
||||||
|
|
||||||
|
// TODO: Database definitions should probably be elsewhere through injection
|
||||||
// Mariadb isn't strictly necessary here for anything, it's just here pre-emptively
|
// Mariadb isn't strictly necessary here for anything, it's just here pre-emptively
|
||||||
this.mariadb = new MariaDB(this, { options: databases.mariadb, MARIA_HOST, MARIA_USER, MARIA_PORT, MARIA_PASS, MARIA_DB });
|
this.mariadb = new MariaDB(this, { options: databases.mariadb, MARIA_HOST, MARIA_USER, MARIA_PORT, MARIA_PASS, MARIA_DB });
|
||||||
// Mongo is used for session and user storage
|
// Mongo is used for session and user storage
|
||||||
@ -95,6 +98,8 @@ class Server extends EventEmitter {
|
|||||||
MONGO_AUTH_DB
|
MONGO_AUTH_DB
|
||||||
});
|
});
|
||||||
this.userDatabase = new UserDatabase(this, this.mongodb, { validUserTypes });
|
this.userDatabase = new UserDatabase(this, this.mongodb, { validUserTypes });
|
||||||
|
// Alias
|
||||||
|
this.users = this.userDatabase;
|
||||||
|
|
||||||
// Distributed memory storage, using mongo in this case, but this could be redis or whatever
|
// Distributed memory storage, using mongo in this case, but this could be redis or whatever
|
||||||
this.memoryStoreProvider = new MongoDB(this, {
|
this.memoryStoreProvider = new MongoDB(this, {
|
||||||
@ -111,12 +116,12 @@ class Server extends EventEmitter {
|
|||||||
this.memoryCache = new MemoryCache(this, {
|
this.memoryCache = new MemoryCache(this, {
|
||||||
provider: this.memoryStoreProvider
|
provider: this.memoryStoreProvider
|
||||||
});
|
});
|
||||||
// Alias
|
|
||||||
this.users = this.userDatabase;
|
this.rateLimiter = new RateLimiter(this, this.memoryCache);
|
||||||
|
|
||||||
// Authenticator takes care of sessions, logins and authorisations
|
// Authenticator takes care of sessions, logins and authorisations
|
||||||
this.authenticator = new Authenticator(this, this.userDatabase, {
|
this.authenticator = new Authenticator(this, this.userDatabase, {
|
||||||
mongo: this.mongodb,
|
sessionStorage: MongoStore.create({ client: this.mongodb.client, dbName: this.mongodb.database, touchAfter: 600 }),
|
||||||
secret: SECRET, // Secret for sessions
|
secret: SECRET, // Secret for sessions
|
||||||
name: `${pkg.name}.s`,
|
name: `${pkg.name}.s`,
|
||||||
cookie: {
|
cookie: {
|
||||||
@ -228,7 +233,16 @@ class Server extends EventEmitter {
|
|||||||
|
|
||||||
_handleMessage (msg) {
|
_handleMessage (msg) {
|
||||||
if (msg._shutdown)
|
if (msg._shutdown)
|
||||||
this.shutdown();
|
return this.shutdown();
|
||||||
|
if (msg._id)
|
||||||
|
return this._handleCommand(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _handleCommand (msg) {
|
||||||
|
if (msg.type === 'reqregcode') {
|
||||||
|
const code = await this.userDatabase.createRegistrationCode();
|
||||||
|
process.send({ _id: msg._id, code });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to pass options to the logger in a unified way
|
// Helper function to pass options to the logger in a unified way
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
const { Collection } = require("@discordjs/collection");
|
const { Collection } = require("@discordjs/collection");
|
||||||
const { ObjectId } = require("mongodb");
|
const { ObjectId } = require("mongodb");
|
||||||
|
|
||||||
const { AbstractUserDatabase } = require("../interfaces/");
|
const { UserDatabaseInterface } = require("../interfaces/");
|
||||||
const { User } = require("../structures");
|
const { User, UserApplication } = require("../structures");
|
||||||
const UserApplicataion = require("../structures/UserApplication");
|
|
||||||
const { Util } = require("../../util");
|
const { Util } = require("../../util");
|
||||||
|
|
||||||
// MongoDB based user db
|
// MongoDB based user db
|
||||||
class UserDatabase extends AbstractUserDatabase {
|
class UserDatabase extends UserDatabaseInterface {
|
||||||
|
|
||||||
constructor (server, db, {
|
constructor (server, db, {
|
||||||
userColllection = 'users',
|
userColllection = 'users',
|
||||||
appCollection = 'applications',
|
appCollection = 'applications',
|
||||||
validUserTypes,
|
|
||||||
disableCache
|
disableCache
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
@ -21,7 +19,6 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
this.logger = server.createLogger(this);
|
this.logger = server.createLogger(this);
|
||||||
this.cache = new Collection();
|
this.cache = new Collection();
|
||||||
this.userCollection = null;
|
this.userCollection = null;
|
||||||
User.setValidTypes(validUserTypes);
|
|
||||||
|
|
||||||
this._userColllection = userColllection;
|
this._userColllection = userColllection;
|
||||||
this._appCollection = appCollection;
|
this._appCollection = appCollection;
|
||||||
@ -38,7 +35,7 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
if (!page)
|
if (!page)
|
||||||
throw new Error('Missing page number');
|
throw new Error('Missing page number');
|
||||||
|
|
||||||
const data = await this.db.find('users', query, { limit: amount, skip: (page - 1) * amount });
|
const data = await this.db.find(this._userColllection, query, { limit: amount, skip: (page - 1) * amount });
|
||||||
const users = [];
|
const users = [];
|
||||||
for (const user of data) {
|
for (const user of data) {
|
||||||
let u = this.cache.get(user._id);
|
let u = this.cache.get(user._id);
|
||||||
@ -63,7 +60,7 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
if (id.includes('temp'))
|
if (id.includes('temp'))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
const data = await this.db.findOne(this._userColllection, { _id: new ObjectId(id) });
|
const data = await this.db.findOne(this._userColllection, { _id: id });
|
||||||
if (!data)
|
if (!data)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@ -97,16 +94,16 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findUser (username) {
|
async findUser (name) {
|
||||||
|
|
||||||
if (!username)
|
if (!name)
|
||||||
throw new Error('Missing username');
|
throw new Error('Missing username');
|
||||||
|
|
||||||
let user = this.cache.find(u => u.username?.toLowerCase() === username.toLowerCase());
|
let user = this.cache.find(u => u.username?.toLowerCase() === name.toLowerCase());
|
||||||
if (user)
|
if (user)
|
||||||
return Promise.resolve(user);
|
return Promise.resolve(user);
|
||||||
|
|
||||||
const data = await this.db.findOne(this._userColllection, { username }, { collation: { locale: 'en', strength: 2 } });
|
const data = await this.db.findOne(this._userColllection, { name }, { collation: { locale: 'en', strength: 2 } });
|
||||||
if (!data)
|
if (!data)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@ -173,7 +170,7 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
// If a user with the same username already exists, force the holder of the discord account to create a new account or log in to the other account and link them
|
// If a user with the same username already exists, force the holder of the discord account to create a new account or log in to the other account and link them
|
||||||
const existing = await this.db.findOne(this._userColllection, { username: profile.username }, { collation: { locale: 'en', strength: 2 } });
|
const existing = await this.db.findOne(this._userColllection, { name: profile.username }, { collation: { locale: 'en', strength: 2 } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const temp = this._createUser({ type: 'user', temporary: true, displayName: profile.username });
|
const temp = this._createUser({ type: 'user', temporary: true, displayName: profile.username });
|
||||||
temp.addExternalProfile('discord', profile);
|
temp.addExternalProfile('discord', profile);
|
||||||
@ -201,11 +198,10 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
* @param {string} password
|
* @param {string} password
|
||||||
* @memberof UserDatabase
|
* @memberof UserDatabase
|
||||||
*/
|
*/
|
||||||
async createUser (username, password) {
|
async createUser (name, password) {
|
||||||
|
|
||||||
const user = this._createUser({
|
const user = this._createUser({
|
||||||
username,
|
name
|
||||||
type: 'user'
|
|
||||||
});
|
});
|
||||||
await user.setPassword(password);
|
await user.setPassword(password);
|
||||||
await this.updateUser(user);
|
await this.updateUser(user);
|
||||||
@ -252,7 +248,7 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
* @memberof UserDatabase
|
* @memberof UserDatabase
|
||||||
*/
|
*/
|
||||||
async updateUser (user) {
|
async updateUser (user) {
|
||||||
const { json } = user;
|
const { jsonPrivate: json } = user;
|
||||||
if (user._id)
|
if (user._id)
|
||||||
await this.db.updateOne(this._userColllection, { _id: new ObjectId(user._id) }, json);
|
await this.db.updateOne(this._userColllection, { _id: new ObjectId(user._id) }, json);
|
||||||
else {
|
else {
|
||||||
@ -263,7 +259,7 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateApplication (app) {
|
async updateApplication (app) {
|
||||||
const { json } = app;
|
const { jsonPrivate: json } = app;
|
||||||
await this.db.updateOne(this._appCollection, { _id: new ObjectId(json._id) }, json, true);
|
await this.db.updateOne(this._appCollection, { _id: new ObjectId(json._id) }, json, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,11 +275,20 @@ class UserDatabase extends AbstractUserDatabase {
|
|||||||
_createUser (data) {
|
_createUser (data) {
|
||||||
if (!data)
|
if (!data)
|
||||||
throw new Error(`Missing data to create user`);
|
throw new Error(`Missing data to create user`);
|
||||||
return new User(this, data);
|
if (data._id)
|
||||||
|
data.id = data._id;
|
||||||
|
if (!data.id)
|
||||||
|
data.id = new ObjectId();
|
||||||
|
|
||||||
|
return new User(this.updateUser.bind(this), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
_createApp (user, data) {
|
_createApp (data) {
|
||||||
return new UserApplicataion(user, data);
|
if (data._id)
|
||||||
|
data.id = data._id;
|
||||||
|
if (!data.id)
|
||||||
|
data.id = new ObjectId();
|
||||||
|
return new UserApplication(this.updateApplication.bind(this), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -59,7 +59,7 @@ class UserEndpoint extends ApiEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user (req, res) {
|
user (req, res) {
|
||||||
return res.json(req.user.safeJson);
|
return res.json(req.user.json);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadAvatar (req, res) {
|
async uploadAvatar (req, res) {
|
||||||
@ -239,7 +239,7 @@ class UserEndpoint extends ApiEndpoint {
|
|||||||
async applications (req, res) {
|
async applications (req, res) {
|
||||||
const { user } = req;
|
const { user } = req;
|
||||||
const applications = await user.fetchApplications();
|
const applications = await user.fetchApplications();
|
||||||
res.json(Object.values(applications).map(app => app.safeJson));
|
res.json(Object.values(applications).map(app => app.json));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createApplication (req, res) {
|
async createApplication (req, res) {
|
||||||
@ -249,7 +249,7 @@ class UserEndpoint extends ApiEndpoint {
|
|||||||
if (!name)
|
if (!name)
|
||||||
return res.status(400).send('Missing name');
|
return res.status(400).send('Missing name');
|
||||||
const application = await user.createApplication(name, opts);
|
const application = await user.createApplication(name, opts);
|
||||||
res.json(application.safeJson);
|
res.json(application.json);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ class UsersEndpoint extends ApiEndpoint {
|
|||||||
return res.status(400).send('Missing page number');
|
return res.status(400).send('Missing page number');
|
||||||
|
|
||||||
const users = await this.users.fetchUsers(page, amount);
|
const users = await this.users.fetchUsers(page, amount);
|
||||||
res.json(users.map(user => user.safeJson));
|
res.json(users.map(user => user.json));
|
||||||
}
|
}
|
||||||
|
|
||||||
async user (req, res) {
|
async user (req, res) {
|
||||||
@ -39,7 +39,7 @@ class UsersEndpoint extends ApiEndpoint {
|
|||||||
if (!user)
|
if (!user)
|
||||||
return res.status(404).end();
|
return res.status(404).end();
|
||||||
|
|
||||||
res.json(user.safeJson);
|
res.json(user.json);
|
||||||
}
|
}
|
||||||
|
|
||||||
async avatar (req, res) {
|
async avatar (req, res) {
|
||||||
@ -60,7 +60,7 @@ class UsersEndpoint extends ApiEndpoint {
|
|||||||
return res.status(404).send('Could not find the user');
|
return res.status(404).send('Could not find the user');
|
||||||
|
|
||||||
const applications = await user.fetchApplications();
|
const applications = await user.fetchApplications();
|
||||||
res.json(Object.values(applications).map(app => app.safeJson));
|
res.json(Object.values(applications).map(app => app.json));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
120
src/server/interfaces/AbstractUser.js
Normal file
120
src/server/interfaces/AbstractUser.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
const { ObjectId } = require("mongodb");
|
||||||
|
const { PermissionManager } = require("../../util");
|
||||||
|
|
||||||
|
class AbstractUser {
|
||||||
|
|
||||||
|
static ProtectedFields = [ '_id' ];
|
||||||
|
|
||||||
|
#_id = null;
|
||||||
|
#_name = null;
|
||||||
|
#_disabled = null;
|
||||||
|
#_permissions = null;
|
||||||
|
#_cachedTimestamp = null;
|
||||||
|
#_createdTimestamp = null;
|
||||||
|
#_icon = null;
|
||||||
|
|
||||||
|
#saveFunc = null;
|
||||||
|
|
||||||
|
constructor (saveFunction, { name, disabled, id, permissions, createdTimestamp, icon } = {}) {
|
||||||
|
if (this.constructor === AbstractUser)
|
||||||
|
throw new Error('This class cannot be instantiated, only derived');
|
||||||
|
|
||||||
|
if (!saveFunction || typeof saveFunction !== 'function')
|
||||||
|
throw new Error('Missing save function');
|
||||||
|
|
||||||
|
this.#saveFunc = saveFunction;
|
||||||
|
|
||||||
|
if (typeof id !== 'string' && !(id instanceof ObjectId))
|
||||||
|
throw new Error(`Missing ID: expecting string or ObjectId, got ${typeof id}`);
|
||||||
|
if (id instanceof ObjectId)
|
||||||
|
id = id.toString();
|
||||||
|
|
||||||
|
if (typeof name !== 'string')
|
||||||
|
throw new Error(`Missing name: expecting string, got ${typeof name}`);
|
||||||
|
|
||||||
|
this.#_id = id;
|
||||||
|
this.#_name = name;
|
||||||
|
this.#_disabled = disabled || false;
|
||||||
|
this.#_permissions = PermissionManager.merge(permissions || {}, PermissionManager.DefaultPermissions);
|
||||||
|
this.#_cachedTimestamp = Date.now();
|
||||||
|
this.#_createdTimestamp = createdTimestamp || Date.now();
|
||||||
|
this.#_icon = icon || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
save () {
|
||||||
|
return this.#saveFunc(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPermission (perm, level = 1) {
|
||||||
|
return PermissionManager.checkPermissions(this.permissions, perm, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON data to be stored in DB, not safe to return as a response
|
||||||
|
*
|
||||||
|
* @memberof AbstractUser
|
||||||
|
*/
|
||||||
|
get jsonPrivate () {
|
||||||
|
return {
|
||||||
|
_id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
disabled: this.disabled,
|
||||||
|
permissions: this.permissions,
|
||||||
|
createdTimestamp: this.createdTimestamp,
|
||||||
|
icon: this.icon
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe to respond to requests with
|
||||||
|
*
|
||||||
|
* @readonly
|
||||||
|
* @memberof AbstractUser
|
||||||
|
*/
|
||||||
|
get json () {
|
||||||
|
const json = this.jsonPrivate;
|
||||||
|
// `_id` is omitted, instead using `id`
|
||||||
|
for (const key of AbstractUser.ProtectedFields)
|
||||||
|
delete json[key];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...json,
|
||||||
|
id: this.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get id () {
|
||||||
|
return this.#_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name () {
|
||||||
|
return this.#_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get disabled () {
|
||||||
|
return this.#_disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
get icon () {
|
||||||
|
return this.#_icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
get permissions () {
|
||||||
|
return Object.freeze({ ...this.#_permissions });
|
||||||
|
}
|
||||||
|
|
||||||
|
get createdAt () {
|
||||||
|
return new Date(this.#_createdTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
get createdTimestamp () {
|
||||||
|
return this.#_cachedTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cachedTimestamp () {
|
||||||
|
return this.#_cachedTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AbstractUser;
|
@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
*
|
* Interface class, anything that interacts with a user database within the framework is implemented against this
|
||||||
* @abstract
|
* @abstract
|
||||||
* @class AbstractUserDatabase
|
* @class UserDatabaseInterface
|
||||||
*/
|
*/
|
||||||
class AbstractUserDatabase {
|
class UserDatabaseInterface {
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
if (this.constructor === UserDatabaseInterface)
|
||||||
|
throw new Error('This class cannot be instantiated, only derived');
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch by ID
|
// Fetch by ID
|
||||||
fetchUser () {
|
fetchUser () {
|
||||||
@ -31,4 +36,4 @@ class AbstractUserDatabase {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = AbstractUserDatabase;
|
module.exports = UserDatabaseInterface;
|
@ -1,119 +1,114 @@
|
|||||||
const Argon2 = require('argon2');
|
const Argon2 = require('argon2');
|
||||||
const { ObjectId } = require('mongodb');
|
const { ObjectId } = require('mongodb');
|
||||||
const { Util, PermissionManager } = require('../../util');
|
const { Util } = require('../../util');
|
||||||
const UserApplicataion = require('./UserApplication');
|
const { AbstractUser } = require('../interfaces');
|
||||||
|
const UserApplication = require('./UserApplication');
|
||||||
|
|
||||||
// Fields omitted in safeJson
|
// Fields omitted in the json getter
|
||||||
// Should be keys used in the json, not the ones defined in the constructor
|
// Should be keys used in jsonPrivate, not the ones defined in the constructor
|
||||||
const ProtectedFields = [ '_id', 'otpSecret', 'password' ];
|
// const ProtectedFields = [ 'otpSecret', 'password' ];
|
||||||
|
|
||||||
class User {
|
class User extends AbstractUser {
|
||||||
|
|
||||||
static validTypes = [];
|
#passwordHash = null;
|
||||||
|
#otpSecret = null;
|
||||||
|
#_mfa = null;
|
||||||
|
|
||||||
constructor (db, data) {
|
#_displayName = null;
|
||||||
|
#_externalProfiles = null;
|
||||||
|
#_applications = null;
|
||||||
|
|
||||||
this._db = db;
|
constructor (saveFunc, data) {
|
||||||
|
|
||||||
this.temporary = data.temporary || false;
|
super(saveFunc, data);
|
||||||
this.disabled = data.disabled || false;
|
|
||||||
|
|
||||||
this._id = data._id || null;
|
AbstractUser.ProtectedFields.push('otpSecret', 'password');
|
||||||
if (this.temporary)
|
|
||||||
this._tempId = `temp-${Date.now()}`;
|
|
||||||
|
|
||||||
if (!data.username && !data.temporary)
|
this.#_applications = data.applications || [];
|
||||||
throw new Error('Missing name for user');
|
this.#_externalProfiles = data.externalProfiles || {};
|
||||||
this.username = data.username || 'temporary user';
|
|
||||||
this._displayName = data.displayName || null;
|
|
||||||
|
|
||||||
if (!data.type)
|
|
||||||
throw new Error('Missing type for user');
|
|
||||||
if (User.validTypes.length && !User.validTypes.includes(data.type))
|
|
||||||
throw new Error('Invalid user type');
|
|
||||||
this.type = data.type;
|
|
||||||
|
|
||||||
this.applications = {};
|
|
||||||
this._applications = data.applications || [];
|
|
||||||
|
|
||||||
this.externalProfiles = data.externalProfiles || {};
|
|
||||||
this.permissions = PermissionManager.merge(data.permissions || {}, PermissionManager.DefaultPermissions);
|
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
this._passwordHash = data.password || null;
|
this.#passwordHash = data.password || null;
|
||||||
this._otpSecret = data.otpSecret || null;
|
this.#otpSecret = data.otpSecret || null;
|
||||||
this._2fa = data.twoFactor || false;
|
this.#_mfa = data.twoFactor || false;
|
||||||
|
this.#_displayName = data.displyName || null;
|
||||||
|
|
||||||
this.cachedTimestamp = Date.now();
|
|
||||||
this.createdTimestamp = data.createdTimestamp || Date.now();
|
|
||||||
|
|
||||||
this.avatar = data.avatar || null;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
get id () {
|
|
||||||
return this._id || this._tempId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayName () {
|
get displayName () {
|
||||||
return this._displayName || this.username;
|
return this.#_displayName || this.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
get username () {
|
||||||
|
return this.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
get passwordLoginEnabled () {
|
get passwordLoginEnabled () {
|
||||||
return this._passwordHash !== null;
|
return this._passwordHash !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchApplications () {
|
get avatar () {
|
||||||
const apps = await Promise.all(this._applications.map(id => this._db.fetchApplication(id)));
|
return this.icon;
|
||||||
for (const app of apps)
|
|
||||||
this.applications[app.id] = app;
|
|
||||||
return this.applications;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createApplication (name, { description } = {}) {
|
get mfa () {
|
||||||
const _id = new ObjectId();
|
return this.#_mfa;
|
||||||
// User id : app id : creation time : random string
|
}
|
||||||
const rawToken = `${this.id}:${_id}:${Date.now()}:${Util.randomString(8)}`;
|
|
||||||
const token = Util.encrypt(rawToken, Buffer.from(process.env.ENCRYPTION_KEY, 'base64'));
|
get externalProfiles () {
|
||||||
|
return Object.freeze({ ...this.#_externalProfiles });
|
||||||
|
}
|
||||||
|
|
||||||
|
// async fetchApplications () {
|
||||||
|
// const apps = await Promise.all(this._applications.map(id => this._db.fetchApplication(id)));
|
||||||
|
// for (const app of apps)
|
||||||
|
// this.applications[app.id] = app;
|
||||||
|
// return this.applications;
|
||||||
|
// }
|
||||||
|
|
||||||
|
async createApplication (name, { description } = {}, saveFn) {
|
||||||
|
if (typeof saveFn !== 'function')
|
||||||
|
return Promise.reject(new Error('Missing save function for application'));
|
||||||
|
const id = new ObjectId().toString();
|
||||||
|
const token = Util.randomUUID();
|
||||||
const opts = {
|
const opts = {
|
||||||
name,
|
name,
|
||||||
_id,
|
id,
|
||||||
token,
|
token,
|
||||||
description
|
description,
|
||||||
|
ownerId: this.id
|
||||||
};
|
};
|
||||||
const application = new UserApplicataion(this, opts);
|
const application = new UserApplication(saveFn, opts);
|
||||||
await application.save();
|
await application.save();
|
||||||
this.applications[_id] = application;
|
this.#_applications.push(id);
|
||||||
this._applications.push(_id);
|
|
||||||
await this.save();
|
await this.save();
|
||||||
return application;
|
return application;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteApplication (id) {
|
// async deleteApplication (id) {
|
||||||
const app = this._db.fetchApplication(id);
|
// const app = this._db.fetchApplication(id);
|
||||||
if (!app)
|
// if (!app)
|
||||||
return null;
|
// return null;
|
||||||
|
// }
|
||||||
|
|
||||||
}
|
// attachApplication (app) {
|
||||||
|
// this.applications[app.id] = app;
|
||||||
attachApplication (app) {
|
// }
|
||||||
this.applications[app.id] = app;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setPassword (passwd, save = false) {
|
async setPassword (passwd, save = false) {
|
||||||
const hash = await Argon2.hash(passwd);
|
const hash = await Argon2.hash(passwd);
|
||||||
this._passwordHash = hash;
|
this.#passwordHash = hash;
|
||||||
if (save)
|
if (save)
|
||||||
await this.save();
|
await this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate (passwd) {
|
async authenticate (passwd) {
|
||||||
const result = await Argon2.verify(this._passwordHash, passwd);
|
const result = await Argon2.verify(this.#passwordHash, passwd);
|
||||||
if (result)
|
if (!result) {
|
||||||
return true;
|
// TODO: potentially add something to track failed attempts
|
||||||
|
}
|
||||||
// TODO: potentially add something to track failed attempts
|
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,58 +120,29 @@ class User {
|
|||||||
*/
|
*/
|
||||||
addExternalProfile (platform, profile) {
|
addExternalProfile (platform, profile) {
|
||||||
profile.provider = platform;
|
profile.provider = platform;
|
||||||
this.externalProfiles[platform] = profile;
|
this.#_externalProfiles[platform] = profile;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* @param {string} perm Permission to check for
|
|
||||||
* @return {boolean}
|
|
||||||
* @memberof User
|
|
||||||
*/
|
|
||||||
hasPermission (perm, level = 1) {
|
|
||||||
return PermissionManager.checkPermissions(this.permissions, perm, level);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasExternalProfile (name) {
|
hasExternalProfile (name) {
|
||||||
return Boolean(this.externalProfiles[name]);
|
return Boolean(this.externalProfiles[name]);
|
||||||
}
|
}
|
||||||
|
|
||||||
save () {
|
get jsonPrivate () {
|
||||||
return this._db.updateUser(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveApplication (app) {
|
|
||||||
return this._db.updateApplication(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
get json () {
|
|
||||||
return {
|
return {
|
||||||
_id: this._id,
|
...super.jsonPrivate,
|
||||||
username: this.username,
|
displayName: this.displayName,
|
||||||
displayName: this._displayName,
|
|
||||||
type: this.type,
|
|
||||||
permissions: this.permissions,
|
|
||||||
externalProfiles: this.externalProfiles,
|
externalProfiles: this.externalProfiles,
|
||||||
password: this._passwordHash,
|
password: this.#passwordHash,
|
||||||
otpSecret: this._otpSecret,
|
otpSecret: this.#otpSecret,
|
||||||
twoFactor: this._2fa,
|
twoFactor: this.mfa,
|
||||||
applications: this._applications,
|
applications: this.#_applications,
|
||||||
createdTimestamp: this.createdTimestamp,
|
|
||||||
disabled: this.disabled,
|
|
||||||
avatar: this.avatar,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get safeJson () {
|
get json () {
|
||||||
const { json } = this;
|
const json = super.json;
|
||||||
for (const key of ProtectedFields)
|
|
||||||
delete json[key];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...json,
|
...json,
|
||||||
id: this.id,
|
|
||||||
externalProfiles: Object.values(this.externalProfiles).map(prof => {
|
externalProfiles: Object.values(this.externalProfiles).map(prof => {
|
||||||
return { id: prof.id, provider: prof.provider, username: prof.username };
|
return { id: prof.id, provider: prof.provider, username: prof.username };
|
||||||
}),
|
}),
|
||||||
@ -184,10 +150,6 @@ class User {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static setValidTypes (types) {
|
|
||||||
User.validTypes = types;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = User;
|
module.exports = User;
|
@ -1,67 +1,50 @@
|
|||||||
const { PermissionManager } = require("../../util");
|
const { AbstractUser } = require("../interfaces");
|
||||||
|
|
||||||
// Fields omitted in safeJson
|
class UserApplicataion extends AbstractUser {
|
||||||
// Should be keys used in the json, not the ones defined in the constructor
|
|
||||||
const ProtectedFields = [ '_id' ];
|
|
||||||
class UserApplicataion {
|
|
||||||
|
|
||||||
constructor (user, options) {
|
#_token = null;
|
||||||
|
#_user = null;
|
||||||
|
#_description = null;
|
||||||
|
|
||||||
if (!user)
|
constructor (saveFunc, options = {}) {
|
||||||
throw new Error('Missing user for user application');
|
|
||||||
this.user = user;
|
|
||||||
|
|
||||||
if (!options._id)
|
super(saveFunc, options);
|
||||||
throw new Error('Missing id for application');
|
|
||||||
this.id = options._id;
|
|
||||||
|
|
||||||
if (!options.name)
|
if (typeof options.token !== 'string')
|
||||||
throw new Error('Missing name for application');
|
|
||||||
this.name = options.name;
|
|
||||||
|
|
||||||
if (!options.token)
|
|
||||||
throw new Error('Missing token for appliaction');
|
throw new Error('Missing token for appliaction');
|
||||||
this.token = options.token;
|
this.#_token = options.token;
|
||||||
|
|
||||||
this.description = options.description || null;
|
if (typeof options.ownerId !== 'string')
|
||||||
|
throw new Error('Missing ownerId (owner) for application');
|
||||||
|
this.#_user = options.ownerId;
|
||||||
|
|
||||||
this.permissions = options.permissions || {};
|
this.#_description = options.description || null;
|
||||||
this.createdAt = options.createdAt || Date.now();
|
|
||||||
this.type = 'application';
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
save () {
|
get ownerId () {
|
||||||
return this.user.saveApplication(this);
|
return this.#_user;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPermission (perm, level = 1) {
|
get description () {
|
||||||
return PermissionManager.checkPermissions(this.permissions, perm, level);
|
return this.#_description;
|
||||||
}
|
}
|
||||||
|
|
||||||
get json () {
|
get token () {
|
||||||
|
return this.#_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
get jsonPrivate () {
|
||||||
return {
|
return {
|
||||||
_id: this.id,
|
...super.jsonPrivate,
|
||||||
name: this.name,
|
|
||||||
permissions: this.permissions,
|
|
||||||
token: this.token,
|
token: this.token,
|
||||||
createdAt: this.createdAt,
|
ownerId: this.#_user,
|
||||||
user: this.user.id,
|
|
||||||
description: this.description,
|
description: this.description,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get safeJson () {
|
get json () {
|
||||||
|
return super.json;
|
||||||
const { json } = this;
|
|
||||||
for (const key of ProtectedFields)
|
|
||||||
delete json[key];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...json,
|
|
||||||
id: this.id,
|
|
||||||
token: this.token.encrypted,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
User: require('./User')
|
User: require('./User'),
|
||||||
|
UserApplication: require('./UserApplication')
|
||||||
};
|
};
|
@ -1,4 +1,6 @@
|
|||||||
const { scryptSync, createCipheriv, randomFillSync, createDecipheriv } = require('node:crypto');
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { scryptSync, createCipheriv, randomFillSync, createDecipheriv, randomUUID } = require('node:crypto');
|
||||||
const CRYPTO_ALGO = 'id-aes256-GCM';
|
const CRYPTO_ALGO = 'id-aes256-GCM';
|
||||||
|
|
||||||
class Util {
|
class Util {
|
||||||
@ -98,9 +100,11 @@ class Util {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static cryptoRandomString (len = 32, base = 'utf8') {
|
static cryptoRandomString (len = 32, base = 'utf8') {
|
||||||
|
|
||||||
return randomFillSync(Buffer.alloc(len)).toString(base);
|
return randomFillSync(Buffer.alloc(len)).toString(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
static randomUUID () {
|
||||||
|
return randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
static createEncryptionKey (secret, salt, len = 32) {
|
static createEncryptionKey (secret, salt, len = 32) {
|
||||||
@ -172,6 +176,37 @@ class Util {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read directory recursively and return all file paths
|
||||||
|
* @static
|
||||||
|
* @param {string} directory Full path to target directory
|
||||||
|
* @param {boolean} [ignoreDotfiles=true]
|
||||||
|
* @return {string[]} Array with the paths to the files within the directory
|
||||||
|
* @memberof Util
|
||||||
|
*/
|
||||||
|
static readdirRecursive (directory, ignoreDotfiles = true) {
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
(function read (dir) {
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith('.') && ignoreDotfiles)
|
||||||
|
continue;
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
|
||||||
|
if (fs.statSync(filePath).isDirectory()) {
|
||||||
|
read(filePath);
|
||||||
|
} else {
|
||||||
|
result.push(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(directory));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Util;
|
module.exports = Util;
|
@ -5,42 +5,47 @@ beforeEach(() => {
|
|||||||
perms = { developer: { default: 5 }, administrator: 10 };
|
perms = { developer: { default: 5 }, administrator: 10 };
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Constructor', () => {
|
describe('PermissionManager', () => {
|
||||||
expect(() => new PermissionManager()).toThrow();
|
test('Constructor', () => {
|
||||||
});
|
expect(() => new PermissionManager()).toThrow();
|
||||||
|
});
|
||||||
test('Ensure permission', () => {
|
|
||||||
|
test('Ensure permission', () => {
|
||||||
expect(() => PermissionManager.ensurePermission()).toThrow();
|
|
||||||
|
expect(() => PermissionManager.ensurePermission()).toThrow();
|
||||||
expect(PermissionManager.DefaultPermissions).toEqual({});
|
|
||||||
|
expect(PermissionManager.DefaultPermissions).toEqual({});
|
||||||
PermissionManager.ensurePermission('administrator:removeuser:force:bruh:moment:broo');
|
|
||||||
PermissionManager.ensurePermission('developer:toggledebug');
|
PermissionManager.ensurePermission('administrator:removeuser:force:bruh:moment:broo');
|
||||||
PermissionManager.ensurePermission('developer');
|
PermissionManager.ensurePermission('developer:toggledebug');
|
||||||
|
PermissionManager.ensurePermission('developer');
|
||||||
expect(PermissionManager.DefaultPermissions).toHaveProperty('administrator.removeuser.force');
|
|
||||||
expect(PermissionManager.DefaultPermissions).toHaveProperty('developer.toggledebug');
|
expect(PermissionManager.DefaultPermissions).toHaveProperty('administrator.removeuser.force');
|
||||||
expect(PermissionManager.DefaultPermissions).not.toHaveProperty('developer.removeuser.force');
|
expect(PermissionManager.DefaultPermissions).toHaveProperty('developer.toggledebug');
|
||||||
|
expect(PermissionManager.DefaultPermissions).not.toHaveProperty('developer.removeuser.force');
|
||||||
});
|
|
||||||
|
});
|
||||||
test('Test permission merge', () => {
|
|
||||||
|
test('Test permission merge', () => {
|
||||||
expect(perms).toHaveProperty('administrator', 10);
|
|
||||||
PermissionManager.ensurePermission('administrator:removeuser:force');
|
expect(perms).toHaveProperty('administrator', 10);
|
||||||
PermissionManager.merge(perms, PermissionManager.DefaultPermissions);
|
PermissionManager.ensurePermission('administrator:removeuser:force');
|
||||||
expect(perms).toHaveProperty('administrator.removeuser.force');
|
PermissionManager.merge(perms, PermissionManager.DefaultPermissions);
|
||||||
expect(perms).toHaveProperty('administrator.default', 10);
|
expect(perms).toHaveProperty('administrator.removeuser.force');
|
||||||
|
expect(perms).toHaveProperty('administrator.default', 10);
|
||||||
});
|
|
||||||
|
});
|
||||||
test('Check for permission', () => {
|
|
||||||
|
test('Check for permission', () => {
|
||||||
expect(() => PermissionManager.checkPermissions()).toThrow();
|
|
||||||
expect(() => PermissionManager.checkPermissions(perms)).toThrow();
|
expect(() => PermissionManager.checkPermissions()).toThrow();
|
||||||
|
expect(() => PermissionManager.checkPermissions(perms)).toThrow();
|
||||||
expect(PermissionManager.checkPermissions(perms, 'administrator', 10)).toBe(true);
|
|
||||||
expect(PermissionManager.checkPermissions(perms, 'developer', 5)).toBe(true);
|
expect(PermissionManager.checkPermissions(perms, 'administrator', 10)).toBe(true);
|
||||||
expect(PermissionManager.checkPermissions(perms, 'developer', 10)).toBe(false);
|
expect(PermissionManager.checkPermissions(perms, 'developer', 5)).toBe(true);
|
||||||
|
expect(PermissionManager.checkPermissions(perms, 'developer', 10)).toBe(false);
|
||||||
|
|
||||||
|
expect(PermissionManager.checkPermissions(perms, 'bargus', 0)).toBe(false);
|
||||||
|
|
||||||
|
});
|
||||||
});
|
});
|
100
tests/UserStructures.test.js
Normal file
100
tests/UserStructures.test.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
const AbstractUser = require('../src/server/interfaces/AbstractUser');
|
||||||
|
const { User, UserApplication } = require('../src/server/structures');
|
||||||
|
const { Util } = require('../src/util');
|
||||||
|
|
||||||
|
test('Abstract user', () => {
|
||||||
|
expect(() => new AbstractUser()).toThrow();
|
||||||
|
const Extended = class extends AbstractUser { };
|
||||||
|
|
||||||
|
expect(() => new Extended()).toThrow();
|
||||||
|
expect(() => new Extended(() => null)).toThrow();
|
||||||
|
expect(() => new Extended(() => null, { id: 123 })).toThrow();
|
||||||
|
expect(() => new Extended(() => null, { id: '123' })).toThrow();
|
||||||
|
expect(() => new Extended(() => null, { id: '123', name: 'dingus' })).not.toThrow();
|
||||||
|
|
||||||
|
const save = jest.fn();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const permissions = {
|
||||||
|
administrator: 5
|
||||||
|
};
|
||||||
|
const instance = new Extended(save, { id: '1ph45', name: 'navy', permissions, createdTimestamp: now });
|
||||||
|
instance.save();
|
||||||
|
|
||||||
|
expect(save).toHaveBeenCalled();
|
||||||
|
expect(instance.hasPermission('administrator', 10)).toBe(false);
|
||||||
|
expect(instance.hasPermission('administrator')).toBe(true);
|
||||||
|
expect(instance.json).toHaveProperty('id');
|
||||||
|
expect(instance.jsonPrivate).toHaveProperty('_id');
|
||||||
|
expect(instance.createdAt.getTime()).toEqual(now);
|
||||||
|
expect(instance.cachedTimestamp).toBeGreaterThanOrEqual(now);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User structure', async () => {
|
||||||
|
|
||||||
|
expect(() => new User()).toThrow();
|
||||||
|
const save = jest.fn();
|
||||||
|
const permissions = { developer: 5 };
|
||||||
|
const id = Util.randomString();
|
||||||
|
const name = 'navy';
|
||||||
|
const password = Util.randomString();
|
||||||
|
const instance = new User(save, { id, name, permissions, password });
|
||||||
|
|
||||||
|
expect(instance.passwordLoginEnabled).toBe(true);
|
||||||
|
expect(instance.avatar).toBe(null);
|
||||||
|
|
||||||
|
const saveApp = jest.fn();
|
||||||
|
const appName = 'ship';
|
||||||
|
const description = 'A ship';
|
||||||
|
await expect(() => instance.createApplication(appName, { description })).rejects.toThrow();
|
||||||
|
const app = await instance.createApplication(appName, { description }, saveApp);
|
||||||
|
expect(app.name).toBe(appName);
|
||||||
|
expect(app.description).toBe(description);
|
||||||
|
expect(saveApp).toHaveBeenCalledWith(app);
|
||||||
|
|
||||||
|
const pw = 'Pw123';
|
||||||
|
await instance.setPassword(pw, true);
|
||||||
|
expect(instance.passwordLoginEnabled).toBe(true);
|
||||||
|
expect(await instance.authenticate(pw)).toBe(true);
|
||||||
|
|
||||||
|
await expect(() => instance.setPassword({ prop: 'val' })).rejects.toThrow();
|
||||||
|
|
||||||
|
const profile = { username: 'dingus', id: 'asf231t' };
|
||||||
|
instance.addExternalProfile('platform', profile);
|
||||||
|
expect(instance.json.externalProfiles[0]).toEqual(profile);
|
||||||
|
expect(instance.hasExternalProfile('platform')).toEqual(true);
|
||||||
|
|
||||||
|
expect(instance.jsonPrivate).toHaveProperty('_id', id);
|
||||||
|
expect(instance.json).toHaveProperty('id', id);
|
||||||
|
expect(instance.json).toHaveProperty('displayName', name);
|
||||||
|
expect(instance.json).not.toHaveProperty('password');
|
||||||
|
|
||||||
|
instance.save();
|
||||||
|
expect(save).toHaveBeenCalledWith(instance);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Application structure', () => {
|
||||||
|
expect(() => new UserApplication()).toThrow();
|
||||||
|
|
||||||
|
const save = jest.fn();
|
||||||
|
const permissions = { developer: 5 };
|
||||||
|
const id = Util.randomString();
|
||||||
|
const ownerId = Util.randomString();
|
||||||
|
const name = 'ship';
|
||||||
|
const token = Util.randomUUID();
|
||||||
|
const instance = new UserApplication(save, { id, name, permissions, token, ownerId });
|
||||||
|
|
||||||
|
expect(instance.ownerId).toEqual(ownerId);
|
||||||
|
expect(instance.jsonPrivate).toHaveProperty('_id', id);
|
||||||
|
expect(instance.jsonPrivate).toHaveProperty('token', token);
|
||||||
|
expect(instance.json).toHaveProperty('id', id);
|
||||||
|
expect(instance.json).toHaveProperty('name', name);
|
||||||
|
expect(instance.json).toHaveProperty('ownerId', ownerId);
|
||||||
|
expect(instance.json).not.toHaveProperty('password');
|
||||||
|
|
||||||
|
instance.save();
|
||||||
|
expect(save).toHaveBeenCalledWith(instance);
|
||||||
|
|
||||||
|
});
|
@ -1950,6 +1950,11 @@ color-support@^1.1.2:
|
|||||||
resolved "https://registry.corgi.wtf/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
|
resolved "https://registry.corgi.wtf/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
|
||||||
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
|
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
|
||||||
|
|
||||||
|
commandparser@^1.0.24:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.corgi.wtf/commandparser/-/commandparser-1.1.2.tgz#9e1c0e9431b13cae1f612ac97de7aed7b8da9773"
|
||||||
|
integrity sha512-zOicKRPAapf4W7EffUQXFjSlpMkZzgOJ3/Xq/KiHWqZKEHVKVj4vUMKuisU2y3tlxAEmoon5hxGkjeViyy42iQ==
|
||||||
|
|
||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
version "0.0.1"
|
version "0.0.1"
|
||||||
resolved "https://registry.corgi.wtf/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
resolved "https://registry.corgi.wtf/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||||
|
Loading…
Reference in New Issue
Block a user