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 · ", ncommit, ncommit == 1 ? "" : "s"); buf_printf(b, "<b>%d</b> branch%s · <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, " · <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, " · first commit "); rel_time(b, first_at); } } buf_free(&gb); long mt = repo_mtime(rdir); if (mt) { buf_puts(b, " · 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 · 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 · %d user%s" " · ", 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'>" "⇪ 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, " · 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 — " "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, "📁 <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, "📄 <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, "🔗 "); } 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, " · <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, " · "); 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'/>" " — <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, " <"); buf_put_escaped(&b, ae ? ae : "", ae ? strlen(ae) : 0); buf_puts(&b, "></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> · 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); }