diff options
| author | Nikolay Govorov <me@govorov.online> | 2026-08-27 00:26:01 +0100 |
|---|---|---|
| committer | Nikolay Govorov <me@govorov.online> | 2026-08-27 00:26:01 +0100 |
| commit | c87253a7d0075b5c136cad451aa10fb8f211babb (patch) | |
| tree | e09e18003cc0ce66d2ba7332815fe9f2c491648a | |
| parent | 02737cdb123aa8afca77223386e7fc5c7fa79675 (diff) | |
| download | tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip | |
Transfers the main page layout to typed rust
Diffstat
| -rw-r--r-- | Cargo.lock | 87 | +85 −2 |
| -rw-r--r-- | Cargo.toml | 3 | +3 −0 |
| -rw-r--r-- | cgit/cgit.css | 5 | +5 −0 |
| -rw-r--r-- | cgit/html.c | 17 | +17 −0 |
| -rw-r--r-- | cgit/html.h | 1 | +1 −0 |
| -rw-r--r-- | cgit/json.c | 118 | +118 −0 |
| -rw-r--r-- | cgit/json.h | 16 | +16 −0 |
| -rw-r--r-- | cgit/ui-blob.c | 2 | +1 −1 |
| -rw-r--r-- | cgit/ui-repolist.c | 354 | +235 −119 |
| -rw-r--r-- | cgit/ui-summary.c | 8 | +4 −4 |
| -rw-r--r-- | crates/gilti/Cargo.toml | 3 | +3 −0 |
| -rw-r--r-- | crates/gilti/src/main.rs | 118 | +117 −1 |
| -rw-r--r-- | crates/gilti/src/ui.rs | 296 | +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("&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("<repo>")); + assert!(markup.contains("x&y...")); + assert!(markup.contains("href=\"/?url=repo/log/&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()); + } +} |
