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/html.c | |
| parent | 0b6e8026a6b530c7d88607bfdf79b9692987abaa (diff) | |
Split up the rendering from the logic
Diffstat (limited to 'src/html.c')
| -rw-r--r-- | src/html.c | 599 |
1 files changed, 599 insertions, 0 deletions
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; +} |
