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
|
||||
**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`
|
||||
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/passport-discord": "^0.2.2-b",
|
||||
"argon2": "^0.30.2",
|
||||
"commandparser": "^1.0.24",
|
||||
"connect-mongo": "^4.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"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
|
||||
const { MasterLogger } = require('@navy.gif/logger');
|
||||
const { Parser: CommandParser } = require('commandparser');
|
||||
const { Collection } = require('@discordjs/collection');
|
||||
|
||||
// Local
|
||||
const Shard = require('./Shard');
|
||||
const { Util } = require('../util');
|
||||
|
||||
class Controller extends EventEmitter {
|
||||
|
||||
@ -20,6 +22,7 @@ class Controller extends EventEmitter {
|
||||
this._debug = options.debug || false;
|
||||
|
||||
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)
|
||||
this.serverFilePath = path.resolve(options.serverFilePath);
|
||||
@ -54,6 +57,25 @@ class Controller extends EventEmitter {
|
||||
const { shardCount = 1, shardOptions = {}, serverOptions = {}, logger = {}, discord = {}, databases = {} } = this._options;
|
||||
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 = [];
|
||||
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 });
|
||||
@ -92,9 +114,47 @@ class Controller extends EventEmitter {
|
||||
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 () {
|
||||
this.logger.info('Received SIGINT, shutting down');
|
||||
const promises = this.shards.map(shard => shard.awaitShutdown());
|
||||
setTimeout(process.exit, 90_000);
|
||||
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`);
|
||||
|
||||
|
@ -34,6 +34,8 @@ class Shard extends EventEmitter {
|
||||
|
||||
this._awaitingShutdown = null;
|
||||
|
||||
this.awaitingResponse = new Map();
|
||||
|
||||
}
|
||||
|
||||
async spawn (waitForReady = false) {
|
||||
@ -106,18 +108,30 @@ class Shard extends EventEmitter {
|
||||
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) => {
|
||||
if (this.ready && this.process) {
|
||||
|
||||
if (expectResponse) {
|
||||
message._id = Util.randomUUID();
|
||||
const to = setTimeout(reject, 10_000, [ new Error('Message timeout') ]);
|
||||
this.awaitingResponse.set(message._id, (...args) => {
|
||||
clearTimeout(to);
|
||||
resolve(...args);
|
||||
});
|
||||
}
|
||||
|
||||
this.process.send(message, err => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
return reject(err);
|
||||
|
||||
if (!expectResponse)
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
reject(new Error(`[shard-${this.id}] Cannot send message to dead shard.`));
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@ -147,7 +161,11 @@ class Shard extends EventEmitter {
|
||||
this.ready = false;
|
||||
this.fatal = true;
|
||||
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 Authenticator = require('./middleware/Authenticator');
|
||||
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
|
||||
@ -81,6 +83,7 @@ class Server extends EventEmitter {
|
||||
// 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') });
|
||||
|
||||
// TODO: Database definitions should probably be elsewhere through injection
|
||||
// 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 });
|
||||
// Mongo is used for session and user storage
|
||||
@ -95,6 +98,8 @@ class Server extends EventEmitter {
|
||||
MONGO_AUTH_DB
|
||||
});
|
||||
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
|
||||
this.memoryStoreProvider = new MongoDB(this, {
|
||||
@ -111,12 +116,12 @@ class Server extends EventEmitter {
|
||||
this.memoryCache = new MemoryCache(this, {
|
||||
provider: this.memoryStoreProvider
|
||||
});
|
||||
// Alias
|
||||
this.users = this.userDatabase;
|
||||
|
||||
this.rateLimiter = new RateLimiter(this, this.memoryCache);
|
||||
|
||||
// Authenticator takes care of sessions, logins and authorisations
|
||||
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
|
||||
name: `${pkg.name}.s`,
|
||||
cookie: {
|
||||
@ -228,7 +233,16 @@ class Server extends EventEmitter {
|
||||
|
||||
_handleMessage (msg) {
|
||||
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
|
||||
|
@ -1,18 +1,16 @@
|
||||
const { Collection } = require("@discordjs/collection");
|
||||
const { ObjectId } = require("mongodb");
|
||||
|
||||
const { AbstractUserDatabase } = require("../interfaces/");
|
||||
const { User } = require("../structures");
|
||||
const UserApplicataion = require("../structures/UserApplication");
|
||||
const { UserDatabaseInterface } = require("../interfaces/");
|
||||
const { User, UserApplication } = require("../structures");
|
||||
const { Util } = require("../../util");
|
||||
|
||||
// MongoDB based user db
|
||||
class UserDatabase extends AbstractUserDatabase {
|
||||
class UserDatabase extends UserDatabaseInterface {
|
||||
|
||||
constructor (server, db, {
|
||||
userColllection = 'users',
|
||||
appCollection = 'applications',
|
||||
validUserTypes,
|
||||
disableCache
|
||||
}) {
|
||||
|
||||
@ -21,7 +19,6 @@ class UserDatabase extends AbstractUserDatabase {
|
||||
this.logger = server.createLogger(this);
|
||||
this.cache = new Collection();
|
||||
this.userCollection = null;
|
||||
User.setValidTypes(validUserTypes);
|
||||
|
||||
this._userColllection = userColllection;
|
||||
this._appCollection = appCollection;
|
||||
@ -38,7 +35,7 @@ class UserDatabase extends AbstractUserDatabase {
|
||||
if (!page)
|
||||
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 = [];
|
||||
for (const user of data) {
|
||||
let u = this.cache.get(user._id);
|
||||
@ -63,7 +60,7 @@ class UserDatabase extends AbstractUserDatabase {
|
||||
if (id.includes('temp'))
|
||||
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)
|
||||
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');
|
||||
|
||||
let user = this.cache.find(u => u.username?.toLowerCase() === username.toLowerCase());
|
||||
let user = this.cache.find(u => u.username?.toLowerCase() === name.toLowerCase());
|
||||
if (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)
|
||||
return null;
|
||||
|
||||
@ -173,7 +170,7 @@ class UserDatabase extends AbstractUserDatabase {
|
||||
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
|
||||
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) {
|
||||
const temp = this._createUser({ type: 'user', temporary: true, displayName: profile.username });
|
||||
temp.addExternalProfile('discord', profile);
|
||||
@ -201,11 +198,10 @@ class UserDatabase extends AbstractUserDatabase {
|
||||
* @param {string} password
|
||||
* @memberof UserDatabase
|
||||
*/
|
||||
async createUser (username, password) {
|
||||
async createUser (name, password) {
|
||||
|
||||
const user = this._createUser({
|
||||
username,
|
||||
type: 'user'
|
||||
name
|
||||
});
|
||||
await user.setPassword(password);
|
||||
await this.updateUser(user);
|
||||
@ -252,7 +248,7 @@ class UserDatabase extends AbstractUserDatabase {
|
||||
* @memberof UserDatabase
|
||||
*/
|
||||
async updateUser (user) {
|
||||
const { json } = user;
|
||||
const { jsonPrivate: json } = user;
|
||||
if (user._id)
|
||||
await this.db.updateOne(this._userColllection, { _id: new ObjectId(user._id) }, json);
|
||||
else {
|
||||
@ -263,7 +259,7 @@ class UserDatabase extends AbstractUserDatabase {
|
||||
}
|
||||
|
||||
async updateApplication (app) {
|
||||
const { json } = app;
|
||||
const { jsonPrivate: json } = app;
|
||||
await this.db.updateOne(this._appCollection, { _id: new ObjectId(json._id) }, json, true);
|
||||
}
|
||||
|
||||
@ -279,11 +275,20 @@ class UserDatabase extends AbstractUserDatabase {
|
||||
_createUser (data) {
|
||||
if (!data)
|
||||
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) {
|
||||
return new UserApplicataion(user, data);
|
||||
_createApp (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) {
|
||||
return res.json(req.user.safeJson);
|
||||
return res.json(req.user.json);
|
||||
}
|
||||
|
||||
async uploadAvatar (req, res) {
|
||||
@ -239,7 +239,7 @@ class UserEndpoint extends ApiEndpoint {
|
||||
async applications (req, res) {
|
||||
const { user } = req;
|
||||
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) {
|
||||
@ -249,7 +249,7 @@ class UserEndpoint extends ApiEndpoint {
|
||||
if (!name)
|
||||
return res.status(400).send('Missing name');
|
||||
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');
|
||||
|
||||
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) {
|
||||
@ -39,7 +39,7 @@ class UsersEndpoint extends ApiEndpoint {
|
||||
if (!user)
|
||||
return res.status(404).end();
|
||||
|
||||
res.json(user.safeJson);
|
||||
res.json(user.json);
|
||||
}
|
||||
|
||||
async avatar (req, res) {
|
||||
@ -60,7 +60,7 @@ class UsersEndpoint extends ApiEndpoint {
|
||||
return res.status(404).send('Could not find the user');
|
||||
|
||||
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
|
||||
* @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
|
||||
fetchUser () {
|
||||
@ -31,4 +36,4 @@ class AbstractUserDatabase {
|
||||
|
||||
}
|
||||
|
||||
module.exports = AbstractUserDatabase;
|
||||
module.exports = UserDatabaseInterface;
|
@ -1,119 +1,114 @@
|
||||
const Argon2 = require('argon2');
|
||||
const { ObjectId } = require('mongodb');
|
||||
const { Util, PermissionManager } = require('../../util');
|
||||
const UserApplicataion = require('./UserApplication');
|
||||
const { Util } = require('../../util');
|
||||
const { AbstractUser } = require('../interfaces');
|
||||
const UserApplication = require('./UserApplication');
|
||||
|
||||
// Fields omitted in safeJson
|
||||
// Should be keys used in the json, not the ones defined in the constructor
|
||||
const ProtectedFields = [ '_id', 'otpSecret', 'password' ];
|
||||
// Fields omitted in the json getter
|
||||
// Should be keys used in jsonPrivate, not the ones defined in the constructor
|
||||
// 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;
|
||||
this.disabled = data.disabled || false;
|
||||
super(saveFunc, data);
|
||||
|
||||
this._id = data._id || null;
|
||||
if (this.temporary)
|
||||
this._tempId = `temp-${Date.now()}`;
|
||||
AbstractUser.ProtectedFields.push('otpSecret', 'password');
|
||||
|
||||
if (!data.username && !data.temporary)
|
||||
throw new Error('Missing name for user');
|
||||
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);
|
||||
this.#_applications = data.applications || [];
|
||||
this.#_externalProfiles = data.externalProfiles || {};
|
||||
|
||||
/** @private */
|
||||
this._passwordHash = data.password || null;
|
||||
this._otpSecret = data.otpSecret || null;
|
||||
this._2fa = data.twoFactor || false;
|
||||
this.#passwordHash = data.password || null;
|
||||
this.#otpSecret = data.otpSecret || null;
|
||||
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 () {
|
||||
return this._displayName || this.username;
|
||||
return this.#_displayName || this.username;
|
||||
}
|
||||
|
||||
get username () {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
get passwordLoginEnabled () {
|
||||
return this._passwordHash !== null;
|
||||
}
|
||||
|
||||
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;
|
||||
get avatar () {
|
||||
return this.icon;
|
||||
}
|
||||
|
||||
async createApplication (name, { description } = {}) {
|
||||
const _id = new ObjectId();
|
||||
// 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 mfa () {
|
||||
return this.#_mfa;
|
||||
}
|
||||
|
||||
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 = {
|
||||
name,
|
||||
_id,
|
||||
id,
|
||||
token,
|
||||
description
|
||||
description,
|
||||
ownerId: this.id
|
||||
};
|
||||
const application = new UserApplicataion(this, opts);
|
||||
const application = new UserApplication(saveFn, opts);
|
||||
await application.save();
|
||||
this.applications[_id] = application;
|
||||
this._applications.push(_id);
|
||||
this.#_applications.push(id);
|
||||
await this.save();
|
||||
return application;
|
||||
}
|
||||
|
||||
async deleteApplication (id) {
|
||||
const app = this._db.fetchApplication(id);
|
||||
if (!app)
|
||||
return null;
|
||||
// async deleteApplication (id) {
|
||||
// const app = this._db.fetchApplication(id);
|
||||
// if (!app)
|
||||
// return null;
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
attachApplication (app) {
|
||||
this.applications[app.id] = app;
|
||||
}
|
||||
// attachApplication (app) {
|
||||
// this.applications[app.id] = app;
|
||||
// }
|
||||
|
||||
async setPassword (passwd, save = false) {
|
||||
const hash = await Argon2.hash(passwd);
|
||||
this._passwordHash = hash;
|
||||
this.#passwordHash = hash;
|
||||
if (save)
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async authenticate (passwd) {
|
||||
const result = await Argon2.verify(this._passwordHash, passwd);
|
||||
if (result)
|
||||
return true;
|
||||
|
||||
const result = await Argon2.verify(this.#passwordHash, passwd);
|
||||
if (!result) {
|
||||
// TODO: potentially add something to track failed attempts
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -125,58 +120,29 @@ class User {
|
||||
*/
|
||||
addExternalProfile (platform, profile) {
|
||||
profile.provider = platform;
|
||||
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);
|
||||
this.#_externalProfiles[platform] = profile;
|
||||
}
|
||||
|
||||
hasExternalProfile (name) {
|
||||
return Boolean(this.externalProfiles[name]);
|
||||
}
|
||||
|
||||
save () {
|
||||
return this._db.updateUser(this);
|
||||
}
|
||||
|
||||
saveApplication (app) {
|
||||
return this._db.updateApplication(app);
|
||||
}
|
||||
|
||||
get json () {
|
||||
get jsonPrivate () {
|
||||
return {
|
||||
_id: this._id,
|
||||
username: this.username,
|
||||
displayName: this._displayName,
|
||||
type: this.type,
|
||||
permissions: this.permissions,
|
||||
...super.jsonPrivate,
|
||||
displayName: this.displayName,
|
||||
externalProfiles: this.externalProfiles,
|
||||
password: this._passwordHash,
|
||||
otpSecret: this._otpSecret,
|
||||
twoFactor: this._2fa,
|
||||
applications: this._applications,
|
||||
createdTimestamp: this.createdTimestamp,
|
||||
disabled: this.disabled,
|
||||
avatar: this.avatar,
|
||||
password: this.#passwordHash,
|
||||
otpSecret: this.#otpSecret,
|
||||
twoFactor: this.mfa,
|
||||
applications: this.#_applications,
|
||||
};
|
||||
}
|
||||
|
||||
get safeJson () {
|
||||
const { json } = this;
|
||||
for (const key of ProtectedFields)
|
||||
delete json[key];
|
||||
|
||||
get json () {
|
||||
const json = super.json;
|
||||
return {
|
||||
...json,
|
||||
id: this.id,
|
||||
externalProfiles: Object.values(this.externalProfiles).map(prof => {
|
||||
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;
|
@ -1,67 +1,50 @@
|
||||
const { PermissionManager } = require("../../util");
|
||||
const { AbstractUser } = require("../interfaces");
|
||||
|
||||
// Fields omitted in safeJson
|
||||
// Should be keys used in the json, not the ones defined in the constructor
|
||||
const ProtectedFields = [ '_id' ];
|
||||
class UserApplicataion {
|
||||
class UserApplicataion extends AbstractUser {
|
||||
|
||||
constructor (user, options) {
|
||||
#_token = null;
|
||||
#_user = null;
|
||||
#_description = null;
|
||||
|
||||
if (!user)
|
||||
throw new Error('Missing user for user application');
|
||||
this.user = user;
|
||||
constructor (saveFunc, options = {}) {
|
||||
|
||||
if (!options._id)
|
||||
throw new Error('Missing id for application');
|
||||
this.id = options._id;
|
||||
super(saveFunc, options);
|
||||
|
||||
if (!options.name)
|
||||
throw new Error('Missing name for application');
|
||||
this.name = options.name;
|
||||
|
||||
if (!options.token)
|
||||
if (typeof options.token !== 'string')
|
||||
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.createdAt = options.createdAt || Date.now();
|
||||
this.type = 'application';
|
||||
this.#_description = options.description || null;
|
||||
|
||||
}
|
||||
|
||||
save () {
|
||||
return this.user.saveApplication(this);
|
||||
get ownerId () {
|
||||
return this.#_user;
|
||||
}
|
||||
|
||||
hasPermission (perm, level = 1) {
|
||||
return PermissionManager.checkPermissions(this.permissions, perm, level);
|
||||
get description () {
|
||||
return this.#_description;
|
||||
}
|
||||
|
||||
get json () {
|
||||
get token () {
|
||||
return this.#_token;
|
||||
}
|
||||
|
||||
get jsonPrivate () {
|
||||
return {
|
||||
_id: this.id,
|
||||
name: this.name,
|
||||
permissions: this.permissions,
|
||||
...super.jsonPrivate,
|
||||
token: this.token,
|
||||
createdAt: this.createdAt,
|
||||
user: this.user.id,
|
||||
ownerId: this.#_user,
|
||||
description: this.description,
|
||||
};
|
||||
}
|
||||
|
||||
get safeJson () {
|
||||
|
||||
const { json } = this;
|
||||
for (const key of ProtectedFields)
|
||||
delete json[key];
|
||||
|
||||
return {
|
||||
...json,
|
||||
id: this.id,
|
||||
token: this.token.encrypted,
|
||||
};
|
||||
get json () {
|
||||
return super.json;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
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';
|
||||
|
||||
class Util {
|
||||
@ -98,9 +100,11 @@ class Util {
|
||||
}
|
||||
|
||||
static cryptoRandomString (len = 32, base = 'utf8') {
|
||||
|
||||
return randomFillSync(Buffer.alloc(len)).toString(base);
|
||||
}
|
||||
|
||||
static randomUUID () {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
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;
|
@ -5,6 +5,7 @@ beforeEach(() => {
|
||||
perms = { developer: { default: 5 }, administrator: 10 };
|
||||
});
|
||||
|
||||
describe('PermissionManager', () => {
|
||||
test('Constructor', () => {
|
||||
expect(() => new PermissionManager()).toThrow();
|
||||
});
|
||||
@ -43,4 +44,8 @@ test('Check for permission', () => {
|
||||
expect(PermissionManager.checkPermissions(perms, 'administrator', 10)).toBe(true);
|
||||
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"
|
||||
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:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.corgi.wtf/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
|
Loading…
Reference in New Issue
Block a user