summaryrefslogtreecommitdiff
path: root/src/html.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/html.c')
-rw-r--r--src/html.c599
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 &rarr;</button>\n"
+ " </a>\n",
+ result->clients[i].id,
+ result->clients[i].is_stale ? "stale" : "",
+ result->clients[i].name,
+ masked_secret,
+ result->clients[i].last_backup.tv_sec > 0.0 ? format_time(result->clients[i].last_backup).buf : "-",
+ status_to_badge_class(result->clients[i].status),
+ status_to_display(result->clients[i].status),
+ result->clients[i].id
+ );
+ }
+
+ fprintf(f,
+ " </div>\n"
+ " </main>\n"
+ "</body>\n"
+ "</html>\n");
+
+ fclose(f);
+ *out_len = buf_len;
+ return buf;
+}
+
+char *html_render_client_detail_page(const char *host, const struct GetClientOnlyResult *client_result, const struct GetBackupsResult *backups_result, size_t *out_len) {
+ // Determine status and staleness
+ int is_stale = 0;
+ const char *current_status = "OK";
+ if (backups_result->backups_len > 0) {
+ const char *last_status = backups_result->backups[0].status;
+ if (strcmp(last_status, "RUNNING") == 0) {
+ current_status = "RUNNING";
+ } else if (strcmp(last_status, "INTERRUPTED") == 0) {
+ current_status = "INTERRUPTED";
+ }
+ }
+
+ char *buf = NULL;
+ size_t buf_len = 0;
+ FILE *f = open_memstream(&buf, &buf_len);
+ if (!f) {
+ return NULL;
+ }
+
+ fprintf(f,
+ "<!DOCTYPE html>\n"
+ "<html lang=\"en\">\n"
+ "<head>\n"
+ " <meta charset=\"UTF-8\">\n"
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
+ " <title>BorgFlag - %s</title>\n", client_result->name);
+ emit_stylesheet(f);
+ fprintf(f,
+ "<script>\n"
+ "function copy(btn) {\n"
+ " navigator.clipboard.writeText(btn.closest('pre').innerText.replace('Copy','').trim()).then(() => {\n"
+ " btn.textContent = 'Copied!';\n"
+ " setTimeout(() => btn.textContent = 'Copy', 2000);\n"
+ " });\n"
+ "}\n"
+ "</script>\n"
+ "</head>\n"
+ "<body>\n"
+ " <header>\n"
+ " <b>BorgFlag</b>\n"
+ " <small><form method=\"POST\" action=\"/logout\" style=\"display:inline\"><button type=\"submit\" style=\"border:none;background:none;color:var(--dim);font-size:12px;padding:0;cursor:pointer;text-transform:none;letter-spacing:0\">Sign out</button></form></small>\n"
+ " </header>\n"
+ " <main>\n"
+ " <a href=\"/clients\">&larr; Back to clients</a>\n"
+ " <article%s>\n"
+ " <h3>%s</h3>\n"
+ " <p>%d backups <b class=\"badge %s\">%s</b></p>\n",
+ is_stale ? " class=\"stale\"" : "",
+ client_result->name,
+ backups_result->backups_len,
+ status_to_badge_class(current_status),
+ status_to_display(current_status)
+ );
+
+ if (is_stale) {
+ fprintf(f,
+ " <p class=\"warning\">Warning: Last backup was more than 24 hours ago. Expected every 24 hours.</p>\n"
+ );
+ }
+
+ fprintf(f,
+ " <code>Secret: %s</code>\n"
+ " </article>\n"
+ " <details>\n"
+ " <summary>API Snippets</summary>\n"
+ " <div>\n"
+ " <div>\n"
+ " <h4>Start backup</h4>\n"
+ " <pre><span class=\"cmd\">curl</span> <span class=\"flag\">-X POST</span> <span class=\"url\">http://%s/api/report</span> \\\n"
+ " <span class=\"flag\">-H</span> <span class=\"val\">\"Content-Type: application/json\"</span> \\\n"
+ " <span class=\"flag\">-d</span> <span class=\"val\">'{</span>\n"
+ " <span class=\"key\">\"client\"</span>: <span class=\"val\">\"%s\"</span>,\n"
+ " <span class=\"key\">\"secret\"</span>: <span class=\"secret\">\"%s\"</span>,\n"
+ " <span class=\"key\">\"event\"</span>: <span class=\"val\">\"begin_backup\"</span>\n"
+ " <span class=\"val\">}'</span><button onclick=\"copy(this)\">Copy</button></pre>\n"
+ " </div>\n"
+ " <div>\n"
+ " <h4>End backup</h4>\n"
+ " <pre><span class=\"cmd\">curl</span> <span class=\"flag\">-X POST</span> <span class=\"url\">http://%s/api/report</span> \\\n"
+ " <span class=\"flag\">-H</span> <span class=\"val\">\"Content-Type: application/json\"</span> \\\n"
+ " <span class=\"flag\">-d</span> <span class=\"val\">'{</span>\n"
+ " <span class=\"key\">\"client\"</span>: <span class=\"val\">\"%s\"</span>,\n"
+ " <span class=\"key\">\"secret\"</span>: <span class=\"secret\">\"%s\"</span>,\n"
+ " <span class=\"key\">\"event\"</span>: <span class=\"val\">\"end_backup\"</span>\n"
+ " <span class=\"val\">}'</span><button onclick=\"copy(this)\">Copy</button></pre>\n"
+ " </div>\n"
+ " </div>\n"
+ " </details>\n",
+ client_result->secret,
+ host ? host : "localhost",
+ client_result->name,
+ client_result->secret,
+ host ? host : "localhost",
+ client_result->name,
+ client_result->secret
+ );
+
+ fprintf(f, " <h4>Recent Backups</h4>\n");
+
+ if(backups_result->backups_len == 0) {
+ fprintf(f, " <p style=\"color:var(--dim)\">No backups recorded.</p>\n");
+ } else {
+ fprintf(f, " <div class=\"backups\">\n");
+
+ for (int i = 0; i < backups_result->backups_len; i++) {
+ const char *badge_class;
+ const char *badge_text;
+ if (strcmp(backups_result->backups[i].status, "RUNNING") == 0) {
+ badge_class = "run";
+ badge_text = "Running";
+ } else if (strcmp(backups_result->backups[i].status, "INTERRUPTED") == 0) {
+ badge_class = "fail";
+ badge_text = "Failed";
+ } else {
+ badge_class = "ok";
+ badge_text = "Done";
+ }
+
+ struct timespec duration = time_sub(&backups_result->backups[i].completed, &backups_result->backups[i].started);
+ fprintf(f,
+ " <div><time>%s</time><span>%s</span><b class=\"badge %s\">%s</b></div>\n",
+ format_time(backups_result->backups[i].started).buf,
+ format_duration(duration).buf,
+ badge_class,
+ badge_text
+ );
+ }
+
+ fprintf(f, " </div>\n");
+ }
+
+ fprintf(f,
+ " </main>\n"
+ "</body>\n"
+ "</html>\n");
+
+ fclose(f);
+ *out_len = buf_len;
+ return buf;
+}
+
+char *html_render_login_page(const char *error_msg, size_t *out_len) {
+ char *buf = NULL;
+ size_t buf_len = 0;
+ FILE *f = open_memstream(&buf, &buf_len);
+ if (!f) return NULL;
+
+ fprintf(f,
+ "<!DOCTYPE html>\n"
+ "<html lang=\"en\">\n"
+ "<head>\n"
+ " <meta charset=\"UTF-8\">\n"
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
+ " <title>BorgFlag - Login</title>\n");
+ emit_stylesheet(f);
+ fprintf(f,
+ "</head>\n"
+ "<body>\n"
+ " <div class=\"login-page\">\n"
+ " <form method=\"POST\" action=\"/login\">\n"
+ " <h1>BorgFlag</h1>\n");
+
+ if (error_msg) {
+ fprintf(f, " <div class=\"error\">%s</div>\n", error_msg);
+ }
+
+ fprintf(f,
+ " <div>\n"
+ " <label>Username</label>\n"
+ " <input type=\"text\" name=\"username\" autocomplete=\"username\" required>\n"
+ " </div>\n"
+ " <div>\n"
+ " <label>Password</label>\n"
+ " <input type=\"password\" name=\"password\" autocomplete=\"current-password\" required>\n"
+ " </div>\n"
+ " <button type=\"submit\">Sign In</button>\n"
+ " </form>\n"
+ " </div>\n"
+ "</body>\n"
+ "</html>\n");
+
+ fclose(f);
+ *out_len = buf_len;
+ return buf;
+}