diff --git a/.dockerignore b/.dockerignore index 47e82a105..3c9fdd6e1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ /.idea /node_modules -/data +/data* /cypress /out /test diff --git a/.gitignore b/.gitignore index 06dca04b4..7ef7c7119 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist-ssr /data !/data/.gitkeep +/data* .vscode /private diff --git a/docker/debian-base.dockerfile b/docker/debian-base.dockerfile index c2b8bfb41..6701511fd 100644 --- a/docker/debian-base.dockerfile +++ b/docker/debian-base.dockerfile @@ -33,4 +33,4 @@ RUN apt update && \ rm -rf /var/lib/apt/lists/* && \ apt --yes autoremove RUN chown -R node:node /var/lib/mysql - +ENV UPTIME_KUMA_ENABLE_EMBEDDED_MARIADB=1 diff --git a/extra/remove-2fa.js b/extra/remove-2fa.js index f88c43fca..e6a8b97c8 100644 --- a/extra/remove-2fa.js +++ b/extra/remove-2fa.js @@ -12,7 +12,7 @@ const rl = readline.createInterface({ }); const main = async () => { - Database.init(args); + Database.initDataDir(args); await Database.connect(); try { diff --git a/extra/reset-password.js b/extra/reset-password.js index 168983312..3f6f79c1d 100644 --- a/extra/reset-password.js +++ b/extra/reset-password.js @@ -13,7 +13,7 @@ const rl = readline.createInterface({ const main = async () => { console.log("Connecting the database"); - Database.init(args); + Database.initDataDir(args); await Database.connect(false, false, true); try { diff --git a/server/database.js b/server/database.js index ffc88ca23..954882a61 100644 --- a/server/database.js +++ b/server/database.js @@ -25,7 +25,7 @@ class Database { */ static uploadDir; - static path; + static sqlitePath; /** * @type {boolean} @@ -83,10 +83,10 @@ class Database { static noReject = true; /** - * Initialize the database + * Initialize the data directory * @param {Object} args Arguments to initialize DB with */ - static init(args) { + static initDataDir(args) { // Data Directory (must be end with "/") Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; @@ -96,7 +96,7 @@ class Database { PluginsManager.disable = true; } - Database.path = Database.dataDir + "kuma.db"; + Database.sqlitePath = Database.dataDir + "kuma.db"; if (! fs.existsSync(Database.dataDir)) { fs.mkdirSync(Database.dataDir, { recursive: true }); } @@ -110,6 +110,26 @@ class Database { log.info("db", `Data Dir: ${Database.dataDir}`); } + static readDBConfig() { + let dbConfig; + + let dbConfigString = fs.readFileSync(path.join(Database.dataDir, "db-config.json")).toString("utf-8"); + dbConfig = JSON.parse(dbConfigString); + + if (typeof dbConfig !== "object") { + throw new Error("Invalid db-config.json, it must be an object"); + } + + if (typeof dbConfig.type !== "string") { + throw new Error("Invalid db-config.json, type must be a string"); + } + return dbConfig; + } + + static writeDBConfig(dbConfig) { + fs.writeFileSync(path.join(Database.dataDir, "db-config.json"), JSON.stringify(dbConfig, null, 4)); + } + /** * Connect to the database * @param {boolean} [testMode=false] Should the connection be @@ -121,21 +141,11 @@ class Database { */ static async connect(testMode = false, autoloadModels = true, noLog = false) { const acquireConnectionTimeout = 120 * 1000; - let dbConfig; - try { - let dbConfigString = fs.readFileSync(path.join(Database.dataDir, "db-config.json")).toString("utf-8"); - dbConfig = JSON.parse(dbConfigString); - - if (typeof dbConfig !== "object") { - throw new Error("Invalid db-config.json, it must be an object"); - } - - if (typeof dbConfig.type !== "string") { - throw new Error("Invalid db-config.json, type must be a string"); - } - } catch (_) { + dbConfig = this.readDBConfig(); + } catch (err) { + log.warn("db", err.message); dbConfig = { type: "sqlite", //type: "embedded-mariadb", @@ -151,7 +161,7 @@ class Database { config = { client: Dialect, connection: { - filename: Database.path, + filename: Database.sqlitePath, acquireConnectionTimeout: acquireConnectionTimeout, }, useNullAsDefault: true, @@ -497,15 +507,15 @@ class Database { if (! this.backupPath) { log.info("db", "Backing up the database"); this.backupPath = this.dataDir + "kuma.db.bak" + version; - fs.copyFileSync(Database.path, this.backupPath); + fs.copyFileSync(Database.sqlitePath, this.backupPath); - const shmPath = Database.path + "-shm"; + const shmPath = Database.sqlitePath + "-shm"; if (fs.existsSync(shmPath)) { this.backupShmPath = shmPath + ".bak" + version; fs.copyFileSync(shmPath, this.backupShmPath); } - const walPath = Database.path + "-wal"; + const walPath = Database.sqlitePath + "-wal"; if (fs.existsSync(walPath)) { this.backupWalPath = walPath + ".bak" + version; fs.copyFileSync(walPath, this.backupWalPath); @@ -535,13 +545,13 @@ class Database { if (this.backupPath) { log.error("db", "Patching the database failed!!! Restoring the backup"); - const shmPath = Database.path + "-shm"; - const walPath = Database.path + "-wal"; + const shmPath = Database.sqlitePath + "-shm"; + const walPath = Database.sqlitePath + "-wal"; // Delete patch failed db try { - if (fs.existsSync(Database.path)) { - fs.unlinkSync(Database.path); + if (fs.existsSync(Database.sqlitePath)) { + fs.unlinkSync(Database.sqlitePath); } if (fs.existsSync(shmPath)) { @@ -557,7 +567,7 @@ class Database { } // Restore backup - fs.copyFileSync(this.backupPath, Database.path); + fs.copyFileSync(this.backupPath, Database.sqlitePath); if (this.backupShmPath) { fs.copyFileSync(this.backupShmPath, shmPath); @@ -575,7 +585,7 @@ class Database { /** Get the size of the database */ static getSize() { log.debug("db", "Database.getSize()"); - let stats = fs.statSync(Database.path); + let stats = fs.statSync(Database.sqlitePath); log.debug("db", stats); return stats.size; } diff --git a/server/jobs/util-worker.js b/server/jobs/util-worker.js index 1aeec794d..76131203d 100644 --- a/server/jobs/util-worker.js +++ b/server/jobs/util-worker.js @@ -36,7 +36,7 @@ const connectDb = async function () { process.env.DATA_DIR || workerData["data-dir"] || "./data/" ); - Database.init({ + Database.initDataDir({ "data-dir": dbPath, }); diff --git a/server/server.js b/server/server.js index 3870b09df..627c586bc 100644 --- a/server/server.js +++ b/server/server.js @@ -143,6 +143,7 @@ const { Settings } = require("./settings"); const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { pluginsHandler } = require("./socket-handlers/plugins-handler"); const { EmbeddedMariaDB } = require("./embedded-mariadb"); +const { SetupDatabase } = require("./setup-database"); app.use(express.json()); @@ -168,8 +169,20 @@ let jwtSecret = null; let needSetup = false; (async () => { - Database.init(args); + // Create a data directory + Database.initDataDir(args); + + // Check if is chosen a database type + let setupDatabase = new SetupDatabase(args, server); + if (setupDatabase.isNeedSetup()) { + // Hold here and start a special setup page until user choose a database type + await setupDatabase.start(hostname, port); + } + + // Connect to database await initDatabase(testMode); + + // Database should be ready now await server.initAfterDatabaseReady(); server.loadPlugins(); server.entryPage = await Settings.get("entryPage"); @@ -334,7 +347,7 @@ let needSetup = false; } // Login Rate Limit - if (! await loginRateLimiter.pass(callback)) { + if (!await loginRateLimiter.pass(callback)) { log.info("auth", `Too many failed requests for user ${data.username}. IP=${clientIP}`); return; } @@ -407,7 +420,7 @@ let needSetup = false; socket.on("logout", async (callback) => { // Rate Limit - if (! await loginRateLimiter.pass(callback)) { + if (!await loginRateLimiter.pass(callback)) { return; } @@ -421,7 +434,7 @@ let needSetup = false; socket.on("prepare2FA", async (currentPassword, callback) => { try { - if (! await twoFaRateLimiter.pass(callback)) { + if (!await twoFaRateLimiter.pass(callback)) { return; } @@ -470,7 +483,7 @@ let needSetup = false; const clientIP = await server.getClientIP(socket); try { - if (! await twoFaRateLimiter.pass(callback)) { + if (!await twoFaRateLimiter.pass(callback)) { return; } @@ -502,7 +515,7 @@ let needSetup = false; const clientIP = await server.getClientIP(socket); try { - if (! await twoFaRateLimiter.pass(callback)) { + if (!await twoFaRateLimiter.pass(callback)) { return; } @@ -809,9 +822,10 @@ let needSetup = false; } let list = await R.getAll(` - SELECT * FROM heartbeat - WHERE monitor_id = ? AND - time > DATETIME('now', '-' || ? || ' hours') + SELECT * + FROM heartbeat + WHERE monitor_id = ? + AND time > DATETIME('now', '-' || ? || ' hours') ORDER BY time ASC `, [ monitorID, @@ -1068,7 +1082,7 @@ let needSetup = false; try { checkLogin(socket); - if (! password.newPassword) { + if (!password.newPassword) { throw new Error("Invalid new password"); } @@ -1375,7 +1389,7 @@ let needSetup = false; ]); let tagId; - if (! tag) { + if (!tag) { // -> If it doesn't exist, create new tag from backup file let beanTag = R.dispense("tag"); beanTag.name = oldTag.name; @@ -1644,9 +1658,9 @@ async function afterLogin(socket, user) { * @returns {Promise} */ async function initDatabase(testMode = false) { - if (! fs.existsSync(Database.path)) { + if (! fs.existsSync(Database.sqlitePath)) { log.info("server", "Copying Database"); - fs.copyFileSync(Database.templatePath, Database.path); + fs.copyFileSync(Database.templatePath, Database.sqlitePath); } log.info("server", "Connecting to the Database"); diff --git a/server/setup-database.js b/server/setup-database.js new file mode 100644 index 000000000..53d68fd08 --- /dev/null +++ b/server/setup-database.js @@ -0,0 +1,194 @@ +const express = require("express"); +const { log } = require("../src/util"); +const expressStaticGzip = require("express-static-gzip"); +const fs = require("fs"); +const path = require("path"); +const Database = require("./database"); +const { allowDevAllOrigin } = require("./util-server"); + +/** + * A standalone express app that is used to setup database + * It is used when db-config.json and kuma.db are not found or invalid + * Once it is configured, it will shutdown and start the main server + */ +class SetupDatabase { + + /** + * Show Setup Page + * @type {boolean} + */ + needSetup = true; + + server; + + constructor(args, server) { + this.server = server; + + // Priority: env > db-config.json + // If env is provided, write it to db-config.json + // If db-config.json is found, check if it is valid + // If db-config.json is not found or invalid, check if kuma.db is found + // If kuma.db is not found, show setup page + + let dbConfig; + + try { + dbConfig = Database.readDBConfig(); + } catch (e) { + log.info("setup-database", "db-config.json is not found or invalid: " + e.message); + + // Check if kuma.db is found (1.X.X users) + if (fs.existsSync(path.join(Database.dataDir, "kuma.db"))) { + this.needSetup = false; + } else { + this.needSetup = true; + } + dbConfig = {}; + } + + if (process.env.UPTIME_KUMA_DB_TYPE) { + this.needSetup = false; + log.info("setup-database", "UPTIME_KUMA_DB_TYPE is provided by env, try to override db-config.json"); + dbConfig.type = process.env.UPTIME_KUMA_DB_TYPE; + dbConfig.hostname = process.env.UPTIME_KUMA_DB_HOSTNAME; + dbConfig.port = process.env.UPTIME_KUMA_DB_PORT; + dbConfig.database = process.env.UPTIME_KUMA_DB_NAME; + dbConfig.username = process.env.UPTIME_KUMA_DB_USERNAME; + dbConfig.password = process.env.UPTIME_KUMA_DB_PASSWORD; + Database.writeDBConfig(dbConfig); + } + + } + + /** + * Show Setup Page + */ + isNeedSetup() { + return this.needSetup; + } + + isEnabledEmbeddedMariaDB() { + return process.env.UPTIME_KUMA_ENABLE_EMBEDDED_MARIADB === "1"; + } + + start(hostname, port) { + return new Promise((resolve) => { + const app = express(); + let tempServer; + app.use(express.json()); + + app.get("/", async (request, response) => { + response.redirect("/setup-database"); + }); + + app.get("/api/entry-page", async (request, response) => { + allowDevAllOrigin(response); + response.json({ + type: "setup-database", + }); + }); + + app.get("/info", (request, response) => { + allowDevAllOrigin(response); + response.json({ + isEnabledEmbeddedMariaDB: this.isEnabledEmbeddedMariaDB(), + }); + }); + + app.post("/setup-database", async (request, response) => { + allowDevAllOrigin(response); + + console.log(request); + + let dbConfig = request.body.dbConfig; + + let supportedDBTypes = [ "mariadb", "sqlite" ]; + + if (this.isEnabledEmbeddedMariaDB()) { + supportedDBTypes.push("embedded-mariadb"); + } + + // Validate input + if (typeof dbConfig !== "object") { + response.status(400).json("Invalid dbConfig"); + return; + } + + if (!dbConfig.type) { + response.status(400).json("Database Type is required"); + return; + } + + if (!supportedDBTypes.includes(dbConfig.type)) { + response.status(400).json("Unsupported Database Type"); + return; + } + + if (dbConfig.type === "mariadb") { + if (!dbConfig.hostname) { + response.status(400).json("Hostname is required"); + return; + } + + if (!dbConfig.port) { + response.status(400).json("Port is required"); + return; + } + + if (!dbConfig.dbName) { + response.status(400).json("Database name is required"); + return; + } + + if (!dbConfig.username) { + response.status(400).json("Username is required"); + return; + } + + if (!dbConfig.password) { + response.status(400).json("Password is required"); + return; + } + } + + // Write db-config.json + Database.writeDBConfig(dbConfig); + + response.json({ + ok: true, + }); + + // Shutdown down this express and start the main server + log.info("setup-database", "Database is configured, close setup-database server and start the main server now."); + if (tempServer) { + tempServer.close(); + } + resolve(); + }); + + app.use("/", expressStaticGzip("dist", { + enableBrotli: true, + })); + + app.get("*", async (_request, response) => { + response.send(this.server.indexHTML); + }); + + app.options("*", async (_request, response) => { + allowDevAllOrigin(response); + response.end(); + }); + + tempServer = app.listen(port, hostname, () => { + log.info("setup-database", `Starting Setup Database on ${port}`); + let domain = (hostname) ? hostname : "localhost"; + log.info("setup-database", `Open http://${domain}:${port} in your browser`); + log.info("setup-database", "Waiting for user action..."); + }); + }); + } +} + +module.exports = { + SetupDatabase, +}; diff --git a/server/util-server.js b/server/util-server.js index edce28901..615edcbc9 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -613,6 +613,7 @@ exports.allowDevAllOrigin = (res) => { */ exports.allowAllOrigin = (res) => { res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); }; diff --git a/src/lang/en.json b/src/lang/en.json index d907f4e0c..39628cdab 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -1,5 +1,10 @@ { "languageName": "English", + "setupDatabaseChooseDatabase": "Which database do you want to use?", + "setupDatabaseEmbeddedMariaDB": "You don't need to set anything. This docker image have embedded and configured a MariaDB for you automatically. Uptime Kuma will connect to this database via unix socket.", + "setupDatabaseMariaDB": "Connect to an external MariaDB database. You need to set the database connection information.", + "setupDatabaseSQLite": "A simple database file. It is recommended for small scale deployment. Before 2.0.0, Uptime Kuma used SQLite by default.", + "dbName": "Database Name", "Settings": "Settings", "Dashboard": "Dashboard", "Help": "Help", diff --git a/src/mixins/socket.js b/src/mixins/socket.js index 6bd0aafc5..120b11629 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -86,6 +86,11 @@ export default { } } + // Also don't need to connect to the socket.io for setup database page + if (location.pathname === "/setup-database") { + return; + } + this.socket.initedSocketIO = true; let protocol = (location.protocol === "https:") ? "wss://" : "ws://"; diff --git a/src/pages/Entry.vue b/src/pages/Entry.vue index 30e314b2f..b87c4d998 100644 --- a/src/pages/Entry.vue +++ b/src/pages/Entry.vue @@ -19,25 +19,33 @@ export default { }, async mounted() { - // There are only 2 cases that could come in here. + // There are only 3 cases that could come in here. // 1. Matched status Page domain name // 2. Vue Frontend Dev - let res = (await axios.get("/api/entry-page")).data; + // 3. Vue Frontend Dev (not setup database yet) + let res; + try { + res = (await axios.get("/api/entry-page")).data; - if (res.type === "statusPageMatchedDomain") { - this.statusPageSlug = res.statusPageSlug; - this.$root.forceStatusPageTheme = true; + if (res.type === "statusPageMatchedDomain") { + this.statusPageSlug = res.statusPageSlug; + this.$root.forceStatusPageTheme = true; - } else if (res.type === "entryPage") { // Dev only. For production, the logic is in the server side - const entryPage = res.entryPage; + } else if (res.type === "entryPage") { // Dev only. For production, the logic is in the server side + const entryPage = res.entryPage; - if (entryPage === "statusPage") { - this.$router.push("/status"); + if (entryPage === "statusPage") { + this.$router.push("/status"); + } else { + this.$router.push("/dashboard"); + } + } else if (res.type === "setup-database") { + this.$router.push("/setup-database"); } else { this.$router.push("/dashboard"); } - } else { - this.$router.push("/dashboard"); + } catch (e) { + alert("Cannot connect to the backend server. Did you start the backend server? (npm run start-server-dev)"); } }, diff --git a/src/pages/SetupDatabase.vue b/src/pages/SetupDatabase.vue new file mode 100644 index 000000000..122b548d1 --- /dev/null +++ b/src/pages/SetupDatabase.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/src/router.js b/src/router.js index 35647511f..41814bc8e 100644 --- a/src/router.js +++ b/src/router.js @@ -19,6 +19,7 @@ import DockerHosts from "./components/settings/Docker.vue"; import MaintenanceDetails from "./pages/MaintenanceDetails.vue"; import ManageMaintenance from "./pages/ManageMaintenance.vue"; import Plugins from "./components/settings/Plugins.vue"; +import SetupDatabase from "./pages/SetupDatabase.vue"; // Settings - Sub Pages import Appearance from "./components/settings/Appearance.vue"; @@ -163,6 +164,10 @@ const routes = [ path: "/setup", component: Setup, }, + { + path: "/setup-database", + component: SetupDatabase, + }, { path: "/status-page", component: StatusPage, diff --git a/test/backend.spec.js b/test/backend.spec.js index 644a0fd08..66a8aac32 100644 --- a/test/backend.spec.js +++ b/test/backend.spec.js @@ -235,13 +235,13 @@ describe("The function filterAndJoin", () => { describe("Test uptimeKumaServer.getClientIP()", () => { it("should able to get a correct client IP", async () => { - Database.init({ + Database.initDataDir({ "data-dir": "./data/test" }); - if (! fs.existsSync(Database.path)) { + if (! fs.existsSync(Database.sqlitePath)) { log.info("server", "Copying Database"); - fs.copyFileSync(Database.templatePath, Database.path); + fs.copyFileSync(Database.templatePath, Database.sqlitePath); } await Database.connect(true);