diff --git a/app/database/Database.js b/app/database/Database.js new file mode 100644 index 0000000..422a1f3 --- /dev/null +++ b/app/database/Database.js @@ -0,0 +1,111 @@ +/** + * @module @database + * @since 2.0.0 + */ + +const fs = require('fs/promises'); +const autoBind = require('auto-bind'); + +const UsersAPI = require('@database/api/UsersAPI'); + +/** + * The Database object + * Currently the "Database" is pretty much a JSON key value store. + * However this will be updated when switching to SQLite + * https://github.com/Capevace/mission-control/issues/17 + */ +class Database { + constructor(databasePath) { + /** + * The databases data. This will change and is temporary + * @protected + * @type {object} + */ + this.data = {}; + + /** + * The path to the database file + * @type {string} + */ + this.path = databasePath; + + /** + * The available Database API's for safe db usage + * @typedef {DatabaseAPIList} + */ + this.api = { + /** + * The users API + * @type {UsersAPI} + */ + users: new UsersAPI(this) + }; + + autoBind(this); + } + + /** + * Init connection to database etc. + */ + async init() { + this.data = require(this.path); + } + + /** + * Save the data to disk. + * TODO: This is bad but will be fixed with the switch to SQLite, + * so it's a very temporary solution. + * @protected + */ + _saveData() { + fs.writeFileSync( + config.databasePath, + JSON.stringify(database, null, 2), + err => { + if (err) logger.error('Error writing database file', err); + } + ); + } + + /** + * Set a value for a key in the database. + * + * The function synchronously saves the new data to disk as well. + * Quite the performance bottleneck for now and DEFINIETLY not safe at all + * like wtf. But works for now. + * + * @param {string} key - The key to set + * @param {any} value - The value to set + */ + set(key, value) { + this.data[key] = value; + + // Save the database to disk + this._saveData(); + } + + /** + * Retrieve data from a database with a given key. + * + * @todo multi-level getting + * + * @param {string} key - The key to retrieve from the database. (1-level deep) + * @param {any} [defaultValue = null] - The default value if no value was found. + * @return {any} - The data from the database. + */ + get(key, defaultValue = null) { + return database[key] || defaultValue; + } +} + +Database.verifyOrCreateDatabaseAt = async function verifyOrCreateDatabaseAt(databasePath) { + // If the database.json file doenst exist, create it + // Otherwise, read from it and populate the database. + if (!(await fs.exists(config.databasePath))) { + await fs.writeFile(config.databasePath, '{}'); + } +}; + + + + diff --git a/app/database/api/DatabaseAPI.js b/app/database/api/DatabaseAPI.js new file mode 100644 index 0000000..a6f48a4 --- /dev/null +++ b/app/database/api/DatabaseAPI.js @@ -0,0 +1,24 @@ +/** + * @module @database + * @since 2.0.0 + */ + +const autoBind = require('auto-bind'); + +/** + * The base class to build database API's on + */ +class DatabaseAPI { + constructor(database) { + /** + * Reference to the database class + * @type {Database} + */ + this.database = database; + + // Automatically bind all methods to "this" + autoBind(this); + } +} + +module.exports = DatabaseAPI; \ No newline at end of file diff --git a/app/database/api/UsersAPI.js b/app/database/api/UsersAPI.js new file mode 100644 index 0000000..02e2ff3 --- /dev/null +++ b/app/database/api/UsersAPI.js @@ -0,0 +1,177 @@ +/** + * @module @database + * @since 2.0.0 + */ + +const DatabaseAPI = require('@database/api/DatabaseAPI'); + +const User = require('@models/User'); + +const UserError = require('@helpers/UserError'); +const crypto = require('@helpers/crypto'); + +/** + * Database API for the User model + */ +class UsersAPI extends DatabaseAPI { + constructor(database) { + super(database); + + /** + * The temporary password to use for the temporary user. + * @protected + * @type {string} + */ + this._tempPassword = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + + // TODO: Temporary user creation should not be handled in the UsersAPI class + // Instead a new Defaults class or something should be created that handles all these edge cases. + const foundUsers = db.get('users', {}); + + // If no users are present, we generate a temporary user account and tell the user in the console + if (Object.keys(foundUsers).length === 0) { + logger.newLine(); + logger.warn('========================================================='); + logger.newLine(); + logger.warn('No registered users found in DB!'); + logger.warn(`Enabling admin account 'temp' with password '${this._tempPassword}'`); + logger.newLine(); + logger.warn('========================================================='); + logger.newLine(); + } + } + + /** + * Get all users from DB + * @return {Object} The users map + */ + async all() { + return this.database + .get('users', { 'temp': User.composeTempUser(this._tempPassword) }) + } + + /** + * Set all users in DB + * @protected + * @param {Object} users + */ + async _setUsers(users) { + this.database.set('users', users); + } + + /** + * Set a User in DB + * @protected + * @param {string} username + * @param {User} user + */ + async _setUser(username, user) { + const users = await this.all(); + + users[username] = User.validate({ + ...user, + username // we make sure, the key we set is the same username as in the user object + }); + + await this._setUsers(users); + } + + /** + * Find User by username + * @param {string} username The username + * @return {User | null} + */ + async findUnsafe(username) { + return (await this.all())[username] || null; + } + + /** + * Find User by username and remove sensitive information + * @param {string} username The username + * @return {User | null} + */ + async find(username) { + const user = await this.findUnsafe(username); + + return user + ? { ...user, password: undefined } + : null; + } + + /** + * Create a new user + * @param {string} username + * @param {User} userData + */ + async create(username, userData) { + if (await this.find(username)) + throw new UserError(`Username ${username} unavailable`); + + // Password can not be changed in the update method + await this._setUser(username, { + ...userData, + password: await crypto.hashPassword(userData.password) + }); + } + + /** + * Delete a User + * @param {string} username + */ + async delete(username) { + // If user does't exist + if (!await this.find(username)) { + throw new UserError(`User ${username} doesn't exists`); + } + + let users = await this.all(); + delete users[username]; + await this._setUsers(users); + } + + /** + * Update the user meta in DB (display name and avatar url). + * + * This method does NOT allow changing the password. + * Only do this with `usersApi.users.updatePassword(, )`. + * + * @param {string} username Username + * @param {User} user The user object + * @param {boolean} shouldCreate Should the user be created if he's not found + * @return {void} + */ + async update(username, userData) { + const oldUser = await this.findUnsafe(username); + + if (!oldUser) + throw new UserError(`User ${username} could not be found`); + + // Password can not be changed in the update method + await this._setUser(username, { + ...oldUser, // First the old user data is the basis + ...userData, // New user data overrides the old (doesn't have to be complete) + username: oldUser.username, // Make sure username stays the same + password: oldUser.password // Make sure password wasn't changed + }); + } + + /** + * Hash a users password and save it to DB. + * + * @param {string} username + * @param {string} password + */ + async updatePassword(username, password) { + const oldUser = await this.findUnsafe(username); + + if (!oldUser) + throw new UserError(`User ${username} could not be found`); + + await this._setUser(username, { + ...oldUser, + password: await crypto.hashPassword(password) + }); + } +} + +module.exports = UsersAPI; \ No newline at end of file diff --git a/app/database/api/users.js b/app/database/api/users.js deleted file mode 100644 index 965d7d6..0000000 --- a/app/database/api/users.js +++ /dev/null @@ -1,155 +0,0 @@ -const User = require('@models/User'); -const UserError = require('@helpers/UserError'); -const crypto = require('@helpers/crypto'); - -const logger = require('@helpers/logger').createLogger('Database', 'magenta'); - -module.exports = function initAPI(db) { - const foundUsers = db.get('users', null); - const tempPassword = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); - - // If no users are present, we generate a temporary user account and tell the user in the console - if (!foundUsers || Object.keys(foundUsers).length === 0) { - logger.newLine(); - logger.warn('========================================================='); - logger.newLine(); - logger.warn('No registered users found in DB!'); - logger.warn(`Enabling admin account 'temp' with password '${tempPassword}'`); - logger.newLine(); - logger.warn('========================================================='); - logger.newLine(); - } - - /** - * Get Users Map from DB - * @return {Object} The users map - */ - const getAllUsers = async () => db.get('users', { 'temp': User.composeTempUser(tempPassword) }); - - /** - * Set Users Map in DB. - * @param {Object} users - */ - const setUsers = async (users) => db.set('users', users); - - /** - * Set a User in DB - * @param {string} username - * @param {User} user - */ - const setUser = async (username, user) => { - const users = await getAllUsers(); - - users[username] = User.validate({ - ...user, - username // we make sure, the key we set is the same username as in the user object - }); - - await setUsers(users); - }; - - const usersApi = { - all: getAllUsers, - - /** - * Find User by username - * @param {string} username The username - * @return {User | null} - */ - async findUser(username) { - return (await getAllUsers())[username] || null; - }, - - /** - * Find User by username and remove sensitive information - * @param {string} username The username - * @return {User | null} - */ - async findSafeUser(username) { - const user = await usersApi.findUser(username); - - return user - ? { ...user, password: undefined } - : null; - }, - - /** - * Create a new user - * @param {string} username - * @param {User} userData - */ - async create(username, userData) { - const oldUser = await usersApi.findUser(username); - - if (oldUser) - throw new UserError(`Username ${username} unavailable`); - - // Password can not be changed in the update method - await setUser(username, { - ...userData, - password: await crypto.hashPassword(userData.password) - }); - }, - - /** - * Delete a User - * @param {string} username - */ - async delete(username) { - const oldUser = await usersApi.findUser(username); - - if (!oldUser) - throw new UserError(`User ${username} doesn't exists`); - - let users = await getAllUsers(); - delete users[username]; - await setUsers(users); - }, - - /** - * Update the user meta in DB (display name and avatar url). - * - * This method does NOT allow changing the password. - * Only do this with `usersApi.users.updatePassword(, )`. - * - * @param {string} username Username - * @param {User} user The user object - * @param {boolean} shouldCreate Should the user be created if he's not found - * @return {void} - */ - async update(username, userData) { - const oldUser = await usersApi.findUser(username); - - if (!oldUser) - throw new UserError(`User ${username} could not be found`); - - // Password can not be changed in the update method - await setUser(username, { - ...oldUser, - ...userData, - username: oldUser.username, - password: oldUser.password - }); - }, - - /** - * Hash a users password and save it to DB. - * - * @param {string} username - * @param {string} password - */ - async updatePassword(username, password) { - const oldUser = await usersApi.findUser(username); - - if (!oldUser) - throw new UserError(`User ${username} could not be found`); - - await setUser(username, { - ...oldUser, - password: await crypto.hashPassword(password) - }); - } - }; - - return usersApi; -} \ No newline at end of file diff --git a/app/database/index.js b/app/database/index.js deleted file mode 100644 index 4cd01f1..0000000 --- a/app/database/index.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Database Module - * @module @database - * @since 1.0.0 - */ -const logger = require('@helpers/logger').createLogger('Database', 'cyan'); -const config = require('@config'); -const fs = require('fs'); - -let database = {}; - -// If the storage folder doesnt exist, create it -if (!fs.existsSync(config.storagePath)) { - logger.warn("Storage folder doesn't exist. Creating /storage."); - fs.mkdirSync(config.storagePath, { recursive: true }); -} - -// If the database.json file doenst exist, create it -// Otherwise, read from it and populate the database. -if (!fs.existsSync(config.databasePath)) { - fs.writeFileSync(config.databasePath, '{}'); -} else { - database = require(config.databasePath); -} - -/** - * Set an object in the database to a given value. - * - * This function is used to save data permanently to the database. - * Saves on every set. - * - * @todo More object setting than 1 level deep. - * - * @param {String} key - The database object key to query. For now, only 1 level deep. - * @param {any} value - The value you want to set. - */ -module.exports.set = function set(key, value) { - database[key] = value; - - // Save the database to disk - fs.writeFileSync( - config.databasePath, - JSON.stringify(database, null, 2), - err => { - if (err) logger.error('Error writing database file', err); - } - ); -}; - -/** - * Retrieve data from a database with a given key. - * - * @todo multi-level getting - * - * @param {String} key - The key to retrieve from the database. (1-level deep) - * @return {Object} The object from the database. - */ -module.exports.get = function get(key, defaultValue) { - return database[key] || defaultValue; -}; - -/** - * The database API to safely interact with the database. - * @type {Object} - */ -module.exports.api = { - /** - * The users API. - * @type {Object} - */ - users: require('./api/users')(module.exports) -}; - diff --git a/app/index.js b/app/index.js index 6934303..ff4afdd 100755 --- a/app/index.js +++ b/app/index.js @@ -25,10 +25,14 @@ async function startMissionControl(progress) { logger.info(`Starting Mission Control...`); progress('Boot Database', 0.01); - const database = require('@database'); // eslint-disable-line no-unused-vars + const Database = require('@database/Database'); + const database = new Database(config.databasePath); + await database.init(); let sessionSecret = database.get('session-secret', null); if (!sessionSecret) { + // TODO: UUIDs are time-based so this is incredibly insecure. + // Change this to some solid crypto random generation. sessionSecret = uuid(); database.set('session-secret', sessionSecret); }