git-trace

romele / git-trace public

62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 10m ago

src/web.c

#include "web.h"

#include "auth.h"
#include "ci.h"
#include "collab.h"
#include "crypto.h"
#include "patchset.h"
#include "ratelimit.h"
#include "ssh.h"
#include "wcommit.h"
#include "gitx.h"
#include "highlight.h"
#include "markdown.h"
#include "mirror.h"
#include "repo.h"
#include "util.h"

#include <ctype.h>
#include <dirent.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>

#ifndef GT_VERSION
#define GT_VERSION "v0.0-dev"
#endif

static const char CSS[] =
    "*{box-sizing:border-box}body{font:14px/1.5 system-ui,sans-serif;"
    "margin:0;color:#171717;background:#fff}"
    "header{background:#fff;color:#171717;padding:.6rem 1rem;"
    "border-bottom:1px solid #e5e7eb;display:flex;align-items:center}"
    "header a{color:#171717;text-decoration:none;font-weight:700;"
    "display:inline-flex;align-items:center;gap:.45rem}"
    "header nav{margin-left:auto;display:flex;gap:.9rem;align-items:center}"
    "header nav a{font-weight:500;font-size:.92em;color:#4b5563}"
    "header nav a:hover{color:#171717;text-decoration:underline}"
    "header nav .who{font-weight:600;color:#171717}"
    "main{width:85%;max-width:1280px;margin:1.5rem auto;padding:0 1rem}"
    "a{color:#2563eb}a:hover{text-decoration:underline}"
    "table{border-collapse:collapse;width:100%}"
    "td,th{padding:.35rem .6rem;border-bottom:1px solid #e5e7eb;text-align:left}"
    "pre,code{font:13px/1.45 ui-monospace,Consolas,monospace}"
    "code{background:#f3f4f6;border-radius:.3em;padding:.1em .3em}"
    "pre{background:#f6f8fa;border:1px solid #e5e7eb;border-radius:.4em;"
    "padding:.8rem;overflow:auto}pre code{background:none;padding:0}"
    /* syntax highlighting — GitHub-light palette */
    ".hl-c{color:#6a737d;font-style:italic}.hl-s{color:#032f62}"
    ".hl-k{color:#d73a49;font-weight:600}.hl-n{color:#005cc5}"
    ".hl-p{color:#6f42c1}"
    "input:not([type]),input[type=text],input[type=password],"
    "input[type=email],input[type=search],select,textarea{font:inherit;"
    "padding:.32em .55em;border:1px solid #d1d5db;border-radius:.3em;"
    "background:#fff;color:#171717}"
    "input[type=submit],button{font:inherit;background:#2563eb;color:#fff;"
    "border:1px solid #1d4ed8;border-radius:.3em;padding:.32em .85em;"
    "cursor:pointer}"
    "a:focus-visible,button:focus-visible,input:focus-visible,"
    "select:focus-visible,textarea:focus-visible{outline:2px solid #2563eb;"
    "outline-offset:1px}"
    ".muted{color:#6b7280}.crumbs a{text-decoration:none}"
    ".mono{font-family:ui-monospace,monospace;font-size:.9em}"
    ".ic{width:15px;height:15px;vertical-align:-2.5px}"
    ".badge{display:inline-flex;align-items:center;gap:.3em;padding:.05em .55em;"
    "border-radius:1em;font-size:.78em;font-weight:600;margin-left:.4em;"
    "border:1px solid transparent}"
    ".badge .ic{width:11px;height:11px;vertical-align:0}"
    ".bpub{background:#dafbe1;color:#116329;border-color:#aceebb}"
    ".bpriv{background:#fff8c5;color:#7d4e00;border-color:#f0d88c}"
    ".btn{display:inline-block;background:#2563eb;color:#fff;"
    "padding:.35em .9em;border-radius:.3em;text-decoration:none}"
    ".btn:hover{background:#1d4ed8;text-decoration:none}"
    ".btn2{display:inline-flex;align-items:center;gap:.4em;background:#fff;"
    "border:1px solid #d1d5db;border-radius:.35em;color:#374151;"
    "padding:.32em .85em;text-decoration:none;font-size:.92em}"
    ".btn2:hover{background:#f3f4f6;text-decoration:none}"
    ".empty{text-align:center;color:#9ca3af;padding:2.2rem 1rem}"
    ".empty svg{display:block;margin:0 auto .6rem;opacity:.35}"
    ".qwrap{margin:.4rem 0}.qbar{display:inline-block;width:180px;height:8px;"
    "background:#e5e7eb;border-radius:4px;vertical-align:middle;margin-right:.5em}"
    ".qbar>div{height:100%;background:#2563eb;border-radius:4px}"
    ".dash{display:flex;gap:2rem}.dash>div{min-width:0;flex:1}"
    ".feed{font-size:.9em}"
    ".sha{font-family:ui-monospace,monospace;font-size:.9em}"
    ".d-add{background:#dafbe1}.d-del{background:#ffebe9}"
    ".d-hunk{background:#ddf4ff;color:#0550ae}.d-meta{color:#6b7280}"
    ".repo{display:flex;gap:1.4rem;align-items:flex-start}"
    ".repo aside{flex:0 0 165px;display:flex;flex-direction:column;gap:.8rem}"
    ".repo .work{flex:1;min-width:0}"
    ".repo nav{display:flex;flex-direction:column;gap:.15rem}"
    ".repo nav a{display:flex;align-items:center;gap:.45em;padding:.38rem .55rem;"
    "border-radius:.35em;text-decoration:none;color:#4b5563;"
    "border-left:3px solid transparent}"
    ".repo nav a.on{background:#eff6ff;color:#1d4ed8;font-weight:600;"
    "border-left-color:#2563eb}"
    ".sidestat{border:1px solid #e5e7eb;border-radius:.4em;padding:.55rem .7rem;"
    "font-size:.85em;color:#4b5563}"
    ".sidestat .qbar{display:block;width:100%;margin:.3rem 0 .15rem}"
    ".panel{background:#fff;border:1px solid #e5e7eb;border-radius:.45em;"
    "padding:.8rem 1rem;margin-bottom:1rem}"
    ".panel h4{margin:0 0 .55rem;font-size:.8em;color:#6b7280;"
    "text-transform:uppercase;letter-spacing:.05em;font-weight:600}"
    /* landing hero */
    ".hero{text-align:center;padding:2.2rem 0 1.2rem;color:#171717}"
    ".hero>svg{color:#2563eb}"
    ".hero h1{font-size:2.1rem;margin:.4rem 0 .1rem;letter-spacing:-.02em}"
    ".hero .ver{font-size:.85rem;font-weight:600;vertical-align:.45em;"
    "background:#eff6ff;border:1px solid #bfdbfe;border-radius:1em;"
    "padding:.05em .6em;margin-left:.5em;color:#1d4ed8}"
    ".hero p.tag{color:#4b5563;margin:.35rem auto .95rem;font-size:1.02em;"
    "max-width:34em}"
    ".hero .btn,.hero .btn2{font-size:1.02em;margin:0 .3em}"
    /* landing metrics + featured repos + activity */
    ".stat-grid{display:flex;gap:1rem;justify-content:center;"
    "margin:1.1rem auto 1.4rem;max-width:640px}"
    ".stat-grid>div{flex:1;border:1px solid #e5e7eb;border-radius:.6em;"
    "padding:.85rem .4rem;text-align:center}"
    ".stat-grid b{display:block;font-size:1.6rem;line-height:1.25}"
    ".stat-grid span{color:#6b7280;font-size:.82em}"
    ".feat{display:grid;grid-template-columns:repeat(auto-fill,"
    "minmax(230px,1fr));gap:.7rem;margin:.7rem 0 1.4rem}"
    ".feat a{display:block;border:1px solid #e5e7eb;border-radius:.5em;"
    "padding:.6rem .85rem;color:#171717}"
    ".feat a:hover{border-color:#93c5fd;text-decoration:none}"
    ".feat .fn{display:block;font-weight:600;color:#2563eb}"
    ".feat .fd{display:block;color:#6b7280;font-size:.85em;overflow:hidden;"
    "text-overflow:ellipsis;white-space:nowrap}"
    ".act{list-style:none;padding:0;margin:.3rem 0 1.4rem;font-size:.92em}"
    ".act li{padding:.18rem 0;color:#4b5563}"
    /* commit timeline — the "trace" */
    ".tl{list-style:none;margin:.5rem 0;padding:0}"
    ".tl li{position:relative;padding:.1rem 0 .95rem 1.4rem}"
    ".tl li::before{content:'';position:absolute;left:0;top:.5rem;"
    "width:9px;height:9px;border-radius:50%;background:#2563eb}"
    ".tl li::after{content:'';position:absolute;left:4px;top:1.35rem;"
    "bottom:0;width:2px;background:#e5e7eb}"
    ".tl li:last-child::after{display:none}"
    ".tl .who{color:#6b7280;font-size:.85em;margin-left:.5em}"
    ".tl .tlt{font-weight:600}"
    ".tl .tlm{color:#6b7280;font-size:.85em;margin-top:.05rem}"
    ".tlm .sha{color:#94a3b8}"
    /* trace graph: the commit DAG as SVG lanes, one row per commit */
    ".tg{display:flex;align-items:flex-start}"
    ".tg svg{flex:none;display:block;margin-right:.45rem}"
    ".tg .tgt{height:38px;overflow:hidden;min-width:0;padding-top:.15rem}"
    ".tg .tgt .tlt{font-weight:600;white-space:nowrap;overflow:hidden;"
    "text-overflow:ellipsis;line-height:1.3}"
    ".tg .tgt .tlm{color:#6b7280;font-size:.85em;white-space:nowrap;"
    "overflow:hidden;text-overflow:ellipsis}"
    ".repostat{color:#4b5563;font-size:.9em;margin:-.35rem 0 1.1rem}"
    ".repostat b{color:#171717;font-weight:600}"
    /* mini live trace inside the landing hero */
    ".minitrace{max-width:300px;margin:1.5rem auto .2rem;text-align:left}"
    ".minitrace .tl{margin:0}"
    ".minitrace .tl li{padding:.05rem 0 .6rem 1.4rem;font-size:.92em}"
    ".minitrace .tl li::before{top:.42rem}"
    ".minitrace .tl li::after{top:1.25rem}"
    ".mtcap{text-align:center;color:#6b7280;font-size:.85em;margin:.5rem 0 0}"
    /* rendered README under the file list */
    ".readme{border:1px solid #e5e7eb;border-radius:.5em;margin:1.1rem 0;"
    "background:#fff}"
    ".readme>.rdh{padding:.45rem .9rem;border-bottom:1px solid #e5e7eb;"
    "color:#6b7280;font-size:.83em}"
    /* the README is a document: measure + rhythm like GitHub's */
    ".rdb{padding:.4rem 1.4rem 1.2rem;max-width:56em}"
    ".rdb p{margin:.75em 0;line-height:1.65}"
    ".rdb h1,.rdb h2{border-bottom:1px solid #e5e7eb;padding-bottom:.25em}"
    ".rdb h1{font-size:1.65em;margin:.7em 0 .4em}"
    ".rdb h2{font-size:1.3em;margin:1em 0 .4em}"
    ".rdb h3{font-size:1.12em;margin:1em 0 .35em}"
    ".rdb h4,.rdb h5,.rdb h6{font-size:1em;margin:1em 0 .3em}"
    ".rdb ul,.rdb ol{padding-left:1.6em;margin:.6em 0}"
    ".rdb li{margin:.15em 0}"
    ".rdb pre{margin:.7em 0}"
    ".rdb hr{border:none;border-top:1px solid #e5e7eb;margin:1.2rem 0}"
    ".rdb blockquote{border-left:3px solid #d1d5db;margin:.7em 0;"
    "padding:.05em 1em;color:#6b7280}"
    /* clone URL: a readonly input beats a copy button under zero-JS —
     * one click selects the whole command */
    ".clonerow{display:flex;gap:.6rem;align-items:center;margin:.32rem 0}"
    ".proto{flex:0 0 3em;font-size:.78em;font-weight:700;color:#6b7280;"
    "text-transform:uppercase}"
    ".copyin{font:13px/1.4 ui-monospace,Consolas,monospace;flex:1;min-width:0;"
    "background:#f6f8fa;border:1px solid #e5e7eb;border-radius:.3em;"
    "padding:.32em .6em;color:#171717}"
    /* dark mode: same layout, slate palette — follows the OS
     * preference, zero JS, zero toggles */
    "@media (prefers-color-scheme:dark){"
    "body{background:#0f172a;color:#e2e8f0}"
    "header{background:#0f172a;color:#e2e8f0;border-bottom-color:#334155}"
    "header a{color:#e2e8f0}"
    "header nav a{color:#94a3b8}"
    "header nav a:hover{color:#e2e8f0}"
    "header nav .who{color:#e2e8f0}"
    "a{color:#60a5fa}"
    "td,th{border-bottom-color:#334155}"
    "code{background:#1e293b;color:#e2e8f0}"
    "pre{background:#0b1220;border-color:#334155}"
    ".hl-c{color:#64748b}.hl-s{color:#7dd3fc}.hl-k{color:#f472b6}"
    ".hl-n{color:#fbbf24}.hl-p{color:#c4b5fd}"
    "input:not([type]),input[type=text],input[type=password],"
    "input[type=email],input[type=search],select,textarea{background:#0b1220;"
    "color:#e2e8f0;border-color:#334155}"
    ".muted{color:#94a3b8}"
    ".bpub{background:#052e16;color:#4ade80;border-color:#166534}"
    ".bpriv{background:#422006;color:#fbbf24;border-color:#a16207}"
    ".btn{background:#3b82f6}.btn:hover{background:#2563eb}"
    ".btn2{background:#1e293b;border-color:#334155;color:#e2e8f0}"
    ".btn2:hover{background:#334155}"
    ".empty{color:#64748b}"
    ".qbar{background:#334155}.qbar>div{background:#3b82f6}"
    ".d-add{background:#052e16}.d-del{background:#450a0a}"
    ".d-hunk{background:#172554;color:#93c5fd}.d-meta{color:#94a3b8}"
    ".repo nav a{color:#94a3b8}"
    ".repo nav a.on{background:#1e293b;color:#93c5fd;"
    "border-left-color:#3b82f6}"
    ".sidestat{border-color:#334155;color:#94a3b8}"
    ".panel{background:#0f172a;border-color:#334155}"
    ".panel h4{color:#94a3b8}"
    ".hero{color:#e2e8f0}.hero>svg{color:#3b82f6}"
    ".hero .ver{background:#172554;border-color:#1e3a8a;color:#93c5fd}"
    ".hero p.tag{color:#94a3b8}"
    ".stat-grid>div{border-color:#334155}"
    ".stat-grid span{color:#94a3b8}"
    ".feat a{border-color:#334155;color:#e2e8f0}"
    ".feat a:hover{border-color:#3b82f6}"
    ".feat .fn{color:#60a5fa}.feat .fd{color:#94a3b8}"
    ".act li{color:#94a3b8}"
    ".tl li::before{background:#3b82f6}"
    ".tl li::after{background:#334155}"
    ".tl .who{color:#94a3b8}"
    ".tl .tlm{color:#94a3b8}.tlm .sha{color:#64748b}"
    ".tg .tgt .tlm{color:#94a3b8}"
    ".repostat{color:#94a3b8}.repostat b{color:#e2e8f0}"
    ".readme{border-color:#334155;background:#0f172a}"
    ".readme>.rdh{border-bottom-color:#334155;color:#94a3b8}"
    ".rdb h1,.rdb h2{border-bottom-color:#334155}"
    ".rdb hr{border-top-color:#334155}"
    ".rdb blockquote{border-left-color:#334155;color:#94a3b8}"
    ".mtcap{color:#94a3b8}"
    ".proto{color:#94a3b8}"
    ".copyin{background:#0b1220;border-color:#334155;color:#e2e8f0}"
    "a:focus-visible,button:focus-visible,input:focus-visible,"
    "select:focus-visible,textarea:focus-visible{outline-color:#3b82f6}}"
    /* narrow screens: sidebar collapses into a horizontal tab row
     * under the repo header, storage box goes full-width below it */
    "@media (max-width:768px){"
    "main{width:95%}"
    ".repo{flex-direction:column;gap:.6rem}"
    ".repo aside{flex:none;width:100%;gap:.4rem}"
    ".repo nav{flex-direction:row;flex-wrap:wrap}"
    ".repo nav a{border-left:none;border-bottom:2px solid transparent}"
    ".repo nav a.on{border-bottom-color:#2563eb}"
    ".sidestat{max-width:240px}"
    ".stat-grid{flex-direction:column;gap:.6rem}"
    ".dash{flex-direction:column}}";


const char *gt_web_css(void) { return CSS; }

static void size_fmt(struct buf *b, long long kb);
static void rel_time(struct buf *b, long t);
static long repo_mtime(const char *dir);

static const char *tab_icon(const char *key)
{
    if (!strcmp(key, "tree"))     return I_FOLDER;
    if (!strcmp(key, "log"))      return I_COMMIT;
    if (!strcmp(key, "refs"))     return I_TAG;
    if (!strcmp(key, "issues"))   return I_ISSUE;
    if (!strcmp(key, "changes"))  return I_MERGE;
    if (!strcmp(key, "activity")) return I_PULSE;
    if (!strcmp(key, "search"))   return I_SEARCH;
    if (!strcmp(key, "feed"))     return I_FEED;
    if (!strcmp(key, "settings")) return I_SLIDER;
    return "";
}

void gt_page_top(struct buf *b, const char *title,
                 const struct request *r)
{
    buf_puts(b, "<!doctype html><html><head><meta charset=utf-8>"
                "<meta name=viewport content='width=device-width,initial-scale=1'>"
                "<title>");
    buf_put_escaped(b, title, strlen(title));
    buf_puts(b, " - git-trace</title><style>");
    buf_puts(b, CSS);
    buf_puts(b, "</style></head><body><header>"
                "<a href='/'>" I_LOGO "git-trace</a><nav>");
    if (r && r->user_id > 0) {
        buf_puts(b, "<a class=who href='/account'>");
        buf_put_escaped(b, r->user_name, strlen(r->user_name));
        buf_puts(b, "</a><a href='/new'>+ new repo</a>"
                    "<a href='/logout'>log out</a>");
    } else {
        buf_puts(b, "<a href='/login'>log in</a>");
        if (r && r->open_reg)
            buf_puts(b, "<a href='/register'>register</a>");
    }
    buf_puts(b, "</nav></header><main>");
}

void gt_page_bottom(struct buf *b)
{
    buf_puts(b, "</main></body></html>");
}

void gt_send_page(struct request *r, struct buf *b)
{
    http_respond(r, 200, "text/html; charset=utf-8", b->data, b->len);
}

/* one tab entry: icon + label, highlighted when `active` matches */
static void tab_link(struct buf *b, const char *o, const char *n,
                     const char *key, const char *label,
                     const char *active)
{
    buf_printf(b, "<a%s href='/%s/%s/%s'>",
               active && strcmp(active, key) == 0 ? " class=on" : "",
               o, n, key);
    buf_puts(b, tab_icon(key));
    buf_puts(b, label);
    buf_puts(b, "</a>");
}

void gt_repo_tabs(struct buf *b, struct app_ctx *app,
                  const char *o, const char *n, int rank,
                  const char *active)
{
    int pub = 1, ci_on = 0;
    long long quota = 0, used = 0;
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT r.is_public, u.quota_kb,"
            " (SELECT COALESCE(SUM(x.size_kb),0) FROM repos x"
            "  WHERE x.owner_id=r.owner_id), r.ci_enabled"
            " FROM repos r"
            " LEFT JOIN users u ON u.id=r.owner_id"
            " WHERE r.name=? AND (u.name=? OR (r.owner_id IS NULL AND ?='_'))",
            -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_text(st, 1, n, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 2, o, -1, SQLITE_TRANSIENT);
        sqlite3_bind_text(st, 3, o, -1, SQLITE_TRANSIENT);
        if (sqlite3_step(st) == SQLITE_ROW) {
            pub = sqlite3_column_int(st, 0);
            if (sqlite3_column_type(st, 1) != SQLITE_NULL) {
                quota = sqlite3_column_int64(st, 1);
                used  = sqlite3_column_int64(st, 2);
            }
            ci_on = sqlite3_column_int(st, 3);
        }
        sqlite3_finalize(st);
    }
    buf_printf(b, "<h2>" I_REPO "<a href='/%s/%s/tree'>%s / %s</a>"
                  " <span class='badge %s'>%s%s</span></h2>",
               o, n, o, n,
               pub ? "bpub" : "bpriv",
               pub ? I_GLOBE : I_LOCK,
               pub ? "public" : "private");
    /* summary bar: activity + state at a glance, straight under the
     * owner/name header on every repo page.  Two fast git calls;
     * an unborn repo just skips the commit count. */
    char rdir[PATH_MAX];
    if (snprintf(rdir, sizeof rdir, "%s/%s/%s.git",
                 app->repos_root, o, n) < (int)sizeof rdir) {
        int ncommit = -1, nbr = 0, ntag = 0;
        struct buf gb;
        buf_init(&gb);
        const char *gargv[] = { "git", "for-each-ref",
                                "--format=%(refname)", NULL };
        if (git_capture(rdir, gargv, &gb) == 0 && gb.data) {
            char *sv = NULL;
            for (char *ln = strtok_r(gb.data, "\n", &sv); ln;
                 ln = strtok_r(NULL, "\n", &sv)) {
                if (starts_with(ln, "refs/heads/"))
                    nbr++;
                else if (starts_with(ln, "refs/tags/"))
                    ntag++;
            }
        }
        buf_free(&gb);
        buf_init(&gb);
        const char *cargv[] = { "git", "rev-list", "--count",
                                "HEAD", NULL };
        if (git_capture(rdir, cargv, &gb) == 0 && gb.data)
            ncommit = atoi(gb.data);
        buf_free(&gb);
        buf_puts(b, "<p class=repostat>");
        if (ncommit >= 0)
            buf_printf(b, "<b>%d</b> commit%s &middot; ",
                       ncommit, ncommit == 1 ? "" : "s");
        buf_printf(b, "<b>%d</b> branch%s &middot; <b>%d</b> tag%s",
                   nbr, nbr == 1 ? "" : "es",
                   ntag, ntag == 1 ? "" : "s");
        /* contributors + top author + repo age: one history walk,
         * capped tracking so a hostile import can't blow memory */
        buf_init(&gb);
        const char *aargv[] = { "git", "log", "--format=%an%x1f%at",
                                "HEAD", NULL };
        if (ncommit > 0 && git_capture(rdir, aargv, &gb) == 0 && gb.data) {
            char names[256][64];
            int cnt[256], nauth = 0;
            long first_at = 0;
            char *sv = NULL;
            for (char *ln = strtok_r(gb.data, "\n", &sv); ln;
                 ln = strtok_r(NULL, "\n", &sv)) {
                char *sep = strchr(ln, '\x1f');
                long at = 0;
                if (sep) {
                    *sep = '\0';
                    at = atol(sep + 1);
                }
                if (at > 0 && (first_at == 0 || at < first_at))
                    first_at = at;
                if (nauth < 256) {
                    int k;
                    for (k = 0; k < nauth; k++)
                        if (strcmp(names[k], ln) == 0)
                            break;
                    if (k == nauth) {
                        snprintf(names[k], 64, "%.63s", ln);
                        cnt[k] = 0;
                        nauth++;
                    }
                    cnt[k]++;
                }
            }
            int top = 0;
            for (int k = 1; k < nauth; k++)
                if (cnt[k] > cnt[top])
                    top = k;
            if (nauth > 0) {
                buf_printf(b, " &middot; <b>%d</b> contributor%s",
                           nauth, nauth == 1 ? "" : "s");
                buf_puts(b, " (top: ");
                buf_put_escaped(b, names[top], strlen(names[top]));
                buf_puts(b, ")");
            }
            if (first_at > 0) {
                buf_puts(b, " &middot; first commit ");
                rel_time(b, first_at);
            }
        }
        buf_free(&gb);
        long mt = repo_mtime(rdir);
        if (mt) {
            buf_puts(b, " &middot; last push ");
            rel_time(b, mt);
        }
        buf_puts(b, "</p>");
    }
    /* two-column layout: left menu + storage, right work area.
     * callers must close it with gt_repo_body_end(). */
    buf_puts(b, "<div class=repo><aside><nav>");
    tab_link(b, o, n, "tree",     "files",    active);
    tab_link(b, o, n, "log",      "commits",  active);
    tab_link(b, o, n, "refs",     "refs",     active);
    tab_link(b, o, n, "issues",   "issues",   active);
    tab_link(b, o, n, "changes",  "changes",  active);
    tab_link(b, o, n, "search",   "search",   active);
    tab_link(b, o, n, "feed",     "feed",     active);
    tab_link(b, o, n, "activity", "activity", active);
    if (ci_on)
        tab_link(b, o, n, "ci",     "ci",       active);
    if (rank >= 3)
        tab_link(b, o, n, "settings", "settings", active);
    buf_puts(b, "</nav>");
    /* owner account storage — only for owner/admin eyes */
    if (rank >= 3 && quota > 0) {
        buf_puts(b, "<div class=sidestat>account storage");
        buf_puts(b, "<div class=qbar><div style='width:");
        int pct = (int)(used * 100 / quota);
        buf_printf(b, "%d%%'></div></div>", pct > 100 ? 100 : pct);
        size_fmt(b, used);
        buf_puts(b, " / ");
        size_fmt(b, quota);
        buf_puts(b, "</div>");
    }
    buf_puts(b, "</aside><div class=work>");
}

void gt_repo_body_end(struct buf *b)
{
    buf_puts(b, "</div></div>");
}

/* default ref for views: the repo's HEAD branch, else literal HEAD */
static char *pick_ref(struct request *r, const char *repo_dir)
{
    char ref[256];
    if (req_query(r, "ref", ref, sizeof ref) == 0) {
        if (!git_ref_ok(ref))
            return NULL;
        return xstrdup(ref);
    }
    char *d = git_default_branch(repo_dir);
    return d ? d : xstrdup("HEAD");
}

/* ---------------- index ---------------- */

/* Preloaded effective-permission + metadata table: one query per
 * index render instead of repo_perm's two queries per repository. */
struct perm_row {
    char      owner[64], name[64], perm;
    int       pub;                  /* is_public */
    long long size_kb;
};

static const struct perm_row *perm_find(const struct perm_row *t, int n,
                                        const char *o, const char *name)
{
    for (int i = 0; i < n; i++)
        if (strcmp(t[i].owner, o) == 0 && strcmp(t[i].name, name) == 0)
            return &t[i];
    return NULL;
}

static char perm_of(const struct perm_row *t, int n,
                    const char *o, const char *name)
{
    const struct perm_row *r = perm_find(t, n, o, name);
    return r ? r->perm : 'r';       /* unregistered repo: public read */
}

static struct perm_row *perms_load(sqlite3 *db, int uid, int *nout)
{
    struct perm_row *t = NULL;
    int n = 0;
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(db,
            "SELECT COALESCE(u.name,'_'), r.name,"
            " CASE WHEN ?1>0 AND r.owner_id=?1 THEN 'a'"
            "  ELSE COALESCE("
            "   (SELECT a.role FROM repo_acl a"
            "    WHERE a.repo_id=r.id AND a.user_id=?1),"
            "   CASE WHEN r.is_public THEN 'r' ELSE 'n' END) END,"
            " r.is_public, r.size_kb"
            " FROM repos r LEFT JOIN users u ON u.id=r.owner_id",
            -1, &st, NULL) != SQLITE_OK)
        return NULL;
    sqlite3_bind_int(st, 1, uid);
    while (sqlite3_step(st) == SQLITE_ROW) {
        const char *po = (const char *)sqlite3_column_text(st, 0);
        const char *pn = (const char *)sqlite3_column_text(st, 1);
        const char *pp = (const char *)sqlite3_column_text(st, 2);
        if (!po || !pn || !pp)
            continue;
        size_t lo = strlen(po), ln = strlen(pn);
        if (lo > 63 || ln > 63)
            continue;
        struct perm_row *nt = realloc(t, (size_t)(n + 1) * sizeof *t);
        if (!nt)
            break;
        t = nt;
        memcpy(t[n].owner, po, lo + 1);
        memcpy(t[n].name, pn, ln + 1);
        t[n].perm = pp[0];
        t[n].pub = sqlite3_column_int(st, 3);
        t[n].size_kb = sqlite3_column_int64(st, 4);
        n++;
    }
    sqlite3_finalize(st);
    *nout = n;
    return t;
}

static void size_fmt(struct buf *b, long long kb)
{
    if (kb >= 1048576)
        buf_printf(b, "%.1f GiB", kb / 1048576.0);
    else if (kb >= 1024)
        buf_printf(b, "%lld MiB", kb / 1024);
    else
        buf_printf(b, "%lld KiB", kb);
}

/* "3h ago" / "12d ago" — the index and the commit timeline both
 * speak in relative time; the exact date stays in the title attr */
static void rel_time(struct buf *b, long t)
{
    long d = (long)time(NULL) - t;
    if (d < 0)
        d = 0;
    if (d < 90)
        buf_printf(b, "%lds ago", d);
    else if (d < 5400)
        buf_printf(b, "%ldm ago", d / 60);
    else if (d < 129600)
        buf_printf(b, "%ldh ago", d / 3600);
    else if (d < 5184000)
        buf_printf(b, "%ldd ago", d / 86400);
    else if (d < 31622400)
        buf_printf(b, "%ldmo ago", d / 2592000);
    else
        buf_printf(b, "%ldy ago", d / 31536000);
}

static void storage_bar(struct buf *b, long long used, long long quota)
{
    buf_puts(b, "<div class=qwrap><div class=qbar><div style='width:");
    int pct = quota > 0 ? (int)(used * 100 / quota) : 0;
    if (pct > 100)
        pct = 100;
    buf_printf(b, "%d%%'></div></div> ", pct);
    size_fmt(b, used);
    if (quota > 0) {
        buf_puts(b, " / ");
        size_fmt(b, quota);
    }
    buf_puts(b, " storage</div>");
}

/* one index row: repo identity + description + last-touch time */
struct idx_row {
    char      owner[64], name[64], desc[201];
    long long size_kb;
    int       pub;
    long      mtime;
};

/* "recent activity" without spawning git: max mtime over the files a
 * push or merge is guaranteed to touch — loose refs, packed-refs and
 * the objects dir (a new commit always lands there). */
static long repo_mtime(const char *dir)
{
    static const char *const probe[] = { "packed-refs", "HEAD",
                                         "objects", "info/refs" };
    long m = 0;
    for (size_t i = 0; i < sizeof probe / sizeof probe[0]; i++) {
        char p[PATH_MAX];
        struct stat sb;
        if (snprintf(p, sizeof p, "%s/%s", dir, probe[i])
                < (int)sizeof p && stat(p, &sb) == 0 && sb.st_mtime > m)
            m = (long)sb.st_mtime;
    }
    static const char *const refdirs[] = { "refs/heads", "refs/tags" };
    for (size_t i = 0; i < sizeof refdirs / sizeof refdirs[0]; i++) {
        char p[PATH_MAX];
        if (snprintf(p, sizeof p, "%s/%s", dir, refdirs[i])
                >= (int)sizeof p)
            continue;
        DIR *d = opendir(p);
        if (!d)
            continue;
        struct dirent *e;
        int scanned = 0;
        while ((e = readdir(d)) && scanned < 512) {
            if (e->d_name[0] == '.')
                continue;
            char rp[PATH_MAX];
            struct stat sb;
            scanned++;
            if (snprintf(rp, sizeof rp, "%s/%s", p, e->d_name)
                    < (int)sizeof rp &&
                stat(rp, &sb) == 0 && sb.st_mtime > m)
                m = (long)sb.st_mtime;
        }
        closedir(d);
    }
    return m;
}

static int idx_cmp(const void *a, const void *b)
{
    long d = ((const struct idx_row *)b)->mtime
           - ((const struct idx_row *)a)->mtime;
    return d > 0 ? 1 : d < 0 ? -1 : 0;
}

static int idx_match(const struct idx_row *w, const char *q)
{
    if (!q[0])
        return 1;
    char on[130];
    snprintf(on, sizeof on, "%s/%s", w->owner, w->name);
    return strcasestr(on, q) || strcasestr(w->desc, q);
}

void web_index(void *ctx, struct request *r)
{
    struct app_ctx *app = ctx;
    int nperms = 0;
    struct perm_row *perms = perms_load(app->db, r->user_id, &nperms);

    /* account storage for the quota bar */
    long long quota = 0, used = 0;
    if (r->user_id > 0) {
        sqlite3_stmt *st;
        if (sqlite3_prepare_v2(app->db,
                "SELECT quota_kb FROM users WHERE id=?",
                -1, &st, NULL) == SQLITE_OK) {
            sqlite3_bind_int(st, 1, r->user_id);
            if (sqlite3_step(st) == SQLITE_ROW)
                quota = sqlite3_column_int64(st, 0);
            sqlite3_finalize(st);
        }
        if (sqlite3_prepare_v2(app->db,
                "SELECT COALESCE(SUM(size_kb),0) FROM repos"
                " WHERE owner_id=?", -1, &st, NULL) == SQLITE_OK) {
            sqlite3_bind_int(st, 1, r->user_id);
            if (sqlite3_step(st) == SQLITE_ROW)
                used = sqlite3_column_int64(st, 0);
            sqlite3_finalize(st);
        }
    }

    char q[128] = "";
    req_query(r, "q", q, sizeof q);

    /* collect first, sort by recent activity, then render */
    struct idx_row *rows = NULL;
    int nrows = 0;
    long long total_kb = 0;
    DIR *root = opendir(app->repos_root);
    if (root) {
        struct dirent *oe;
        while ((oe = readdir(root))) {
            if (!owner_ok(oe->d_name))
                continue;
            char odir[PATH_MAX];
            if (snprintf(odir, sizeof odir, "%s/%s",
                         app->repos_root, oe->d_name) >= (int)sizeof odir)
                continue;
            DIR *od = opendir(odir);
            if (!od)
                continue;
            struct dirent *re_;
            while ((re_ = readdir(od))) {
                if (!ends_with(re_->d_name, ".git"))
                    continue;
                struct idx_row w;
                size_t nl = strlen(re_->d_name) - 4;
                if (nl == 0 || nl >= sizeof w.name)
                    continue;
                memcpy(w.name, re_->d_name, nl);
                w.name[nl] = '\0';
                if (!name_ok(w.name) ||
                    strlen(oe->d_name) >= sizeof w.owner)
                    continue;
                strcpy(w.owner, oe->d_name);
                /* private repos are invisible without read permission */
                if (perm_rank(perm_of(perms, nperms,
                                      w.owner, w.name)) < 1)
                    continue;

                w.desc[0] = '\0';
                w.mtime = 0;
                char rdir[PATH_MAX];
                int rl = snprintf(rdir, sizeof rdir, "%s/%s",
                                  odir, re_->d_name);
                if (rl > 0 && rl < (int)sizeof rdir) {
                    w.mtime = repo_mtime(rdir);
                    /* description lives inside the bare repo */
                    char dpath[PATH_MAX];
                    if ((size_t)rl + 13 <= sizeof dpath) {
                        memcpy(dpath, rdir, (size_t)rl);
                        memcpy(dpath + rl, "/description", 13);
                        FILE *f = fopen(dpath, "r");
                        if (f) {
                            if (fgets(w.desc, sizeof w.desc, f))
                                w.desc[strcspn(w.desc, "\r\n")] = '\0';
                            fclose(f);
                        }
                    }
                }
                const struct perm_row *meta =
                    perm_find(perms, nperms, w.owner, w.name);
                w.pub = !meta || meta->pub;
                w.size_kb = meta ? meta->size_kb : 0;
                if (!idx_match(&w, q))
                    continue;
                struct idx_row *nr =
                    realloc(rows, (size_t)(nrows + 1) * sizeof *nr);
                if (!nr)
                    break;
                rows = nr;
                rows[nrows++] = w;
                total_kb += w.size_kb;
            }
            closedir(od);
        }
        closedir(root);
    }
    qsort(rows, (size_t)nrows, sizeof *rows, idx_cmp);

    /* instance-wide metrics */
    int nusers = 0;
    {
        sqlite3_stmt *st;
        if (sqlite3_prepare_v2(app->db, "SELECT COUNT(*) FROM users",
                -1, &st, NULL) == SQLITE_OK) {
            if (sqlite3_step(st) == SQLITE_ROW)
                nusers = sqlite3_column_int(st, 0);
            sqlite3_finalize(st);
        }
    }

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, "repositories", r);
    if (r->user_id == 0) {
        /* public landing: brand + pitch + metrics, marketing-style */
        buf_puts(&b, "<div class=hero>" I_LOGO_LG "<h1>");
        gt_esc(&b, app->site_name);
        buf_printf(&b, " <span class=ver>%s</span></h1>"
                       "<p class=tag><b>trace every change.</b>"
                       " lightweight git hosting focused on history,"
                       " clarity and speed — without the complexity"
                       " of large devops platforms.</p>"
                       "<p><a class=btn href='#repos'>explore"
                       " repositories</a> ", GT_VERSION);
        if (r->open_reg)
            buf_puts(&b, "<a class=btn2 href='/register'>create"
                         " account</a>");
        else
            buf_puts(&b, "<a class=btn2 href='/login'>log in</a>");
        buf_puts(&b, "</p>");

        /* identity element: a live "trace" — the newest commits on
         * the most recently pushed public repo, rendered with the
         * same timeline markup the log view uses */
        for (int i = 0; i < nrows; i++) {
            if (!rows[i].pub)
                continue;
            char rdir[PATH_MAX];
            if (snprintf(rdir, sizeof rdir, "%s/%s/%s.git",
                         app->repos_root, rows[i].owner, rows[i].name)
                    >= (int)sizeof rdir)
                break;
            struct buf lg;
            buf_init(&lg);
            const char *largv[] = { "git", "log", "-n", "5",
                "--format=%H%x1f%s", "HEAD", "--", NULL };
            if (git_capture(rdir, largv, &lg) != 0 || !lg.data) {
                buf_free(&lg);
                break;
            }
            buf_puts(&b, "<div class=minitrace><ul class=tl>");
            char *sv = NULL;
            for (char *ln = strtok_r(lg.data, "\n", &sv); ln;
                 ln = strtok_r(NULL, "\n", &sv)) {
                char *sha = strtok(ln, "\x1f");
                char *sub = strtok(NULL, "\x1f");
                if (!sha)
                    continue;
                buf_printf(&b, "<li><a class=sha"
                               " href='/%s/%s/commit/%s'>%.10s</a> ",
                           rows[i].owner, rows[i].name, sha, sha);
                buf_put_escaped(&b, sub ? sub : "",
                                sub ? strlen(sub) : 0);
                buf_puts(&b, "</li>");
            }
            buf_puts(&b, "</ul><p class=mtcap>the trace — live commits"
                         " on ");
            buf_printf(&b, "<a href='/%s/%s/log'>%s / %s</a></p></div>",
                       rows[i].owner, rows[i].name,
                       rows[i].owner, rows[i].name);
            buf_free(&lg);
            break;
        }
        buf_puts(&b, "</div>"
                    "<div class=stat-grid><div><b>");
        buf_printf(&b, "%d</b><span>repositories</span></div>"
                       "<div><b>%d</b><span>users</span></div>"
                       "<div><b>", nrows, nusers);
        size_fmt(&b, total_kb);
        buf_puts(&b, "</b><span>storage</span></div>");
        /* a real number beats a marketing claim: the daemon's own
         * resident set, read straight from procfs */
        long rss_kb = 0;
        FILE *pf = fopen("/proc/self/status", "r");
        if (pf) {
            char line[128];
            while (fgets(line, sizeof line, pf))
                if (starts_with(line, "VmRSS:")) {
                    rss_kb = atol(line + 6);
                    break;
                }
            fclose(pf);
        }
        if (rss_kb > 0) {
            buf_puts(&b, "<div><b>");
            size_fmt(&b, rss_kb);
            buf_puts(&b, "</b><span>daemon rss</span></div>");
        }
        buf_puts(&b, "</div>");

        /* instance pulse: latest events on public repos only, so the
         * landing never leaks a private repo's existence */
        sqlite3_stmt *ast;
        if (sqlite3_prepare_v2(app->db,
                "SELECT a.ts, COALESCE(a.actor,'(anon)'), a.action,"
                " COALESCE(ou.name||'/'||rp.name,'-')"
                " FROM audit_log a JOIN repos rp ON rp.id=a.repo_id"
                " LEFT JOIN users ou ON ou.id=rp.owner_id"
                " WHERE rp.is_public=1"
                " ORDER BY a.id DESC LIMIT 8",
                -1, &ast, NULL) == SQLITE_OK) {
            int got = 0;
            while (sqlite3_step(ast) == SQLITE_ROW) {
                if (!got) {
                    buf_puts(&b, "<h3>recent activity</h3><ul class=act>");
                    got = 1;
                }
                buf_puts(&b, "<li><span class=muted>");
                rel_time(&b, (long)sqlite3_column_int64(ast, 0));
                buf_puts(&b, "</span> ");
                gt_esc(&b, (const char *)sqlite3_column_text(ast, 1));
                buf_puts(&b, " ");
                gt_esc(&b, (const char *)sqlite3_column_text(ast, 2));
                buf_puts(&b, " <b>");
                gt_esc(&b, (const char *)sqlite3_column_text(ast, 3));
                buf_puts(&b, "</b></li>");
            }
            if (got)
                buf_puts(&b, "</ul>");
            sqlite3_finalize(ast);
        }

        /* featured: most recently touched public repos */
        int nf = 0;
        for (int i = 0; i < nrows && nf < 6; i++) {
            if (!rows[i].pub)
                continue;
            if (!nf)
                buf_puts(&b, "<h3>featured repositories</h3>"
                             "<div class=feat>");
            nf++;
            buf_puts(&b, "<a href='/");
            buf_puts(&b, rows[i].owner);
            buf_put(&b, "/", 1);
            buf_puts(&b, rows[i].name);
            buf_puts(&b, "/tree'><span class=fn>");
            buf_put_escaped(&b, rows[i].owner, strlen(rows[i].owner));
            buf_put(&b, " / ", 3);
            buf_put_escaped(&b, rows[i].name, strlen(rows[i].name));
            buf_puts(&b, "</span><span class=fd>");
            buf_put_escaped(&b, rows[i].desc, strlen(rows[i].desc));
            buf_puts(&b, "</span></a>");
        }
        if (nf)
            buf_puts(&b, "</div>");
        buf_puts(&b, "<h2 id=repos>repositories</h2>");
    } else {
        /* signed-in landing: a compact dashboard greeting */
        buf_printf(&b, "<h1>%s &middot; dashboard"
                       " <span class=ver style='font-size:.5em'>%s</span></h1>",
                   app->site_name, GT_VERSION);
        buf_printf(&b, "<p class=muted>%d repo%s &middot; %d user%s"
                       " &middot; ",
                   nrows, nrows == 1 ? "" : "s",
                   nusers, nusers == 1 ? "" : "s");
        size_fmt(&b, total_kb);
        buf_puts(&b, " hosted</p>");
    }
    buf_puts(&b, "<form method=get action='/' class=idxq>"
                 "<input name=q placeholder='search repositories' value='");
    buf_put_escaped(&b, q, strlen(q));
    buf_puts(&b, "'> <button>search</button>");
    if (q[0])
        buf_puts(&b, " <a href='/'>clear</a>");
    buf_puts(&b, "</form>");
    if (r->user_id > 0) {
        storage_bar(&b, used, quota);
        buf_puts(&b, "<p><a class=btn href='/new'>New repository</a></p>");
    }
    buf_puts(&b, "<div class=dash><div>");
    buf_puts(&b, "<table>");

    for (int i = 0; i < nrows; i++) {
        const struct idx_row *w = &rows[i];
        buf_puts(&b, "<tr><td><a href='/");
        buf_puts(&b, w->owner);
        buf_put(&b, "/", 1);
        buf_puts(&b, w->name);
        buf_puts(&b, "/tree'>");
        buf_put_escaped(&b, w->owner, strlen(w->owner));
        buf_put(&b, " / ", 3);
        buf_put_escaped(&b, w->name, strlen(w->name));
        buf_puts(&b, "</a> <span class=badge>");
        buf_puts(&b, w->pub ? "public" : "private");
        buf_puts(&b, "</span></td><td class=muted>");
        buf_put_escaped(&b, w->desc, strlen(w->desc));
        buf_puts(&b, "</td><td class=muted>");
        if (w->size_kb)
            size_fmt(&b, w->size_kb);
        buf_puts(&b, "</td><td class=muted>");
        if (w->mtime) {
            char d[32];
            struct tm tm;
            time_t tt = (time_t)w->mtime;
            gmtime_r(&tt, &tm);
            strftime(d, sizeof d, "%Y-%m-%d", &tm);
            buf_printf(&b, "<span title='%s'>", d);
            rel_time(&b, w->mtime);
            buf_puts(&b, "</span>");
        }
        buf_puts(&b, "</td></tr>");
    }
    free(rows);
    if (!nrows)
        buf_puts(&b, q[0]
            ? "<tr><td class=muted>No repositories match.</td></tr>"
            : "<tr><td class=muted>No repositories yet.</td></tr>");
    buf_puts(&b, "</table>");
    buf_puts(&b, "</div>");

    /* activity feed: events on repos owned by the viewer */
    if (r->user_id > 0) {
        buf_puts(&b, "<div class=feed><h3>Recent activity</h3>"
                    "<table class=feed>");
        sqlite3_stmt *st;
        if (sqlite3_prepare_v2(app->db,
                "SELECT strftime('%m-%d %H:%M',a.ts,'unixepoch'),"
                " COALESCE(a.actor,'(anon)'), a.action,"
                " COALESCE(ou.name||'/'||rp.name,'-'), a.detail"
                " FROM audit_log a JOIN repos rp ON rp.id=a.repo_id"
                " LEFT JOIN users ou ON ou.id=rp.owner_id"
                " WHERE rp.owner_id=?"
                " ORDER BY a.id DESC LIMIT 15",
                -1, &st, NULL) == SQLITE_OK) {
            sqlite3_bind_int(st, 1, r->user_id);
            int rows = 0;
            while (sqlite3_step(st) == SQLITE_ROW) {
                buf_puts(&b, "<tr><td class=muted>");
                gt_esc(&b, (const char *)sqlite3_column_text(st, 0));
                buf_puts(&b, "</td><td>");
                gt_esc(&b, (const char *)sqlite3_column_text(st, 1));
                buf_puts(&b, "</td><td>");
                gt_esc(&b, (const char *)sqlite3_column_text(st, 2));
                buf_puts(&b, "</td><td class=muted>");
                gt_esc(&b, (const char *)sqlite3_column_text(st, 3));
                buf_puts(&b, "</td><td class=muted>");
                gt_esc(&b, (const char *)sqlite3_column_text(st, 4));
                buf_puts(&b, "</td></tr>");
                rows++;
            }
            if (!rows)
                buf_puts(&b, "<tr><td class=muted>no activity yet"
                             "</td></tr>");
            sqlite3_finalize(st);
        }
        buf_puts(&b, "</table></div>");
    }
    buf_puts(&b, "</div>");
    free(perms);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

/* ---------------- repo views ---------------- */

static void view_tree(struct app_ctx *app, struct request *r, const char *repo_dir, int rk,
                      const char *o, const char *n)
{
    char path[1024];
    path[0] = '\0';
    req_query(r, "path", path, sizeof path);
    if (!git_path_ok(path)) {
        http_error(r, 400, "bad path");
        return;
    }
    char *ref = pick_ref(r, repo_dir);
    if (!ref) {
        http_error(r, 400, "bad ref");
        return;
    }

    char path_copy[1024];
    snprintf(path_copy, sizeof path_copy, "%s", path);

    char treeish[1400];
    snprintf(treeish, sizeof treeish, "%s%s%s", ref,
             path[0] ? ":" : "", path);

    struct buf out;
    buf_init(&out);
    const char *argv[] = { "git", "ls-tree", "-z", treeish, NULL };
    int have_tree = git_capture(repo_dir, argv, &out) == 0;
    if (!have_tree) {
        buf_free(&out);
        /* unborn HEAD = freshly created empty repo: friendly page,
         * not a 404.  Bad ref/path on a non-empty repo still 404s. */
        const char *hv[] = { "git", "rev-parse", "-q", "--verify",
                             "HEAD", NULL };
        struct buf hb;
        buf_init(&hb);
        int born = git_capture(repo_dir, hv, &hb) == 0;
        buf_free(&hb);
        if (born) {
            free(ref);
            http_error(r, 404, "no such tree or ref");
            return;
        }
    }

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "tree");

    /* clone URLs — one protocol per row inside a panel so a lazy
     * copy/paste can never grab both commands at once.
     * req_header_dup: the raw header value is not NUL-terminated and
     * strlen would run past it into the rest of the header block. */
    char *host = req_header_dup(r, "Host");
    buf_puts(&b, "<div class=panel><h4>clone</h4>"
                "<div class=clonerow><span class=proto>http</span>"
                "<input class=copyin readonly value=\"git clone http://");
    gt_esc(&b, host ? host : "<host>");
    buf_printf(&b, "/%s/%s.git\"></div>", o, n);
    free(host);
    if (app->ssh_url[0]) {
        buf_puts(&b, "<div class=clonerow><span class=proto>ssh</span>"
                    "<input class=copyin readonly value=\"git clone ");
        gt_esc(&b, app->ssh_url);
        buf_printf(&b, "/%s/%s.git\"></div>", o, n);
    }
    buf_puts(&b, "</div>");

    /* writer-only web commit actions — URLs carry encoded ref+dir */
    if (rk >= 2) {
        struct buf lb;
        buf_init(&lb);
        buf_printf(&lb, "ref=%s&dir=", ref);
        buf_put_urlenc(&lb, path, 1);
        buf_puts(&b, "<p class=muted>write via web: "
                    "<a class=btn href='/");
        buf_printf(&b, "%s/%s/new-file?%s'>+ new file</a> ", o, n,
                   lb.data ? lb.data : "");
        buf_printf(&b, "<a class=btn href='/%s/%s/upload?%s'>"
                    "&#8682; upload</a></p>", o, n,
                    lb.data ? lb.data : "");
        buf_free(&lb);
    }

    /* status panel: repo size + owner account usage; the empty-repo
     * notice lives inside the same block per the layout */
    int rid = gt_repo_rowid(app->db, o, n);
    if (rid > 0) {
        long long rsize = 0, used = 0, quota = 0;
        int oid = 0;
        sqlite3_stmt *st;
        if (sqlite3_prepare_v2(app->db,
                "SELECT r.size_kb, r.owner_id FROM repos r WHERE r.id=?",
                -1, &st, NULL) == SQLITE_OK) {
            sqlite3_bind_int(st, 1, rid);
            if (sqlite3_step(st) == SQLITE_ROW) {
                rsize = sqlite3_column_int64(st, 0);
                oid = sqlite3_column_type(st, 1) == SQLITE_NULL
                      ? 0 : sqlite3_column_int(st, 1);
            }
            sqlite3_finalize(st);
        }
        buf_puts(&b, "<div class=panel><h4>repository</h4>"
                    "<div class=stat>this repo: <b>");
        size_fmt(&b, rsize);
        buf_puts(&b, "</b>");
        if (oid > 0 &&
            sqlite3_prepare_v2(app->db,
                "SELECT u.quota_kb,"
                " (SELECT COALESCE(SUM(x.size_kb),0) FROM repos x"
                "  WHERE x.owner_id=u.id) FROM users u WHERE u.id=?",
                -1, &st, NULL) == SQLITE_OK) {
            sqlite3_bind_int(st, 1, oid);
            if (sqlite3_step(st) == SQLITE_ROW) {
                quota = sqlite3_column_int64(st, 0);
                used = sqlite3_column_int64(st, 1);
            }
            sqlite3_finalize(st);
            if (quota > 0) {
                buf_puts(&b, " &middot; account: <b>");
                size_fmt(&b, used);
                buf_puts(&b, " / ");
                size_fmt(&b, quota);
                buf_puts(&b, "</b>");
            }
        }
        buf_puts(&b, "</div>");
        if (!have_tree) {
            /* quick-start card: the two commands a newcomer needs,
             * one per row so copy/paste never grabs a merged line */
            char *h = req_header_dup(r, "Host");
            buf_puts(&b, "<div class=panel><h4>empty repository &mdash; "
                        "quick start</h4>"
                        "<div class=clonerow><span class=proto>remote</span>"
                        "<code>git remote add origin http://");
            if (r->user_name[0]) {
                gt_esc(&b, r->user_name);
                buf_puts(&b, "@");
            }
            gt_esc(&b, h ? h : "<host>");
            buf_printf(&b, "/%s/%s.git</code></div>", o, n);
            if (app->ssh_url[0]) {
                buf_puts(&b, "<div class=clonerow><span class=proto>ssh</span>"
                            "<code>git remote add origin ");
                gt_esc(&b, app->ssh_url);
                buf_printf(&b, "/%s/%s.git</code></div>", o, n);
            }
            buf_puts(&b, "<div class=clonerow><span class=proto>push</span>"
                        "<code>git push -u origin master</code></div>"
                        "<p class=muted>or clone it above and commit "
                        "inside the working copy</p></div>");
            free(h);
        }
        buf_puts(&b, "</div>");
    }

    if (!have_tree) {
        gt_repo_body_end(&b);
        gt_page_bottom(&b);
        gt_send_page(r, &b);
        buf_free(&b);
        free(ref);
        return;
    }

    /* breadcrumbs */
    buf_puts(&b, "<p class=crumbs><a href='/");
    buf_printf(&b, "%s/%s/tree?ref=", o, n);
    buf_put_urlenc(&b, ref, 0);
    buf_printf(&b, "'>%s</a>", n);
    char accum[1024] = "";
    for (char *seg = strtok(path_copy, "/"); seg; seg = strtok(NULL, "/")) {
        if (accum[0])
            strcat(accum, "/");
        strcat(accum, seg);
        buf_puts(&b, " / <a href='/");
        buf_printf(&b, "%s/%s/tree?ref=", o, n);
        buf_put_urlenc(&b, ref, 0);
        buf_puts(&b, "&path=");
        buf_put_urlenc(&b, accum, 1);
        buf_puts(&b, "'>");
        buf_put_escaped(&b, seg, strlen(seg));
        buf_puts(&b, "</a>");
    }
    buf_puts(&b, "</p><table>");

    /* entries: "<mode> <type> <sha>\t<name>\0" */
    const char *p = out.data;
    const char *end = out.data + out.len;
    while (p < end) {
        const char *nul = memchr(p, 0, (size_t)(end - p));
        if (!nul)
            break;
        const char *tab = memchr(p, '\t', (size_t)(nul - p));
        if (!tab) {
            p = nul + 1;
            continue;
        }
        char meta[128];
        size_t ml = (size_t)(tab - p);
        if (ml >= sizeof meta)
            ml = sizeof meta - 1;
        memcpy(meta, p, ml);
        meta[ml] = '\0';
        char type[16] = "";
        sscanf(meta, "%*o %15s", type);

        const char *name = tab + 1;
        char child[1400];
        snprintf(child, sizeof child, "%s%s%s", path, path[0] ? "/" : "",
                 name);

        buf_puts(&b, "<tr><td>");
        if (strcmp(type, "tree") == 0) {
            buf_puts(&b, "&#128193; <a href='/");
            buf_printf(&b, "%s/%s/tree?ref=", o, n);
            buf_put_urlenc(&b, ref, 0);
            buf_puts(&b, "&path=");
            buf_put_urlenc(&b, child, 1);
            buf_puts(&b, "'>");
        } else if (strcmp(type, "blob") == 0) {
            buf_puts(&b, "&#128196; <a href='/");
            buf_printf(&b, "%s/%s/blob?ref=", o, n);
            buf_put_urlenc(&b, ref, 0);
            buf_puts(&b, "&path=");
            buf_put_urlenc(&b, child, 1);
            buf_puts(&b, "'>");
        } else {
            buf_puts(&b, "&#128279; ");
        }
        buf_put_escaped(&b, name, (size_t)(nul - name));
        if (strcmp(type, "tree") == 0 || strcmp(type, "blob") == 0)
            buf_puts(&b, "</a>");
        buf_printf(&b, "</td><td class=muted>%s</td></tr>", type);
        p = nul + 1;
    }
    buf_puts(&b, "</table>");

    /* the README is the front page of a repository — rendered
     * markdown for .md files, preformatted text otherwise */
    if (!path[0]) {
        static const char *const readmes[] = {
            "README.md", "readme.md", "README.markdown",
            "README", "README.txt", NULL
        };
        for (int i = 0; readmes[i]; i++) {
            char spec[1400];
            snprintf(spec, sizeof spec, "%s:%s", ref, readmes[i]);
            struct buf rm;
            buf_init(&rm);
            const char *rargv[] = { "git", "show", spec, NULL };
            if (git_capture(repo_dir, rargv, &rm) == 0 && rm.data &&
                rm.len > 0 && rm.len <= 262144 &&
                !memchr(rm.data, 0, rm.len)) {
                buf_puts(&b, "<div class=readme><div class=rdh>");
                buf_put_escaped(&b, readmes[i], strlen(readmes[i]));
                buf_puts(&b, "</div><div class=rdb>");
                if (ends_with(readmes[i], ".md") ||
                    ends_with(readmes[i], ".markdown"))
                    md_emit(&b, rm.data, rm.len);
                else {
                    buf_puts(&b, "<pre>");
                    buf_put_escaped(&b, rm.data, rm.len);
                    buf_puts(&b, "</pre>");
                }
                buf_puts(&b, "</div></div>");
                buf_free(&rm);
                break;
            }
            buf_free(&rm);
        }
    }
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
    buf_free(&out);
    free(ref);
}

static void view_blob(struct app_ctx *app, struct request *r, const char *repo_dir, int rk,
                      const char *o, const char *n)
{
    char path[1024];
    if (req_query(r, "path", path, sizeof path) != 0 || !git_path_ok(path) ||
        !path[0]) {
        http_error(r, 400, "bad path");
        return;
    }
    char *ref = pick_ref(r, repo_dir);
    if (!ref) {
        http_error(r, 400, "bad ref");
        return;
    }
    char spec[1400];
    snprintf(spec, sizeof spec, "%s:%s", ref, path);

    struct buf out;
    buf_init(&out);
    const char *argv[] = { "git", "show", spec, NULL };
    if (git_capture(repo_dir, argv, &out) != 0) {
        buf_free(&out);
        free(ref);
        http_error(r, 404, "no such blob");
        return;
    }

    if (req_query(r, "raw", path, sizeof path) == 0 ||
        memchr(out.data, 0, out.len)) {   /* binary -> raw download */
        http_respond(r, 200, "application/octet-stream",
                     out.data ? out.data : "", out.len);
        buf_free(&out);
        free(ref);
        return;
    }

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "tree");
    buf_puts(&b, "<p class=crumbs>");
    buf_put_escaped(&b, path, strlen(path));
    if (rk >= 2) {
        struct buf lb;
        buf_init(&lb);
        buf_printf(&lb, "ref=%s&path=", ref);
        buf_put_urlenc(&lb, path, 1);
        buf_printf(&b, " &middot; <a href='/%s/%s/edit?%s'>edit</a>",
                   o, n, lb.data ? lb.data : "");
        buf_free(&lb);
    }
    buf_puts(&b, "</p><pre>");
    if (out.len <= 262144)
        hl_emit(&b, out.data, out.len, path);
    else
        buf_put_escaped(&b, out.data, out.len);   /* huge file: plain */
    buf_puts(&b, "</pre>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
    buf_free(&out);
    free(ref);
}

/* ---------------- trace graph ----------------
 * The commit DAG drawn as SVG lanes — the product's identity element:
 * nodes are commits, edges are parenthood.  Pure server-side SVG.
 *
 * Lane model (a compact take on git's graph.c): each column expects
 * one sha.  A commit occupies the first lane expecting it; its first
 * parent keeps that lane, extra parents claim free lanes; merges free
 * every lane that was expecting the commit.  Rows are fixed-height so
 * lanes line up vertically down the page. */

#define TG_ROWS  200
#define TG_LANES 24
#define TG_PAR   8
#define TG_H     38          /* row height, must match .tg .tgt */
#define TG_DX    12          /* lane pitch */
#define TG_X(l)  (8 + (l) * TG_DX)
#define TG_CY    13          /* node centre, ~subject baseline */

static const char *TG_PAL[] = {
    "#3b82f6", "#22c55e", "#f59e0b", "#a855f7",
    "#06b6d4", "#f43f5e", "#84cc16", "#f97316"
};
#define TG_NPAL ((int)(sizeof TG_PAL / sizeof TG_PAL[0]))

struct tgc {
    char        sha[41];
    char        par[TG_PAR][41];
    int         npar;
    const char *an;                 /* points into the log buffer */
    long        at;
    const char *sub;                /* points into the log buffer */
    int         col;                /* lane of the node */
    int         ncolor;             /* palette index of the lane */
    int         in[TG_LANES], nin;  /* lanes that expected this sha */
    int         out[TG_PAR], nout;  /* lane of each parent */
    unsigned    on;                 /* lanes occupied entering the row */
    unsigned char lc[TG_LANES];     /* palette index per lane */
};

static void tg_emit_edge(struct buf *b, int x1, int y1, int x2, int y2,
                         int color)
{
    if (x1 == x2)
        buf_printf(b, "<line x1='%d' y1='%d' x2='%d' y2='%d' "
                      "stroke='%s' stroke-width='1.5'/>",
                   x1, y1, x2, y2, TG_PAL[color]);
    else
        buf_printf(b, "<path d='M%d %d C%d %d,%d %d,%d %d' fill='none' "
                      "stroke='%s' stroke-width='1.5'/>",
                   x1, y1, x1, (y1 * 2 + y2) / 3,
                   x2, (y1 + y2 * 2) / 3, x2, y2, TG_PAL[color]);
}

static void render_trace_graph(struct buf *b, struct buf *out,
                               const char *o, const char *n)
{
    struct tgc *cs = calloc(TG_ROWS, sizeof *cs);
    if (!cs)
        return;
    int nc = 0;
    char *save = NULL;
    for (char *line = out->data ? strtok_r(out->data, "\n", &save) : NULL;
         line && nc < TG_ROWS;
         line = strtok_r(NULL, "\n", &save)) {
        struct tgc *c = &cs[nc];
        char *p = line;
        char *f[4];
        int nf = 0;
        for (; nf < 4 && p; nf++) {
            f[nf] = p;
            p = strchr(p, '\x1f');
            if (p)
                *p++ = '\0';
        }
        if (nf < 4 || !p)           /* 4 fields + subject remainder */
            continue;
        snprintf(c->sha, sizeof c->sha, "%.40s", f[0]);
        char *psave = NULL;
        for (char *w = strtok_r(f[1], " ", &psave);
             w && c->npar < TG_PAR;
             w = strtok_r(NULL, " ", &psave))
            snprintf(c->par[c->npar++], 41, "%.40s", w);
        c->an  = f[2];
        c->at  = atol(f[3]);
        c->sub = p;
        nc++;
    }
    if (nc == 0) {
        free(cs);
        return;
    }

    /* layout pass: assign every commit a lane and resolve parent
     * columns; keep the max occupied column for the svg width */
    char lane[TG_LANES][41];
    unsigned char lcol[TG_LANES];
    memset(lane, 0, sizeof lane);
    int nextc = 0, maxc = 0;
    for (int i = 0; i < nc; i++) {
        struct tgc *c = &cs[i];
        c->on = 0;
        for (int l = 0; l < TG_LANES; l++)
            if (lane[l][0]) {
                c->on |= 1u << l;
                c->lc[l] = lcol[l];
            }
        for (int l = 0; l < TG_LANES; l++)
            if (lane[l][0] && strcmp(lane[l], c->sha) == 0)
                c->in[c->nin++] = l;
        int col;
        if (c->nin > 0) {
            col = c->in[0];
        } else {
            for (col = 0; col < TG_LANES && lane[col][0]; col++)
                ;
            if (col >= TG_LANES)
                col = TG_LANES - 1;      /* saturated: share a column */
            lcol[col] = nextc++ % TG_NPAL;
            c->lc[col] = lcol[col];
        }
        c->col = col;
        c->ncolor = lcol[col];
        for (int k = 1; k < c->nin; k++)
            lane[c->in[k]][0] = '\0';    /* merge: extra lanes converge */
        if (c->npar == 0) {
            lane[col][0] = '\0';         /* root: the trace ends here */
        } else {
            snprintf(lane[col], 41, "%.40s", c->par[0]);
            c->out[c->nout++] = col;
            for (int j = 1; j < c->npar; j++) {
                int e = -1;
                for (int l = 0; l < TG_LANES; l++)
                    if (lane[l][0] && strcmp(lane[l], c->par[j]) == 0) {
                        e = l;
                        break;
                    }
                if (e < 0) {
                    for (e = 0; e < TG_LANES && lane[e][0]; e++)
                        ;
                    if (e >= TG_LANES)
                        continue;        /* no column left: skip edge */
                    snprintf(lane[e], 41, "%.40s", c->par[j]);
                    lcol[e] = nextc++ % TG_NPAL;
                }
                c->out[c->nout++] = e;
            }
        }
        for (int l = 0; l < TG_LANES; l++)
            if (lane[l][0] && l + 1 > maxc)
                maxc = l + 1;
    }

    int w = TG_X(maxc) + 8;
    buf_printf(b, "<div class=tgw style='overflow-x:auto'>");
    for (int i = 0; i < nc; i++) {
        struct tgc *c = &cs[i];
        buf_printf(b, "<div class=tg><svg width='%d' height='%d' "
                      "viewBox='0 0 %d %d'>", w, TG_H, w, TG_H);
        /* verticals: every occupied lane that does not converge here */
        for (int l = 0; l < TG_LANES; l++) {
            if (!(c->on & (1u << l)))
                continue;
            int expect = 0;
            for (int k = 0; k < c->nin; k++)
                if (c->in[k] == l) {
                    expect = 1;
                    break;
                }
            if (!expect)
                tg_emit_edge(b, TG_X(l), 0, TG_X(l), TG_H, c->lc[l]);
        }
        /* incoming edges: lanes expecting this commit -> node */
        for (int k = 0; k < c->nin; k++)
            tg_emit_edge(b, TG_X(c->in[k]), 0, TG_X(c->col), TG_CY,
                         c->lc[c->in[k]]);
        /* outgoing edges: node -> each parent's lane */
        for (int k = 0; k < c->nout; k++)
            tg_emit_edge(b, TG_X(c->col), TG_CY, TG_X(c->out[k]), TG_H,
                         c->ncolor);
        buf_printf(b, "<a href='/%s/%s/commit/%s'>"
                      "<circle cx='%d' cy='%d' r='3.2' fill='%s'>"
                      "<title>%.10s</title></circle></a></svg>",
                   o, n, c->sha, TG_X(c->col), TG_CY,
                   TG_PAL[c->ncolor], c->sha);
        buf_puts(b, "<div class=tgt><div class=tlt>");
        buf_put_escaped(b, c->sub, strlen(c->sub));
        buf_printf(b, "</div><div class=tlm><a class=sha "
                      "href='/%s/%s/commit/%s'>%.10s</a> ", o, n,
                   c->sha, c->sha);
        buf_put_escaped(b, c->an, strlen(c->an));
        buf_puts(b, " &middot; ");
        if (c->at > 0) {
            char d[32];
            struct tm tm;
            time_t tt = (time_t)c->at;
            gmtime_r(&tt, &tm);
            strftime(d, sizeof d, "%Y-%m-%d %H:%M", &tm);
            buf_printf(b, "<span title='%s UTC'>", d);
            rel_time(b, c->at);
            buf_puts(b, "</span>");
        }
        buf_puts(b, "</div></div></div>");
    }
    buf_puts(b, "</div>");
    free(cs);
}

static void view_log(struct app_ctx *app, struct request *r, const char *repo_dir, int rk,
                     const char *o, const char *n)
{
    char *ref = pick_ref(r, repo_dir);
    if (!ref) {
        http_error(r, 400, "bad ref");
        return;
    }
    struct buf out;
    buf_init(&out);
    const char *argv[] = {
        "git", "log", "-n", "200", "--date-order",
        "--format=%H%x1f%P%x1f%an%x1f%at%x1f%s", ref, "--", NULL
    };
    int rc = git_capture(repo_dir, argv, &out);
    if (rc != 0) {
        /* unborn branch / bad ref */
        buf_free(&out);
        struct buf b;
        buf_init(&b);
        gt_page_top(&b, n, r);
        gt_repo_tabs(&b, app, o, n, rk, "log");
        buf_puts(&b, "<p class=muted>No commits on this ref.</p>");
        gt_repo_body_end(&b);
        gt_page_bottom(&b);
        gt_send_page(r, &b);
        buf_free(&b);
        free(ref);
        return;
    }

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "log");
    buf_printf(&b, "<p class=muted>branch <code>");
    buf_put_escaped(&b, ref, strlen(ref));
    /* the "trace": the commit DAG drawn as SVG lanes */
    buf_puts(&b, "</code></p>");
    render_trace_graph(&b, &out, o, n);
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
    buf_free(&out);
    free(ref);
}

/* browser renderer for Atom feeds: /feed stays valid Atom for feed
 * readers, and carries an xml-stylesheet PI so browsers transform it
 * into a readable page.  Served by web_feed_xsl. */
static const char FEED_XSL[] =
    "<?xml version='1.0'?>\n"
    "<xsl:stylesheet version='1.0'"
    " xmlns:xsl='http://www.w3.org/1999/XSL/Transform'"
    " xmlns:a='http://www.w3.org/2005/Atom'>\n"
    "<xsl:output method='html'/>\n"
    "<xsl:template match='/a:feed'>\n"
    "<html><head><title><xsl:value-of select='a:title'/></title>\n"
    "<style>"
    "body{font:14px/1.5 system-ui,sans-serif;margin:2rem auto;"
    "max-width:62em;color:#1a1a1a;background:#fafaf8}"
    "a{color:#2d5a2e;text-decoration:none}"
    "h1{font-size:1.25rem;border-bottom:2px solid #2d3a2e;"
    "padding-bottom:.4rem}"
    ".sub{color:#777;font-size:.9em;margin-bottom:1.4rem}"
    ".entry{border-bottom:1px solid #e3e0d8;padding:.6rem 0}"
    ".entry .meta{color:#777;font-size:.85em;margin-top:.15rem}"
    "</style></head><body>\n"
    "<h1><xsl:value-of select='a:title'/></h1>\n"
    "<p class='sub'>Atom feed — subscribe in a feed reader. "
    "This page is the browser-friendly view of the same data.</p>\n"
    "<xsl:for-each select='a:entry'>\n"
    "<div class='entry'>"
    "<a><xsl:attribute name='href'>"
    "<xsl:value-of select='a:link/@href'/>"
    "</xsl:attribute><xsl:value-of select='a:title'/></a>"
    "<div class='meta'><xsl:value-of select='a:author/a:name'/>"
    " &#8212; <xsl:value-of select='a:updated'/></div>"
    "</div>\n"
    "</xsl:for-each>\n"
    "</body></html>\n"
    "</xsl:template>\n"
    "</xsl:stylesheet>\n";

void web_feed_xsl(void *ctx, struct request *r)
{
    (void)ctx;
    http_respond(r, 200, "text/xsl; charset=utf-8",
                 FEED_XSL, sizeof FEED_XSL - 1);
}

/* /feed — a subscribe page for humans: the feed URL to paste into a
 * reader.  The machine feed itself is served at /feed.atom. */
static void view_feed(struct app_ctx *app, struct request *r,
                      const char *repo_dir, int rk,
                      const char *o, const char *n)
{
    (void)repo_dir;
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "feed");
    char *host = req_header_dup(r, "Host");
    buf_puts(&b, "<div class=panel><h4>subscribe</h4>"
                "<div class=clonerow><span class=proto>atom</span>"
                "<input class=copyin readonly value=\"http://");
    gt_esc(&b, host ? host : "<host>");
    buf_printf(&b, "/%s/%s/feed.atom\"></div>", o, n);
    free(host);
    buf_printf(&b, "<p class=muted>Latest commits on this repository as"
                   " an Atom feed — paste the URL into your reader, or"
                   " <a href='/%s/%s/feed.atom'>open the raw feed</a>."
                   "</p></div>", o, n);
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

/* Atom feed of recent commits — same log query as /log but emitted
 * as application/atom+xml.  Routed through web_repo, so private repos
 * are protected by the read rank like any other view. */
static void view_feed_atom(struct app_ctx *app, struct request *r,
                           const char *repo_dir, const char *o,
                           const char *n)
{
    (void)app;
    char *ref = pick_ref(r, repo_dir);
    if (!ref) {
        http_error(r, 400, "bad ref");
        return;
    }
    struct buf out;
    buf_init(&out);
    const char *argv[] = {
        "git", "log", "-n", "40",
        "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s", ref, "--", NULL
    };
    if (git_capture(repo_dir, argv, &out) != 0 || !out.data) {
        buf_free(&out);
        free(ref);
        http_error(r, 404, "no commits to feed");
        return;
    }

    struct buf b;
    buf_init(&b);
    buf_puts(&b, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
                "<?xml-stylesheet type=\"text/xsl\" href=\"/feed.xsl\"?>\n"
                "<feed xmlns=\"http://www.w3.org/2005/Atom\">\n<title>");
    buf_put_escaped(&b, o, strlen(o));
    buf_puts(&b, "/");
    buf_put_escaped(&b, n, strlen(n));
    buf_puts(&b, " commits</title>\n<id>");
    buf_printf(&b, "/%s/%s", o, n);
    buf_puts(&b, "</id>\n<link href='");
    buf_printf(&b, "/%s/%s/feed.atom' rel='self'/>", o, n);
    buf_printf(&b, "<link href='/%s/%s'/>\n", o, n);

    char *save = NULL;
    int first = 1;
    for (char *line = strtok_r(out.data, "\n", &save); line;
         line = strtok_r(NULL, "\n", &save)) {
        char *sha = strtok(line, "\x1f");
        char *an  = strtok(NULL, "\x1f");
        char *ae  = strtok(NULL, "\x1f");
        char *ad  = strtok(NULL, "\x1f");
        char *sub = strtok(NULL, "\x1f");
        if (!sha || !ad)
            continue;
        if (first) {
            buf_puts(&b, "<updated>");
            buf_puts(&b, ad);
            buf_puts(&b, "</updated>\n");
            first = 0;
        }
        buf_puts(&b, "<entry>\n<title>");
        buf_put_escaped(&b, sub ? sub : "", sub ? strlen(sub) : 0);
        buf_puts(&b, "</title>\n<id>/");
        buf_printf(&b, "%s/%s/commit/%s", o, n, sha);
        buf_puts(&b, "</id>\n<link href='/");
        buf_printf(&b, "%s/%s/commit/%s'/>\n", o, n, sha);
        buf_puts(&b, "<updated>");
        buf_puts(&b, ad);
        buf_puts(&b, "</updated>\n<author><name>");
        buf_put_escaped(&b, an ? an : "", an ? strlen(an) : 0);
        buf_puts(&b, "</name><email>");
        buf_put_escaped(&b, ae ? ae : "", ae ? strlen(ae) : 0);
        buf_puts(&b, "</email></author>\n<summary>");
        buf_put_escaped(&b, sub ? sub : "", sub ? strlen(sub) : 0);
        buf_puts(&b, "</summary>\n</entry>\n");
    }
    buf_puts(&b, "</feed>\n");
    /* 'self' lets the browser fetch /feed.xsl to render the feed as
     * a readable page; readers ignore the stylesheet entirely */
    http_respond_csp(r, 200, "application/atom+xml; charset=utf-8",
                     "default-src 'self'", b.data, b.len);
    buf_free(&b);
    buf_free(&out);
    free(ref);
}

/* /search?q= — fixed-string grep over the tree at ref via `git grep`.
 * Output lines are "<rev>:<path>:<lineno>:<text>"; paths may contain
 * ':' so the lineno field is located by scanning for ":\d+:" anchors
 * from the left after stripping the known rev prefix. */
static void view_search(struct app_ctx *app, struct request *r,
                        const char *repo_dir, int rk,
                        const char *o, const char *n)
{
    char q[256] = "";
    int have_q = req_query(r, "q", q, sizeof q) == 0 && q[0];
    char icase[8] = "";
    req_query(r, "i", icase, sizeof icase);

    char *ref = pick_ref(r, repo_dir);
    if (!ref) {
        http_error(r, 400, "bad ref");
        return;
    }

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "search");
    buf_puts(&b, "<form method=get action='/");
    buf_printf(&b, "%s/%s/search'>", o, n);
    buf_printf(&b, "<input type=hidden name=ref value='%s'>", ref);
    buf_puts(&b, "<p><input name=q required maxlength=255 "
                 "placeholder='search this repository' value='");
    buf_put_escaped(&b, q, strlen(q));
    buf_puts(&b, "'> <label><input type=checkbox name=i value=1");
    buf_puts(&b, icase[0] ? " checked" : "");
    buf_puts(&b, "> ignore case</label> <button>search</button></p>"
                 "</form>");

    if (have_q) {
        struct buf out;
        buf_init(&out);
        const char *argv[16];
        int a = 0;
        argv[a++] = "git"; argv[a++] = "grep";
        argv[a++] = "-n"; argv[a++] = "-F";
        if (icase[0])
            argv[a++] = "-i";
        argv[a++] = "-e"; argv[a++] = q;
        argv[a++] = ref; argv[a++] = "--";
        argv[a] = NULL;
        if (git_capture(repo_dir, argv, &out) != 0)
            buf_puts(&b, "<p class=muted>no matches</p>");
        else {
            buf_puts(&b, "<table>");
            size_t revlen = strlen(ref);
            char *save = NULL;
            int shown = 0;
            for (char *line = strtok_r(out.data, "\n", &save); line;
                 line = strtok_r(NULL, "\n", &save)) {
                if (strncmp(line, ref, revlen) != 0 ||
                    line[revlen] != ':')
                    continue;
                char *p = line + revlen + 1;    /* "path:line:text" */
                /* find ":" followed by digits ":" — ':' is legal in
                 * filenames, so the numeric anchor locates lineno */
                char *path = p, *num = NULL, *text = NULL;
                for (char *c = strchr(p, ':'); c; c = strchr(c + 1, ':')) {
                    char *d = c + 1;
                    if (!isdigit((unsigned char)*d))
                        continue;
                    char *e = d;
                    while (isdigit((unsigned char)*e))
                        e++;
                    if (*e == ':') {
                        *c = '\0';
                        *e = '\0';
                        num = d;
                        text = e + 1;
                        break;
                    }
                }
                if (!num)
                    continue;
                if (shown++ >= 200) {
                    buf_puts(&b, "<tr><td colspan=3 class=muted>"
                                 "… truncated at 200 matches</td></tr>");
                    break;
                }
                buf_puts(&b, "<tr><td><a href='/");
                buf_printf(&b, "%s/%s/blob?path=", o, n);
                buf_put_urlenc(&b, path, 1);
                buf_printf(&b, "&ref=%s'>", ref);
                buf_put_escaped(&b, path, strlen(path));
                buf_puts(&b, "</a>:</td><td class=muted>");
                buf_puts(&b, num);
                buf_puts(&b, "</td><td><code>");
                buf_put_escaped(&b, text, strlen(text));
                buf_puts(&b, "</code></td></tr>");
            }
            buf_puts(&b, "</table>");
        }
        buf_free(&out);
    }
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
    free(ref);
}

static int sha_ok(const char *s)
{
    size_t n = strlen(s);
    if (n < 7 || n > 64)
        return 0;
    for (size_t i = 0; i < n; i++)
        if (!isxdigit((unsigned char)s[i]) || isupper((unsigned char)s[i]))
            return 0;
    return 1;
}

static void view_commit(struct app_ctx *app, struct request *r, const char *repo_dir, int rk,
                        const char *o, const char *n, const char *sha)
{
    if (!sha_ok(sha)) {
        http_error(r, 400, "bad commit id");
        return;
    }
    struct buf meta, diff;
    buf_init(&meta);
    buf_init(&diff);

    const char *margv[] = {
        "git", "show", "-s", "--date=iso",
        "--format=%H%x1f%an%x1f%ae%x1f%ad%x1f%B", sha, NULL
    };
    const char *dargv[] = {
        "git", "show", "--format=", "--patch", "--no-color", sha, NULL
    };
    if (git_capture(repo_dir, margv, &meta) != 0) {
        buf_free(&meta);
        buf_free(&diff);
        http_error(r, 404, "no such commit");
        return;
    }
    git_capture(repo_dir, dargv, &diff);

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "log");

    if (!meta.data)
        meta.data = xstrdup("");
    char *fsha = strtok(meta.data, "\x1f");
    if (!fsha)
        fsha = (char *)sha;
    char *an   = strtok(NULL, "\x1f");
    char *ae   = strtok(NULL, "\x1f");
    char *ad   = strtok(NULL, "\x1f");
    char *msg  = strtok(NULL, "\x1f");

    buf_puts(&b, "<table>");
    buf_puts(&b, "<tr><td class=muted>commit</td><td class=sha>");
    buf_put_escaped(&b, fsha, strlen(fsha));
    buf_puts(&b, "</td></tr><tr><td class=muted>author</td><td>");
    buf_put_escaped(&b, an ? an : "", an ? strlen(an) : 0);
    buf_puts(&b, " &lt;");
    buf_put_escaped(&b, ae ? ae : "", ae ? strlen(ae) : 0);
    buf_puts(&b, "&gt;</td></tr><tr><td class=muted>date</td><td>");
    buf_put_escaped(&b, ad ? ad : "", ad ? strlen(ad) : 0);
    buf_puts(&b, "</td></tr></table><pre>");
    buf_put_escaped(&b, msg ? msg : "", msg ? strlen(msg) : 0);
    buf_puts(&b, "</pre>");

    /* diff with line-class colouring */
    buf_puts(&b, "<pre>");
    if (diff.data)
        gt_render_diff(&b, diff.data, diff.len);
    buf_puts(&b, "</pre>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
    buf_free(&meta);
    buf_free(&diff);
}

void gt_render_diff(struct buf *b, const char *data, size_t n)
{
    const char *p = data;
    const char *end = data + n;
    while (p < end) {
        const char *eol = memchr(p, '\n', (size_t)(end - p));
        size_t ll = eol ? (size_t)(eol - p) : (size_t)(end - p);
        const char *cls = "";
        if (ll && p[0] == '+')
            cls = " class=d-add";
        else if (ll && p[0] == '-')
            cls = " class=d-del";
        else if (starts_with(p, "@@"))
            cls = " class=d-hunk";
        else if (starts_with(p, "diff ") || starts_with(p, "index "))
            cls = " class=d-meta";
        if (*cls) {
            buf_printf(b, "<span%s>", cls);
            buf_put_escaped(b, p, ll);
            buf_puts(b, "</span>");
        } else {
            buf_put_escaped(b, p, ll);
        }
        buf_put(b, "\n", 1);
        p = eol ? eol + 1 : end;
    }
}

static void view_refs(struct app_ctx *app, struct request *r, const char *repo_dir, int rk,
                      const char *o, const char *n)
{
    struct buf out;
    buf_init(&out);
    const char *argv[] = {
        "git", "for-each-ref",
        "--format=%(refname)%09%(objectname:short)%09%(subject)",
        "refs/heads", "refs/tags", "refs/mirror", NULL
    };
    git_capture(repo_dir, argv, &out);

    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "refs");
    buf_puts(&b, "<table>");
    if (!out.len)
        buf_puts(&b, "<tr><td class=muted>no refs</td></tr>");

    char *save = NULL;
    for (char *line = out.data ? strtok_r(out.data, "\n", &save) : NULL;
         line;
         line = strtok_r(NULL, "\n", &save)) {
        char *ref = strtok(line, "\t");
        char *sha = strtok(NULL, "\t");
        char *sub = strtok(NULL, "\t");
        if (!ref)
            continue;
        const char *short_ = starts_with(ref, "refs/heads/")
            ? ref + 11 : starts_with(ref, "refs/tags/") ? ref + 10 : ref;
        int mirrored = starts_with(ref, "refs/mirror/");
        buf_puts(&b, "<tr><td class=muted>");
        buf_put_escaped(&b, ref, strlen(ref));
        buf_puts(&b, "</td><td>");
        if (!mirrored) {
            buf_puts(&b, "<a href='/");
            buf_printf(&b, "%s/%s/tree?ref=", o, n);
            buf_put_urlenc(&b, short_, 0);
            buf_puts(&b, "'>");
        }
        buf_put_escaped(&b, short_, strlen(short_));
        if (mirrored)
            buf_puts(&b, " <span class=muted>(read-only mirror)</span>");
        else
            buf_puts(&b, "</a>");
        buf_puts(&b, "</td><td class=sha>");
        buf_put_escaped(&b, sha ? sha : "", sha ? strlen(sha) : 0);
        buf_puts(&b, "</td><td class=muted>");
        buf_put_escaped(&b, sub ? sub : "", sub ? strlen(sub) : 0);
        buf_puts(&b, "</td></tr>");
    }
    buf_puts(&b, "</table>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
    buf_free(&out);
}

/* ---------------- web commits (new-file / edit / upload) ---------------- */

#define WC_TEXT_MAX  (1u << 20)      /* urlencoded editor body cap */
#define WC_FILE_MAX  (4u << 20)      /* multipart upload body cap */

/* multipart parsing lives in wcommit.c: wc_mp_find/attr/boundary,
 * wc_mp_csrf_ok, wc_basename (exported so the fuzzer can reach them) */

static void user_email(sqlite3 *db, int uid, char *out, size_t n)
{
    out[0] = '\0';
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(db, "SELECT email FROM users WHERE id=?",
                           -1, &st, NULL) != SQLITE_OK)
        return;
    sqlite3_bind_int(st, 1, uid);
    if (sqlite3_step(st) == SQLITE_ROW) {
        const char *e = (const char *)sqlite3_column_text(st, 0);
        if (e)
            snprintf(out, n, "%s", e);
    }
    sqlite3_finalize(st);
}

/* shared form: file path + content/file + message + csrf */
static void wc_form(struct app_ctx *app, struct request *r, int rk,
                    const char *o, const char *n, const char *action,
                    const char *sub, const char *ref, const char *path,
                    const char *content, size_t clen,
                    const char *err, int is_upload)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, n, r);
    gt_repo_tabs(&b, app, o, n, rk, "tree");
    if (err)
        buf_printf(&b, "<p class=err>%s</p>", err);
    buf_printf(&b, "<h1>%s</h1>", action);
    buf_printf(&b, "<form method=post action='/%s/%s/%s'", o, n, sub);
    if (is_upload)
        buf_puts(&b, " enctype='multipart/form-data'");
    buf_puts(&b, ">");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=ref value='");
    buf_put_escaped(&b, ref, strlen(ref));
    buf_puts(&b, "'>");
    buf_puts(&b, "<p>branch <code>");
    buf_put_escaped(&b, ref, strlen(ref));
    buf_puts(&b, "</code></p>");
    buf_puts(&b, "<p>path <input name=path size=60 required value='");
    if (path)
        buf_put_escaped(&b, path, strlen(path));
    buf_puts(&b, "'></p>");
    if (is_upload) {
        buf_puts(&b, "<p>file <input type=file name=file required></p>");
    } else {
        buf_puts(&b, "<p><textarea name=content "
                    "rows=24 cols=90 style='width:100%;font-family:"
                    "ui-monospace,monospace'>");
        if (content)
            buf_put_escaped(&b, content, clen);
        buf_puts(&b, "</textarea></p>");
    }
    buf_puts(&b, "<p>commit message <input name=msg size=60 "
                "placeholder='update via web'></p>"
                "<p><button class=btn>commit</button></p></form>");
    gt_repo_body_end(&b);
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

/* commit then redirect to the blob; on plumbing error re-render */
static void wc_commit_and_show(struct app_ctx *app, struct request *r,
                               int rk, const char *repo_dir,
                               const char *o, const char *n,
                               const char *sub, const char *ref,
                               const char *path, const void *data,
                               size_t dlen, const char *msg,
                               int is_upload)
{
    char mail[160], sha[41], werr[160], m2[512];
    user_email(app->db, r->user_id, mail, sizeof mail);
    snprintf(m2, sizeof m2, "%s", msg && *msg ? msg : "update via web");
    if (wc_commit_file(app, repo_dir, ref, path, data, dlen, m2,
                       r->user_name, mail, sha, werr, sizeof werr) != 0) {
        wc_form(app, r, rk, o, n,
                is_upload ? "upload file" :
                strcmp(sub, "edit") == 0 ? "edit file" : "new file",
                sub, ref, path, is_upload ? NULL : data,
                is_upload ? 0 : dlen, werr, is_upload);
        return;
    }
    struct buf ub;
    buf_init(&ub);
    buf_printf(&ub, "/%s/%s/blob?ref=%s&path=", o, n, ref);
    buf_put_urlenc(&ub, path, 1);
    http_redirect(r, ub.data ? ub.data : "/");
    buf_free(&ub);
}

/* GET/POST /<o>/<r>/new-file, /edit, /upload — write-rank only */
static void wc_view(struct app_ctx *app, struct request *r,
                    const char *repo_dir, int rk,
                    const char *o, const char *n, const char *mode)
{
    /* anonymous first: a public repo's anonymous reader deserves the
     * login redirect, not a pretend-missing page */
    if (r->user_id == 0) {
        http_redirect(r, "/login");
        return;
    }
    if (rk < 2) {                          /* readers: hide it entirely */
        http_error(r, 404, "no such view");
        return;
    }
    int is_upload = strcmp(mode, "upload") == 0;
    int is_edit = strcmp(mode, "edit") == 0;

    if (strcmp(r->method, "GET") == 0) {
        char *ref = pick_ref(r, repo_dir);
        if (!ref) {
            http_error(r, 400, "bad ref");
            return;
        }
        char path[1024] = "";
        req_query(r, is_edit ? "path" : "dir", path, sizeof path);
        if (is_edit) {
            if (!git_path_ok(path) || !path[0]) {
                free(ref);
                http_error(r, 400, "bad path");
                return;
            }
            char spec[1400];
            snprintf(spec, sizeof spec, "%s:%s", ref, path);
            struct buf out;
            buf_init(&out);
            const char *av[] = { "git", "show", spec, NULL };
            if (git_capture(repo_dir, av, &out) != 0 ||
                out.len > WC_TEXT_MAX ||
                memchr(out.data, 0, out.len)) {
                buf_free(&out);
                free(ref);
                http_error(r, 400,
                           "file missing, binary or too large to edit");
                return;
            }
            wc_form(app, r, rk, o, n, "edit file", "edit", ref, path,
                    out.data, out.len, NULL, 0);
            buf_free(&out);
        } else {
            if (path[0] && !git_path_ok(path)) {
                free(ref);
                http_error(r, 400, "bad path");
                return;
            }
            wc_form(app, r, rk, o, n,
                    is_upload ? "upload file" : "new file",
                    mode, ref, path, NULL, 0, NULL, is_upload);
        }
        free(ref);
        return;
    }

    /* ---------------- POST ---------------- */
    char ref[256], path[1024], msg[512];
    const char *data = NULL;
    size_t dlen = 0;
    char *body = NULL;
    char *content = NULL;             /* malloc'd editor content */

    if (is_upload) {
        char bnd[240];
        if (wc_mp_boundary(r, bnd, sizeof bnd) != 0) {
            http_error(r, 400, "bad multipart body");
            return;
        }
        size_t blen;
        body = gt_read_form(r, WC_FILE_MAX + 8192, &blen);
        if (!body) {
            http_error(r, 400, "upload too large");
            return;
        }
        if (!wc_mp_csrf_ok(r, body, blen, bnd)) {
            free(body);
            http_error(r, 403, "bad csrf token");
            return;
        }
        const char *d;
        size_t dl;
        char fn[256] = "";
        if (!wc_mp_find(body, blen, bnd, "file", &d, &dl,
                     fn, sizeof fn) || dl == 0) {
            free(body);
            http_error(r, 400, "no file in upload");
            return;
        }
        data = d;
        dlen = dl;
        /* small text fields ride as plain multipart parts */
        const char *fd;
        size_t fl;
        ref[0] = path[0] = msg[0] = '\0';
        if (wc_mp_find(body, blen, bnd, "ref", &fd, &fl, NULL, 0))
            snprintf(ref, sizeof ref, "%.*s", (int)fl, fd);
        if (wc_mp_find(body, blen, bnd, "path", &fd, &fl, NULL, 0))
            snprintf(path, sizeof path, "%.*s", (int)fl, fd);
        if (wc_mp_find(body, blen, bnd, "msg", &fd, &fl, NULL, 0))
            snprintf(msg, sizeof msg, "%.*s", (int)fl, fd);
        /* empty or dir-only path -> take the client's filename */
        if (!path[0] || ends_with(path, "/")) {
            char bn[256];
            if (wc_basename(fn, bn, sizeof bn) != 0) {
                free(body);
                http_error(r, 400, "bad filename");
                return;
            }
            size_t pl = strlen(path);
            snprintf(path + pl, sizeof path - pl, "%s", bn);
        }
    } else {
        size_t blen;
        body = gt_read_post(r, WC_TEXT_MAX + 8192, &blen);
        if (!body)
            return;
        form_get(body, blen, "ref", ref, sizeof ref);
        form_get(body, blen, "path", path, sizeof path);
        form_get(body, blen, "msg", msg, sizeof msg);
        content = xmalloc(WC_TEXT_MAX);
        if (form_get(body, blen, "content", content,
                     WC_TEXT_MAX) != 0) {
            free(body);
            free(content);
            http_error(r, 400, "no content");
            return;
        }
        free(body);
        body = NULL;
        data = content;
        dlen = strlen(content);
    }

    int bad = !git_ref_ok(ref) || !git_path_ok(path) || !path[0] ||
              starts_with(path, ".git/") || strcmp(path, ".git") == 0 ||
              strstr(path, "/.git/") != NULL;
    if (bad) {
        free(body);
        free(content);
        http_error(r, 400, "bad ref or path");
        return;
    }
    wc_commit_and_show(app, r, rk, repo_dir, o, n, mode, ref, path,
                       data, dlen, msg, is_upload);
    free(body);
    free(content);
}

void web_repo(void *ctx, struct request *r,
              const char *owner, const char *repo, const char *sub)
{
    struct app_ctx *app = ctx;
    char repo_dir[PATH_MAX];
    if (repo_path(repo_dir, sizeof repo_dir, app->repos_root, owner, repo)
        != 0) {
        http_error(r, 404, "no such repository");
        return;
    }
    /* the mirror-sync trigger is authenticated by webhook signature,
     * not by session: route it before the read-permission gate so
     * private mirrors can be driven by upstream push events */
    if (strcmp(sub, "/mirror-sync") == 0) {
        mirror_trigger(app, r, owner, repo);
        return;
    }

    /* hide existence of repos the user cannot read */
    int rk = perm_rank(repo_perm(app->db, owner, repo, r->user_id));
    if (rk < 1) {
        http_error(r, 404, "no such repository");
        return;
    }

    int is_get = strcmp(r->method, "GET") == 0;

    /* collaboration routes accept POST; code views are GET-only */
    if (starts_with(sub, "/issues")) {
        collab_issues(app, r, repo_dir, owner, repo, rk, sub + 7);
        return;
    }
    if (starts_with(sub, "/mr")) {
        collab_mrs(app, r, repo_dir, owner, repo, rk, sub + 3);
        return;
    }
    if (starts_with(sub, "/patches")) {
        collab_patches(app, r, repo_dir, owner, repo, rk, sub + 8);
        return;
    }
    if (strcmp(sub, "/settings") == 0) {
        collab_settings(app, r, owner, repo, rk);
        return;
    }
    if (starts_with(sub, "/ci")) {
        view_ci(app, r, repo_dir, rk, owner, repo, sub + 3);
        return;
    }
    if (strcmp(sub, "/activity") == 0) {
        collab_activity(app, r, owner, repo, rk);
        return;
    }
    if (strcmp(sub, "/changes") == 0) {
        collab_changes(app, r, owner, repo, rk);
        return;
    }
    if (strcmp(sub, "/new-file") == 0 || strcmp(sub, "/edit") == 0 ||
        strcmp(sub, "/upload") == 0) {
        wc_view(app, r, repo_dir, rk, owner, repo, sub + 1);
        return;
    }

    if (!is_get) {
        http_error(r, 405, "method not allowed");
        return;
    }

    if (strcmp(sub, "") == 0 || strcmp(sub, "/") == 0) {
        char loc[300];
        snprintf(loc, sizeof loc, "/%s/%s/tree", owner, repo);
        http_redirect(r, loc);
    } else if (strcmp(sub, "/tree") == 0) {
        view_tree(app, r, repo_dir, rk, owner, repo);
    } else if (strcmp(sub, "/blob") == 0) {
        view_blob(app, r, repo_dir, rk, owner, repo);
    } else if (strcmp(sub, "/log") == 0) {
        view_log(app, r, repo_dir, rk, owner, repo);
    } else if (strcmp(sub, "/feed") == 0) {
        view_feed(app, r, repo_dir, rk, owner, repo);
    } else if (strcmp(sub, "/feed.atom") == 0) {
        view_feed_atom(app, r, repo_dir, owner, repo);
    } else if (strcmp(sub, "/search") == 0) {
        view_search(app, r, repo_dir, rk, owner, repo);
    } else if (starts_with(sub, "/commit/")) {
        view_commit(app, r, repo_dir, rk, owner, repo, sub + 8);
    } else if (strcmp(sub, "/refs") == 0) {
        view_refs(app, r, repo_dir, rk, owner, repo);
    } else {
        http_error(r, 404, "unknown view");
    }
}

/* ---------------- auth pages ---------------- */

static void login_form(struct request *r, const char *err, int open_reg)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, "log in", r);
    buf_puts(&b, "<h1>Log in</h1>");
    if (err) {
        buf_puts(&b, "<p style='color:#a00'>");
        buf_put_escaped(&b, err, strlen(err));
        buf_puts(&b, "</p>");
    }
    buf_puts(&b,
        "<form method=post action='/login'>"
        "<p>name <input name=name required maxlength=63 "
        "pattern='[A-Za-z0-9][A-Za-z0-9._-]*'></p>"
        "<p>password <input name=pass type=password required></p>"
        "<p><button type=submit>log in</button></p></form>");
    if (open_reg)
        buf_puts(&b, "<p class=muted>no account? "
                     "<a href='/register'>register</a></p>");
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

void web_login(void *ctx, struct request *r)
{
    struct app_ctx *app = ctx;

    if (strcmp(r->method, "GET") == 0) {
        login_form(r, NULL, app->open_reg);
        return;
    }
    if (strcmp(r->method, "POST") != 0) {
        http_error(r, 405, "method not allowed");
        return;
    }
    if (r->content_length <= 0 || r->content_length > 8192) {
        http_error(r, 413, "bad form body");
        return;
    }

    char *body = xmalloc((size_t)r->content_length + 1);
    size_t got = 0;
    while (got < (size_t)r->content_length) {
        long n = body_read(r, body + got,
                           (size_t)r->content_length - got);
        if (n <= 0)
            break;
        got += (size_t)n;
    }
    body[got] = '\0';

    char name[64], pass[256];
    int uid = -1;
    if (form_get(body, got, "name", name, sizeof name) == 0 &&
        form_get(body, got, "pass", pass, sizeof pass) == 0 &&
        name_ok(name))
        uid = auth_check_secret(app->db, name, pass);
    memset(body, 0, got);
    free(body);
    memset(pass, 0, sizeof pass);

    if (uid < 0) {
        rl_fail(r->remote_ip);
        db_audit(app->db, 0, name[0] ? name : NULL, r->remote_ip, 0,
                 "login_fail", NULL);
        usleep(500 * 1000);           /* blunt online brute force */
        login_form(r, "invalid credentials", app->open_reg);
        return;
    }
    rl_ok(r->remote_ip);
    db_audit(app->db, uid, name, r->remote_ip, 0, "login", NULL);
    char tok[65];
    if (session_create(app->db, uid, tok) != 0) {
        http_error(r, 500, "cannot create session");
        return;
    }
    char cookie[160];
    snprintf(cookie, sizeof cookie,
             "gt_session=%s; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000",
             tok);
    http_redirect_cookie(r, "/", cookie);
}

void web_logout(void *ctx, struct request *r)
{
    struct app_ctx *app = ctx;
    char tok[80];
    char *hv = req_header_dup(r, "cookie");
    if (hv) {
        char *p = strstr(hv, "gt_session=");
        if (p) {
            p += 11;
            size_t i = 0;
            while (i < sizeof(tok) - 1 && p[i] && p[i] != ';') {
                tok[i] = p[i];
                i++;
            }
            tok[i] = '\0';
            if (strlen(tok) == 64)
                session_delete(app->db, tok);
        }
        free(hv);
    }
    http_redirect_cookie(r, "/",
        "gt_session=; Path=/; HttpOnly; Max-Age=0");
}

/* ---------------- registration ---------------- */

static void reg_form(struct request *r, const char *err)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, "register", r);
    buf_puts(&b, "<h1>Register</h1>");
    if (err) {
        buf_puts(&b, "<p class=err>");
        gt_esc(&b, err);
        buf_puts(&b, "</p>");
    }
    buf_puts(&b,
        "<form method=post action='/register'>"
        "<p>name <input name=name required maxlength=63 "
        "pattern='[A-Za-z0-9][A-Za-z0-9._-]*'></p>"
        "<p>email <input name=email maxlength=200 "
        "placeholder='optional — enables mail-in patch auth'></p>"
        "<p>password <input name=pass type=password required "
        "minlength=8></p>"
        "<p>repeat password <input name=pass2 type=password required "
        "minlength=8></p>"
        "<p><button type=submit>register</button></p></form>"
        "<p class=muted>Free accounts get 500 MiB of storage across "
        "all repositories and unlimited public repos.</p>");
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

/*
 * Public sign-up, enabled by GIT_TRACE_OPEN_REG=1.  Anonymous endpoint:
 * no session exists to protect, so CSRF tokens don't apply — the
 * Origin check in httpd still rejects cross-site POSTs.  Rate limited
 * like the login path to blunt account farming.
 */
void web_register(void *ctx, struct request *r)
{
    struct app_ctx *app = ctx;

    if (!app->open_reg) {
        http_error(r, 404, "not found");
        return;
    }
    if (strcmp(r->method, "GET") == 0) {
        reg_form(r, NULL);
        return;
    }
    if (strcmp(r->method, "POST") != 0) {
        http_error(r, 405, "method not allowed");
        return;
    }
    int d = rl_check(r->remote_ip);
    if (d < 0) {
        http_error(r, 429, "too many attempts");
        return;
    }
    if (d > 0)
        usleep((useconds_t)d * 1000);
    if (r->content_length <= 0 || r->content_length > 8192) {
        http_error(r, 413, "bad form body");
        return;
    }

    char *body = xmalloc((size_t)r->content_length + 1);
    size_t got = 0;
    while (got < (size_t)r->content_length) {
        long n = body_read(r, body + got,
                           (size_t)r->content_length - got);
        if (n <= 0)
            break;
        got += (size_t)n;
    }
    body[got] = '\0';

    char name[64] = "", pass[256] = "", pass2[256] = "", email[256] = "";
    const char *err = NULL;
    form_get(body, got, "name", name, sizeof name);
    form_get(body, got, "pass", pass, sizeof pass);
    form_get(body, got, "pass2", pass2, sizeof pass2);
    form_get(body, got, "email", email, sizeof email);

    int uid = -1;
    if (!name_ok(name))
        err = "invalid user name";
    else if (strlen(pass) < 8)
        err = "password too short (8+ characters)";
    else if (strcmp(pass, pass2) != 0)
        err = "passwords do not match";
    else if (auth_user_id(app->db, name) >= 0)
        err = "name already taken";
    else if (auth_user_add(app->db, name, pass,
                           email[0] ? email : NULL, 0) == 0)
        uid = auth_user_id(app->db, name);

    memset(body, 0, got);
    free(body);
    memset(pass, 0, sizeof pass);
    memset(pass2, 0, sizeof pass2);

    if (uid < 0) {
        rl_fail(r->remote_ip);
        reg_form(r, err ? err : "registration failed");
        return;
    }
    db_audit(app->db, uid, name, r->remote_ip, 0, "register", NULL);
    char tok[65];
    if (session_create(app->db, uid, tok) != 0) {
        http_error(r, 500, "cannot create session");
        return;
    }
    char cookie[160];
    snprintf(cookie, sizeof cookie,
             "gt_session=%s; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000",
             tok);
    http_redirect_cookie(r, "/", cookie);
}

/* ---------------- account self-service ----------------
 * /account: profile (email), password change, SSH keys and API
 * tokens — everything the CLI can do on a user, self-served */

static void account_page(struct app_ctx *app, struct request *r,
                         const char *msg, int is_err, const char *newtok)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, "account", r);
    buf_puts(&b, "<h1>account</h1>");
    if (msg) {
        buf_printf(&b, "<p class=%s>", is_err ? "err" : "ok");
        gt_esc(&b, msg);
        buf_puts(&b, "</p>");
    }

    /* profile: name, tier, email */
    long texp = 0;
    const char *tier = auth_user_tier(app->db, r->user_id, &texp);
    buf_printf(&b, "<h3>profile</h3><p>user <b>");
    gt_esc(&b, r->user_name);
    buf_puts(&b, "</b> &middot; tier <b>");
    gt_esc(&b, tier);
    buf_puts(&b, "</b>");
    if (texp) {
        char d[32];
        struct tm tm;
        time_t tt = texp;
        gmtime_r(&tt, &tm);
        strftime(d, sizeof d, "%Y-%m-%d", &tm);
        buf_printf(&b, " (expires ");
        gt_esc(&b, d);
        buf_puts(&b, ")");
    }
    buf_puts(&b, "</p>");

    char email[256] = "";
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT email FROM users WHERE id=?", -1, &st, NULL)
        == SQLITE_OK) {
        sqlite3_bind_int(st, 1, r->user_id);
        if (sqlite3_step(st) == SQLITE_ROW &&
            sqlite3_column_text(st, 0))
            snprintf(email, sizeof email, "%s",
                     (const char *)sqlite3_column_text(st, 0));
        sqlite3_finalize(st);
    }
    buf_puts(&b, "<form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=email>"
                 "<p>email <input name=email size=40 value='");
    gt_esc(&b, email);
    buf_puts(&b, "' placeholder='used by mail-in patch auth'></p>"
                 "<p><button>save</button></p></form>");

    /* my repositories: owned + shared via ACL */
    buf_puts(&b, "<h3>your repositories</h3><table>"
                 "<tr><th>repo</th><th>visibility</th><th>size</th>"
                 "<th></th></tr>");
    int myn = 0;
    if (sqlite3_prepare_v2(app->db,
            "SELECT name,is_public,size_kb FROM repos"
            " WHERE owner_id=? ORDER BY name", -1, &st, NULL)
        == SQLITE_OK) {
        sqlite3_bind_int(st, 1, r->user_id);
        while (sqlite3_step(st) == SQLITE_ROW) {
            const char *nm = (const char *)sqlite3_column_text(st, 0);
            if (!nm)
                continue;
            buf_printf(&b, "<tr><td><a href='/%s/", r->user_name);
            gt_esc(&b, nm);
            buf_puts(&b, "/tree'>");
            gt_esc(&b, r->user_name);
            buf_puts(&b, " / ");
            gt_esc(&b, nm);
            buf_printf(&b, "</a></td><td><span class='badge %s'>%s</span>"
                           "</td><td class=muted>",
                       sqlite3_column_int(st, 1) ? "bpub" : "bpriv",
                       sqlite3_column_int(st, 1) ? "public" : "private");
            size_fmt(&b, sqlite3_column_int64(st, 2));
            buf_puts(&b, "</td><td><a href='/");
            gt_esc(&b, r->user_name);
            buf_put(&b, "/", 1);
            gt_esc(&b, nm);
            buf_puts(&b, "/settings'>settings</a></td></tr>");
            myn++;
        }
        sqlite3_finalize(st);
    }
    if (sqlite3_prepare_v2(app->db,
            "SELECT COALESCE(u.name,'_'), rp.name, a.role, rp.is_public"
            " FROM repo_acl a JOIN repos rp ON rp.id=a.repo_id"
            " LEFT JOIN users u ON u.id=rp.owner_id"
            " WHERE a.user_id=? AND rp.owner_id IS NOT ?"
            " ORDER BY 1,2", -1, &st, NULL) == SQLITE_OK) {
        sqlite3_bind_int(st, 1, r->user_id);
        sqlite3_bind_int(st, 2, r->user_id);
        while (sqlite3_step(st) == SQLITE_ROW) {
            const char *on = (const char *)sqlite3_column_text(st, 0);
            const char *rn = (const char *)sqlite3_column_text(st, 1);
            const char *role = (const char *)sqlite3_column_text(st, 2);
            if (!on || !rn)
                continue;
            buf_printf(&b, "<tr><td><a href='/%s/%s/tree'>%s / %s</a>"
                           " <span class=muted>(%s)</span></td>"
                           "<td><span class='badge %s'>%s</span></td>"
                           "<td class=muted></td><td>",
                       on, rn, on, rn, role && role[0] ? role : "r",
                       sqlite3_column_int(st, 3) ? "bpub" : "bpriv",
                       sqlite3_column_int(st, 3) ? "public" : "private");
            if (role && role[0] == 'a')
                buf_printf(&b, "<a href='/%s/%s/settings'>settings</a>",
                           on, rn);
            buf_puts(&b, "</td></tr>");
            myn++;
        }
        sqlite3_finalize(st);
    }
    buf_puts(&b, "</table>");
    if (!myn)
        buf_puts(&b, "<p class=muted>No repositories yet — "
                     "<a href='/new'>create one</a>.</p>");

    /* password change */
    buf_puts(&b, "<h3>password</h3><form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=passwd>"
                 "<p>current <input name=cur type=password required></p>"
                 "<p>new <input name=pass type=password required "
                 "minlength=8></p>"
                 "<p>repeat <input name=pass2 type=password required "
                 "minlength=8></p>"
                 "<p><button>change password</button></p></form>");

    /* API tokens */
    buf_puts(&b, "<h3>api tokens</h3><table>"
                 "<tr><th>label</th><th>created</th><th></th></tr>");
    sqlite3_prepare_v2(app->db,
        "SELECT id,name,created_at FROM auth_tokens"
        " WHERE user_id=? ORDER BY id", -1, &st, NULL);
    sqlite3_bind_int(st, 1, r->user_id);
    while (sqlite3_step(st) == SQLITE_ROW) {
        long id = sqlite3_column_int64(st, 0);
        const char *lab = (const char *)sqlite3_column_text(st, 1);
        time_t tt = sqlite3_column_int64(st, 2);
        char d[32];
        struct tm tm;
        gmtime_r(&tt, &tm);
        strftime(d, sizeof d, "%Y-%m-%d", &tm);
        buf_puts(&b, "<tr><td>");
        gt_esc(&b, lab ? lab : "token");
        buf_puts(&b, "</td><td class=muted>");
        gt_esc(&b, d);
        buf_puts(&b, "</td><td><form method=post style='display:inline'>");
        gt_csrf_input(&b, r);
        buf_printf(&b, "<input type=hidden name=action value=tok_rm>"
                       "<input type=hidden name=id value=%ld>"
                       "<button>revoke</button></form></td></tr>", id);
    }
    sqlite3_finalize(st);
    buf_puts(&b, "</table><form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=tok_add>"
                 "<p><input name=label required maxlength=63 "
                 "placeholder='laptop or mail'></p>"
                 "<p><button>create token</button></p></form>");
    if (newtok) {
        buf_puts(&b, "<p class=ok>new token (shown once): "
                     "<code class=mono>");
        gt_esc(&b, newtok);
        buf_puts(&b, "</code></p>");
    }

    /* SSH keys */
    buf_puts(&b, "<h3>ssh keys</h3><table>"
                 "<tr><th>label</th><th>fingerprint</th><th></th></tr>");
    sqlite3_prepare_v2(app->db,
        "SELECT id,name,fingerprint FROM ssh_keys"
        " WHERE user_id=? ORDER BY id", -1, &st, NULL);
    sqlite3_bind_int(st, 1, r->user_id);
    while (sqlite3_step(st) == SQLITE_ROW) {
        long id = sqlite3_column_int64(st, 0);
        const char *lab = (const char *)sqlite3_column_text(st, 1);
        const char *fp = (const char *)sqlite3_column_text(st, 2);
        buf_puts(&b, "<tr><td>");
        gt_esc(&b, lab ? lab : "key");
        buf_puts(&b, "</td><td class=mono>");
        gt_esc(&b, fp);
        buf_puts(&b, "</td><td><form method=post style='display:inline'>");
        gt_csrf_input(&b, r);
        buf_printf(&b, "<input type=hidden name=action value=key_rm>"
                       "<input type=hidden name=id value=%ld>"
                       "<button>remove</button></form></td></tr>", id);
    }
    sqlite3_finalize(st);
    buf_puts(&b, "</table><form method=post>");
    gt_csrf_input(&b, r);
    buf_puts(&b, "<input type=hidden name=action value=key_add>"
                 "<p><input name=key required size=60 "
                 "placeholder='ssh-ed25519 AAAA... comment'> "
                 "<input name=label maxlength=63 placeholder='label'></p>"
                 "<p><button>add key</button></p></form>"
                 "<p class=muted>Keys are bound to your account and "
                 "unlock <code>git push</code>/<code>pull</code> over "
                 "SSH immediately — no server restart needed.</p>");

    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

void web_account(void *ctx, struct request *r)
{
    struct app_ctx *app = ctx;
    if (r->user_id <= 0) {
        http_redirect(r, "/login");
        return;
    }
    if (strcmp(r->method, "GET") == 0) {
        account_page(app, r, NULL, 0, NULL);
        return;
    }
    if (strcmp(r->method, "POST") != 0) {
        http_error(r, 405, "method not allowed");
        return;
    }

    size_t blen;
    char *body = gt_read_post(r, 65536, &blen);
    if (!body)
        return;

    char act[16] = "", msg[160] = "";
    int is_err = 0;
    char newtok[80] = "";
    form_get(body, blen, "action", act, sizeof act);

    if (strcmp(act, "passwd") == 0) {
        char cur[256] = "", p1[256] = "", p2[256] = "";
        form_get(body, blen, "cur", cur, sizeof cur);
        form_get(body, blen, "pass", p1, sizeof p1);
        form_get(body, blen, "pass2", p2, sizeof p2);
        if (strlen(p1) < 8) {
            snprintf(msg, sizeof msg, "password too short (8+)");
            is_err = 1;
        } else if (strcmp(p1, p2) != 0) {
            snprintf(msg, sizeof msg, "passwords do not match");
            is_err = 1;
        } else if (auth_check_secret(app->db, r->user_name, cur) < 0) {
            snprintf(msg, sizeof msg, "current password incorrect");
            is_err = 1;
            rl_fail(r->remote_ip);
        } else if (auth_user_passwd(app->db, r->user_name, p1) == 0) {
            snprintf(msg, sizeof msg, "password updated");
            db_audit(app->db, r->user_id, r->user_name, r->remote_ip, 0,
                     "passwd", NULL);
        }
        gt_bzero(cur, sizeof cur);
        gt_bzero(p1, sizeof p1);
        gt_bzero(p2, sizeof p2);
    } else if (strcmp(act, "email") == 0) {
        char em[256] = "";
        form_get(body, blen, "email", em, sizeof em);
        const char *at = strchr(em, '@');
        if (em[0] && (!at || !strchr(at, '.'))) {
            snprintf(msg, sizeof msg, "invalid email address");
            is_err = 1;
        } else if (auth_set_email(app->db, r->user_id, em) == 0) {
            snprintf(msg, sizeof msg, "email %s",
                     em[0] ? "updated" : "cleared");
        } else {
            snprintf(msg, sizeof msg, "email update failed");
            is_err = 1;
        }
    } else if (strcmp(act, "tok_add") == 0) {
        char label[64] = "";
        form_get(body, blen, "label", label, sizeof label);
        if (label[0] &&
            auth_mktoken(app->db, r->user_name, label,
                         newtok, sizeof newtok) == 0) {
            db_audit(app->db, r->user_id, r->user_name, r->remote_ip, 0,
                     "token_create", "%s", label);
        } else {
            snprintf(msg, sizeof msg, "token creation failed");
            is_err = 1;
        }
    } else if (strcmp(act, "tok_rm") == 0) {
        char id[16] = "";
        form_get(body, blen, "id", id, sizeof id);
        if (auth_token_rm(app->db, r->user_id, strtol(id, NULL, 10)) == 0) {
            snprintf(msg, sizeof msg, "token revoked");
            db_audit(app->db, r->user_id, r->user_name, r->remote_ip, 0,
                     "token_revoke", "id=%s", id);
        } else {
            snprintf(msg, sizeof msg, "no such token");
            is_err = 1;
        }
    } else if (strcmp(act, "key_add") == 0) {
        char key[1400] = "", label[64] = "";
        form_get(body, blen, "key", key, sizeof key);
        form_get(body, blen, "label", label, sizeof label);
        char *eol = strpbrk(key, "\r\n");       /* paste may carry them */
        if (eol)
            *eol = '\0';
        if (key[0] && sshkey_add(app->db, r->user_name, key,
                                 label[0] ? label : NULL) > 0) {
            ssh_keyfile_write(app);      /* key live for sshd right away */
            snprintf(msg, sizeof msg, "key added");
            db_audit(app->db, r->user_id, r->user_name, r->remote_ip, 0,
                     "sshkey_add", "%s", label);
        } else {
            snprintf(msg, sizeof msg, "invalid or duplicate key");
            is_err = 1;
        }
    } else if (strcmp(act, "key_rm") == 0) {
        char id[16] = "";
        form_get(body, blen, "id", id, sizeof id);
        if (sshkey_rm_own(app->db, r->user_id, strtol(id, NULL, 10)) == 0) {
            ssh_keyfile_write(app);
            snprintf(msg, sizeof msg, "key removed");
            db_audit(app->db, r->user_id, r->user_name, r->remote_ip, 0,
                     "sshkey_rm", "id=%s", id);
        } else {
            snprintf(msg, sizeof msg, "no such key");
            is_err = 1;
        }
    } else {
        snprintf(msg, sizeof msg, "unknown action");
        is_err = 1;
    }
    free(body);
    account_page(app, r, msg[0] ? msg : NULL, is_err,
                 newtok[0] ? newtok : NULL);
}

/* ---------------- new repository ---------------- */

/* entitlement gate: opt-in — a self-hosted instance has no paywall;
 * the hosted service sets GIT_TRACE_TIER_GATE=1 */
static int tier_gate_on(void)
{
    const char *v = getenv("GIT_TRACE_TIER_GATE");
    return v && v[0] && strcmp(v, "0") != 0;
}

static void new_form(struct app_ctx *app, struct request *r,
                     const char *err)
{
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, "new repository", r);
    buf_puts(&b, "<h1>New repository</h1>");
    if (err) {
        buf_puts(&b, "<p class=err>");
        gt_esc(&b, err);
        buf_puts(&b, "</p>");
    }
    buf_puts(&b, "<form method=post action='/new'>");
    gt_csrf_input(&b, r);
    buf_puts(&b,
        "<p>name <input name=name required maxlength=63 "
        "pattern='[A-Za-z0-9][A-Za-z0-9._-]*'></p>"
        "<p>description <input name=desc maxlength=200></p>");
    if (tier_gate_on() &&
        tier_rank(auth_user_tier(app->db, r->user_id, NULL)) < 1) {
        buf_puts(&b,
            "<p><label><input type=checkbox name=private disabled> "
            "private</label> — requires the <b>pro</b> tier</p>");
    } else {
        buf_puts(&b,
            "<p><label><input type=checkbox name=private> private"
            "</label></p>");
    }
    buf_puts(&b,
        "<p><button type=submit>Create repository</button></p></form>");
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}

void web_new(void *ctx, struct request *r)
{
    struct app_ctx *app = ctx;
    if (strcmp(r->method, "GET") == 0) {
        if (r->user_id <= 0) {
            http_redirect(r, "/login");
            return;
        }
        new_form(app, r, NULL);
        return;
    }

    size_t blen;
    char *body = gt_read_post(r, 65536, &blen);
    if (!body)
        return;

    char name[64] = "", desc[512] = "", priv[8] = "";
    form_get(body, blen, "name", name, sizeof name);
    form_get(body, blen, "desc", desc, sizeof desc);
    form_get(body, blen, "private", priv, sizeof priv);
    free(body);

    const char *err = NULL;
    if (!name_ok(name))
        err = "invalid repository name";
    else if (repo_exists(app->repos_root, r->user_name, name))
        err = "repository already exists";

    if (err) {
        rl_fail(r->remote_ip);
        new_form(app, r, err);
        return;
    }

    int is_priv = strcmp(priv, "on") == 0;
    if (is_priv && tier_gate_on() && !auth_is_admin(app->db, r->user_id)
        && tier_rank(auth_user_tier(app->db, r->user_id, NULL)) < 1) {
        db_audit(app->db, r->user_id, r->user_name, r->remote_ip, 0,
                 "tier_denied", "private repo %s/%s", r->user_name, name);
        new_form(app, r,
                 "private repositories require the pro tier");
        return;
    }
    if (repo_create(app, r->user_id, r->user_name, name,
                    desc[0] ? desc : NULL, is_priv) != 0) {
        rl_fail(r->remote_ip);
        http_error(r, 500, "cannot create repository");
        return;
    }
    db_audit(app->db, r->user_id, r->user_name, r->remote_ip, 0,
             "repo_create", "%s/%s.git%s", r->user_name, name,
             is_priv ? " (private)" : "");
    char loc[256];
    snprintf(loc, sizeof loc, "/%s/%s/tree", r->user_name, name);
    http_redirect(r, loc);
}

/* ---------------- global audit log (admin only) ---------------- */

void web_audit(void *ctx, struct request *r)
{
    struct app_ctx *app = ctx;
    if (r->user_id <= 0 || !auth_is_admin(app->db, r->user_id)) {
        http_error(r, 403, "admin only");
        return;
    }
    if (strcmp(r->method, "GET") != 0) {
        http_error(r, 405, "method not allowed");
        return;
    }
    struct buf b;
    buf_init(&b);
    gt_page_top(&b, "audit", r);
    buf_puts(&b, "<h1>Audit log</h1>");
    buf_puts(&b, "<table><tr><th>time</th><th>actor</th><th>ip</th>"
                "<th>action</th><th>repo</th><th>detail</th></tr>");
    sqlite3_stmt *st;
    if (sqlite3_prepare_v2(app->db,
            "SELECT strftime('%Y-%m-%d %H:%M',a.ts,'unixepoch'),"
            " COALESCE(a.actor,'(anon)'), COALESCE(a.ip,'-'), a.action,"
            " COALESCE(ou.name||'/'||r.name,'-'), a.detail"
            " FROM audit_log a LEFT JOIN repos r ON r.id=a.repo_id"
            " LEFT JOIN users ou ON ou.id=r.owner_id"
            " ORDER BY a.id DESC LIMIT 200",
            -1, &st, NULL) == SQLITE_OK) {
        while (sqlite3_step(st) == SQLITE_ROW) {
            buf_puts(&b, "<tr><td class=muted>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 0));
            buf_puts(&b, "</td><td>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 1));
            buf_puts(&b, "</td><td class=muted>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 2));
            buf_puts(&b, "</td><td>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 3));
            buf_puts(&b, "</td><td class=muted>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 4));
            buf_puts(&b, "</td><td class=muted>");
            gt_esc(&b, (const char *)sqlite3_column_text(st, 5));
            buf_puts(&b, "</td></tr>");
        }
        sqlite3_finalize(st);
    }
    buf_puts(&b, "</table>");
    gt_page_bottom(&b);
    gt_send_page(r, &b);
    buf_free(&b);
}