aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--Cargo.lock72+72 −0
-rw-r--r--Cargo.toml3+2 −1
-rw-r--r--README.md14+9 −5
-rw-r--r--cgit/cgit.c217+72 −145
-rw-r--r--cgit/cgit.h7+2 −5
-rw-r--r--cgit/cmd.c52+28 −24
-rw-r--r--cgit/html.c15+0 −15
-rw-r--r--cgit/html.h1+0 −1
-rw-r--r--cgit/parsing.c50+0 −50
-rw-r--r--cgit/ui-atom.c6+1 −5
-rw-r--r--cgit/ui-repolist.c79+34 −45
-rw-r--r--cgit/ui-shared.c409+233 −176
-rw-r--r--cgit/ui-shared.h3+3 −0
-rw-r--r--cgit/ui-snapshot.c4+4 −0
-rw-r--r--cgit/ui-ssdiff.c10+6 −4
-rw-r--r--cgit/ui-summary.c2+1 −1
-rw-r--r--cgit/ui-tag.c16+11 −5
-rw-r--r--charts/gilti/README.md8+5 −3
-rw-r--r--charts/gilti/templates/deployment.yaml8+5 −3
-rw-r--r--charts/gilti/values.schema.json6+6 −0
-rw-r--r--charts/gilti/values.yaml4+4 −0
-rw-r--r--crates/gilti/Cargo.toml1+1 −0
-rw-r--r--crates/gilti/src/cgi.rs19+17 −2
-rw-r--r--crates/gilti/src/lfs.rs389+389 −0
-rw-r--r--crates/gilti/src/main.rs575+531 −44
-rw-r--r--crates/gilti/src/router.rs867+867 −0
-rw-r--r--crates/gilti/src/ui.rs10+5 −5
-rwxr-xr-xtests/smoke.sh39+29 −10
28 files changed, 2337 insertions, 549 deletions
diff --git a/Cargo.lock b/Cargo.lock
index ba66bad..542fa41 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -55,12 +55,56 @@ dependencies = [
]
[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -104,6 +148,16 @@ dependencies = [
]
[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
name = "gilti"
version = "0.1.0"
dependencies = [
@@ -112,6 +166,7 @@ dependencies = [
"percent-encoding",
"serde",
"serde_json",
+ "sha2",
"tokio",
"tower",
]
@@ -352,6 +407,17 @@ dependencies = [
]
[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -466,6 +532,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 6fb0143..14294b4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,11 +18,12 @@ repository = "https://github.com/dimidiumlabs/gilti"
axum = { version = "0.8.4", default-features = false, features = ["http1", "tokio"] }
libc = "0.2.172"
percent-encoding = "2.3.2"
-tokio = { version = "1.44.2", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal"] }
+tokio = { version = "1.44.2", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal"] }
tower = { version = "0.5.2", features = ["util"] }
maud = "0.27.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
+sha2 = "0.10.9"
[profile.release]
codegen-units = 1
diff --git a/README.md b/README.md
index 862664d..5e09b9b 100644
--- a/README.md
+++ b/README.md
@@ -9,12 +9,15 @@ platforms but aren't ready to host complex services like Forgejo.
## Security boundary
-- Git fetch and push use SSH public-key authentication through `gilti-ssh`.
+- Git fetch is available anonymously over smart HTTP; authenticated fetch and
+ push use SSH public-key authentication through `gilti-ssh`.
- Every configured key has read/write access to every repository and may create
a repository by pushing to its name for the first time.
-- cgit is anonymous and read-only; every repository is publicly visible.
-- Smart HTTP, password authentication, shells, forwarding, and tunnels are
- disabled; cgit filters are not supported.
+- Repository browsing, archives, LFS downloads, and smart HTTP fetches are
+ anonymous and read-only; every repository is publicly visible.
+- Password authentication, shells, forwarding, and tunnels are disabled; cgit
+ filters are not supported. Optional unauthenticated HTTP writes must be
+ enabled explicitly.
- Gilti is a single-replica service backed by one POSIX persistent volume. It is
not an HA system.
@@ -44,7 +47,8 @@ The HTTP configuration is read from the environment at startup:
- `GILTI_CGIT_ROOT_TITLE` (default: `Gilti`);
- `GILTI_CGIT_ROOT_DESCRIPTION` (default: `A tiny Git server`);
- `GILTI_CGIT_CLONE_PREFIX` (empty by default);
-- `GILTI_CGIT_CACHE` (default: `5`, maximum: `3600` seconds).
+- `GILTI_CGIT_CACHE` (default: `5`, maximum: `3600` seconds);
+- `GILTI_HTTP_WRITE` (`0` by default; `1` enables unauthenticated HTTP pushes and LFS uploads).
Gilti snapshots the authorized keys file at process startup; changing it takes
effect after a restart. Repositories and the persistent SSH host key live on
diff --git a/cgit/cgit.c b/cgit/cgit.c
index f04a962..dbf8a0f 100644
--- a/cgit/cgit.c
+++ b/cgit/cgit.c
@@ -63,61 +63,29 @@ static int config_integer(const char *name)
return result;
}
-static void querystring_cb(const char *name, const char *value)
+static char *request_optional_string(const char *name)
{
- if (!value)
- value = "";
-
- if (!strcmp(name,"r")) {
- ctx.qry.repo = xstrdup(value);
- ctx.repo = cgit_get_repoinfo(value);
- } else if (!strcmp(name, "p")) {
- ctx.qry.page = xstrdup(value);
- } else if (!strcmp(name, "url")) {
- if (*value == '/')
- value++;
- ctx.qry.url = xstrdup(value);
- cgit_parse_url(value);
- } else if (!strcmp(name, "qt")) {
- ctx.qry.grep = xstrdup(value);
- } else if (!strcmp(name, "q")) {
- ctx.qry.search = xstrdup(value);
- } else if (!strcmp(name, "h")) {
- ctx.qry.head = xstrdup(value);
- } else if (!strcmp(name, "id")) {
- ctx.qry.oid = xstrdup(value);
- ctx.qry.has_oid = 1;
- } else if (!strcmp(name, "id2")) {
- ctx.qry.oid2 = xstrdup(value);
- ctx.qry.has_oid = 1;
- } else if (!strcmp(name, "ofs")) {
- ctx.qry.ofs = atoi(value);
- } else if (!strcmp(name, "path")) {
- ctx.qry.path = trim_end(value, '/');
- } else if (!strcmp(name, "name")) {
- ctx.qry.name = xstrdup(value);
- } else if (!strcmp(name, "s")) {
- ctx.qry.sort = xstrdup(value);
- } else if (!strcmp(name, "showmsg")) {
- ctx.qry.showmsg = atoi(value);
- } else if (!strcmp(name, "period")) {
- ctx.qry.period = xstrdup(value);
- } else if (!strcmp(name, "dt")) {
- ctx.qry.difftype = atoi(value);
- ctx.qry.has_difftype = 1;
- } else if (!strcmp(name, "ss")) {
- /* No longer generated, but there may be links out there. */
- ctx.qry.difftype = atoi(value) ? DIFF_SSDIFF : DIFF_UNIFIED;
- ctx.qry.has_difftype = 1;
- } else if (!strcmp(name, "all")) {
- ctx.qry.show_all = atoi(value);
- } else if (!strcmp(name, "context")) {
- ctx.qry.context = atoi(value);
- } else if (!strcmp(name, "ignorews")) {
- ctx.qry.ignorews = atoi(value);
- } else if (!strcmp(name, "follow")) {
- ctx.qry.follow = atoi(value);
+ const char *value = getenv(name);
+
+ return value && *value ? xstrdup(value) : NULL;
+}
+
+static int request_optional_integer(const char *name)
+{
+ const char *value = getenv(name);
+ char *end;
+ long result;
+
+ if (!value || !*value)
+ return 0;
+ errno = 0;
+ result = strtol(value, &end, 10);
+ if (errno || end == value || *end || result < INT_MIN || result > INT_MAX) {
+ fprintf(stderr, "gilti-cgit: environment variable %s must be an integer\n",
+ name);
+ exit(1);
}
+ return result;
}
static void prepare_context(void)
@@ -209,65 +177,49 @@ static void prepare_context(void)
ctx.page.mimetype = "text/html";
ctx.page.charset = PAGE_ENCODING;
ctx.page.modified = time(NULL);
- if (ctx.env.query_string)
- ctx.qry.raw = xstrdup(ctx.env.query_string);
-}
-
-struct refmatch {
- char *req_ref;
- char *first_ref;
- int match;
-};
-
-static int find_current_ref(const struct reference *ref, void *cb_data)
-{
- struct refmatch *info;
-
- info = (struct refmatch *)cb_data;
- if (!strcmp(ref->name, info->req_ref))
- info->match = 1;
- if (!info->first_ref)
- info->first_ref = xstrdup(ref->name);
- return info->match;
}
-static void free_refmatch_inner(struct refmatch *info)
+static void prepare_request(void)
{
- if (info->first_ref)
- free(info->first_ref);
-}
-
-static char *find_default_branch(struct cgit_repo *repo)
-{
- struct refmatch info;
- char *ref;
-
- info.req_ref = repo->defbranch;
- info.first_ref = NULL;
- info.match = 0;
- refs_for_each_branch_ref(get_main_ref_store(the_repository),
- find_current_ref, &info);
- if (info.match)
- ref = info.req_ref;
- else
- ref = info.first_ref;
- if (ref)
- ref = xstrdup(ref);
- free_refmatch_inner(&info);
-
- return ref;
+ ctx.qry.repo = request_optional_string("GILTI_REPOSITORY");
+ ctx.qry.page = config_string("GILTI_PAGE");
+ ctx.qry.url = request_optional_string("GILTI_CURRENT_URL");
+ ctx.qry.head = request_optional_string("GILTI_REVISION");
+ if (ctx.qry.head) {
+ ctx.qry.oid = xstrdup(ctx.qry.head);
+ ctx.qry.has_oid = 1;
+ }
+ ctx.qry.oid2 = request_optional_string("GILTI_OLD_REVISION");
+ ctx.qry.path = request_optional_string("GILTI_PATH");
+ ctx.qry.format = request_optional_string("GILTI_FORMAT");
+ ctx.qry.signature = request_optional_integer("GILTI_SIGNATURE");
+ ctx.qry.search = request_optional_string("GILTI_QUERY_SEARCH");
+ ctx.qry.grep = request_optional_string("GILTI_QUERY_GREP");
+ ctx.qry.sort = request_optional_string("GILTI_QUERY_SORT");
+ ctx.qry.period = request_optional_string("GILTI_QUERY_PERIOD");
+ ctx.qry.ofs = request_optional_integer("GILTI_QUERY_OFFSET");
+ ctx.qry.showmsg = request_optional_integer("GILTI_QUERY_SHOWMSG");
+ ctx.qry.context = request_optional_integer("GILTI_QUERY_CONTEXT");
+ ctx.qry.ignorews = request_optional_integer("GILTI_QUERY_IGNOREWS");
+ ctx.qry.follow = request_optional_integer("GILTI_QUERY_FOLLOW");
+ if (getenv("GILTI_QUERY_DIFFTYPE")) {
+ ctx.qry.difftype = request_optional_integer("GILTI_QUERY_DIFFTYPE");
+ ctx.qry.has_difftype = 1;
+ }
+ if (ctx.qry.repo)
+ ctx.repo = cgit_get_repoinfo(ctx.qry.repo);
}
static char *guess_defbranch(void)
{
- const char *ref, *refname;
+ const char *ref;
struct object_id oid;
ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository),
"HEAD", 0, &oid, NULL);
- if (!ref || !skip_prefix(ref, "refs/heads/", &refname))
- return "master";
- return xstrdup(refname);
+ if (!ref)
+ return xstrdup("HEAD");
+ return xstrdup(ref);
}
/* The caller must free filename and ref after calling this. */
@@ -341,7 +293,7 @@ static void choose_readme(struct cgit_repo *repo)
static void print_no_repo_clone_urls(const char *url)
{
html("<tr><td><a rel='vcs-git' href='");
- html_url_path(url);
+ html_attr(url);
html("' title='");
html_attr(ctx.repo->name);
html(" Git repository'>");
@@ -385,34 +337,26 @@ static int prepare_repo_cmd(int nongit)
if (!ctx.repo->defbranch)
ctx.repo->defbranch = guess_defbranch();
- if (!ctx.qry.head) {
- ctx.qry.nohead = 1;
- ctx.qry.head = find_default_branch(ctx.repo);
- }
+ if (!ctx.qry.head)
+ ctx.qry.head = xstrdup("HEAD");
- if (!ctx.qry.head) {
+ if (repo_get_oid(the_repository, ctx.qry.head, &oid)) {
+ if (strcmp(ctx.qry.page, "summary")) {
+ cgit_print_error_page(404, "Not found",
+ "Invalid revision: %s", ctx.qry.head);
+ return 1;
+ }
cgit_print_http_headers();
cgit_print_docstart();
cgit_print_pageheader();
cgit_print_error("Repository seems to be empty");
- if (!strcmp(ctx.qry.page, "summary")) {
- html("<table class='list'><tr class='nohover'><td>&nbsp;</td></tr><tr class='nohover'><th class='left'>Clone</th></tr>\n");
- cgit_prepare_repo_env(ctx.repo);
- cgit_add_clone_urls(print_no_repo_clone_urls);
- html("</table>\n");
- }
+ html("<table class='list'><tr class='nohover'><td>&nbsp;</td></tr><tr class='nohover'><th class='left'>Clone</th></tr>\n");
+ cgit_prepare_repo_env(ctx.repo);
+ cgit_add_clone_urls(print_no_repo_clone_urls);
+ html("</table>\n");
cgit_print_docend();
return 1;
}
-
- if (repo_get_oid(the_repository, ctx.qry.head, &oid)) {
- char *old_head = ctx.qry.head;
- ctx.qry.head = xstrdup(ctx.repo->defbranch);
- cgit_print_error_page(404, "Not found",
- "Invalid branch: %s", old_head);
- free(old_head);
- return 1;
- }
string_list_sort(&ctx.repo->submodules);
cgit_prepare_repo_env(ctx.repo);
choose_readme(ctx.repo);
@@ -424,6 +368,11 @@ static void process_request(void)
struct cgit_cmd *cmd;
int nongit = 0;
+ if (ctx.qry.repo && !ctx.repo) {
+ cgit_print_error_page(404, "Not found", "Repository not found");
+ return;
+ }
+
if (ctx.repo)
prepare_repo_env(&nongit);
@@ -466,8 +415,6 @@ static NORETURN void cgit_die_routine(const char *msg, va_list params)
int cmd_main(int argc UNUSED, const char **argv UNUSED)
{
- const char *path;
-
set_die_routine(cgit_die_routine);
prepare_context();
@@ -476,27 +423,7 @@ int cmd_main(int argc UNUSED, const char **argv UNUSED)
cgit_repolist.repos = NULL;
scan_tree(config_value("CGIT_SCAN_PATH"));
- ctx.repo = NULL;
- http_parse_querystring(ctx.qry.raw, querystring_cb);
-
- /* If no url parameter is specified on the querystring, use PATH_INFO
- * as url. This allows cgit to work with virtual urls without the need
- * for rewriterules in the webserver.
- */
- path = ctx.env.path_info;
- if (!ctx.qry.url && path) {
- if (path[0] == '/')
- path++;
- ctx.qry.url = xstrdup(path);
- if (ctx.qry.raw) {
- char *newqry = fmtalloc("%s?%s", path, ctx.qry.raw);
- free(ctx.qry.raw);
- ctx.qry.raw = newqry;
- } else
- ctx.qry.raw = xstrdup(ctx.qry.url);
- cgit_parse_url(ctx.qry.url);
- }
-
+ prepare_request();
process_request();
return 0;
}
diff --git a/cgit/cgit.h b/cgit/cgit.h
index 1102c87..818970f 100644
--- a/cgit/cgit.h
+++ b/cgit/cgit.h
@@ -154,7 +154,6 @@ struct reflist {
struct cgit_query {
int has_oid;
int has_difftype;
- char *raw;
char *repo;
char *page;
char *search;
@@ -163,11 +162,11 @@ struct cgit_query {
char *oid;
char *oid2;
char *path;
- char *name;
char *url;
char *period;
+ char *format;
+ int signature;
int ofs;
- int nohead;
char *sort;
int showmsg;
diff_type difftype;
@@ -329,8 +328,6 @@ extern char *fmtalloc(const char *format,...);
extern struct commitinfo *cgit_parse_commit(struct commit *commit);
extern struct taginfo *cgit_parse_tag(struct tag *tag);
-extern void cgit_parse_url(const char *url);
-
extern const char *cgit_repobasename(const char *reponame);
extern int cgit_parse_snapshots_mask(const char *str);
diff --git a/cgit/cmd.c b/cgit/cmd.c
index 13892a9..43a9036 100644
--- a/cgit/cmd.c
+++ b/cgit/cmd.c
@@ -43,29 +43,16 @@ static void atom_fn(void)
static void about_fn(void)
{
- if (ctx.repo) {
- size_t path_info_len = ctx.env.path_info ? strlen(ctx.env.path_info) : 0;
- if (!ctx.qry.path &&
- ctx.qry.url[strlen(ctx.qry.url) - 1] != '/' &&
- (!path_info_len || ctx.env.path_info[path_info_len - 1] != '/')) {
- char *currenturl = cgit_currenturl();
- char *redirect = fmtalloc("%s/", currenturl);
- cgit_redirect(redirect, true);
- free(currenturl);
- free(redirect);
- } else if (ctx.repo->readme.nr)
- cgit_print_repo_readme(ctx.qry.path);
- else if (ctx.repo->homepage)
- cgit_redirect(ctx.repo->homepage, false);
- else {
- char *currenturl = cgit_currenturl();
- char *redirect = fmtalloc("%s../", currenturl);
- cgit_redirect(redirect, false);
- free(currenturl);
- free(redirect);
- }
- } else
+ if (!ctx.repo) {
cgit_print_site_readme();
+ return;
+ }
+ if (ctx.repo->readme.nr)
+ cgit_print_repo_readme(NULL);
+ else if (ctx.repo->homepage)
+ cgit_redirect(ctx.repo->homepage, false);
+ else
+ cgit_print_error_page(404, "Not found", "No repository documentation");
}
static void blame_fn(void)
@@ -86,6 +73,14 @@ static void commit_fn(void)
cgit_print_commit(ctx.qry.oid, ctx.qry.path);
}
+static void revision_fn(void)
+{
+ if (ctx.qry.oid && starts_with(ctx.qry.oid, "refs/tags/"))
+ cgit_print_tag(ctx.qry.oid);
+ else
+ cgit_print_commit(ctx.qry.oid, NULL);
+}
+
static void diff_fn(void)
{
cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 0);
@@ -136,8 +131,16 @@ static void refs_fn(void)
static void snapshot_fn(void)
{
- cgit_print_snapshot(ctx.qry.head, ctx.qry.oid, ctx.qry.path,
- ctx.qry.nohead);
+ char *filename;
+
+ if (!ctx.qry.format) {
+ cgit_print_error_page(400, "Bad request", "Archive format is required");
+ return;
+ }
+ filename = fmtalloc("%s.%s%s", cgit_snapshot_prefix(ctx.repo),
+ ctx.qry.format, ctx.qry.signature ? ".asc" : "");
+ cgit_print_snapshot(ctx.qry.head, ctx.qry.oid, filename, 0);
+ free(filename);
}
static void stats_fn(void)
@@ -180,6 +183,7 @@ struct cgit_cmd *cgit_get_cmd(void)
def_cmd(plain, 1, 0, 0),
def_cmd(rawdiff, 1, 1, 0),
def_cmd(refs, 1, 0, 0),
+ def_cmd(revision, 1, 0, 0),
def_cmd(repolist, 0, 0, 0),
def_cmd(snapshot, 1, 0, 0),
def_cmd(stats, 1, 1, 0),
diff --git a/cgit/html.c b/cgit/html.c
index bef58df..37f05a0 100644
--- a/cgit/html.c
+++ b/cgit/html.c
@@ -348,18 +348,3 @@ int html_include_text(const char *filename)
fclose(f);
return 0;
}
-
-void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value))
-{
- const char *t = txt;
-
- while (t && *t) {
- char *name = url_decode_parameter_name(&t);
- if (*name) {
- char *value = url_decode_parameter_value(&t);
- fn(name, value);
- free(value);
- }
- free(name);
- }
-}
diff --git a/cgit/html.h b/cgit/html.h
index 7cedb45..03c4efb 100644
--- a/cgit/html.h
+++ b/cgit/html.h
@@ -37,6 +37,5 @@ extern void html_fileperm(unsigned short mode);
extern int html_include(const char *filename);
extern int html_include_text(const char *filename);
-extern void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value));
#endif /* HTML_H */
diff --git a/cgit/parsing.c b/cgit/parsing.c
index fc4215f..686ef5c 100644
--- a/cgit/parsing.c
+++ b/cgit/parsing.c
@@ -14,56 +14,6 @@
#include "cgit.h"
-/*
- * url syntax: [repo ['/' cmd [ '/' path]]]
- * repo: any valid repo url, may contain '/'
- * cmd: log | commit | diff | tree | view | blob | snapshot
- * path: any valid path, may contain '/'
- *
- */
-void cgit_parse_url(const char *url)
-{
- char *c, *cmd, *p, *buf;
- struct cgit_repo *repo;
-
- if (!url || url[0] == '\0')
- return;
-
- ctx.qry.page = NULL;
- ctx.repo = cgit_get_repoinfo(url);
- if (ctx.repo) {
- ctx.qry.repo = ctx.repo->url;
- return;
- }
-
- buf = xstrdup(url);
- cmd = NULL;
- c = strchr(buf, '/');
- while (c) {
- c[0] = '\0';
- repo = cgit_get_repoinfo(buf);
- if (repo) {
- ctx.repo = repo;
- cmd = c;
- }
- c[0] = '/';
- c = strchr(c + 1, '/');
- }
-
- if (ctx.repo) {
- ctx.qry.repo = ctx.repo->url;
- p = strchr(cmd + 1, '/');
- if (p) {
- p[0] = '\0';
- if (p[1])
- ctx.qry.path = trim_end(p + 1, '/');
- }
- if (cmd[1])
- ctx.qry.page = xstrdup(cmd + 1);
- }
- free(buf);
-}
-
static char *substr(const char *head, const char *tail)
{
char *buf;
diff --git a/cgit/ui-atom.c b/cgit/ui-atom.c
index 5da047a..5371319 100644
--- a/cgit/ui-atom.c
+++ b/cgit/ui-atom.c
@@ -19,7 +19,6 @@
static void add_entry(struct commit *commit, const char *host)
{
- char delim = '&';
char *hex;
char *mail, *t, *t2;
struct commitinfo *info;
@@ -65,11 +64,8 @@ static void add_entry(struct commit *commit, const char *host)
html("<link rel='alternate' type='text/html' href='");
html(cgit_httpscheme());
html_attr(host);
- pageurl = cgit_pageurl(ctx.repo->url, "commit", NULL);
+ pageurl = cgit_revurl(ctx.repo->url, hex);
html_attr(pageurl);
- if (ctx.cfg.virtual_root)
- delim = '?';
- html_attrf("%cid=%s", delim, hex);
html("'/>\n");
free(pageurl);
}
diff --git a/cgit/ui-repolist.c b/cgit/ui-repolist.c
index 5f2da3d..f6f2e99 100644
--- a/cgit/ui-repolist.c
+++ b/cgit/ui-repolist.c
@@ -41,12 +41,14 @@ static time_t read_agefile(const char *path)
static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime)
{
struct strbuf path = STRBUF_INIT;
+ struct strbuf head = STRBUF_INIT;
struct stat s;
struct cgit_repo *r = (struct cgit_repo *)repo;
+ const char *ref;
if (repo->mtime != -1) {
*mtime = repo->mtime;
- return 1;
+ return repo->mtime != 0;
}
strbuf_addf(&path, "%s/%s", repo->path, ctx.cfg.agefile);
if (stat(path.buf, &s) == 0) {
@@ -58,12 +60,22 @@ static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime)
}
strbuf_reset(&path);
- strbuf_addf(&path, "%s/refs/heads/%s", repo->path,
- repo->defbranch ? repo->defbranch : "master");
- if (stat(path.buf, &s) == 0) {
- *mtime = s.st_mtime;
- r->mtime = *mtime;
- goto end;
+ strbuf_addf(&path, "%s/HEAD", repo->path);
+ if (strbuf_read_file(&head, path.buf, 64) >= 0) {
+ strbuf_trim(&head);
+ if (skip_prefix(head.buf, "ref: ", &ref)) {
+ strbuf_reset(&path);
+ strbuf_addf(&path, "%s/%s", repo->path, ref);
+ if (stat(path.buf, &s) == 0) {
+ *mtime = s.st_mtime;
+ r->mtime = *mtime;
+ goto end;
+ }
+ } else if (head.len && stat(path.buf, &s) == 0) {
+ *mtime = s.st_mtime;
+ r->mtime = *mtime;
+ goto end;
+ }
}
strbuf_reset(&path);
@@ -77,6 +89,7 @@ static int get_repo_modtime(const struct cgit_repo *repo, time_t *mtime)
*mtime = 0;
r->mtime = *mtime;
end:
+ strbuf_release(&head);
strbuf_release(&path);
return (r->mtime != 0);
}
@@ -96,22 +109,11 @@ static int is_match(struct cgit_repo *repo)
return 0;
}
-static int is_in_url(struct cgit_repo *repo)
-{
- if (!ctx.qry.url)
- return 1;
- if (repo->url && starts_with(repo->url, ctx.qry.url))
- return 1;
- return 0;
-}
-
static int is_visible(struct cgit_repo *repo)
{
if (repo->hide || repo->ignore)
return 0;
- if (!(is_match(repo) && is_in_url(repo)))
- return 0;
- return 1;
+ return is_match(repo);
}
static int any_repos_visible(void)
@@ -266,7 +268,7 @@ static void url_path(struct strbuf *url, const char *value)
const unsigned char *p = (const unsigned char *)(value ? value : "");
for (; *p; p++) {
- if (isalnum(*p) || strchr("!$()*,./:;@-[]_~+&", *p))
+ if (isalnum(*p) || *p == '/' || *p == '_')
strbuf_addch(url, *p);
else
strbuf_addf(url, "%%%02x", *p);
@@ -312,31 +314,15 @@ static char *repo_url(const struct cgit_repo *repo, const char *page,
const char *query)
{
struct strbuf url = STRBUF_INIT;
- const char *delimiter = "?";
- if (ctx.cfg.virtual_root) {
- url_path(&url, ctx.cfg.virtual_root);
- url_path(&url, repo->url);
- if (repo->url[strlen(repo->url) - 1] != '/')
- strbuf_addch(&url, '/');
- if (page) {
- url_path(&url, page);
- strbuf_addch(&url, '/');
- }
- } else {
- url_path(&url, ctx.cfg.script_name);
- strbuf_addstr(&url, "?url=");
- url_arg(&url, repo->url);
- if (repo->url[strlen(repo->url) - 1] != '/')
- strbuf_addch(&url, '/');
- if (page) {
- url_arg(&url, page);
- strbuf_addch(&url, '/');
- }
- delimiter = "&";
+ strbuf_addch(&url, '/');
+ url_path(&url, repo->url);
+ if (page) {
+ strbuf_addstr(&url, "/+/HEAD/+/");
+ url_path(&url, page);
}
if (query)
- strbuf_addf(&url, "%s%s", delimiter, query);
+ strbuf_addf(&url, "?%s", query);
return strbuf_detach(&url, NULL);
}
@@ -428,7 +414,7 @@ void cgit_print_repolist(void)
json_key("title"); json_value(ctx.cfg.root_title); json(",");
json_key("root_desc"); json_value(ctx.cfg.root_desc); json(",");
json_key("root_url"); json_value(cgit_rooturl()); json(",");
- json_key("about_url"); { struct strbuf about = STRBUF_INIT; strbuf_addf(&about, "%s?p=about", cgit_rooturl()); json_value(about.buf); strbuf_release(&about); } json(",");
+ json_key("about_url"); json_value("/-/about"); json(",");
json_key("noheader"); json_bool(ctx.cfg.noheader); json(",");
json_key("search"); json_value(ctx.qry.search); json(",");
json_key("current_url"); { char *url = cgit_currenturl(); json_value(url); free(url); } json(",");
@@ -443,6 +429,8 @@ void cgit_print_repolist(void)
json_key("rows"); json("[");
for (i = 0; i < cgit_repolist.count; i++) {
struct cgit_repo *repo = &cgit_repolist.repos[i];
+ time_t mtime;
+ int populated;
char *section, *url;
if (!is_visible(repo)) continue;
hits++;
@@ -469,8 +457,9 @@ void cgit_print_repolist(void)
json_key("owner"); json_value(repo->owner); json(",");
json_key("owner_url"); { char *current = cgit_currenturl(); struct strbuf owner = STRBUF_INIT; strbuf_addf(&owner, "%s?q=", current); url_arg(&owner, repo->owner); json_value(owner.buf); strbuf_release(&owner); free(current); } json(",");
json_key("idle"); json_age(repo); json(",");
- json_key("log_url"); url = repo_url(repo, "log", ctx.qry.showmsg ? "showmsg=1" : NULL); json_value(url); free(url); json(",");
- json_key("tree_url"); url = repo_url(repo, "tree", NULL); json_value(url); free(url);
+ populated = get_repo_modtime(repo, &mtime);
+ json_key("log_url"); if (populated) { url = repo_url(repo, "log", ctx.qry.showmsg ? "showmsg=1" : NULL); json_value(url); free(url); } else json("null"); json(",");
+ json_key("tree_url"); if (populated) { url = repo_url(repo, "tree", NULL); json_value(url); free(url); } else json("null");
json("}"); first = 0;
}
json("],");
diff --git a/cgit/ui-shared.c b/cgit/ui-shared.c
index 96a8c3c..49e9c00 100644
--- a/cgit/ui-shared.c
+++ b/cgit/ui-shared.c
@@ -75,47 +75,15 @@ char *cgit_hosturl(void)
char *cgit_currenturl(void)
{
- const char *root = cgit_rooturl();
-
- if (!ctx.qry.url)
- return xstrdup(root);
- if (root[0] && root[strlen(root) - 1] == '/')
- return fmtalloc("%s%s", root, ctx.qry.url);
- return fmtalloc("%s/%s", root, ctx.qry.url);
+ return xstrdup(ctx.qry.url ? ctx.qry.url : cgit_rooturl());
}
char *cgit_currentfullurl(void)
{
- const char *root = cgit_rooturl();
- const char *orig_query = ctx.env.query_string ? ctx.env.query_string : "";
- size_t len = strlen(orig_query);
- char *query = xmalloc(len + 2), *start_url, *ret;
-
- /* Remove all url=... parts from query string */
- memcpy(query + 1, orig_query, len + 1);
- query[0] = '?';
- start_url = query;
- while ((start_url = strstr(start_url, "url=")) != NULL) {
- if (start_url[-1] == '?' || start_url[-1] == '&') {
- const char *end_url = strchr(start_url, '&');
- if (end_url)
- memmove(start_url, end_url + 1, strlen(end_url));
- else
- start_url[0] = '\0';
- } else
- ++start_url;
- }
- if (!query[1])
- query[0] = '\0';
+ const char *url = ctx.qry.url ? ctx.qry.url : cgit_rooturl();
+ const char *query = ctx.env.query_string;
- if (!ctx.qry.url)
- ret = fmtalloc("%s%s", root, query);
- else if (root[0] && root[strlen(root) - 1] == '/')
- ret = fmtalloc("%s%s%s", root, ctx.qry.url, query);
- else
- ret = fmtalloc("%s/%s%s", root, ctx.qry.url, query);
- free(query);
- return ret;
+ return query && *query ? fmtalloc("%s?%s", url, query) : xstrdup(url);
}
const char *cgit_rooturl(void)
@@ -126,32 +94,86 @@ const char *cgit_rooturl(void)
return ctx.cfg.script_name;
}
+static void add_url_path(struct strbuf *url, const char *value)
+{
+ const unsigned char *p = (const unsigned char *)(value ? value : "");
+
+ for (; *p; p++) {
+ if (isalnum(*p) || *p == '/' || *p == '_')
+ strbuf_addch(url, *p);
+ else
+ strbuf_addf(url, "%%%02x", *p);
+ }
+}
+
+static void add_repo_url(struct strbuf *url, const char *reponame)
+{
+ strbuf_addch(url, '/');
+ add_url_path(url, reponame);
+}
+
+static const char *link_revision(const char *head, const char *rev)
+{
+ return rev ? rev : head ? head : "HEAD";
+}
+
+static void add_revision(struct strbuf *url, const char *revision)
+{
+ struct object_id oid;
+
+ if (!strcmp(revision, "HEAD") || starts_with(revision, "refs/") ||
+ !get_oid_hex(revision, &oid))
+ add_url_path(url, revision);
+ else {
+ strbuf_addstr(url, "refs/heads/");
+ add_url_path(url, revision);
+ }
+}
+
+static char *repo_view_url(const char *reponame, const char *page,
+ const char *revision, const char *path)
+{
+ struct strbuf url = STRBUF_INIT;
+
+ add_repo_url(&url, reponame);
+ if (!page || !strcmp(page, "summary"))
+ return strbuf_detach(&url, NULL);
+ strbuf_addstr(&url, "/+/");
+ if (!strcmp(page, "about") || !strcmp(page, "stats") ||
+ !strcmp(page, "refs")) {
+ strbuf_addstr(&url, page);
+ return strbuf_detach(&url, NULL);
+ }
+ add_revision(&url, revision ? revision : "HEAD");
+ if (strcmp(page, "commit")) {
+ strbuf_addstr(&url, "/+/");
+ strbuf_addstr(&url, !strcmp(page, "plain") ? "tree" : page);
+ if (path) {
+ strbuf_addch(&url, '/');
+ add_url_path(&url, path);
+ }
+ }
+ return strbuf_detach(&url, NULL);
+}
+
char *cgit_repourl(const char *reponame)
{
- if (ctx.cfg.virtual_root)
- return fmtalloc("%s%s/", ctx.cfg.virtual_root, reponame);
- else
- return fmtalloc("?r=%s", reponame);
+ return repo_view_url(reponame, NULL, NULL, NULL);
}
char *cgit_fileurl(const char *reponame, const char *pagename,
const char *filename, const char *query)
{
- struct strbuf sb = STRBUF_INIT;
- char *delim;
-
- if (ctx.cfg.virtual_root) {
- strbuf_addf(&sb, "%s%s/%s/%s", ctx.cfg.virtual_root, reponame,
- pagename, (filename ? filename:""));
- delim = "?";
- } else {
- strbuf_addf(&sb, "?url=%s/%s/%s", reponame, pagename,
- (filename ? filename : ""));
- delim = "&amp;";
- }
- if (query)
- strbuf_addf(&sb, "%s%s", delim, query);
- return strbuf_detach(&sb, NULL);
+ char *url = repo_view_url(reponame,
+ !strcmp(pagename, "atom") ? "feed/atom" : pagename,
+ ctx.qry.head, filename);
+ char *result;
+
+ if (!query)
+ return url;
+ result = fmtalloc("%s?%s", url, query);
+ free(url);
+ return result;
}
char *cgit_pageurl(const char *reponame, const char *pagename,
@@ -160,6 +182,24 @@ char *cgit_pageurl(const char *reponame, const char *pagename,
return cgit_fileurl(reponame, pagename, NULL, query);
}
+char *cgit_revurl(const char *reponame, const char *revision)
+{
+ return repo_view_url(reponame, "commit", revision, NULL);
+}
+
+char *cgit_treeurl(const char *reponame, const char *revision,
+ const char *path, const char *fragment)
+{
+ char *url = repo_view_url(reponame, "tree", revision, path);
+ char *result;
+
+ if (!fragment)
+ return url;
+ result = fmtalloc("%s#%s", url, fragment);
+ free(url);
+ return result;
+}
+
const char *cgit_repobasename(const char *reponame)
{
/* I assume we don't need to store more than one repo basename */
@@ -202,7 +242,9 @@ static void site_url(const char *page, const char *search, const char *sort, int
{
char *delim = "?";
- if (always_root || page)
+ if (page)
+ htmlf("/-/%s", page);
+ else if (always_root)
html_attr(cgit_rooturl());
else {
char *currenturl = cgit_currenturl();
@@ -210,10 +252,6 @@ static void site_url(const char *page, const char *search, const char *sort, int
free(currenturl);
}
- if (page) {
- htmlf("?p=%s", page);
- delim = "&amp;";
- }
if (search) {
html(delim);
html("q=");
@@ -259,11 +297,8 @@ void cgit_index_link(const char *name, const char *title, const char *class,
site_link(NULL, name, title, class, pattern, sort, ofs, always_root);
}
-static char *repolink(const char *title, const char *class, const char *page,
- const char *head, const char *path)
+static void link_start(const char *url, const char *title, const char *class)
{
- char *delim = "?";
-
html("<a");
if (title) {
html(" title='");
@@ -276,52 +311,33 @@ static char *repolink(const char *title, const char *class, const char *page,
html("'");
}
html(" href='");
- if (ctx.cfg.virtual_root) {
- html_url_path(ctx.cfg.virtual_root);
- html_url_path(ctx.repo->url);
- if (ctx.repo->url[strlen(ctx.repo->url) - 1] != '/')
- html("/");
- if (page) {
- html_url_path(page);
- html("/");
- if (path)
- html_url_path(path);
- }
- } else {
- html_url_path(ctx.cfg.script_name);
- html("?url=");
- html_url_arg(ctx.repo->url);
- if (ctx.repo->url[strlen(ctx.repo->url) - 1] != '/')
- html("/");
- if (page) {
- html_url_arg(page);
- html("/");
- if (path)
- html_url_arg(path);
- }
- delim = "&amp;";
- }
- if (head && ctx.repo->defbranch && strcmp(head, ctx.repo->defbranch)) {
- html(delim);
- html("h=");
- html_url_arg(head);
- delim = "&amp;";
- }
- return fmt("%s", delim);
+ html_attr(url);
+}
+
+static char *repolink(const char *title, const char *class, const char *page,
+ const char *head, const char *path)
+{
+ char *url = repo_view_url(ctx.repo->url, page, head, path);
+
+ link_start(url, title, class);
+ free(url);
+ return "?";
}
static void reporevlink(const char *page, const char *name, const char *title,
const char *class, const char *head, const char *rev,
const char *path)
{
- char *delim;
+ char *url = repo_view_url(ctx.repo->url, page,
+ link_revision(head, rev), path);
- delim = repolink(title, class, page, head, path);
- if (rev && ctx.qry.head != NULL && strcmp(rev, ctx.qry.head)) {
- html(delim);
- html("id=");
- html_url_arg(rev);
+ if (!strcmp(page ? page : "", "plain")) {
+ char *raw = fmtalloc("%s?format=raw", url);
+ free(url);
+ url = raw;
}
+ link_start(url, title, class);
+ free(url);
html("'>");
html_txt(name);
html("</a>");
@@ -336,7 +352,11 @@ void cgit_summary_link(const char *name, const char *title, const char *class,
void cgit_tag_link(const char *name, const char *title, const char *class,
const char *tag)
{
- reporevlink("tag", name, title, class, tag, NULL, NULL);
+ char *ref = starts_with(tag, "refs/tags/") ? xstrdup(tag) :
+ fmtalloc("refs/tags/%s", tag);
+
+ reporevlink("commit", name, title, class, ref, NULL, NULL);
+ free(ref);
}
void cgit_tree_link(const char *name, const char *title, const char *class,
@@ -364,13 +384,7 @@ void cgit_log_link(const char *name, const char *title, const char *class,
{
char *delim;
- delim = repolink(title, class, "log", head, path);
- if (rev && ctx.qry.head && strcmp(rev, ctx.qry.head)) {
- html(delim);
- html("id=");
- html_url_arg(rev);
- delim = "&amp;";
- }
+ delim = repolink(title, class, "log", link_revision(head, rev), path);
if (grep && pattern) {
html(delim);
html("qt=");
@@ -405,13 +419,7 @@ void cgit_commit_link(const char *name, const char *title, const char *class,
{
char *delim;
- delim = repolink(title, class, "commit", head, path);
- if (rev && ctx.qry.head && strcmp(rev, ctx.qry.head)) {
- html(delim);
- html("id=");
- html_url_arg(rev);
- delim = "&amp;";
- }
+ delim = repolink(title, class, "commit", link_revision(head, rev), NULL);
if (ctx.qry.difftype) {
html(delim);
htmlf("dt=%d", ctx.qry.difftype);
@@ -454,28 +462,48 @@ void cgit_snapshot_link(const char *name, const char *title, const char *class,
const char *head, const char *rev,
const char *archivename)
{
- reporevlink("snapshot", name, title, class, head, rev, archivename);
+ const char *format = NULL;
+ const struct cgit_snapshot_format *f;
+ char *url, *full;
+ int signature = ends_with(archivename, ".asc");
+
+ for (f = cgit_snapshot_formats; f->suffix; f++)
+ if (strstr(archivename, f->suffix)) {
+ format = f->suffix + 1;
+ break;
+ }
+ if (!format)
+ return;
+ url = repo_view_url(ctx.repo->url,
+ signature ? "archive-signature" : "archive",
+ link_revision(head, rev), NULL);
+ full = fmtalloc("%s?format=%s", url, format);
+ free(url);
+ link_start(full, title, class);
+ free(full);
+ html("'>");
+ html_txt(name);
+ html("</a>");
}
void cgit_diff_link(const char *name, const char *title, const char *class,
const char *head, const char *new_rev, const char *old_rev,
const char *path)
{
- char *delim;
+ struct strbuf url = STRBUF_INIT;
+ char *delim = "?";
- delim = repolink(title, class, "diff", head, path);
- if (new_rev && ctx.qry.head != NULL && strcmp(new_rev, ctx.qry.head)) {
- html(delim);
- html("id=");
- html_url_arg(new_rev);
- delim = "&amp;";
- }
- if (old_rev) {
- html(delim);
- html("id2=");
- html_url_arg(old_rev);
- delim = "&amp;";
+ add_repo_url(&url, ctx.repo->url);
+ strbuf_addstr(&url, "/+/diff/");
+ add_revision(&url, old_rev ? old_rev : "HEAD");
+ strbuf_addstr(&url, "..");
+ add_revision(&url, link_revision(head, new_rev));
+ if (path) {
+ strbuf_addstr(&url, "/+/");
+ add_url_path(&url, path);
}
+ link_start(url.buf, title, class);
+ strbuf_release(&url);
if (ctx.qry.difftype) {
html(delim);
htmlf("dt=%d", ctx.qry.difftype);
@@ -504,7 +532,33 @@ void cgit_diff_link(const char *name, const char *title, const char *class,
void cgit_patch_link(const char *name, const char *title, const char *class,
const char *head, const char *rev, const char *path)
{
- reporevlink("patch", name, title, class, head, rev, path);
+ struct strbuf url = STRBUF_INIT;
+ struct object_id oid;
+ struct commit *commit;
+ const char *new_rev = link_revision(head, rev);
+ char *old_rev = xstrdup("HEAD");
+
+ if (!repo_get_oid(the_repository, new_rev, &oid) &&
+ (commit = lookup_commit_reference(the_repository, &oid)) &&
+ commit->parents) {
+ free(old_rev);
+ old_rev = xstrdup(oid_to_hex(&commit->parents->item->object.oid));
+ }
+ add_repo_url(&url, ctx.repo->url);
+ strbuf_addstr(&url, "/+/patch/");
+ add_revision(&url, old_rev);
+ strbuf_addstr(&url, "..");
+ add_revision(&url, new_rev);
+ if (path) {
+ strbuf_addstr(&url, "/+/");
+ add_url_path(&url, path);
+ }
+ link_start(url.buf, title, class);
+ strbuf_release(&url);
+ free(old_rev);
+ html("'>");
+ html_txt(name);
+ html("</a>");
}
void cgit_stats_link(const char *name, const char *title, const char *class,
@@ -541,7 +595,7 @@ static void cgit_self_link(char *name, const char *title, const char *class)
ctx.qry.path, ctx.qry.ofs,
ctx.qry.grep, ctx.qry.search,
ctx.qry.showmsg, ctx.qry.follow);
- else if (!strcmp(ctx.qry.page, "commit"))
+ else if (!strcmp(ctx.qry.page, "commit") || !strcmp(ctx.qry.page, "revision"))
cgit_commit_link(name, title, class, ctx.qry.head,
ctx.qry.has_oid ? ctx.qry.oid : NULL,
ctx.qry.path);
@@ -816,31 +870,27 @@ void cgit_print_docstart(void)
if (ctx.cfg.css.items)
for_each_string_list(&ctx.cfg.css, emit_css_link, NULL);
else
- emit_css_link(NULL, "/cgit.css");
+ emit_css_link(NULL, "/-/assets/cgit.css");
if (ctx.cfg.js.items)
for_each_string_list(&ctx.cfg.js, emit_js_link, NULL);
else
- emit_js_link(NULL, "/cgit.js");
+ emit_js_link(NULL, "/-/assets/cgit.js");
if (ctx.cfg.favicon && *ctx.cfg.favicon) {
html("<link rel='shortcut icon' href='");
html_attr(ctx.cfg.favicon);
html("'/>\n");
}
- if (host && ctx.repo && ctx.qry.head) {
+ if (host && ctx.repo && ctx.qry.head && starts_with(ctx.qry.head, "refs/")) {
char *fileurl;
- struct strbuf sb = STRBUF_INIT;
- strbuf_addf(&sb, "h=%s", ctx.qry.head);
html("<link rel='alternate' title='Atom feed' href='");
html(cgit_httpscheme());
html_attr(host);
- fileurl = cgit_fileurl(ctx.repo->url, "atom", ctx.qry.vpath,
- sb.buf);
+ fileurl = cgit_fileurl(ctx.repo->url, "atom", ctx.qry.vpath, NULL);
html_attr(fileurl);
html("' type='application/atom+xml'/>\n");
- strbuf_release(&sb);
free(fileurl);
}
if (ctx.repo)
@@ -927,38 +977,39 @@ void cgit_add_clone_urls(void (*fn)(const char *))
{
if (ctx.repo->clone_url)
add_clone_urls(fn, expand_macros(ctx.repo->clone_url), NULL);
- else if (ctx.cfg.clone_prefix)
- add_clone_urls(fn, ctx.cfg.clone_prefix, ctx.repo->url);
+ else if (ctx.cfg.clone_prefix) {
+ char *suffix = fmtalloc("%s.git", ctx.repo->url);
+ add_clone_urls(fn, ctx.cfg.clone_prefix, suffix);
+ free(suffix);
+ } else {
+ char *host = cgit_hosturl();
+ struct strbuf url = STRBUF_INIT;
+
+ if (!host)
+ return;
+ strbuf_addf(&url, "%s%s", cgit_httpscheme(), host);
+ add_repo_url(&url, ctx.repo->url);
+ strbuf_addstr(&url, ".git");
+ fn(url.buf);
+ strbuf_release(&url);
+ free(host);
+ }
}
-static int print_branch_option(const struct reference *ref, void *cb_data)
+static int print_branch_option(const struct reference *ref, void *cb_data UNUSED)
{
- char *name = (char *)ref->name;
- html_option(name, name, ctx.qry.head);
+ char *url = repo_view_url(ctx.repo->url, "tree", ref->name,
+ ctx.qry.vpath);
+
+ html_option(url, ref->name,
+ ctx.qry.head && !strcmp(ref->name, ctx.qry.head) ? url : NULL);
+ free(url);
return 0;
}
-void cgit_add_hidden_formfields(int incl_head, int incl_search,
- const char *page)
+void cgit_add_hidden_formfields(int incl_head UNUSED, int incl_search,
+ const char *page UNUSED)
{
- if (!ctx.cfg.virtual_root) {
- struct strbuf url = STRBUF_INIT;
-
- strbuf_addf(&url, "%s/%s", ctx.qry.repo, page);
- if (ctx.qry.vpath)
- strbuf_addf(&url, "/%s", ctx.qry.vpath);
- html_hidden("url", url.buf);
- strbuf_release(&url);
- }
-
- if (incl_head && ctx.qry.head && ctx.repo->defbranch &&
- strcmp(ctx.qry.head, ctx.repo->defbranch))
- html_hidden("h", ctx.qry.head);
-
- if (ctx.qry.oid)
- html_hidden("id", ctx.qry.oid);
- if (ctx.qry.oid2)
- html_hidden("id2", ctx.qry.oid2);
if (ctx.qry.showmsg)
html_hidden("showmsg", "1");
@@ -982,17 +1033,26 @@ static void cgit_print_path_crumbs(char *path)
{
char *old_path = ctx.qry.path;
char *p = path, *q, *end = path + strlen(path);
+ int blame = !strcmp(ctx.qry.page, "blame");
int levels = 0;
ctx.qry.path = NULL;
- cgit_self_link("root", NULL, NULL);
+ if (blame)
+ cgit_tree_link("root", NULL, NULL, ctx.qry.head,
+ ctx.qry.oid, NULL);
+ else
+ cgit_self_link("root", NULL, NULL);
ctx.qry.path = p = path;
while (p < end) {
if (!(q = strchr(p, '/')) || levels > 15)
q = end;
*q = '\0';
html_txt("/");
- cgit_self_link(p, NULL, NULL);
+ if (blame && q < end)
+ cgit_tree_link(p, NULL, NULL, ctx.qry.head,
+ ctx.qry.oid, ctx.qry.path);
+ else
+ cgit_self_link(p, NULL, NULL);
if (q < end)
*q = '/';
p = q + 1;
@@ -1033,17 +1093,13 @@ static void print_header(void)
html(" : ");
cgit_summary_link(ctx.repo->name, NULL, NULL, NULL);
html("</td><td class='form'>");
- html("<form method='get'>\n");
- cgit_add_hidden_formfields(0, 1, ctx.qry.page);
- html("<select name='h' onchange='this.form.submit();'>\n");
+ html("<select onchange='window.location.href=this.value'>\n");
refs_for_each_branch_ref(get_main_ref_store(the_repository),
print_branch_option, ctx.qry.head);
if (ctx.repo->enable_remote_branches)
refs_for_each_remote_ref(get_main_ref_store(the_repository),
print_branch_option, ctx.qry.head);
- html("</select> ");
- html("<input type='submit' value='switch'/>");
- html("</form>");
+ html("</select>");
} else
html_txt(ctx.cfg.root_title);
html("</td></tr>\n");
@@ -1085,7 +1141,8 @@ void cgit_print_pageheader(void)
else
cgit_tree_link("tree", NULL, hc("tree"), ctx.qry.head,
ctx.qry.oid, ctx.qry.vpath);
- cgit_commit_link("commit", NULL, hc("commit"),
+ cgit_commit_link("commit", NULL,
+ ctx.qry.page && !strcmp(ctx.qry.page, "revision") ? "active" : hc("commit"),
ctx.qry.head, ctx.qry.oid, ctx.qry.vpath);
cgit_diff_link("diff", NULL, hc("diff"), ctx.qry.head,
ctx.qry.oid, ctx.qry.oid2, ctx.qry.vpath);
@@ -1102,7 +1159,7 @@ void cgit_print_pageheader(void)
if (ctx.cfg.virtual_root) {
char *fileurl = cgit_fileurl(ctx.qry.repo, "log",
ctx.qry.vpath, NULL);
- html_url_path(fileurl);
+ html_attr(fileurl);
free(fileurl);
}
html("'>\n");
diff --git a/cgit/ui-shared.h b/cgit/ui-shared.h
index 9e0027c..c1d055b 100644
--- a/cgit/ui-shared.h
+++ b/cgit/ui-shared.h
@@ -15,6 +15,9 @@ extern char *cgit_fileurl(const char *reponame, const char *pagename,
const char *filename, const char *query);
extern char *cgit_pageurl(const char *reponame, const char *pagename,
const char *query);
+extern char *cgit_revurl(const char *reponame, const char *revision);
+extern char *cgit_treeurl(const char *reponame, const char *revision,
+ const char *path, const char *fragment);
extern void cgit_add_clone_urls(void (*fn)(const char *));
diff --git a/cgit/ui-snapshot.c b/cgit/ui-snapshot.c
index 99fae75..d2cc488 100644
--- a/cgit/ui-snapshot.c
+++ b/cgit/ui-snapshot.c
@@ -33,6 +33,10 @@ static int write_archive_type(const char *format, const char *hex, const char *p
strbuf_release(&buf);
}
strvec_push(&argv, hex);
+ if (ctx.qry.path) {
+ strvec_push(&argv, "--");
+ strvec_push(&argv, ctx.qry.path);
+ }
/*
* Now we need to copy the pointers to arguments into a new
* structure because write_archive will rearrange its arguments
diff --git a/cgit/ui-ssdiff.c b/cgit/ui-ssdiff.c
index f523559..f310ec1 100644
--- a/cgit/ui-ssdiff.c
+++ b/cgit/ui-ssdiff.c
@@ -240,8 +240,9 @@ static void print_ssdiff_line(char *class,
if (old_line_no > 0) {
struct diff_filespec *old_file = cgit_get_current_old_file();
char *lineno_str = fmt("n%d", old_line_no);
- char *id_str = fmt("id=%s#%s", is_null_oid(&old_file->oid)?"HEAD":oid_to_hex(old_rev_oid), lineno_str);
- char *fileurl = cgit_fileurl(ctx.repo->url, "tree", old_file->path, id_str);
+ char *fileurl = cgit_treeurl(ctx.repo->url,
+ is_null_oid(&old_file->oid) ? "HEAD" : oid_to_hex(old_rev_oid),
+ old_file->path, lineno_str);
html("<td class='lineno'><a href='");
html(fileurl);
htmlf("'>%s</a>", lineno_str + 1);
@@ -263,8 +264,9 @@ static void print_ssdiff_line(char *class,
if (new_line_no > 0) {
struct diff_filespec *new_file = cgit_get_current_new_file();
char *lineno_str = fmt("n%d", new_line_no);
- char *id_str = fmt("id=%s#%s", is_null_oid(&new_file->oid)?"HEAD":oid_to_hex(new_rev_oid), lineno_str);
- char *fileurl = cgit_fileurl(ctx.repo->url, "tree", new_file->path, id_str);
+ char *fileurl = cgit_treeurl(ctx.repo->url,
+ is_null_oid(&new_file->oid) ? "HEAD" : oid_to_hex(new_rev_oid),
+ new_file->path, lineno_str);
html("<td class='lineno'><a href='");
html(fileurl);
htmlf("'>%s</a>", lineno_str + 1);
diff --git a/cgit/ui-summary.c b/cgit/ui-summary.c
index 7a81a35..dc62ea9 100644
--- a/cgit/ui-summary.c
+++ b/cgit/ui-summary.c
@@ -36,7 +36,7 @@ static void print_url(const char *url)
}
htmlf("<tr><td colspan='%d'><a rel='vcs-git' href='", columns);
- html_url_path(url);
+ html_attr(url);
html("' title='");
html_attr(ctx.repo->name);
html(" Git repository'>");
diff --git a/cgit/ui-tag.c b/cgit/ui-tag.c
index 86b81bd..e053f05 100644
--- a/cgit/ui-tag.c
+++ b/cgit/ui-tag.c
@@ -49,14 +49,20 @@ void cgit_print_tag(char *revname)
struct strbuf fullref = STRBUF_INIT;
struct object_id oid;
struct object *obj;
+ const char *displayname;
if (!revname)
revname = ctx.qry.head;
- strbuf_addf(&fullref, "refs/tags/%s", revname);
+ if (skip_prefix(revname, "refs/tags/", &displayname))
+ strbuf_addstr(&fullref, revname);
+ else {
+ displayname = revname;
+ strbuf_addf(&fullref, "refs/tags/%s", revname);
+ }
if (repo_get_oid(the_repository, fullref.buf, &oid)) {
cgit_print_error_page(404, "Not found",
- "Bad tag reference: %s", revname);
+ "Bad tag reference: %s", displayname);
goto cleanup;
}
obj = parse_object(the_repository, &oid);
@@ -72,13 +78,13 @@ void cgit_print_tag(char *revname)
tag = lookup_tag(the_repository, &oid);
if (!tag || parse_tag(the_repository, tag) || !(info = cgit_parse_tag(tag))) {
cgit_print_error_page(500, "Internal server error",
- "Bad tag object: %s", revname);
+ "Bad tag object: %s", displayname);
goto cleanup;
}
cgit_print_layout_start();
html("<table class='commit-info'>\n");
html("<tr><td>tag name</td><td>");
- html_txt(revname);
+ html_txt(displayname);
htmlf(" (%s)</td></tr>\n", oid_to_hex(&oid));
if (info->tagger_date > 0) {
html("<tr><td>tag date</td><td>");
@@ -108,7 +114,7 @@ void cgit_print_tag(char *revname)
cgit_print_layout_start();
html("<table class='commit-info'>\n");
html("<tr><td>tag name</td><td>");
- html_txt(revname);
+ html_txt(displayname);
html("</td></tr>\n");
html("<tr><td>tagged object</td><td class='oid'>");
cgit_object_link(obj);
diff --git a/charts/gilti/README.md b/charts/gilti/README.md
index 14a31f4..f8e5476 100644
--- a/charts/gilti/README.md
+++ b/charts/gilti/README.md
@@ -31,9 +31,11 @@ git push -u origin main
## Repository visibility
-cgit is anonymous and read-only and scans the complete repository directory.
-Consequently every repository available over SSH is also publicly visible over
-HTTP. Web cloning and snapshots are disabled in the default policy.
+Repository browsing, smart HTTP cloning, archives, and LFS downloads are
+anonymous and read-only. Consequently every repository available over SSH is
+also publicly visible over HTTP. HTTP pushes and LFS uploads remain disabled by
+default; setting `http.write: true` enables them without authentication and is
+appropriate only behind a trusted access-control proxy.
## Networking
diff --git a/charts/gilti/templates/deployment.yaml b/charts/gilti/templates/deployment.yaml
index 12a39ce..58bf5d0 100644
--- a/charts/gilti/templates/deployment.yaml
+++ b/charts/gilti/templates/deployment.yaml
@@ -56,6 +56,8 @@ spec:
value: {{ .Values.cgit.clonePrefix | quote }}
- name: GILTI_CGIT_CACHE
value: {{ .Values.cgit.cache | quote }}
+ - name: GILTI_HTTP_WRITE
+ value: {{ ternary "1" "0" .Values.http.write | quote }}
ports:
- name: http
containerPort: 8080
@@ -65,18 +67,18 @@ spec:
protocol: TCP
startupProbe:
httpGet:
- path: /healthz
+ path: /-/health
port: http
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet:
- path: /healthz
+ path: /-/health
port: http
periodSeconds: 10
livenessProbe:
httpGet:
- path: /healthz
+ path: /-/health
port: http
periodSeconds: 20
resources:
diff --git a/charts/gilti/values.schema.json b/charts/gilti/values.schema.json
index c15674a..a22ed4e 100644
--- a/charts/gilti/values.schema.json
+++ b/charts/gilti/values.schema.json
@@ -31,6 +31,12 @@
"cache": { "type": "integer", "minimum": 0, "maximum": 3600 }
}
},
+ "http": {
+ "type": "object",
+ "properties": {
+ "write": { "type": "boolean" }
+ }
+ },
"persistence": {
"type": "object",
"properties": {
diff --git a/charts/gilti/values.yaml b/charts/gilti/values.yaml
index 03c480b..60e492e 100644
--- a/charts/gilti/values.yaml
+++ b/charts/gilti/values.yaml
@@ -24,6 +24,10 @@ cgit:
# In-memory CGI response lifetime in seconds; zero disables the cache.
cache: 5
+http:
+ # Allow unauthenticated Git receive-pack and LFS uploads.
+ write: false
+
persistence:
enabled: true
existingClaim: ""
diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml
index 5752260..fee4d7c 100644
--- a/crates/gilti/Cargo.toml
+++ b/crates/gilti/Cargo.toml
@@ -21,3 +21,4 @@ tower.workspace = true
maud.workspace = true
serde.workspace = true
serde_json.workspace = true
+sha2.workspace = true
diff --git a/crates/gilti/src/cgi.rs b/crates/gilti/src/cgi.rs
index ffcc081..38d0693 100644
--- a/crates/gilti/src/cgi.rs
+++ b/crates/gilti/src/cgi.rs
@@ -185,6 +185,12 @@ impl ResponseCache {
}
}
+#[derive(Clone, Default)]
+pub struct Environment(pub Vec<(std::ffi::OsString, std::ffi::OsString)>);
+
+#[derive(Clone, Copy)]
+pub struct NoCache;
+
#[derive(Clone, Copy)]
pub struct RemoteAddr(pub std::net::SocketAddr);
@@ -237,10 +243,15 @@ impl Cgi {
|value| value.0,
);
let (parts, body) = request.into_parts();
- let body = axum::body::to_bytes(body, 1024 * 1024)
+ let body = axum::body::to_bytes(body, 1024 * 1024 * 1024)
.await
.map_err(std::io::Error::other)?;
- let cache_key = CacheKey::from_request(&parts, &body);
+ let cache_key = parts
+ .extensions
+ .get::<NoCache>()
+ .is_none()
+ .then(|| CacheKey::from_request(&parts, &body))
+ .flatten();
if let Some(response) = self
.cache
.as_ref()
@@ -313,6 +324,10 @@ impl Cgi {
}
}
+ if let Some(environment) = parts.extensions.get::<Environment>() {
+ command.envs(environment.0.iter().cloned());
+ }
+
let mut child = command.spawn()?;
let mut stdin = child
.stdin
diff --git a/crates/gilti/src/lfs.rs b/crates/gilti/src/lfs.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/lfs.rs
@@ -0,0 +1,389 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+const MAX_OBJECT_SIZE: usize = 1024 * 1024 * 1024;
+
+#[derive(serde::Deserialize)]
+struct BatchRequest {
+ operation: String,
+ objects: Vec<ObjectRequest>,
+}
+
+#[derive(serde::Deserialize)]
+struct ObjectRequest {
+ oid: String,
+ size: u64,
+}
+
+#[derive(serde::Deserialize)]
+struct VerifyRequest {
+ oid: String,
+ size: u64,
+}
+
+#[derive(serde::Serialize)]
+struct BatchResponse {
+ transfer: &'static str,
+ objects: Vec<ObjectResponse>,
+}
+
+#[derive(serde::Serialize)]
+struct ObjectResponse {
+ oid: String,
+ size: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ actions: Option<Actions>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option<ObjectError>,
+}
+
+#[derive(serde::Serialize)]
+struct Actions {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ download: Option<Action>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ upload: Option<Action>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ verify: Option<Action>,
+}
+
+#[derive(serde::Serialize)]
+struct Action {
+ href: String,
+}
+
+#[derive(serde::Serialize)]
+struct ObjectError {
+ code: u16,
+ message: &'static str,
+}
+
+pub async fn serve(
+ repositories: &std::path::Path,
+ repo: &str,
+ path: &str,
+ write_enabled: bool,
+ request: axum::extract::Request,
+) -> axum::response::Response {
+ let Some(repository) = repository(repositories, repo) else {
+ return plain(axum::http::StatusCode::NOT_FOUND, "repository not found\n");
+ };
+ let objects = repository.join("lfs/objects");
+ let method = request.method().clone();
+
+ if path == "objects/batch" && method == axum::http::Method::POST {
+ return batch(objects, write_enabled, request).await;
+ }
+ if let Some(oid) = path
+ .strip_prefix("objects/")
+ .and_then(|value| value.strip_suffix("/verify"))
+ && method == axum::http::Method::POST
+ {
+ return verify(&objects, oid, request).await;
+ }
+ if let Some(oid) = path.strip_prefix("objects/") {
+ if !valid_oid(oid) {
+ return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n");
+ }
+ return match method {
+ axum::http::Method::GET | axum::http::Method::HEAD => {
+ download(&objects, oid, method == axum::http::Method::HEAD).await
+ }
+ axum::http::Method::PUT if write_enabled => upload(&objects, oid, request).await,
+ axum::http::Method::PUT => plain(
+ axum::http::StatusCode::FORBIDDEN,
+ "LFS uploads are disabled\n",
+ ),
+ _ => plain(
+ axum::http::StatusCode::METHOD_NOT_ALLOWED,
+ "method not allowed\n",
+ ),
+ };
+ }
+ plain(axum::http::StatusCode::NOT_FOUND, "not found\n")
+}
+
+async fn batch(
+ objects: std::path::PathBuf,
+ write_enabled: bool,
+ request: axum::extract::Request,
+) -> axum::response::Response {
+ let base = request
+ .uri()
+ .path()
+ .strip_suffix("/objects/batch")
+ .unwrap_or(request.uri().path());
+ let scheme = request
+ .headers()
+ .get("x-forwarded-proto")
+ .and_then(|value| value.to_str().ok())
+ .filter(|value| matches!(*value, "http" | "https"))
+ .unwrap_or("http");
+ let host = request
+ .headers()
+ .get(axum::http::header::HOST)
+ .and_then(|value| value.to_str().ok())
+ .unwrap_or("localhost");
+ let prefix = format!("{scheme}://{host}{base}/objects");
+ let body = match axum::body::to_bytes(request.into_body(), 1024 * 1024).await {
+ Ok(body) => body,
+ Err(_) => {
+ return plain(
+ axum::http::StatusCode::PAYLOAD_TOO_LARGE,
+ "request too large\n",
+ );
+ }
+ };
+ let request: BatchRequest = match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"),
+ };
+ if !matches!(request.operation.as_str(), "download" | "upload") {
+ return plain(
+ axum::http::StatusCode::BAD_REQUEST,
+ "invalid LFS operation\n",
+ );
+ }
+
+ let objects = request
+ .objects
+ .into_iter()
+ .map(|object| {
+ if !valid_oid(&object.oid) {
+ return error(object, 422, "invalid object id");
+ }
+ let stored = object_path(&objects, &object.oid);
+ let present = std::fs::metadata(&stored)
+ .is_ok_and(|metadata| metadata.is_file() && metadata.len() == object.size);
+ match request.operation.as_str() {
+ "download" if present => response(
+ object,
+ Actions {
+ download: Some(Action {
+ href: format!(
+ "{prefix}/{}",
+ stored.file_name().unwrap().to_string_lossy()
+ ),
+ }),
+ upload: None,
+ verify: None,
+ },
+ ),
+ "download" => error(object, 404, "object not found"),
+ "upload" if present => response(
+ object,
+ Actions {
+ download: Some(Action {
+ href: format!(
+ "{prefix}/{}",
+ stored.file_name().unwrap().to_string_lossy()
+ ),
+ }),
+ upload: None,
+ verify: None,
+ },
+ ),
+ "upload" if !write_enabled => error(object, 403, "LFS uploads are disabled"),
+ "upload" => response(
+ object,
+ Actions {
+ download: None,
+ upload: Some(Action {
+ href: format!(
+ "{prefix}/{}",
+ stored.file_name().unwrap().to_string_lossy()
+ ),
+ }),
+ verify: Some(Action {
+ href: format!(
+ "{prefix}/{}/verify",
+ stored.file_name().unwrap().to_string_lossy()
+ ),
+ }),
+ },
+ ),
+ _ => unreachable!(),
+ }
+ })
+ .collect();
+ json(
+ axum::http::StatusCode::OK,
+ &BatchResponse {
+ transfer: "basic",
+ objects,
+ },
+ )
+}
+
+async fn download(objects: &std::path::Path, oid: &str, head: bool) -> axum::response::Response {
+ let path = object_path(objects, oid);
+ let metadata = match tokio::fs::metadata(&path).await {
+ Ok(metadata) if metadata.is_file() => metadata,
+ _ => return plain(axum::http::StatusCode::NOT_FOUND, "object not found\n"),
+ };
+ let body = if head {
+ axum::body::Body::empty()
+ } else {
+ match tokio::fs::read(path).await {
+ Ok(body) => axum::body::Body::from(body),
+ Err(_) => {
+ return plain(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "read failed\n",
+ );
+ }
+ }
+ };
+ axum::http::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(axum::http::header::CONTENT_TYPE, "application/octet-stream")
+ .header(axum::http::header::CONTENT_LENGTH, metadata.len())
+ .body(body)
+ .expect("valid LFS response")
+}
+
+async fn upload(
+ objects: &std::path::Path,
+ oid: &str,
+ request: axum::extract::Request,
+) -> axum::response::Response {
+ use sha2::Digest;
+
+ let body = match axum::body::to_bytes(request.into_body(), MAX_OBJECT_SIZE).await {
+ Ok(body) => body,
+ Err(_) => {
+ return plain(
+ axum::http::StatusCode::PAYLOAD_TOO_LARGE,
+ "object too large\n",
+ );
+ }
+ };
+ let actual = format!("{:x}", sha2::Sha256::digest(&body));
+ if actual != oid {
+ return plain(
+ axum::http::StatusCode::UNPROCESSABLE_ENTITY,
+ "object hash mismatch\n",
+ );
+ }
+ let path = object_path(objects, oid);
+ let Some(parent) = path.parent() else {
+ return plain(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "invalid storage path\n",
+ );
+ };
+ if tokio::fs::create_dir_all(parent).await.is_err() {
+ return plain(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "storage failed\n",
+ );
+ }
+ let temporary = parent.join(format!(".{oid}.{}.tmp", std::process::id()));
+ if tokio::fs::write(&temporary, &body).await.is_err()
+ || tokio::fs::rename(&temporary, &path).await.is_err()
+ {
+ let _ = tokio::fs::remove_file(temporary).await;
+ return plain(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "storage failed\n",
+ );
+ }
+ plain(axum::http::StatusCode::OK, "")
+}
+
+async fn verify(
+ objects: &std::path::Path,
+ oid: &str,
+ request: axum::extract::Request,
+) -> axum::response::Response {
+ if !valid_oid(oid) {
+ return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n");
+ }
+ let body = match axum::body::to_bytes(request.into_body(), 64 * 1024).await {
+ Ok(body) => body,
+ Err(_) => {
+ return plain(
+ axum::http::StatusCode::PAYLOAD_TOO_LARGE,
+ "request too large\n",
+ );
+ }
+ };
+ let request: VerifyRequest = match serde_json::from_slice(&body) {
+ Ok(request) => request,
+ Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"),
+ };
+ let valid = request.oid == oid
+ && std::fs::metadata(object_path(objects, oid))
+ .is_ok_and(|metadata| metadata.is_file() && metadata.len() == request.size);
+ if valid {
+ plain(axum::http::StatusCode::OK, "")
+ } else {
+ plain(axum::http::StatusCode::NOT_FOUND, "object not found\n")
+ }
+}
+
+fn repository(root: &std::path::Path, repo: &str) -> Option<std::path::PathBuf> {
+ let root = std::fs::canonicalize(root).ok()?;
+ let repository = std::fs::canonicalize(root.join(format!("{repo}.git"))).ok()?;
+ repository
+ .is_dir()
+ .then_some(repository)
+ .filter(|repository| repository.starts_with(root))
+}
+
+fn object_path(root: &std::path::Path, oid: &str) -> std::path::PathBuf {
+ root.join(&oid[..2]).join(&oid[2..4]).join(oid)
+}
+
+fn valid_oid(oid: &str) -> bool {
+ oid.len() == 64
+ && oid
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
+
+fn response(object: ObjectRequest, actions: Actions) -> ObjectResponse {
+ ObjectResponse {
+ oid: object.oid,
+ size: object.size,
+ actions: Some(actions),
+ error: None,
+ }
+}
+
+fn error(object: ObjectRequest, code: u16, message: &'static str) -> ObjectResponse {
+ ObjectResponse {
+ oid: object.oid,
+ size: object.size,
+ actions: None,
+ error: Some(ObjectError { code, message }),
+ }
+}
+
+fn json<T: serde::Serialize>(
+ status: axum::http::StatusCode,
+ value: &T,
+) -> axum::response::Response {
+ match serde_json::to_vec(value) {
+ Ok(body) => axum::http::Response::builder()
+ .status(status)
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ "application/vnd.git-lfs+json",
+ )
+ .body(axum::body::Body::from(body))
+ .expect("valid LFS response"),
+ Err(_) => plain(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "encoding failed\n",
+ ),
+ }
+}
+
+fn plain(status: axum::http::StatusCode, message: &'static str) -> axum::response::Response {
+ axum::http::Response::builder()
+ .status(status)
+ .header(axum::http::header::CONTENT_TYPE, "text/plain")
+ .body(axum::body::Body::from(message))
+ .expect("valid LFS response")
+}
diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs
index 67a3de9..f7e4cd0 100644
--- a/crates/gilti/src/main.rs
+++ b/crates/gilti/src/main.rs
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
mod cgi;
+mod lfs;
+pub mod router;
mod ui;
const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080";
@@ -11,7 +13,12 @@ const DEFAULT_ROOT_DESCRIPTION: &str = "A tiny Git server";
const MAX_CACHE_SECONDS: u64 = 3600;
const CGIT: &str = "/usr/local/bin/gilti-cgit";
+const GIT_HTTP_BACKENDS: &[&str] = &[
+ "/usr/libexec/git-core/git-http-backend",
+ "/usr/lib/git-core/git-http-backend",
+];
const GIT_HOME: &str = "/var/lib/gilti/git";
+const REPOSITORIES: &str = "/var/lib/gilti/git/repositories";
const CGIT_CSS: &str = "/usr/share/webapps/cgit/cgit.css";
const CGIT_JS: &str = "/usr/share/webapps/cgit/cgit.js";
@@ -24,14 +31,14 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
("CGIT_CASE_SENSITIVE_SORT", "1"),
("CGIT_CLONE_URL", ""),
("CGIT_COMMIT_SORT", "0"),
- ("CGIT_CSS", "/cgit.css"),
+ ("CGIT_CSS", "/-/assets/cgit.css"),
("CGIT_DIFFTYPE", "0"),
("CGIT_EMBEDDED", "0"),
- ("CGIT_ENABLE_BLAME", "0"),
+ ("CGIT_ENABLE_BLAME", "1"),
("CGIT_ENABLE_COMMIT_GRAPH", "1"),
("CGIT_ENABLE_FOLLOW_LINKS", "0"),
- ("CGIT_ENABLE_HTML_SERVING", "0"),
- ("CGIT_ENABLE_HTTP_CLONE", "0"),
+ ("CGIT_ENABLE_HTML_SERVING", "1"),
+ ("CGIT_ENABLE_HTTP_CLONE", "1"),
("CGIT_ENABLE_INDEX_LINKS", "1"),
("CGIT_ENABLE_INDEX_OWNER", "1"),
("CGIT_ENABLE_LOG_FILECOUNT", "1"),
@@ -39,13 +46,13 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
("CGIT_ENABLE_REMOTE_BRANCHES", "0"),
("CGIT_ENABLE_SUBJECT_LINKS", "0"),
("CGIT_ENABLE_TREE_LINENUMBERS", "1"),
- ("CGIT_FAVICON", "/favicon.ico"),
+ ("CGIT_FAVICON", "/-/assets/favicon.ico"),
("CGIT_FOOTER", ""),
("CGIT_HEADER", ""),
("CGIT_HEAD_INCLUDE", ""),
- ("CGIT_JS", ""),
+ ("CGIT_JS", "/-/assets/cgit.js"),
("CGIT_LOCAL_TIME", "0"),
- ("CGIT_LOGO", "/cgit.png"),
+ ("CGIT_LOGO", "/-/assets/cgit.png"),
("CGIT_LOGO_LINK", ""),
("CGIT_MAX_ATOM_ITEMS", "10"),
("CGIT_MAX_BLOB_SIZE", "0"),
@@ -53,7 +60,7 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
("CGIT_MAX_MESSAGE_LENGTH", "80"),
("CGIT_MAX_REPO_COUNT", "50"),
("CGIT_MAX_REPODESC_LENGTH", "80"),
- ("CGIT_MAX_STATS", "0"),
+ ("CGIT_MAX_STATS", "4"),
("CGIT_MIMETYPE_FILE", ""),
("CGIT_MODULE_LINK", ""),
("CGIT_NOHEADER", "0"),
@@ -71,7 +78,7 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
("CGIT_SECTION", ""),
("CGIT_SECTION_FROM_PATH", "0"),
("CGIT_SECTION_SORT", "1"),
- ("CGIT_SNAPSHOTS", "0"),
+ ("CGIT_SNAPSHOTS", "2147483647"),
("CGIT_STRICT_EXPORT", ""),
("CGIT_SUMMARY_BRANCHES", "10"),
("CGIT_SUMMARY_LOG", "10"),
@@ -82,8 +89,10 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[
];
#[derive(Clone)]
-struct AppState {
+struct RepositoryService {
cgit: cgi::Cgi,
+ git: cgi::Cgi,
+ write_enabled: bool,
}
struct Config {
@@ -92,6 +101,7 @@ struct Config {
root_title: String,
root_description: String,
clone_prefix: String,
+ http_write: bool,
}
impl Config {
@@ -106,6 +116,7 @@ impl Config {
root_title: environment("GILTI_CGIT_ROOT_TITLE", DEFAULT_ROOT_TITLE)?,
root_description: environment("GILTI_CGIT_ROOT_DESCRIPTION", DEFAULT_ROOT_DESCRIPTION)?,
clone_prefix: environment("GILTI_CGIT_CLONE_PREFIX", "")?,
+ http_write: parse_bool("GILTI_HTTP_WRITE", &environment("GILTI_HTTP_WRITE", "0")?)?,
})
}
}
@@ -120,6 +131,14 @@ fn environment(name: &str, default: &str) -> std::io::Result<String> {
}
}
+fn parse_bool(name: &str, value: &str) -> std::io::Result<bool> {
+ match value {
+ "0" => Ok(false),
+ "1" => Ok(true),
+ _ => Err(invalid_config(format!("{name} must be 0 or 1"))),
+ }
+}
+
fn parse_cache(value: &str) -> std::io::Result<std::time::Duration> {
let seconds = value
.parse::<u64>()
@@ -140,7 +159,8 @@ fn invalid_config(message: impl Into<String>) -> std::io::Error {
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::from_environment()?;
let listen_addr = config.listen_addr;
- check_files()?;
+ let git_http_backend = git_http_backend()?;
+ check_files(&git_http_backend)?;
if std::env::args().nth(1).as_deref() == Some("--check") {
return Ok(());
@@ -155,30 +175,60 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
for (name, value) in CGIT_ENVIRONMENT {
cgit = cgit.env(*name, *value);
}
- let state = AppState { cgit };
+ let git = cgi::Cgi::new(git_http_backend, GIT_HOME, config.listen_addr)
+ .env("GIT_PROJECT_ROOT", REPOSITORIES)
+ .env("GIT_HTTP_EXPORT_ALL", "1")
+ .env("HOME", GIT_HOME)
+ .env("USER", "git")
+ .env("LOGNAME", "git")
+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
+ .env("GIT_CONFIG_NOSYSTEM", "1")
+ .env("PATH", "/usr/bin:/bin");
+ let repositories = RepositoryService {
+ cgit,
+ git,
+ write_enabled: config.http_write,
+ };
let app = axum::Router::new()
.route(
- "/healthz",
- axum::routing::get(async || plain_response(axum::http::StatusCode::OK, "ok\n")),
+ "/-/health",
+ axum::routing::get(async || {
+ response(
+ axum::http::StatusCode::OK,
+ "application/json",
+ b"{\"status\":\"ok\"}\n".to_vec(),
+ )
+ }),
)
.route(
- "/cgit.css",
+ "/-/about",
+ axum::routing::get(async || {
+ plain_response(axum::http::StatusCode::OK, "Gilti Git server\n")
+ }),
+ )
+ .route(
+ "/-/terms",
+ axum::routing::get(async || {
+ plain_response(axum::http::StatusCode::OK, "No additional terms of use.\n")
+ }),
+ )
+ .route(
+ "/-/assets/cgit.css",
axum::routing::get(async || static_file(CGIT_CSS, "text/css")),
)
.route(
- "/cgit.js",
+ "/-/assets/cgit.js",
axum::routing::get(async || static_file(CGIT_JS, "text/javascript")),
)
.route(
- "/cgit.png",
+ "/-/assets/cgit.png",
axum::routing::get(async || static_file(CGIT_LOGO, "image/png")),
)
.route(
- "/favicon.ico",
+ "/-/assets/favicon.ico",
axum::routing::get(async || static_file(CGIT_FAVICON, "image/x-icon")),
)
- .fallback(proxy_to_cgit)
- .with_state(state);
+ .fallback_service(repositories);
let listener = tokio::net::TcpListener::bind(listen_addr).await?;
eprintln!("gilti: listening on {listen_addr}");
@@ -193,12 +243,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
-fn check_files() -> std::io::Result<()> {
- let metadata = std::fs::metadata(CGIT)?;
- if !metadata.is_file()
- || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0
- {
- return Err(std::io::Error::other(format!("{CGIT} is not executable")));
+fn git_http_backend() -> std::io::Result<std::path::PathBuf> {
+ GIT_HTTP_BACKENDS
+ .iter()
+ .map(std::path::PathBuf::from)
+ .find(|path| path.is_file())
+ .ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::NotFound, "git-http-backend not found")
+ })
+}
+
+fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> {
+ for path in [std::path::Path::new(CGIT), git_http_backend] {
+ let metadata = std::fs::metadata(path)?;
+ if !metadata.is_file()
+ || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0
+ {
+ return Err(std::io::Error::other(format!(
+ "{} is not executable",
+ path.display()
+ )));
+ }
}
for path in [CGIT_CSS, CGIT_JS, CGIT_LOGO, CGIT_FAVICON] {
if !std::fs::metadata(path)?.is_file() {
@@ -231,26 +296,402 @@ fn static_file(path: &str, content_type: &'static str) -> axum::response::Respon
}
}
-async fn proxy_to_cgit(
- axum::extract::State(state): axum::extract::State<AppState>,
- axum::extract::ConnectInfo(remote): axum::extract::ConnectInfo<std::net::SocketAddr>,
- mut request: axum::extract::Request,
-) -> axum::response::Response {
- if request.method() != axum::http::Method::GET && request.method() != axum::http::Method::HEAD {
- return plain_response(axum::http::StatusCode::FORBIDDEN, "forbidden\n");
+impl tower::Service<axum::extract::Request> for RepositoryService {
+ type Response = axum::response::Response;
+ type Error = std::convert::Infallible;
+ type Future = std::pin::Pin<
+ Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
+ >;
+
+ fn poll_ready(
+ &mut self,
+ _context: &mut std::task::Context<'_>,
+ ) -> std::task::Poll<Result<(), Self::Error>> {
+ std::task::Poll::Ready(Ok(()))
}
- request.extensions_mut().insert(cgi::RemoteAddr(remote));
- let method = request.method().clone();
- match tower::ServiceExt::oneshot(state.cgit.clone(), request).await {
- Ok(response) => render_private_page(method, response).await,
- Err(error) => {
- eprintln!("gilti: cgit request failed: {error}");
- plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n")
+ fn call(&mut self, request: axum::extract::Request) -> Self::Future {
+ let service = self.clone();
+ Box::pin(async move { Ok(service.execute(request).await) })
+ }
+}
+
+impl RepositoryService {
+ async fn execute(&self, mut request: axum::extract::Request) -> axum::response::Response {
+ let route = match router::parse(request.uri().path()) {
+ Ok(route) => route,
+ Err(_) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"),
+ };
+ if let Some(axum::extract::ConnectInfo(remote)) = request
+ .extensions()
+ .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
+ .copied()
+ {
+ request.extensions_mut().insert(cgi::RemoteAddr(remote));
+ }
+
+ match route {
+ router::Route::Summary(route) => redirect(&route.repo),
+ router::Route::GitClone(route) => redirect(&route.repo),
+ router::Route::GitLfs(route) => {
+ lfs::serve(
+ std::path::Path::new(REPOSITORIES),
+ &route.repo,
+ &route.params,
+ self.write_enabled,
+ request,
+ )
+ .await
+ }
+ router::Route::GitInfoRefs(route) => {
+ self.git(request, route.repo, "info/refs", self.write_enabled)
+ .await
+ }
+ router::Route::GitUploadPack(route) => {
+ self.git(request, route.repo, "git-upload-pack", false)
+ .await
+ }
+ router::Route::GitReceivePack(route) if self.write_enabled => {
+ self.git(request, route.repo, "git-receive-pack", true)
+ .await
+ }
+ router::Route::GitReceivePack(_) => {
+ plain_response(axum::http::StatusCode::FORBIDDEN, "HTTP push is disabled\n")
+ }
+ router::Route::GitHead(route) => self.git(request, route.repo, "HEAD", false).await,
+ router::Route::GitObjects(route) => {
+ self.git(
+ request,
+ route.repo,
+ &format!("objects/{}", route.params),
+ false,
+ )
+ .await
+ }
+ route => self.cgit(request, route).await,
+ }
+ }
+
+ async fn git(
+ &self,
+ mut request: axum::extract::Request,
+ repo: String,
+ endpoint: &str,
+ authenticated: bool,
+ ) -> axum::response::Response {
+ if !safe_repository(&repo) {
+ return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
+ }
+ let mut environment = vec![
+ ("PATH_INFO".into(), format!("/{repo}.git/{endpoint}").into()),
+ ("GIT_PROJECT_ROOT".into(), REPOSITORIES.into()),
+ ];
+ if authenticated {
+ environment.push(("REMOTE_USER".into(), "gilti".into()));
+ }
+ request
+ .extensions_mut()
+ .insert(cgi::Environment(environment));
+ request.extensions_mut().insert(cgi::NoCache);
+ match tower::ServiceExt::oneshot(self.git.clone(), request).await {
+ Ok(response) => response,
+ Err(error) => internal_error("git-http-backend", error),
+ }
+ }
+
+ async fn cgit(
+ &self,
+ mut request: axum::extract::Request,
+ route: router::Route,
+ ) -> axum::response::Response {
+ if request.method() != axum::http::Method::GET
+ && request.method() != axum::http::Method::HEAD
+ {
+ return plain_response(
+ axum::http::StatusCode::METHOD_NOT_ALLOWED,
+ "method not allowed\n",
+ );
+ }
+ let query = match request_query(request.uri().query()) {
+ Ok(query) => query,
+ Err(()) => return plain_response(axum::http::StatusCode::BAD_REQUEST, "bad query\n"),
+ };
+ if !valid_format(&route, query.format.as_deref()) {
+ return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n");
+ }
+ let environment = match cgit_environment(route, query, request.uri().path()) {
+ Ok(environment) => environment,
+ Err(()) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"),
+ };
+ request
+ .extensions_mut()
+ .insert(cgi::Environment(environment));
+ let method = request.method().clone();
+ match tower::ServiceExt::oneshot(self.cgit.clone(), request).await {
+ Ok(response) => render_private_page(method, response).await,
+ Err(error) => internal_error("cgit", error),
+ }
+ }
+}
+
+#[derive(Default)]
+struct RequestQuery {
+ format: Option<String>,
+ environment: Vec<(std::ffi::OsString, std::ffi::OsString)>,
+}
+
+fn request_query(query: Option<&str>) -> Result<RequestQuery, ()> {
+ let mut result = RequestQuery::default();
+ for pair in query
+ .unwrap_or("")
+ .split('&')
+ .filter(|pair| !pair.is_empty())
+ {
+ let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
+ let name = decode_query(name)?;
+ let value = decode_query(value)?;
+ if name == "format" {
+ if result.format.replace(value).is_some() {
+ return Err(());
+ }
+ continue;
+ }
+ let environment = match name.as_str() {
+ "q" => "GILTI_QUERY_SEARCH",
+ "qt" => "GILTI_QUERY_GREP",
+ "ofs" => "GILTI_QUERY_OFFSET",
+ "s" => "GILTI_QUERY_SORT",
+ "showmsg" => "GILTI_QUERY_SHOWMSG",
+ "period" => "GILTI_QUERY_PERIOD",
+ "dt" => "GILTI_QUERY_DIFFTYPE",
+ "context" => "GILTI_QUERY_CONTEXT",
+ "ignorews" => "GILTI_QUERY_IGNOREWS",
+ "follow" => "GILTI_QUERY_FOLLOW",
+ "view" => "GILTI_QUERY_VIEW",
+ _ => return Err(()),
+ };
+ if result
+ .environment
+ .iter()
+ .any(|(existing, _)| existing == std::ffi::OsStr::new(environment))
+ {
+ return Err(());
}
+ result.environment.push((environment.into(), value.into()));
+ }
+ Ok(result)
+}
+
+fn decode_query(value: &str) -> Result<String, ()> {
+ let value = value.replace('+', " ");
+ let value = percent_encoding::percent_decode_str(&value)
+ .decode_utf8()
+ .map_err(|_| ())?
+ .into_owned();
+ (!value.contains('\0')).then_some(value).ok_or(())
+}
+
+fn valid_format(route: &router::Route, format: Option<&str>) -> bool {
+ let Some(format) = format else {
+ return true;
+ };
+ match route {
+ router::Route::Tree(_) | router::Route::Diff(_) => matches!(format, "html" | "raw"),
+ router::Route::Object(_) => format == "raw",
+ router::Route::Patch(_) => matches!(format, "patch" | "raw"),
+ router::Route::Archive(_) | router::Route::ArchiveSignature(_) => matches!(
+ format,
+ "tar" | "tar.gz" | "tar.bz2" | "tar.lz" | "tar.xz" | "tar.zst" | "zip"
+ ),
+ router::Route::AtomFeed(_) => format == "atom",
+ _ => format == "html",
}
}
+fn cgit_environment(
+ route: router::Route,
+ query: RequestQuery,
+ current_url: &str,
+) -> Result<Vec<(std::ffi::OsString, std::ffi::OsString)>, ()> {
+ let mut environment = query.environment;
+ let format = query.format.as_deref();
+ let mut set = |name: &str, value: String| environment.push((name.into(), value.into()));
+ set("GILTI_CURRENT_URL", current_url.to_owned());
+
+ match route {
+ router::Route::Repositories => set("GILTI_PAGE", "repolist".to_owned()),
+ router::Route::Overview(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "summary".to_owned());
+ }
+ router::Route::About(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "about".to_owned());
+ }
+ router::Route::Stats(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "stats".to_owned());
+ }
+ router::Route::Object(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "blob".to_owned());
+ set("GILTI_REVISION", route.params);
+ }
+ router::Route::Refs(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "refs".to_owned());
+ }
+ router::Route::Revision(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "revision".to_owned());
+ set("GILTI_REVISION", revision(route.params));
+ }
+ router::Route::Log(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "log".to_owned());
+ set("GILTI_REVISION", revision(route.params.rev));
+ if let Some(path) = route.params.path {
+ set("GILTI_PATH", path);
+ }
+ }
+ router::Route::Tree(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set(
+ "GILTI_PAGE",
+ if format == Some("raw") {
+ "plain"
+ } else {
+ "tree"
+ }
+ .to_owned(),
+ );
+ set("GILTI_REVISION", revision(route.params.rev));
+ if let Some(path) = route.params.path {
+ set("GILTI_PATH", path);
+ }
+ }
+ router::Route::Blame(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "blame".to_owned());
+ set("GILTI_REVISION", revision(route.params.rev));
+ set("GILTI_PATH", route.params.path);
+ }
+ router::Route::Archive(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "snapshot".to_owned());
+ set("GILTI_REVISION", revision(route.params.rev));
+ set("GILTI_FORMAT", format.unwrap_or("tar.gz").to_owned());
+ if let Some(path) = route.params.path {
+ set("GILTI_PATH", path);
+ }
+ }
+ router::Route::ArchiveSignature(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "snapshot".to_owned());
+ set("GILTI_REVISION", revision(route.params));
+ set("GILTI_FORMAT", format.unwrap_or("tar.gz").to_owned());
+ set("GILTI_SIGNATURE", "1".to_owned());
+ }
+ router::Route::AtomFeed(route) => {
+ set("GILTI_REPOSITORY", route.repo);
+ set("GILTI_PAGE", "atom".to_owned());
+ set("GILTI_REVISION", route.params.reference);
+ if let Some(path) = route.params.path {
+ set("GILTI_PATH", path);
+ }
+ }
+ router::Route::Diff(route) => {
+ return comparison_environment(environment, route, format, false);
+ }
+ router::Route::Patch(route) => {
+ return comparison_environment(environment, route, format, true);
+ }
+ _ => return Err(()),
+ }
+ Ok(environment)
+}
+
+fn comparison_environment(
+ mut environment: Vec<(std::ffi::OsString, std::ffi::OsString)>,
+ route: router::RepoRoute<router::Comparison>,
+ format: Option<&str>,
+ patch: bool,
+) -> Result<Vec<(std::ffi::OsString, std::ffi::OsString)>, ()> {
+ environment.push(("GILTI_REPOSITORY".into(), route.repo.into()));
+ environment.push((
+ "GILTI_PAGE".into(),
+ if patch {
+ "patch"
+ } else if format == Some("raw") {
+ "rawdiff"
+ } else {
+ "diff"
+ }
+ .into(),
+ ));
+ environment.push((
+ "GILTI_OLD_REVISION".into(),
+ revision(route.params.old_rev).into(),
+ ));
+ environment.push((
+ "GILTI_REVISION".into(),
+ revision(route.params.new_rev).into(),
+ ));
+ if let Some(path) = route.params.path {
+ environment.push(("GILTI_PATH".into(), path.into()));
+ }
+ Ok(environment)
+}
+
+fn revision(revision: router::Revision) -> String {
+ match revision {
+ router::Revision::Head => "HEAD".to_owned(),
+ router::Revision::Ref(reference) | router::Revision::Commit(reference) => reference,
+ }
+}
+
+fn safe_repository(repo: &str) -> bool {
+ !repo.is_empty()
+ && !repo.starts_with('/')
+ && !repo.chars().any(char::is_control)
+ && !repo.contains('\\')
+ && repo
+ .split('/')
+ .all(|component| !component.is_empty() && !matches!(component, "." | ".."))
+}
+
+fn redirect(repo: &str) -> axum::response::Response {
+ axum::http::Response::builder()
+ .status(axum::http::StatusCode::PERMANENT_REDIRECT)
+ .header(
+ axum::http::header::LOCATION,
+ format!("/{}", encode_path(repo)),
+ )
+ .body(axum::body::Body::empty())
+ .expect("valid redirect")
+}
+
+fn encode_path(value: &str) -> String {
+ let mut encoded = String::new();
+ for byte in value.bytes() {
+ if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' {
+ encoded.push(char::from(byte));
+ } else {
+ use std::fmt::Write;
+ write!(encoded, "%{byte:02X}").expect("writing to String cannot fail");
+ }
+ }
+ encoded
+}
+
+fn internal_error(context: &str, error: std::io::Error) -> axum::response::Response {
+ eprintln!("gilti: {context} request failed: {error}");
+ plain_response(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "internal server error\n",
+ )
+}
+
async fn render_private_page(
method: axum::http::Method,
response: axum::response::Response,
@@ -276,7 +717,10 @@ async fn render_private_page(
Ok(body) => body,
Err(error) => {
eprintln!("gilti: cannot read private page response: {error}");
- return plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n");
+ return plain_response(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "internal server error\n",
+ );
}
};
match ui::render(&body) {
@@ -286,7 +730,10 @@ async fn render_private_page(
),
Err(error) => {
eprintln!("gilti: invalid private page response: {error}");
- plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n")
+ plain_response(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "internal server error\n",
+ )
}
}
}
@@ -312,6 +759,46 @@ fn plain_response(
#[cfg(test)]
mod tests {
+ fn environment<'a>(
+ values: &'a [(std::ffi::OsString, std::ffi::OsString)],
+ name: &str,
+ ) -> &'a str {
+ values
+ .iter()
+ .find(|(key, _)| key == std::ffi::OsStr::new(name))
+ .and_then(|(_, value)| value.to_str())
+ .unwrap()
+ }
+
+ #[test]
+ fn route_parameters_become_trusted_cgit_environment() {
+ let route = super::router::parse(
+ "/group/repo/+/diff/refs/heads/main..0123456789abcdef0123456789abcdef01234567/+/src/lib.rs",
+ )
+ .unwrap();
+ let query = super::request_query(Some("format=raw&context=5")).unwrap();
+ let values = super::cgit_environment(route, query, "/group/repo").unwrap();
+ assert_eq!(environment(&values, "GILTI_REPOSITORY"), "group/repo");
+ assert_eq!(environment(&values, "GILTI_PAGE"), "rawdiff");
+ assert_eq!(
+ environment(&values, "GILTI_OLD_REVISION"),
+ "refs/heads/main"
+ );
+ assert_eq!(
+ environment(&values, "GILTI_REVISION"),
+ "0123456789abcdef0123456789abcdef01234567"
+ );
+ assert_eq!(environment(&values, "GILTI_PATH"), "src/lib.rs");
+ assert_eq!(environment(&values, "GILTI_QUERY_CONTEXT"), "5");
+ }
+
+ #[test]
+ fn structural_query_parameters_are_rejected() {
+ assert!(super::request_query(Some("id=HEAD")).is_err());
+ assert!(super::request_query(Some("path=README.md")).is_err());
+ assert!(super::request_query(Some("format=raw&format=html")).is_err());
+ }
+
#[test]
fn cache_is_bounded() {
assert_eq!(
@@ -325,7 +812,7 @@ mod tests {
#[tokio::test]
async fn private_pages_become_html_and_head_keeps_headers() {
- let model = br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[]}"#;
+ let model = br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/-/about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[]}"#;
let response = axum::http::Response::builder()
.status(axum::http::StatusCode::OK)
.header(
@@ -393,7 +880,7 @@ mod tests {
super::render_private_page(axum::http::Method::GET, response)
.await
.status(),
- axum::http::StatusCode::BAD_GATEWAY
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
}
diff --git a/crates/gilti/src/router.rs b/crates/gilti/src/router.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/router.rs
@@ -0,0 +1,867 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+/*
+URL scheme
+
+The URL space is divided into disjoint Gilti-specific and repository-specific
+namespaces. The reserved top-level segment "-" belongs to Gilti and cannot be
+used as the first segment of a repository name.
+
+Git objects are content-addressed and immutable: a full object ID always
+identifies the same object contents. Refs and repository metadata are mutable,
+and unreachable objects may eventually be removed.
+
+Routes marked I use only immutable selectors. Routes marked M depend on the
+current repository state. A composite selector inherits mutability from its
+components: a tree path under a full commit OID is immutable, while the same
+path under a branch or tag ref is mutable. A comparison is immutable only when
+both of its revision selectors are immutable.
+
+Repository names, ref names, and tree paths may all contain "/". Their
+boundaries are expressed using literal "/+/" segments and are never inferred
+from repository state. Structural delimiters are recognized before percent
+decoding. A literal data segment equal to "+" must be percent-encoded as
+"%2B".
+
+Route parameters whose names end in "*" may contain "/". They are terminated
+by the next structural "/+/" segment, a route-specific terminal marker, or the
+end of the route. The terminal ".git" suffix separates a repository name from
+Git HTTP transport paths.
+
+ {repo*} canonical repository name
+ {ref*} ref name relative to "refs/", for example "heads/main"
+ {path*} path within a Git tree
+ {commit_id} full OID of a commit; abbreviated IDs are not accepted
+ {object_id} full OID of any Git object; abbreviated IDs are not accepted
+ {rev*} "{commit_id}", "refs/{ref*}", or "HEAD"
+ {asset_path*} path below the Gilti static asset root
+ {old_rev*} revision selector
+ {new_rev*} revision selector
+ {dumb_http_path*} path below the Git dumb HTTP "objects/" endpoint
+ {lfs_path*} path below the Git LFS endpoint
+
+Archive, diff, blob, and object formats are representation options, not route
+parameters. They are selected by the "format" query parameter, then by the
+Accept header, and finally by the server default. The query parameter takes
+precedence when both are present. Archive signature routes use the same query
+parameter to identify the signed archive format. Raw blob contents, raw Git
+object contents, and raw unified diffs are representations of their respective
+views rather than separate views.
+
+Rendered documents are an HTML view mode selected with the "view" query
+parameter. Supported values are "source" and "rendered". Renderable documents
+may default to "rendered"; other blobs default to "source".
+
+Gilti views and operational endpoints:
+
+/-/about <- Gilti about page
+/-/terms <- Gilti terms of use
+/-/assets/{asset_path*} <- Static asset
+/-/health <- Health check returning {"status":"ok"}
+*/
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Route {
+ // Repository views:
+ /// M: Repository list
+ /// /
+ Repositories,
+
+ /// M: Repository overview
+ /// /{repo*}
+ Overview(RepoRoute<()>),
+
+ /// M: Repository documentation
+ /// /{repo*}/+/about
+ About(RepoRoute<()>),
+
+ /// M: Repository activity statistics
+ /// /{repo*}/+/stats
+ Stats(RepoRoute<()>),
+
+ /// M: Recent tags, commits, and summary data
+ /// /{repo*}/+/summary
+ Summary(RepoRoute<()>),
+
+ // Immutable object views:
+ /// I: Git object
+ /// /{repo*}/+/object/{object_id}
+ Object(RepoRoute<String>),
+
+ // Refs and revisions:
+ /// M: Ref list
+ /// /{repo*}/+/refs
+ Refs(RepoRoute<()>),
+
+ /// I/M: Revision overview and resolved target
+ /// /{repo*}/+/{rev*}
+ Revision(RepoRoute<Revision>),
+
+ /// I/M: Commit log, optionally restricted to a path
+ /// /{repo*}/+/{rev*}/+/log
+ /// /{repo*}/+/{rev*}/+/log/{path*}
+ Log(RepoRoute<RevisionPath>),
+
+ /// I/M: Tree, blob, document, or submodule
+ /// /{repo*}/+/{rev*}/+/tree
+ /// /{repo*}/+/{rev*}/+/tree/{path*}
+ Tree(RepoRoute<RevisionPath>),
+
+ /// I/M: File blame
+ /// /{repo*}/+/{rev*}/+/blame/{path*}
+ Blame(RepoRoute<RevisionFile>),
+
+ /// I/M: Archive of the root tree or a subtree
+ /// /{repo*}/+/{rev*}/+/archive
+ /// /{repo*}/+/{rev*}/+/archive/{path*}
+ Archive(RepoRoute<RevisionPath>),
+
+ /// I/M: Detached archive signature
+ /// /{repo*}/+/{rev*}/+/archive-signature
+ ArchiveSignature(RepoRoute<Revision>),
+
+ // Feed & updates:
+ /// M: Atom commit feed, optionally restricted to a path
+ /// /{repo*}/+/refs/{ref*}/+/feed/atom
+ /// /{repo*}/+/refs/{ref*}/+/feed/atom/{path*}
+ AtomFeed(RepoRoute<RefPath>),
+
+ // Comparisons and patches:
+ /// I/M: Diff between two revisions, optionally restricted to a path
+ /// /{repo*}/+/diff/{old_rev*}..{new_rev*}
+ /// /{repo*}/+/diff/{old_rev*}..{new_rev*}/+/{path*}
+ Diff(RepoRoute<Comparison>),
+
+ /// I/M: Mail-formatted patch or patch series, optionally restricted to a path
+ /// /{repo*}/+/patch/{old_rev*}..{new_rev*}
+ /// /{repo*}/+/patch/{old_rev*}..{new_rev*}/+/{path*}
+ Patch(RepoRoute<Comparison>),
+
+ // Git HTTP transport endpoints:
+ /// M: Public clone URL
+ /// /{repo*}.git
+ GitClone(RepoRoute<()>),
+
+ /// M: Service discovery and ref advertisement
+ /// /{repo*}.git/info/refs
+ GitInfoRefs(RepoRoute<()>),
+
+ /// M: Fetch protocol endpoint
+ /// /{repo*}.git/git-upload-pack
+ GitUploadPack(RepoRoute<()>),
+
+ /// M: Push protocol endpoint, when enabled
+ /// /{repo*}.git/git-receive-pack
+ GitReceivePack(RepoRoute<()>),
+
+ /// M: Dumb HTTP HEAD advertisement, when enabled
+ /// /{repo*}.git/HEAD
+ GitHead(RepoRoute<()>),
+
+ /// Git dumb HTTP object endpoint, when enabled
+ /// /{repo*}.git/objects/{dumb_http_path*}
+ GitObjects(RepoRoute<String>),
+
+ /// Git LFS endpoint, when enabled
+ /// /{repo*}.git/info/lfs/{lfs_path*}
+ GitLfs(RepoRoute<String>),
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RepoRoute<T> {
+ pub repo: String,
+ pub params: T,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RevisionPath {
+ pub rev: Revision,
+ pub path: Option<String>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RevisionFile {
+ pub rev: Revision,
+ pub path: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RefPath {
+ pub reference: String,
+ pub path: Option<String>,
+}
+
+/// A revision range separated by `..` and an optional tree path separated by `/+/`.
+/// It is immutable only when both revision selectors are full commit OIDs.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Comparison {
+ pub old_rev: Revision,
+ pub new_rev: Revision,
+ pub path: Option<String>,
+}
+
+/// A revision route is immutable for a full commit OID and mutable for a ref or
+/// `HEAD`. An annotated tag displays its object chain and peeled target.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Revision {
+ Head,
+ Ref(String),
+ Commit(String),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ParseError;
+
+impl std::fmt::Display for ParseError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter.write_str("invalid repository route")
+ }
+}
+
+impl std::error::Error for ParseError {}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum TokenKind {
+ Text,
+ Slash,
+ Boundary,
+ Range,
+ GitSuffix,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct Token<'a> {
+ kind: TokenKind,
+ raw: &'a str,
+}
+
+/// Parses a raw, percent-encoded URI path. Query parameters are handled separately.
+pub fn parse(path: &str) -> Result<Route, ParseError> {
+ let tokens = lex(path)?;
+ Parser {
+ tokens: &tokens,
+ cursor: 0,
+ }
+ .parse()
+}
+
+fn lex(path: &str) -> Result<Vec<Token<'_>>, ParseError> {
+ let bytes = path.as_bytes();
+ let mut tokens = Vec::new();
+ let (mut cursor, mut start) = (0, 0);
+ while cursor < bytes.len() {
+ if bytes[cursor] == b'%' {
+ if cursor + 2 >= bytes.len()
+ || !bytes[cursor + 1].is_ascii_hexdigit()
+ || !bytes[cursor + 2].is_ascii_hexdigit()
+ {
+ return Err(ParseError);
+ }
+ cursor += 3;
+ continue;
+ }
+ if matches!(bytes[cursor], b'?' | b'#') {
+ return Err(ParseError);
+ }
+ let delimiter = if bytes[cursor..].starts_with(b"/+/") {
+ Some((TokenKind::Boundary, 3))
+ } else if bytes[cursor] == b'/' {
+ Some((TokenKind::Slash, 1))
+ } else if bytes[cursor..].starts_with(b"..") {
+ Some((TokenKind::Range, 2))
+ } else if bytes[cursor..].starts_with(b".git")
+ && (cursor + 4 == bytes.len() || bytes[cursor + 4] == b'/')
+ {
+ Some((TokenKind::GitSuffix, 4))
+ } else {
+ None
+ };
+ let Some((kind, width)) = delimiter else {
+ cursor += 1;
+ continue;
+ };
+ if start < cursor {
+ tokens.push(Token {
+ kind: TokenKind::Text,
+ raw: &path[start..cursor],
+ });
+ }
+ tokens.push(Token {
+ kind,
+ raw: &path[cursor..cursor + width],
+ });
+ cursor += width;
+ start = cursor;
+ }
+ if start < cursor {
+ tokens.push(Token {
+ kind: TokenKind::Text,
+ raw: &path[start..cursor],
+ });
+ }
+ Ok(tokens)
+}
+
+struct Parser<'tokens, 'input> {
+ tokens: &'tokens [Token<'input>],
+ cursor: usize,
+}
+
+impl<'tokens, 'input> Parser<'tokens, 'input> {
+ fn parse(mut self) -> Result<Route, ParseError> {
+ self.expect(TokenKind::Slash)?;
+ if self.end() {
+ return Ok(Route::Repositories);
+ }
+ let repo = decode_repo(self.take_until(&[TokenKind::Boundary, TokenKind::GitSuffix]))?;
+ match self.peek() {
+ None => Ok(Route::Overview(route(repo, ()))),
+ Some(TokenKind::Boundary) => {
+ self.cursor += 1;
+ self.view(repo)
+ }
+ Some(TokenKind::GitSuffix) => {
+ self.cursor += 1;
+ self.git(repo)
+ }
+ _ => Err(ParseError),
+ }
+ }
+
+ fn view(&mut self, repo: String) -> Result<Route, ParseError> {
+ Ok(match self.peek_text() {
+ Some("about") => {
+ self.cursor += 1;
+ self.finish()?;
+ Route::About(route(repo, ()))
+ }
+ Some("stats") => {
+ self.cursor += 1;
+ self.finish()?;
+ Route::Stats(route(repo, ()))
+ }
+ Some("summary") => {
+ self.cursor += 1;
+ self.finish()?;
+ Route::Summary(route(repo, ()))
+ }
+ Some("refs") if self.cursor + 1 == self.tokens.len() => {
+ self.cursor += 1;
+ Route::Refs(route(repo, ()))
+ }
+ Some("object") => {
+ self.cursor += 1;
+ self.expect(TokenKind::Slash)?;
+ let object_id = parse_oid(self.take_text()?)?;
+ self.finish()?;
+ Route::Object(route(repo, object_id))
+ }
+ Some("diff") | Some("patch") => {
+ let patch = self.take_text()? == "patch";
+ self.expect(TokenKind::Slash)?;
+ return self.comparison(repo, patch);
+ }
+ _ => {
+ let rev = self.revision()?;
+ if self.end() {
+ Route::Revision(route(repo, rev))
+ } else {
+ self.expect(TokenKind::Boundary)?;
+ return self.revision_view(repo, rev);
+ }
+ }
+ })
+ }
+
+ fn revision_view(&mut self, repo: String, rev: Revision) -> Result<Route, ParseError> {
+ Ok(match self.take_text()? {
+ "log" => Route::Log(route(
+ repo,
+ RevisionPath {
+ rev,
+ path: self.optional_path(TokenKind::Slash)?,
+ },
+ )),
+ "tree" => Route::Tree(route(
+ repo,
+ RevisionPath {
+ rev,
+ path: self.optional_path(TokenKind::Slash)?,
+ },
+ )),
+ "blame" => {
+ self.expect(TokenKind::Slash)?;
+ Route::Blame(route(
+ repo,
+ RevisionFile {
+ rev,
+ path: decode_path(self.rest())?,
+ },
+ ))
+ }
+ "archive" => Route::Archive(route(
+ repo,
+ RevisionPath {
+ rev,
+ path: self.optional_path(TokenKind::Slash)?,
+ },
+ )),
+ "archive-signature" => {
+ self.finish()?;
+ Route::ArchiveSignature(route(repo, rev))
+ }
+ "feed" => {
+ self.expect(TokenKind::Slash)?;
+ self.expect_text("atom")?;
+ Route::AtomFeed(route(
+ repo,
+ RefPath {
+ reference: reference(rev)?,
+ path: self.optional_path(TokenKind::Slash)?,
+ },
+ ))
+ }
+ _ => return Err(ParseError),
+ })
+ }
+
+ fn comparison(&mut self, repo: String, patch: bool) -> Result<Route, ParseError> {
+ let old_rev = self.revision()?;
+ self.expect(TokenKind::Range)?;
+ let new_rev = self.revision()?;
+ let comparison = route(
+ repo,
+ Comparison {
+ old_rev,
+ new_rev,
+ path: self.optional_path(TokenKind::Boundary)?,
+ },
+ );
+ Ok(if patch {
+ Route::Patch(comparison)
+ } else {
+ Route::Diff(comparison)
+ })
+ }
+
+ fn git(&mut self, repo: String) -> Result<Route, ParseError> {
+ if self.end() {
+ return Ok(Route::GitClone(route(repo, ())));
+ }
+ self.expect(TokenKind::Slash)?;
+ Ok(match self.take_text()? {
+ "info" => {
+ self.expect(TokenKind::Slash)?;
+ match self.take_text()? {
+ "refs" => {
+ self.finish()?;
+ Route::GitInfoRefs(route(repo, ()))
+ }
+ "lfs" => {
+ self.expect(TokenKind::Slash)?;
+ Route::GitLfs(route(repo, decode_path(self.rest())?))
+ }
+ _ => return Err(ParseError),
+ }
+ }
+ "git-upload-pack" => {
+ self.finish()?;
+ Route::GitUploadPack(route(repo, ()))
+ }
+ "git-receive-pack" => {
+ self.finish()?;
+ Route::GitReceivePack(route(repo, ()))
+ }
+ "HEAD" => {
+ self.finish()?;
+ Route::GitHead(route(repo, ()))
+ }
+ "objects" => {
+ self.expect(TokenKind::Slash)?;
+ Route::GitObjects(route(repo, decode_path(self.rest())?))
+ }
+ _ => return Err(ParseError),
+ })
+ }
+
+ fn revision(&mut self) -> Result<Revision, ParseError> {
+ parse_revision(self.take_until(&[TokenKind::Boundary, TokenKind::Range]))
+ }
+
+ fn optional_path(&mut self, separator: TokenKind) -> Result<Option<String>, ParseError> {
+ if self.end() {
+ Ok(None)
+ } else {
+ self.expect(separator)?;
+ decode_path(self.rest()).map(Some)
+ }
+ }
+
+ fn take_until(&mut self, delimiters: &[TokenKind]) -> &'tokens [Token<'input>] {
+ let start = self.cursor;
+ while self.peek().is_some_and(|kind| !delimiters.contains(&kind)) {
+ self.cursor += 1;
+ }
+ &self.tokens[start..self.cursor]
+ }
+
+ fn rest(&mut self) -> &'tokens [Token<'input>] {
+ let rest = &self.tokens[self.cursor..];
+ self.cursor = self.tokens.len();
+ rest
+ }
+
+ fn peek(&self) -> Option<TokenKind> {
+ self.tokens.get(self.cursor).map(|token| token.kind)
+ }
+
+ fn peek_text(&self) -> Option<&'input str> {
+ self.tokens
+ .get(self.cursor)
+ .filter(|token| token.kind == TokenKind::Text)
+ .map(|token| token.raw)
+ }
+
+ fn take_text(&mut self) -> Result<&'input str, ParseError> {
+ let text = self.peek_text().ok_or(ParseError)?;
+ self.cursor += 1;
+ Ok(text)
+ }
+
+ fn expect_text(&mut self, expected: &str) -> Result<(), ParseError> {
+ (self.take_text()? == expected)
+ .then_some(())
+ .ok_or(ParseError)
+ }
+
+ fn expect(&mut self, expected: TokenKind) -> Result<(), ParseError> {
+ (self.peek() == Some(expected))
+ .then(|| self.cursor += 1)
+ .ok_or(ParseError)
+ }
+
+ fn finish(&self) -> Result<(), ParseError> {
+ self.end().then_some(()).ok_or(ParseError)
+ }
+
+ fn end(&self) -> bool {
+ self.cursor == self.tokens.len()
+ }
+}
+
+fn parse_revision(value: &[Token<'_>]) -> Result<Revision, ParseError> {
+ if value.len() == 1 && value[0].kind == TokenKind::Text {
+ return match value[0].raw {
+ "HEAD" => Ok(Revision::Head),
+ oid => parse_oid(oid).map(Revision::Commit),
+ };
+ }
+ if value.len() >= 2 && value[0].raw == "refs" && value[1].kind == TokenKind::Slash {
+ let reference = format!("refs/{}", decode_path(&value[2..])?);
+ return valid_ref(&reference)
+ .then_some(Revision::Ref(reference))
+ .ok_or(ParseError);
+ }
+ Err(ParseError)
+}
+
+fn parse_oid(value: &str) -> Result<String, ParseError> {
+ (matches!(value.len(), 40 | 64)
+ && value
+ .as_bytes()
+ .iter()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)))
+ .then(|| value.to_owned())
+ .ok_or(ParseError)
+}
+
+fn valid_ref(value: &str) -> bool {
+ !value.ends_with('.')
+ && !value.contains("..")
+ && !value.contains("@{")
+ && value.split('/').all(|part| {
+ !part.is_empty()
+ && !part.starts_with('.')
+ && !part.ends_with(".lock")
+ && !part
+ .bytes()
+ .any(|byte| byte < b' ' || byte == 0x7f || b" ~^:?*[\\".contains(&byte))
+ })
+}
+
+fn decode_repo(value: &[Token<'_>]) -> Result<String, ParseError> {
+ decode_name(value, true)
+}
+
+fn decode_path(value: &[Token<'_>]) -> Result<String, ParseError> {
+ decode_name(value, false)
+}
+
+fn decode_name(value: &[Token<'_>], repo: bool) -> Result<String, ParseError> {
+ if value.is_empty() {
+ return Err(ParseError);
+ }
+ for (index, component) in value
+ .split(|token| token.kind == TokenKind::Slash)
+ .enumerate()
+ {
+ let literal = |expected| component.len() == 1 && component[0].raw == expected;
+ if component.is_empty()
+ || component.iter().any(|token| {
+ !matches!(
+ token.kind,
+ TokenKind::Text | TokenKind::Range | TokenKind::GitSuffix
+ )
+ })
+ || literal(".")
+ || literal("..")
+ || literal("+")
+ || repo
+ && (index == 0 && literal("-")
+ || component
+ .last()
+ .is_some_and(|token| token.kind == TokenKind::GitSuffix))
+ {
+ return Err(ParseError);
+ }
+ }
+ let mut decoded = String::new();
+ for token in value {
+ decoded.push_str(
+ &percent_encoding::percent_decode_str(token.raw)
+ .decode_utf8()
+ .map_err(|_| ParseError)?,
+ );
+ }
+ (!decoded.contains('\0'))
+ .then_some(decoded)
+ .ok_or(ParseError)
+}
+
+fn reference(rev: Revision) -> Result<String, ParseError> {
+ match rev {
+ Revision::Ref(reference) => Ok(reference),
+ _ => Err(ParseError),
+ }
+}
+
+fn route<T>(repo: String, params: T) -> RepoRoute<T> {
+ RepoRoute { repo, params }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ Comparison, RefPath, RepoRoute, Revision, RevisionFile, RevisionPath, Route, TokenKind,
+ lex, parse,
+ };
+
+ const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
+ const OTHER: &str = "89abcdef0123456789abcdef0123456789abcdef";
+
+ fn repository<T>(params: T) -> RepoRoute<T> {
+ repo("group/проект", params)
+ }
+
+ fn repo<T>(name: &str, params: T) -> RepoRoute<T> {
+ RepoRoute {
+ repo: name.to_owned(),
+ params,
+ }
+ }
+
+ #[test]
+ fn lexer_preserves_raw_tokens() {
+ use TokenKind::{Boundary, GitSuffix, Range, Slash, Text};
+
+ let tokens = lex("/repo%2Egit.git/diff/HEAD..refs/heads/main/+/src").unwrap();
+ assert_eq!(
+ tokens
+ .iter()
+ .map(|token| (token.kind, token.raw))
+ .collect::<Vec<_>>(),
+ [
+ (Slash, "/"),
+ (Text, "repo%2Egit"),
+ (GitSuffix, ".git"),
+ (Slash, "/"),
+ (Text, "diff"),
+ (Slash, "/"),
+ (Text, "HEAD"),
+ (Range, ".."),
+ (Text, "refs"),
+ (Slash, "/"),
+ (Text, "heads"),
+ (Slash, "/"),
+ (Text, "main"),
+ (Boundary, "/+/"),
+ (Text, "src"),
+ ]
+ );
+ }
+
+ #[test]
+ fn parses_repository_views() {
+ assert_eq!(parse("/").unwrap(), Route::Repositories);
+ assert_eq!(
+ parse("/group/%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82").unwrap(),
+ Route::Overview(repository(()))
+ );
+ assert_eq!(
+ parse("/foo%2Egit/+/summary").unwrap(),
+ Route::Summary(repo("foo.git", ()))
+ );
+ assert_eq!(
+ parse("/odd..repo").unwrap(),
+ Route::Overview(repo("odd..repo", ()))
+ );
+ for (path, expected) in [
+ ("about", Route::About(repository(()))),
+ ("stats", Route::Stats(repository(()))),
+ ("summary", Route::Summary(repository(()))),
+ ("refs", Route::Refs(repository(()))),
+ ] {
+ assert_eq!(parse(&format!("/group/проект/+/{path}")).unwrap(), expected);
+ }
+ assert_eq!(
+ parse(&format!("/group/проект/+/object/{COMMIT}")).unwrap(),
+ Route::Object(repository(COMMIT.to_owned()))
+ );
+ }
+
+ #[test]
+ fn parses_revision_views_and_escaped_paths() {
+ let reference = Revision::Ref("refs/heads/feature/+".to_owned());
+ assert_eq!(
+ parse("/group/проект/+/refs/heads/feature/%2B").unwrap(),
+ Route::Revision(repository(reference.clone()))
+ );
+ assert_eq!(
+ parse("/group/проект/+/refs/heads/feature/%2B/+/tree").unwrap(),
+ Route::Tree(repository(RevisionPath {
+ rev: reference.clone(),
+ path: None,
+ }))
+ );
+ assert_eq!(
+ parse("/group/проект/+/refs/heads/feature/%2B/+/tree/src/%2B/lib.rs").unwrap(),
+ Route::Tree(repository(RevisionPath {
+ rev: reference.clone(),
+ path: Some("src/+/lib.rs".to_owned()),
+ }))
+ );
+ assert_eq!(
+ parse("/group/проект/+/HEAD/+/tree/src/a..b/foo.git").unwrap(),
+ Route::Tree(repository(RevisionPath {
+ rev: Revision::Head,
+ path: Some("src/a..b/foo.git".to_owned()),
+ }))
+ );
+ assert_eq!(
+ parse(&format!("/group/проект/+/{COMMIT}/+/log/src/lib.rs")).unwrap(),
+ Route::Log(repository(RevisionPath {
+ rev: Revision::Commit(COMMIT.to_owned()),
+ path: Some("src/lib.rs".to_owned()),
+ }))
+ );
+ assert_eq!(
+ parse("/group/проект/+/HEAD/+/archive").unwrap(),
+ Route::Archive(repository(RevisionPath {
+ rev: Revision::Head,
+ path: None,
+ }))
+ );
+ assert_eq!(
+ parse("/group/проект/+/refs/heads/main/+/blame/src/lib.rs").unwrap(),
+ Route::Blame(repository(RevisionFile {
+ rev: Revision::Ref("refs/heads/main".to_owned()),
+ path: "src/lib.rs".to_owned(),
+ }))
+ );
+ }
+
+ #[test]
+ fn parses_feeds_comparisons_and_patches() {
+ assert_eq!(
+ parse("/group/проект/+/refs/heads/main/+/feed/atom/src/lib.rs").unwrap(),
+ Route::AtomFeed(repository(RefPath {
+ reference: "refs/heads/main".to_owned(),
+ path: Some("src/lib.rs".to_owned()),
+ }))
+ );
+ assert_eq!(
+ parse(&format!(
+ "/group/проект/+/diff/refs/heads/main..{COMMIT}/+/src/lib.rs"
+ ))
+ .unwrap(),
+ Route::Diff(repository(Comparison {
+ old_rev: Revision::Ref("refs/heads/main".to_owned()),
+ new_rev: Revision::Commit(COMMIT.to_owned()),
+ path: Some("src/lib.rs".to_owned()),
+ }))
+ );
+ assert_eq!(
+ parse(&format!("/group/проект/+/patch/{COMMIT}..{OTHER}")).unwrap(),
+ Route::Patch(repository(Comparison {
+ old_rev: Revision::Commit(COMMIT.to_owned()),
+ new_rev: Revision::Commit(OTHER.to_owned()),
+ path: None,
+ }))
+ );
+ }
+
+ #[test]
+ fn parses_git_transport_routes() {
+ for (path, expected) in [
+ ("/group/проект.git", Route::GitClone(repository(()))),
+ ("/foo%2Egit.git", Route::GitClone(repo("foo.git", ()))),
+ (
+ "/group/проект.git/info/refs",
+ Route::GitInfoRefs(repository(())),
+ ),
+ (
+ "/group/проект.git/git-upload-pack",
+ Route::GitUploadPack(repository(())),
+ ),
+ (
+ "/group/проект.git/git-receive-pack",
+ Route::GitReceivePack(repository(())),
+ ),
+ ("/group/проект.git/HEAD", Route::GitHead(repository(()))),
+ (
+ "/group/проект.git/objects/ab/cdef",
+ Route::GitObjects(repository("ab/cdef".to_owned())),
+ ),
+ (
+ "/group/проект.git/info/lfs/objects/batch",
+ Route::GitLfs(repository("objects/batch".to_owned())),
+ ),
+ ] {
+ assert_eq!(parse(path).unwrap(), expected);
+ }
+ }
+
+ #[test]
+ fn rejects_ambiguous_or_noncanonical_routes() {
+ for path in [
+ "",
+ "/-/about",
+ "/repo/",
+ "/repo/+/",
+ "/repo/+/main",
+ "/repo/+/deadbeef",
+ "/repo/+/refs/heads/bad..ref",
+ "/repo/+/HEAD/+/blame",
+ "/repo/+/HEAD/+/feed/atom",
+ "/repo/+/diff/HEAD...HEAD",
+ "/repo/+/diff/HEAD..",
+ "/repo/+/tree",
+ "/repo.git/unknown",
+ "/repo/%zz",
+ "/repo?format=raw",
+ ] {
+ assert!(parse(path).is_err(), "accepted {path}");
+ }
+ }
+}
diff --git a/crates/gilti/src/ui.rs b/crates/gilti/src/ui.rs
index 33942a9..796baa5 100644
--- a/crates/gilti/src/ui.rs
+++ b/crates/gilti/src/ui.rs
@@ -74,8 +74,8 @@ struct Repo {
owner: String,
owner_url: String,
idle: Option<Age>,
- log_url: String,
- tree_url: String,
+ log_url: Option<String>,
+ tree_url: Option<String>,
}
#[derive(Deserialize)]
@@ -130,7 +130,7 @@ fn render_repolist(page: RepoList) -> Markup {
}
@if shell.css.is_empty() {
- link rel="stylesheet" type="text/css" href="/cgit.css";
+ link rel="stylesheet" type="text/css" href="/-/assets/cgit.css";
}
@for css in &shell.css {
@@ -140,7 +140,7 @@ fn render_repolist(page: RepoList) -> Markup {
}
@if shell.js.is_empty() {
- script type="text/javascript" src="/cgit.js" {}
+ script type="text/javascript" src="/-/assets/cgit.js" {}
}
@for js in &shell.js {
@@ -219,7 +219,7 @@ fn page_content(page: &RepoList, footer_inside: bool) -> Markup {
td { a href=(&repo.url) { (&repo.description.text) @if repo.description.truncated { "..." } } }
@if page.owner_enabled { td { a href=(&repo.owner_url) { (&repo.owner) } } }
td { @if let Some(age) = &repo.idle { span class=(if age.unit == "minutes" { "age-mins".to_owned() } else { format!("age-{}", age.unit) }) data-ut=(age.timestamp) title=(&age.title) { (format!("{:.0}", age.amount)) " " (if age.unit == "minutes" { "min." } else { &age.unit }) } } }
- @if page.links_enabled { td { a class="button" href=(&repo.url) { "summary" } a class="button" href=(&repo.log_url) { "log" } a class="button" href=(&repo.tree_url) { "tree" } } }
+ @if page.links_enabled { td { a class="button" href=(&repo.url) { "summary" } @if let Some(url) = &repo.log_url { a class="button" href=(url) { "log" } } @if let Some(url) = &repo.tree_url { a class="button" href=(url) { "tree" } } } }
},
}
}
diff --git a/tests/smoke.sh b/tests/smoke.sh
index 0ed91da..460d79a 100755
--- a/tests/smoke.sh
+++ b/tests/smoke.sh
@@ -56,7 +56,7 @@ start() {
"$image" >/dev/null
i=0
- until curl -fsS "http://127.0.0.1:$http_port/healthz" >/dev/null 2>&1; do
+ until curl -fsS "http://127.0.0.1:$http_port/-/health" >/dev/null 2>&1; do
i=$((i + 1))
if [ "$i" -ge 60 ]; then
"$engine" logs "$name" >&2
@@ -109,30 +109,30 @@ for expected in \
exit 1
}
done
-[ "$(curl -fsS "http://127.0.0.1:$http_port/healthz")" = ok ] || {
+[ "$(curl -fsS "http://127.0.0.1:$http_port/-/health")" = '{"status":"ok"}' ] || {
echo 'unexpected health response' >&2
exit 1
}
status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$http_port/")
-[ "$status" = 403 ] || {
- echo "POST to cgit returned HTTP $status instead of 403" >&2
+[ "$status" = 405 ] || {
+ echo "POST to repository browser returned HTTP $status instead of 405" >&2
exit 1
}
-curl -fsS "http://127.0.0.1:$http_port/cgit.css" | grep -q 'cgit'
-content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.css" |
+curl -fsS "http://127.0.0.1:$http_port/-/assets/cgit.css" | grep -q 'cgit'
+content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/cgit.css" |
awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }')
[ "$content_type" = text/css ] || {
echo "unexpected cgit.css content type: $content_type" >&2
exit 1
}
-curl -fsS "http://127.0.0.1:$http_port/cgit.js" | grep -q 'function'
-content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.js" |
+curl -fsS "http://127.0.0.1:$http_port/-/assets/cgit.js" | grep -q 'function'
+content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/cgit.js" |
awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }')
[ "$content_type" = text/javascript ] || {
echo "unexpected cgit.js content type: $content_type" >&2
exit 1
}
-curl -fsSI "http://127.0.0.1:$http_port/healthz" >/dev/null
+curl -fsSI "http://127.0.0.1:$http_port/-/health" >/dev/null
# shellcheck disable=SC2016 # Expanded by the shell inside the container.
httpd_uid=$("$engine" exec "$name" sh -c '
@@ -245,7 +245,26 @@ until curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'testing'; do
[ "$i" -lt 30 ] || { "$engine" logs "$name" >&2; exit 1; }
sleep 1
done
-cache_url="http://127.0.0.1:$http_port/testing/"
+curl -fsS "http://127.0.0.1:$http_port/testing" | grep -q 'Initial commit'
+curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/tree/README%2emd" | grep -q 'Testing'
+summary_status=$(curl -sS -o /dev/null -w '%{http_code}:%{redirect_url}' \
+ "http://127.0.0.1:$http_port/testing/+/summary")
+[ "$summary_status" = "308:http://127.0.0.1:$http_port/testing" ] || {
+ echo "unexpected summary redirect: $summary_status" >&2
+ exit 1
+}
+GIT_CONFIG_GLOBAL=/dev/null git clone -q \
+ "http://127.0.0.1:$http_port/testing.git" "$work/testing-http-clone"
+[ -f "$work/testing-http-clone/README.md" ]
+lfs_oid=2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881
+lfs_response=$(printf '{"operation":"download","objects":[{"oid":"%s","size":1}]}' "$lfs_oid" |
+ curl -fsS -H 'Content-Type: application/vnd.git-lfs+json' --data-binary @- \
+ "http://127.0.0.1:$http_port/testing.git/info/lfs/objects/batch")
+printf '%s' "$lfs_response" | grep -q '"code":404' || {
+ echo 'unexpected LFS batch response' >&2
+ exit 1
+}
+cache_url="http://127.0.0.1:$http_port/testing"
curl -fsS -D "$work/cache-1.headers" -o /dev/null "$cache_url"
sleep 2
curl -fsS -D "$work/cache-2.headers" -o /dev/null "$cache_url"