romele / git-trace public
62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 11m ago
src/collab.c
#include "collab.h" #include "auth.h" #include "gitx.h" #include "mirror.h" #include "repo.h" #include "util.h" #include "webhook.h" #include <errno.h> #include <ftw.h> #include <limits.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/stat.h> /* recursive remove for repo deletion (depth-first, no symlinks) */ static int rm_cb(const char *p, const struct stat *st, int flag, struct FTW *f) { (void)st; (void)flag; (void)f; return remove(p); } /* ---------------- shared helpers ---------------- */ int gt_repo_rowid(sqlite3 *db, const char *owner, const char *name) { sqlite3_stmt *st; if (sqlite3_prepare_v2(db, "SELECT r.id 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) return -1; sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 2, owner, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, owner, -1, SQLITE_TRANSIENT); int id = (sqlite3_step(st) == SQLITE_ROW) ? sqlite3_column_int(st, 0) : -1; sqlite3_finalize(st); return id; } char *gt_read_form(struct request *r, size_t max, size_t *outlen) { if (r->content_length <= 0 || (size_t)r->content_length > max) return NULL; size_t cap = (size_t)r->content_length; char *body = xmalloc(cap + 1); size_t got = 0; while (got < cap) { long n = body_read(r, body + got, cap - got); if (n <= 0) break; got += (size_t)n; } body[got] = '\0'; *outlen = got; return body; } /* Cookie-authenticated POSTs must carry the session token as a hidden * form field — binds the request to the browser session and defeats * CSRF. Requests without a cookie (Basic-auth API clients, e.g. * `curl -u user:token`) are not ambient and need no token. */ int gt_csrf_ok(struct request *r, const char *body, size_t n) { if (!r->session_tok[0]) return r->user_id > 0; char f[80]; if (form_get(body, n, "csrf", f, sizeof f) != 0) return 0; return strcmp(f, r->session_tok) == 0; } void gt_csrf_input(struct buf *b, struct request *r) { buf_puts(b, "<input type=hidden name=csrf value='"); buf_put_escaped(b, r->session_tok, strlen(r->session_tok)); buf_puts(b, "'>"); } int gt_require_post(struct request *r) { if (r->user_id == 0) { http_redirect(r, "/login"); return 0; } if (strcmp(r->method, "POST") != 0) { http_error(r, 405, "method not allowed"); return 0; } return 1; } /* Read a form POST body and enforce CSRF in one step — emits the * right error on failure. Success: caller owns the malloc'd body. */ char *gt_read_post(struct request *r, size_t max, size_t *outlen) { if (!gt_require_post(r)) return NULL; char *body = gt_read_form(r, max, outlen); if (!body) { http_error(r, 400, "bad form body"); return NULL; } if (!gt_csrf_ok(r, body, *outlen)) { free(body); http_error(r, 403, "bad csrf token"); return NULL; } return body; } void gt_esc(struct buf *b, const char *s) { if (s) buf_put_escaped(b, s, strlen(s)); } void gt_comment_list(struct buf *b, sqlite3 *db, const char *ptype, long parent) { sqlite3_stmt *st; if (sqlite3_prepare_v2(db, "SELECT c.body, COALESCE(u.name,'(deleted)')," " strftime('%Y-%m-%d %H:%M', c.created_at, 'unixepoch')" " FROM comments c LEFT JOIN users u ON u.id=c.author_id" " WHERE c.parent_type=? AND c.parent_id=? ORDER BY c.id", -1, &st, NULL) != SQLITE_OK) return; sqlite3_bind_text(st, 1, ptype, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 2, parent); while (sqlite3_step(st) == SQLITE_ROW) { buf_puts(b, "<tr><td class=muted style='width:14em'>"); gt_esc(b, (const char *)sqlite3_column_text(st, 1)); buf_puts(b, "<br>"); gt_esc(b, (const char *)sqlite3_column_text(st, 2)); buf_puts(b, "</td><td><pre style='border:0;background:none'>"); gt_esc(b, (const char *)sqlite3_column_text(st, 0)); buf_puts(b, "</pre></td></tr>"); } sqlite3_finalize(st); } void gt_comment_form(struct buf *b, struct request *r, const char *action) { buf_puts(b, "<form method=post action='"); buf_put_escaped(b, action, strlen(action)); buf_puts(b, "'>"); gt_csrf_input(b, r); buf_puts(b, "<input type=hidden name=action value=comment>" "<p><textarea name=body rows=5 cols=80 required></textarea></p>" "<p><button type=submit>comment</button></p></form>"); } int gt_add_comment(sqlite3 *db, const char *ptype, long parent, int uid, const char *body) { sqlite3_stmt *st; if (sqlite3_prepare_v2(db, "INSERT INTO comments(parent_type,parent_id,author_id,body)" " VALUES(?,?,?,?)", -1, &st, NULL) != SQLITE_OK) return -1; sqlite3_bind_text(st, 1, ptype, -1, SQLITE_TRANSIENT); sqlite3_bind_int64(st, 2, parent); sqlite3_bind_int(st, 3, uid); sqlite3_bind_text(st, 4, body, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(st); sqlite3_finalize(st); return rc == SQLITE_DONE ? 0 : -1; } /* ---------------- issues ---------------- */ static void issue_list(struct app_ctx *app, struct request *r, const char *o, const char *n, int rk, int repo_id) { struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "issues"); if (r->user_id > 0) buf_printf(&b, "<p><a class=btn2 href='/%s/%s/issues/new'>" I_PLUS "new issue</a></p>", o, n); sqlite3_stmt *st; if (sqlite3_prepare_v2(app->db, "SELECT i.id,i.title,i.state,COALESCE(u.name,'(deleted)')," " strftime('%Y-%m-%d', i.created_at, 'unixepoch')" " FROM issues i LEFT JOIN users u ON u.id=i.author_id" " WHERE i.repo_id=?" " ORDER BY (i.state='open') DESC, i.id DESC LIMIT 200", -1, &st, NULL) != SQLITE_OK) { buf_free(&b); http_error(r, 500, "db error"); return; } sqlite3_bind_int(st, 1, repo_id); buf_puts(&b, "<table><tr><th>#</th><th>title</th><th>state</th>" "<th>author</th><th>opened</th></tr>"); int any = 0; while (sqlite3_step(st) == SQLITE_ROW) { any = 1; buf_printf(&b, "<tr><td>%lld</td><td><a href='/%s/%s/issues/%lld'>", sqlite3_column_int64(st, 0), o, n, sqlite3_column_int64(st, 0)); gt_esc(&b, (const char *)sqlite3_column_text(st, 1)); buf_puts(&b, "</a></td><td class=muted>"); 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>"); } sqlite3_finalize(st); if (!any) buf_puts(&b, "<tr><td class=muted colspan=5>no issues</td></tr>"); buf_puts(&b, "</table>"); gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); } static void issue_new_form(struct app_ctx *app, struct request *r, const char *o, const char *n, int rk) { struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "issues"); buf_printf(&b, "<h3>new issue</h3>" "<form method=post action='/%s/%s/issues/new'>", o, n); gt_csrf_input(&b, r); buf_puts(&b, "<p><input name=title required maxlength=200 size=60 " "placeholder='title'></p>" "<p><textarea name=body rows=8 cols=80></textarea></p>" "<p><button type=submit>create issue</button></p></form>"); gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); } static void issue_view(struct app_ctx *app, struct request *r, const char *o, const char *n, int rk, int repo_id, long id) { sqlite3_stmt *st; if (sqlite3_prepare_v2(app->db, "SELECT i.title,i.state,COALESCE(u.name,'(deleted)'),i.body," " i.author_id," " strftime('%Y-%m-%d %H:%M', i.created_at, 'unixepoch')" " FROM issues i LEFT JOIN users u ON u.id=i.author_id" " WHERE i.id=? AND i.repo_id=?", -1, &st, NULL) != SQLITE_OK) { http_error(r, 500, "db error"); return; } sqlite3_bind_int64(st, 1, id); sqlite3_bind_int(st, 2, repo_id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); http_error(r, 404, "no such issue"); return; } struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "issues"); buf_printf(&b, "<h3>issue #%ld <span class=muted>(%s)</span></h3>", id, (const char *)sqlite3_column_text(st, 1)); buf_puts(&b, "<p><b>"); gt_esc(&b, (const char *)sqlite3_column_text(st, 0)); buf_puts(&b, "</b></p><p class=muted>opened by "); gt_esc(&b, (const char *)sqlite3_column_text(st, 2)); buf_puts(&b, " on "); gt_esc(&b, (const char *)sqlite3_column_text(st, 5)); buf_puts(&b, "</p><pre style='border:0;background:none'>"); gt_esc(&b, (const char *)sqlite3_column_text(st, 3)); buf_puts(&b, "</pre>"); int author = sqlite3_column_int(st, 4); int is_open = strcmp((const char *)sqlite3_column_text(st, 1), "open") == 0; sqlite3_finalize(st); buf_puts(&b, "<h4>comments</h4><table>"); gt_comment_list(&b, app->db, "issue", id); buf_puts(&b, "</table>"); if (r->user_id > 0) { gt_comment_form(&b, r, ""); if (r->user_id == author || rk >= 2) { buf_printf(&b, "<form method=post style='display:inline'>"); gt_csrf_input(&b, r); buf_printf(&b, "<input type=hidden name=action value=%s>" "<button>%s issue</button></form>", is_open ? "close" : "reopen", is_open ? "close" : "reopen"); } } gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); } static void issue_post(struct app_ctx *app, struct request *r, const char *o, const char *n, int repo_id, long id, int creating) { size_t blen; char *body = gt_read_post(r, 65536, &blen); if (!body) return; if (creating) { char title[210], ibody[8192]; if (form_get(body, blen, "title", title, sizeof title) != 0 || !title[0]) { free(body); http_error(r, 400, "title required"); return; } form_get(body, blen, "body", ibody, sizeof ibody); sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "INSERT INTO issues(repo_id,author_id,title,body)" " VALUES(?,?,?,?)", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_int(st, 2, r->user_id); sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, ibody, -1, SQLITE_TRANSIENT); sqlite3_step(st); long nid = sqlite3_last_insert_rowid(app->db); sqlite3_finalize(st); free(body); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "issue_create", "#%ld %.80s", nid, title); char loc[256]; snprintf(loc, sizeof loc, "/%s/%s/issues/%ld", o, n, nid); http_redirect(r, loc); return; } char act[16] = "", cbody[8192]; form_get(body, blen, "action", act, sizeof act); if (strcmp(act, "comment") == 0) { if (form_get(body, blen, "body", cbody, sizeof cbody) == 0 && cbody[0]) gt_add_comment(app->db, "issue", id, r->user_id, cbody); } else if (strcmp(act, "close") == 0 || strcmp(act, "reopen") == 0) { /* author or write perm; verified again here */ sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "SELECT author_id FROM issues WHERE id=? AND repo_id=?", -1, &st, NULL); sqlite3_bind_int64(st, 1, id); sqlite3_bind_int(st, 2, repo_id); int author = (sqlite3_step(st) == SQLITE_ROW) ? sqlite3_column_int(st, 0) : -1; sqlite3_finalize(st); if (author == r->user_id || perm_rank(repo_perm(app->db, o, n, r->user_id)) >= 2) { sqlite3_prepare_v2(app->db, "UPDATE issues SET state=?," " updated_at=strftime('%s','now') WHERE id=? AND repo_id=?", -1, &st, NULL); sqlite3_bind_text(st, 1, strcmp(act, "close") == 0 ? "closed" : "open", -1, SQLITE_STATIC); sqlite3_bind_int64(st, 2, id); sqlite3_bind_int(st, 3, repo_id); sqlite3_step(st); sqlite3_finalize(st); } } free(body); char loc[256]; snprintf(loc, sizeof loc, "/%s/%s/issues/%ld", o, n, id); http_redirect(r, loc); } void collab_issues(struct app_ctx *app, struct request *r, const char *repo_dir, const char *o, const char *n, int rk, const char *tail) { (void)repo_dir; int repo_id = gt_repo_rowid(app->db, o, n); if (repo_id < 0) { http_error(r, 404, "repository not registered"); return; } if (strcmp(tail, "") == 0) { issue_list(app, r, o, n, rk, repo_id); return; } if (strcmp(tail, "/new") == 0) { if (strcmp(r->method, "GET") == 0) { if (r->user_id == 0) { http_redirect(r, "/login"); return; } issue_new_form(app, r, o, n, rk); } else { issue_post(app, r, o, n, repo_id, 0, 1); } return; } long id = strtol(tail + 1, NULL, 10); if (id <= 0) { http_error(r, 404, "no such issue"); return; } if (strcmp(r->method, "POST") == 0) { issue_post(app, r, o, n, repo_id, id, 0); return; } issue_view(app, r, o, n, rk, repo_id, id); } /* ---------------- merge requests ---------------- */ static int branch_sha(const char *repo_dir, const char *branch, char out[41]) { char ref[300]; snprintf(ref, sizeof ref, "refs/heads/%s", branch); struct buf b; buf_init(&b); const char *argv[] = { "git", "rev-parse", "--verify", "-q", ref, NULL }; int rc = git_capture(repo_dir, argv, &b); if (rc != 0 || b.len < 40) { buf_free(&b); return -1; } memcpy(out, b.data, 40); out[40] = '\0'; buf_free(&b); return 0; } static void mr_list(struct app_ctx *app, struct request *r, const char *o, const char *n, int rk, int repo_id) { struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "changes"); if (r->user_id > 0) buf_printf(&b, "<p><a class=btn2 href='/%s/%s/mr/new'>" I_PLUS "new merge request</a></p>", o, n); sqlite3_stmt *st; if (sqlite3_prepare_v2(app->db, "SELECT m.id,m.title,m.src_branch,m.dst_branch,m.state," " COALESCE(u.name,'(deleted)')," " strftime('%Y-%m-%d', m.created_at, 'unixepoch')" " FROM merge_requests m LEFT JOIN users u ON u.id=m.author_id" " WHERE m.repo_id=?" " ORDER BY (m.state='open') DESC, m.id DESC LIMIT 200", -1, &st, NULL) != SQLITE_OK) { buf_free(&b); http_error(r, 500, "db error"); return; } sqlite3_bind_int(st, 1, repo_id); buf_puts(&b, "<table><tr><th>#</th><th>title</th><th>branches</th>" "<th>state</th><th>author</th></tr>"); int any = 0; while (sqlite3_step(st) == SQLITE_ROW) { any = 1; buf_printf(&b, "<tr><td>%lld</td><td><a href='/%s/%s/mr/%lld'>", sqlite3_column_int64(st, 0), o, n, sqlite3_column_int64(st, 0)); gt_esc(&b, (const char *)sqlite3_column_text(st, 1)); buf_puts(&b, "</a></td><td class=sha>"); gt_esc(&b, (const char *)sqlite3_column_text(st, 2)); buf_puts(&b, " → "); 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); if (!any) buf_puts(&b, "<tr><td class=muted colspan=5>no merge requests" "</td></tr>"); buf_puts(&b, "</table>"); gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); } static void mr_new_form(struct app_ctx *app, struct request *r, const char *repo_dir, const char *o, const char *n, int rk) { (void)app; /* branch list for the <select>s — built once, emitted twice */ struct buf br; buf_init(&br); const char *argv[] = { "git", "for-each-ref", "--format=%(refname:short)", "refs/heads", NULL }; git_capture(repo_dir, argv, &br); struct buf opts; buf_init(&opts); if (br.data) { char *save = NULL; for (char *s = strtok_r(br.data, "\n", &save); s; s = strtok_r(NULL, "\n", &save)) { buf_puts(&opts, "<option>"); gt_esc(&opts, s); buf_puts(&opts, "</option>"); } } struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "changes"); buf_printf(&b, "<h3>new merge request</h3>" "<form method=post action='/%s/%s/mr/new'>", o, n); gt_csrf_input(&b, r); buf_puts(&b, "<p><input name=title required maxlength=200 size=60 " "placeholder='title'></p>" "<p><textarea name=body rows=6 cols=80></textarea></p>" "<p>merge "); buf_puts(&b, "<select name=src>"); if (opts.data) buf_puts(&b, opts.data); buf_puts(&b, "</select> into <select name=dst>"); if (opts.data) buf_puts(&b, opts.data); buf_puts(&b, "</select></p>" "<p><button type=submit>create merge request</button></p>" "</form><p class=muted>push the source branch first" " (same-repo merge requests for now; forks later).</p>"); gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); buf_free(&br); buf_free(&opts); } static void mr_view(struct app_ctx *app, struct request *r, const char *repo_dir, const char *o, const char *n, int rk, int repo_id, long id) { sqlite3_stmt *st; if (sqlite3_prepare_v2(app->db, "SELECT m.title,m.state,m.src_branch,m.dst_branch,m.body," " COALESCE(u.name,'(deleted)'),m.author_id," " strftime('%Y-%m-%d %H:%M', m.created_at, 'unixepoch')" " FROM merge_requests m LEFT JOIN users u ON u.id=m.author_id" " WHERE m.id=? AND m.repo_id=?", -1, &st, NULL) != SQLITE_OK) { http_error(r, 500, "db error"); return; } sqlite3_bind_int64(st, 1, id); sqlite3_bind_int(st, 2, repo_id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); http_error(r, 404, "no such merge request"); return; } char src[256], dst[256], state[16]; snprintf(src, sizeof src, "%s", (const char *)sqlite3_column_text(st, 2)); snprintf(dst, sizeof dst, "%s", (const char *)sqlite3_column_text(st, 3)); snprintf(state, sizeof state, "%s", (const char *)sqlite3_column_text(st, 1)); int author = sqlite3_column_int(st, 6); struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "changes"); buf_printf(&b, "<h3>merge request #%ld <span class=muted>(%s)</span>" "</h3>", id, state); buf_puts(&b, "<p><b>"); gt_esc(&b, (const char *)sqlite3_column_text(st, 0)); buf_puts(&b, "</b></p><p class=muted>"); gt_esc(&b, (const char *)sqlite3_column_text(st, 2)); buf_puts(&b, " → "); gt_esc(&b, (const char *)sqlite3_column_text(st, 3)); buf_puts(&b, " · opened by "); gt_esc(&b, (const char *)sqlite3_column_text(st, 5)); buf_puts(&b, " on "); gt_esc(&b, (const char *)sqlite3_column_text(st, 7)); buf_puts(&b, "</p><pre style='border:0;background:none'>"); gt_esc(&b, (const char *)sqlite3_column_text(st, 4)); buf_puts(&b, "</pre>"); sqlite3_finalize(st); /* merge-base diff */ struct buf diff; buf_init(&diff); char spec[600]; snprintf(spec, sizeof spec, "refs/heads/%s...refs/heads/%s", dst, src); const char *dargv[] = { "git", "diff", "--no-color", spec, NULL }; if (git_capture(repo_dir, dargv, &diff) == 0 && diff.len) { buf_puts(&b, "<h4>changes</h4><pre>"); gt_render_diff(&b, diff.data, diff.len); buf_puts(&b, "</pre>"); } buf_free(&diff); /* GNU-readable export + back-link when this MR was born as a * mailing-list patch set */ buf_printf(&b, "<p class=muted>terminal workflow: " "<code>curl -o mr%ld.patch '<base-url>/%s/%s/mr/%ld.patch' " "&& git am mr%ld.patch</code></p>", id, o, n, id, id); buf_printf(&b, "<p class=muted><a href='/%s/%s/mr/%ld.patch'>" "download as mbox</a> · <a href='/%s/%s/mr/%ld.diff'>" "as diff</a></p>", o, n, id, o, n, id); if (starts_with(src, "psets/")) { long psid = strtol(src + 6, NULL, 10); if (psid > 0) buf_printf(&b, "<p class=muted>↔ patch set " "<a href='/%s/%s/patches/%ld'>#%ld</a></p>", o, n, psid, psid); } buf_puts(&b, "<h4>comments</h4><table>"); gt_comment_list(&b, app->db, "mr", id); buf_puts(&b, "</table>"); if (r->user_id > 0 && strcmp(state, "open") == 0) { gt_comment_form(&b, r, ""); if (rk >= 2) { buf_puts(&b, "<form method=post style='display:inline'>"); gt_csrf_input(&b, r); buf_puts(&b, "<input type=hidden name=action value=merge>" "<button>merge</button></form> "); } if (r->user_id == author || rk >= 2) { buf_puts(&b, "<form method=post style='display:inline'>"); gt_csrf_input(&b, r); buf_puts(&b, "<input type=hidden name=action value=close>" "<button>close</button></form>"); } } gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); } /* merge src into dst inside the bare repo via plumbing: * merge-tree --write-tree → tree oid (or conflicts) * commit-tree → merge commit (2 parents) * update-ref <ref> new old → compare-and-swap * returns 0 merged, 1 conflicts, -1 error */ static int try_merge(const char *repo_dir, const char *src, const char *dst, const char *msg, struct buf *details) { char dstref[300], srcref[300]; snprintf(dstref, sizeof dstref, "refs/heads/%s", dst); snprintf(srcref, sizeof srcref, "refs/heads/%s", src); const char *margv[] = { "git", "merge-tree", "--write-tree", dstref, srcref, NULL }; struct buf mo; buf_init(&mo); int rc = git_capture(repo_dir, margv, &mo); if (rc != 0) { if (mo.data && mo.len) buf_put(details, mo.data, mo.len); buf_free(&mo); return 1; /* conflicts */ } char tree[64]; if (!mo.data || mo.len < 40) { buf_free(&mo); return -1; } memcpy(tree, mo.data, 40); tree[40] = '\0'; buf_free(&mo); char old[41]; if (branch_sha(repo_dir, dst, old) != 0) return -1; char srcsha[41]; if (branch_sha(repo_dir, src, srcsha) != 0) return -1; const char *cargv[] = { "git", "-c", "user.name=git-trace", "-c", "user.email=git-trace@localhost", "commit-tree", "-m", msg, "-p", old, "-p", srcsha, tree, NULL }; struct buf co; buf_init(&co); if (git_capture(repo_dir, cargv, &co) != 0 || co.len < 40) { buf_free(&co); return -1; } char commit[64]; memcpy(commit, co.data, 40); commit[40] = '\0'; buf_free(&co); const char *uargv[] = { "git", "update-ref", dstref, commit, old, NULL }; if (git_capture(repo_dir, uargv, details) != 0) return -1; /* lost the CAS race */ return 0; } static void mr_post(struct app_ctx *app, struct request *r, const char *repo_dir, const char *o, const char *n, int rk, int repo_id, long id, int creating) { size_t blen; char *body = gt_read_post(r, 65536, &blen); if (!body) return; if (creating) { char title[210], mbody[8192], src[256], dst[256]; if (form_get(body, blen, "title", title, sizeof title) != 0 || !title[0] || form_get(body, blen, "src", src, sizeof src) != 0 || form_get(body, blen, "dst", dst, sizeof dst) != 0 || !git_ref_ok(src) || !git_ref_ok(dst) || strcmp(src, dst) == 0) { free(body); http_error(r, 400, "bad merge request fields"); return; } char srcsha[41], dstsha[41]; if (branch_sha(repo_dir, src, srcsha) != 0 || branch_sha(repo_dir, dst, dstsha) != 0) { free(body); http_error(r, 400, "unknown source or destination branch"); return; } form_get(body, blen, "body", mbody, sizeof mbody); sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "INSERT INTO merge_requests(repo_id,author_id,title,body," " src_branch,dst_branch,src_commit) VALUES(?,?,?,?,?,?,?)", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_int(st, 2, r->user_id); sqlite3_bind_text(st, 3, title, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, mbody, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, src, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 6, dst, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 7, srcsha, -1, SQLITE_TRANSIENT); sqlite3_step(st); long nid = sqlite3_last_insert_rowid(app->db); sqlite3_finalize(st); free(body); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "mr_create", "#%ld %s -> %s", nid, src, dst); { struct buf wp; buf_init(&wp); char on2[200]; snprintf(on2, sizeof on2, "%s/%s", o, n); buf_puts(&wp, "{\"event\":\"mr.open\",\"repo\":\""); json_put_esc(&wp, on2, strlen(on2)); buf_printf(&wp, "\",\"mr\":%ld,\"title\":\"", nid); json_put_esc(&wp, title, strlen(title)); buf_puts(&wp, "\",\"author\":\""); json_put_esc(&wp, r->user_name, strlen(r->user_name)); buf_puts(&wp, "\",\"src\":\""); json_put_esc(&wp, src, strlen(src)); buf_puts(&wp, "\",\"dst\":\""); json_put_esc(&wp, dst, strlen(dst)); buf_puts(&wp, "\"}"); wh_notify(app, repo_id, "mr", wp.data ? wp.data : "{}"); buf_free(&wp); } char loc[256]; snprintf(loc, sizeof loc, "/%s/%s/mr/%ld", o, n, nid); http_redirect(r, loc); return; } char act[16] = "", cbody[8192]; form_get(body, blen, "action", act, sizeof act); if (strcmp(act, "comment") == 0) { if (form_get(body, blen, "body", cbody, sizeof cbody) == 0 && cbody[0]) gt_add_comment(app->db, "mr", id, r->user_id, cbody); } else if (strcmp(act, "merge") == 0 && rk >= 2) { /* load MR */ sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "SELECT src_branch,dst_branch,title FROM merge_requests" " WHERE id=? AND repo_id=? AND state='open'", -1, &st, NULL); sqlite3_bind_int64(st, 1, id); sqlite3_bind_int(st, 2, repo_id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); free(body); http_error(r, 409, "merge request not open"); return; } char src[256], dst[256]; snprintf(src, sizeof src, "%s", (const char *)sqlite3_column_text(st, 0)); snprintf(dst, sizeof dst, "%s", (const char *)sqlite3_column_text(st, 1)); char msg[512]; snprintf(msg, sizeof msg, "merge request #%ld: %.400s", id, (const char *)sqlite3_column_text(st, 2)); sqlite3_finalize(st); struct buf det; buf_init(&det); int rc = try_merge(repo_dir, src, dst, msg, &det); free(body); if (rc == 0) { sqlite3_prepare_v2(app->db, "UPDATE merge_requests SET state='merged'," " updated_at=strftime('%s','now') WHERE id=?", -1, &st, NULL); sqlite3_bind_int64(st, 1, id); sqlite3_step(st); sqlite3_finalize(st); buf_free(&det); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "mr_merge", "#%ld %s -> %s", id, src, dst); char loc[256]; snprintf(loc, sizeof loc, "/%s/%s/mr/%ld", o, n, id); http_redirect(r, loc); return; } /* conflicts or error */ struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "changes"); buf_puts(&b, rc == 1 ? "<h3>merge conflicts</h3>" : "<h3>merge failed</h3><pre>"); if (det.data) gt_esc(&b, det.data); buf_puts(&b, "</pre><p class=muted>resolve conflicts on the " "source branch, or merge manually.</p>"); gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); buf_free(&det); return; } else if (strcmp(act, "close") == 0) { sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "SELECT author_id FROM merge_requests WHERE id=? AND repo_id=?", -1, &st, NULL); sqlite3_bind_int64(st, 1, id); sqlite3_bind_int(st, 2, repo_id); int author = (sqlite3_step(st) == SQLITE_ROW) ? sqlite3_column_int(st, 0) : -1; sqlite3_finalize(st); if (author == r->user_id || rk >= 2) { sqlite3_prepare_v2(app->db, "UPDATE merge_requests SET state='closed'," " updated_at=strftime('%s','now') WHERE id=? AND repo_id=?", -1, &st, NULL); sqlite3_bind_int64(st, 1, id); sqlite3_bind_int(st, 2, repo_id); sqlite3_step(st); sqlite3_finalize(st); } } free(body); char loc[256]; snprintf(loc, sizeof loc, "/%s/%s/mr/%ld", o, n, id); http_redirect(r, loc); } /* /mr/N.patch — the web request becomes a GNU-readable mbox; * /mr/N.diff — plain unified diff for review plumbing */ static void mr_export(struct app_ctx *app, struct request *r, const char *repo_dir, int repo_id, long id, const char *ext) { sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "SELECT src_branch,dst_branch FROM merge_requests" " WHERE id=? AND repo_id=?", -1, &st, NULL); sqlite3_bind_int64(st, 1, id); sqlite3_bind_int(st, 2, repo_id); if (sqlite3_step(st) != SQLITE_ROW) { sqlite3_finalize(st); http_error(r, 404, "no such merge request"); return; } char sref[300], dref[300]; snprintf(sref, sizeof sref, "refs/heads/%s", (const char *)sqlite3_column_text(st, 0)); snprintf(dref, sizeof dref, "refs/heads/%s", (const char *)sqlite3_column_text(st, 1)); sqlite3_finalize(st); struct buf out; buf_init(&out); if (strcmp(ext, ".diff") == 0) { char spec[700]; snprintf(spec, sizeof spec, "%s...%s", dref, sref); const char *av[] = { "git", "diff", "--no-color", spec, NULL }; git_capture(repo_dir, av, &out); } else { /* merge-base so the mbox applies cleanly with `git am` */ struct buf mb; buf_init(&mb); const char *mv[] = { "git", "merge-base", dref, sref, NULL }; if (git_capture(repo_dir, mv, &mb) != 0 || mb.len < 40) { buf_free(&mb); buf_free(&out); http_error(r, 500, "no merge base"); return; } mb.data[40] = '\0'; char range[128]; int rl = snprintf(range, sizeof range, "%.40s..%s", mb.data, sref); buf_free(&mb); if (rl < 0 || rl >= (int)sizeof range) { buf_free(&out); http_error(r, 400, "ref too long"); return; } const char *av[] = { "git", "format-patch", "--stdout", range, NULL }; git_capture(repo_dir, av, &out); } if (!out.data || out.len == 0) { buf_free(&out); http_error(r, 404, "nothing to export"); return; } http_respond(r, 200, "text/plain; charset=utf-8", out.data, out.len); buf_free(&out); } void collab_mrs(struct app_ctx *app, struct request *r, const char *repo_dir, const char *o, const char *n, int rk, const char *tail) { int repo_id = gt_repo_rowid(app->db, o, n); if (repo_id < 0) { http_error(r, 404, "repository not registered"); return; } if (strcmp(tail, "") == 0) { mr_list(app, r, o, n, rk, repo_id); return; } if (strcmp(tail, "/new") == 0) { if (strcmp(r->method, "GET") == 0) { if (r->user_id == 0) { http_redirect(r, "/login"); return; } mr_new_form(app, r, repo_dir, o, n, rk); } else { mr_post(app, r, repo_dir, o, n, rk, repo_id, 0, 1); } return; } char *endp = NULL; long id = strtol(tail + 1, &endp, 10); if (id <= 0) { http_error(r, 404, "no such merge request"); return; } /* export formats: /mr/N.patch (mbox, `git am`-ready), * /mr/N.diff (plain unified diff) */ if (strcmp(endp, ".patch") == 0 || strcmp(endp, ".mbox") == 0 || strcmp(endp, ".diff") == 0) { mr_export(app, r, repo_dir, repo_id, id, endp); return; } if (endp[0] != '\0') { http_error(r, 404, "unknown format"); return; } if (strcmp(r->method, "POST") == 0) { mr_post(app, r, repo_dir, o, n, rk, repo_id, id, 0); return; } mr_view(app, r, repo_dir, o, n, rk, repo_id, id); } /* ---------------- settings (ACL + meta) ---------------- */ void collab_settings(struct app_ctx *app, struct request *r, const char *o, const char *n, int rk) { if (rk < 3) { http_error(r, 403, "admin only"); return; } int repo_id = gt_repo_rowid(app->db, o, n); if (repo_id < 0) { http_error(r, 404, "repository not registered"); return; } if (strcmp(r->method, "POST") == 0) { size_t blen; char *body = gt_read_post(r, 65536, &blen); if (!body) return; char act[16] = ""; form_get(body, blen, "action", act, sizeof act); sqlite3_stmt *st; if (strcmp(act, "meta") == 0) { char desc[500], pub[8] = "", ci[8] = ""; form_get(body, blen, "desc", desc, sizeof desc); form_get(body, blen, "public", pub, sizeof pub); form_get(body, blen, "ci", ci, sizeof ci); sqlite3_prepare_v2(app->db, "UPDATE repos SET description=?, is_public=?," " ci_enabled=? WHERE id=?", -1, &st, NULL); sqlite3_bind_text(st, 1, desc, -1, SQLITE_TRANSIENT); sqlite3_bind_int(st, 2, strcmp(pub, "on") == 0); sqlite3_bind_int(st, 3, strcmp(ci, "on") == 0); sqlite3_bind_int(st, 4, repo_id); sqlite3_step(st); sqlite3_finalize(st); } else if (strcmp(act, "acl_add") == 0) { char uname[64], role[4]; if (form_get(body, blen, "user", uname, sizeof uname) == 0 && form_get(body, blen, "role", role, sizeof role) == 0 && name_ok(uname) && (role[0] == 'r' || role[0] == 'w' || role[0] == 'a')) { int uid = auth_user_id(app->db, uname); if (uid > 0) { sqlite3_prepare_v2(app->db, "INSERT INTO repo_acl(repo_id,user_id,role)" " VALUES(?,?,?)" " ON CONFLICT(repo_id,user_id)" " DO UPDATE SET role=excluded.role", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_int(st, 2, uid); sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "acl_add", "%s -> %s", uname, role); } } } else if (strcmp(act, "acl_rm") == 0) { char uname[64]; if (form_get(body, blen, "user", uname, sizeof uname) == 0) { int uid = auth_user_id(app->db, uname); if (uid > 0) { sqlite3_prepare_v2(app->db, "DELETE FROM repo_acl WHERE repo_id=? AND user_id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_int(st, 2, uid); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "acl_rm", "%s", uname); } } } else if (strcmp(act, "rule_add") == 0) { char pat[260], role[4] = "", ff[8] = "", del[8] = ""; if (form_get(body, blen, "pattern", pat, sizeof pat) == 0 && form_get(body, blen, "role", role, sizeof role) == 0 && git_pat_ok(pat) && (role[0] == 'w' || role[0] == 'a') && role[1] == '\0') { form_get(body, blen, "ff", ff, sizeof ff); form_get(body, blen, "del", del, sizeof del); sqlite3_prepare_v2(app->db, "INSERT INTO branch_rules" " (repo_id,pattern,min_role,ff_only,no_delete)" " VALUES(?,?,?,?,?)" " ON CONFLICT(repo_id,pattern) DO UPDATE SET" " min_role=excluded.min_role," " ff_only=excluded.ff_only," " no_delete=excluded.no_delete", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_text(st, 2, pat, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, role, -1, SQLITE_TRANSIENT); sqlite3_bind_int(st, 4, strcmp(ff, "on") == 0); sqlite3_bind_int(st, 5, strcmp(del, "on") == 0); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "rule_add", "%s -> %s", pat, role); } } else if (strcmp(act, "delete") == 0) { /* destructive: requires typing the repo name to confirm. * Order matters: drop the on-disk tree first, then the DB * row — if rm fails the repo stays registered (and thus * still protected by its ACL), never an orphaned * world-readable directory. */ char conf[64] = ""; form_get(body, blen, "confirm", conf, sizeof conf); if (strcmp(conf, n) == 0) { char rdir[PATH_MAX]; struct stat sb; int fsok = repo_path(rdir, sizeof rdir, app->repos_root, o, n) == 0; if (fsok && lstat(rdir, &sb) == 0) fsok = nftw(rdir, rm_cb, 16, FTW_DEPTH | FTW_PHYS) == 0; /* ENOENT: tree already gone -> row cleanup is still ok */ else if (fsok && errno != ENOENT) fsok = 0; if (!fsok) { free(body); http_error(r, 500, "could not remove repo tree"); return; } sqlite3_prepare_v2(app->db, "DELETE FROM repos WHERE id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "repo_delete", "%s/%s", o, n); free(body); http_redirect(r, "/"); return; } } else if (strcmp(act, "rule_rm") == 0) { char rid[16]; if (form_get(body, blen, "id", rid, sizeof rid) == 0) { sqlite3_prepare_v2(app->db, "DELETE FROM branch_rules WHERE repo_id=? AND id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_int(st, 2, atoi(rid)); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "rule_rm", "id=%s", rid); } } else if (strcmp(act, "wh_add") == 0) { char url[500], sec[200] = "", evs[40] = "", ep[8]; if (form_get(body, blen, "url", url, sizeof url) == 0 && (strncmp(url, "http://", 7) == 0 || strncmp(url, "https://", 8) == 0) && strcspn(url, " \t\r\n") == strlen(url)) { form_get(body, blen, "secret", sec, sizeof sec); const char *al[] = { "push", "mr", "patch" }; for (size_t i = 0; i < 3; i++) { ep[0] = '\0'; char key[16]; snprintf(key, sizeof key, "ev_%s", al[i]); form_get(body, blen, key, ep, sizeof ep); if (strcmp(ep, "on") == 0) { if (evs[0]) strncat(evs, ",", sizeof evs - strlen(evs) - 1); strncat(evs, al[i], sizeof evs - strlen(evs) - 1); } } if (!evs[0]) snprintf(evs, sizeof evs, "push"); sqlite3_prepare_v2(app->db, "INSERT INTO repo_webhooks(repo_id,url,secret,events)" " VALUES(?,?,?,?)", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_text(st, 2, url, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, sec, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, evs, -1, SQLITE_TRANSIENT); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "webhook_add", "%s [%s]", url, evs); } } else if (strcmp(act, "wh_del") == 0) { char hid[16]; if (form_get(body, blen, "id", hid, sizeof hid) == 0) { sqlite3_prepare_v2(app->db, "DELETE FROM repo_webhooks" " WHERE repo_id=? AND id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_int(st, 2, atoi(hid)); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "webhook_del", "id=%s", hid); } } else if (strcmp(act, "mir_add") == 0) { char url[500], spec[256], sec[200] = "", dir[8] = "pull"; if (form_get(body, blen, "url", url, sizeof url) == 0 && mirror_url_ok(url)) { form_get(body, blen, "dir", dir, sizeof dir); int push = strcmp(dir, "push") == 0; if (form_get(body, blen, "refspec", spec, sizeof spec) != 0 || !*spec) snprintf(spec, sizeof spec, "%s", push ? "+refs/heads/*:refs/heads/*" : "+refs/heads/*:refs/mirror/*"); form_get(body, blen, "secret", sec, sizeof sec); int ok = push ? mirror_refspec_push_ok(spec) : mirror_refspec_ok(spec); if (ok) { sqlite3_prepare_v2(app->db, "INSERT INTO repo_mirrors(repo_id,url,direction," "refspec,secret) VALUES(?,?,?,?,?)", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_text(st, 2, url, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 3, push ? "push" : "pull", -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 4, spec, -1, SQLITE_TRANSIENT); sqlite3_bind_text(st, 5, push ? "" : sec, -1, SQLITE_TRANSIENT); sqlite3_step(st); sqlite3_finalize(st); char murl[512]; mirror_url_mask(url, murl, sizeof murl); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "mirror_add", "%s %s %s", push ? "push" : "pull", murl, spec); } } } else if (strcmp(act, "mir_del") == 0) { char hid[16]; if (form_get(body, blen, "id", hid, sizeof hid) == 0) { sqlite3_prepare_v2(app->db, "DELETE FROM repo_mirrors" " WHERE repo_id=? AND id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_int(st, 2, atoi(hid)); sqlite3_step(st); sqlite3_finalize(st); db_audit(app->db, r->user_id, r->user_name, r->remote_ip, repo_id, "mirror_del", "id=%s", hid); } } else if (strcmp(act, "mir_sync") == 0) { char arg[300]; snprintf(arg, sizeof arg, "%s/%s", o, n); gt_spawn_task_arg("mirror-sync", arg); } else if (strcmp(act, "mir_push") == 0) { char arg[300]; snprintf(arg, sizeof arg, "%s/%s", o, n); gt_spawn_task_arg("mirror-push", arg); } free(body); char loc[300]; snprintf(loc, sizeof loc, "/%s/%s/settings", o, n); http_redirect(r, loc); return; } /* GET: settings page */ struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "settings"); sqlite3_stmt *st; sqlite3_prepare_v2(app->db, "SELECT description,is_public,ci_enabled FROM repos WHERE id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); int is_pub = 1, ci_on = 0; const char *desc = ""; if (sqlite3_step(st) == SQLITE_ROW) { desc = (const char *)sqlite3_column_text(st, 0); is_pub = sqlite3_column_int(st, 1); ci_on = sqlite3_column_int(st, 2); } char descbuf[512]; snprintf(descbuf, sizeof descbuf, "%s", desc ? desc : ""); sqlite3_finalize(st); buf_puts(&b, "<h3>settings</h3><form method=post>"); gt_csrf_input(&b, r); buf_puts(&b, "<input type=hidden name=action value=meta>" "<p>description <input name=desc size=60 value='"); gt_esc(&b, descbuf); buf_puts(&b, "'></p><p><label><input type=checkbox name=public"); buf_puts(&b, is_pub ? " checked" : ""); buf_puts(&b, "> public (anonymous read)</label></p>" "<p><label><input type=checkbox name=ci"); buf_puts(&b, ci_on ? " checked" : ""); buf_puts(&b, "> ci — run <code>.git-trace-ci</code> from the" " default branch on every push</label></p>" "<p><button>save</button></p></form>"); if (ci_on) { buf_puts(&b, "<table><tr><th>recent builds</th><th>status</th>" "<th>branch</th></tr>"); sqlite3_prepare_v2(app->db, "SELECT id, sha, status, branch FROM ci_runs" " WHERE repo_id=? ORDER BY id DESC LIMIT 5", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); while (sqlite3_step(st) == SQLITE_ROW) { long id = sqlite3_column_int64(st, 0); const char *sha = (const char *)sqlite3_column_text(st, 1); const char *stt = (const char *)sqlite3_column_text(st, 2); const char *br = (const char *)sqlite3_column_text(st, 3); buf_printf(&b, "<tr><td class=mono>" "<a href='/%s/%s/ci/%ld'>%.10s</a></td>" "<td class=muted>", o, n, id, sha ? sha : "?"); gt_esc(&b, stt); buf_puts(&b, "</td><td class=muted>"); gt_esc(&b, br); buf_puts(&b, "</td></tr>"); } sqlite3_finalize(st); buf_puts(&b, "</table>"); } buf_puts(&b, "<h3>access control</h3><table>" "<tr><th>user</th><th>role</th><th></th></tr>"); sqlite3_prepare_v2(app->db, "SELECT u.name, a.role FROM repo_acl a" " JOIN users u ON u.id=a.user_id WHERE a.repo_id=?", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); while (sqlite3_step(st) == SQLITE_ROW) { const char *un = (const char *)sqlite3_column_text(st, 0); const char *ro = (const char *)sqlite3_column_text(st, 1); buf_puts(&b, "<tr><td>"); gt_esc(&b, un); buf_puts(&b, "</td><td class=muted>"); gt_esc(&b, ro); buf_puts(&b, "</td><td><form method=post style='display:inline'>"); gt_csrf_input(&b, r); buf_puts(&b, "<input type=hidden name=action value=acl_rm>" "<input type=hidden name=user value='"); gt_esc(&b, un); buf_puts(&b, "'><button>remove</button></form></td></tr>"); } sqlite3_finalize(st); buf_puts(&b, "</table><form method=post>"); gt_csrf_input(&b, r); buf_puts(&b, "<input type=hidden name=action value=acl_add>" "<p><input name=user required placeholder='user'> " "<select name=role>" "<option value=r>read</option>" "<option value=w>write</option>" "<option value=a>admin</option>" "</select> <button>grant</button></p></form>"); buf_puts(&b, "<h3>branch protection</h3><table>" "<tr><th>pattern</th><th>push requires</th>" "<th>ff-only</th><th>no-delete</th><th></th></tr>"); sqlite3_prepare_v2(app->db, "SELECT id,pattern,min_role,ff_only,no_delete" " FROM branch_rules WHERE repo_id=? ORDER BY pattern", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); while (sqlite3_step(st) == SQLITE_ROW) { long rid = sqlite3_column_int64(st, 0); const char *pat = (const char *)sqlite3_column_text(st, 1); const char *mro = (const char *)sqlite3_column_text(st, 2); buf_printf(&b, "<tr><td class=mono>"); gt_esc(&b, pat); buf_puts(&b, "</td><td class=muted>"); buf_puts(&b, mro && mro[0] == 'a' ? "admin" : "write"); buf_printf(&b, "</td><td class=muted>%s</td>" "<td class=muted>%s</td>" "<td><form method=post style='display:inline'>", sqlite3_column_int(st, 3) ? "yes" : "-", sqlite3_column_int(st, 4) ? "yes" : "-"); gt_csrf_input(&b, r); buf_printf(&b, "<input type=hidden name=action value=rule_rm>" "<input type=hidden name=id value=%ld>" "<button>remove</button></form></td></tr>", rid); } sqlite3_finalize(st); buf_puts(&b, "</table><form method=post>"); gt_csrf_input(&b, r); buf_puts(&b, "<input type=hidden name=action value=rule_add>" "<p><input name=pattern required" " placeholder='master or release/*'> " "<select name=role>" "<option value=w>write</option>" "<option value=a>admin</option>" "</select> " "<label><input type=checkbox name=ff> ff-only</label> " "<label><input type=checkbox name=del>" " no-delete</label> " "<button>protect</button></p>" "<p class=muted>matching branches refuse pushes below" " the required role; merge requests and patch sets" " stay the enforced path. rules only tighten access —" " the built-in default-branch floor always applies.</p>" "</form>"); buf_puts(&b, "<h3>webhooks</h3><table>" "<tr><th>url</th><th>events</th><th></th></tr>"); sqlite3_prepare_v2(app->db, "SELECT id,url,events FROM repo_webhooks" " WHERE repo_id=? AND active=1 ORDER BY id", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); while (sqlite3_step(st) == SQLITE_ROW) { long hid = sqlite3_column_int64(st, 0); const char *u = (const char *)sqlite3_column_text(st, 1); const char *ev = (const char *)sqlite3_column_text(st, 2); buf_puts(&b, "<tr><td class=mono>"); gt_esc(&b, u); buf_puts(&b, "</td><td class=muted>"); gt_esc(&b, ev); 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=wh_del>" "<input type=hidden name=id value=%ld>" "<button>remove</button></form></td></tr>", hid); } sqlite3_finalize(st); buf_puts(&b, "</table><form method=post>"); gt_csrf_input(&b, r); buf_puts(&b, "<input type=hidden name=action value=wh_add>" "<p><input name=url required size=50" " placeholder='http://host:port/path'> " "<input name=secret size=20" " placeholder='secret (optional)'></p>" "<p><label><input type=checkbox name=ev_push checked>" " push</label> " "<label><input type=checkbox name=ev_mr>" " merge request</label> " "<label><input type=checkbox name=ev_patch>" " patch set</label> " "<button>add webhook</button></p>" "<p class=muted>POSTs a signed JSON payload" " (X-Git-Trace-Signature: sha256=<hmac>)." " http:// is delivered by the built-in client;" " https:// needs curl installed.</p></form>"); /* recent deliveries — an ops log, not a debug console */ sqlite3_prepare_v2(app->db, "SELECT d.event, d.status, d.code, d.attempts," " datetime(d.created_at,'unixepoch')" " FROM webhook_deliveries d" " JOIN repo_webhooks h ON h.id=d.hook_id" " WHERE h.repo_id=? ORDER BY d.id DESC LIMIT 10", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); int any = 0; while (sqlite3_step(st) == SQLITE_ROW) { if (!any) { buf_puts(&b, "<table><tr><th>event</th><th>status</th>" "<th>http</th><th>tries</th><th>at</th></tr>"); any = 1; } const char *ev = (const char *)sqlite3_column_text(st, 0); const char *stt = (const char *)sqlite3_column_text(st, 1); const char *dt = (const char *)sqlite3_column_text(st, 4); buf_printf(&b, "<tr><td class=muted>"); gt_esc(&b, ev); buf_puts(&b, "</td><td>"); gt_esc(&b, stt); buf_printf(&b, "</td><td class=muted>%d</td>" "<td class=muted>%d</td><td class=muted>", sqlite3_column_int(st, 2), sqlite3_column_int(st, 3)); gt_esc(&b, dt); buf_puts(&b, "</td></tr>"); } sqlite3_finalize(st); if (any) buf_puts(&b, "</table>"); /* federation: pull mirrors land in refs/mirror/, push mirrors * fan out to remote forges on every heads update */ buf_puts(&b, "<h3>mirrors</h3><table>" "<tr><th>dir</th><th>remote</th><th>refspec</th>" "<th>last run</th><th>result</th><th></th></tr>"); sqlite3_prepare_v2(app->db, "SELECT id,url,refspec,enabled,last_ok,last_refs,last_err," " datetime(last_ts,'unixepoch'),direction" " FROM repo_mirrors WHERE repo_id=? ORDER BY id", -1, &st, NULL); sqlite3_bind_int(st, 1, repo_id); int mrows = 0; while (sqlite3_step(st) == SQLITE_ROW) { mrows++; sqlite3_int64 mid = sqlite3_column_int64(st, 0); const char *u = (const char *)sqlite3_column_text(st, 1); const char *sp = (const char *)sqlite3_column_text(st, 2); int ok = sqlite3_column_int(st, 4); int nref = sqlite3_column_int(st, 5); const char *err = (const char *)sqlite3_column_text(st, 6); const char *dt = (const char *)sqlite3_column_text(st, 7); const char *dir = (const char *)sqlite3_column_text(st, 8); char murl[512]; mirror_url_mask(u ? u : "", murl, sizeof murl); buf_printf(&b, "<tr><td class=muted>%s</td><td class=mono>", dir && *dir ? dir : "pull"); gt_esc(&b, murl); buf_puts(&b, "</td><td class=mono muted>"); gt_esc(&b, sp); buf_puts(&b, "</td><td class=muted>"); gt_esc(&b, dt ? dt : "never"); buf_puts(&b, "</td><td class=muted>"); if (dt) buf_printf(&b, "%s · %d refs", ok ? "ok" : "FAILED", nref); 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=mir_del>" "<input type=hidden name=id value=%ld>" "<button>remove</button></form></td></tr>", (long)mid); if (err && *err && dt && !ok) buf_printf(&b, "<tr><td></td><td colspan=3" " class=muted style='font-size:.85em'>%.300s" "</td></tr>", err); } sqlite3_finalize(st); if (!mrows) buf_puts(&b, "<tr><td></td><td class=muted>none configured</td>" "<td></td><td></td><td></td><td></td></tr>"); buf_puts(&b, "</table><form method=post>"); gt_csrf_input(&b, r); buf_printf(&b, "<input type=hidden name=action value=mir_add>" "<p><select name=dir>" "<option value=pull>pull (follow upstream)</option>" "<option value=push>push (publish downstream)</option>" "</select> <input name=url required size=45" " placeholder='http://host/alice/repo'> " "<input name=secret size=16" " placeholder='trigger secret (pull)'></p>" "<p><input name=refspec size=50" " placeholder='default for the direction'> " "<button>add mirror</button></p>" "<p class=muted><b>pull</b>: fetched refs land read-only under" " refs/mirror/; an upstream git-trace drives sync via" " <b class=mono>POST /%s/%s/mirror-sync</b> signed with the" " trigger secret. <b>push</b>: every local heads update is" " pushed to the remote (embed credentials in the url:" " https://user:token@host/r.git); A⇄B cycles converge since" " an up-to-date push runs no remote hooks." " url schemes: http, https, git, ssh (file:// refused)." "</p></form><form method=post>", o, n); gt_csrf_input(&b, r); buf_puts(&b, "<p><button name=action value=mir_sync>" "pull now</button> " "<button name=action value=mir_push>" "push now</button></p></form>"); buf_puts(&b, "<h3>danger zone</h3><form method=post>"); gt_csrf_input(&b, r); buf_puts(&b, "<input type=hidden name=action value=delete>" "<p>delete this repository — type <b class=mono>"); gt_esc(&b, n); buf_puts(&b, "</b> to confirm: <input name=confirm " "autocomplete=off> <button>delete</button></p>" "<p class=muted>removes the repository from disk and" " its issues, merge requests, patch sets and ACLs." " there is no undo.</p></form>"); gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); } /* ---------------- unified changes view (MRs + patch sets) ---------------- */ void collab_changes(struct app_ctx *app, struct request *r, const char *o, const char *n, int rk) { int repo_id = gt_repo_rowid(app->db, o, n); struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "changes"); buf_printf(&b, "<p><a class=btn2 href='/%s/%s/mr/new'>" I_PLUS "new merge request</a> " "<a class=btn2 href='/%s/%s/patches/new'>" I_PLUS "submit patches</a></p>", o, n, o, n); /* rows buffered first, so an empty result renders a proper empty * state instead of a header-only table */ struct buf rows; buf_init(&rows); int nrow = 0; sqlite3_stmt *st; if (repo_id > 0 && sqlite3_prepare_v2(app->db, "SELECT kind,id,title,state,uname," " strftime('%Y-%m-%d %H:%M',upd,'unixepoch') FROM (" " SELECT 'mr' AS kind, m.id, m.title, m.state," " COALESCE(u.name,'(deleted)') AS uname," " m.updated_at AS upd" " FROM merge_requests m LEFT JOIN users u ON u.id=m.author_id" " WHERE m.repo_id=?" " UNION ALL" " SELECT 'patch', p.id, p.title, p.state," " COALESCE(u2.name,'(deleted)'), p.updated_at" " FROM patch_sets p LEFT JOIN users u2 ON u2.id=p.author_id" " WHERE p.repo_id=?)" " ORDER BY upd DESC LIMIT 100", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int(st, 1, repo_id); sqlite3_bind_int(st, 2, repo_id); while (sqlite3_step(st) == SQLITE_ROW) { const char *kind = (const char *)sqlite3_column_text(st, 0); long id = sqlite3_column_int64(st, 1); buf_puts(&rows, "<tr><td><span class=badge>"); gt_esc(&rows, kind); buf_puts(&rows, "</span></td><td><a href='/"); buf_printf(&rows, "%s/%s/%s/%ld'>", o, n, kind && kind[0] == 'm' ? "mr" : "patches", id); gt_esc(&rows, (const char *)sqlite3_column_text(st, 2)); buf_puts(&rows, "</a></td><td>"); gt_esc(&rows, (const char *)sqlite3_column_text(st, 3)); buf_puts(&rows, "</td><td>"); gt_esc(&rows, (const char *)sqlite3_column_text(st, 4)); buf_puts(&rows, "</td><td class=muted>"); gt_esc(&rows, (const char *)sqlite3_column_text(st, 5)); buf_puts(&rows, "</td></tr>"); nrow++; } sqlite3_finalize(st); } if (nrow) { buf_puts(&b, "<table><tr><th></th><th>title</th><th>state</th>" "<th>author</th><th>updated</th></tr>"); buf_put(&b, rows.data, rows.len); buf_puts(&b, "</table>"); } else { buf_puts(&b, "<div class=empty>" "<svg width=34 height=34 viewBox='0 0 16 16' " "fill=currentColor><circle cx=4.5 cy=4 r=1.9/>" "<circle cx=4.5 cy=12 r=1.9/><circle cx=12 cy=8 r=1.9/>" "<path d='M4.5 5.9v4.2M6 4.9c2.6 1 4 1.8 4 3.1' " "stroke=currentColor stroke-width=1.3 fill=none/></svg>" "no merge requests or patch sets yet</div>"); } buf_free(&rows); gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); } void collab_activity(struct app_ctx *app, struct request *r, const char *o, const char *n, int rk) { int repo_id = gt_repo_rowid(app->db, o, n); struct buf b; buf_init(&b); gt_page_top(&b, n, r); gt_repo_tabs(&b, app, o, n, rk, "activity"); buf_puts(&b, "<h3>activity</h3><table><tr><th>time</th><th>actor</th>" "<th>action</th><th>detail</th></tr>"); sqlite3_stmt *st; if (repo_id > 0 && sqlite3_prepare_v2(app->db, "SELECT strftime('%Y-%m-%d %H:%M', ts, 'unixepoch')," " COALESCE(actor,'(anon)'), action, detail" " FROM audit_log WHERE repo_id=? ORDER BY id DESC LIMIT 100", -1, &st, NULL) == SQLITE_OK) { sqlite3_bind_int(st, 1, repo_id); 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></tr>"); } sqlite3_finalize(st); } buf_puts(&b, "</table>"); gt_repo_body_end(&b); gt_page_bottom(&b); gt_send_page(r, &b); buf_free(&b); }