diff options
| author | Jesper Jensen <jesper@jnsn.dev> | 2026-02-21 11:14:42 +0100 |
|---|---|---|
| committer | Jesper Jensen <jesper@jnsn.dev> | 2026-02-21 11:14:42 +0100 |
| commit | 1b19afaa339f2f68520d61b634600ddeb04effb7 (patch) | |
| tree | 232ec9fdc03a8955a8c847b88a688306689d2633 /src | |
| parent | 0b6e8026a6b530c7d88607bfdf79b9692987abaa (diff) | |
Split up the rendering from the logic
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.c | 639 | ||||
| -rw-r--r-- | src/app.h | 70 | ||||
| -rw-r--r-- | src/html.c | 599 | ||||
| -rw-r--r-- | src/html.h | 17 | ||||
| -rw-r--r-- | src/main.c | 3 | ||||
| -rw-r--r-- | src/web.c | 1312 | ||||
| -rw-r--r-- | src/web.h | 65 |
7 files changed, 1379 insertions, 1326 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 →</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\">← 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); @@ -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) { @@ -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 →</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\">← 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 @@ -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); |
