summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesper Jensen <jesper@jnsn.dev>2026-02-21 11:14:42 +0100
committerJesper Jensen <jesper@jnsn.dev>2026-02-21 11:14:42 +0100
commit1b19afaa339f2f68520d61b634600ddeb04effb7 (patch)
tree232ec9fdc03a8955a8c847b88a688306689d2633
parent0b6e8026a6b530c7d88607bfdf79b9692987abaa (diff)
Split up the rendering from the logic
-rw-r--r--src/app.c639
-rw-r--r--src/app.h70
-rw-r--r--src/html.c599
-rw-r--r--src/html.h17
-rw-r--r--src/main.c3
-rw-r--r--src/web.c1312
-rw-r--r--src/web.h65
-rw-r--r--test/http.c10
-rw-r--r--test/list_clients.c14
9 files changed, 1390 insertions, 1339 deletions
diff --git a/src/app.c b/src/app.c
new file mode 100644
index 0000000..9d1ee7a
--- /dev/null
+++ b/src/app.c
@@ -0,0 +1,639 @@
+#include "app.h"
+#include "mytime.h"
+
+#include <assert.h>
+#include <errno.h>
+#include <linux/limits.h>
+#include <libgen.h>
+#include <sys/stat.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sqlite3.h>
+#include <fcntl.h>
+
+static void sqliteError(void *pArg, int iErrCode, const char *zMsg) {
+ // @CLEANUP: We should stuff these somewhere, but for now just print it
+ fprintf(stderr, "(%d) %s\n", iErrCode, zMsg);
+}
+
+static int generate_secret(char *out) {
+ unsigned char bytes[16];
+ int fd = open("/dev/urandom", O_RDONLY);
+ if (fd < 0) return -1;
+ ssize_t n = read(fd, bytes, 16);
+ close(fd);
+ if (n != 16) return -1;
+ static const char hex[] = "0123456789abcdef";
+ for (int i = 0; i < 16; i++) {
+ out[i*2] = hex[(bytes[i] >> 4) & 0x0f];
+ out[i*2+1] = hex[bytes[i] & 0x0f];
+ }
+ out[32] = '\0';
+ return 0;
+}
+
+// https://stackoverflow.com/questions/2336242/recursive-mkdir-system-call-on-unix
+static void _mkdir(const char *dir) {
+ char tmp[PATH_MAX];
+ char *p = NULL;
+ size_t len;
+ int rc;
+
+ snprintf(tmp, sizeof(tmp),"%s",dir);
+ len = strlen(tmp);
+ if (tmp[len - 1] == '/')
+ tmp[len - 1] = 0;
+ for (p = tmp + 1; *p; p++)
+ if (*p == '/') {
+ *p = 0;
+ rc = mkdir(tmp, S_IRWXU);
+ if(rc != 0 && errno != EEXIST) {
+ abort();
+ }
+ *p = '/';
+ }
+ rc = mkdir(tmp, S_IRWXU);
+ if(rc != 0 && errno != EEXIST) {
+ abort();
+ }
+
+}
+
+void app_startup(struct App *app) {
+ // @CLEANUP: Move this to main? doesn't belong here at least
+ sqlite3_config(SQLITE_CONFIG_LOG, sqliteError, NULL);
+ sqlite3_config(SQLITE_CONFIG_URI, 1);
+
+ int err;
+
+ printf("Running migrations against %s\n", app->dbname);
+
+ if(memcmp("file::memory", app->dbname, 12) != 0) {
+ char *full = strdup(app->dbname);
+ char *dbdir = dirname(full);
+ _mkdir(dbdir);
+ free(full);
+ }
+
+ sqlite3 *conn;
+ if((err = sqlite3_open(app->dbname, &conn)) != SQLITE_OK) {
+ fprintf(stderr, "(%d) %s\n", err, sqlite3_errmsg(conn));
+ abort();
+ }
+ sqlite3_db_config(conn, SQLITE_DBCONFIG_ENABLE_FKEY, 1, NULL);
+
+ if(sqlite3_exec(conn, "CREATE TABLE IF NOT EXISTS migration (id INTEGER PRIMARY KEY NOT NULL)", NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "SELECT MAX(id) FROM migration", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ uint64_t maxVersion = 0;
+ while((err = sqlite3_step(stmt)) == SQLITE_ROW) {
+ maxVersion = sqlite3_column_int64(stmt, 0);
+ }
+ if(err != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+
+ if(maxVersion < 1) {
+ char *prog =
+ "BEGIN TRANSACTION;"
+ "CREATE TABLE backups ("
+ "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
+ "client TEXT NOT NULL, "
+ "status TEXT NOT NULL, "
+ "started TEXT NOT NULL, "
+ "completed TEXT "
+ ") STRICT";
+ if(sqlite3_exec(conn, prog, NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "INSERT INTO migration (id) VALUES (?1)", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_bind_int64(stmt, 1, 1) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_exec(conn, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(maxVersion < 2) {
+ char *prog =
+ "BEGIN TRANSACTION;"
+ "CREATE TABLE clients ("
+ "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
+ "name TEXT NOT NULL "
+ ") STRICT;"
+ "INSERT INTO clients(name) "
+ "SELECT b.client "
+ "FROM backups b "
+ "GROUP BY b.client;"
+ "CREATE TABLE backups_next ("
+ "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
+ "client INTEGER NOT NULL, "
+ "status TEXT NOT NULL, "
+ "started TEXT NOT NULL, "
+ "completed TEXT ,"
+ "FOREIGN KEY(client) REFERENCES clients(id)"
+ ") STRICT;"
+ "INSERT INTO backups_next(id, client, status, started, completed) "
+ "SELECT b.id, c.id, b.status, b.started, b.completed "
+ "FROM backups b "
+ "JOIN clients c ON b.client = c.name;"
+ "DROP TABLE backups;"
+ "ALTER TABLE backups_next RENAME TO backups;";
+ if(sqlite3_exec(conn, prog, NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "INSERT INTO migration (id) VALUES (?1)", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_bind_int64(stmt, 1, 2) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_exec(conn, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(maxVersion < 3) {
+ char *prog =
+ "BEGIN TRANSACTION;"
+ "CREATE UNIQUE INDEX clients_name ON clients(name)";
+ if(sqlite3_exec(conn, prog, NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "INSERT INTO migration (id) VALUES (?1)", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_bind_int64(stmt, 1, 3) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_exec(conn, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(maxVersion < 4) {
+ char *prog =
+ "BEGIN TRANSACTION;"
+ "ALTER TABLE clients ADD COLUMN secret TEXT NOT NULL DEFAULT ''";
+ if(sqlite3_exec(conn, prog, NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "INSERT INTO migration (id) VALUES (?1)", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_bind_int64(stmt, 1, 4) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_exec(conn, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) {
+ abort();
+ }
+ }
+}
+
+int submit_report(struct App *app, char *client, char *secret, int is_finish) {
+ sqlite3 *conn;
+ int err;
+ if((err = sqlite3_open(app->dbname, &conn)) != SQLITE_OK) {
+ abort();
+ }
+
+ struct timespec now = get_current_time();
+
+ int64_t clientId;
+ {
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "SELECT id, secret FROM clients WHERE name = ?1", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_text(stmt, 1, client, -1, SQLITE_STATIC) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_ROW) {
+ abort();
+ }
+
+ if(sqlite3_column_type(stmt, 0) == SQLITE_NULL) {
+ abort();
+ }
+ clientId = sqlite3_column_int64(stmt, 0);
+
+ const char *stored_secret = (const char *)sqlite3_column_text(stmt, 1);
+ if(stored_secret == NULL || strcmp(stored_secret, secret) != 0) {
+ sqlite3_finalize(stmt);
+ sqlite3_close(conn);
+ return -2;
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(!is_finish) {
+ // REV_BEGIN
+ sqlite3_stmt *stmt;
+
+ // Mark any existing RUNNING backup as INTERRUPTED
+ if(sqlite3_prepare_v2(conn, "UPDATE backups SET status = 'INTERRUPTED', completed = datetime(?1, 'unixepoch', 'subsec') WHERE client = ?2 AND status = 'RUNNING'", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_double(stmt, 1, TIME_AS_FLOAT(now)) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 2, clientId) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ stmt = NULL;
+
+ // Insert the new backup
+ if(sqlite3_prepare_v2(conn, "INSERT INTO backups (client, status, started) VALUES (?1, ?2, datetime(?3, 'unixepoch', 'subsec'))", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 1, clientId) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_text(stmt, 2, "RUNNING", -1, SQLITE_STATIC) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_double(stmt, 3, TIME_AS_FLOAT(now)) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ } else {
+ // REV_FINISH
+ uint64_t backupId;
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "SELECT id FROM backups WHERE client = ?1 AND status = 'RUNNING'", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 1, clientId) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_ROW) {
+ abort();
+ }
+
+ if(sqlite3_column_type(stmt, 0) == SQLITE_NULL) {
+ abort();
+ }
+ backupId = sqlite3_column_int64(stmt, 0);
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ stmt = NULL;
+
+ if(sqlite3_prepare_v2(conn, "UPDATE backups SET status = ?1, completed = datetime(?2, 'unixepoch', 'subsec') WHERE id = ?3", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_text(stmt, 1, "FINISHED", -1, SQLITE_STATIC) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_bind_double(stmt, 2, TIME_AS_FLOAT(now)) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 3, backupId) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(sqlite3_close(conn) != SQLITE_OK) {
+ abort();
+ }
+ return 0;
+}
+
+int create_client(struct App *app, char *name, struct CreateClientResult *result) {
+ sqlite3 *conn;
+ int err;
+ if((err = sqlite3_open(app->dbname, &conn)) != SQLITE_OK) {
+ abort();
+ }
+
+ if(generate_secret(result->secret) != 0) {
+ abort();
+ }
+
+ {
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "INSERT INTO clients (name, secret) VALUES (?1, ?2)", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_text(stmt, 2, result->secret, -1, SQLITE_STATIC) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ result->id = sqlite3_last_insert_rowid(conn);
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(sqlite3_close(conn) != SQLITE_OK) {
+ abort();
+ }
+ return 0;
+}
+
+int delete_client(struct App *app, int64_t client_id) {
+ sqlite3 *conn;
+ if(sqlite3_open(app->dbname, &conn) != SQLITE_OK) {
+ abort();
+ }
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "DELETE FROM clients WHERE id = ?1", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_close(conn) != SQLITE_OK) {
+ abort();
+ }
+ return 0;
+}
+
+int list_clients(struct App *app, struct ListClientResult *result) {
+ sqlite3 *conn;
+ int err;
+ if((err = sqlite3_open(app->dbname, &conn)) != SQLITE_OK) {
+ abort();
+ }
+
+ {
+ sqlite3_stmt *stmt;
+ const char *sql =
+ "SELECT c.id, c.name, c.secret, unixepoch(b_success.completed,'subsec'), b_last.status "
+ "FROM clients c "
+ "LEFT JOIN backups b_success ON b_success.client = c.id "
+ " AND b_success.id = (SELECT id FROM backups WHERE client = c.id AND status = 'FINISHED' ORDER BY id DESC LIMIT 1) "
+ "LEFT JOIN backups b_last ON b_last.client = c.id "
+ " AND b_last.id = (SELECT id FROM backups WHERE client = c.id ORDER BY id DESC LIMIT 1) "
+ "WHERE c.id > ?1 ORDER BY c.id ASC LIMIT ?2";
+ if(sqlite3_prepare_v2(conn, sql, -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 1, -1) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 2, 16) != SQLITE_OK) {
+ abort();
+ }
+
+ int ret;
+ int index = 0;
+ while((ret = sqlite3_step(stmt)) == SQLITE_ROW) {
+ assert(index < 16);
+
+ if(sqlite3_column_type(stmt, 0) == SQLITE_NULL) {
+ abort();
+ }
+
+ int64_t clientId = sqlite3_column_int64(stmt, 0);
+ const uint8_t *name = sqlite3_column_text(stmt, 1);
+ const uint8_t *secret = sqlite3_column_text(stmt, 2);
+ const double last_backup = sqlite3_column_double(stmt, 3);
+ const uint8_t *backup_status = sqlite3_column_text(stmt, 4);
+
+ result->clients[index].id = clientId;
+ strncpy(result->clients[index].name, (char*)name, CLIENT_NAME_MAX);
+ strncpy(result->clients[index].secret, secret ? (char*)secret : "", CLIENT_SECRET_LEN);
+ result->clients[index].last_backup = time_from_double(last_backup);
+
+ if (backup_status == NULL) {
+ strncpy(result->clients[index].status, "MISSING", BACKUP_STATUS_MAX);
+ result->clients[index].is_stale = 0;
+ } else if (strcmp((char*)backup_status, "RUNNING") == 0) {
+ strncpy(result->clients[index].status, "RUNNING", BACKUP_STATUS_MAX);
+ result->clients[index].is_stale = 0;
+ } else if (strcmp((char*)backup_status, "INTERRUPTED") == 0) {
+ strncpy(result->clients[index].status, "INTERRUPTED", BACKUP_STATUS_MAX);
+ result->clients[index].is_stale = 0;
+ } else {
+ strncpy(result->clients[index].status, "OK", BACKUP_STATUS_MAX);
+ result->clients[index].is_stale = 0;
+ }
+
+ index++;
+ }
+ if(ret != SQLITE_DONE) {
+ abort();
+ }
+
+ result->clients_len = index;
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(sqlite3_close(conn) != SQLITE_OK) {
+ abort();
+ }
+ return 0;
+}
+
+int get_client(struct App *app, int64_t client_id, struct GetClientOnlyResult *result) {
+ sqlite3 *conn;
+ int err;
+ if((err = sqlite3_open(app->dbname, &conn)) != SQLITE_OK) {
+ abort();
+ }
+
+ result->found = 0;
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "SELECT id, name, secret FROM clients WHERE id = ?1", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) {
+ abort();
+ }
+
+ int ret = sqlite3_step(stmt);
+ if(ret == SQLITE_ROW) {
+ result->found = 1;
+ result->id = sqlite3_column_int64(stmt, 0);
+ const uint8_t *name = sqlite3_column_text(stmt, 1);
+ const uint8_t *secret = sqlite3_column_text(stmt, 2);
+ strncpy(result->name, (char*)name, CLIENT_NAME_MAX);
+ strncpy(result->secret, (char*)secret, CLIENT_SECRET_LEN);
+ } else if(ret != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_close(conn) != SQLITE_OK) {
+ abort();
+ }
+ return 0;
+}
+
+int get_client_backups(struct App *app, int64_t client_id, struct GetBackupsResult *result) {
+ sqlite3 *conn;
+ int err;
+ if((err = sqlite3_open(app->dbname, &conn)) != SQLITE_OK) {
+ abort();
+ }
+
+ uint16_t limit = result->backups_len;
+
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "SELECT id, status, unixepoch(started, 'subsec'), unixepoch(completed, 'subsec') FROM backups WHERE client = ?1 ORDER BY started DESC LIMIT ?2", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int(stmt, 2, limit) != SQLITE_OK) {
+ abort();
+ }
+
+ int ret;
+ int index = 0;
+ while((ret = sqlite3_step(stmt)) == SQLITE_ROW) {
+ if(index >= limit) break;
+
+ result->backups[index].id = sqlite3_column_int64(stmt, 0);
+
+ const uint8_t *status = sqlite3_column_text(stmt, 1);
+ const double started = sqlite3_column_double(stmt, 2);
+ const double completed = sqlite3_column_double(stmt, 3);
+
+ strncpy(result->backups[index].status, status ? (char*)status : "", BACKUP_STATUS_MAX);
+ result->backups[index].started = time_from_double(started);
+ result->backups[index].completed = time_from_double(completed);
+
+ index++;
+ }
+ if(ret != SQLITE_DONE && ret != SQLITE_ROW) {
+ abort();
+ }
+
+ result->backups_len = index;
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_close(conn) != SQLITE_OK) {
+ abort();
+ }
+ return 0;
+}
diff --git a/src/app.h b/src/app.h
new file mode 100644
index 0000000..46d2c18
--- /dev/null
+++ b/src/app.h
@@ -0,0 +1,70 @@
+#pragma once
+
+#include <stddef.h>
+#include <stdint.h>
+#include <time.h>
+
+#define CLIENT_NAME_MAX 32
+#define CLIENT_SECRET_LEN 33
+#define BACKUP_STATUS_MAX 16
+#define MAX_BACKUPS_DISPLAY 50
+
+struct App {
+ const char* dbname;
+ char session_secret[CLIENT_SECRET_LEN];
+ const char *admin_user;
+ const char *admin_pass;
+};
+
+void app_startup(struct App *app);
+
+struct CreateClientResult {
+ int64_t id;
+ char* name;
+ char secret[CLIENT_SECRET_LEN];
+};
+
+int create_client(struct App *app, char *name, struct CreateClientResult *result);
+int delete_client(struct App *app, int64_t client_id);
+
+struct ListClientResultClient {
+ int64_t id;
+ char name[CLIENT_NAME_MAX];
+ char secret[CLIENT_SECRET_LEN];
+ struct timespec last_backup;
+ char status[BACKUP_STATUS_MAX]; // "OK", "RUNNING", "STALE", "FAILED", "INTERRUPTED"
+ int is_stale;
+};
+
+struct ListClientResult {
+ uint16_t clients_len;
+ // Right now this has to be exactly 16 elements long, but it could be longer in the future
+ struct ListClientResultClient clients[];
+};
+
+int list_clients(struct App *app, struct ListClientResult *result);
+
+struct BackupRecord {
+ int64_t id;
+ char status[BACKUP_STATUS_MAX];
+ struct timespec started;
+ struct timespec completed;
+};
+
+struct GetClientOnlyResult {
+ int found;
+ int64_t id;
+ char name[CLIENT_NAME_MAX];
+ char secret[CLIENT_SECRET_LEN];
+};
+
+struct GetBackupsResult {
+ uint16_t backups_len; // On input: max backups to fetch; On output: actual count
+ struct BackupRecord backups[];
+};
+
+int get_client(struct App *app, int64_t client_id, struct GetClientOnlyResult *result);
+int get_client_backups(struct App *app, int64_t client_id, struct GetBackupsResult *result);
+
+// Returns 0 on success, -1 on error, -2 on secret mismatch
+int submit_report(struct App *app, char *client, char *secret, int is_finish);
diff --git a/src/html.c b/src/html.c
new file mode 100644
index 0000000..10a6848
--- /dev/null
+++ b/src/html.c
@@ -0,0 +1,599 @@
+#include "html.h"
+#include "mytime.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static void emit_stylesheet(FILE *f) {
+ fprintf(f,
+ "<style>\n"
+ "*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n"
+ ":root {\n"
+ " --bg: #0e0f11;\n"
+ " --sur: #16181c;\n"
+ " --bor: #2a2d35;\n"
+ " --dim: #4a4f5e;\n"
+ " --txt: #c8cdd8;\n"
+ " --hi: #eef0f5;\n"
+ " --green: #4af0a0;\n"
+ " --yellow: #f0a04a;\n"
+ " --red: #f06060;\n"
+ " --purple: #c084fc;\n"
+ " --mono: 'Courier New', 'Lucida Console', monospace;\n"
+ "}\n"
+ "body {\n"
+ " background: var(--bg);\n"
+ " color: var(--txt);\n"
+ " font-family: var(--mono);\n"
+ " font-size: 14px;\n"
+ " line-height: 1.5;\n"
+ " min-height: 100vh;\n"
+ "}\n"
+ "/* TOP BAR */\n"
+ "header {\n"
+ " background: var(--sur);\n"
+ " border-bottom: 1px solid var(--bor);\n"
+ " padding: 0 20px;\n"
+ " height: 44px;\n"
+ " display: flex;\n"
+ " align-items: center;\n"
+ " justify-content: space-between;\n"
+ " position: sticky;\n"
+ " top: 0;\n"
+ " z-index: 1;\n"
+ "}\n"
+ "header b { color: var(--green); letter-spacing: 2px; }\n"
+ "header small { color: var(--dim); font-size: 12px; font-family: var(--mono); }\n"
+ "header a { color: var(--dim); font-size: 12px; }\n"
+ "header a:hover { color: var(--green); }\n"
+ "/* MAIN CONTENT */\n"
+ "main { max-width: 860px; margin: 0 auto; padding: 24px 16px; }\n"
+ "/* LOGIN */\n"
+ ".login-page {\n"
+ " min-height: 100vh;\n"
+ " display: flex;\n"
+ " align-items: center;\n"
+ " justify-content: center;\n"
+ " padding: 24px;\n"
+ "}\n"
+ ".login-page form {\n"
+ " background: var(--sur);\n"
+ " border: 1px solid var(--bor);\n"
+ " padding: 36px 28px;\n"
+ " width: 100%%;\n"
+ " max-width: 320px;\n"
+ " display: flex;\n"
+ " flex-direction: column;\n"
+ " gap: 14px;\n"
+ "}\n"
+ ".login-page h1 { color: var(--green); letter-spacing: 3px; text-align: center; font-size: 20px; }\n"
+ ".login-page label { display: block; color: var(--dim); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 5px; }\n"
+ ".login-page input {\n"
+ " width: 100%%;\n"
+ " background: var(--bg);\n"
+ " border: 1px solid var(--bor);\n"
+ " color: var(--hi);\n"
+ " font-family: var(--mono);\n"
+ " font-size: 16px;\n"
+ " padding: 9px 10px;\n"
+ " outline: none;\n"
+ "}\n"
+ ".login-page input:focus { border-color: var(--green); }\n"
+ ".login-page .error {\n"
+ " color: var(--red);\n"
+ " background: #2e0a0a;\n"
+ " border: 1px solid #5c1a1a;\n"
+ " padding: 7px 10px;\n"
+ " font-size: 12px;\n"
+ "}\n"
+ "/* SHARED BUTTON */\n"
+ "button {\n"
+ " font-family: var(--mono);\n"
+ " font-size: 11px;\n"
+ " letter-spacing: 1px;\n"
+ " text-transform: uppercase;\n"
+ " padding: 6px 14px;\n"
+ " border: 1px solid var(--green);\n"
+ " background: transparent;\n"
+ " color: var(--green);\n"
+ " cursor: pointer;\n"
+ " touch-action: manipulation;\n"
+ "}\n"
+ "button:hover, button:active { background: var(--green); color: var(--bg); }\n"
+ ".login-page button { padding: 9px; width: 100%%; }\n"
+ "/* PAGE HEADING */\n"
+ "main > h2 {\n"
+ " color: var(--hi);\n"
+ " font-size: 15px;\n"
+ " letter-spacing: 2px;\n"
+ " text-transform: uppercase;\n"
+ " border-bottom: 1px solid var(--bor);\n"
+ " padding-bottom: 12px;\n"
+ " margin-bottom: 20px;\n"
+ " display: flex;\n"
+ " justify-content: space-between;\n"
+ " align-items: baseline;\n"
+ " flex-wrap: wrap;\n"
+ " gap: 8px;\n"
+ "}\n"
+ "main > h2 small { color: var(--dim); font-size: 12px; font-weight: normal; letter-spacing: 0; }\n"
+ ".clients {\n"
+ " display: grid;\n"
+ " grid-template-columns: 1fr auto auto auto auto;\n"
+ " gap: 0 10px;\n"
+ "}\n"
+ ".clients .row {\n"
+ " display: grid;\n"
+ " grid-column: 1 / -1;\n"
+ " grid-template-columns: subgrid;\n"
+ " border-bottom: 1px solid var(--bor);\n"
+ " padding: 11px 12px;\n"
+ " align-items: center;\n"
+ " color: var(--txt);\n"
+ "}\n"
+ ".clients > .header {\n"
+ " font-size: 11px;\n"
+ " letter-spacing: 1px;\n"
+ " text-transform: uppercase;\n"
+ " color: var(--dim);\n"
+ " padding: 0 12px 10px 12px;\n"
+ " border-bottom: 1px solid var(--bor);\n"
+ "}\n"
+ ".clients > span:last-child { text-align: right; }\n"
+ ".clients > a {\n"
+ " text-decoration: none;\n"
+ "}\n"
+ ".clients > a:hover { background: var(--sur); }\n"
+ ".clients > a > :first-child { color: var(--hi); }\n"
+ ".clients > a > :last-child { text-align: right; }\n"
+ ".clients > a.stale > :first-child { border-left: 2px solid var(--purple); padding-left: 8px; }\n"
+ ".clients > a.stale > :nth-child(3) { color: var(--purple); }\n"
+ ".clients > a > code { font-size: 12px; color: var(--dim); letter-spacing: 1px; }\n"
+ "@media (max-width: 580px) {\n"
+ " .clients > .header { display: none; }\n"
+ " .clients > .row {\n"
+ " grid-template-columns: 1fr auto;\n"
+ " grid-template-rows: auto auto;\n"
+ " padding: 13px 14px;\n"
+ " }\n"
+ " .clients > a > :nth-child(1) { grid-column: 1; grid-row: 1; }\n"
+ " .clients > a > :nth-child(2) { display: none; }\n"
+ " .clients > a > :nth-child(3) { grid-column: 1; grid-row: 2; font-size: 11px; color: var(--dim); }\n"
+ " .clients > a > :nth-child(4) { grid-column: 2; grid-row: 1 / 3; align-self: center; }\n"
+ " .clients > a > :nth-child(5) { display: none; }\n"
+ " .clients > a.stale { border-left: 3px solid var(--purple); padding-left: 11px; }\n"
+ " .clients > a.stale > :first-child { border-left: none; padding-left: 0; }\n"
+ "}\n"
+ "/* BADGES */\n"
+ ".badge { border: 1px solid; padding: 2px 7px; font-size: 10px; letter-spacing: 1px; justify-self: center; }"
+ ".badge.ok { color: var(--green); background: #0a2e1a; border-color: #1a5c34; }\n"
+ ".badge.run { color: var(--yellow); background: #2e200a; border-color: #5c400e; }\n"
+ ".badge.fail { color: var(--red); background: #2e0a0a; border-color: #5c1a1a; }\n"
+ ".badge.stale { color: var(--purple); background: #1e0a2e; border-color: #5c1a8c; }\n"
+ "/* ADD FORM */\n"
+ "#add-form {\n"
+ " background: var(--sur);\n"
+ " border: 1px solid var(--bor);\n"
+ " padding: 16px 20px;\n"
+ " margin-bottom: 20px;\n"
+ " display: none;\n"
+ " gap: 12px;\n"
+ " flex-direction: column;\n"
+ "}\n"
+ "#add-form.open { display: flex; }\n"
+ "#add-form label { color: var(--dim); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 5px; }\n"
+ "#add-form input {\n"
+ " background: var(--bg);\n"
+ " border: 1px solid var(--bor);\n"
+ " color: var(--hi);\n"
+ " font-family: var(--mono);\n"
+ " font-size: 16px;\n"
+ " padding: 8px 10px;\n"
+ " outline: none;\n"
+ " width: 100%%;\n"
+ " max-width: 280px;\n"
+ "}\n"
+ "#add-form input:focus { border-color: var(--green); }\n"
+ "/* DETAIL HEADER */\n"
+ "article {\n"
+ " background: var(--sur);\n"
+ " border: 1px solid var(--bor);\n"
+ " padding: 16px 20px;\n"
+ " margin-bottom: 24px;\n"
+ "}\n"
+ "article.stale { border-left: 3px solid var(--purple); }\n"
+ "article h3 { color: var(--green); font-size: 17px; letter-spacing: 1px; margin-bottom: 4px; }\n"
+ "article p { color: var(--dim); font-size: 12px; }\n"
+ "article p.warning {\n"
+ " color: var(--purple);\n"
+ " background: #1e0a2e;\n"
+ " border: 1px solid #5c1a8c;\n"
+ " padding: 7px 10px;\n"
+ " margin-top: 10px;\n"
+ " font-size: 12px;\n"
+ "}\n"
+ "article code {\n"
+ " display: block;\n"
+ " margin-top: 10px;\n"
+ " padding-top: 10px;\n"
+ " border-top: 1px solid var(--bor);\n"
+ " font-size: 12px;\n"
+ " color: var(--txt);\n"
+ " word-break: break-all;\n"
+ "}\n"
+ "/* BACK LINK */\n"
+ "main > a { display: inline-block; color: var(--dim); font-size: 12px; margin-bottom: 20px; text-decoration: none; }\n"
+ "main > a:hover { color: var(--green); }\n"
+ "/* SNIPPETS */\n"
+ "details {\n"
+ " margin-bottom: 28px;\n"
+ " border-bottom: 1px solid var(--bor);\n"
+ "}\n"
+ "details summary {\n"
+ " font-size: 11px;\n"
+ " letter-spacing: 2px;\n"
+ " text-transform: uppercase;\n"
+ " color: var(--dim);\n"
+ " padding-bottom: 10px;\n"
+ " cursor: pointer;\n"
+ " list-style: none;\n"
+ " display: flex;\n"
+ " justify-content: space-between;\n"
+ "}\n"
+ "details summary:hover { color: var(--txt); }\n"
+ "details summary::after { content: '>'; }\n"
+ "details[open] summary::after { transform: rotate(90deg); }\n"
+ "details summary::-webkit-details-marker { display: none; }\n"
+ "details > div { padding-top: 14px; display: flex; flex-direction: column; gap: 10px; }\n"
+ "details h4 { font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--dim); margin-bottom: 6px; font-weight: normal; }\n"
+ "details pre {\n"
+ " background: var(--bg);\n"
+ " border: 1px solid var(--bor);\n"
+ " padding: 12px 60px 12px 14px;\n"
+ " font-family: var(--mono);\n"
+ " font-size: 12px;\n"
+ " overflow-x: auto;\n"
+ " line-height: 1.6;\n"
+ " position: relative;\n"
+ " white-space: pre;\n"
+ "}\n"
+ "details pre .cmd { color: var(--yellow); }\n"
+ "details pre .flag { color: var(--dim); }\n"
+ "details pre .url { color: var(--green); }\n"
+ "details pre .key { color: #7dd3fc; }\n"
+ "details pre .val { color: #f9a8d4; }\n"
+ "details pre .secret { color: var(--purple); }\n"
+ "details pre button {\n"
+ " position: absolute;\n"
+ " top: 8px; right: 8px;\n"
+ " background: var(--sur);\n"
+ " border-color: var(--bor);\n"
+ " color: var(--dim);\n"
+ " font-size: 10px;\n"
+ "}\n"
+ "details pre button:hover { border-color: var(--green); color: var(--green); background: transparent; }\n"
+ "/* BACKUP LIST */\n"
+ "main > h4 { font-size: 11px; letter-spacing: 2px; text-transform: uppercase; color: var(--dim); margin-bottom: 12px; font-weight: normal; }\n"
+ ".backups { display: flex; flex-direction: column; gap: 8px; }\n"
+ ".backups > div {\n"
+ " background: var(--sur);\n"
+ " border: 1px solid var(--bor);\n"
+ " padding: 10px 16px;\n"
+ " display: flex;\n"
+ " align-items: center;\n"
+ " gap: 12px;\n"
+ "}\n"
+ ".backups > div > time { color: var(--hi); font-size: 13px; flex: 1; }\n"
+ ".backups > div > span { color: var(--dim); font-size: 12px; min-width: 70px; text-align: right; }\n"
+ "</style>\n"
+ );
+}
+
+static void mask_secret(const char *secret, char *out, size_t out_len) {
+ size_t len = strlen(secret);
+ if (len >= 8 && out_len >= 13) {
+ snprintf(out, out_len, "%.4s....%.4s", secret, secret + len - 4);
+ } else if (len > 0) {
+ snprintf(out, out_len, "****");
+ } else {
+ out[0] = '\0';
+ }
+}
+
+static const char *status_to_badge_class(const char *status) {
+ if (strcmp(status, "OK") == 0) return "ok";
+ if (strcmp(status, "RUNNING") == 0) return "run";
+ if (strcmp(status, "STALE") == 0) return "stale";
+ if (strcmp(status, "INTERRUPTED") == 0) return "fail";
+ if (strcmp(status, "FINISHED") == 0) return "ok";
+ return "ok";
+}
+
+static const char *status_to_display(const char *status) {
+ if (strcmp(status, "OK") == 0) return "OK";
+ if (strcmp(status, "RUNNING") == 0) return "Running";
+ if (strcmp(status, "STALE") == 0) return "Stale";
+ if (strcmp(status, "INTERRUPTED") == 0) return "Failed";
+ if (strcmp(status, "FINISHED") == 0) return "Done";
+ return status;
+}
+
+char *html_render_clients_page(const char *host, const struct ListClientResult *result, size_t *out_len) {
+ // Count stale clients
+ int stale_count = 0;
+ for (int i = 0; i < result->clients_len; i++) {
+ if (result->clients[i].is_stale) stale_count++;
+ }
+
+ char *buf = NULL;
+ size_t buf_len = 0;
+ FILE *f = open_memstream(&buf, &buf_len);
+ if (!f) {
+ return NULL;
+ }
+
+ fprintf(f,
+ "<!DOCTYPE html>\n"
+ "<html lang=\"en\">\n"
+ "<head>\n"
+ " <meta charset=\"UTF-8\">\n"
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
+ " <title>BorgFlag - Clients</title>\n");
+ emit_stylesheet(f);
+ fprintf(f,
+ "</head>\n"
+ "<body>\n"
+ " <header>\n"
+ " <b>BorgFlag</b>\n"
+ " <small><form method=\"POST\" action=\"/logout\" style=\"display:inline\"><button type=\"submit\" style=\"border:none;background:none;color:var(--dim);font-size:12px;padding:0;cursor:pointer;text-transform:none;letter-spacing:0\">Sign out</button></form></small>\n"
+ " </header>\n"
+ " <main>\n"
+ " <h2>\n"
+ " Clients\n"
+ " <small>%d registered", result->clients_len);
+
+ if (stale_count > 0) {
+ fprintf(f, "<span style=\"color:var(--purple)\">%d stale</span>", stale_count);
+ }
+
+ fprintf(f,
+ "</small>\n"
+ " <button onclick=\"document.getElementById('add-form').classList.toggle('open')\">+ Add</button>\n"
+ " </h2>\n"
+ " <form id=\"add-form\" method=\"POST\" action=\"/clients\">\n"
+ " <div>\n"
+ " <label>Name</label>\n"
+ " <input type=\"text\" name=\"name\" maxlength=\"31\" placeholder=\"prod-web-02\" required>\n"
+ " </div>\n"
+ " <button type=\"submit\">Create</button>\n"
+ " </form>\n"
+ " <div class=\"clients\">\n"
+ " <div class=\"row header\">\n"
+ " <span>Name</span>\n"
+ " <span>Secret</span>\n"
+ " <span>Last Backup</span>\n"
+ " <span>Status</span>\n"
+ " <span></span>\n"
+ " </div>\n"
+ );
+
+ for (int i = 0; i < result->clients_len; i++) {
+ char masked_secret[16];
+ mask_secret(result->clients[i].secret, masked_secret, sizeof(masked_secret));
+
+ fprintf(f,
+ " <a href=\"/client/%ld\" class=\"row %s\">\n"
+ " <span>%s</span>\n"
+ " <code>%s</code>\n"
+ " <span>%s</span>\n"
+ " <b class=\"badge %s\">%s</b>\n"
+ " <button onclick=\"event.stopPropagation();window.location='/client/%ld'\">View &rarr;</button>\n"
+ " </a>\n",
+ result->clients[i].id,
+ result->clients[i].is_stale ? "stale" : "",
+ result->clients[i].name,
+ masked_secret,
+ result->clients[i].last_backup.tv_sec > 0.0 ? format_time(result->clients[i].last_backup).buf : "-",
+ status_to_badge_class(result->clients[i].status),
+ status_to_display(result->clients[i].status),
+ result->clients[i].id
+ );
+ }
+
+ fprintf(f,
+ " </div>\n"
+ " </main>\n"
+ "</body>\n"
+ "</html>\n");
+
+ fclose(f);
+ *out_len = buf_len;
+ return buf;
+}
+
+char *html_render_client_detail_page(const char *host, const struct GetClientOnlyResult *client_result, const struct GetBackupsResult *backups_result, size_t *out_len) {
+ // Determine status and staleness
+ int is_stale = 0;
+ const char *current_status = "OK";
+ if (backups_result->backups_len > 0) {
+ const char *last_status = backups_result->backups[0].status;
+ if (strcmp(last_status, "RUNNING") == 0) {
+ current_status = "RUNNING";
+ } else if (strcmp(last_status, "INTERRUPTED") == 0) {
+ current_status = "INTERRUPTED";
+ }
+ }
+
+ char *buf = NULL;
+ size_t buf_len = 0;
+ FILE *f = open_memstream(&buf, &buf_len);
+ if (!f) {
+ return NULL;
+ }
+
+ fprintf(f,
+ "<!DOCTYPE html>\n"
+ "<html lang=\"en\">\n"
+ "<head>\n"
+ " <meta charset=\"UTF-8\">\n"
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
+ " <title>BorgFlag - %s</title>\n", client_result->name);
+ emit_stylesheet(f);
+ fprintf(f,
+ "<script>\n"
+ "function copy(btn) {\n"
+ " navigator.clipboard.writeText(btn.closest('pre').innerText.replace('Copy','').trim()).then(() => {\n"
+ " btn.textContent = 'Copied!';\n"
+ " setTimeout(() => btn.textContent = 'Copy', 2000);\n"
+ " });\n"
+ "}\n"
+ "</script>\n"
+ "</head>\n"
+ "<body>\n"
+ " <header>\n"
+ " <b>BorgFlag</b>\n"
+ " <small><form method=\"POST\" action=\"/logout\" style=\"display:inline\"><button type=\"submit\" style=\"border:none;background:none;color:var(--dim);font-size:12px;padding:0;cursor:pointer;text-transform:none;letter-spacing:0\">Sign out</button></form></small>\n"
+ " </header>\n"
+ " <main>\n"
+ " <a href=\"/clients\">&larr; Back to clients</a>\n"
+ " <article%s>\n"
+ " <h3>%s</h3>\n"
+ " <p>%d backups <b class=\"badge %s\">%s</b></p>\n",
+ is_stale ? " class=\"stale\"" : "",
+ client_result->name,
+ backups_result->backups_len,
+ status_to_badge_class(current_status),
+ status_to_display(current_status)
+ );
+
+ if (is_stale) {
+ fprintf(f,
+ " <p class=\"warning\">Warning: Last backup was more than 24 hours ago. Expected every 24 hours.</p>\n"
+ );
+ }
+
+ fprintf(f,
+ " <code>Secret: %s</code>\n"
+ " </article>\n"
+ " <details>\n"
+ " <summary>API Snippets</summary>\n"
+ " <div>\n"
+ " <div>\n"
+ " <h4>Start backup</h4>\n"
+ " <pre><span class=\"cmd\">curl</span> <span class=\"flag\">-X POST</span> <span class=\"url\">http://%s/api/report</span> \\\n"
+ " <span class=\"flag\">-H</span> <span class=\"val\">\"Content-Type: application/json\"</span> \\\n"
+ " <span class=\"flag\">-d</span> <span class=\"val\">'{</span>\n"
+ " <span class=\"key\">\"client\"</span>: <span class=\"val\">\"%s\"</span>,\n"
+ " <span class=\"key\">\"secret\"</span>: <span class=\"secret\">\"%s\"</span>,\n"
+ " <span class=\"key\">\"event\"</span>: <span class=\"val\">\"begin_backup\"</span>\n"
+ " <span class=\"val\">}'</span><button onclick=\"copy(this)\">Copy</button></pre>\n"
+ " </div>\n"
+ " <div>\n"
+ " <h4>End backup</h4>\n"
+ " <pre><span class=\"cmd\">curl</span> <span class=\"flag\">-X POST</span> <span class=\"url\">http://%s/api/report</span> \\\n"
+ " <span class=\"flag\">-H</span> <span class=\"val\">\"Content-Type: application/json\"</span> \\\n"
+ " <span class=\"flag\">-d</span> <span class=\"val\">'{</span>\n"
+ " <span class=\"key\">\"client\"</span>: <span class=\"val\">\"%s\"</span>,\n"
+ " <span class=\"key\">\"secret\"</span>: <span class=\"secret\">\"%s\"</span>,\n"
+ " <span class=\"key\">\"event\"</span>: <span class=\"val\">\"end_backup\"</span>\n"
+ " <span class=\"val\">}'</span><button onclick=\"copy(this)\">Copy</button></pre>\n"
+ " </div>\n"
+ " </div>\n"
+ " </details>\n",
+ client_result->secret,
+ host ? host : "localhost",
+ client_result->name,
+ client_result->secret,
+ host ? host : "localhost",
+ client_result->name,
+ client_result->secret
+ );
+
+ fprintf(f, " <h4>Recent Backups</h4>\n");
+
+ if(backups_result->backups_len == 0) {
+ fprintf(f, " <p style=\"color:var(--dim)\">No backups recorded.</p>\n");
+ } else {
+ fprintf(f, " <div class=\"backups\">\n");
+
+ for (int i = 0; i < backups_result->backups_len; i++) {
+ const char *badge_class;
+ const char *badge_text;
+ if (strcmp(backups_result->backups[i].status, "RUNNING") == 0) {
+ badge_class = "run";
+ badge_text = "Running";
+ } else if (strcmp(backups_result->backups[i].status, "INTERRUPTED") == 0) {
+ badge_class = "fail";
+ badge_text = "Failed";
+ } else {
+ badge_class = "ok";
+ badge_text = "Done";
+ }
+
+ struct timespec duration = time_sub(&backups_result->backups[i].completed, &backups_result->backups[i].started);
+ fprintf(f,
+ " <div><time>%s</time><span>%s</span><b class=\"badge %s\">%s</b></div>\n",
+ format_time(backups_result->backups[i].started).buf,
+ format_duration(duration).buf,
+ badge_class,
+ badge_text
+ );
+ }
+
+ fprintf(f, " </div>\n");
+ }
+
+ fprintf(f,
+ " </main>\n"
+ "</body>\n"
+ "</html>\n");
+
+ fclose(f);
+ *out_len = buf_len;
+ return buf;
+}
+
+char *html_render_login_page(const char *error_msg, size_t *out_len) {
+ char *buf = NULL;
+ size_t buf_len = 0;
+ FILE *f = open_memstream(&buf, &buf_len);
+ if (!f) return NULL;
+
+ fprintf(f,
+ "<!DOCTYPE html>\n"
+ "<html lang=\"en\">\n"
+ "<head>\n"
+ " <meta charset=\"UTF-8\">\n"
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
+ " <title>BorgFlag - Login</title>\n");
+ emit_stylesheet(f);
+ fprintf(f,
+ "</head>\n"
+ "<body>\n"
+ " <div class=\"login-page\">\n"
+ " <form method=\"POST\" action=\"/login\">\n"
+ " <h1>BorgFlag</h1>\n");
+
+ if (error_msg) {
+ fprintf(f, " <div class=\"error\">%s</div>\n", error_msg);
+ }
+
+ fprintf(f,
+ " <div>\n"
+ " <label>Username</label>\n"
+ " <input type=\"text\" name=\"username\" autocomplete=\"username\" required>\n"
+ " </div>\n"
+ " <div>\n"
+ " <label>Password</label>\n"
+ " <input type=\"password\" name=\"password\" autocomplete=\"current-password\" required>\n"
+ " </div>\n"
+ " <button type=\"submit\">Sign In</button>\n"
+ " </form>\n"
+ " </div>\n"
+ "</body>\n"
+ "</html>\n");
+
+ fclose(f);
+ *out_len = buf_len;
+ return buf;
+}
diff --git a/src/html.h b/src/html.h
new file mode 100644
index 0000000..f244cdb
--- /dev/null
+++ b/src/html.h
@@ -0,0 +1,17 @@
+#pragma once
+
+#include "app.h"
+#include <stddef.h>
+
+char *html_render_login_page(const char *error_msg, size_t *out_len);
+
+char *html_render_clients_page(
+ const char *host,
+ const struct ListClientResult *clients,
+ size_t *out_len);
+
+char *html_render_client_detail_page(
+ const char *host,
+ const struct GetClientOnlyResult *client,
+ const struct GetBackupsResult *backups,
+ size_t *out_len);
diff --git a/src/main.c b/src/main.c
index e5d872d..9a87d7c 100644
--- a/src/main.c
+++ b/src/main.c
@@ -1,3 +1,4 @@
+#include "app.h"
#include "web.h"
#include <signal.h>
@@ -40,7 +41,7 @@ int main(int argc, char ** argv) {
signal(SIGTERM, signal_shutdown);
signal(SIGINT, signal_shutdown);
- prepare_database(&app);
+ app_startup(&app);
web_begin(&server, atoi(argv[1]), admin_user, admin_pass);
while (!shutdown_requested) {
diff --git a/src/web.c b/src/web.c
index 6dd18e1..9c0789b 100644
--- a/src/web.c
+++ b/src/web.c
@@ -1,21 +1,15 @@
#include "web.h"
+#include "app.h"
+#include "html.h"
#include "mytime.h"
#include <assert.h>
-#include <errno.h>
-#include <linux/limits.h>
-#include <libgen.h>
-#include <math.h>
-#include <sys/socket.h>
#include <string.h>
-#include <sys/stat.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
-#include <string.h>
#include <microhttpd.h>
-#include <sqlite3.h>
#include <cjson/cJSON.h>
#include <fcntl.h>
#include <crypt.h>
@@ -23,11 +17,6 @@
#define PAGE "<html><head><title>libmicrohttpd demo</title>"\
"</head><body>libmicrohttpd demo</body></html>"
-static void sqliteError(void *pArg, int iErrCode, const char *zMsg) {
- // @CLEANUP: We should stuff these somewhere, but for now just print it
- fprintf(stderr, "(%d) %s\n", iErrCode, zMsg);
-}
-
static int generate_secret(char *out) {
unsigned char bytes[16];
int fd = open("/dev/urandom", O_RDONLY);
@@ -64,1243 +53,6 @@ static int is_authenticated(struct App *app, struct MHD_Connection *connection)
return strcmp(cookie, expected) == 0;
}
-// https://stackoverflow.com/questions/2336242/recursive-mkdir-system-call-on-unix
-static void _mkdir(const char *dir) {
- char tmp[PATH_MAX];
- char *p = NULL;
- size_t len;
- int rc;
-
- snprintf(tmp, sizeof(tmp),"%s",dir);
- len = strlen(tmp);
- if (tmp[len - 1] == '/')
- tmp[len - 1] = 0;
- for (p = tmp + 1; *p; p++)
- if (*p == '/') {
- *p = 0;
- rc = mkdir(tmp, S_IRWXU);
- if(rc != 0 && errno != EEXIST) {
- abort();
- }
- *p = '/';
- }
- rc = mkdir(tmp, S_IRWXU);
- if(rc != 0 && errno != EEXIST) {
- abort();
- }
-
-}
-
-void prepare_database(struct App *app) {
- // @CLEANUP: Move this to main? doesn't belong here at least
- sqlite3_config(SQLITE_CONFIG_LOG, sqliteError, NULL);
- sqlite3_config(SQLITE_CONFIG_URI, 1);
-
- int err;
-
- printf("Running migrations against %s\n", app->dbname);
-
- if(memcmp("file::memory", app->dbname, 12) != 0) {
- char *full = strdup(app->dbname);
- char *dbdir = dirname(full);
- _mkdir(dbdir);
- free(full);
- }
-
- sqlite3 *conn;
- if((err = sqlite3_open(app->dbname, &conn)) != SQLITE_OK) {
- fprintf(stderr, "(%d) %s\n", err, sqlite3_errmsg(conn));
- abort();
- }
- sqlite3_db_config(conn, SQLITE_DBCONFIG_ENABLE_FKEY, 1, NULL);
-
- if(sqlite3_exec(conn, "CREATE TABLE IF NOT EXISTS migration (id INTEGER PRIMARY KEY NOT NULL)", NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "SELECT MAX(id) FROM migration", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
-
- uint64_t maxVersion = 0;
- while((err = sqlite3_step(stmt)) == SQLITE_ROW) {
- maxVersion = sqlite3_column_int64(stmt, 0);
- }
- if(err != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
-
- if(maxVersion < 1) {
- char *prog =
- "BEGIN TRANSACTION;"
- "CREATE TABLE backups ("
- "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
- "client TEXT NOT NULL, "
- "status TEXT NOT NULL, "
- "started TEXT NOT NULL, "
- "completed TEXT "
- ") STRICT";
- if(sqlite3_exec(conn, prog, NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "INSERT INTO migration (id) VALUES (?1)", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_bind_int64(stmt, 1, 1) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_exec(conn, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
- }
-
- if(maxVersion < 2) {
- char *prog =
- "BEGIN TRANSACTION;"
- "CREATE TABLE clients ("
- "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
- "name TEXT NOT NULL "
- ") STRICT;"
- "INSERT INTO clients(name) "
- "SELECT b.client "
- "FROM backups b "
- "GROUP BY b.client;"
- "CREATE TABLE backups_next ("
- "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
- "client INTEGER NOT NULL, "
- "status TEXT NOT NULL, "
- "started TEXT NOT NULL, "
- "completed TEXT ,"
- "FOREIGN KEY(client) REFERENCES clients(id)"
- ") STRICT;"
- "INSERT INTO backups_next(id, client, status, started, completed) "
- "SELECT b.id, c.id, b.status, b.started, b.completed "
- "FROM backups b "
- "JOIN clients c ON b.client = c.name;"
- "DROP TABLE backups;"
- "ALTER TABLE backups_next RENAME TO backups;";
- if(sqlite3_exec(conn, prog, NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "INSERT INTO migration (id) VALUES (?1)", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_bind_int64(stmt, 1, 2) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_exec(conn, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
- }
-
- if(maxVersion < 3) {
- char *prog =
- "BEGIN TRANSACTION;"
- "CREATE UNIQUE INDEX clients_name ON clients(name)";
- if(sqlite3_exec(conn, prog, NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "INSERT INTO migration (id) VALUES (?1)", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_bind_int64(stmt, 1, 3) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_exec(conn, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
- }
-
- if(maxVersion < 4) {
- char *prog =
- "BEGIN TRANSACTION;"
- "ALTER TABLE clients ADD COLUMN secret TEXT NOT NULL DEFAULT ''";
- if(sqlite3_exec(conn, prog, NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "INSERT INTO migration (id) VALUES (?1)", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_bind_int64(stmt, 1, 4) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_exec(conn, "COMMIT TRANSACTION", NULL, NULL, NULL) != SQLITE_OK) {
- abort();
- }
- }
-}
-
-enum ReportEvent {
- REV_BEGIN,
- REV_FINISH,
-};
-
-// @CLEANUP: This should live somewhere else, but I don't have a spot for it yet.
-// Returns 0 on success, -1 on error, -2 on secret mismatch
-int submit_report(struct App *app, char *client, char *secret, enum ReportEvent event) {
- sqlite3 *conn;
- int err;
- if((err = sqlite3_open(app->dbname, &conn)) != SQLITE_OK) {
- abort();
- }
-
- struct timespec now = get_current_time();
-
- int64_t clientId;
- {
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "SELECT id, secret FROM clients WHERE name = ?1", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_text(stmt, 1, client, -1, SQLITE_STATIC) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_ROW) {
- abort();
- }
-
- if(sqlite3_column_type(stmt, 0) == SQLITE_NULL) {
- abort();
- }
- clientId = sqlite3_column_int64(stmt, 0);
-
- const char *stored_secret = (const char *)sqlite3_column_text(stmt, 1);
- if(stored_secret == NULL || strcmp(stored_secret, secret) != 0) {
- sqlite3_finalize(stmt);
- sqlite3_close(conn);
- return -2;
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
- }
-
- switch(event) {
- case REV_BEGIN: {
- sqlite3_stmt *stmt;
-
- // Mark any existing RUNNING backup as INTERRUPTED
- if(sqlite3_prepare_v2(conn, "UPDATE backups SET status = 'INTERRUPTED', completed = datetime(?1, 'unixepoch', 'subsec') WHERE client = ?2 AND status = 'RUNNING'", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_double(stmt, 1, TIME_AS_FLOAT(now)) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 2, clientId) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
- stmt = NULL;
-
- // Insert the new backup
- if(sqlite3_prepare_v2(conn, "INSERT INTO backups (client, status, started) VALUES (?1, ?2, datetime(?3, 'unixepoch', 'subsec'))", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 1, clientId) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_text(stmt, 2, "RUNNING", -1, SQLITE_STATIC) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_double(stmt, 3, TIME_AS_FLOAT(now)) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
- break;
- }
- case REV_FINISH: {
- uint64_t backupId;
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "SELECT id FROM backups WHERE client = ?1 AND status = 'RUNNING'", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 1, clientId) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_ROW) {
- abort();
- }
-
- if(sqlite3_column_type(stmt, 0) == SQLITE_NULL) {
- abort();
- }
- backupId = sqlite3_column_int64(stmt, 0);
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
- stmt = NULL;
-
- if(sqlite3_prepare_v2(conn, "UPDATE backups SET status = ?1, completed = datetime(?2, 'unixepoch', 'subsec') WHERE id = ?3", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_text(stmt, 1, "FINISHED", -1, SQLITE_STATIC) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_bind_double(stmt, 2, TIME_AS_FLOAT(now)) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 3, backupId) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
- break;
- }
- }
-
- if(sqlite3_close(conn) != SQLITE_OK) {
- abort();
- }
- return 0;
-}
-
-int create_client(struct Request *req, char *name, struct CreateClientResult *result) {
- sqlite3 *conn;
- int err;
- if((err = sqlite3_open(req->server->app->dbname, &conn)) != SQLITE_OK) {
- abort();
- }
-
- if(generate_secret(result->secret) != 0) {
- abort();
- }
-
- {
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "INSERT INTO clients (name, secret) VALUES (?1, ?2)", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_text(stmt, 2, result->secret, -1, SQLITE_STATIC) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
-
- result->id = sqlite3_last_insert_rowid(conn);
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
- }
-
- if(sqlite3_close(conn) != SQLITE_OK) {
- abort();
- }
- return 0;
-}
-
-int delete_client(struct Request *req, int64_t client_id) {
- sqlite3 *conn;
- if(sqlite3_open(req->server->app->dbname, &conn) != SQLITE_OK) {
- abort();
- }
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "DELETE FROM clients WHERE id = ?1", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_step(stmt) != SQLITE_DONE) {
- abort();
- }
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_close(conn) != SQLITE_OK) {
- abort();
- }
- return 0;
-}
-
-int list_clients(struct Request *req, struct ListClientResult *result) {
- sqlite3 *conn;
- int err;
- if((err = sqlite3_open(req->server->app->dbname, &conn)) != SQLITE_OK) {
- abort();
- }
-
- {
- sqlite3_stmt *stmt;
- const char *sql =
- "SELECT c.id, c.name, c.secret, unixepoch(b_success.completed,'subsec'), b_last.status "
- "FROM clients c "
- "LEFT JOIN backups b_success ON b_success.client = c.id "
- " AND b_success.id = (SELECT id FROM backups WHERE client = c.id AND status = 'FINISHED' ORDER BY id DESC LIMIT 1) "
- "LEFT JOIN backups b_last ON b_last.client = c.id "
- " AND b_last.id = (SELECT id FROM backups WHERE client = c.id ORDER BY id DESC LIMIT 1) "
- "WHERE c.id > ?1 ORDER BY c.id ASC LIMIT ?2";
- if(sqlite3_prepare_v2(conn, sql, -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 1, -1) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 2, 16) != SQLITE_OK) {
- abort();
- }
-
- int ret;
- int index = 0;
- while((ret = sqlite3_step(stmt)) == SQLITE_ROW) {
- assert(index < 16);
-
- if(sqlite3_column_type(stmt, 0) == SQLITE_NULL) {
- abort();
- }
-
- int64_t clientId = sqlite3_column_int64(stmt, 0);
- const uint8_t *name = sqlite3_column_text(stmt, 1);
- const uint8_t *secret = sqlite3_column_text(stmt, 2);
- const double last_backup = sqlite3_column_double(stmt, 3);
- const uint8_t *backup_status = sqlite3_column_text(stmt, 4);
-
- result->clients[index].id = clientId;
- strncpy(result->clients[index].name, (char*)name, CLIENT_NAME_MAX);
- strncpy(result->clients[index].secret, secret ? (char*)secret : "", CLIENT_SECRET_LEN);
- result->clients[index].last_backup = time_from_double(last_backup);
-
- if (backup_status == NULL) {
- strncpy(result->clients[index].status, "MISSING", BACKUP_STATUS_MAX);
- result->clients[index].is_stale = 0;
- } else if (strcmp((char*)backup_status, "RUNNING") == 0) {
- strncpy(result->clients[index].status, "RUNNING", BACKUP_STATUS_MAX);
- result->clients[index].is_stale = 0;
- } else if (strcmp((char*)backup_status, "INTERRUPTED") == 0) {
- strncpy(result->clients[index].status, "INTERRUPTED", BACKUP_STATUS_MAX);
- result->clients[index].is_stale = 0;
- } else {
- strncpy(result->clients[index].status, "OK", BACKUP_STATUS_MAX);
- result->clients[index].is_stale = 0;
- }
-
- index++;
- }
- if(ret != SQLITE_DONE) {
- abort();
- }
-
- result->clients_len = index;
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
- }
-
- if(sqlite3_close(conn) != SQLITE_OK) {
- abort();
- }
- return 0;
-}
-
-int get_client(struct Request *req, int64_t client_id, struct GetClientOnlyResult *result) {
- sqlite3 *conn;
- int err;
- if((err = sqlite3_open(req->server->app->dbname, &conn)) != SQLITE_OK) {
- abort();
- }
-
- result->found = 0;
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "SELECT id, name, secret FROM clients WHERE id = ?1", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) {
- abort();
- }
-
- int ret = sqlite3_step(stmt);
- if(ret == SQLITE_ROW) {
- result->found = 1;
- result->id = sqlite3_column_int64(stmt, 0);
- const uint8_t *name = sqlite3_column_text(stmt, 1);
- const uint8_t *secret = sqlite3_column_text(stmt, 2);
- strncpy(result->name, (char*)name, CLIENT_NAME_MAX);
- strncpy(result->secret, (char*)secret, CLIENT_SECRET_LEN);
- } else if(ret != SQLITE_DONE) {
- abort();
- }
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_close(conn) != SQLITE_OK) {
- abort();
- }
- return 0;
-}
-
-int get_client_backups(struct Request *req, int64_t client_id, struct GetBackupsResult *result) {
- sqlite3 *conn;
- int err;
- if((err = sqlite3_open(req->server->app->dbname, &conn)) != SQLITE_OK) {
- abort();
- }
-
- uint16_t limit = result->backups_len;
-
- sqlite3_stmt *stmt;
- if(sqlite3_prepare_v2(conn, "SELECT id, status, unixepoch(started, 'subsec'), unixepoch(completed, 'subsec') FROM backups WHERE client = ?1 ORDER BY started DESC LIMIT ?2", -1, &stmt, NULL) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) {
- abort();
- }
- if(sqlite3_bind_int(stmt, 2, limit) != SQLITE_OK) {
- abort();
- }
-
- int ret;
- int index = 0;
- while((ret = sqlite3_step(stmt)) == SQLITE_ROW) {
- if(index >= limit) break;
-
- result->backups[index].id = sqlite3_column_int64(stmt, 0);
-
- const uint8_t *status = sqlite3_column_text(stmt, 1);
- const double started = sqlite3_column_double(stmt, 2);
- const double completed = sqlite3_column_double(stmt, 3);
-
- strncpy(result->backups[index].status, status ? (char*)status : "", BACKUP_STATUS_MAX);
- result->backups[index].started = time_from_double(started);
- result->backups[index].completed = time_from_double(completed);
-
- index++;
- }
- if(ret != SQLITE_DONE && ret != SQLITE_ROW) {
- abort();
- }
-
- result->backups_len = index;
-
- if(sqlite3_finalize(stmt) != SQLITE_OK) {
- abort();
- }
-
- if(sqlite3_close(conn) != SQLITE_OK) {
- abort();
- }
- return 0;
-}
-
-static void emit_stylesheet(FILE *f) {
- fprintf(f,
- "<style>\n"
- "*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n"
- ":root {\n"
- " --bg: #0e0f11;\n"
- " --sur: #16181c;\n"
- " --bor: #2a2d35;\n"
- " --dim: #4a4f5e;\n"
- " --txt: #c8cdd8;\n"
- " --hi: #eef0f5;\n"
- " --green: #4af0a0;\n"
- " --yellow: #f0a04a;\n"
- " --red: #f06060;\n"
- " --purple: #c084fc;\n"
- " --mono: 'Courier New', 'Lucida Console', monospace;\n"
- "}\n"
- "body {\n"
- " background: var(--bg);\n"
- " color: var(--txt);\n"
- " font-family: var(--mono);\n"
- " font-size: 14px;\n"
- " line-height: 1.5;\n"
- " min-height: 100vh;\n"
- "}\n"
- "/* TOP BAR */\n"
- "header {\n"
- " background: var(--sur);\n"
- " border-bottom: 1px solid var(--bor);\n"
- " padding: 0 20px;\n"
- " height: 44px;\n"
- " display: flex;\n"
- " align-items: center;\n"
- " justify-content: space-between;\n"
- " position: sticky;\n"
- " top: 0;\n"
- " z-index: 1;\n"
- "}\n"
- "header b { color: var(--green); letter-spacing: 2px; }\n"
- "header small { color: var(--dim); font-size: 12px; font-family: var(--mono); }\n"
- "header a { color: var(--dim); font-size: 12px; }\n"
- "header a:hover { color: var(--green); }\n"
- "/* MAIN CONTENT */\n"
- "main { max-width: 860px; margin: 0 auto; padding: 24px 16px; }\n"
- "/* LOGIN */\n"
- ".login-page {\n"
- " min-height: 100vh;\n"
- " display: flex;\n"
- " align-items: center;\n"
- " justify-content: center;\n"
- " padding: 24px;\n"
- "}\n"
- ".login-page form {\n"
- " background: var(--sur);\n"
- " border: 1px solid var(--bor);\n"
- " padding: 36px 28px;\n"
- " width: 100%%;\n"
- " max-width: 320px;\n"
- " display: flex;\n"
- " flex-direction: column;\n"
- " gap: 14px;\n"
- "}\n"
- ".login-page h1 { color: var(--green); letter-spacing: 3px; text-align: center; font-size: 20px; }\n"
- ".login-page label { display: block; color: var(--dim); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 5px; }\n"
- ".login-page input {\n"
- " width: 100%%;\n"
- " background: var(--bg);\n"
- " border: 1px solid var(--bor);\n"
- " color: var(--hi);\n"
- " font-family: var(--mono);\n"
- " font-size: 16px;\n"
- " padding: 9px 10px;\n"
- " outline: none;\n"
- "}\n"
- ".login-page input:focus { border-color: var(--green); }\n"
- ".login-page .error {\n"
- " color: var(--red);\n"
- " background: #2e0a0a;\n"
- " border: 1px solid #5c1a1a;\n"
- " padding: 7px 10px;\n"
- " font-size: 12px;\n"
- "}\n"
- "/* SHARED BUTTON */\n"
- "button {\n"
- " font-family: var(--mono);\n"
- " font-size: 11px;\n"
- " letter-spacing: 1px;\n"
- " text-transform: uppercase;\n"
- " padding: 6px 14px;\n"
- " border: 1px solid var(--green);\n"
- " background: transparent;\n"
- " color: var(--green);\n"
- " cursor: pointer;\n"
- " touch-action: manipulation;\n"
- "}\n"
- "button:hover, button:active { background: var(--green); color: var(--bg); }\n"
- ".login-page button { padding: 9px; width: 100%%; }\n"
- "/* PAGE HEADING */\n"
- "main > h2 {\n"
- " color: var(--hi);\n"
- " font-size: 15px;\n"
- " letter-spacing: 2px;\n"
- " text-transform: uppercase;\n"
- " border-bottom: 1px solid var(--bor);\n"
- " padding-bottom: 12px;\n"
- " margin-bottom: 20px;\n"
- " display: flex;\n"
- " justify-content: space-between;\n"
- " align-items: baseline;\n"
- " flex-wrap: wrap;\n"
- " gap: 8px;\n"
- "}\n"
- "main > h2 small { color: var(--dim); font-size: 12px; font-weight: normal; letter-spacing: 0; }\n"
- "/* CLIENT GRID */\n"
- ".clients {\n"
- " display: grid;\n"
- " grid-template-columns: 1fr 150px 155px 80px 70px;\n"
- " gap: 0;\n"
- "}\n"
- ".clients > span {\n"
- " font-size: 11px;\n"
- " letter-spacing: 1px;\n"
- " text-transform: uppercase;\n"
- " color: var(--dim);\n"
- " padding: 0 12px 10px 12px;\n"
- " border-bottom: 1px solid var(--bor);\n"
- "}\n"
- ".clients > span:last-child { text-align: right; }\n"
- ".clients > a {\n"
- " display: grid;\n"
- " grid-column: 1 / -1;\n"
- " grid-template-columns: subgrid;\n"
- " align-items: center;\n"
- " padding: 11px 12px;\n"
- " border-bottom: 1px solid var(--bor);\n"
- " text-decoration: none;\n"
- " color: var(--txt);\n"
- " transition: background .1s;\n"
- "}\n"
- ".clients > a:hover { background: var(--sur); }\n"
- ".clients > a > :first-child { color: var(--hi); }\n"
- ".clients > a > :last-child { text-align: right; }\n"
- ".clients > a.stale > :first-child { border-left: 2px solid var(--purple); padding-left: 8px; }\n"
- ".clients > a.stale > :nth-child(3) { color: var(--purple); }\n"
- ".clients > a > code { font-size: 12px; color: var(--dim); letter-spacing: 1px; }\n"
- "@media (max-width: 580px) {\n"
- " .clients > span { display: none; }\n"
- " .clients > a {\n"
- " grid-template-columns: 1fr auto;\n"
- " grid-template-rows: auto auto;\n"
- " padding: 13px 14px;\n"
- " }\n"
- " .clients > a > :nth-child(1) { grid-column: 1; grid-row: 1; }\n"
- " .clients > a > :nth-child(2) { display: none; }\n"
- " .clients > a > :nth-child(3) { grid-column: 1; grid-row: 2; font-size: 11px; color: var(--dim); }\n"
- " .clients > a > :nth-child(4) { grid-column: 2; grid-row: 1 / 3; align-self: center; }\n"
- " .clients > a > :nth-child(5) { display: none; }\n"
- " .clients > a.stale { border-left: 3px solid var(--purple); padding-left: 11px; }\n"
- " .clients > a.stale > :first-child { border-left: none; padding-left: 0; }\n"
- "}\n"
- "/* BADGES */\n"
- ".badge { border: 1px solid; padding: 2px 7px; font-size: 10px; letter-spacing: 1px; justify-self: center; }"
- ".badge.ok { color: var(--green); background: #0a2e1a; border-color: #1a5c34; }\n"
- ".badge.run { color: var(--yellow); background: #2e200a; border-color: #5c400e; }\n"
- ".badge.fail { color: var(--red); background: #2e0a0a; border-color: #5c1a1a; }\n"
- ".badge.stale { color: var(--purple); background: #1e0a2e; border-color: #5c1a8c; }\n"
- "@keyframes blink { 50%% { opacity: .4; } }\n"
- "/* ADD FORM */\n"
- "#add-form {\n"
- " background: var(--sur);\n"
- " border: 1px solid var(--bor);\n"
- " padding: 16px 20px;\n"
- " margin-bottom: 20px;\n"
- " display: none;\n"
- " gap: 12px;\n"
- " flex-direction: column;\n"
- "}\n"
- "#add-form.open { display: flex; }\n"
- "#add-form label { color: var(--dim); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; display: block; margin-bottom: 5px; }\n"
- "#add-form input {\n"
- " background: var(--bg);\n"
- " border: 1px solid var(--bor);\n"
- " color: var(--hi);\n"
- " font-family: var(--mono);\n"
- " font-size: 16px;\n"
- " padding: 8px 10px;\n"
- " outline: none;\n"
- " width: 100%%;\n"
- " max-width: 280px;\n"
- "}\n"
- "#add-form input:focus { border-color: var(--green); }\n"
- "/* DETAIL HEADER */\n"
- "article {\n"
- " background: var(--sur);\n"
- " border: 1px solid var(--bor);\n"
- " padding: 16px 20px;\n"
- " margin-bottom: 24px;\n"
- "}\n"
- "article.stale { border-left: 3px solid var(--purple); }\n"
- "article h3 { color: var(--green); font-size: 17px; letter-spacing: 1px; margin-bottom: 4px; }\n"
- "article p { color: var(--dim); font-size: 12px; }\n"
- "article p.warning {\n"
- " color: var(--purple);\n"
- " background: #1e0a2e;\n"
- " border: 1px solid #5c1a8c;\n"
- " padding: 7px 10px;\n"
- " margin-top: 10px;\n"
- " font-size: 12px;\n"
- "}\n"
- "article code {\n"
- " display: block;\n"
- " margin-top: 10px;\n"
- " padding-top: 10px;\n"
- " border-top: 1px solid var(--bor);\n"
- " font-size: 12px;\n"
- " color: var(--txt);\n"
- " word-break: break-all;\n"
- "}\n"
- "/* BACK LINK */\n"
- "main > a { display: inline-block; color: var(--dim); font-size: 12px; margin-bottom: 20px; text-decoration: none; }\n"
- "main > a:hover { color: var(--green); }\n"
- "/* SNIPPETS */\n"
- "details {\n"
- " margin-bottom: 28px;\n"
- " border-bottom: 1px solid var(--bor);\n"
- "}\n"
- "details summary {\n"
- " font-size: 11px;\n"
- " letter-spacing: 2px;\n"
- " text-transform: uppercase;\n"
- " color: var(--dim);\n"
- " padding-bottom: 10px;\n"
- " cursor: pointer;\n"
- " list-style: none;\n"
- " display: flex;\n"
- " justify-content: space-between;\n"
- "}\n"
- "details summary:hover { color: var(--txt); }\n"
- "details summary::after { content: '>'; transition: transform .2s; }\n"
- "details[open] summary::after { transform: rotate(90deg); }\n"
- "details summary::-webkit-details-marker { display: none; }\n"
- "details > div { padding-top: 14px; display: flex; flex-direction: column; gap: 10px; }\n"
- "details h4 { font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--dim); margin-bottom: 6px; font-weight: normal; }\n"
- "details pre {\n"
- " background: var(--bg);\n"
- " border: 1px solid var(--bor);\n"
- " padding: 12px 60px 12px 14px;\n"
- " font-family: var(--mono);\n"
- " font-size: 12px;\n"
- " overflow-x: auto;\n"
- " line-height: 1.6;\n"
- " position: relative;\n"
- " white-space: pre;\n"
- "}\n"
- "details pre .cmd { color: var(--yellow); }\n"
- "details pre .flag { color: var(--dim); }\n"
- "details pre .url { color: var(--green); }\n"
- "details pre .key { color: #7dd3fc; }\n"
- "details pre .val { color: #f9a8d4; }\n"
- "details pre .secret { color: var(--purple); }\n"
- "details pre button {\n"
- " position: absolute;\n"
- " top: 8px; right: 8px;\n"
- " background: var(--sur);\n"
- " border-color: var(--bor);\n"
- " color: var(--dim);\n"
- " font-size: 10px;\n"
- "}\n"
- "details pre button:hover { border-color: var(--green); color: var(--green); background: transparent; }\n"
- "/* BACKUP LIST */\n"
- "main > h4 { font-size: 11px; letter-spacing: 2px; text-transform: uppercase; color: var(--dim); margin-bottom: 12px; font-weight: normal; }\n"
- ".backups { display: flex; flex-direction: column; gap: 8px; }\n"
- ".backups > div {\n"
- " background: var(--sur);\n"
- " border: 1px solid var(--bor);\n"
- " padding: 10px 16px;\n"
- " display: flex;\n"
- " align-items: center;\n"
- " gap: 12px;\n"
- "}\n"
- ".backups > div > time { color: var(--hi); font-size: 13px; flex: 1; }\n"
- ".backups > div > span { color: var(--dim); font-size: 12px; min-width: 70px; text-align: right; }\n"
- "</style>\n"
- );
-}
-
-static void mask_secret(const char *secret, char *out, size_t out_len) {
- size_t len = strlen(secret);
- if (len >= 8 && out_len >= 13) {
- snprintf(out, out_len, "%.4s....%.4s", secret, secret + len - 4);
- } else if (len > 0) {
- snprintf(out, out_len, "****");
- } else {
- out[0] = '\0';
- }
-}
-
-static const char *status_to_badge_class(const char *status) {
- if (strcmp(status, "OK") == 0) return "ok";
- if (strcmp(status, "RUNNING") == 0) return "run";
- if (strcmp(status, "STALE") == 0) return "stale";
- if (strcmp(status, "INTERRUPTED") == 0) return "fail";
- if (strcmp(status, "FINISHED") == 0) return "ok";
- return "ok";
-}
-
-static const char *status_to_display(const char *status) {
- if (strcmp(status, "OK") == 0) return "OK";
- if (strcmp(status, "RUNNING") == 0) return "Running";
- if (strcmp(status, "STALE") == 0) return "Stale";
- if (strcmp(status, "INTERRUPTED") == 0) return "Failed";
- if (strcmp(status, "FINISHED") == 0) return "Done";
- return status;
-}
-
-static char *render_clients_page(struct Request *request, size_t *out_len) {
- int ret;
- struct ListClientResult *result = malloc(sizeof(struct ListClientResult) + sizeof(struct ListClientResultClient) * 16);
- ret = list_clients(request, result);
- if(ret != 0) {
- free(result);
- return NULL;
- }
-
- // Count stale clients
- int stale_count = 0;
- for (int i = 0; i < result->clients_len; i++) {
- if (result->clients[i].is_stale) stale_count++;
- }
-
- char *buf = NULL;
- size_t buf_len = 0;
- FILE *f = open_memstream(&buf, &buf_len);
- if (!f) {
- free(result);
- return NULL;
- }
-
- fprintf(f,
- "<!DOCTYPE html>\n"
- "<html lang=\"en\">\n"
- "<head>\n"
- " <meta charset=\"UTF-8\">\n"
- " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
- " <title>BorgFlag - Clients</title>\n");
- emit_stylesheet(f);
- fprintf(f,
- "</head>\n"
- "<body>\n"
- " <header>\n"
- " <b>BorgFlag</b>\n"
- " <small><form method=\"POST\" action=\"/logout\" style=\"display:inline\"><button type=\"submit\" style=\"border:none;background:none;color:var(--dim);font-size:12px;padding:0;cursor:pointer;text-transform:none;letter-spacing:0\">Sign out</button></form></small>\n"
- " </header>\n"
- " <main>\n"
- " <h2>\n"
- " Clients\n"
- " <small>%d registered", result->clients_len);
-
- if (stale_count > 0) {
- fprintf(f, "<span style=\"color:var(--purple)\">%d stale</span>", stale_count);
- }
-
- fprintf(f,
- "</small>\n"
- " <button onclick=\"document.getElementById('add-form').classList.toggle('open')\">+ Add</button>\n"
- " </h2>\n"
- " <form id=\"add-form\" method=\"POST\" action=\"/clients\">\n"
- " <div>\n"
- " <label>Name</label>\n"
- " <input type=\"text\" name=\"name\" maxlength=\"31\" placeholder=\"prod-web-02\" required>\n"
- " </div>\n"
- " <button type=\"submit\">Create</button>\n"
- " </form>\n"
- " <div class=\"clients\">\n"
- " <span>Name</span>\n"
- " <span>Secret</span>\n"
- " <span>Last Backup</span>\n"
- " <span>Status</span>\n"
- " <span></span>\n"
- );
-
- for (int i = 0; i < result->clients_len; i++) {
- char masked_secret[16];
- mask_secret(result->clients[i].secret, masked_secret, sizeof(masked_secret));
-
- fprintf(f,
- " <a href=\"/client/%ld\"%s>\n"
- " <span>%s</span>\n"
- " <code>%s</code>\n"
- " <span>%s</span>\n"
- " <b class=\"badge %s\">%s</b>\n"
- " <button onclick=\"event.stopPropagation();window.location='/client/%ld'\">View &rarr;</button>\n"
- " </a>\n",
- result->clients[i].id,
- result->clients[i].is_stale ? " class=\"stale\"" : "",
- result->clients[i].name,
- masked_secret,
- result->clients[i].last_backup.tv_sec > 0.0 ? format_time(result->clients[i].last_backup).buf : "-",
- status_to_badge_class(result->clients[i].status),
- status_to_display(result->clients[i].status),
- result->clients[i].id
- );
- }
-
- fprintf(f,
- " </div>\n"
- " </main>\n"
- "</body>\n"
- "</html>\n");
-
- fclose(f);
- *out_len = buf_len;
- free(result);
- return buf;
-}
-
-static char *render_client_detail_page(struct Request *request, int64_t client_id, size_t *out_len) {
- struct GetClientOnlyResult client_result;
- int ret = get_client(request, client_id, &client_result);
- if(ret != 0) {
- return NULL;
- }
-
- if(!client_result.found) {
- return NULL;
- }
-
- struct GetBackupsResult *backups_result = malloc(sizeof(struct GetBackupsResult) + sizeof(struct BackupRecord) * MAX_BACKUPS_DISPLAY);
- backups_result->backups_len = MAX_BACKUPS_DISPLAY;
- ret = get_client_backups(request, client_id, backups_result);
- if(ret != 0) {
- free(backups_result);
- return NULL;
- }
-
- // Determine status and staleness
- int is_stale = 0;
- const char *current_status = "OK";
- if (backups_result->backups_len > 0) {
- const char *last_status = backups_result->backups[0].status;
- if (strcmp(last_status, "RUNNING") == 0) {
- current_status = "RUNNING";
- } else if (strcmp(last_status, "INTERRUPTED") == 0) {
- current_status = "INTERRUPTED";
- }
- }
-
- char *buf = NULL;
- size_t buf_len = 0;
- FILE *f = open_memstream(&buf, &buf_len);
- if (!f) {
- free(backups_result);
- return NULL;
- }
-
- fprintf(f,
- "<!DOCTYPE html>\n"
- "<html lang=\"en\">\n"
- "<head>\n"
- " <meta charset=\"UTF-8\">\n"
- " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
- " <title>BorgFlag - %s</title>\n", client_result.name);
- emit_stylesheet(f);
- fprintf(f,
- "<script>\n"
- "function copy(btn) {\n"
- " navigator.clipboard.writeText(btn.closest('pre').innerText.replace('Copy','').trim()).then(() => {\n"
- " btn.textContent = 'Copied!';\n"
- " setTimeout(() => btn.textContent = 'Copy', 2000);\n"
- " });\n"
- "}\n"
- "</script>\n"
- "</head>\n"
- "<body>\n"
- " <header>\n"
- " <b>BorgFlag</b>\n"
- " <small><form method=\"POST\" action=\"/logout\" style=\"display:inline\"><button type=\"submit\" style=\"border:none;background:none;color:var(--dim);font-size:12px;padding:0;cursor:pointer;text-transform:none;letter-spacing:0\">Sign out</button></form></small>\n"
- " </header>\n"
- " <main>\n"
- " <a href=\"/clients\">&larr; Back to clients</a>\n"
- " <article%s>\n"
- " <h3>%s</h3>\n"
- " <p>%d backups <b class=\"badge %s\">%s</b></p>\n",
- is_stale ? " class=\"stale\"" : "",
- client_result.name,
- backups_result->backups_len,
- status_to_badge_class(current_status),
- status_to_display(current_status)
- );
-
- if (is_stale) {
- fprintf(f,
- " <p class=\"warning\">Warning: Last backup was more than 24 hours ago. Expected every 24 hours.</p>\n"
- );
- }
-
- fprintf(f,
- " <code>Secret: %s</code>\n"
- " </article>\n"
- " <details>\n"
- " <summary>API Snippets</summary>\n"
- " <div>\n"
- " <div>\n"
- " <h4>Start backup</h4>\n"
- " <pre><span class=\"cmd\">curl</span> <span class=\"flag\">-X POST</span> <span class=\"url\">http://%s/api/report</span> \\\n"
- " <span class=\"flag\">-H</span> <span class=\"val\">\"Content-Type: application/json\"</span> \\\n"
- " <span class=\"flag\">-d</span> <span class=\"val\">'{</span>\n"
- " <span class=\"key\">\"client\"</span>: <span class=\"val\">\"%s\"</span>,\n"
- " <span class=\"key\">\"secret\"</span>: <span class=\"secret\">\"%s\"</span>,\n"
- " <span class=\"key\">\"event\"</span>: <span class=\"val\">\"begin_backup\"</span>\n"
- " <span class=\"val\">}'</span><button onclick=\"copy(this)\">Copy</button></pre>\n"
- " </div>\n"
- " <div>\n"
- " <h4>End backup</h4>\n"
- " <pre><span class=\"cmd\">curl</span> <span class=\"flag\">-X POST</span> <span class=\"url\">http://%s/api/report</span> \\\n"
- " <span class=\"flag\">-H</span> <span class=\"val\">\"Content-Type: application/json\"</span> \\\n"
- " <span class=\"flag\">-d</span> <span class=\"val\">'{</span>\n"
- " <span class=\"key\">\"client\"</span>: <span class=\"val\">\"%s\"</span>,\n"
- " <span class=\"key\">\"secret\"</span>: <span class=\"secret\">\"%s\"</span>,\n"
- " <span class=\"key\">\"event\"</span>: <span class=\"val\">\"end_backup\"</span>\n"
- " <span class=\"val\">}'</span><button onclick=\"copy(this)\">Copy</button></pre>\n"
- " </div>\n"
- " </div>\n"
- " </details>\n",
- client_result.secret,
- request->host ? request->host : "localhost",
- client_result.name,
- client_result.secret,
- request->host ? request->host : "localhost",
- client_result.name,
- client_result.secret
- );
-
- fprintf(f, " <h4>Recent Backups</h4>\n");
-
- if(backups_result->backups_len == 0) {
- fprintf(f, " <p style=\"color:var(--dim)\">No backups recorded.</p>\n");
- } else {
- fprintf(f, " <div class=\"backups\">\n");
-
- for (int i = 0; i < backups_result->backups_len; i++) {
- const char *badge_class;
- const char *badge_text;
- if (strcmp(backups_result->backups[i].status, "RUNNING") == 0) {
- badge_class = "run";
- badge_text = "Running";
- } else if (strcmp(backups_result->backups[i].status, "INTERRUPTED") == 0) {
- badge_class = "fail";
- badge_text = "Failed";
- } else {
- badge_class = "ok";
- badge_text = "Done";
- }
-
- struct timespec duration = time_sub(&backups_result->backups[i].completed, &backups_result->backups[i].started);
- fprintf(f,
- " <div><time>%s</time><span>%s</span><b class=\"badge %s\">%s</b></div>\n",
- format_time(backups_result->backups[i].started).buf,
- format_duration(duration).buf,
- badge_class,
- badge_text
- );
- }
-
- fprintf(f, " </div>\n");
- }
-
- fprintf(f,
- " </main>\n"
- "</body>\n"
- "</html>\n");
-
- fclose(f);
- *out_len = buf_len;
- free(backups_result);
- return buf;
-}
-
-static char *render_login_page(const char *error_msg, size_t *out_len) {
- char *buf = NULL;
- size_t buf_len = 0;
- FILE *f = open_memstream(&buf, &buf_len);
- if (!f) return NULL;
-
- fprintf(f,
- "<!DOCTYPE html>\n"
- "<html lang=\"en\">\n"
- "<head>\n"
- " <meta charset=\"UTF-8\">\n"
- " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
- " <title>BorgFlag - Login</title>\n");
- emit_stylesheet(f);
- fprintf(f,
- "</head>\n"
- "<body>\n"
- " <div class=\"login-page\">\n"
- " <form method=\"POST\" action=\"/login\">\n"
- " <h1>BorgFlag</h1>\n");
-
- if (error_msg) {
- fprintf(f, " <div class=\"error\">%s</div>\n", error_msg);
- }
-
- fprintf(f,
- " <div>\n"
- " <label>Username</label>\n"
- " <input type=\"text\" name=\"username\" autocomplete=\"username\" required>\n"
- " </div>\n"
- " <div>\n"
- " <label>Password</label>\n"
- " <input type=\"password\" name=\"password\" autocomplete=\"current-password\" required>\n"
- " </div>\n"
- " <button type=\"submit\">Sign In</button>\n"
- " </form>\n"
- " </div>\n"
- "</body>\n"
- "</html>\n");
-
- fclose(f);
- *out_len = buf_len;
- return buf;
-}
-
static enum MHD_Result create_post_collector(struct PostCollector *c) {
c->data = malloc(512);
c->size = 0;
@@ -1438,7 +190,7 @@ static enum MHD_Result handler(
if (!request->form.username_set || !request->form.password_set) {
size_t html_len;
- char *html = render_login_page("Invalid credentials", &html_len);
+ char *html = html_render_login_page("Invalid credentials", &html_len);
if (!html) return MHD_NO;
struct MHD_Response *response = MHD_create_response_from_buffer(
html_len, html, MHD_RESPMEM_MUST_FREE);
@@ -1451,7 +203,7 @@ static enum MHD_Result handler(
if (!validate_credentials(server->app, request->form.username, request->form.password)) {
size_t html_len;
- char *html = render_login_page("Invalid credentials", &html_len);
+ char *html = html_render_login_page("Invalid credentials", &html_len);
if (!html) return MHD_NO;
struct MHD_Response *response = MHD_create_response_from_buffer(
html_len, html, MHD_RESPMEM_MUST_FREE);
@@ -1495,7 +247,7 @@ static enum MHD_Result handler(
}
struct CreateClientResult result;
- ret = create_client(request, request->form.name, &result);
+ ret = create_client(server->app, request->form.name, &result);
if(ret != 0) return MHD_NO;
// Post-Redirect-Get: redirect back to GET /clients
@@ -1524,7 +276,7 @@ static enum MHD_Result handler(
return MHD_NO;
}
- ret = delete_client(request, request->form.client_id);
+ ret = delete_client(server->app, request->form.client_id);
if(ret != 0) return MHD_NO;
struct MHD_Response *response = MHD_create_response_from_buffer(
@@ -1552,17 +304,17 @@ static enum MHD_Result handler(
if(!cJSON_IsString(eventJson) || eventJson->valuestring == NULL) {
abort();
}
- enum ReportEvent event;
+ int is_finish;
if(strcmp(eventJson->valuestring, "begin_backup") == 0) {
- event = REV_BEGIN;
+ is_finish = 0;
} else if(strcmp(eventJson->valuestring, "end_backup") == 0) {
- event = REV_FINISH;
+ is_finish = 1;
} else {
abort();
}
- ret = submit_report(server->app, clientJson->valuestring, secretJson->valuestring, event);
+ ret = submit_report(server->app, clientJson->valuestring, secretJson->valuestring, is_finish);
if(ret == -2) {
cJSON_Delete(rootJson);
destroy_post_collector(&request->pp);
@@ -1614,7 +366,7 @@ static enum MHD_Result handler(
}
struct CreateClientResult result;
- ret = create_client(request, nameJson->valuestring, &result);
+ ret = create_client(server->app, nameJson->valuestring, &result);
if(ret != 0) {
// Should actually return an error page, but this will do for now
return MHD_NO;
@@ -1668,7 +420,7 @@ static enum MHD_Result handler(
if(strcmp(url, "/login") == 0) {
size_t html_len;
- char *html = render_login_page(NULL, &html_len);
+ char *html = html_render_login_page(NULL, &html_len);
if (!html) return MHD_NO;
struct MHD_Response *response = MHD_create_response_from_buffer(
html_len, html, MHD_RESPMEM_MUST_FREE);
@@ -1688,8 +440,18 @@ static enum MHD_Result handler(
return ret;
}
+ // Fetch clients from app layer
+ struct ListClientResult *clients = malloc(sizeof(struct ListClientResult) + sizeof(struct ListClientResultClient) * 16);
+ ret = list_clients(server->app, clients);
+ if(ret != 0) {
+ free(clients);
+ return MHD_NO;
+ }
+
+ // Render HTML
size_t html_len;
- char *html = render_clients_page(request, &html_len);
+ char *html = html_render_clients_page(request->host, clients, &html_len);
+ free(clients);
if (!html) {
return MHD_NO;
@@ -1730,8 +492,34 @@ static enum MHD_Result handler(
return ret;
}
+ // Fetch client data from app layer
+ struct GetClientOnlyResult client_result;
+ ret = get_client(server->app, client_id, &client_result);
+ if(ret != 0) {
+ return MHD_NO;
+ }
+
+ if(!client_result.found) {
+ // Not Found
+ struct MHD_Response *response = MHD_create_response_from_buffer(
+ 0, "", MHD_RESPMEM_PERSISTENT);
+ ret = MHD_queue_response(connection, MHD_HTTP_NOT_FOUND, response);
+ MHD_destroy_response(response);
+ return ret;
+ }
+
+ struct GetBackupsResult *backups_result = malloc(sizeof(struct GetBackupsResult) + sizeof(struct BackupRecord) * MAX_BACKUPS_DISPLAY);
+ backups_result->backups_len = MAX_BACKUPS_DISPLAY;
+ ret = get_client_backups(server->app, client_id, backups_result);
+ if(ret != 0) {
+ free(backups_result);
+ return MHD_NO;
+ }
+
+ // Render HTML
size_t html_len;
- char *html = render_client_detail_page(request, client_id, &html_len);
+ char *html = html_render_client_detail_page(request->host, &client_result, backups_result, &html_len);
+ free(backups_result);
if (!html) {
// Not Found
diff --git a/src/web.h b/src/web.h
index dd6e824..2bd85ea 100644
--- a/src/web.h
+++ b/src/web.h
@@ -1,25 +1,12 @@
#pragma once
+#include "app.h"
+
#include <stddef.h>
#include <stdint.h>
-#include <time.h>
struct MHD_PostProcessor;
-#define CLIENT_NAME_MAX 32
-#define CLIENT_SECRET_LEN 33
-#define BACKUP_STATUS_MAX 16
-#define MAX_BACKUPS_DISPLAY 50
-
-struct App {
- const char* dbname;
- char session_secret[CLIENT_SECRET_LEN];
- const char *admin_user;
- const char *admin_pass;
-};
-
-void prepare_database(struct App *app);
-
struct Server {
struct App *app;
@@ -53,51 +40,3 @@ struct Request {
struct FormData form;
const char *host;
};
-
-struct CreateClientResult {
- int64_t id;
- char* name;
- char secret[CLIENT_SECRET_LEN];
-};
-
-int create_client(struct Request *req, char *name, struct CreateClientResult *result);
-int delete_client(struct Request *req, int64_t client_id);
-
-struct ListClientResultClient {
- int64_t id;
- char name[CLIENT_NAME_MAX];
- char secret[CLIENT_SECRET_LEN];
- struct timespec last_backup;
- char status[BACKUP_STATUS_MAX]; // "OK", "RUNNING", "STALE", "FAILED", "INTERRUPTED"
- int is_stale;
-};
-
-struct ListClientResult {
- uint16_t clients_len;
- // Right now this has to be exactly 16 elements long, but it could be longer in the future
- struct ListClientResultClient clients[];
-};
-
-int list_clients(struct Request *req, struct ListClientResult *result);
-
-struct BackupRecord {
- int64_t id;
- char status[BACKUP_STATUS_MAX];
- struct timespec started;
- struct timespec completed;
-};
-
-struct GetClientOnlyResult {
- int found;
- int64_t id;
- char name[CLIENT_NAME_MAX];
- char secret[CLIENT_SECRET_LEN];
-};
-
-struct GetBackupsResult {
- uint16_t backups_len; // On input: max backups to fetch; On output: actual count
- struct BackupRecord backups[];
-};
-
-int get_client(struct Request *req, int64_t client_id, struct GetClientOnlyResult *result);
-int get_client_backups(struct Request *req, int64_t client_id, struct GetBackupsResult *result);
diff --git a/test/http.c b/test/http.c
index 97f7a6b..0d18242 100644
--- a/test/http.c
+++ b/test/http.c
@@ -1,3 +1,4 @@
+#include "app.h"
#include "web.h"
#include "mytime.h"
@@ -50,7 +51,7 @@ int main(int argc, char **argv) {
struct App app = {
.dbname = "file::memory:?cache=shared",
};
- prepare_database(&app);
+ app_startup(&app);
struct Server server = {
.app = &app,
};
@@ -320,16 +321,15 @@ int main(int argc, char **argv) {
assert(code == 204);
body = (struct memory){0};
- // Use internal function to verify backup statuses
- struct Request req = { .server = &server };
+ // Use app layer functions to verify backup statuses
struct GetClientOnlyResult client_result;
- int ret = get_client(&req, 1, &client_result);
+ int ret = get_client(&app, 1, &client_result);
assert(ret == 0);
assert(client_result.found == 1);
struct GetBackupsResult *backups_result = malloc(sizeof(struct GetBackupsResult) + sizeof(struct BackupRecord) * MAX_BACKUPS_DISPLAY);
backups_result->backups_len = MAX_BACKUPS_DISPLAY;
- ret = get_client_backups(&req, 1, backups_result);
+ ret = get_client_backups(&app, 1, backups_result);
assert(ret == 0);
// Should have at least 2 backups (the interrupted one and the running one)
diff --git a/test/list_clients.c b/test/list_clients.c
index ea34794..3cab798 100644
--- a/test/list_clients.c
+++ b/test/list_clients.c
@@ -1,4 +1,4 @@
-#include "web.h"
+#include "app.h"
#include <assert.h>
#include <stdio.h>
@@ -7,21 +7,19 @@
int main() {
struct App app = { .dbname = "file::memory:?cache=shared" };
- prepare_database(&app);
- struct Server server = { .app = &app };
- struct Request request = { .server = &server };
+ app_startup(&app);
- // Insert 17 clients using create_client
+ // Insert 17 clients using app_create_client
for (int i = 1; i <= 17; i++) {
char name[32];
snprintf(name, sizeof(name), "client%d", i);
struct CreateClientResult cr;
- create_client(&request, name, &cr);
+ create_client(&app, name, &cr);
}
- // Call list_clients
+ // Call app_list_clients
struct ListClientResult *result = malloc(sizeof(*result) + 16 * sizeof(struct ListClientResultClient));
- list_clients(&request, result);
+ list_clients(&app, result);
// Verify: exactly 16 returned, correct order and names
assert(result->clients_len == 16);