UNPKG

errsole-sequelize

Version:

Sequelize storage plugin for Errsole

760 lines (689 loc) 24.6 kB
/** * @typedef {Object} Log * @property {number} [id] * @property {string} hostname * @property {number} pid * @property {string} source * @property {Date} timestamp * @property {string} level * @property {string} message * @property {string} [meta] */ /** * @typedef {Object} LogFilter * @property {string[]} [hostnames] * @property {{source: string, level: string}[]} [level_json] * @property {number} [errsole_id] * @property {number} [lt_id] * @property {number} [gt_id] * @property {Date} [lte_timestamp] * @property {Date} [gte_timestamp] * @property {number} [limit=100] */ /** * @typedef {Object} Config * @property {number} id * @property {string} key * @property {string} value */ /** * @typedef {Object} User * @property {number} id * @property {string} name * @property {string} email * @property {string} role */ const bcrypt = require('bcryptjs'); const { EventEmitter } = require('events'); const cron = require('node-cron'); const { Sequelize, DataTypes } = require('sequelize'); const packageJSON = require('../package.json'); class ErrsoleSequelize extends EventEmitter { /** * Constructs an instance of the database handler using Sequelize. * This constructor initializes the Sequelize instance and sets up the database models and synchronization. * @param {Object} options - Configuration options for the Sequelize connection, * as described in the Sequelize documentation: https://sequelize.org/api/v6/class/src/sequelize.js~sequelize */ constructor (options = {}) { super(); if (!options.logging) options.logging = false; this.name = packageJSON.name; this.version = packageJSON.version || '0.0.0'; this.dialect = options.dialect; this.sequelize = new Sequelize(options); this.isConnectionInProgress = true; this.defineModels(); this.syncModels(); } async defineModels () { this.errsoleLogs = this.sequelize.define('ErrsoleLogs', { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, hostname: DataTypes.STRING, pid: DataTypes.INTEGER, source: DataTypes.STRING, timestamp: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, level: { type: DataTypes.STRING, allowNull: false, defaultValue: 'info' }, message: DataTypes.TEXT, meta: DataTypes.TEXT, errsole_id: { type: DataTypes.BIGINT, allowNull: true } }, { tableName: 'errsole_logs_v2', timestamps: false, indexes: [ { fields: ['source', 'level', 'id'] }, { fields: ['source', 'level', 'timestamp'] }, { fields: ['hostname', 'pid', 'id'] }, { fields: ['errsole_id'] } ] }); this.errsoleUsers = this.sequelize.define('ErrsoleUsers', { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, name: DataTypes.STRING, email: { type: DataTypes.STRING, unique: true, allowNull: false, validate: { isEmail: { msg: 'Invalid email format' } } }, hashedPassword: { field: 'hashed_password', type: DataTypes.STRING, allowNull: false, validate: { notEmpty: { msg: 'Password cannot be empty' } } }, role: { type: DataTypes.STRING, allowNull: false } }, { tableName: 'errsole_users', timestamps: false }); this.errsoleConfig = this.sequelize.define('ErrsoleConfig', { id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, key: { type: DataTypes.STRING, unique: true, allowNull: false }, value: { type: DataTypes.STRING, allowNull: false } }, { tableName: 'errsole_config', freezeTableName: true, timestamps: false }); } async syncModels () { await this.sequelize.authenticate(); await this.sequelize.sync(); try { const queryInterface = this.sequelize.getQueryInterface(); await queryInterface.removeIndex('errsole_logs_v2', 'errsole_logs_v2_message'); await queryInterface.addIndex('errsole_logs_v2', ['timestamp']); } catch {} await this.ensureLogsTTL(); cron.schedule('0 * * * *', () => { this.deleteExpiredLogs(); }); this.isConnectionInProgress = false; this.emit('ready'); } /** * Retrieves a configuration entry from the database. * * @async * @function getConfig * @param {string} key - The key of the configuration entry to retrieve. * @returns {Promise<{item: Config}>} - A promise that resolves with an object containing the configuration item. * @throws {Error} - Throws an error if the operation fails. */ async getConfig (key) { const result = await this.errsoleConfig.findOne({ where: { key } }); if (!result) return {}; return { item: result.dataValues }; } /** * Updates or adds a configuration entry in the database. * * @async * @function setConfig * @param {string} key - The key of the configuration entry. * @param {string} value - The value to be stored for the configuration entry. * @returns {Promise<{item: Config}>} - A promise that resolves with an object containing the updated or added configuration item. * @throws {Error} - Throws an error if the operation fails. */ async setConfig (key, value) { await this.errsoleConfig.upsert({ key, value }); const result = await this.errsoleConfig.findOne({ where: { key } }); if (!result) return {}; return { item: result.dataValues }; } /** * Deletes a configuration entry from the database. * * @async * @function deleteConfig * @param {string} key - The key of the configuration entry to be deleted. * @returns {Promise<{}>} - A Promise that resolves with an empty object upon successful deletion of the configuration. * @throws {Error} - Throws an error if the operation fails. */ async deleteConfig (key) { const result = await this.errsoleConfig.findOne({ where: { key } }); if (!result) throw new Error('Configuration not found.'); await result.destroy(); return {}; } /** * Adds log entries to the database. * * @async * @function postLogs * @param {Log[]} logEntries - An array of log entries to be added to the database. * @returns {Promise<{}>} - A Promise that resolves with an empty object. * @throws {Error} - Throws an error if the operation fails. */ async postLogs (logEntries) { while (this.isConnectionInProgress) { await new Promise(resolve => setTimeout(resolve, 100)); } try { await this.errsoleLogs.bulkCreate(logEntries, { logging: false }); } catch (err) { } return {}; } /** * Retrieves a list of distinct hostnames from the errsole_logs_v2 table. * * @async * @function getHostnames * @returns {Promise<{items: string[]}>} - A Promise that resolves with an object containing an array of distinct hostnames. * @throws {Error} - Throws an error if the operation fails. */ async getHostnames () { try { const result = await this.errsoleLogs.findAll({ attributes: [[Sequelize.fn('DISTINCT', Sequelize.col('hostname')), 'hostname']], where: { hostname: { [Sequelize.Op.ne]: '', // Filter out empty hostnames [Sequelize.Op.not]: null // Filter out null hostnames } }, raw: true }); const hostnames = result.map(row => row.hostname).sort(); return { items: hostnames }; } catch (err) { throw new Error('Failed to retrieve hostnames.'); } } /** * Retrieves log entries from the database based on specified filters. * * @async * @function getLogs * @param {LogFilter} [filters] - Filters to apply for log retrieval. * @returns {Promise<{items: Log[]}>} - A Promise that resolves with an object containing log items. * @throws {Error} - Throws an error if the operation fails. */ async getLogs (filters = {}) { // Set default limit if not provided const defaultLimit = 100; filters.limit = filters.limit || defaultLimit; const where = {}; let sortOrder = [['id', 'DESC']]; let shouldReverse = true; // Apply filters if (filters.hostname) where.hostname = filters.hostname; if (filters.hostnames && filters.hostnames.length > 0) { where.hostname = { [Sequelize.Op.in]: filters.hostnames }; } if (filters.pid) where.pid = filters.pid; if (filters.sources) where.source = { [Sequelize.Op.in]: filters.sources }; if (filters.levels) where.level = { [Sequelize.Op.in]: filters.levels }; if (filters.level_json) { where[Sequelize.Op.or] = filters.level_json.map(levelObj => ({ [Sequelize.Op.and]: [{ source: levelObj.source }, { level: levelObj.level }] })); } // Apply level_json or errsole_id filters if (filters.level_json || filters.errsole_id) { const orConditions = []; if (filters.level_json && filters.level_json.length > 0) { const levelJsonConditions = filters.level_json.map(levelObj => ({ [Sequelize.Op.and]: [ { source: levelObj.source }, { level: levelObj.level } ] })); orConditions.push({ [Sequelize.Op.or]: levelJsonConditions }); } if (filters.errsole_id) { orConditions.push({ errsole_id: filters.errsole_id }); } if (orConditions.length > 0) { where[Sequelize.Op.or] = orConditions; } } if (filters.lt_id) { where.id = { [Sequelize.Op.lt]: filters.lt_id }; sortOrder = [['id', 'DESC']]; shouldReverse = true; } else if (filters.gt_id) { where.id = { [Sequelize.Op.gt]: filters.gt_id }; sortOrder = [['id', 'ASC']]; shouldReverse = false; } else if (filters.lte_timestamp || filters.gte_timestamp) { where.timestamp = {}; if (filters.lte_timestamp) { where.timestamp[Sequelize.Op.lte] = new Date(filters.lte_timestamp); sortOrder = [['timestamp', 'DESC']]; shouldReverse = true; } if (filters.gte_timestamp) { where.timestamp[Sequelize.Op.gte] = new Date(filters.gte_timestamp); sortOrder = [['timestamp', 'ASC']]; shouldReverse = false; } } const result = await this.errsoleLogs.findAll({ where, order: sortOrder, limit: filters.limit, attributes: { exclude: ['meta'] }, raw: true }); if (shouldReverse) result.reverse(); return { items: result }; } /** * Retrieves log entries from the database based on specified search terms and filters. * * @async * @function searchLogs * @param {string[]} searchTerms - An array of search terms. * @param {LogFilter} [filters] - Filters to refine the search. * @returns {Promise<{items: Log[]}>} - A promise that resolves with an object containing an array of log items. * @throws {Error} - Throws an error if the operation fails. */ async searchLogs (searchTerms, filters = {}) { filters.limit = filters.limit || 100; let gteTimestamp, lteTimestamp; if (filters.gte_timestamp && !filters.lte_timestamp) { filters.gte_timestamp = new Date(filters.gte_timestamp); lteTimestamp = new Date(filters.gte_timestamp.getTime() + 24 * 60 * 60 * 1000); } if (filters.lte_timestamp && !filters.gte_timestamp) { filters.lte_timestamp = new Date(filters.lte_timestamp); gteTimestamp = new Date(filters.lte_timestamp.getTime() - 24 * 60 * 60 * 1000); } const where = { message: { [Sequelize.Op.and]: searchTerms.map(searchTerm => ({ [Sequelize.Op.like]: `%${searchTerm}%` })) } }; let sortOrder = [['id', 'DESC']]; let shouldReverse = true; // Apply filters if (filters.hostname) { where.hostname = filters.hostname; } if (filters.hostnames && filters.hostnames.length > 0) { where.hostname = { [Sequelize.Op.in]: filters.hostnames }; } if (filters.pid) { where.pid = filters.pid; } if (filters.sources) { where.source = { [Sequelize.Op.in]: filters.sources }; } if (filters.levels) { where.level = { [Sequelize.Op.in]: filters.levels }; } if (filters.level_json) { where[Sequelize.Op.or] = filters.level_json.map(levelObj => ({ [Sequelize.Op.and]: [{ source: levelObj.source }, { level: levelObj.level }] })); } // Apply level_json or errsole_id filters if (filters.level_json || filters.errsole_id) { const orConditions = []; if (filters.level_json && filters.level_json.length > 0) { const levelJsonConditions = filters.level_json.map(levelObj => ({ [Sequelize.Op.and]: [ { source: levelObj.source }, { level: levelObj.level } ] })); orConditions.push({ [Sequelize.Op.or]: levelJsonConditions }); } if (filters.errsole_id) { orConditions.push({ errsole_id: filters.errsole_id }); } if (orConditions.length > 0) { where[Sequelize.Op.or] = orConditions; } } if (filters.lt_id) { where.id = { [Sequelize.Op.lt]: filters.lt_id }; sortOrder = [['id', 'DESC']]; shouldReverse = true; } if (filters.gt_id) { where.id = { [Sequelize.Op.gt]: filters.gt_id }; sortOrder = [['id', 'ASC']]; shouldReverse = false; } if (filters.lte_timestamp || filters.gte_timestamp) { where.timestamp = {}; if (filters.lte_timestamp) { where.timestamp[Sequelize.Op.lte] = new Date(filters.lte_timestamp); sortOrder = [['id', 'DESC']]; shouldReverse = true; } if (filters.gte_timestamp) { where.timestamp[Sequelize.Op.gte] = new Date(filters.gte_timestamp); sortOrder = [['id', 'ASC']]; shouldReverse = false; } if (lteTimestamp) { where.timestamp[Sequelize.Op.lte] = new Date(lteTimestamp); filters.lte_timestamp = lteTimestamp; } if (gteTimestamp) { where.timestamp[Sequelize.Op.gte] = new Date(gteTimestamp); filters.gte_timestamp = gteTimestamp; } } const result = await this.errsoleLogs.findAll({ where, order: sortOrder, limit: filters.limit, attributes: { exclude: ['meta'] }, raw: true }); if (shouldReverse) result.reverse(); return { items: result, filters }; } /** * Retrieves the meta data of a log entry. * * @async * @function getMeta * @param {number} id - The unique ID of the log entry. * @returns {Promise<{item: meta, id}>} - A Promise that resolves with an object containing the log ID and its associated metadata. * @throws {Error} - Throws an error if the log entry is not found or the operation fails. */ async getMeta (id) { const result = await this.errsoleLogs.findOne({ where: { id }, attributes: ['id', 'meta'], raw: true }); if (!result) { throw new Error('Log entry not found.'); } return { item: { id: result.id, meta: result.meta } }; } /** * Ensures that the Time To Live (TTL) configuration for logs is set. * * @async * @function ensureLogsTTL * @returns {Promise<{}>} - A promise that resolves with an empty object once the TTL configuration is confirmed or updated. */ async ensureLogsTTL () { const DEFAULT_TTL = 2592000000; // 30 days in milliseconds try { const configResult = await this.getConfig('logsTTL'); if (!configResult.item) { await this.setConfig('logsTTL', DEFAULT_TTL.toString()); } } catch (err) { console.error(err); } return {}; } /** * Deletes expired logs based on TTL configuration. * * @async * @function deleteExpiredLogs */ async deleteExpiredLogs () { if (this.deleteExpiredLogsRunning) return; this.deleteExpiredLogsRunning = true; const defaultLogsTTL = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds try { let logsTTL = defaultLogsTTL; const configResult = await this.getConfig('logsTTL'); if (configResult.item) { const parsedTTL = parseInt(configResult.item.value, 10); logsTTL = isNaN(parsedTTL) ? defaultLogsTTL : parsedTTL; } const expirationTime = new Date(Date.now() - logsTTL); let deletedRowCount; do { deletedRowCount = await this.errsoleLogs.destroy({ where: { timestamp: { [Sequelize.Op.lt]: expirationTime } }, limit: 1000 }); await this.delay(10000); } while (deletedRowCount > 0); } catch (err) { console.error(err); } finally { this.deleteExpiredLogsRunning = false; } } async delay (ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Creates a new user record in the database. * * @async * @function createUser * @param {Object} user - The user data. * @param {string} user.name - The name of the user. * @param {string} user.email - The email address of the user. * @param {string} user.password - The password of the user. * @param {string} user.role - The role of the user. * @returns {Promise<{item: User}>} - A promise that resolves with an object containing the new user item. * @throws {Error} - Throws an error if the user creation fails due to duplicate email or other database issues. */ async createUser (user) { const SALT_ROUNDS = 10; try { const hashedPassword = await bcrypt.hash(user.password, SALT_ROUNDS); const result = await this.errsoleUsers.create({ name: user.name, email: user.email, hashedPassword, role: user.role }); const userItem = result.dataValues; delete userItem.hashedPassword; return { item: userItem }; } catch (err) { if (err.name === 'SequelizeUniqueConstraintError') throw new Error('A user with the provided email already exists.'); throw err; } } /** * Verifies a user's credentials against stored records. * * @async * @function verifyUser * @param {string} email - The email address of the user. * @param {string} password - The password of the user * @returns {Promise<{item: User}>} - A promise that resolves with an object containing the user item upon successful verification. * @throws {Error} - Throws an error if the operation fails. */ async verifyUser (email, password) { if (!email || !password) throw new Error('Both email and password are required for verification.'); const user = await this.errsoleUsers.findOne({ where: { email }, raw: true }); if (!user) throw new Error('User not found.'); const isPasswordCorrect = await bcrypt.compare(password, user.hashedPassword); if (!isPasswordCorrect) throw new Error('Incorrect password.'); // Exclude password before returning the user object delete user.hashedPassword; return { item: user }; } /** * Retrieves the total count of users from the database. * * @async * @function getUserCount * @returns {Promise<{count: number}>} - A promise that resolves with an object containing the count of users. * @throws {Error} - Throws an error if the operation fails. */ async getUserCount () { const count = await this.errsoleUsers.count(); return { count }; } /** * Retrieves all user records from the database. * * @async * @function getAllUsers * @returns {Promise<{items: User[]}>} - A promise that resolves with an object containing an array of user items. * @throws {Error} - Throws an error if the operation fails. */ async getAllUsers () { const users = await this.errsoleUsers.findAll({ attributes: { exclude: ['hashedPassword'] }, raw: true }); return { items: users }; } /** * Retrieves a user record from the database based on the provided email. * * @async * @function getUserByEmail * @param {string} email - The email address of the user. * @returns {Promise<{item: User}>} - A Promise that resolves with an object containing the user item. * @throws {Error} - Throws an error if no user matches the email address. */ async getUserByEmail (email) { if (!email) throw new Error('Email is required.'); const user = await this.errsoleUsers.findOne({ where: { email }, attributes: { exclude: ['hashedPassword'] }, raw: true }); if (!user) throw new Error('User not found.'); return { item: user }; } /** * Updates a user's record in the database based on the provided email. * * @async * @function updateUserByEmail * @param {string} email - The email address of the user to be updated. * @param {Object} updates - The updates to be applied to the user record. * @returns {Promise<{item: User}>} - A Promise that resolves with an object containing the updated user item. * @throws {Error} - Throws an error if no updates could be applied or the user is not found. */ async updateUserByEmail (email, updates) { // Validate input parameters if (!email) throw new Error('Email is required.'); if (!updates || Object.keys(updates).length === 0) throw new Error('No updates provided.'); // Prevent updates to restricted fields const restrictedFields = ['id', 'hashedPassword']; restrictedFields.forEach(field => delete updates[field]); const user = await this.errsoleUsers.findOne({ where: { email } }); if (!user) throw new Error('User not found.'); // Apply the updates const [updateCount] = await this.errsoleUsers.update(updates, { where: { email } }); if (updateCount === 0) throw new Error('No updates applied.'); // Retrieve the updated user information const updatedUser = await this.errsoleUsers.findOne({ where: { email }, attributes: { exclude: ['hashedPassword'] }, raw: true }); if (!updatedUser) throw new Error('Failed to retrieve updated user details.'); return { item: updatedUser }; } /** * Updates a user's password in the database. * * @async * @function updatePassword * @param {string} email - The email address of the user whose password is to be updated. * @param {string} currentPassword - The current password of the user for verification. * @param {string} newPassword - The new password to replace the current one. * @returns {Promise<{item: User}>} - A Promise that resolves with an object containing the updated user item (excluding sensitive information). * @throws {Error} - If the user is not found, if the current password is incorrect, or if the password update fails. */ async updatePassword (email, currentPassword, newPassword) { if (!email || !currentPassword || !newPassword) throw new Error('Email, current password, and new password are required.'); const user = await this.errsoleUsers.findOne({ where: { email }, raw: true }); if (!user) throw new Error('User not found.'); const isPasswordCorrect = await bcrypt.compare(currentPassword, user.hashedPassword); if (!isPasswordCorrect) throw new Error('Current password is incorrect.'); const hashedPassword = await bcrypt.hash(newPassword, 10); const [updateCount] = await this.errsoleUsers.update({ hashedPassword }, { where: { email } }); if (updateCount === 0) { throw new Error('Password update failed.'); } delete user.hashedPassword; return { item: user }; } /** * Deletes a user record from the database. * * @async * @function deleteUser * @param {number} id - The unique ID of the user to be deleted. * @returns {Promise<{}>} - A Promise that resolves with an empty object upon successful deletion of the user. * @throws {Error} - Throws an error if no user is found with the given ID or if the database operation fails. */ async deleteUser (userId) { if (!userId) throw new Error('User ID is required.'); const user = await this.errsoleUsers.findOne({ where: { id: userId } }); if (!user) throw new Error('User not found.'); await user.destroy(); return { item: {} }; } } module.exports = ErrsoleSequelize; module.exports.default = ErrsoleSequelize;