diff --git a/options.json b/options.json index 28f86b8..6f2e3ec 100644 --- a/options.json +++ b/options.json @@ -24,7 +24,17 @@ }, "storage": { "mongodb": { - "database": "galactic" + "database": "galactic", + "tables": [ + "infractions", + "guilds", + "messages", + "attachments", + "users", + "permissions", + "role_cache", + "webhooks" + ] } } } \ No newline at end of file diff --git a/src/structure/DiscordClient.js b/src/structure/DiscordClient.js index 110a3df..bb8a131 100644 --- a/src/structure/DiscordClient.js +++ b/src/structure/DiscordClient.js @@ -2,6 +2,7 @@ const { Client } = require('discord.js'); const { Logger, Intercom, EventHooker, LocaleLoader, Registry, Dispatcher, Resolver } = require('./client/'); const { Observer, Command } = require('./interfaces/'); +const StorageManager = require('./storage/StorageManager.js'); const { DefaultGuild } = require('../constants/'); @@ -20,6 +21,7 @@ class DiscordClient extends Client { this.intercom = new Intercom(this); this.logger = new Logger(this); this.localeLoader = new LocaleLoader(this); + this.storageManager = new StorageManager(this, options.storage); this.registry = new Registry(this); this.dispatcher = new Dispatcher(this); diff --git a/src/structure/storage/StorageManager.js b/src/structure/storage/StorageManager.js new file mode 100644 index 0000000..56206f8 --- /dev/null +++ b/src/structure/storage/StorageManager.js @@ -0,0 +1,65 @@ +const path = require('path'); +const fs = require('fs'); +const chalk = require('chalk'); + +const { Provider } = require('./interfaces/'); + +class StorageManager { + + constructor(client, options = {}) { + + this.client = client; + + this.providers = {}; + this.options = options; + + } + + async initialize() { + + // this.manager.logger.write('debug', "Initializing storage providers."); + + const _providers = path.join(process.cwd(), "structure/storage/providers"); + const providers = fs.readdirSync(_providers); + + for (const _provider of providers) { + + let provider = require(path.join(_providers, _provider)); + provider = new provider(this.client, this.options); + + this.providers[provider.name] = provider; + await provider.initialize(); + this._log(`Provider ${chalk.bold(provider.name)} was ${chalk.bold('loaded')}.`, provider); + + await provider.loadTables(); + + } + + return this; + + } + + _getName(instance) { + if (instance instanceof Provider) return instance.name.substring(0, 5); + return `${instance.provider.name.substring(0, 5)}:${instance.name}`; + } + + _error(info, instance = null) { + this.client.logger.error(`${chalk.bold(`[STORA]`)} ${instance ? `(${this._getName(instance)}) ` : ''}${info}`); + } + + _log(info, instance = null) { + this.client.logger.info(`${chalk.bold(`[STORA]`)} ${instance ? `(${this._getName(instance)}) ` : ''}${info}`); + } + + get mongodb() { + return this.providers.mongodb; + } + + get mariadb() { + return this.providers.mariadb; + } + +} + +module.exports = StorageManager; \ No newline at end of file diff --git a/src/structure/storage/interfaces/MariadbTable.js b/src/structure/storage/interfaces/MariadbTable.js new file mode 100644 index 0000000..75f6b10 --- /dev/null +++ b/src/structure/storage/interfaces/MariadbTable.js @@ -0,0 +1,14 @@ +class MariadbTable { + + constructor(client, provider, opts = {}) { + + this.client = client; + this.provider = provider; + + this.name = opts.name; + + } + +} + +module.exports = MariadbTable; \ No newline at end of file diff --git a/src/structure/storage/interfaces/MongodbTable.js b/src/structure/storage/interfaces/MongodbTable.js new file mode 100644 index 0000000..bdedd06 --- /dev/null +++ b/src/structure/storage/interfaces/MongodbTable.js @@ -0,0 +1,191 @@ +const { ObjectId } = require('mongodb'); + +class MongodbTable { + + constructor(client, provider, opts = {}) { + + this.client = client; + this.provider = provider; + + this.name = opts.name; + + } + + //Data Search + + find(query, opts = {}) { //opts: { projection: ... } + query = this._handleData(query); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.find(query, opts, async (error, cursor) => { + if (error) return reject(error); + return resolve(await cursor.toArray()); + }); + }); + } + + findOne(query, opts = {}) { //opts: { projection: ..., sort: ... } + query = this._handleData(query); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.findOne(query, opts, async (error, item) => { + if (error) return reject(error); + return resolve(item); + }); + }); + } + + aggregate(query) { + query = this._handleData(query); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.aggregate(query, (error, item) => { + if (error) return reject(error); + return resolve(item.toArray()); + }); + }); + } + + random(query, amount = 1) { + query = this._handleData(query); + if (amount > 100) amount = 100; + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.aggregate([{ $match: query }, { $sample: { size: amount } }], (error, item) => { + if (error) return reject(error); + return resolve(item); + }); + }); + } + + //Data Manipulation + + insertOne(data) { + data = this._handleData(data); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.insertOne(data, (error, result) => { + if (error) return reject(error); + return resolve(result); + }); + }); + } + + //NOTE: insertMany? + + deleteOne(query) { + query = this._handleData(query); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.deleteOne(query, (error, result) => { + if (error) return reject(error); + return resolve(result); + }); + }); + } + + deleteMany(query) { + query = this._handleData(query); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.deleteMany(query, (error, result) => { + if (error) return reject(error); + return resolve(result); + }); + }); + } + + updateOne(query, data, upsert = true) { + query = this._handleData(query); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.updateOne(query, { $set: data }, { upsert }, async (error, result) => { + if (error) return reject(error); + + const { matchedCount, upsertedCount, modifiedCount } = result; + return resolve({ matched: matchedCount, upserted: upsertedCount, modified: modifiedCount }); + }); + }); + } + + removeProperty(query, data) { + query = this._handleData(query); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + + const unset = {}; + for (const field of data) unset[field] = ''; + this.collection.updateOne(query, { $unset: unset }, async (error, result) => { + if (error) return reject(error); + const { matchedCount, modifiedCount } = result; + return resolve({ matched: matchedCount, modified: modifiedCount }); + }); + + }); + } + + push(query, data, upsert = true) { + query = this._handleData(query); + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.updateOne(query, { $push: data }, { upsert }, async (error, result) => { + if (error) return reject(error); + return resolve(result); + }); + }); + } + + //Statistics + stats(options = {}) { + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.stats(options, (error, statistics) => { + if (error) return reject(error); + const { ns, size, count, avgObjSize, freeStorageSize, capped } = statistics; + return resolve({ + index: ns, + averageSize: avgObjSize, + currentSize: size, + remainingSize: freeStorageSize, + maximumSize: size + freeStorageSize, + count, + capped + }); + }); + }); + } + + count(query, options = {}) { + return new Promise((resolve, reject) => { + if (!this.provider._initialized) return reject(new Error('MongoDB is not connected.')); + this.collection.countDocuments(query, options, (error, result) => { + if (error) return reject(error); + return resolve(result); + }); + }); + } + + //Lazy Function + _handleData(data) { //Convert data._id to Mongo ObjectIds (gets converted to plaintext through shard communication) + if (data._id) { + if (typeof data._id === 'string') data._id = ObjectId(data._id); + else if (typeof data._id === 'object') data._id = { + $in: Object.values(data._id)[0].map((id) => { + return ObjectId(id); + }) + }; + } + return data; + } + + + //Getters + + get collection() { + return this.provider.db.collection(this.name); + } + + +} + +module.exports = MongodbTable; \ No newline at end of file diff --git a/src/structure/storage/interfaces/Provider.js b/src/structure/storage/interfaces/Provider.js new file mode 100644 index 0000000..0d734f3 --- /dev/null +++ b/src/structure/storage/interfaces/Provider.js @@ -0,0 +1,93 @@ +const path = require('path'); +const chalk = require('chalk'); + +const Util = require('../../../Util.js'); + +const MongodbTable = require('./MongodbTable.js'); +const MariadbTable = require('./MariadbTable.js'); + +const Constants = { + Tables: { + 'mongodb': MongodbTable, + 'mariadb': MariadbTable + } +}; + +class Provider { + + constructor(client, opts) { + + if (!opts.config) throw new Error('No config file provided!'); + this.config = opts.config[opts.name]; + if (this.config && (!this.config.database || !this.config.host)) throw new Error('Invalid config file provided!' + JSON.stringify(this.config)); + + this.client = client; + this.storageManager = client.storageManager; + + this.name = opts.name; + this.connection = null; + this.db = null; + + this.tables = {}; + + this._initialized = false; + this._class = Constants.Tables[opts.name]; + + } + + async loadTables() { + + const directory = path.join(process.cwd(), 'structure/storage/'); + const files = await Util.readdirRecursive(path.join(directory, `tables/${this.name}`)); + + const loaded = []; + + for (const file of files) { + const func = require(file); + if (typeof func !== 'function') { + this.storageManager._error("Attempted to index an invalid function as a table.", this); + delete require.cache[file]; + continue; + } + const table = new func(this.client, this); + if (this._class && !(table instanceof this._class)) { + this.storageManager._error("Attempted to load an invalid class as a table.", this); + delete require.cache[file]; + continue; + } + + loaded.push(await this.loadTable(table)); + + } + + for (const table of this.config.tables) { + if (this.tables[table]) continue; + else loaded.push(await this.loadTable(new this._class(this.client, this, { + name: table + }))); + } + + return loaded; + + } + + async loadTable(table) { + + if (this.tables[table.name]) { + this.storageManager._error("Attempted to load an existing table.", table); + return null; + } + + this.tables[table.name] = table; + this.storageManager._log(`Table ${chalk.bold(table.name)} was ${chalk.bold('loaded')}.`, table); + return table; + + } + + _error(err) { + this.storageManager._error(err, this); + } + +} + +module.exports = Provider; \ No newline at end of file diff --git a/src/structure/storage/interfaces/index.js b/src/structure/storage/interfaces/index.js new file mode 100644 index 0000000..9556531 --- /dev/null +++ b/src/structure/storage/interfaces/index.js @@ -0,0 +1,5 @@ +module.exports = { + Provider: require('./Provider.js'), + MongodbTable: require('./MongodbTable.js'), + MariadbTable: require('./MariadbTable.js') +}; \ No newline at end of file diff --git a/src/structure/storage/providers/Mariadb.js b/src/structure/storage/providers/Mariadb.js new file mode 100644 index 0000000..e2defb1 --- /dev/null +++ b/src/structure/storage/providers/Mariadb.js @@ -0,0 +1,76 @@ +const { Provider } = require('../interfaces/'); +const MySQL = require('mysql'); + +class MariaDBProvider extends Provider { + + constructor(client, config) { + + super(client, { + name: 'mariadb', + config + }); + + } + + async initialize() { + + try { + + this.db = MySQL.createPool(this.config); + + this.db.on('connection', async (connection) => { + + // this.manager.logger.log('MariaDB connected.'); + + connection.on('error', (err) => { + // this.manager.logger.error('MariaDB errored.', err); + }); + + connection.on('close', (data) => { + // this.manager.logger.log('MariaDB connection closed.', data); + }); + + }); + + this.loaded = true; + + } catch (err) { + + // this.manager.logger.error('MariaDB connection failed.', err); + + } + + return this; + + } + + close() { + this.db.end(); + } + + /** + * Query using SQL to MariaDB + * + * @param {string} query SQL query string. + * @param {array} values Array of values to replace ? with in the query string + * @returns {object} Returns an object containing the query result + * @memberof MariaDBProvider + */ + query(query, values) { + + if (!this.loaded) throw new Error('MariaDB not connected'); + + return new Promise((resolve, reject) => { + + this.db.query(query, values, (err, result) => { + if (err) reject(err); + resolve(result); + }); + + }); + + } + +} + +module.exports = MariaDBProvider; \ No newline at end of file diff --git a/src/structure/storage/providers/Mongodb.js b/src/structure/storage/providers/Mongodb.js new file mode 100644 index 0000000..857c322 --- /dev/null +++ b/src/structure/storage/providers/Mongodb.js @@ -0,0 +1,77 @@ +const { Provider } = require('../interfaces/'); +const { MongoClient } = require('mongodb'); + +class MongoDBProvider extends Provider { + + constructor(client, config) { + + super(client, { + name: 'mongodb', + config + }); + + } + + async initialize() { + + try { + this.connection = await MongoClient.connect(process.env.MONGODB_HOST + this.config.database, { useUnifiedTopology: true }); + this.db = await this.connection.db(this.config.database); + return this._initialized = true; //eslint-disable-line no-return-assign + } catch (err) { + this._error(err); + return false; + } + + } + + stats(options = {}) { + + return new Promise((resolve, reject) => { + + if (!this._initialized) return reject(new Error('MongoDB is not connected.')); + this.db.stats(options, (error, result) => { + if (error) reject(error); + resolve(result); + }); + + }); + + } + + get guilds() { + return this.tables.guilds; + } + + get permissions() { + return this.tables.permissions; + } + + get infractions() { + return this.tables.infractions; + } + + get messages() { + return this.tables.messages; + } + + get attachments() { + return this.tables.attachments; + } + + get users() { + return this.tables.users; + } + + // eslint-disable-next-line camelcase + get role_cache() { + return this.tables.role_cache; + } + + get webhooks() { + return this.tables.webhooks; + } + +} + +module.exports = MongoDBProvider; \ No newline at end of file diff --git a/src/structure/storage/tables/mariadb/Activity.js b/src/structure/storage/tables/mariadb/Activity.js new file mode 100644 index 0000000..f25cd8a --- /dev/null +++ b/src/structure/storage/tables/mariadb/Activity.js @@ -0,0 +1,17 @@ +const { MariadbTable } = require('../../interfaces/'); + +class InfractionsTable extends MariadbTable { + + constructor(client, provider) { + + super(client, provider, { + name: 'activity' + }); + + this.provider = provider; + + } + +} + +module.exports = InfractionsTable; \ No newline at end of file diff --git a/src/structure/storage/tables/mongodb/Infractions.js b/src/structure/storage/tables/mongodb/Infractions.js new file mode 100644 index 0000000..e1feba4 --- /dev/null +++ b/src/structure/storage/tables/mongodb/Infractions.js @@ -0,0 +1,17 @@ +const { MongodbTable } = require('../../interfaces/'); + +class InfractionsTable extends MongodbTable { + + constructor(client, provider) { + + super(client, provider, { + name: 'infractions' + }); + + this.provider = provider; + + } + +} + +module.exports = InfractionsTable; \ No newline at end of file