diff --git a/backend/UserConfig.ts b/backend/UserConfig.ts index c31b903..df4bce5 100644 --- a/backend/UserConfig.ts +++ b/backend/UserConfig.ts @@ -102,12 +102,17 @@ export class UserConfig { if (!Checkers.s3.credentials.secretKey(config.s3.credentials.secretKey)) throw new Error('Invalid S3 Secret key'); } - // * Optional SQL config(s) (Currently only checks MySQL) - if (config.sql?.mySql != null) { - if (!Checkers.sql.mySql.host(config.sql.mySql.host)) throw new Error('Invalid MySql Host'); - if (!Checkers.sql.mySql.user(config.sql.mySql.user)) throw new Error('Invalid MySql User'); - if (!Checkers.sql.mySql.password(config.sql.mySql.password)) throw new Error('Invalid MySql Password'); - if (!Checkers.sql.mySql.database(config.sql.mySql.database)) throw new Error('Invalid MySql Database'); + // * Optional database config(s) + if (config.database != null) { + // these both have the same schema so we can just check both + if (config.database.kind == 'mysql' || config.database.kind == 'postgres') { + if (config.database.options != undefined) { + if (!Checkers.sql.mySql.host(config.database.options.host)) throw new Error('Invalid database host'); + if (!Checkers.sql.mySql.user(config.database.options.user)) throw new Error('Invalid databse user'); + if (!Checkers.sql.mySql.password(config.database.options.password)) throw new Error('Invalid database password'); + if (!Checkers.sql.mySql.database(config.database.options.database)) throw new Error('Invalid database'); + } else throw new Error('Database options missing'); + } } // * optional rate limit config diff --git a/backend/app.ts b/backend/app.ts index ff51707..85165c8 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -15,6 +15,7 @@ import { UserConfig } from './UserConfig'; import { MySQLDatabase } from './sql/mysql'; import { buildFrontendRouter } from './routers/_frontend'; import { DBManager } from './sql/database'; +import { PostgreSQLDatabase } from './sql/postgres'; /** * Top-level metadata exports @@ -114,10 +115,21 @@ async function main() { .catch((err) => (err.code && err.code === 'ENOENT' ? {} : console.error(err), resolve(void 0)))); // If user config is ready, try to configure SQL - if (UserConfig.ready && UserConfig.config.sql?.mySql != null) { - try { await DBManager.use(new MySQLDatabase()); } - catch (err) { throw new Error(`Failed to configure SQL`); } - } else { + if (UserConfig.ready && UserConfig.config.database != null) { + try { + switch (UserConfig.config.database?.kind) { + case 'json': + await DBManager.use(new JSONDatabase()); + break; + case 'mysql': + await DBManager.use(new MySQLDatabase()); + break; + case 'postgres': + await DBManager.use(new PostgreSQLDatabase()); + break; + } + } catch (err) { throw new Error(`Failed to configure SQL`); } + } else { // default to json database await DBManager.use(new JSONDatabase()); } diff --git a/backend/data.ts b/backend/data.ts index 61426e3..72e2f8c 100644 --- a/backend/data.ts +++ b/backend/data.ts @@ -20,45 +20,17 @@ const PATHS = { users: path.join('.ass-data/users.json') }; -const bothWriter = async (files: FilesSchema, users: UsersSchema) => { - await fs.writeJson(PATHS.files, files, { spaces: '\t' }); - await fs.writeJson(PATHS.users, users, { spaces: '\t' }); +/** + * database kind -> name mapping + */ +const DBNAMES = { + 'mysql': 'MySQL', + 'postgres': 'PostgreSQL', + 'json': 'JSON' }; -export const setDataModeToSql = (): Promise => new Promise(async (resolve, reject) => { - log.debug('Setting data mode to SQL'); - - // Main config check - if (!UserConfig.ready || !UserConfig.config.sql?.mySql) return reject(new Error('MySQL not configured')); - const mySqlConf = UserConfig.config.sql.mySql; - - // Read data files - const [files, users]: [FilesSchema, UsersSchema] = await Promise.all([fs.readJson(PATHS.files), fs.readJson(PATHS.users)]); - - // Check the MySQL configuration - const checker = (val: string) => val != null && val !== ''; - const issue = - !checker(mySqlConf.host) ? 'Missing MySQL Host' - : !checker(mySqlConf.user) ? 'Missing MySQL User' - : !checker(mySqlConf.password) ? 'Missing MySQL Password' - : !checker(mySqlConf.database) ? 'Missing MySQL Database' - - // ! Blame VS Code for this weird indentation - : undefined; - - // Set the vars - files.useSql = issue == null; - users.useSql = issue == null; - - // Write data & return - await bothWriter(files, users); - (issue) ? reject(new Error(issue)) : resolve(void 0); -}); - export const put = (sector: DataSector, key: NID, data: AssFile | AssUser): Promise => new Promise(async (resolve, reject) => { try { - const useSql = UserConfig.config.sql != undefined; - if (sector === 'files') { // * 1: Save as files (image, video, etc) await DBManager.put('assfiles', key, data as AssFile); @@ -67,7 +39,7 @@ export const put = (sector: DataSector, key: NID, data: AssFile | AssUser): Prom await DBManager.put('assusers', key, data as AssUser); } - log.info(`PUT ${sector} data`, `using ${useSql ? 'SQL' : 'local JSON'}`, key); + log.info(`PUT ${sector} data`, `using ${DBNAMES[UserConfig.config.database?.kind ?? 'json']}`, key); resolve(void 0); } catch (err) { reject(err); diff --git a/backend/routers/api.ts b/backend/routers/api.ts index 3383b5e..e2aa2bc 100644 --- a/backend/routers/api.ts +++ b/backend/routers/api.ts @@ -9,6 +9,9 @@ import { nanoid } from '../generators'; import { UserConfig } from '../UserConfig'; import { rateLimiterMiddleware, setRateLimiter } from '../ratelimit'; import { DBManager } from '../sql/database'; +import { JSONDatabase } from '../sql/json'; +import { MySQLDatabase } from '../sql/mysql'; +import { PostgreSQLDatabase } from '../sql/postgres'; const router = Router({ caseSensitive: true }); @@ -26,9 +29,20 @@ router.post('/setup', BodyParserJson(), async (req, res) => { // Save config await UserConfig.saveConfigFile(); - // Set data storage (not files) to SQL if required - if (UserConfig.config.sql?.mySql != null) - await Promise.all([DBManager.configure(), data.setDataModeToSql()]); + // set up new databases + if (UserConfig.config.database) { + switch (UserConfig.config.database.kind) { + case 'json': + await DBManager.use(new JSONDatabase()); + break; + case 'mysql': + await DBManager.use(new MySQLDatabase()); + break; + case 'postgres': + await DBManager.use(new PostgreSQLDatabase()); + break; + } + } // set rate limits if (UserConfig.config.rateLimit?.api) setRateLimiter('api', UserConfig.config.rateLimit.api); diff --git a/backend/sql/json.ts b/backend/sql/json.ts index 2d60545..6703ed2 100644 --- a/backend/sql/json.ts +++ b/backend/sql/json.ts @@ -89,10 +89,10 @@ export const ensureFiles = (): Promise => new Promise(async (resolve, reje * JSON database. i know json isnt sql, shut up. */ export class JSONDatabase implements Database { - open(): Promise { return Promise.resolve() } - close(): Promise { return Promise.resolve() } + public open(): Promise { return Promise.resolve() } + public close(): Promise { return Promise.resolve() } - configure(): Promise { + public configure(): Promise { return new Promise((resolve, reject) => { ensureFiles(); @@ -100,7 +100,7 @@ export class JSONDatabase implements Database { }); } - put(table: DatabaseTable, key: string, data: DatabaseValue): Promise { + public put(table: DatabaseTable, key: string, data: DatabaseValue): Promise { return new Promise(async (resolve, reject) => { if (table == 'assfiles') { // ? Local JSON @@ -138,14 +138,14 @@ export class JSONDatabase implements Database { }) } - get(table: DatabaseTable, key: string): Promise { + public get(table: DatabaseTable, key: string): Promise { return new Promise(async (resolve, reject) => { const data = (await fs.readJson(PATHMAP[table]))[SECTORMAP[table]][key]; (!data) ? resolve(undefined) : resolve(data); }); } - getAll(table: DatabaseTable): Promise<{ [index: string]: DatabaseValue }> { + public getAll(table: DatabaseTable): Promise<{ [index: string]: DatabaseValue }> { return new Promise(async (resolve, reject) => { const data = (await fs.readJson(PATHMAP[table]))[SECTORMAP[table]]; (!data) ? resolve({}) : resolve(data); diff --git a/backend/sql/mysql.ts b/backend/sql/mysql.ts index d2c9bfd..3e90b47 100644 --- a/backend/sql/mysql.ts +++ b/backend/sql/mysql.ts @@ -25,6 +25,31 @@ export class MySQLDatabase implements Database { .catch((err) => reject(err))); } + /** + * validate the mysql config + */ + private _validateConfig(): string | undefined { + // make sure the configuration exists + if (!UserConfig.ready) return 'User configuration not ready'; + if (typeof UserConfig.config.database != 'object') return 'MySQL configuration missing'; + if (UserConfig.config.database.kind != "mysql") return 'Database not set to MySQL, but MySQL is in use, something has gone terribly wrong'; + if (typeof UserConfig.config.database.options != 'object') return 'MySQL configuration missing'; + + let mySqlConf = UserConfig.config.database.options; + + // Check the MySQL configuration + const checker = (val: string) => val != null && val !== ''; + const issue = + !checker(mySqlConf.host) ? 'Missing MySQL Host' + : !checker(mySqlConf.user) ? 'Missing MySQL User' + : !checker(mySqlConf.password) ? 'Missing MySQL Password' + : !checker(mySqlConf.database) ? 'Missing MySQL Database' + // ! Blame VS Code for this weird indentation + : undefined; + + return issue; + } + public open() { return Promise.resolve(); } public close() { return Promise.resolve(); } @@ -35,11 +60,11 @@ export class MySQLDatabase implements Database { return new Promise(async (resolve, reject) => { try { // Config check - if (!UserConfig.ready) throw new Error('User configuration not ready'); - if (!UserConfig.config.sql?.mySql) throw new Error('MySQL configuration missing'); + let configError = this._validateConfig(); + if (configError) throw new Error(configError); // Create the pool - this._pool = mysql.createPool(UserConfig.config.sql.mySql); + this._pool = mysql.createPool(UserConfig.config.database!.options!); // Check if the pool is usable const [rowz, _fields] = await this._pool.query(`SHOW FULL TABLES WHERE Table_Type LIKE 'BASE TABLE';`); @@ -61,7 +86,7 @@ export class MySQLDatabase implements Database { // Check which tables ACTUALLY do exist for (let row of rows_tableData) { - const table = row[`Tables_in_${UserConfig.config.sql!.mySql!.database}` + const table = row[`Tables_in_${UserConfig.config.database!.options!.database}` ] as DatabaseTable; if (table === 'assfiles') tablesExist.files = true; if (table === 'assusers') tablesExist.users = true; diff --git a/backend/sql/postgres.ts b/backend/sql/postgres.ts index e69de29..45a3ed0 100644 --- a/backend/sql/postgres.ts +++ b/backend/sql/postgres.ts @@ -0,0 +1,35 @@ +import { Client } from 'pg'; + +import { log } from '../log'; +import { Database, DatabaseTable, DatabaseValue } from './database'; + +export class PostgreSQLDatabase implements Database { + private _client: Client; + + public open(): Promise { return Promise.resolve(); } + public close(): Promise { return Promise.resolve(); } + + public configure(): Promise { + return new Promise((resolve, reject) => { + try { + + } catch (err) { + log.error('PostgreSQL', 'failed to initialize'); + console.error(err); + reject(err); + } + }); + } + + public put(table: DatabaseTable, key: string, data: DatabaseValue): Promise { + throw new Error("Method not implemented."); + } + + public get(table: DatabaseTable, key: string): Promise { + throw new Error("Method not implemented."); + } + + public getAll(table: DatabaseTable): Promise<{ [index: string]: DatabaseValue; }> { + throw new Error("Method not implemented."); + } +} \ No newline at end of file diff --git a/common/types.d.ts b/common/types.d.ts index af05700..305f5e7 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -24,7 +24,7 @@ declare module 'ass' { maximumFileSize: number; s3?: S3Configuration; - sql?: SqlConfiguration; + database?: DatabaseConfiguration; rateLimit?: RateLimitConfiguration; } @@ -51,13 +51,23 @@ declare module 'ass' { } } - interface SqlConfiguration { - mySql?: { - host: string; - user: string; - password: string; - database: string; - } + interface DatabaseConfiguration { + kind: 'mysql' | 'postgres' | 'json'; + options?: MySQLConfiguration | PostgresConfiguration; + } + + interface MySQLConfiguration { + host: string; + user: string; + password: string; + database: string; + } + + interface PostgresConfiguration { + host: string; + user: string; + password: string; + database: string; } /** @@ -219,7 +229,6 @@ declare module 'ass' { files: { [key: NID]: AssFile; } - useSql: boolean; meta: { [key: string]: any }; } @@ -232,7 +241,6 @@ declare module 'ass' { [key: NID]: AssUser; }; cliKey: string; - useSql: boolean; meta: { [key: string]: any }; } } diff --git a/frontend/setup.mts b/frontend/setup.mts index 306e608..ca33b7e 100644 --- a/frontend/setup.mts +++ b/frontend/setup.mts @@ -1,4 +1,4 @@ -import { SlInput, SlButton } from '@shoelace-style/shoelace'; +import { SlInput, SlButton, SlTab } from '@shoelace-style/shoelace'; import { IdType, UserConfiguration } from 'ass'; const genericErrorAlert = () => alert('An error occured, please check the console for details'); @@ -41,11 +41,20 @@ document.addEventListener('DOMContentLoaded', () => { s3secretKey: document.querySelector('#s3-secretKey') as SlInput, s3region: document.querySelector('#s3-region') as SlInput, + jsonTab: document.querySelector('#json-tab') as SlTab, + + mySqlTab: document.querySelector('#mysql-tab') as SlTab, mySqlHost: document.querySelector('#mysql-host') as SlInput, mySqlUser: document.querySelector('#mysql-user') as SlInput, mySqlPassword: document.querySelector('#mysql-password') as SlInput, mySqlDatabase: document.querySelector('#mysql-database') as SlInput, + pgsqlTab: document.querySelector('#pgsql-tab') as SlTab, + pgsqlHost: document.querySelector('#pgsql-host') as SlInput, + pgsqlUser: document.querySelector('#pgsql-user') as SlInput, + pgsqlPassword: document.querySelector('#pgsql-password') as SlInput, + pgsqlDatabase: document.querySelector('#pgsql-database') as SlInput, + userUsername: document.querySelector('#user-username') as SlInput, userPassword: document.querySelector('#user-password') as SlInput, @@ -88,14 +97,34 @@ document.addEventListener('DOMContentLoaded', () => { config.s3.region = Elements.s3region.value; } - // Append MySQL to config, if specified - if (Elements.mySqlHost.value != null && Elements.mySqlHost.value !== '') { - if (!config.sql) config.sql = {}; - config.sql.mySql = { - host: Elements.mySqlHost.value, - user: Elements.mySqlUser.value, - password: Elements.mySqlPassword.value, - database: Elements.mySqlDatabase.value + // Append database to config, if specified + if (Elements.jsonTab.active) { + config.database = { + kind: 'json' + }; + } else if (Elements.mySqlTab.active) { + if (Elements.mySqlHost.value != null && Elements.mySqlHost.value != '') { + config.database = { + kind: 'mysql', + options: { + host: Elements.mySqlHost.value, + user: Elements.mySqlUser.value, + password: Elements.mySqlPassword.value, + database: Elements.mySqlDatabase.value + } + } + }; + } else if (Elements.pgsqlTab.active) { + if (Elements.pgsqlHost.value != null && Elements.pgsqlHost.value != '') { + config.database = { + kind: 'postgres', + options: { + host: Elements.pgsqlHost.value, + user: Elements.pgsqlUser.value, + password: Elements.pgsqlPassword.value, + database: Elements.pgsqlDatabase.value + } + } }; } diff --git a/package.json b/package.json index c8a5659..c24d47a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "mysql2": "^3.6.1", "node-fetch": "^2.6.7", "node-vibrant": "^3.1.6", + "pg": "^8.11.3", "pug": "^3.0.2", "sanitize-filename": "^1.6.3", "sharp": "^0.32.6", @@ -76,6 +77,7 @@ "@types/luxon": "^3.3.2", "@types/node": "^18.16.19", "@types/node-fetch": "^2.6.6", + "@types/pg": "^8.10.7", "@types/sharp": "^0.32.0", "@types/tailwindcss": "^3.1.0", "@types/uuid": "^8.3.1" diff --git a/views/setup.pug b/views/setup.pug index 6446068..cd7cb6a 100644 --- a/views/setup.pug +++ b/views/setup.pug @@ -30,6 +30,39 @@ block content h3.setup-text-item-title Password sl-input#user-password(type='password' placeholder='the-most-secure' clearable): sl-icon(slot='prefix' name='fas-lock' library='fa') + //- * Database + h2.setup-text-section-header.mt-4 Database + .setup-panel + sl-tab-group + //- * JSON + sl-tab#json-tab(slot='nav' panel='json') JSON + sl-tab-panel(name='json') + | you all good! + + //- * MySQL + sl-tab#mysql-tab(slot='nav' panel='mysql') MySQL + sl-tab-panel(name='mysql') + h3.setup-text-item-title Host + sl-input#mysql-host(type='text' placeholder='mysql.example.com' clearable): sl-icon(slot='prefix' name='fas-server' library='fa') + h3.setup-text-item-title User + sl-input#mysql-user(type='text' placeholder='myassql' clearable): sl-icon(slot='prefix' name='fas-user' library='fa') + h3.setup-text-item-title Password + sl-input#mysql-password(type='password' placeholder='super-secure' clearable): sl-icon(slot='prefix' name='fas-lock' library='fa') + h3.setup-text-item-title Database + sl-input#mysql-database(type='text' placeholder='assdb' clearable): sl-icon(slot='prefix' name='fas-database' library='fa') + + //- * PostgreSQL + sl-tab#pgsql-tab(slot='nav' panel='pgsql') PostgreSQL + sl-tab-panel(name='pgsql') + h3.setup-text-item-title Host + sl-input#pgsql-host(type='text' placeholder='postgres.example.com' clearable): sl-icon(slot='prefix' name='fas-server' library='fa') + h3.setup-text-item-title User + sl-input#pgsql-user(type='text' placeholder='posgrassql' clearable): sl-icon(slot='prefix' name='fas-user' library='fa') + h3.setup-text-item-title Password + sl-input#pgsql-password(type='password' placeholder='super-secure' clearable): sl-icon(slot='prefix' name='fas-lock' library='fa') + h3.setup-text-item-title Database + sl-input#pgsql-database(type='text' placeholder='assdb' clearable): sl-icon(slot='prefix' name='fas-database' library='fa') + //- * S3 h2.setup-text-section-header.mt-4 S3 #[span.setup-text-optional optional] .setup-panel @@ -43,18 +76,6 @@ block content sl-input#s3-secretKey(type='password' placeholder='EF56GH78IJ90KL12' clearable): sl-icon(slot='prefix' name='fas-user-secret' library='fa') h3.setup-text-item-title Region #[span.setup-text-optional optional] sl-input#s3-region(type='text' placeholder='us-east' clearable): sl-icon(slot='prefix' name='fas-map-location-dot' library='fa') - - //- * MySQL - h2.setup-text-section-header.mt-4 MySQL #[span.setup-text-optional optional] - .setup-panel - h3.setup-text-item-title Host - sl-input#mysql-host(type='text' placeholder='mysql.example.com' clearable): sl-icon(slot='prefix' name='fas-server' library='fa') - h3.setup-text-item-title User - sl-input#mysql-user(type='text' placeholder='myassql' clearable): sl-icon(slot='prefix' name='fas-user' library='fa') - h3.setup-text-item-title Password - sl-input#mysql-password(type='password' placeholder='super-secure' clearable): sl-icon(slot='prefix' name='fas-lock' library='fa') - h3.setup-text-item-title Database - sl-input#mysql-database(type='text' placeholder='assdb' clearable): sl-icon(slot='prefix' name='fas-database' library='fa') //- * Rate Limits h2.setup-text-section-header.mt-4 Rate Limits #[span.setup-text-optional optional]