Compare commits

..

No commits in common. "e1b60f3c3eae3e958d8a7030edc9c03d176e41ea" and "e976c8d47fae74af2590da426556833f23ecb476" have entirely different histories.

19 changed files with 255 additions and 619 deletions

View File

@ -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 `UserDatabaseInterface.js`. Takes care of sessions, authentication and authorisation, relies on an implementation of `AbstractUserDatabase.js`.
**UserDatabase:** `/src/server/components/UserDatabase.js` **UserDatabase:** `/src/server/components/UserDatabase.js`
Implementation of `UserDatabaseInterface.js`, takes care of user management. Implementation of `AbstractUserDatabase.js`, takes care of user management.

View File

@ -15,7 +15,6 @@
"@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",

View File

@ -1,10 +0,0 @@
const { Command } = require("commandparser");
class BaseCommand extends Command {
constructor (controller, opts) {
super(opts);
this.controller = controller;
}
}
module.exports = BaseCommand;

View File

@ -5,12 +5,10 @@ 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 {
@ -22,7 +20,6 @@ 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);
@ -57,25 +54,6 @@ 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 });
@ -114,48 +92,10 @@ 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');
setTimeout(process.exit, 90_000); const promises = this.shards.map(shard => shard.awaitShutdown());
const promises = this.shards.filter(shard => shard.ready).map(shard => shard.awaitShutdown()); await Promise.all(promises);
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

View File

@ -34,8 +34,6 @@ class Shard extends EventEmitter {
this._awaitingShutdown = null; this._awaitingShutdown = null;
this.awaitingResponse = new Map();
} }
async spawn (waitForReady = false) { async spawn (waitForReady = false) {
@ -108,30 +106,18 @@ class Shard extends EventEmitter {
this._handleExit(null, null, false); this._handleExit(null, null, false);
} }
send (message, expectResponse = false) { send (message) {
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) {
if (expectResponse) { this.process.send(message, err => {
message._id = Util.randomUUID(); if (err)
const to = setTimeout(reject, 10_000, [ new Error('Message timeout') ]); reject(err);
this.awaitingResponse.set(message._id, (...args) => { else
clearTimeout(to); resolve();
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();
});
}); });
} }
@ -161,11 +147,7 @@ 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);
return this.emit('fatal', message); this.emit('fatal', message);
} else if (message._id) {
const promise = this.awaitingResponse.get(message._id);
if (promise)
return promise(message);
} }
} }

View File

@ -1,40 +0,0 @@
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;

View File

@ -19,8 +19,6 @@ 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
@ -83,7 +81,6 @@ 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
@ -98,8 +95,6 @@ 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, {
@ -116,12 +111,12 @@ class Server extends EventEmitter {
this.memoryCache = new MemoryCache(this, { this.memoryCache = new MemoryCache(this, {
provider: this.memoryStoreProvider provider: this.memoryStoreProvider
}); });
// Alias
this.rateLimiter = new RateLimiter(this, this.memoryCache); this.users = this.userDatabase;
// 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, {
sessionStorage: MongoStore.create({ client: this.mongodb.client, dbName: this.mongodb.database, touchAfter: 600 }), mongo: this.mongodb,
secret: SECRET, // Secret for sessions secret: SECRET, // Secret for sessions
name: `${pkg.name}.s`, name: `${pkg.name}.s`,
cookie: { cookie: {
@ -233,16 +228,7 @@ class Server extends EventEmitter {
_handleMessage (msg) { _handleMessage (msg) {
if (msg._shutdown) if (msg._shutdown)
return this.shutdown(); 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

View File

@ -1,16 +1,18 @@
const { Collection } = require("@discordjs/collection"); const { Collection } = require("@discordjs/collection");
const { ObjectId } = require("mongodb"); const { ObjectId } = require("mongodb");
const { UserDatabaseInterface } = require("../interfaces/"); const { AbstractUserDatabase } = require("../interfaces/");
const { User, UserApplication } = require("../structures"); const { User } = 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 UserDatabaseInterface { class UserDatabase extends AbstractUserDatabase {
constructor (server, db, { constructor (server, db, {
userColllection = 'users', userColllection = 'users',
appCollection = 'applications', appCollection = 'applications',
validUserTypes,
disableCache disableCache
}) { }) {
@ -19,6 +21,7 @@ class UserDatabase extends UserDatabaseInterface {
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;
@ -35,7 +38,7 @@ class UserDatabase extends UserDatabaseInterface {
if (!page) if (!page)
throw new Error('Missing page number'); throw new Error('Missing page number');
const data = await this.db.find(this._userColllection, query, { limit: amount, skip: (page - 1) * amount }); const data = await this.db.find('users', 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);
@ -60,7 +63,7 @@ class UserDatabase extends UserDatabaseInterface {
if (id.includes('temp')) if (id.includes('temp'))
return null; return null;
const data = await this.db.findOne(this._userColllection, { _id: id }); const data = await this.db.findOne(this._userColllection, { _id: new ObjectId(id) });
if (!data) if (!data)
return null; return null;
@ -94,16 +97,16 @@ class UserDatabase extends UserDatabaseInterface {
} }
async findUser (name) { async findUser (username) {
if (!name) if (!username)
throw new Error('Missing username'); throw new Error('Missing username');
let user = this.cache.find(u => u.username?.toLowerCase() === name.toLowerCase()); let user = this.cache.find(u => u.username?.toLowerCase() === username.toLowerCase());
if (user) if (user)
return Promise.resolve(user); return Promise.resolve(user);
const data = await this.db.findOne(this._userColllection, { name }, { collation: { locale: 'en', strength: 2 } }); const data = await this.db.findOne(this._userColllection, { username }, { collation: { locale: 'en', strength: 2 } });
if (!data) if (!data)
return null; return null;
@ -170,7 +173,7 @@ class UserDatabase extends UserDatabaseInterface {
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, { name: profile.username }, { collation: { locale: 'en', strength: 2 } }); const existing = await this.db.findOne(this._userColllection, { username: 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);
@ -198,10 +201,11 @@ class UserDatabase extends UserDatabaseInterface {
* @param {string} password * @param {string} password
* @memberof UserDatabase * @memberof UserDatabase
*/ */
async createUser (name, password) { async createUser (username, password) {
const user = this._createUser({ const user = this._createUser({
name username,
type: 'user'
}); });
await user.setPassword(password); await user.setPassword(password);
await this.updateUser(user); await this.updateUser(user);
@ -248,7 +252,7 @@ class UserDatabase extends UserDatabaseInterface {
* @memberof UserDatabase * @memberof UserDatabase
*/ */
async updateUser (user) { async updateUser (user) {
const { jsonPrivate: json } = user; const { 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 {
@ -259,7 +263,7 @@ class UserDatabase extends UserDatabaseInterface {
} }
async updateApplication (app) { async updateApplication (app) {
const { jsonPrivate: json } = app; const { 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);
} }
@ -275,20 +279,11 @@ class UserDatabase extends UserDatabaseInterface {
_createUser (data) { _createUser (data) {
if (!data) if (!data)
throw new Error(`Missing data to create user`); throw new Error(`Missing data to create user`);
if (data._id) return new User(this, data);
data.id = data._id;
if (!data.id)
data.id = new ObjectId();
return new User(this.updateUser.bind(this), data);
} }
_createApp (data) { _createApp (user, data) {
if (data._id) return new UserApplicataion(user, data);
data.id = data._id;
if (!data.id)
data.id = new ObjectId();
return new UserApplication(this.updateApplication.bind(this), data);
} }
} }

View File

@ -59,7 +59,7 @@ class UserEndpoint extends ApiEndpoint {
} }
user (req, res) { user (req, res) {
return res.json(req.user.json); return res.json(req.user.safeJson);
} }
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.json)); res.json(Object.values(applications).map(app => app.safeJson));
} }
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.json); res.json(application.safeJson);
} }

View File

@ -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.json)); res.json(users.map(user => user.safeJson));
} }
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.json); res.json(user.safeJson);
} }
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.json)); res.json(Object.values(applications).map(app => app.safeJson));
} }
} }

View File

@ -1,120 +0,0 @@
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;

View File

@ -1,14 +1,9 @@
/** /**
* Interface class, anything that interacts with a user database within the framework is implemented against this *
* @abstract * @abstract
* @class UserDatabaseInterface * @class AbstractUserDatabase
*/ */
class UserDatabaseInterface { class AbstractUserDatabase {
constructor () {
if (this.constructor === UserDatabaseInterface)
throw new Error('This class cannot be instantiated, only derived');
}
// Fetch by ID // Fetch by ID
fetchUser () { fetchUser () {
@ -36,4 +31,4 @@ class UserDatabaseInterface {
} }
module.exports = UserDatabaseInterface; module.exports = AbstractUserDatabase;

View File

@ -1,114 +1,119 @@
const Argon2 = require('argon2'); const Argon2 = require('argon2');
const { ObjectId } = require('mongodb'); const { ObjectId } = require('mongodb');
const { Util } = require('../../util'); const { Util, PermissionManager } = require('../../util');
const { AbstractUser } = require('../interfaces'); const UserApplicataion = require('./UserApplication');
const UserApplication = require('./UserApplication');
// Fields omitted in the json getter // Fields omitted in safeJson
// Should be keys used in jsonPrivate, not the ones defined in the constructor // Should be keys used in the json, not the ones defined in the constructor
// const ProtectedFields = [ 'otpSecret', 'password' ]; const ProtectedFields = [ '_id', 'otpSecret', 'password' ];
class User extends AbstractUser { class User {
#passwordHash = null; static validTypes = [];
#otpSecret = null;
#_mfa = null;
#_displayName = null; constructor (db, data) {
#_externalProfiles = null;
#_applications = null;
constructor (saveFunc, data) { this._db = db;
super(saveFunc, data); this.temporary = data.temporary || false;
this.disabled = data.disabled || false;
AbstractUser.ProtectedFields.push('otpSecret', 'password'); this._id = data._id || null;
if (this.temporary)
this._tempId = `temp-${Date.now()}`;
this.#_applications = data.applications || []; if (!data.username && !data.temporary)
this.#_externalProfiles = data.externalProfiles || {}; 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);
/** @private */ /** @private */
this.#passwordHash = data.password || null; this._passwordHash = data.password || null;
this.#otpSecret = data.otpSecret || null; this._otpSecret = data.otpSecret || null;
this.#_mfa = data.twoFactor || false; this._2fa = 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;
} }
get avatar () { async fetchApplications () {
return this.icon; 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 mfa () { async createApplication (name, { description } = {}) {
return this.#_mfa; const _id = new ObjectId();
} // User id : app id : creation time : random string
const rawToken = `${this.id}:${_id}:${Date.now()}:${Util.randomString(8)}`;
get externalProfiles () { const token = Util.encrypt(rawToken, Buffer.from(process.env.ENCRYPTION_KEY, 'base64'));
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 UserApplication(saveFn, opts); const application = new UserApplicataion(this, opts);
await application.save(); await application.save();
this.#_applications.push(id); this.applications[_id] = application;
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)
// TODO: potentially add something to track failed attempts return true;
}
// TODO: potentially add something to track failed attempts
return result;
} }
/** /**
@ -120,29 +125,58 @@ class User extends AbstractUser {
*/ */
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]);
} }
get jsonPrivate () { save () {
return { return this._db.updateUser(this);
...super.jsonPrivate, }
displayName: this.displayName,
externalProfiles: this.externalProfiles, saveApplication (app) {
password: this.#passwordHash, return this._db.updateApplication(app);
otpSecret: this.#otpSecret,
twoFactor: this.mfa,
applications: this.#_applications,
};
} }
get json () { get json () {
const json = super.json; return {
_id: this._id,
username: this.username,
displayName: this._displayName,
type: this.type,
permissions: this.permissions,
externalProfiles: this.externalProfiles,
password: this._passwordHash,
otpSecret: this._otpSecret,
twoFactor: this._2fa,
applications: this._applications,
createdTimestamp: this.createdTimestamp,
disabled: this.disabled,
avatar: this.avatar,
};
}
get safeJson () {
const { json } = this;
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 };
}), }),
@ -150,6 +184,10 @@ class User extends AbstractUser {
}; };
} }
static setValidTypes (types) {
User.validTypes = types;
}
} }
module.exports = User; module.exports = User;

View File

@ -1,50 +1,67 @@
const { AbstractUser } = require("../interfaces"); const { PermissionManager } = require("../../util");
class UserApplicataion extends AbstractUser { // Fields omitted in safeJson
// Should be keys used in the json, not the ones defined in the constructor
const ProtectedFields = [ '_id' ];
class UserApplicataion {
#_token = null; constructor (user, options) {
#_user = null;
#_description = null;
constructor (saveFunc, options = {}) { if (!user)
throw new Error('Missing user for user application');
this.user = user;
super(saveFunc, options); if (!options._id)
throw new Error('Missing id for application');
this.id = options._id;
if (typeof options.token !== 'string') if (!options.name)
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;
if (typeof options.ownerId !== 'string') this.description = options.description || null;
throw new Error('Missing ownerId (owner) for application');
this.#_user = options.ownerId;
this.#_description = options.description || null; this.permissions = options.permissions || {};
this.createdAt = options.createdAt || Date.now();
this.type = 'application';
} }
get ownerId () { save () {
return this.#_user; return this.user.saveApplication(this);
} }
get description () { hasPermission (perm, level = 1) {
return this.#_description; return PermissionManager.checkPermissions(this.permissions, perm, level);
} }
get token () { get json () {
return this.#_token;
}
get jsonPrivate () {
return { return {
...super.jsonPrivate, _id: this.id,
name: this.name,
permissions: this.permissions,
token: this.token, token: this.token,
ownerId: this.#_user, createdAt: this.createdAt,
user: this.user.id,
description: this.description, description: this.description,
}; };
} }
get json () { get safeJson () {
return super.json;
const { json } = this;
for (const key of ProtectedFields)
delete json[key];
return {
...json,
id: this.id,
token: this.token.encrypted,
};
} }
} }

View File

@ -1,4 +1,3 @@
module.exports = { module.exports = {
User: require('./User'), User: require('./User')
UserApplication: require('./UserApplication')
}; };

View File

@ -1,6 +1,4 @@
const fs = require('node:fs'); const { scryptSync, createCipheriv, randomFillSync, createDecipheriv } = require('node:crypto');
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 {
@ -100,11 +98,9 @@ class Util {
} }
static cryptoRandomString (len = 32, base = 'utf8') { static cryptoRandomString (len = 32, base = 'utf8') {
return randomFillSync(Buffer.alloc(len)).toString(base);
}
static randomUUID () { return randomFillSync(Buffer.alloc(len)).toString(base);
return randomUUID();
} }
static createEncryptionKey (secret, salt, len = 32) { static createEncryptionKey (secret, salt, len = 32) {
@ -176,37 +172,6 @@ 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;

View File

@ -5,47 +5,42 @@ beforeEach(() => {
perms = { developer: { default: 5 }, administrator: 10 }; perms = { developer: { default: 5 }, administrator: 10 };
}); });
describe('PermissionManager', () => { test('Constructor', () => {
test('Constructor', () => { expect(() => new PermissionManager()).toThrow();
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('administrator:removeuser:force:bruh:moment:broo'); PermissionManager.ensurePermission('developer:toggledebug');
PermissionManager.ensurePermission('developer:toggledebug'); PermissionManager.ensurePermission('developer');
PermissionManager.ensurePermission('developer');
expect(PermissionManager.DefaultPermissions).toHaveProperty('administrator.removeuser.force');
expect(PermissionManager.DefaultPermissions).toHaveProperty('administrator.removeuser.force'); expect(PermissionManager.DefaultPermissions).toHaveProperty('developer.toggledebug');
expect(PermissionManager.DefaultPermissions).toHaveProperty('developer.toggledebug'); expect(PermissionManager.DefaultPermissions).not.toHaveProperty('developer.removeuser.force');
expect(PermissionManager.DefaultPermissions).not.toHaveProperty('developer.removeuser.force');
});
});
test('Test permission merge', () => {
test('Test permission merge', () => {
expect(perms).toHaveProperty('administrator', 10);
expect(perms).toHaveProperty('administrator', 10); PermissionManager.ensurePermission('administrator:removeuser:force');
PermissionManager.ensurePermission('administrator:removeuser:force'); PermissionManager.merge(perms, PermissionManager.DefaultPermissions);
PermissionManager.merge(perms, PermissionManager.DefaultPermissions); expect(perms).toHaveProperty('administrator.removeuser.force');
expect(perms).toHaveProperty('administrator.removeuser.force'); expect(perms).toHaveProperty('administrator.default', 10);
expect(perms).toHaveProperty('administrator.default', 10);
});
});
test('Check for permission', () => {
test('Check for permission', () => {
expect(() => PermissionManager.checkPermissions()).toThrow();
expect(() => PermissionManager.checkPermissions()).toThrow(); expect(() => PermissionManager.checkPermissions(perms)).toThrow();
expect(() => PermissionManager.checkPermissions(perms)).toThrow();
expect(PermissionManager.checkPermissions(perms, 'administrator', 10)).toBe(true);
expect(PermissionManager.checkPermissions(perms, 'administrator', 10)).toBe(true); expect(PermissionManager.checkPermissions(perms, 'developer', 5)).toBe(true);
expect(PermissionManager.checkPermissions(perms, 'developer', 5)).toBe(true); expect(PermissionManager.checkPermissions(perms, 'developer', 10)).toBe(false);
expect(PermissionManager.checkPermissions(perms, 'developer', 10)).toBe(false);
expect(PermissionManager.checkPermissions(perms, 'bargus', 0)).toBe(false);
});
}); });

View File

@ -1,100 +0,0 @@
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);
});

View File

@ -1950,11 +1950,6 @@ 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"