Compare commits

...

3 Commits

Author SHA1 Message Date
e1b60f3c3e
rewrites of users & applications
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-13 02:09:27 +02:00
63f7c763ef
controller commands 2023-02-13 02:08:28 +02:00
7b94be6915
user structures tests & improvement to perms tests 2023-02-13 02:06:29 +02:00
19 changed files with 617 additions and 253 deletions

View File

@ -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.

View File

@ -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",

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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"