romele / git-trace public
62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 10m ago
src/export.c
#include "export.h" #include "auth.h" #include "gitx.h" #include "highlight.h" #include "markdown.h" #include "repo.h" #include "util.h" #include "web.h" #include <ctype.h> #include <dirent.h> #include <errno.h> #include <limits.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/stat.h> /* ---- small helpers ---------------------------------------------------- */ static int xp_write(const char *path, const struct buf *b) { FILE *f = fopen(path, "wb"); if (!f) return -1; size_t w = b->data ? fwrite(b->data, 1, b->len, f) : 0; fclose(f); return (int)w == (int)b->len ? 0 : -1; } /* every exported page is self-contained: inline CSS, no path math */ static void xp_head(struct buf *b, const char *title, int depth) { 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, "</title><style>"); buf_puts(b, gt_web_css()); buf_puts(b, "nav a{margin-right:.9em}</style></head><body>" "<header><a href='"); for (int i = 0; i < depth; i++) buf_puts(b, "../"); buf_puts(b, "index.html'>git-trace export</a></header><main>"); } static void xp_foot(struct buf *b) { buf_puts(b, "</main></body></html>"); } /* join helper — output paths live in struct buf, so -Wformat-truncation * has nothing to warn about and no path is ever silently cut */ static void xp_path(struct buf *b, const char *dir, const char *suf) { buf_init(b); buf_printf(b, "%s%s", dir, suf); } /* export filename for a git path: '/' is kept as a real separator — * the output mirrors the repo tree so file:// links survive the * browser's percent-decoding. Within each component keep * [A-Za-z0-9._-] and %-encode the rest; components equal to "." or * ".." are fully encoded so a hostile tree cannot escape file/ */ static void fname_enc(struct buf *b, const char *path) { const char *p = path; while (*p) { const char *sl = strchr(p, '/'); size_t len = sl ? (size_t)(sl - p) : strlen(p); int dots = (len == 1 && p[0] == '.') || (len == 2 && p[0] == '.' && p[1] == '.'); for (size_t i = 0; i < len; i++) { unsigned char ch = (unsigned char)p[i]; if (!dots && (isalnum(ch) || ch == '.' || ch == '_' || ch == '-')) buf_put(b, &ch, 1); else buf_printf(b, "%%%02X", ch); } if (sl) buf_put(b, "/", 1); p = sl ? sl + 1 : p + len; } } /* create the parent directory chain of an output path */ static int xp_mkparent(const char *path) { struct buf d; buf_init(&d); buf_printf(&d, "%s", path); int r = 0; char *sl = d.data ? strrchr(d.data, '/') : NULL; if (sl) { *sl = '\0'; r = mkdir_p(d.data); } buf_free(&d); return r; } /* ---- per-repo export --------------------------------------------------- */ struct repo_job { char owner[64], name[64], dir[PATH_MAX], ref[256]; }; static int is_binary(const char *p, size_t n) { return memchr(p, 0, n) != NULL; } static int export_commit(struct repo_job *j, const char *rdir, const char *sha) { struct buf out; buf_init(&out); const char *argv[] = { "git", "show", "--format=fuller", "--stat", "--patch", "--find-renames", sha, NULL }; int rc = git_capture(rdir, argv, &out); if (rc != 0 || out.len > 524288) { /* cap huge diffs */ buf_free(&out); return rc == 0 ? 0 : -1; /* skip, keep going */ } struct buf pp; buf_init(&pp); buf_printf(&pp, "%s/commit/%s.html", j->dir, sha); struct buf b; buf_init(&b); char title[160]; snprintf(title, sizeof title, "%s/%s commit %.12s", j->owner, j->name, sha); xp_head(&b, title, 3); /* commit/ dir depth */ buf_puts(&b, "<nav><a href='../index.html'>files</a>" "<a href='../log.html'>commits</a></nav>"); buf_puts(&b, "<pre>"); buf_put_escaped(&b, out.data ? out.data : "", out.len); buf_puts(&b, "</pre>"); xp_foot(&b); int w = xp_write(pp.data, &b); buf_free(&b); buf_free(&pp); buf_free(&out); return w; } /* one highlighted file page per blob; returns 0 ok */ static int export_files(struct repo_job *j, const char *rdir) { struct buf fd; xp_path(&fd, j->dir, "/file"); if (mkdir_p(fd.data) != 0) { buf_free(&fd); return -1; } buf_free(&fd); struct buf out; buf_init(&out); const char *argv[] = { "git", "ls-tree", "-r", "-z", j->ref, NULL }; if (git_capture(rdir, argv, &out) != 0 || !out.data) { buf_free(&out); return -1; } /* records: "<mode> blob <sha>\t<path>" NUL-separated; -r lists * leaves, so tree/submodule entries are filtered on type */ for (char *p = out.data; p < out.data + out.len; ) { char *tab = memchr(p, '\t', out.data + out.len - p); char *nul = memchr(p, '\0', out.data + out.len - p); if (!tab || !nul || nul < tab) break; *tab = '\0'; if (strstr(p, " blob ") == NULL) { /* keep blobs only */ p = nul + 1; continue; } const char *path = tab + 1; struct buf fb; /* encoded filename */ buf_init(&fb); fname_enc(&fb, path); buf_puts(&fb, ".html"); struct buf fp; buf_init(&fp); buf_printf(&fp, "%s/file/%s", j->dir, fb.data ? fb.data : "x.html"); xp_mkparent(fp.data); char spec[1400]; snprintf(spec, sizeof spec, "%s:%s", j->ref, path); struct buf content; buf_init(&content); const char *sargv[] = { "git", "show", spec, NULL }; /* depth varies: file/<enc> sits under repo/, and each '/' * kept in the encoded name adds one real level */ int sub = 0; for (const char *q = fb.data; q && *q; q++) sub += (*q == '/'); struct buf up; buf_init(&up); for (int k = 0; k <= sub; k++) buf_puts(&up, "../"); struct buf b; buf_init(&b); char title[1200]; snprintf(title, sizeof title, "%s/%s: %s", j->owner, j->name, path); xp_head(&b, title, 3 + sub); /* file/ + nesting */ buf_printf(&b, "<nav><a href='%sindex.html'>files</a>" "<a href='%slog.html'>commits</a></nav>", up.data, up.data); buf_puts(&b, "<p class=crumbs>"); buf_put_escaped(&b, path, strlen(path)); buf_puts(&b, "</p>"); if (git_capture(rdir, sargv, &content) != 0) { buf_puts(&b, "<p class=muted>unavailable</p>"); } else if (is_binary(content.data ? content.data : "", content.len)) { buf_puts(&b, "<p class=muted>binary file ("); buf_printf(&b, "%zu bytes)</p>", content.len); } else { buf_puts(&b, "<pre>"); if (content.len <= 262144) hl_emit(&b, content.data, content.len, path); else buf_put_escaped(&b, content.data, content.len); buf_puts(&b, "</pre>"); } xp_foot(&b); xp_write(fp.data, &b); buf_free(&b); buf_free(&up); buf_free(&fb); buf_free(&fp); buf_free(&content); p = nul + 1; } buf_free(&out); return 0; } static int export_repo(struct app_ctx *c, const char *destdir, const char *owner, const char *name, int maxc) { char rdir[PATH_MAX]; if (repo_path(rdir, sizeof rdir, c->repos_root, owner, name) != 0) return -1; struct repo_job j; memset(&j, 0, sizeof j); snprintf(j.owner, sizeof j.owner, "%s", owner); snprintf(j.name, sizeof j.name, "%s", name); snprintf(j.dir, sizeof j.dir, "%s/%s/%s", destdir, owner, name); char *d = git_default_branch(rdir); snprintf(j.ref, sizeof j.ref, "%s", d ? d : "HEAD"); free(d); struct buf dp; xp_path(&dp, j.dir, "/commit"); int mk = mkdir_p(dp.data); buf_free(&dp); if (mk != 0) return -1; /* ---- log.html + per-commit pages ---- */ char nbuf[16]; snprintf(nbuf, sizeof nbuf, "-%d", maxc); struct buf log; buf_init(&log); const char *largv[] = { "git", "log", nbuf, "--date=short", "--format=%H%x1f%an%x1f%ad%x1f%s", j.ref, "--", NULL }; struct buf b; buf_init(&b); char title[200]; snprintf(title, sizeof title, "%s/%s", owner, name); if (git_capture(rdir, largv, &log) == 0 && log.data) { xp_head(&b, title, 2); buf_puts(&b, "<nav><a href='index.html'>files</a></nav>" "<h2>commits</h2><table>"); char *save = NULL; for (char *line = strtok_r(log.data, "\n", &save); line; line = strtok_r(NULL, "\n", &save)) { char *sha = strtok(line, "\x1f"); char *an = strtok(NULL, "\x1f"); char *ad = strtok(NULL, "\x1f"); char *sub = strtok(NULL, "\x1f"); if (!sha) continue; buf_puts(&b, "<tr><td class=sha><a href='commit/"); buf_printf(&b, "%s.html'>%.10s</a></td><td>", sha, sha); buf_put_escaped(&b, sub ? sub : "", sub ? strlen(sub) : 0); buf_puts(&b, "</td><td class=muted>"); buf_put_escaped(&b, an ? an : "", an ? strlen(an) : 0); buf_puts(&b, "</td><td class=muted>"); buf_put_escaped(&b, ad ? ad : "", ad ? strlen(ad) : 0); buf_puts(&b, "</td></tr>"); export_commit(&j, rdir, sha); } buf_puts(&b, "</table>"); } else { xp_head(&b, title, 2); buf_puts(&b, "<p class=muted>no commits</p>"); } buf_free(&log); xp_foot(&b); xp_path(&dp, j.dir, "/log.html"); xp_write(dp.data, &b); buf_free(&dp); buf_free(&b); /* ---- refs.html ---- */ struct buf refs; buf_init(&refs); const char *rargv[] = { "git", "for-each-ref", "--format=%(refname:short)%09%(objecttype)%09%(*objectname)", "refs/heads", "refs/tags", NULL }; buf_init(&b); xp_head(&b, title, 2); buf_puts(&b, "<nav><a href='index.html'>files</a>" "<a href='log.html'>commits</a></nav><h2>refs</h2>"); if (git_capture(rdir, rargv, &refs) == 0 && refs.data) { buf_puts(&b, "<pre>"); buf_put_escaped(&b, refs.data, refs.len); buf_puts(&b, "</pre>"); } buf_free(&refs); xp_foot(&b); xp_path(&dp, j.dir, "/refs.html"); xp_write(dp.data, &b); buf_free(&dp); buf_free(&b); /* ---- file pages ---- */ export_files(&j, rdir); /* ---- index.html: summary + flat file list + README ---- */ buf_init(&b); xp_head(&b, title, 2); buf_printf(&b, "<h2>%s / %s</h2>", owner, name); buf_puts(&b, "<nav><a href='log.html'>commits</a>" "<a href='refs.html'>refs</a></nav>"); buf_puts(&b, "<h3>files</h3><table>"); struct buf ls; buf_init(&ls); const char *targv[] = { "git", "ls-tree", "-r", "--name-only", "-z", j.ref, NULL }; if (git_capture(rdir, targv, &ls) == 0 && ls.data) { for (char *p = ls.data; p < ls.data + ls.len; ) { char *nul = memchr(p, '\0', ls.data + ls.len - p); if (!nul) break; buf_puts(&b, "<tr><td><a href='file/"); fname_enc(&b, p); buf_puts(&b, ".html'>"); buf_put_escaped(&b, p, strlen(p)); buf_puts(&b, "</a></td></tr>"); p = nul + 1; } } buf_free(&ls); buf_puts(&b, "</table>"); /* README inline, if present */ const char *readmes[] = { "README.md", "README", "README.txt" }; for (size_t i = 0; i < sizeof readmes / sizeof readmes[0]; i++) { char spec[320]; snprintf(spec, sizeof spec, "%s:%s", j.ref, readmes[i]); struct buf rm; buf_init(&rm); const char *rargv2[] = { "git", "show", spec, NULL }; if (git_capture(rdir, rargv2, &rm) == 0 && rm.data && rm.len < 262144 && !is_binary(rm.data, rm.len)) { buf_puts(&b, "<h3>"); buf_put_escaped(&b, readmes[i], strlen(readmes[i])); buf_puts(&b, "</h3>"); if (ends_with(readmes[i], ".md")) { buf_puts(&b, "<div class=rdb>"); md_emit(&b, rm.data, rm.len); buf_puts(&b, "</div>"); } else { buf_puts(&b, "<pre>"); buf_put_escaped(&b, rm.data, rm.len); buf_puts(&b, "</pre>"); } buf_free(&rm); break; } buf_free(&rm); } xp_foot(&b); xp_path(&dp, j.dir, "/index.html"); int w = xp_write(dp.data, &b); buf_free(&dp); buf_free(&b); printf("%s/%s -> %s\n", owner, name, j.dir); return w; } /* ---- driver ------------------------------------------------------------ */ int gt_export(struct app_ctx *c, int argc, char **argv) { const char *destdir = "export"; int maxc = 100, priv = 0, i = 0; for (; i < argc; i++) { if (strcmp(argv[i], "-o") == 0 && i + 1 < argc) destdir = argv[++i]; else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) maxc = atoi(argv[++i]); else if (strcmp(argv[i], "--private") == 0) priv = 1; else break; } if (strlen(destdir) > PATH_MAX - 160 || mkdir_p(destdir) != 0) { fprintf(stderr, "git-trace: cannot create %s\n", destdir); return 1; } if (maxc < 1) maxc = 1; /* repo set: explicit owner/name list, else all (public) repos */ struct buf idx; buf_init(&idx); xp_head(&idx, "repositories", 0); buf_puts(&idx, "<h1>git-trace export</h1><table>"); int n = 0; if (i < argc) { for (; i < argc; i++) { char owner[64], name[64]; const char *sl = strchr(argv[i], '/'); if (!sl || sl == argv[i]) continue; size_t ol = (size_t)(sl - argv[i]); if (ol >= sizeof owner) continue; memcpy(owner, argv[i], ol); owner[ol] = '\0'; snprintf(name, sizeof name, "%s", sl + 1); if (!owner_ok(owner) || !name_ok(name)) continue; if (export_repo(c, destdir, owner, name, maxc) == 0) { buf_printf(&idx, "<tr><td><a href='%s/%s/index.html'>" "%s/%s</a></td></tr>", owner, name, owner, name); n++; } } } else { /* discovery mirrors the web index: the filesystem is the * source of truth for existence, repo_perm() decides what * anonymous may read (public or unregistered -> 'r'). * --private lifts the filter entirely — explicit operator * consent to publish private code. */ DIR *root = opendir(c->repos_root); struct dirent *oe; while (root && (oe = readdir(root))) { if (!owner_ok(oe->d_name)) continue; struct buf od; buf_init(&od); buf_printf(&od, "%s/%s", c->repos_root, oe->d_name); DIR *rd = opendir(od.data ? od.data : ""); buf_free(&od); if (!rd) continue; struct dirent *re_; while ((re_ = readdir(rd))) { if (!ends_with(re_->d_name, ".git")) continue; char name[64]; size_t nl = strlen(re_->d_name) - 4; if (nl == 0 || nl >= sizeof name) continue; memcpy(name, re_->d_name, nl); name[nl] = '\0'; if (!name_ok(name)) continue; if (!priv && repo_perm(c->db, oe->d_name, name, 0) != 'r') continue; if (export_repo(c, destdir, oe->d_name, name, maxc) != 0) { fprintf(stderr, "git-trace: export %s/%s failed\n", oe->d_name, name); continue; } buf_printf(&idx, "<tr><td><a href='%s/%s/index.html'>" "%s/%s</a></td></tr>", oe->d_name, name, oe->d_name, name); n++; } closedir(rd); } if (root) closedir(root); } buf_puts(&idx, "</table>"); xp_foot(&idx); struct buf ip; xp_path(&ip, destdir, "/index.html"); xp_write(ip.data, &idx); buf_free(&ip); buf_free(&idx); printf("%d repo%s exported to %s\n", n, n == 1 ? "" : "s", destdir); return n > 0 ? 0 : 1; }