aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat
-rw-r--r--Cargo.lock87+85 −2
-rw-r--r--Cargo.toml3+3 −0
-rw-r--r--cgit/cgit.css5+5 −0
-rw-r--r--cgit/html.c17+17 −0
-rw-r--r--cgit/html.h1+1 −0
-rw-r--r--cgit/json.c118+118 −0
-rw-r--r--cgit/json.h16+16 −0
-rw-r--r--cgit/ui-blob.c2+1 −1
-rw-r--r--cgit/ui-repolist.c354+235 −119
-rw-r--r--cgit/ui-summary.c8+4 −4
-rw-r--r--crates/gilti/Cargo.toml3+3 −0
-rw-r--r--crates/gilti/src/main.rs118+117 −1
-rw-r--r--crates/gilti/src/ui.rs296+296 −0
13 files changed, 901 insertions, 127 deletions
diff --git a/Cargo.lock b/Cargo.lock
index e34a357..ba66bad 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -108,7 +108,10 @@ name = "gilti"
version = "0.1.0"
dependencies = [
"axum",
+ "maud",
"percent-encoding",
+ "serde",
+ "serde_json",
"tokio",
"tower",
]
@@ -219,6 +222,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
+name = "maud"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
+dependencies = [
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -263,6 +288,18 @@ dependencies = [
]
[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "version_check",
+]
+
+[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -272,6 +309,16 @@ dependencies = [
]
[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -288,7 +335,20 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.4",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
]
[[package]]
@@ -325,6 +385,17 @@ dependencies = [
[[package]]
name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
@@ -364,7 +435,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.4",
]
[[package]]
@@ -401,6 +472,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -420,3 +497,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index 5af0365..6fb0143 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,6 +20,9 @@ libc = "0.2.172"
percent-encoding = "2.3.2"
tokio = { version = "1.44.2", features = ["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"
[profile.release]
codegen-units = 1
diff --git a/cgit/cgit.css b/cgit/cgit.css
index ab9067b..1153711 100644
--- a/cgit/cgit.css
+++ b/cgit/cgit.css
@@ -262,6 +262,11 @@ div#cgit div#summary {
margin-bottom: 1em;
}
+div#cgit div#summary pre {
+ margin: 0;
+ white-space: pre-wrap;
+}
+
div#cgit table#downloads {
float: right;
border-collapse: collapse;
diff --git a/cgit/html.c b/cgit/html.c
index 60d16dd..bef58df 100644
--- a/cgit/html.c
+++ b/cgit/html.c
@@ -332,6 +332,23 @@ int html_include(const char *filename)
return 0;
}
+int html_include_text(const char *filename)
+{
+ FILE *f;
+ char buf[4096];
+ size_t len;
+
+ if (!(f = fopen(filename, "r"))) {
+ fprintf(stderr, "[cgit] Failed to include file %s: %s (%d).\n",
+ filename, strerror(errno), errno);
+ return -1;
+ }
+ while ((len = fread(buf, 1, sizeof(buf), f)) > 0)
+ html_ntxt(buf, len);
+ fclose(f);
+ return 0;
+}
+
void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value))
{
const char *t = txt;
diff --git a/cgit/html.h b/cgit/html.h
index 65b5aa1..7cedb45 100644
--- a/cgit/html.h
+++ b/cgit/html.h
@@ -35,6 +35,7 @@ extern void html_link_open(const char *url, const char *title, const char *class
extern void html_link_close(void);
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));
diff --git a/cgit/json.c b/cgit/json.c
new file mode 100644
--- /dev/null
+++ b/cgit/json.c
@@ -0,0 +1,118 @@
+/* SPDX-FileCopyrightText: 2026 Nikolay Govorov */
+/* SPDX-License-Identifier: GPL-2.0-only */
+
+#include "json.h"
+#include "cgit.h"
+
+void json(const char *raw) {
+ if (write(STDOUT_FILENO, raw, strlen(raw)) != strlen(raw))
+ die_errno("write error on json output");
+}
+
+void jsonf(const char *format, ...) {
+ va_list args;
+ struct strbuf buf = STRBUF_INIT;
+
+ va_start(args, format);
+ strbuf_vaddf(&buf, format, args);
+ va_end(args);
+ json(buf.buf);
+ strbuf_release(&buf);
+}
+
+static int utf8_len(const unsigned char *p) {
+ int len;
+
+ if (p[0] < 0x80) {
+ return 1;
+ }
+
+ if (p[0] >= 0xc2 && p[0] <= 0xdf) {
+ len = 2;
+ } else if (p[0] >= 0xe0 && p[0] <= 0xef) {
+ len = 3;
+ } else if (p[0] >= 0xf0 && p[0] <= 0xf4) {
+ len = 4;
+ } else {
+ return 0;
+ }
+
+ if (!p[1] || (len > 2 && !p[2]) || (len > 3 && !p[3])) {
+ return 0;
+ }
+
+ if ((p[1] & 0xc0) != 0x80 || (len > 2 && (p[2] & 0xc0) != 0x80) ||
+ (len > 3 && (p[3] & 0xc0) != 0x80)) {
+ return 0;
+ }
+
+ if ((p[0] == 0xe0 && p[1] < 0xa0) || (p[0] == 0xed && p[1] > 0x9f) ||
+ (p[0] == 0xf0 && p[1] < 0x90) || (p[0] == 0xf4 && p[1] > 0x8f)) {
+ return 0;
+ }
+
+ return len;
+}
+
+void json_value(const char *value) {
+ const unsigned char *p = (const unsigned char *)(value ? value : "");
+
+ json("\"");
+ while (*p) {
+ int len;
+ switch (*p) {
+ case '\"':
+ json("\\\"");
+ p++;
+ break;
+ case '\\':
+ json("\\\\");
+ p++;
+ break;
+ case '\b':
+ json("\\b");
+ p++;
+ break;
+ case '\f':
+ json("\\f");
+ p++;
+ break;
+ case '\n':
+ json("\\n");
+ p++;
+ break;
+ case '\r':
+ json("\\r");
+ p++;
+ break;
+ case '\t':
+ json("\\t");
+ p++;
+ break;
+ default:
+ if (*p < 0x20) {
+ jsonf("\\u%04x", *p++);
+ } else if (*p < 0x80) {
+ char ch[] = {*p++, '\0'};
+ json(ch);
+ } else if ((len = utf8_len(p))) {
+ if (write(STDOUT_FILENO, p, len) != len)
+ die_errno("write error on json output");
+ p += len;
+ } else {
+ json("\xef\xbf\xbd");
+ p++;
+ }
+ }
+ }
+ json("\"");
+}
+
+void json_key(const char *key) {
+ json_value(key);
+ json(":");
+}
+
+void json_int(intmax_t value) { jsonf("%" PRIdMAX, value); }
+
+void json_bool(int value) { json(value ? "true" : "false"); }
diff --git a/cgit/json.h b/cgit/json.h
new file mode 100644
--- /dev/null
+++ b/cgit/json.h
@@ -0,0 +1,16 @@
+/* SPDX-FileCopyrightText: 2026 Nikolay Govorov */
+/* SPDX-License-Identifier: GPL-2.0-only */
+
+#ifndef JSON_H
+#define JSON_H
+
+#include "cgit.h"
+
+extern void json(const char *raw);
+extern void jsonf(const char *format, ...);
+extern void json_key(const char *key);
+extern void json_value(const char *value);
+extern void json_int(intmax_t value);
+extern void json_bool(int value);
+
+#endif
diff --git a/cgit/ui-blob.c b/cgit/ui-blob.c
index 6ead9f7..fab1dd9 100644
--- a/cgit/ui-blob.c
+++ b/cgit/ui-blob.c
@@ -110,7 +110,7 @@ int cgit_print_file(char *path, const char *head, int file_only)
if (!buf)
return -1;
buf[size] = '\0';
- html_raw(buf, size);
+ html_ntxt(buf, size);
free(buf);
return 0;
}
diff --git a/cgit/ui-repolist.c b/cgit/ui-repolist.c
index faf3f5f..5f2da3d 100644
--- a/cgit/ui-repolist.c
+++ b/cgit/ui-repolist.c
@@ -13,7 +13,9 @@
#include "cgit.h"
#include "ui-repolist.h"
#include "html.h"
+#include "json.h"
#include "ui-shared.h"
+#include "version.h"
static time_t read_agefile(const char *path)
{
@@ -79,13 +81,6 @@ end:
return (r->mtime != 0);
}
-static void print_modtime(struct cgit_repo *repo)
-{
- time_t t;
- if (get_repo_modtime(repo, &t))
- cgit_print_age(t, 0, -1);
-}
-
static int is_match(struct cgit_repo *repo)
{
if (!ctx.qry.search)
@@ -130,49 +125,6 @@ static int any_repos_visible(void)
return 0;
}
-static void print_sort_header(const char *title, const char *sort)
-{
- char *currenturl = cgit_currenturl();
- html("<th class='left'><a href='");
- html_attr(currenturl);
- htmlf("?s=%s", sort);
- if (ctx.qry.search) {
- html("&amp;q=");
- html_url_arg(ctx.qry.search);
- }
- htmlf("'>%s</a></th>", title);
- free(currenturl);
-}
-
-static void print_header(void)
-{
- html("<tr class='nohover'>");
- print_sort_header("Name", "name");
- print_sort_header("Description", "desc");
- if (ctx.cfg.enable_index_owner)
- print_sort_header("Owner", "owner");
- print_sort_header("Idle", "idle");
- if (ctx.cfg.enable_index_links)
- html("<th class='left'>Links</th>");
- html("</tr>\n");
-}
-
-
-static void print_pager(int items, int pagelen, char *search, char *sort)
-{
- int i, ofs;
- char *class = NULL;
- html("<ul class='pager'>");
- for (i = 0, ofs = 0; ofs < items; i++, ofs = i * pagelen) {
- class = (ctx.qry.ofs == ofs) ? "current" : NULL;
- html("<li>");
- cgit_index_link(fmt("[%d]", i + 1), fmt("Page %d", i + 1),
- class, search, sort, ofs, 0);
- html("</li>");
- }
- html("</ul>");
-}
-
static int cmp(const char *s1, const char *s2)
{
if (s1 && s2) {
@@ -269,99 +221,263 @@ static int sort_repolist(char *field)
}
+static void json_include(const char *path)
+{
+ struct strbuf content = STRBUF_INIT;
+
+ if (!path || strbuf_read_file(&content, path, 0) < 0)
+ json("null");
+ else
+ json_value(content.buf);
+ strbuf_release(&content);
+}
+
+static void json_string_list(const struct string_list *list)
+{
+ struct string_list_item *item;
+ int first = 1;
+
+ json("[");
+ for_each_string_list_item(item, list) {
+ if (!first)
+ json(",");
+ json_value(item->string);
+ first = 0;
+ }
+ json("]");
+}
+
+static void url_arg(struct strbuf *url, const char *value)
+{
+ const unsigned char *p = (const unsigned char *)(value ? value : "");
+
+ for (; *p; p++) {
+ if (isalnum(*p) || strchr("!$()*,./:;@-[]_~", *p))
+ strbuf_addch(url, *p);
+ else if (*p == ' ')
+ strbuf_addch(url, '+');
+ else
+ strbuf_addf(url, "%%%02x", *p);
+ }
+}
+
+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))
+ strbuf_addch(url, *p);
+ else
+ strbuf_addf(url, "%%%02x", *p);
+ }
+}
+
+static char *sort_url(const char *sort)
+{
+ char *currenturl = cgit_currenturl();
+ struct strbuf url = STRBUF_INIT;
+
+ strbuf_addf(&url, "%s?s=%s", currenturl, sort);
+ free(currenturl);
+ if (ctx.qry.search) {
+ strbuf_addstr(&url, "&q=");
+ url_arg(&url, ctx.qry.search);
+ }
+ return strbuf_detach(&url, NULL);
+}
+
+static char *pager_url(const char *search, const char *sort, int ofs)
+{
+ char *currenturl = cgit_currenturl();
+ struct strbuf url = STRBUF_INIT;
+ const char *delimiter = "?";
+
+ strbuf_addstr(&url, currenturl);
+ free(currenturl);
+ if (search) {
+ strbuf_addf(&url, "?q=%s", search);
+ delimiter = "&";
+ }
+ if (sort) {
+ strbuf_addf(&url, "%ss=%s", delimiter, sort);
+ delimiter = "&";
+ }
+ if (ofs)
+ strbuf_addf(&url, "%sofs=%d", delimiter, ofs);
+ return strbuf_detach(&url, NULL);
+}
+
+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 = "&";
+ }
+ if (query)
+ strbuf_addf(&url, "%s%s", delimiter, query);
+ return strbuf_detach(&url, NULL);
+}
+
+static void json_description(const char *description)
+{
+ size_t len = description ? strlen(description) : 0;
+ size_t shown = len;
+
+ if (shown > ctx.cfg.max_repodesc_len)
+ shown = ctx.cfg.max_repodesc_len;
+ char *text = xmemdupz(description ? description : "", shown);
+ json("{");
+ json_key("text");
+ json_value(text);
+ free(text);
+ json(",");
+ json_key("truncated");
+ json_bool(len > shown);
+ json("}");
+}
+
+static void json_age(struct cgit_repo *repo)
+{
+ time_t t, now, seconds;
+ const char *unit;
+ double amount;
+
+ if (!get_repo_modtime(repo, &t)) {
+ json("null");
+ return;
+ }
+ time(&now);
+ seconds = now > t ? now - t : 0;
+ if (seconds < TM_HOUR * 2) { unit = "minutes"; amount = seconds * 1.0 / TM_MIN; }
+ else if (seconds < TM_DAY * 2) { unit = "hours"; amount = seconds * 1.0 / TM_HOUR; }
+ else if (seconds < TM_WEEK * 2) { unit = "days"; amount = seconds * 1.0 / TM_DAY; }
+ else if (seconds < TM_MONTH * 2) { unit = "weeks"; amount = seconds * 1.0 / TM_WEEK; }
+ else if (seconds < TM_YEAR * 2) { unit = "months"; amount = seconds * 1.0 / TM_MONTH; }
+ else { unit = "years"; amount = seconds * 1.0 / TM_YEAR; }
+ json("{");
+ json_key("timestamp"); json_int(t); json(",");
+ json_key("title"); json_value(show_date(t, 0, cgit_date_mode(DATE_ISO8601))); json(",");
+ json_key("unit"); json_value(unit); json(",");
+ json_key("amount"); jsonf("%.0f", amount);
+ json("}");
+}
+
+static void json_shell(void)
+{
+ json_key("shell"); json("{");
+ json_key("embedded"); json_bool(ctx.cfg.embedded); json(",");
+ json_key("robots"); json_value(ctx.cfg.robots); json(",");
+ json_key("css"); json_string_list(&ctx.cfg.css); json(",");
+ json_key("js"); json_string_list(&ctx.cfg.js); json(",");
+ json_key("favicon"); json_value(ctx.cfg.favicon); json(",");
+ json_key("head_include"); json_include(ctx.cfg.head_include); json(",");
+ json_key("header"); json_include(ctx.cfg.header); json(",");
+ json_key("footer_configured"); json_bool(ctx.cfg.footer != NULL); json(",");
+ json_key("footer"); json_include(ctx.cfg.footer); json(",");
+ json_key("logo"); json_value(ctx.cfg.logo); json(",");
+ json_key("logo_link"); json_value(ctx.cfg.logo_link); json(",");
+ json_key("cgit_version"); json_value(cgit_version); json(",");
+ json_key("git_version"); json_value(git_version_string); json(",");
+ json_key("generated_at"); json_value(show_date(time(NULL), 0, cgit_date_mode(DATE_ISO8601)));
+ json("}");
+}
+
void cgit_print_repolist(void)
{
- int i, columns = 3, hits = 0, header = 0;
+ int i, hits = 0, sorted = 0, first = 1;
char *last_section = NULL;
- char *section;
- char *repourl;
- int sorted = 0;
if (!any_repos_visible()) {
cgit_print_error_page(404, "Not found", "No repositories found");
return;
}
-
- if (ctx.cfg.enable_index_links)
- ++columns;
- if (ctx.cfg.enable_index_owner)
- ++columns;
-
ctx.page.title = ctx.cfg.root_title;
+ ctx.page.mimetype = "application/vnd.gilti.repolist+json";
+ ctx.page.charset = NULL;
cgit_print_http_headers();
- cgit_print_docstart();
- cgit_print_pageheader();
if (ctx.qry.sort)
sorted = sort_repolist(ctx.qry.sort);
else if (ctx.cfg.section_sort)
sort_repolist("section");
- html("<table summary='repository list' class='list nowrap'>");
+ json("{");
+ json_key("page"); json_value("repolist"); json(",");
+ 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("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(",");
+ json_key("root_readme"); json_bool(ctx.cfg.root_readme != NULL); json(",");
+ json_key("owner_enabled"); json_bool(ctx.cfg.enable_index_owner); json(",");
+ json_key("links_enabled"); json_bool(ctx.cfg.enable_index_links); json(",");
+ json_key("section_grouping"); json_bool(!sorted); json(",");
+ json_shell(); json(",");
+ json_key("sort_urls"); json("{");
+ { const char *names[] = {"name", "desc", "owner", "idle"}; for (i = 0; i < 4; i++) { char *url = sort_url(names[i]); if (i) json(","); json_key(names[i]); json_value(url); free(url); } }
+ json("},");
+ json_key("rows"); json("[");
for (i = 0; i < cgit_repolist.count; i++) {
- ctx.repo = &cgit_repolist.repos[i];
- if (!is_visible(ctx.repo))
- continue;
+ struct cgit_repo *repo = &cgit_repolist.repos[i];
+ char *section, *url;
+ if (!is_visible(repo)) continue;
hits++;
- if (hits <= ctx.qry.ofs)
- continue;
- if (hits > ctx.qry.ofs + ctx.cfg.max_repo_count)
- continue;
- if (!header++)
- print_header();
- section = ctx.repo->section;
- if (section && !strcmp(section, ""))
- section = NULL;
+ if (hits <= ctx.qry.ofs || hits > ctx.qry.ofs + ctx.cfg.max_repo_count) continue;
+ section = repo->section && *repo->section ? repo->section : NULL;
if (!sorted &&
((last_section == NULL && section != NULL) ||
- (last_section != NULL && section == NULL) ||
- (last_section != NULL && section != NULL &&
- strcmp(section, last_section)))) {
- htmlf("<tr class='nohover-highlight'><td colspan='%d' class='reposection'>",
- columns);
- html_txt(section);
- html("</td></tr>");
+ (last_section != NULL && section == NULL) ||
+ (last_section != NULL && section != NULL &&
+ strcmp(section, last_section)))) {
+ if (!first) json(",");
+ json("{"); json_key("section");
+ if (section) json_value(section); else json("null");
+ json("}");
+ first = 0;
last_section = section;
}
- htmlf("<tr><td class='%s'>",
- !sorted && section ? "sublevel-repo" : "toplevel-repo");
- cgit_summary_link(ctx.repo->name, NULL, NULL, NULL);
- html("</td><td>");
- repourl = cgit_repourl(ctx.repo->url);
- html_link_open(repourl, NULL, NULL);
- free(repourl);
- if (html_ntxt(ctx.repo->desc, ctx.cfg.max_repodesc_len) < 0)
- html("...");
- html_link_close();
- html("</td><td>");
- if (ctx.cfg.enable_index_owner) {
- char *currenturl = cgit_currenturl();
- html("<a href='");
- html_attr(currenturl);
- html("?q=");
- html_url_arg(ctx.repo->owner);
- html("'>");
- html_txt(ctx.repo->owner);
- html("</a>");
- free(currenturl);
- html("</td><td>");
- }
- print_modtime(ctx.repo);
- html("</td>");
- if (ctx.cfg.enable_index_links) {
- html("<td>");
- cgit_summary_link("summary", NULL, "button", NULL);
- cgit_log_link("log", NULL, "button", NULL, NULL, NULL,
- 0, NULL, NULL, ctx.qry.showmsg, 0);
- cgit_tree_link("tree", NULL, "button", NULL, NULL, NULL);
- html("</td>");
- }
- html("</tr>\n");
+ if (!first) json(",");
+ json("{");
+ json_key("name"); json_value(repo->name); json(",");
+ json_key("section"); if (section) json_value(section); else json("null"); json(",");
+ json_key("url"); url = repo_url(repo, NULL, NULL); json_value(url); free(url); json(",");
+ json_key("description"); json_description(repo->desc); json(",");
+ 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);
+ json("}"); first = 0;
}
- html("</table>");
+ json("],");
+ json_key("pager"); json("[");
if (hits > ctx.cfg.max_repo_count)
- print_pager(hits, ctx.cfg.max_repo_count, ctx.qry.search, ctx.qry.sort);
- cgit_print_docend();
+ for (i = 0; i * ctx.cfg.max_repo_count < hits; i++) { char *url = pager_url(ctx.qry.search, ctx.qry.sort, i * ctx.cfg.max_repo_count); if (i) json(","); json("{"); json_key("url"); json_value(url); free(url); json(","); json_key("current"); json_bool(ctx.qry.ofs == i * ctx.cfg.max_repo_count); json("}"); }
+ json("]}");
}
void cgit_print_site_readme(void)
diff --git a/cgit/ui-summary.c b/cgit/ui-summary.c
index 31567ec..7a81a35 100644
--- a/cgit/ui-summary.c
+++ b/cgit/ui-summary.c
@@ -132,14 +132,14 @@ void cgit_print_repo_readme(const char *path)
goto done;
}
- /* Print the calculated readme from the git repo or filesystem. */
- html("<div id='summary'>");
+ /* Print the calculated readme from the git repo or filesystem as text. */
+ html("<div id='summary'><pre>");
if (ref)
cgit_print_file(filename, ref, 1);
else
- html_include(filename);
+ html_include_text(filename);
- html("</div>");
+ html("</pre></div>");
if (free_filename)
free(filename);
diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml
index 7722322..5752260 100644
--- a/crates/gilti/Cargo.toml
+++ b/crates/gilti/Cargo.toml
@@ -18,3 +18,6 @@ axum.workspace = true
percent-encoding.workspace = true
tokio.workspace = true
tower.workspace = true
+maud.workspace = true
+serde.workspace = true
+serde_json.workspace = true
diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs
index 591b88e..1e323b5 100644
--- a/crates/gilti/src/main.rs
+++ b/crates/gilti/src/main.rs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
mod cgi;
+mod ui;
const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080";
const MAX_CACHE_SECONDS: u64 = 3600;
@@ -191,8 +192,9 @@ async fn proxy_to_cgit(
}
request.extensions_mut().insert(cgi::RemoteAddr(remote));
+ let method = request.method().clone();
match tower::ServiceExt::oneshot(state.cgit.clone(), request).await {
- Ok(response) => response,
+ 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")
@@ -200,6 +202,46 @@ async fn proxy_to_cgit(
}
}
+async fn render_private_page(
+ method: axum::http::Method,
+ response: axum::response::Response,
+) -> axum::response::Response {
+ let private_page = response
+ .headers()
+ .get(axum::http::header::CONTENT_TYPE)
+ .is_some_and(|value| value.as_bytes() == ui::PRIVATE_CONTENT_TYPE.as_bytes());
+ if !private_page {
+ return response;
+ }
+
+ let (mut parts, body) = response.into_parts();
+ parts.headers.insert(
+ axum::http::header::CONTENT_TYPE,
+ axum::http::HeaderValue::from_static("text/html; charset=UTF-8"),
+ );
+ parts.headers.remove(axum::http::header::CONTENT_LENGTH);
+ if method == axum::http::Method::HEAD {
+ return axum::http::response::Response::from_parts(parts, axum::body::Body::empty());
+ }
+ let body = match axum::body::to_bytes(body, usize::MAX).await {
+ Ok(body) => body,
+ Err(error) => {
+ eprintln!("gilti: cannot read private page response: {error}");
+ return plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n");
+ }
+ };
+ match ui::render(&body) {
+ Ok(markup) => axum::http::response::Response::from_parts(
+ parts,
+ axum::body::Body::from(markup.into_string()),
+ ),
+ Err(error) => {
+ eprintln!("gilti: invalid private page response: {error}");
+ plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n")
+ }
+ }
+}
+
fn response(
status: axum::http::StatusCode,
content_type: &'static str,
@@ -237,4 +279,78 @@ mod tests {
assert!(super::prepare_cgit_config("cache=3601\n").is_err());
assert!(super::prepare_cgit_config("cache=forever\n").is_err());
}
+
+ #[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 response = axum::http::Response::builder()
+ .status(axum::http::StatusCode::OK)
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ super::ui::PRIVATE_CONTENT_TYPE,
+ )
+ .header(axum::http::header::CACHE_CONTROL, "max-age=60")
+ .header(axum::http::header::CONTENT_LENGTH, model.len())
+ .body(axum::body::Body::from(model.as_slice()))
+ .unwrap();
+ let response = super::render_private_page(axum::http::Method::GET, response).await;
+ assert_eq!(
+ response.headers()[axum::http::header::CONTENT_TYPE],
+ "text/html; charset=UTF-8"
+ );
+ assert_eq!(
+ response.headers()[axum::http::header::CACHE_CONTROL],
+ "max-age=60"
+ );
+ assert!(
+ !response
+ .headers()
+ .contains_key(axum::http::header::CONTENT_LENGTH)
+ );
+ let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .unwrap();
+ assert!(
+ std::str::from_utf8(&body)
+ .unwrap()
+ .contains("repository list")
+ );
+
+ let response = axum::http::Response::builder()
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ super::ui::PRIVATE_CONTENT_TYPE,
+ )
+ .header(axum::http::header::CACHE_CONTROL, "max-age=60")
+ .body(axum::body::Body::empty())
+ .unwrap();
+ let response = super::render_private_page(axum::http::Method::HEAD, response).await;
+ assert_eq!(
+ response.headers()[axum::http::header::CONTENT_TYPE],
+ "text/html; charset=UTF-8"
+ );
+ assert!(
+ axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .unwrap()
+ .is_empty()
+ );
+ }
+
+ #[tokio::test]
+ async fn malformed_private_page_fails_closed() {
+ let response = axum::http::Response::builder()
+ .header(
+ axum::http::header::CONTENT_TYPE,
+ super::ui::PRIVATE_CONTENT_TYPE,
+ )
+ .body(axum::body::Body::from("not json"))
+ .unwrap();
+ assert_eq!(
+ super::render_private_page(axum::http::Method::GET, response)
+ .await
+ .status(),
+ axum::http::StatusCode::BAD_GATEWAY
+ );
+ }
}
diff --git a/crates/gilti/src/ui.rs b/crates/gilti/src/ui.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti/src/ui.rs
@@ -0,0 +1,296 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use maud::{Markup, PreEscaped, html};
+use serde::Deserialize;
+
+pub const PRIVATE_CONTENT_TYPE: &str = "application/vnd.gilti.repolist+json";
+
+#[derive(Deserialize)]
+struct RepoList {
+ page: String,
+ title: String,
+ root_desc: String,
+ root_url: String,
+ about_url: String,
+ noheader: bool,
+ search: String,
+ current_url: String,
+ root_readme: bool,
+ owner_enabled: bool,
+ links_enabled: bool,
+ section_grouping: bool,
+ shell: Shell,
+ sort_urls: SortUrls,
+ rows: Vec<Row>,
+ pager: Vec<Pager>,
+}
+
+#[derive(Deserialize)]
+struct Shell {
+ embedded: bool,
+ robots: String,
+ css: Vec<String>,
+ js: Vec<String>,
+ favicon: String,
+ head_include: Option<String>,
+ header: Option<String>,
+ footer_configured: bool,
+ footer: Option<String>,
+ logo: String,
+ logo_link: String,
+ cgit_version: String,
+ git_version: String,
+ generated_at: String,
+}
+
+#[derive(Deserialize)]
+struct SortUrls {
+ name: String,
+ desc: String,
+ owner: String,
+ idle: String,
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum Row {
+ Section(Section),
+ Repo(Box<Repo>),
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Section {
+ section: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct Repo {
+ name: String,
+ section: Option<String>,
+ url: String,
+ description: Description,
+ owner: String,
+ owner_url: String,
+ idle: Option<Age>,
+ log_url: String,
+ tree_url: String,
+}
+
+#[derive(Deserialize)]
+struct Description {
+ text: String,
+ truncated: bool,
+}
+
+#[derive(Deserialize)]
+struct Age {
+ timestamp: i64,
+ title: String,
+ unit: String,
+ amount: f64,
+}
+
+#[derive(Deserialize)]
+struct Pager {
+ url: String,
+ current: bool,
+}
+
+pub fn render(body: &[u8]) -> Result<Markup, serde_json::Error> {
+ let page: RepoList = serde_json::from_slice(body)?;
+ if page.page != "repolist" {
+ return Err(serde_json::Error::io(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "unknown private page",
+ )));
+ }
+ Ok(render_repolist(page))
+}
+
+fn include(content: &Option<String>) -> Markup {
+ PreEscaped(content.clone().unwrap_or_default())
+}
+
+fn render_repolist(page: RepoList) -> Markup {
+ let shell = &page.shell;
+ html! {
+ @if shell.embedded {
+ (include(&shell.header))
+ } @else {
+ (maud::DOCTYPE)
+ html lang="en" {
+ head {
+ title { (&page.title) }
+ meta name="generator" content=(format!("cgit {}", shell.cgit_version));
+
+ @if !shell.robots.is_empty() {
+ meta name="robots" content=(&shell.robots);
+ }
+
+ @if shell.css.is_empty() {
+ link rel="stylesheet" type="text/css" href="/cgit.css";
+ }
+
+ @for css in &shell.css {
+ @if !css.is_empty() {
+ link rel="stylesheet" type="text/css" href=(css);
+ }
+ }
+
+ @if shell.js.is_empty() {
+ script type="text/javascript" src="/cgit.js" {}
+ }
+
+ @for js in &shell.js {
+ @if !js.is_empty() {
+ script type="text/javascript" src=(js) {}
+ }
+ }
+
+ @if !shell.favicon.is_empty() {
+ link rel="shortcut icon" href=(&shell.favicon);
+ }
+
+ (include(&shell.head_include))
+ }
+ body {
+ (include(&shell.header))
+ (page_content(&page, true))
+ }
+ }
+ }
+ @if shell.embedded {
+ (page_content(&page, false))
+ (footer(shell, true))
+ }
+ }
+}
+
+fn page_content(page: &RepoList, footer_inside: bool) -> Markup {
+ let shell = &page.shell;
+ let has_repos = page.rows.iter().any(|row| matches!(row, Row::Repo(_)));
+ html! {
+ div id="cgit" {
+ @if !page.noheader {
+ table id="header" {
+ tr {
+ @if !shell.logo.is_empty() {
+ td class="logo" rowspan="2" {
+ a href=(if shell.logo_link.is_empty() { &page.root_url } else { &shell.logo_link }) {
+ img src=(&shell.logo) alt="cgit logo";
+ }
+ }
+ }
+ td class="main" { (&page.title) }
+ }
+ tr { td class="sub" { (&page.root_desc) } }
+ }
+ }
+ table class="tabs" { tr {
+ td {
+ a href=(&page.root_url) class="active" { "index" }
+ @if page.root_readme { a href=(&page.about_url) { "about" } }
+ }
+ td class="form" {
+ form method="get" action=(&page.current_url) {
+ input type="search" name="q" size="10" value=(&page.search);
+ input type="submit" value="search";
+ }
+ }
+ }}
+ div class="content" {
+ table summary="repository list" class="list nowrap" {
+ @if has_repos { tr class="nohover" {
+ th class="left" { a href=(&page.sort_urls.name) { "Name" } }
+ th class="left" { a href=(&page.sort_urls.desc) { "Description" } }
+ @if page.owner_enabled { th class="left" { a href=(&page.sort_urls.owner) { "Owner" } } }
+ th class="left" { a href=(&page.sort_urls.idle) { "Idle" } }
+ @if page.links_enabled { th class="left" { "Links" } }
+ }}
+ @for row in &page.rows {
+ @match row {
+ Row::Section(section) => tr class="nohover-highlight" {
+ td colspan=(3 + usize::from(page.owner_enabled) + usize::from(page.links_enabled)) class="reposection" { (section.section.as_deref().unwrap_or("")) }
+ },
+ Row::Repo(repo) => tr {
+ td class=(if page.section_grouping && repo.section.is_some() { "sublevel-repo" } else { "toplevel-repo" }) { a href=(&repo.url) { (&repo.name) } }
+ 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.pager.is_empty() { ul class="pager" { @for (index, item) in page.pager.iter().enumerate() { li { a href=(&item.url) class=[item.current.then_some("current")] title=(format!("Page {}", index + 1)) { "[" (index + 1) "]" } } } } }
+ }
+ @if footer_inside { (footer(&page.shell, false)) }
+ }
+ }
+}
+
+fn footer(shell: &Shell, embedded: bool) -> Markup {
+ if let Some(footer) = &shell.footer {
+ return PreEscaped(footer.clone());
+ }
+ if embedded || shell.footer_configured {
+ return Markup::default();
+ }
+ html! { div class="footer" { "generated by " a href="https://git.zx2c4.com/cgit/about/" { "cgit " (&shell.cgit_version) } " (" a href="https://git-scm.com/" { "git " (&shell.git_version) } ") at " (&shell.generated_at) } }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn renders_shell_and_escapes_data() {
+ let markup = super::render(br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":false,"search":"<x>","current_url":"/","root_readme":true,"owner_enabled":true,"links_enabled":true,"section_grouping":true,"shell":{"embedded":false,"robots":"noindex","css":["/custom.css"],"js":["/custom.js"],"favicon":"/favicon.ico","head_include":"<meta name='x'>","header":"ignored","footer_configured":false,"footer":null,"logo":"/cgit.png","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":[{"name":"<repo>","section":"group","url":"/?url=repo","description":{"text":"x&y","truncated":true},"owner":"o","owner_url":"/?q=o","idle":{"timestamp":1,"title":"date","unit":"years","amount":1},"log_url":"/?url=repo/log/&showmsg=1","tree_url":"/?url=repo/tree/"}],"pager":[]}"#).unwrap().into_string();
+ assert!(markup.contains("&lt;repo&gt;"));
+ assert!(markup.contains("x&amp;y..."));
+ assert!(markup.contains("href=\"/?url=repo/log/&amp;showmsg=1\""));
+ assert!(markup.contains("/custom.css"));
+ assert!(markup.contains("cgit.png"));
+ assert!(markup.contains("name='x'"));
+ }
+
+ #[test]
+ fn empty_rows_have_no_column_header() {
+ let markup = super::render(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":[{"url":"/?ofs=0","current":false}]}"#).unwrap().into_string();
+ assert!(!markup.contains("<tr class=\"nohover\">"));
+ }
+
+ #[test]
+ fn footer_respects_embedded_and_unreadable_include_modes() {
+ let mut shell = super::Shell {
+ embedded: false,
+ robots: String::new(),
+ css: Vec::new(),
+ js: Vec::new(),
+ favicon: String::new(),
+ head_include: None,
+ header: None,
+ footer_configured: false,
+ footer: None,
+ logo: String::new(),
+ logo_link: String::new(),
+ cgit_version: "v1".into(),
+ git_version: "2".into(),
+ generated_at: "now".into(),
+ };
+ assert!(super::footer(&shell, true).into_string().is_empty());
+ shell.footer_configured = true;
+ assert!(super::footer(&shell, false).into_string().is_empty());
+ shell.footer = Some("<footer>configured</footer>".into());
+ assert!(
+ super::footer(&shell, true)
+ .into_string()
+ .contains("<footer>configured</footer>")
+ );
+ }
+
+ #[test]
+ fn rejects_invalid_model() {
+ assert!(super::render(br#"{"page":"repolist"}"#).is_err());
+ }
+}