From 8df52a40a0bc160ba2949211eb2cc1dd79ed4d51 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Sat, 29 Aug 2026 02:27:44 +0100 Subject: Migrate base html views to git2-rs --- .github/workflows/build.yml | 1 + Cargo.lock | 113 +++++ Cargo.toml | 2 + Dockerfile | 7 +- cgit/cgit.c | 206 ++++---- cgit/cgit.h | 32 -- cgit/cmd.c | 131 +---- cgit/cmd.h | 3 +- cgit/json.c | 118 ----- cgit/json.h | 16 - cgit/scan-tree.c | 210 -------- cgit/scan-tree.h | 5 - cgit/shared.c | 33 +- cgit/ui-blame.c | 312 ------------ cgit/ui-blame.h | 10 - cgit/ui-blob.c | 188 -------- cgit/ui-blob.h | 12 - cgit/ui-clone.c | 142 ------ cgit/ui-clone.h | 12 - cgit/ui-plain.c | 213 -------- cgit/ui-plain.h | 10 - cgit/ui-refs.c | 221 --------- cgit/ui-refs.h | 12 - cgit/ui-repolist.c | 480 ------------------- cgit/ui-repolist.h | 11 - cgit/ui-shared.h | 2 +- cgit/ui-summary.c | 148 ------ cgit/ui-summary.h | 11 - cgit/ui-tag.c | 130 ----- cgit/ui-tag.h | 10 - cgit/ui-tree.c | 400 ---------------- cgit/ui-tree.h | 10 - crates/gilti-ssh/Cargo.toml | 1 + crates/gilti/Cargo.toml | 2 + crates/gilti/src/lfs.rs | 13 +- crates/gilti/src/main.rs | 324 +++++-------- crates/gilti/src/models/about.rs | 31 ++ crates/gilti/src/models/archive_signature.rs | 31 ++ crates/gilti/src/models/blame.rs | 114 +++++ crates/gilti/src/models/mod.rs | 33 ++ crates/gilti/src/models/object.rs | 87 ++++ crates/gilti/src/models/overview.rs | 122 +++++ crates/gilti/src/models/refs.rs | 196 ++++++++ crates/gilti/src/models/repositories.rs | 181 +++++++ crates/gilti/src/models/repository.rs | 81 ++++ crates/gilti/src/models/revision.rs | 34 ++ crates/gilti/src/models/tag.rs | 112 +++++ crates/gilti/src/models/tree.rs | 250 ++++++++++ crates/gilti/src/ui.rs | 296 ------------ crates/gilti/src/views/about.rs | 36 ++ crates/gilti/src/views/archive_signature.rs | 53 ++ crates/gilti/src/views/blame.rs | 112 +++++ crates/gilti/src/views/mod.rs | 39 ++ crates/gilti/src/views/object.rs | 52 ++ crates/gilti/src/views/overview.rs | 155 ++++++ crates/gilti/src/views/refs.rs | 87 ++++ crates/gilti/src/views/repositories.rs | 170 +++++++ crates/gilti/src/views/shared.rs | 126 +++++ crates/gilti/src/views/tag.rs | 76 +++ crates/gilti/src/views/tree.rs | 293 +++++++++++ 60 files changed, 2843 insertions(+), 3475 deletions(-) delete mode 100644 cgit/json.c delete mode 100644 cgit/json.h delete mode 100644 cgit/scan-tree.c delete mode 100644 cgit/scan-tree.h delete mode 100644 cgit/ui-blame.c delete mode 100644 cgit/ui-blame.h delete mode 100644 cgit/ui-blob.c delete mode 100644 cgit/ui-blob.h delete mode 100644 cgit/ui-clone.c delete mode 100644 cgit/ui-clone.h delete mode 100644 cgit/ui-plain.c delete mode 100644 cgit/ui-plain.h delete mode 100644 cgit/ui-refs.c delete mode 100644 cgit/ui-refs.h delete mode 100644 cgit/ui-repolist.c delete mode 100644 cgit/ui-repolist.h delete mode 100644 cgit/ui-summary.c delete mode 100644 cgit/ui-summary.h delete mode 100644 cgit/ui-tag.c delete mode 100644 cgit/ui-tag.h delete mode 100644 cgit/ui-tree.c delete mode 100644 cgit/ui-tree.h create mode 100644 crates/gilti/src/models/about.rs create mode 100644 crates/gilti/src/models/archive_signature.rs create mode 100644 crates/gilti/src/models/blame.rs create mode 100644 crates/gilti/src/models/mod.rs create mode 100644 crates/gilti/src/models/object.rs create mode 100644 crates/gilti/src/models/overview.rs create mode 100644 crates/gilti/src/models/refs.rs create mode 100644 crates/gilti/src/models/repositories.rs create mode 100644 crates/gilti/src/models/repository.rs create mode 100644 crates/gilti/src/models/revision.rs create mode 100644 crates/gilti/src/models/tag.rs create mode 100644 crates/gilti/src/models/tree.rs delete mode 100644 crates/gilti/src/ui.rs create mode 100644 crates/gilti/src/views/about.rs create mode 100644 crates/gilti/src/views/archive_signature.rs create mode 100644 crates/gilti/src/views/blame.rs create mode 100644 crates/gilti/src/views/mod.rs create mode 100644 crates/gilti/src/views/object.rs create mode 100644 crates/gilti/src/views/overview.rs create mode 100644 crates/gilti/src/views/refs.rs create mode 100644 crates/gilti/src/views/repositories.rs create mode 100644 crates/gilti/src/views/shared.rs create mode 100644 crates/gilti/src/views/tag.rs create mode 100644 crates/gilti/src/views/tree.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b388d20..9253edd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,6 +81,7 @@ jobs: apk add --no-cache \ build-base=0.5-r3 \ cargo=1.87.0-r1 \ + cmake \ rust=1.87.0-r1 \ git=2.49.1-r0 \ python3 \ diff --git a/Cargo.lock b/Cargo.lock index 542fa41..0e053dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "block-buffer" version = "0.10.4" @@ -69,6 +75,18 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -114,6 +132,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "futures-channel" version = "0.3.34" @@ -157,11 +181,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "gilti" version = "0.1.0" dependencies = [ "axum", + "git2", + "libc", "maud", "percent-encoding", "serde", @@ -178,6 +215,18 @@ dependencies = [ "libc", ] +[[package]] +name = "git2" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", +] + [[package]] name = "http" version = "1.5.0" @@ -264,12 +313,52 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libgit2-sys" +version = "0.18.8+1.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "matchit" version = "0.8.4" @@ -333,6 +422,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -363,6 +458,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "serde" version = "1.0.229" @@ -417,6 +518,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -543,6 +650,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 14294b4..9cedeae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ resolver = "3" [workspace.package] publish = false edition = "2024" +rust-version = "1.87" version = "0.1.0" license = "AGPL-3.0-or-later" authors = ["Nikolay Govorov "] @@ -16,6 +17,7 @@ repository = "https://github.com/dimidiumlabs/gilti" [workspace.dependencies] axum = { version = "0.8.4", default-features = false, features = ["http1", "tokio"] } +git2 = { version = "0.21.0", default-features = false, features = ["unstable-sha256", "vendored-libgit2"] } libc = "0.2.172" percent-encoding = "2.3.2" tokio = { version = "1.44.2", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal"] } diff --git a/Dockerfile b/Dockerfile index bd6957a..9db5a5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,12 +19,17 @@ LABEL org.opencontainers.image.title="Gilti" \ org.opencontainers.image.licenses="AGPL-3.0-or-later" RUN apk add --no-cache \ + bzip2=1.0.8-r6 \ git=2.49.1-r0 \ + git-daemon=2.49.1-r0 \ libgcc=14.2.0-r6 \ + lzip=1.25-r0 \ openssh-keygen=10.0_p1-r10 \ openssh-server=10.0_p1-r10 \ su-exec=0.2-r3 \ - tini=0.19.0-r3 && \ + tini=0.19.0-r3 \ + xz=5.8.1-r0 \ + zstd=1.5.7-r0 && \ addgroup -S -g 10000 git && \ install -d -m 0755 -o root -g root /var/lib/gilti && \ adduser -S -D -u 10000 -G git -h /var/lib/gilti/git -s /bin/sh git && \ diff --git a/cgit/cgit.c b/cgit/cgit.c index dbf8a0f..344ff55 100644 --- a/cgit/cgit.c +++ b/cgit/cgit.c @@ -17,9 +17,6 @@ #include "cmd.h" #include "html.h" #include "ui-shared.h" -#include "ui-blob.h" -#include "ui-summary.h" -#include "scan-tree.h" const char *cgit_version = CGIT_VERSION; @@ -93,26 +90,17 @@ static void prepare_context(void) const char *value; memset(&ctx, 0, sizeof(ctx)); - ctx.cfg.agefile = config_string("CGIT_AGEFILE"); - ctx.cfg.branch_sort = config_integer("CGIT_BRANCH_SORT"); - ctx.cfg.case_sensitive_sort = config_integer("CGIT_CASE_SENSITIVE_SORT"); ctx.cfg.clone_prefix = config_optional_string("CGIT_CLONE_PREFIX"); ctx.cfg.clone_url = config_optional_string("CGIT_CLONE_URL"); ctx.cfg.commit_sort = config_integer("CGIT_COMMIT_SORT"); ctx.cfg.difftype = config_integer("CGIT_DIFFTYPE"); ctx.cfg.embedded = config_integer("CGIT_EMBEDDED"); - ctx.cfg.enable_blame = config_integer("CGIT_ENABLE_BLAME"); ctx.cfg.enable_commit_graph = config_integer("CGIT_ENABLE_COMMIT_GRAPH"); ctx.cfg.enable_follow_links = config_integer("CGIT_ENABLE_FOLLOW_LINKS"); - ctx.cfg.enable_html_serving = config_integer("CGIT_ENABLE_HTML_SERVING"); - ctx.cfg.enable_http_clone = config_integer("CGIT_ENABLE_HTTP_CLONE"); - ctx.cfg.enable_index_links = config_integer("CGIT_ENABLE_INDEX_LINKS"); - ctx.cfg.enable_index_owner = config_integer("CGIT_ENABLE_INDEX_OWNER"); ctx.cfg.enable_log_filecount = config_integer("CGIT_ENABLE_LOG_FILECOUNT"); ctx.cfg.enable_log_linecount = config_integer("CGIT_ENABLE_LOG_LINECOUNT"); ctx.cfg.enable_remote_branches = config_integer("CGIT_ENABLE_REMOTE_BRANCHES"); ctx.cfg.enable_subject_links = config_integer("CGIT_ENABLE_SUBJECT_LINKS"); - ctx.cfg.enable_tree_linenumbers = config_integer("CGIT_ENABLE_TREE_LINENUMBERS"); ctx.cfg.favicon = config_string("CGIT_FAVICON"); ctx.cfg.footer = config_optional_string("CGIT_FOOTER"); ctx.cfg.head_include = config_optional_string("CGIT_HEAD_INCLUDE"); @@ -121,34 +109,21 @@ static void prepare_context(void) ctx.cfg.logo = config_string("CGIT_LOGO"); ctx.cfg.logo_link = config_optional_string("CGIT_LOGO_LINK"); ctx.cfg.max_atom_items = config_integer("CGIT_MAX_ATOM_ITEMS"); - ctx.cfg.max_blob_size = config_integer("CGIT_MAX_BLOB_SIZE"); ctx.cfg.max_commit_count = config_integer("CGIT_MAX_COMMIT_COUNT"); ctx.cfg.max_msg_len = config_integer("CGIT_MAX_MESSAGE_LENGTH"); - ctx.cfg.max_repo_count = config_integer("CGIT_MAX_REPO_COUNT"); - ctx.cfg.max_repodesc_len = config_integer("CGIT_MAX_REPODESC_LENGTH"); ctx.cfg.max_stats = config_integer("CGIT_MAX_STATS"); ctx.cfg.mimetype_file = config_optional_string("CGIT_MIMETYPE_FILE"); ctx.cfg.module_link = config_optional_string("CGIT_MODULE_LINK"); ctx.cfg.noheader = config_integer("CGIT_NOHEADER"); ctx.cfg.noplainemail = config_integer("CGIT_NOPLAINEMAIL"); - ctx.cfg.remove_suffix = config_integer("CGIT_REMOVE_SUFFIX"); cgit_default_repo_desc = config_string("CGIT_REPO_DEFAULT_DESC"); ctx.cfg.renamelimit = config_integer("CGIT_RENAMELIMIT"); - ctx.cfg.repository_sort = config_string("CGIT_REPOSITORY_SORT"); ctx.cfg.robots = config_string("CGIT_ROBOTS"); ctx.cfg.root_desc = config_string("CGIT_ROOT_DESC"); - ctx.cfg.root_readme = config_optional_string("CGIT_ROOT_README"); ctx.cfg.root_title = config_string("CGIT_ROOT_TITLE"); - ctx.cfg.scan_hidden_path = config_integer("CGIT_SCAN_HIDDEN_PATH"); ctx.cfg.script_name = config_string("SCRIPT_NAME"); ctx.cfg.section = config_string("CGIT_SECTION"); - ctx.cfg.section_from_path = config_integer("CGIT_SECTION_FROM_PATH"); - ctx.cfg.section_sort = config_integer("CGIT_SECTION_SORT"); ctx.cfg.snapshots = config_integer("CGIT_SNAPSHOTS"); - ctx.cfg.strict_export = config_optional_string("CGIT_STRICT_EXPORT"); - ctx.cfg.summary_branches = config_integer("CGIT_SUMMARY_BRANCHES"); - ctx.cfg.summary_log = config_integer("CGIT_SUMMARY_LOG"); - ctx.cfg.summary_tags = config_integer("CGIT_SUMMARY_TAGS"); ctx.cfg.virtual_root = ensure_end(config_value("CGIT_VIRTUAL_ROOT"), '/'); string_list_init_dup(&ctx.cfg.css); string_list_init_dup(&ctx.cfg.js); @@ -206,8 +181,30 @@ static void prepare_request(void) 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 void prepare_repository(void) +{ + char *description = NULL; + char *description_path; + char *separator; + struct passwd *owner; + struct stat stat; + size_t size; + + ctx.repo = cgit_add_repo(ctx.qry.repo); + ctx.repo->path = config_string("GILTI_REPOSITORY_PATH"); + description_path = fmtalloc("%s/description", ctx.repo->path); + if (!read_first_line(description_path, &description, &size)) + ctx.repo->desc = description; + free(description_path); + if (!lstat(ctx.repo->path, &stat) && (owner = getpwuid(stat.st_uid))) { + ctx.repo->owner = strdup_first_line(owner->pw_gecos && *owner->pw_gecos ? + owner->pw_gecos : owner->pw_name); + separator = strchr(ctx.repo->owner, ','); + if (separator) + *separator = '\0'; + } } static char *guess_defbranch(void) @@ -222,83 +219,93 @@ static char *guess_defbranch(void) return xstrdup(ref); } -/* The caller must free filename and ref after calling this. */ -static inline void parse_readme(const char *readme, char **filename, char **ref, struct cgit_repo *repo) +struct walk_tree_context { + const char *match_path; + unsigned int found_path:1; +}; + +static int find_path(const struct object_id *oid UNUSED, struct strbuf *base, + const char *pathname, unsigned mode, void *cbdata) +{ + struct walk_tree_context *walk_tree_ctx = cbdata; + + if (!S_ISREG(mode)) + return READ_TREE_RECURSIVE; + if (strncmp(base->buf, walk_tree_ctx->match_path, base->len) || + strcmp(walk_tree_ctx->match_path + base->len, pathname)) + return READ_TREE_RECURSIVE; + walk_tree_ctx->found_path = 1; + return 0; +} + +static int ref_path_exists(const char *path, const char *ref) +{ + struct object_id oid; + unsigned long size; + struct pathspec_item path_item = { + .match = xstrdup(path), + .len = strlen(path) + }; + struct pathspec paths = { + .nr = 1, + .items = &path_item + }; + struct walk_tree_context walk_tree_ctx = { + .match_path = path, + .found_path = 0 + }; + + if (!repo_get_oid(the_repository, ref, &oid) && + odb_read_object_info(the_repository->objects, &oid, &size) == OBJ_COMMIT) + read_tree(the_repository, + repo_get_commit_tree(the_repository, + lookup_commit_reference(the_repository, &oid)), + &paths, find_path, &walk_tree_ctx); + free(path_item.match); + return walk_tree_ctx.found_path; +} + +static void parse_readme(const char *readme, char **filename, char **ref) { const char *colon; *filename = NULL; *ref = NULL; - - if (!readme || !readme[0]) + if (!readme || !*readme) return; - - /* Check if the readme is tracked in the git repo. */ colon = strchr(readme, ':'); - if (colon && strlen(colon) > 1) { - /* If it starts with a colon, we want to use head given - * from query or the default branch */ - if (colon == readme && ctx.qry.head) - *ref = xstrdup(ctx.qry.head); - else if (colon == readme && repo->defbranch) - *ref = xstrdup(repo->defbranch); + if (colon && colon[1]) { + if (colon == readme) + *ref = xstrdup(ctx.qry.head ? ctx.qry.head : ctx.repo->defbranch); else *ref = xstrndup(readme, colon - readme); readme = colon + 1; } - - /* Prepend repo path to relative readme path unless tracked. */ - if (!(*ref) && readme[0] != '/') - *filename = fmtalloc("%s/%s", repo->path, readme); - else - *filename = xstrdup(readme); + *filename = *ref || readme[0] == '/' ? xstrdup(readme) : + fmtalloc("%s/%s", ctx.repo->path, readme); } -static void choose_readme(struct cgit_repo *repo) + +static void choose_readme(void) { - int found; char *filename, *ref; struct string_list_item *entry; - if (!repo->readme.nr) - return; - - found = 0; - for_each_string_list_item(entry, &repo->readme) { - parse_readme(entry->string, &filename, &ref, repo); - if (!filename) { - free(filename); - free(ref); - continue; - } - if (ref) { - if (cgit_ref_path_exists(filename, ref, 1)) { - found = 1; - break; - } - } - else if (!access(filename, R_OK)) { - found = 1; - break; + for_each_string_list_item(entry, &ctx.repo->readme) { + parse_readme(entry->string, &filename, &ref); + if ((ref && ref_path_exists(filename, ref)) || + (!ref && !access(filename, R_OK))) { + ctx.repo->readme.strdup_strings = 1; + string_list_clear(&ctx.repo->readme, 0); + ctx.repo->readme.strdup_strings = 0; + string_list_append(&ctx.repo->readme, filename)->util = ref; + return; } free(filename); free(ref); } - repo->readme.strdup_strings = 1; - string_list_clear(&repo->readme, 0); - repo->readme.strdup_strings = 0; - if (found) - string_list_append(&repo->readme, filename)->util = ref; -} - -static void print_no_repo_clone_urls(const char *url) -{ - html(""); - html_txt(url); - html("\n"); + ctx.repo->readme.strdup_strings = 1; + string_list_clear(&ctx.repo->readme, 0); + ctx.repo->readme.strdup_strings = 0; } static void prepare_repo_env(int *nongit) @@ -341,25 +348,13 @@ static int prepare_repo_cmd(int nongit) ctx.qry.head = xstrdup("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"); - html("\n"); - cgit_prepare_repo_env(ctx.repo); - cgit_add_clone_urls(print_no_repo_clone_urls); - html("
 
Clone
\n"); - cgit_print_docend(); + cgit_print_error_page(404, "Not found", + "Invalid revision: %s", ctx.qry.head); return 1; } string_list_sort(&ctx.repo->submodules); cgit_prepare_repo_env(ctx.repo); - choose_readme(ctx.repo); + choose_readme(); return 0; } @@ -383,12 +378,6 @@ static void process_request(void) return; } - if (!ctx.cfg.enable_http_clone && cmd->is_clone) { - ctx.page.title = "cgit error"; - cgit_print_error_page(404, "Not found", "Invalid request"); - return; - } - if (cmd->want_repo && !ctx.repo) { cgit_print_error_page(400, "Bad request", "No repository selected"); @@ -418,12 +407,9 @@ int cmd_main(int argc UNUSED, const char **argv UNUSED) set_die_routine(cgit_die_routine); prepare_context(); - cgit_repolist.length = 0; - cgit_repolist.count = 0; - cgit_repolist.repos = NULL; - - scan_tree(config_value("CGIT_SCAN_PATH")); prepare_request(); + if (ctx.qry.repo) + prepare_repository(); process_request(); return 0; } diff --git a/cgit/cgit.h b/cgit/cgit.h index 818970f..fedec59 100644 --- a/cgit/cgit.h +++ b/cgit/cgit.h @@ -90,16 +90,13 @@ struct cgit_repo { char *logo_link; char *snapshot_prefix; int snapshots; - int enable_blame; int enable_commit_graph; int enable_follow_links; int enable_log_filecount; int enable_log_linecount; int enable_remote_branches; int enable_subject_links; - int enable_html_serving; int max_stats; - int branch_sort; int commit_sort; time_t mtime; struct string_list submodules; @@ -107,12 +104,6 @@ struct cgit_repo { int ignore; }; -struct cgit_repolist { - int length; - int count; - struct cgit_repo *repos; -}; - struct commitinfo { struct commit *commit; char *author; @@ -178,7 +169,6 @@ struct cgit_query { }; struct cgit_config { - char *agefile; char *clone_prefix; char *clone_url; char *favicon; @@ -197,44 +187,24 @@ struct cgit_config { char *root_readme; char *script_name; char *section; - char *repository_sort; char *virtual_root; /* Always ends with '/'. */ - char *strict_export; - int case_sensitive_sort; int embedded; int enable_follow_links; - int enable_http_clone; - int enable_index_links; - int enable_index_owner; - int enable_blame; int enable_commit_graph; int enable_log_filecount; int enable_log_linecount; int enable_remote_branches; int enable_subject_links; - int enable_html_serving; - int enable_tree_linenumbers; int local_time; int max_atom_items; - int max_repo_count; int max_commit_count; int max_msg_len; - int max_repodesc_len; - int max_blob_size; int max_stats; int noplainemail; int noheader; int renamelimit; - int remove_suffix; - int scan_hidden_path; - int section_from_path; int snapshots; - int section_sort; - int summary_branches; - int summary_log; - int summary_tags; diff_type difftype; - int branch_sort; int commit_sort; struct string_list mimetypes; struct string_list js; @@ -280,13 +250,11 @@ struct cgit_snapshot_format { extern const char *cgit_version; -extern struct cgit_repolist cgit_repolist; extern struct cgit_context ctx; extern const struct cgit_snapshot_format cgit_snapshot_formats[]; extern char *cgit_default_repo_desc; extern struct cgit_repo *cgit_add_repo(const char *url); -extern struct cgit_repo *cgit_get_repoinfo(const char *url); extern int chk_zero(int result, char *msg); extern int chk_positive(int result, char *msg); diff --git a/cgit/cmd.c b/cgit/cmd.c index 43a9036..024b115 100644 --- a/cgit/cmd.c +++ b/cgit/cmd.c @@ -15,70 +15,21 @@ #include "cmd.h" #include "ui-shared.h" #include "ui-atom.h" -#include "ui-blame.h" -#include "ui-blob.h" -#include "ui-clone.h" #include "ui-commit.h" #include "ui-diff.h" #include "ui-log.h" #include "ui-patch.h" -#include "ui-plain.h" -#include "ui-refs.h" -#include "ui-repolist.h" #include "ui-snapshot.h" #include "ui-stats.h" -#include "ui-summary.h" -#include "ui-tag.h" -#include "ui-tree.h" - -static void HEAD_fn(void) -{ - cgit_clone_head(); -} static void atom_fn(void) { cgit_print_atom(ctx.qry.head, ctx.qry.path, ctx.cfg.max_atom_items); } -static void about_fn(void) -{ - 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) -{ - if (ctx.repo->enable_blame) - cgit_print_blame(); - else - cgit_print_error_page(403, "Forbidden", "Blame is disabled"); -} - -static void blob_fn(void) -{ - cgit_print_blob(ctx.qry.oid, ctx.qry.path, ctx.qry.head, 0); -} - -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); + cgit_print_commit(ctx.qry.oid, NULL); } static void diff_fn(void) @@ -91,11 +42,6 @@ static void rawdiff_fn(void) cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 1); } -static void info_fn(void) -{ - cgit_clone_info(); -} - static void log_fn(void) { cgit_print_log(ctx.qry.oid, ctx.qry.ofs, ctx.cfg.max_commit_count, @@ -104,31 +50,11 @@ static void log_fn(void) ctx.repo->commit_sort); } -static void objects_fn(void) -{ - cgit_clone_objects(); -} - -static void repolist_fn(void) -{ - cgit_print_repolist(); -} - static void patch_fn(void) { cgit_print_patch(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path); } -static void plain_fn(void) -{ - cgit_print_plain(); -} - -static void refs_fn(void) -{ - cgit_print_refs(); -} - static void snapshot_fn(void) { char *filename; @@ -148,58 +74,23 @@ static void stats_fn(void) cgit_show_stats(); } -static void summary_fn(void) -{ - cgit_print_summary(); -} - -static void tag_fn(void) -{ - cgit_print_tag(ctx.qry.oid); -} - -static void tree_fn(void) -{ - cgit_print_tree(ctx.qry.oid, ctx.qry.path); -} - -#define def_cmd(name, want_repo, want_vpath, is_clone) \ - {#name, name##_fn, want_repo, want_vpath, is_clone} +#define def_cmd(name, want_repo, want_vpath) \ + {#name, name##_fn, want_repo, want_vpath} struct cgit_cmd *cgit_get_cmd(void) { static struct cgit_cmd cmds[] = { - def_cmd(HEAD, 1, 0, 1), - def_cmd(atom, 1, 0, 0), - def_cmd(about, 0, 0, 0), - def_cmd(blame, 1, 1, 0), - def_cmd(blob, 1, 0, 0), - def_cmd(commit, 1, 1, 0), - def_cmd(diff, 1, 1, 0), - def_cmd(info, 1, 0, 1), - def_cmd(log, 1, 1, 0), - def_cmd(objects, 1, 0, 1), - def_cmd(patch, 1, 1, 0), - 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), - def_cmd(summary, 1, 0, 0), - def_cmd(tag, 1, 0, 0), - def_cmd(tree, 1, 1, 0), + def_cmd(atom, 1, 0), + def_cmd(diff, 1, 1), + def_cmd(log, 1, 1), + def_cmd(patch, 1, 1), + def_cmd(rawdiff, 1, 1), + def_cmd(revision, 1, 0), + def_cmd(snapshot, 1, 0), + def_cmd(stats, 1, 1), }; int i; - if (ctx.qry.page == NULL) { - if (ctx.repo) - ctx.qry.page = "summary"; - else - ctx.qry.page = "repolist"; - } - for (i = 0; i < sizeof(cmds)/sizeof(*cmds); i++) if (!strcmp(ctx.qry.page, cmds[i].name)) return &cmds[i]; diff --git a/cgit/cmd.h b/cgit/cmd.h index 24377e8..21c1798 100644 --- a/cgit/cmd.h +++ b/cgit/cmd.h @@ -11,8 +11,7 @@ struct cgit_cmd { const char *name; cgit_cmd_fn fn; unsigned int want_repo:1, - want_vpath:1, - is_clone:1; + want_vpath:1; }; extern struct cgit_cmd *cgit_get_cmd(void); diff --git a/cgit/json.c b/cgit/json.c deleted file mode 100644 index 461fe2f..0000000 --- a/cgit/json.c +++ /dev/null @@ -1,118 +0,0 @@ -/* 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 deleted file mode 100644 index 60368ee..0000000 --- a/cgit/json.h +++ /dev/null @@ -1,16 +0,0 @@ -/* 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/scan-tree.c b/cgit/scan-tree.c deleted file mode 100644 index bc29daa..0000000 --- a/cgit/scan-tree.c +++ /dev/null @@ -1,210 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* scan-tree.c - * - * Copyright (C) 2006-2014 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#include "cgit.h" -#include "scan-tree.h" -#include "html.h" - -/* return 1 if path contains a objects/ directory and a HEAD file */ -static int is_git_dir(const char *path) -{ - struct stat st; - struct strbuf pathbuf = STRBUF_INIT; - int result = 0; - - strbuf_addf(&pathbuf, "%s/objects", path); - if (stat(pathbuf.buf, &st)) { - if (errno != ENOENT) - fprintf(stderr, "Error checking path %s: %s (%d)\n", - path, strerror(errno), errno); - goto out; - } - if (!S_ISDIR(st.st_mode)) - goto out; - - strbuf_reset(&pathbuf); - strbuf_addf(&pathbuf, "%s/HEAD", path); - if (stat(pathbuf.buf, &st)) { - if (errno != ENOENT) - fprintf(stderr, "Error checking path %s: %s (%d)\n", - path, strerror(errno), errno); - goto out; - } - if (!S_ISREG(st.st_mode)) - goto out; - - result = 1; -out: - strbuf_release(&pathbuf); - return result; -} - -static char *xstrrchr(char *s, char *from, int c) -{ - while (from >= s && *from != c) - from--; - return from < s ? NULL : from; -} - -static void add_repo(const char *base, struct strbuf *path) -{ - struct stat st; - struct passwd *pwd; - struct cgit_repo *repo; - size_t pathlen; - struct strbuf rel = STRBUF_INIT; - char *p, *slash; - int n; - size_t size; - - if (stat(path->buf, &st)) { - fprintf(stderr, "Error accessing %s: %s (%d)\n", - path->buf, strerror(errno), errno); - return; - } - - strbuf_addch(path, '/'); - pathlen = path->len; - - if (ctx.cfg.strict_export) { - strbuf_addstr(path, ctx.cfg.strict_export); - if(stat(path->buf, &st)) - return; - strbuf_setlen(path, pathlen); - } - - strbuf_addstr(path, "noweb"); - if (!stat(path->buf, &st)) - return; - strbuf_setlen(path, pathlen); - - if (!starts_with(path->buf, base)) - strbuf_addbuf(&rel, path); - else - strbuf_addstr(&rel, path->buf + strlen(base) + 1); - - if (!strcmp(rel.buf + rel.len - 5, "/.git")) - strbuf_setlen(&rel, rel.len - 5); - else if (rel.len && rel.buf[rel.len - 1] == '/') - strbuf_setlen(&rel, rel.len - 1); - - repo = cgit_add_repo(rel.buf); - - if (ctx.cfg.remove_suffix) { - size_t urllen; - strip_suffix(repo->url, ".git", &urllen); - strip_suffix_mem(repo->url, &urllen, "/"); - repo->url[urllen] = '\0'; - } - repo->path = strdup_first_line(path->buf); - while (!repo->owner) { - if ((pwd = getpwuid(st.st_uid)) == NULL) { - fprintf(stderr, "Error reading owner-info for %s: %s (%d)\n", - path->buf, strerror(errno), errno); - break; - } - if (pwd->pw_gecos) - if ((p = strchr(pwd->pw_gecos, ','))) - *p = '\0'; - repo->owner = strdup_first_line(pwd->pw_gecos ? pwd->pw_gecos : pwd->pw_name); - } - - if (repo->desc == cgit_default_repo_desc || !repo->desc) { - strbuf_addstr(path, "description"); - if (!stat(path->buf, &st)) - read_first_line(path->buf, &repo->desc, &size); - strbuf_setlen(path, pathlen); - } - - if (ctx.cfg.section_from_path) { - n = ctx.cfg.section_from_path; - if (n > 0) { - slash = rel.buf - 1; - while (slash && n && (slash = strchr(slash + 1, '/'))) - n--; - } else { - slash = rel.buf + rel.len; - while (slash && n && (slash = xstrrchr(rel.buf, slash - 1, '/'))) - n++; - } - if (slash && !n) { - *slash = '\0'; - repo->section = strdup_first_line(rel.buf); - *slash = '/'; - if (starts_with(repo->name, repo->section)) { - repo->name += strlen(repo->section); - if (*repo->name == '/') - repo->name++; - } - } - } - - strbuf_release(&rel); -} - -static void scan_path(const char *base, const char *path) -{ - DIR *dir = opendir(path); - struct dirent *ent; - struct strbuf pathbuf = STRBUF_INIT; - size_t pathlen = strlen(path); - struct stat st; - - if (!dir) { - fprintf(stderr, "Error opening directory %s: %s (%d)\n", - path, strerror(errno), errno); - return; - } - - strbuf_add(&pathbuf, path, strlen(path)); - if (is_git_dir(pathbuf.buf)) { - add_repo(base, &pathbuf); - goto end; - } - strbuf_addstr(&pathbuf, "/.git"); - if (is_git_dir(pathbuf.buf)) { - add_repo(base, &pathbuf); - goto end; - } - /* - * Add one because we don't want to lose the trailing '/' when we - * reset the length of pathbuf in the loop below. - */ - pathlen++; - while ((ent = readdir(dir)) != NULL) { - if (ent->d_name[0] == '.') { - if (ent->d_name[1] == '\0') - continue; - if (ent->d_name[1] == '.' && ent->d_name[2] == '\0') - continue; - if (!ctx.cfg.scan_hidden_path) - continue; - } - strbuf_setlen(&pathbuf, pathlen); - strbuf_addstr(&pathbuf, ent->d_name); - if (stat(pathbuf.buf, &st)) { - fprintf(stderr, "Error checking path %s: %s (%d)\n", - pathbuf.buf, strerror(errno), errno); - continue; - } - if (S_ISDIR(st.st_mode)) - scan_path(base, pathbuf.buf); - } -end: - strbuf_release(&pathbuf); - closedir(dir); -} - -void scan_tree(const char *path) -{ - scan_path(path, path); -} diff --git a/cgit/scan-tree.h b/cgit/scan-tree.h deleted file mode 100644 index 626be7e..0000000 --- a/cgit/scan-tree.h +++ /dev/null @@ -1,5 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -extern void scan_tree(const char *path); diff --git a/cgit/shared.c b/cgit/shared.c index fa6b10f..dd86e24 100644 --- a/cgit/shared.c +++ b/cgit/shared.c @@ -14,7 +14,6 @@ #include "cgit.h" -struct cgit_repolist cgit_repolist; struct cgit_context ctx; int chk_zero(int result, char *msg) @@ -41,20 +40,8 @@ int chk_non_negative(int result, char *msg) char *cgit_default_repo_desc; struct cgit_repo *cgit_add_repo(const char *url) { - struct cgit_repo *ret; - - if (++cgit_repolist.count > cgit_repolist.length) { - if (cgit_repolist.length == 0) - cgit_repolist.length = 8; - else - cgit_repolist.length *= 2; - cgit_repolist.repos = xrealloc(cgit_repolist.repos, - cgit_repolist.length * - sizeof(struct cgit_repo)); - } + struct cgit_repo *ret = xcalloc(1, sizeof(*ret)); - ret = &cgit_repolist.repos[cgit_repolist.count-1]; - memset(ret, 0, sizeof(struct cgit_repo)); ret->url = trim_end(url, '/'); *strchrnul(ret->url, '\n') = '\0'; ret->name = ret->url; @@ -65,16 +52,13 @@ struct cgit_repo *cgit_add_repo(const char *url) ret->homepage = NULL; ret->section = ctx.cfg.section; ret->snapshots = ctx.cfg.snapshots; - ret->enable_blame = ctx.cfg.enable_blame; ret->enable_commit_graph = ctx.cfg.enable_commit_graph; ret->enable_follow_links = ctx.cfg.enable_follow_links; ret->enable_log_filecount = ctx.cfg.enable_log_filecount; ret->enable_log_linecount = ctx.cfg.enable_log_linecount; ret->enable_remote_branches = ctx.cfg.enable_remote_branches; ret->enable_subject_links = ctx.cfg.enable_subject_links; - ret->enable_html_serving = ctx.cfg.enable_html_serving; ret->max_stats = ctx.cfg.max_stats; - ret->branch_sort = ctx.cfg.branch_sort; ret->commit_sort = ctx.cfg.commit_sort; ret->module_link = ctx.cfg.module_link; ret->readme = ctx.cfg.readme; @@ -85,21 +69,6 @@ struct cgit_repo *cgit_add_repo(const char *url) return ret; } -struct cgit_repo *cgit_get_repoinfo(const char *url) -{ - int i; - struct cgit_repo *repo; - - for (i = 0; i < cgit_repolist.count; i++) { - repo = &cgit_repolist.repos[i]; - if (repo->ignore) - continue; - if (!strcmp(repo->url, url)) - return repo; - } - return NULL; -} - void cgit_free_commitinfo(struct commitinfo *info) { free(info->author); diff --git a/cgit/ui-blame.c b/cgit/ui-blame.c deleted file mode 100644 index e714256..0000000 --- a/cgit/ui-blame.c +++ /dev/null @@ -1,312 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-blame.c: functions for blame output - * - * Copyright (C) 2006-2017 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#define USE_THE_REPOSITORY_VARIABLE - -#include "cgit.h" -#include "ui-blame.h" -#include "html.h" -#include "ui-shared.h" -#include "strvec.h" -#include "blame.h" - - -static char *emit_suspect_detail(struct blame_origin *suspect) -{ - struct commitinfo *info; - struct strbuf detail = STRBUF_INIT; - - info = cgit_parse_commit(suspect->commit); - - strbuf_addf(&detail, "author %s", info->author); - if (!ctx.cfg.noplainemail) - strbuf_addf(&detail, " %s", info->author_email); - strbuf_addf(&detail, " %s\n", - show_date(info->author_date, info->author_tz, - cgit_date_mode(DATE_ISO8601))); - - strbuf_addf(&detail, "committer %s", info->committer); - if (!ctx.cfg.noplainemail) - strbuf_addf(&detail, " %s", info->committer_email); - strbuf_addf(&detail, " %s\n\n", - show_date(info->committer_date, info->committer_tz, - cgit_date_mode(DATE_ISO8601))); - - strbuf_addstr(&detail, info->subject); - - cgit_free_commitinfo(info); - return strbuf_detach(&detail, NULL); -} - -static void emit_blame_entry_hash(struct blame_entry *ent) -{ - struct blame_origin *suspect = ent->suspect; - struct object_id *oid = &suspect->commit->object.oid; - unsigned long line = 0; - - char *detail = emit_suspect_detail(suspect); - html(""); - cgit_commit_link(repo_find_unique_abbrev(the_repository, oid, DEFAULT_ABBREV), detail, - NULL, ctx.qry.head, oid_to_hex(oid), suspect->path); - html(""); - free(detail); - - if (!repo_parse_commit(the_repository, suspect->commit) && suspect->commit->parents) { - struct commit *parent = suspect->commit->parents->item; - - html(" "); - cgit_blame_link("^", "Blame the previous revision", NULL, - ctx.qry.head, oid_to_hex(&parent->object.oid), - suspect->path); - } - - while (line++ < ent->num_lines) - html("\n"); -} - -static void emit_blame_entry_linenumber(struct blame_entry *ent) -{ - const char *numberfmt = "%1$d\n"; - - unsigned long lineno = ent->lno; - while (lineno < ent->lno + ent->num_lines) - htmlf(numberfmt, ++lineno); -} - -static void emit_blame_entry_line_background(struct blame_scoreboard *sb, - struct blame_entry *ent) -{ - unsigned long line; - size_t len, maxlen = 2; - const char* pos, *endpos; - - for (line = ent->lno; line < ent->lno + ent->num_lines; line++) { - html("\n"); - pos = blame_nth_line(sb, line); - endpos = blame_nth_line(sb, line + 1); - len = 0; - while (pos < endpos) { - len++; - if (*pos++ == '\t') - len = (len + 7) & ~7; - } - if (len > maxlen) - maxlen = len; - } - - for (len = 0; len < maxlen - 1; len++) - html(" "); -} - -struct walk_tree_context { - char *curr_rev; - int match_baselen; - int state; -}; - -static void print_object(const struct object_id *oid, const char *path, - const char *rev) -{ - enum object_type type; - char *buf; - unsigned long size; - struct strvec rev_argv = STRVEC_INIT; - struct rev_info revs; - struct blame_scoreboard sb; - struct blame_origin *o; - struct blame_entry *ent = NULL; - - type = odb_read_object_info(the_repository->objects, oid, &size); - if (type == OBJ_BAD) { - cgit_print_error_page(404, "Not found", "Bad object name: %s", - oid_to_hex(oid)); - return; - } - - buf = odb_read_object(the_repository->objects, oid, &type, &size); - if (!buf) { - cgit_print_error_page(500, "Internal server error", - "Error reading object %s", oid_to_hex(oid)); - return; - } - - strvec_push(&rev_argv, "blame"); - strvec_push(&rev_argv, rev); - repo_init_revisions(the_repository, &revs, NULL); - revs.diffopt.flags.allow_textconv = 1; - setup_revisions(rev_argv.nr, rev_argv.v, &revs, NULL); - init_scoreboard(&sb); - sb.revs = &revs; - sb.repo = the_repository; - sb.path = path; - setup_scoreboard(&sb, &o); - o->suspects = blame_entry_prepend(NULL, 0, sb.num_lines, o); - prio_queue_put(&sb.commits, o->commit); - blame_origin_decref(o); - sb.ent = NULL; - sb.path = path; - assign_blame(&sb, 0); - blame_sort_final(&sb); - blame_coalesce(&sb); - - cgit_set_title_from_path(path); - - cgit_print_layout_start(); - htmlf("blob: %s (", oid_to_hex(oid)); - cgit_plain_link("plain", NULL, NULL, ctx.qry.head, rev, path); - html(") ("); - cgit_tree_link("tree", NULL, NULL, ctx.qry.head, rev, path); - html(")\n"); - - if (buffer_is_binary(buf, size)) { - html("
blob is binary.
"); - goto cleanup; - } - if (ctx.cfg.max_blob_size && size / 1024 > ctx.cfg.max_blob_size) { - htmlf("
blob size (%ldKB)" - " exceeds display size limit (%dKB).
", - size / 1024, ctx.cfg.max_blob_size); - goto cleanup; - } - - html("\n\n"); - - /* Commit hashes */ - html("\n"); - - /* Line numbers */ - if (ctx.cfg.enable_tree_linenumbers) { - html("\n"); - } - - html("\n"); - - html("\n
"); - for (ent = sb.ent; ent; ent = ent->next) { - html("
");
-		emit_blame_entry_hash(ent);
-		html("
"); - } - html("
"); - for (ent = sb.ent; ent; ent = ent->next) { - html("
");
-			emit_blame_entry_linenumber(ent);
-			html("
"); - } - html("
"); - - /* Colored bars behind lines */ - html("
"); - for (ent = sb.ent; ent; ) { - struct blame_entry *e = ent->next; - html("
");
-		emit_blame_entry_line_background(&sb, ent);
-		html("
"); - free(ent); - ent = e; - } - html("
"); - - free((void *)sb.final_buf); - - /* Lines */ - html("
");
-	html_txt(buf);
-	html("
"); - - html("
\n"); - - cgit_print_layout_end(); - -cleanup: - free(buf); -} - -static int walk_tree(const struct object_id *oid, struct strbuf *base, - const char *pathname, unsigned mode, void *cbdata) -{ - struct walk_tree_context *walk_tree_ctx = cbdata; - - if (base->len == walk_tree_ctx->match_baselen) { - if (S_ISREG(mode)) { - struct strbuf buffer = STRBUF_INIT; - strbuf_addbuf(&buffer, base); - strbuf_addstr(&buffer, pathname); - print_object(oid, buffer.buf, walk_tree_ctx->curr_rev); - strbuf_release(&buffer); - walk_tree_ctx->state = 1; - } else if (S_ISDIR(mode)) { - walk_tree_ctx->state = 2; - } - } else if (base->len < INT_MAX - && (int)base->len > walk_tree_ctx->match_baselen) { - walk_tree_ctx->state = 2; - } else if (S_ISDIR(mode)) { - return READ_TREE_RECURSIVE; - } - return 0; -} - -static int basedir_len(const char *path) -{ - const char *p = strrchr(path, '/'); - if (p) - return p - path + 1; - return 0; -} - -void cgit_print_blame(void) -{ - const char *rev = ctx.qry.oid; - struct object_id oid; - struct commit *commit; - struct pathspec_item path_items = { - .match = ctx.qry.path, - .len = ctx.qry.path ? strlen(ctx.qry.path) : 0 - }; - struct pathspec paths = { - .nr = 1, - .items = &path_items - }; - struct walk_tree_context walk_tree_ctx = { - .state = 0 - }; - - if (!rev) - rev = ctx.qry.head; - - if (repo_get_oid(the_repository, rev, &oid)) { - cgit_print_error_page(404, "Not found", - "Invalid revision name: %s", rev); - return; - } - commit = lookup_commit_reference(the_repository, &oid); - if (!commit || repo_parse_commit(the_repository, commit)) { - cgit_print_error_page(404, "Not found", - "Invalid commit reference: %s", rev); - return; - } - - walk_tree_ctx.curr_rev = xstrdup(rev); - walk_tree_ctx.match_baselen = (path_items.match) ? - basedir_len(path_items.match) : -1; - - read_tree(the_repository, repo_get_commit_tree(the_repository, commit), - &paths, walk_tree, &walk_tree_ctx); - if (!walk_tree_ctx.state) - cgit_print_error_page(404, "Not found", "Not found"); - else if (walk_tree_ctx.state == 2) - cgit_print_error_page(404, "No blame for folders", - "Blame is not available for folders."); - - free(walk_tree_ctx.curr_rev); -} diff --git a/cgit/ui-blame.h b/cgit/ui-blame.h deleted file mode 100644 index 280496a..0000000 --- a/cgit/ui-blame.h +++ /dev/null @@ -1,10 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_BLAME_H -#define UI_BLAME_H - -extern void cgit_print_blame(void); - -#endif /* UI_BLAME_H */ diff --git a/cgit/ui-blob.c b/cgit/ui-blob.c deleted file mode 100644 index fab1dd9..0000000 --- a/cgit/ui-blob.c +++ /dev/null @@ -1,188 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-blob.c: show blob content - * - * Copyright (C) 2006-2014 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#define USE_THE_REPOSITORY_VARIABLE - -#include "cgit.h" -#include "ui-blob.h" -#include "html.h" -#include "ui-shared.h" - -struct walk_tree_context { - const char *match_path; - struct object_id *matched_oid; - unsigned int found_path:1; - unsigned int file_only:1; -}; - -static int walk_tree(const struct object_id *oid, struct strbuf *base, - const char *pathname, unsigned mode, void *cbdata) -{ - struct walk_tree_context *walk_tree_ctx = cbdata; - - if (walk_tree_ctx->file_only && !S_ISREG(mode)) - return READ_TREE_RECURSIVE; - if (strncmp(base->buf, walk_tree_ctx->match_path, base->len) - || strcmp(walk_tree_ctx->match_path + base->len, pathname)) - return READ_TREE_RECURSIVE; - oidcpy(walk_tree_ctx->matched_oid, oid); - walk_tree_ctx->found_path = 1; - return 0; -} - -int cgit_ref_path_exists(const char *path, const char *ref, int file_only) -{ - struct object_id oid; - unsigned long size; - struct pathspec_item path_items = { - .match = xstrdup(path), - .len = strlen(path) - }; - struct pathspec paths = { - .nr = 1, - .items = &path_items - }; - struct walk_tree_context walk_tree_ctx = { - .match_path = path, - .matched_oid = &oid, - .found_path = 0, - .file_only = file_only - }; - - if (repo_get_oid(the_repository, ref, &oid)) - goto done; - if (odb_read_object_info(the_repository->objects, &oid, &size) != OBJ_COMMIT) - goto done; - read_tree(the_repository, - repo_get_commit_tree(the_repository, lookup_commit_reference(the_repository, &oid)), - &paths, walk_tree, &walk_tree_ctx); - -done: - free(path_items.match); - return walk_tree_ctx.found_path; -} - -int cgit_print_file(char *path, const char *head, int file_only) -{ - struct object_id oid; - enum object_type type; - char *buf; - unsigned long size; - struct commit *commit; - struct pathspec_item path_items = { - .match = path, - .len = strlen(path) - }; - struct pathspec paths = { - .nr = 1, - .items = &path_items - }; - struct walk_tree_context walk_tree_ctx = { - .match_path = path, - .matched_oid = &oid, - .found_path = 0, - .file_only = file_only - }; - - if (repo_get_oid(the_repository, head, &oid)) - return -1; - type = odb_read_object_info(the_repository->objects, &oid, &size); - if (type == OBJ_COMMIT) { - commit = lookup_commit_reference(the_repository, &oid); - read_tree(the_repository, repo_get_commit_tree(the_repository, commit), - &paths, walk_tree, &walk_tree_ctx); - if (!walk_tree_ctx.found_path) - return -1; - type = odb_read_object_info(the_repository->objects, &oid, &size); - } - if (type == OBJ_BAD) - return -1; - buf = odb_read_object(the_repository->objects, &oid, &type, &size); - if (!buf) - return -1; - buf[size] = '\0'; - html_ntxt(buf, size); - free(buf); - return 0; -} - -void cgit_print_blob(const char *hex, char *path, const char *head, int file_only) -{ - struct object_id oid; - enum object_type type; - char *buf; - unsigned long size; - struct commit *commit; - struct pathspec_item path_items = { - .match = path, - .len = path ? strlen(path) : 0 - }; - struct pathspec paths = { - .nr = 1, - .items = &path_items - }; - struct walk_tree_context walk_tree_ctx = { - .match_path = path, - .matched_oid = &oid, - .found_path = 0, - .file_only = file_only - }; - - if (hex) { - if (get_oid_hex(hex, &oid)) { - cgit_print_error_page(400, "Bad request", - "Bad hex value: %s", hex); - return; - } - } else { - if (repo_get_oid(the_repository, head, &oid)) { - cgit_print_error_page(404, "Not found", - "Bad ref: %s", head); - return; - } - } - - type = odb_read_object_info(the_repository->objects, &oid, &size); - - if ((!hex) && type == OBJ_COMMIT && path) { - commit = lookup_commit_reference(the_repository, &oid); - read_tree(the_repository, repo_get_commit_tree(the_repository, commit), - &paths, walk_tree, &walk_tree_ctx); - type = odb_read_object_info(the_repository->objects, &oid, &size); - } - - if (type == OBJ_BAD) { - cgit_print_error_page(404, "Not found", - "Bad object name: %s", hex); - return; - } - - buf = odb_read_object(the_repository->objects, &oid, &type, &size); - if (!buf) { - cgit_print_error_page(500, "Internal server error", - "Error reading object %s", hex); - return; - } - - buf[size] = '\0'; - if (buffer_is_binary(buf, size)) - ctx.page.mimetype = "application/octet-stream"; - else - ctx.page.mimetype = "text/plain"; - ctx.page.filename = path; - - html("X-Content-Type-Options: nosniff\n"); - html("Content-Security-Policy: default-src 'none'\n"); - cgit_print_http_headers(); - html_raw(buf, size); - free(buf); -} diff --git a/cgit/ui-blob.h b/cgit/ui-blob.h deleted file mode 100644 index f955a81..0000000 --- a/cgit/ui-blob.h +++ /dev/null @@ -1,12 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_BLOB_H -#define UI_BLOB_H - -extern int cgit_ref_path_exists(const char *path, const char *ref, int file_only); -extern int cgit_print_file(char *path, const char *head, int file_only); -extern void cgit_print_blob(const char *hex, char *path, const char *head, int file_only); - -#endif /* UI_BLOB_H */ diff --git a/cgit/ui-clone.c b/cgit/ui-clone.c deleted file mode 100644 index 2fa05d2..0000000 --- a/cgit/ui-clone.c +++ /dev/null @@ -1,142 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-clone.c: functions for http cloning, based on - * git's http-backend.c by Shawn O. Pearce - * - * Copyright (C) 2006-2014 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#define USE_THE_REPOSITORY_VARIABLE - -#include "cgit.h" -#include "ui-clone.h" -#include "html.h" -#include "ui-shared.h" -#include "packfile.h" - -static int print_ref_info(const struct reference *ref, void *cb_data) -{ - struct object *obj; - - if (!(obj = parse_object(the_repository, ref->oid))) - return 0; - - htmlf("%s\t%s\n", oid_to_hex(ref->oid), ref->name); - if (obj->type == OBJ_TAG) { - if (!(obj = deref_tag(the_repository, obj, ref->name, 0))) - return 0; - htmlf("%s\t%s^{}\n", oid_to_hex(&obj->oid), ref->name); - } - return 0; -} - -static void print_pack_info(void) -{ - struct odb_source *source; - char *offset; - - ctx.page.mimetype = "text/plain"; - ctx.page.filename = "objects/info/packs"; - cgit_print_http_headers(); - odb_reprepare(the_repository->objects); - for (source = the_repository->objects->sources; source; source = source->next) { - struct odb_source_files *files = odb_source_files_downcast(source); - struct packfile_list_entry *e; - for (e = files->packed->packs.head; e; e = e->next) { - struct packed_git *p = e->pack; - if (p->pack_local) { - offset = strrchr(p->pack_name, '/'); - if (offset && offset[1] != '\0') - ++offset; - else - offset = p->pack_name; - htmlf("P %s\n", offset); - } - } - } -} - -static void send_file(const char *path) -{ - struct stat st; - - if (stat(path, &st)) { - switch (errno) { - case ENOENT: - cgit_print_error_page(404, "Not found", "Not found"); - break; - case EACCES: - cgit_print_error_page(403, "Forbidden", "Forbidden"); - break; - default: - cgit_print_error_page(400, "Bad request", "Bad request"); - } - return; - } - ctx.page.mimetype = "application/octet-stream"; - ctx.page.filename = path; - skip_prefix(path, ctx.repo->path, &ctx.page.filename); - skip_prefix(ctx.page.filename, "/", &ctx.page.filename); - cgit_print_http_headers(); - html_include(path); -} - -void cgit_clone_info(void) -{ - if (!ctx.qry.path || strcmp(ctx.qry.path, "refs")) { - cgit_print_error_page(400, "Bad request", "Bad request"); - return; - } - - ctx.page.mimetype = "text/plain"; - ctx.page.filename = "info/refs"; - cgit_print_http_headers(); - refs_for_each_ref(get_main_ref_store(the_repository), - print_ref_info, NULL); -} - -void cgit_clone_objects(void) -{ - char *p, *path; - - if (!ctx.qry.path) - goto err; - - if (!strcmp(ctx.qry.path, "info/packs")) { - print_pack_info(); - return; - } - - /* Avoid directory traversal by forbidding "..", but also work around - * other funny business by just specifying a fairly strict format. For - * example, now we don't have to stress out about the Cygwin port. - */ - for (p = ctx.qry.path; *p; ++p) { - if (*p == '.' && *(p + 1) == '.') - goto err; - if (!isalnum(*p) && *p != '/' && *p != '.' && *p != '-') - goto err; - } - - path = repo_git_path(the_repository, "objects/%s", ctx.qry.path); - send_file(path); - free(path); - return; - -err: - cgit_print_error_page(400, "Bad request", "Bad request"); -} - -void cgit_clone_head(void) -{ - char *path; - - path = repo_git_path(the_repository, "HEAD"); - send_file(path); - free(path); -} diff --git a/cgit/ui-clone.h b/cgit/ui-clone.h deleted file mode 100644 index afdd2a0..0000000 --- a/cgit/ui-clone.h +++ /dev/null @@ -1,12 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_CLONE_H -#define UI_CLONE_H - -void cgit_clone_info(void); -void cgit_clone_objects(void); -void cgit_clone_head(void); - -#endif /* UI_CLONE_H */ diff --git a/cgit/ui-plain.c b/cgit/ui-plain.c deleted file mode 100644 index 8cfc76d..0000000 --- a/cgit/ui-plain.c +++ /dev/null @@ -1,213 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-plain.c: functions for output of plain blobs by path - * - * Copyright (C) 2006-2014 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#define USE_THE_REPOSITORY_VARIABLE - -#include "cgit.h" -#include "ui-plain.h" -#include "html.h" -#include "ui-shared.h" - -struct walk_tree_context { - int match_baselen; - int match; -}; - -static int print_object(const struct object_id *oid, const char *path) -{ - enum object_type type; - char *buf, *mimetype; - unsigned long size; - - type = odb_read_object_info(the_repository->objects, oid, &size); - if (type == OBJ_BAD) { - cgit_print_error_page(404, "Not found", "Not found"); - return 0; - } - - buf = odb_read_object(the_repository->objects, oid, &type, &size); - if (!buf) { - cgit_print_error_page(404, "Not found", "Not found"); - return 0; - } - - mimetype = get_mimetype_for_filename(path); - ctx.page.mimetype = mimetype; - - if (!ctx.repo->enable_html_serving) { - html("X-Content-Type-Options: nosniff\n"); - html("Content-Security-Policy: default-src 'none'\n"); - if (mimetype) { - /* Built-in white list allows PDF and everything that isn't text/ and application/ */ - if ((!strncmp(mimetype, "text/", 5) || !strncmp(mimetype, "application/", 12)) && strcmp(mimetype, "application/pdf")) - ctx.page.mimetype = NULL; - } - } - - if (!ctx.page.mimetype) { - if (buffer_is_binary(buf, size)) { - ctx.page.mimetype = "application/octet-stream"; - ctx.page.charset = NULL; - } else { - ctx.page.mimetype = "text/plain"; - } - } - ctx.page.filename = path; - ctx.page.size = size; - ctx.page.etag = oid_to_hex(oid); - cgit_print_http_headers(); - html_raw(buf, size); - free(mimetype); - free(buf); - return 1; -} - -static char *buildpath(const char *base, int baselen, const char *path) -{ - if (path[0]) - return fmtalloc("%.*s%s/", baselen, base, path); - else - return fmtalloc("%.*s/", baselen, base); -} - -static void print_dir(const struct object_id *oid, const char *base, - int baselen, const char *path) -{ - char *fullpath, *slash; - size_t len; - - fullpath = buildpath(base, baselen, path); - slash = (fullpath[0] == '/' ? "" : "/"); - ctx.page.etag = oid_to_hex(oid); - cgit_print_http_headers(); - htmlf("%s", slash); - html_txt(fullpath); - htmlf("\n\n

%s", slash); - html_txt(fullpath); - html("

\n
    \n"); - len = strlen(fullpath); - if (len > 1) { - fullpath[len - 1] = 0; - slash = strrchr(fullpath, '/'); - if (slash) - *(slash + 1) = 0; - else { - free(fullpath); - fullpath = NULL; - } - html("
  • "); - cgit_plain_link("../", NULL, NULL, ctx.qry.head, ctx.qry.oid, - fullpath); - html("
  • \n"); - } - free(fullpath); -} - -static void print_dir_entry(const struct object_id *oid, const char *base, - int baselen, const char *path, unsigned mode) -{ - char *fullpath; - - fullpath = buildpath(base, baselen, path); - if (!S_ISDIR(mode) && !S_ISGITLINK(mode)) - fullpath[strlen(fullpath) - 1] = 0; - html("
  • "); - if (S_ISGITLINK(mode)) { - cgit_submodule_link(NULL, fullpath, oid_to_hex(oid)); - } else - cgit_plain_link(path, NULL, NULL, ctx.qry.head, ctx.qry.oid, - fullpath); - html("
  • \n"); - free(fullpath); -} - -static void print_dir_tail(void) -{ - html("
\n\n"); -} - -static int walk_tree(const struct object_id *oid, struct strbuf *base, - const char *pathname, unsigned mode, void *cbdata) -{ - struct walk_tree_context *walk_tree_ctx = cbdata; - - if (base->len == walk_tree_ctx->match_baselen) { - if (S_ISREG(mode) || S_ISLNK(mode)) { - if (print_object(oid, pathname)) - walk_tree_ctx->match = 1; - } else if (S_ISDIR(mode)) { - print_dir(oid, base->buf, base->len, pathname); - walk_tree_ctx->match = 2; - return READ_TREE_RECURSIVE; - } - } else if (base->len < INT_MAX && (int)base->len > walk_tree_ctx->match_baselen) { - print_dir_entry(oid, base->buf, base->len, pathname, mode); - walk_tree_ctx->match = 2; - } else if (S_ISDIR(mode)) { - return READ_TREE_RECURSIVE; - } - - return 0; -} - -static int basedir_len(const char *path) -{ - const char *p = strrchr(path, '/'); - if (p) - return p - path + 1; - return 0; -} - -void cgit_print_plain(void) -{ - const char *rev = ctx.qry.oid; - struct object_id oid; - struct commit *commit; - struct pathspec_item path_items = { - .match = ctx.qry.path, - .len = ctx.qry.path ? strlen(ctx.qry.path) : 0 - }; - struct pathspec paths = { - .nr = 1, - .items = &path_items - }; - struct walk_tree_context walk_tree_ctx = { - .match = 0 - }; - - if (!rev) - rev = ctx.qry.head; - - if (repo_get_oid(the_repository, rev, &oid)) { - cgit_print_error_page(404, "Not found", "Not found"); - return; - } - commit = lookup_commit_reference(the_repository, &oid); - if (!commit || repo_parse_commit(the_repository, commit)) { - cgit_print_error_page(404, "Not found", "Not found"); - return; - } - if (!path_items.match) { - path_items.match = ""; - walk_tree_ctx.match_baselen = -1; - print_dir(get_commit_tree_oid(commit), "", 0, ""); - walk_tree_ctx.match = 2; - } - else - walk_tree_ctx.match_baselen = basedir_len(path_items.match); - read_tree(the_repository, repo_get_commit_tree(the_repository, commit), - &paths, walk_tree, &walk_tree_ctx); - if (!walk_tree_ctx.match) - cgit_print_error_page(404, "Not found", "Not found"); - else if (walk_tree_ctx.match == 2) - print_dir_tail(); -} diff --git a/cgit/ui-plain.h b/cgit/ui-plain.h deleted file mode 100644 index 9296751..0000000 --- a/cgit/ui-plain.h +++ /dev/null @@ -1,10 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_PLAIN_H -#define UI_PLAIN_H - -extern void cgit_print_plain(void); - -#endif /* UI_PLAIN_H */ diff --git a/cgit/ui-refs.c b/cgit/ui-refs.c deleted file mode 100644 index 1cd53f8..0000000 --- a/cgit/ui-refs.c +++ /dev/null @@ -1,221 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-refs.c: browse symbolic refs - * - * Copyright (C) 2006-2014 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#define USE_THE_REPOSITORY_VARIABLE - -#include "cgit.h" -#include "ui-refs.h" -#include "html.h" -#include "ui-shared.h" - -static inline int cmp_age(int age1, int age2) -{ - /* age1 and age2 are assumed to be non-negative */ - return age2 - age1; -} - -static int cmp_ref_name(const void *a, const void *b) -{ - struct refinfo *r1 = *(struct refinfo **)a; - struct refinfo *r2 = *(struct refinfo **)b; - - return strcmp(r1->refname, r2->refname); -} - -static int cmp_branch_age(const void *a, const void *b) -{ - struct refinfo *r1 = *(struct refinfo **)a; - struct refinfo *r2 = *(struct refinfo **)b; - - return cmp_age(r1->commit->committer_date, r2->commit->committer_date); -} - -static int get_ref_age(struct refinfo *ref) -{ - if (!ref->object) - return 0; - switch (ref->object->type) { - case OBJ_TAG: - return ref->tag ? ref->tag->tagger_date : 0; - case OBJ_COMMIT: - return ref->commit ? ref->commit->committer_date : 0; - } - return 0; -} - -static int cmp_tag_age(const void *a, const void *b) -{ - struct refinfo *r1 = *(struct refinfo **)a; - struct refinfo *r2 = *(struct refinfo **)b; - - return cmp_age(get_ref_age(r1), get_ref_age(r2)); -} - -static int print_branch(struct refinfo *ref) -{ - struct commitinfo *info = ref->commit; - char *name = (char *)ref->refname; - - if (!info) - return 1; - html(""); - cgit_log_link(name, NULL, NULL, name, NULL, NULL, 0, NULL, NULL, - ctx.qry.showmsg, 0); - html(""); - - if (ref->object->type == OBJ_COMMIT) { - cgit_commit_link(info->subject, NULL, NULL, name, NULL, NULL); - html(""); - html_txt(info->author); - html(""); - cgit_print_age(info->committer_date, info->committer_tz, -1); - } else { - html(""); - cgit_object_link(ref->object); - } - html("\n"); - return 0; -} - -static void print_tag_header(void) -{ - html("Tag" - "Download" - "Author" - "Age\n"); -} - -static int print_tag(struct refinfo *ref) -{ - struct tag *tag = NULL; - struct taginfo *info = NULL; - char *name = (char *)ref->refname; - struct object *obj = ref->object; - - if (obj->type == OBJ_TAG) { - tag = (struct tag *)obj; - obj = tag->tagged; - info = ref->tag; - if (!info) - return 1; - } - - html(""); - cgit_tag_link(name, NULL, NULL, name); - html(""); - if (ctx.repo->snapshots && (obj->type == OBJ_COMMIT)) - cgit_print_snapshot_links(ctx.repo, name, "  "); - else - cgit_object_link(obj); - html(""); - if (info) { - if (info->tagger) - html_txt(info->tagger); - } else if (ref->object->type == OBJ_COMMIT) { - html_txt(ref->commit->author); - } - html(""); - if (info) { - if (info->tagger_date > 0) - cgit_print_age(info->tagger_date, info->tagger_tz, -1); - } else if (ref->object->type == OBJ_COMMIT) { - cgit_print_age(ref->commit->commit->date, 0, -1); - } - html("\n"); - - return 0; -} - -static void print_refs_link(const char *path) -{ - html(""); - cgit_refs_link("[...]", NULL, NULL, ctx.qry.head, NULL, path); - html(""); -} - -void cgit_print_branches(int maxcount) -{ - struct reflist list; - int i; - - html("Branch" - "Commit message" - "Author" - "Age\n"); - - list.refs = NULL; - list.alloc = list.count = 0; - refs_for_each_branch_ref(get_main_ref_store(the_repository), - cgit_refs_cb, &list); - if (ctx.repo->enable_remote_branches) - refs_for_each_remote_ref(get_main_ref_store(the_repository), - cgit_refs_cb, &list); - - if (maxcount == 0 || maxcount > list.count) - maxcount = list.count; - - qsort(list.refs, list.count, sizeof(*list.refs), cmp_branch_age); - if (ctx.repo->branch_sort == 0) - qsort(list.refs, maxcount, sizeof(*list.refs), cmp_ref_name); - - for (i = 0; i < maxcount; i++) - print_branch(list.refs[i]); - - if (maxcount < list.count) - print_refs_link("heads"); - - cgit_free_reflist_inner(&list); -} - -void cgit_print_tags(int maxcount) -{ - struct reflist list; - int i; - - list.refs = NULL; - list.alloc = list.count = 0; - refs_for_each_tag_ref(get_main_ref_store(the_repository), - cgit_refs_cb, &list); - if (list.count == 0) - return; - qsort(list.refs, list.count, sizeof(*list.refs), cmp_tag_age); - if (!maxcount) - maxcount = list.count; - else if (maxcount > list.count) - maxcount = list.count; - print_tag_header(); - for (i = 0; i < maxcount; i++) - print_tag(list.refs[i]); - - if (maxcount < list.count) - print_refs_link("tags"); - - cgit_free_reflist_inner(&list); -} - -void cgit_print_refs(void) -{ - cgit_print_layout_start(); - html(""); - - if (ctx.qry.path && starts_with(ctx.qry.path, "heads")) - cgit_print_branches(0); - else if (ctx.qry.path && starts_with(ctx.qry.path, "tags")) - cgit_print_tags(0); - else { - cgit_print_branches(0); - html(""); - cgit_print_tags(0); - } - html("
 
"); - cgit_print_layout_end(); -} diff --git a/cgit/ui-refs.h b/cgit/ui-refs.h deleted file mode 100644 index 4c7f0e3..0000000 --- a/cgit/ui-refs.h +++ /dev/null @@ -1,12 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_REFS_H -#define UI_REFS_H - -extern void cgit_print_branches(int maxcount); -extern void cgit_print_tags(int maxcount); -extern void cgit_print_refs(void); - -#endif /* UI_REFS_H */ diff --git a/cgit/ui-repolist.c b/cgit/ui-repolist.c deleted file mode 100644 index f6f2e99..0000000 --- a/cgit/ui-repolist.c +++ /dev/null @@ -1,480 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-repolist.c: functions for generating the repolist page - * - * Copyright (C) 2006-2014 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#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) -{ - time_t result; - size_t size; - char *buf = NULL; - struct strbuf date_buf = STRBUF_INIT; - - if (read_first_line(path, &buf, &size)) { - free(buf); - return 0; - } - - if (parse_date(buf, &date_buf) == 0) - result = strtoul(date_buf.buf, NULL, 10); - else - result = 0; - free(buf); - strbuf_release(&date_buf); - return result; -} - -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 repo->mtime != 0; - } - strbuf_addf(&path, "%s/%s", repo->path, ctx.cfg.agefile); - if (stat(path.buf, &s) == 0) { - *mtime = read_agefile(path.buf); - if (*mtime) { - r->mtime = *mtime; - goto end; - } - } - - strbuf_reset(&path); - 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); - strbuf_addf(&path, "%s/%s", repo->path, "packed-refs"); - if (stat(path.buf, &s) == 0) { - *mtime = s.st_mtime; - r->mtime = *mtime; - goto end; - } - - *mtime = 0; - r->mtime = *mtime; -end: - strbuf_release(&head); - strbuf_release(&path); - return (r->mtime != 0); -} - -static int is_match(struct cgit_repo *repo) -{ - if (!ctx.qry.search) - return 1; - if (repo->url && strcasestr(repo->url, ctx.qry.search)) - return 1; - if (repo->name && strcasestr(repo->name, ctx.qry.search)) - return 1; - if (repo->desc && strcasestr(repo->desc, ctx.qry.search)) - return 1; - if (repo->owner && strcasestr(repo->owner, ctx.qry.search)) - return 1; - return 0; -} - -static int is_visible(struct cgit_repo *repo) -{ - if (repo->hide || repo->ignore) - return 0; - return is_match(repo); -} - -static int any_repos_visible(void) -{ - int i; - - for (i = 0; i < cgit_repolist.count; i++) { - if (is_visible(&cgit_repolist.repos[i])) - return 1; - } - return 0; -} - -static int cmp(const char *s1, const char *s2) -{ - if (s1 && s2) { - if (ctx.cfg.case_sensitive_sort) - return strcmp(s1, s2); - else - return strcasecmp(s1, s2); - } - if (s1 && !s2) - return -1; - if (s2 && !s1) - return 1; - return 0; -} - -static int sort_name(const void *a, const void *b) -{ - const struct cgit_repo *r1 = a; - const struct cgit_repo *r2 = b; - - return cmp(r1->name, r2->name); -} - -static int sort_desc(const void *a, const void *b) -{ - const struct cgit_repo *r1 = a; - const struct cgit_repo *r2 = b; - - return cmp(r1->desc, r2->desc); -} - -static int sort_owner(const void *a, const void *b) -{ - const struct cgit_repo *r1 = a; - const struct cgit_repo *r2 = b; - - return cmp(r1->owner, r2->owner); -} - -static int sort_idle(const void *a, const void *b) -{ - const struct cgit_repo *r1 = a; - const struct cgit_repo *r2 = b; - time_t t1, t2; - - t1 = t2 = 0; - get_repo_modtime(r1, &t1); - get_repo_modtime(r2, &t2); - return t2 - t1; -} - -static int sort_section(const void *a, const void *b) -{ - const struct cgit_repo *r1 = a; - const struct cgit_repo *r2 = b; - int result; - - result = cmp(r1->section, r2->section); - if (!result) { - if (!strcmp(ctx.cfg.repository_sort, "age")) - result = sort_idle(r1, r2); - if (!result) - result = cmp(r1->name, r2->name); - } - return result; -} - -struct sortcolumn { - const char *name; - int (*fn)(const void *a, const void *b); -}; - -static const struct sortcolumn sortcolumn[] = { - {"section", sort_section}, - {"name", sort_name}, - {"desc", sort_desc}, - {"owner", sort_owner}, - {"idle", sort_idle}, - {NULL, NULL} -}; - -static int sort_repolist(char *field) -{ - const struct sortcolumn *column; - - for (column = &sortcolumn[0]; column->name; column++) { - if (strcmp(field, column->name)) - continue; - qsort(cgit_repolist.repos, cgit_repolist.count, - sizeof(struct cgit_repo), column->fn); - return 1; - } - return 0; -} - - -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) || *p == '/' || *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; - - strbuf_addch(&url, '/'); - url_path(&url, repo->url); - if (page) { - strbuf_addstr(&url, "/+/HEAD/+/"); - url_path(&url, page); - } - if (query) - strbuf_addf(&url, "?%s", 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, hits = 0, sorted = 0, first = 1; - char *last_section = NULL; - - if (!any_repos_visible()) { - cgit_print_error_page(404, "Not found", "No repositories found"); - return; - } - ctx.page.title = ctx.cfg.root_title; - ctx.page.mimetype = "application/vnd.gilti.repolist+json"; - ctx.page.charset = NULL; - cgit_print_http_headers(); - - if (ctx.qry.sort) - sorted = sort_repolist(ctx.qry.sort); - else if (ctx.cfg.section_sort) - sort_repolist("section"); - - 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"); 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(","); - 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++) { - struct cgit_repo *repo = &cgit_repolist.repos[i]; - time_t mtime; - int populated; - char *section, *url; - if (!is_visible(repo)) continue; - hits++; - 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)))) { - if (!first) json(","); - json("{"); json_key("section"); - if (section) json_value(section); else json("null"); - json("}"); - first = 0; - last_section = section; - } - 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(","); - 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("],"); - json_key("pager"); json("["); - if (hits > ctx.cfg.max_repo_count) - 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) -{ - cgit_print_layout_start(); - if (!ctx.cfg.root_readme) - goto done; - html_include(ctx.cfg.root_readme); -done: - cgit_print_layout_end(); -} diff --git a/cgit/ui-repolist.h b/cgit/ui-repolist.h deleted file mode 100644 index f13d18a..0000000 --- a/cgit/ui-repolist.h +++ /dev/null @@ -1,11 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_REPOLIST_H -#define UI_REPOLIST_H - -extern void cgit_print_repolist(void); -extern void cgit_print_site_readme(void); - -#endif /* UI_REPOLIST_H */ diff --git a/cgit/ui-shared.h b/cgit/ui-shared.h index c1d055b..152cb0a 100644 --- a/cgit/ui-shared.h +++ b/cgit/ui-shared.h @@ -19,7 +19,6 @@ 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 *)); extern void cgit_index_link(const char *name, const char *title, const char *class, const char *pattern, const char *sort, int ofs, int always_root); @@ -87,6 +86,7 @@ extern void cgit_compose_snapshot_prefix(struct strbuf *filename, extern void cgit_print_snapshot_links(const struct cgit_repo *repo, const char *ref, const char *separator); extern const char *cgit_snapshot_prefix(const struct cgit_repo *repo); +extern void cgit_add_clone_urls(void (*fn)(const char *)); extern void cgit_add_hidden_formfields(int incl_head, int incl_search, const char *page); diff --git a/cgit/ui-summary.c b/cgit/ui-summary.c deleted file mode 100644 index dc62ea9..0000000 --- a/cgit/ui-summary.c +++ /dev/null @@ -1,148 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-summary.c: functions for generating repo summary page - * - * Copyright (C) 2006-2014 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#include "cgit.h" -#include "ui-summary.h" -#include "html.h" -#include "ui-blob.h" -#include "ui-log.h" -#include "ui-plain.h" -#include "ui-refs.h" -#include "ui-shared.h" - -static int urls; - -static void print_url(const char *url) -{ - int columns = 3; - - if (ctx.repo->enable_log_filecount) - columns++; - if (ctx.repo->enable_log_linecount) - columns++; - - if (urls++ == 0) { - htmlf(" ", columns); - htmlf("Clone\n", columns); - } - - htmlf(""); - html_txt(url); - html("\n"); -} - -void cgit_print_summary(void) -{ - int columns = 3; - - if (ctx.repo->enable_log_filecount) - columns++; - if (ctx.repo->enable_log_linecount) - columns++; - - cgit_print_layout_start(); - html(""); - cgit_print_branches(ctx.cfg.summary_branches); - htmlf("", columns); - cgit_print_tags(ctx.cfg.summary_tags); - if (ctx.cfg.summary_log > 0) { - htmlf("", columns); - cgit_print_log(ctx.qry.head, 0, ctx.cfg.summary_log, NULL, - NULL, NULL, 0, 0, 0); - } - urls = 0; - cgit_add_clone_urls(print_url); - html("
 
 
"); - cgit_print_layout_end(); -} - -/* The caller must free the return value. */ -static char* append_readme_path(const char *filename, const char *ref, const char *path) -{ - char *file, *base_dir, *full_path, *resolved_base = NULL, *resolved_full = NULL; - /* If a subpath is specified for the about page, make it relative - * to the directory containing the configured readme. */ - - file = xstrdup(filename); - base_dir = dirname(file); - if (!strcmp(base_dir, ".") || !strcmp(base_dir, "..")) { - if (!ref) { - free(file); - return NULL; - } - full_path = xstrdup(path); - } else - full_path = fmtalloc("%s/%s", base_dir, path); - - if (!ref) { - resolved_base = realpath(base_dir, NULL); - resolved_full = realpath(full_path, NULL); - if (!resolved_base || !resolved_full || !starts_with(resolved_full, resolved_base)) { - free(full_path); - full_path = NULL; - } - } - - free(file); - free(resolved_base); - free(resolved_full); - - return full_path; -} - -void cgit_print_repo_readme(const char *path) -{ - char *filename, *ref, *mimetype; - int free_filename = 0; - - mimetype = get_mimetype_for_filename(path); - if (mimetype && (!strncmp(mimetype, "image/", 6) || !strncmp(mimetype, "video/", 6))) { - ctx.page.mimetype = mimetype; - ctx.page.charset = NULL; - cgit_print_plain(); - free(mimetype); - return; - } - free(mimetype); - - cgit_print_layout_start(); - if (ctx.repo->readme.nr == 0) - goto done; - - filename = ctx.repo->readme.items[0].string; - ref = ctx.repo->readme.items[0].util; - - if (path) { - free_filename = 1; - filename = append_readme_path(filename, ref, path); - if (!filename) - goto done; - } - - /* Print the calculated readme from the git repo or filesystem as text. */ - html("
");
-	if (ref)
-		cgit_print_file(filename, ref, 1);
-	else
-		html_include_text(filename);
-
-	html("
"); - if (free_filename) - free(filename); - -done: - cgit_print_layout_end(); -} diff --git a/cgit/ui-summary.h b/cgit/ui-summary.h deleted file mode 100644 index 68f8052..0000000 --- a/cgit/ui-summary.h +++ /dev/null @@ -1,11 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_SUMMARY_H -#define UI_SUMMARY_H - -extern void cgit_print_summary(void); -extern void cgit_print_repo_readme(const char *path); - -#endif /* UI_SUMMARY_H */ diff --git a/cgit/ui-tag.c b/cgit/ui-tag.c deleted file mode 100644 index e053f05..0000000 --- a/cgit/ui-tag.c +++ /dev/null @@ -1,130 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-tag.c: display a tag - * - * Copyright (C) 2006-2014 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - -#define USE_THE_REPOSITORY_VARIABLE - -#include "cgit.h" -#include "ui-tag.h" -#include "html.h" -#include "ui-shared.h" - -static void print_tag_content(char *buf) -{ - char *p; - - if (!buf) - return; - - html("
"); - p = strchr(buf, '\n'); - if (p) - *p = '\0'; - html_txt(buf); - html("
"); - if (p) { - html("
"); - html_txt(++p); - html("
"); - } -} - -static void print_download_links(char *revname) -{ - html("download"); - cgit_print_snapshot_links(ctx.repo, revname, "
"); - html(""); -} - -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; - - 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", displayname); - goto cleanup; - } - obj = parse_object(the_repository, &oid); - if (!obj) { - cgit_print_error_page(500, "Internal server error", - "Bad object id: %s", oid_to_hex(&oid)); - goto cleanup; - } - if (obj->type == OBJ_TAG) { - struct tag *tag; - struct taginfo *info; - - 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", displayname); - goto cleanup; - } - cgit_print_layout_start(); - html("\n"); - html("\n", oid_to_hex(&oid)); - if (info->tagger_date > 0) { - html("\n"); - } - if (info->tagger) { - html("\n"); - } - html("\n"); - if (ctx.repo->snapshots) - print_download_links(revname); - html("
tag name"); - html_txt(displayname); - htmlf(" (%s)
tag date"); - html_txt(show_date(info->tagger_date, info->tagger_tz, - cgit_date_mode(DATE_ISO8601))); - html("
tagged by"); - html_txt(info->tagger); - if (info->tagger_email && !ctx.cfg.noplainemail) { - html(" "); - html_txt(info->tagger_email); - } - html("
tagged object"); - cgit_object_link(tag->tagged); - html("
\n"); - print_tag_content(info->msg); - cgit_print_layout_end(); - cgit_free_taginfo(info); - } else { - cgit_print_layout_start(); - html("\n"); - html("\n"); - html("\n"); - if (ctx.repo->snapshots) - print_download_links(revname); - html("
tag name"); - html_txt(displayname); - html("
tagged object"); - cgit_object_link(obj); - html("
\n"); - cgit_print_layout_end(); - } - -cleanup: - strbuf_release(&fullref); -} diff --git a/cgit/ui-tag.h b/cgit/ui-tag.h deleted file mode 100644 index b152061..0000000 --- a/cgit/ui-tag.h +++ /dev/null @@ -1,10 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_TAG_H -#define UI_TAG_H - -extern void cgit_print_tag(char *revname); - -#endif /* UI_TAG_H */ diff --git a/cgit/ui-tree.c b/cgit/ui-tree.c deleted file mode 100644 index 1ef9508..0000000 --- a/cgit/ui-tree.c +++ /dev/null @@ -1,400 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -/* ui-tree.c: functions for tree output - * - * Copyright (C) 2006-2017 cgit Development Team - * - * Licensed under GNU General Public License v2 - * (see COPYING for full license text) - */ - - #define USE_THE_REPOSITORY_VARIABLE - -#include "cgit.h" -#include "ui-tree.h" -#include "html.h" -#include "ui-shared.h" - -struct walk_tree_context { - char *curr_rev; - char *match_path; - int state; -}; - -static void print_text_buffer(char *buf, unsigned long size) -{ - unsigned long lineno, idx; - const char *numberfmt = "%1$d\n"; - - html("\n"); - - if (ctx.cfg.enable_tree_linenumbers) { - html("\n"); - } - else { - html("\n"); - } - - html("
");
-		idx = 0;
-		lineno = 0;
-
-		if (size) {
-			htmlf(numberfmt, ++lineno);
-			while (idx < size - 1) { // skip absolute last newline
-				if (buf[idx] == '\n')
-					htmlf(numberfmt, ++lineno);
-				idx++;
-			}
-		}
-		html("
");
-	html_txt(buf);
-	html("
\n"); -} - -#define ROWLEN 32 - -static void print_binary_buffer(char *buf, unsigned long size) -{ - unsigned long ofs, idx; - static char ascii[ROWLEN + 1]; - - html("\n"); - html(""); - for (ofs = 0; ofs < size; ofs += ROWLEN, buf += ROWLEN) { - htmlf("\n"); - } - html("
ofshex dumpascii
%04lx", ofs); - for (idx = 0; idx < ROWLEN && ofs + idx < size; idx++) - htmlf("%*s%02x", - idx == 16 ? 4 : 1, "", - buf[idx] & 0xff); - html(" "); - for (idx = 0; idx < ROWLEN && ofs + idx < size; idx++) - ascii[idx] = isgraph(buf[idx]) ? buf[idx] : '.'; - ascii[idx] = '\0'; - html_txt(ascii); - html("
\n"); -} - -static void print_object(const struct object_id *oid, const char *path, const char *rev) -{ - enum object_type type; - char *buf; - unsigned long size; - bool is_binary; - - type = odb_read_object_info(the_repository->objects, oid, &size); - if (type == OBJ_BAD) { - cgit_print_error_page(404, "Not found", - "Bad object name: %s", oid_to_hex(oid)); - return; - } - - buf = odb_read_object(the_repository->objects, oid, &type, &size); - if (!buf) { - cgit_print_error_page(500, "Internal server error", - "Error reading object %s", oid_to_hex(oid)); - return; - } - is_binary = buffer_is_binary(buf, size); - - cgit_set_title_from_path(path); - - cgit_print_layout_start(); - htmlf("blob: %s (", oid_to_hex(oid)); - cgit_plain_link("plain", NULL, NULL, ctx.qry.head, - rev, path); - if (ctx.repo->enable_blame && !is_binary) { - html(") ("); - cgit_blame_link("blame", NULL, NULL, ctx.qry.head, - rev, path); - } - html(")\n"); - - if (ctx.cfg.max_blob_size && size / 1024 > ctx.cfg.max_blob_size) { - htmlf("
blob size (%ldKB) exceeds display size limit (%dKB).
", - size / 1024, ctx.cfg.max_blob_size); - return; - } - - if (is_binary) - print_binary_buffer(buf, size); - else - print_text_buffer(buf, size); - - free(buf); -} - -struct single_tree_ctx { - struct strbuf *path; - struct object_id oid; - char *name; - size_t count; -}; - -static int single_tree_cb(const struct object_id *oid, struct strbuf *base, - const char *pathname, unsigned mode, void *cbdata) -{ - struct single_tree_ctx *ctx = cbdata; - - if (++ctx->count > 1) - return -1; - - if (!S_ISDIR(mode)) { - ctx->count = 2; - return -1; - } - - ctx->name = xstrdup(pathname); - oidcpy(&ctx->oid, oid); - strbuf_addf(ctx->path, "/%s", pathname); - return 0; -} - -static void write_tree_link(const struct object_id *oid, char *name, - char *rev, struct strbuf *fullpath) -{ - size_t initial_length = fullpath->len; - struct tree *tree; - struct single_tree_ctx tree_ctx = { - .path = fullpath, - .count = 1, - }; - struct pathspec paths = { - .nr = 0 - }; - - oidcpy(&tree_ctx.oid, oid); - - while (tree_ctx.count == 1) { - cgit_tree_link(name, NULL, "ls-dir", ctx.qry.head, rev, - fullpath->buf); - - tree = lookup_tree(the_repository, &tree_ctx.oid); - if (!tree) - return; - - free(tree_ctx.name); - tree_ctx.name = NULL; - tree_ctx.count = 0; - - read_tree(the_repository, tree, &paths, single_tree_cb, &tree_ctx); - - if (tree_ctx.count != 1) - break; - - html(" / "); - name = tree_ctx.name; - } - - strbuf_setlen(fullpath, initial_length); -} - -static int ls_item(const struct object_id *oid, struct strbuf *base, - const char *pathname, unsigned mode, void *cbdata) -{ - struct walk_tree_context *walk_tree_ctx = cbdata; - char *name; - struct strbuf fullpath = STRBUF_INIT; - struct strbuf linkpath = STRBUF_INIT; - struct strbuf class = STRBUF_INIT; - enum object_type type; - unsigned long size = 0; - char *buf; - - name = xstrdup(pathname); - strbuf_addf(&fullpath, "%s%s%s", ctx.qry.path ? ctx.qry.path : "", - ctx.qry.path ? "/" : "", name); - - if (!S_ISGITLINK(mode)) { - type = odb_read_object_info(the_repository->objects, oid, &size); - if (type == OBJ_BAD) { - htmlf("Bad object: %s %s", - name, - oid_to_hex(oid)); - goto cleanup; - } - } - - html(""); - cgit_print_filemode(mode); - html(""); - if (S_ISGITLINK(mode)) { - cgit_submodule_link("ls-mod", fullpath.buf, oid_to_hex(oid)); - } else if (S_ISDIR(mode)) { - write_tree_link(oid, name, walk_tree_ctx->curr_rev, - &fullpath); - } else { - char *ext = strrchr(name, '.'); - strbuf_addstr(&class, "ls-blob"); - if (ext) - strbuf_addf(&class, " %s", ext + 1); - cgit_tree_link(name, NULL, class.buf, ctx.qry.head, - walk_tree_ctx->curr_rev, fullpath.buf); - } - if (S_ISLNK(mode)) { - html(" -> "); - buf = odb_read_object(the_repository->objects, oid, &type, &size); - if (!buf) { - htmlf("Error reading object: %s", oid_to_hex(oid)); - goto cleanup; - } - strbuf_addbuf(&linkpath, &fullpath); - strbuf_addf(&linkpath, "/../%s", buf); - strbuf_normalize_path(&linkpath); - cgit_tree_link(buf, NULL, class.buf, ctx.qry.head, - walk_tree_ctx->curr_rev, linkpath.buf); - free(buf); - strbuf_release(&linkpath); - } - htmlf("%li", size); - - html(""); - cgit_log_link("log", NULL, "button", ctx.qry.head, - walk_tree_ctx->curr_rev, fullpath.buf, 0, NULL, NULL, - ctx.qry.showmsg, 0); - if (ctx.repo->max_stats) - cgit_stats_link("stats", NULL, "button", ctx.qry.head, - fullpath.buf); - if (!S_ISGITLINK(mode)) - cgit_plain_link("plain", NULL, "button", ctx.qry.head, - walk_tree_ctx->curr_rev, fullpath.buf); - if (!S_ISDIR(mode) && ctx.repo->enable_blame) - cgit_blame_link("blame", NULL, "button", ctx.qry.head, - walk_tree_ctx->curr_rev, fullpath.buf); - html("\n"); - -cleanup: - free(name); - strbuf_release(&fullpath); - strbuf_release(&class); - return 0; -} - -static void ls_head(void) -{ - cgit_print_layout_start(); - html("\n"); - html(""); - html(""); - html(""); - html(""); - html("\n"); -} - -static void ls_tail(void) -{ - html("
ModeNameSize"); - html("
\n"); - cgit_print_layout_end(); -} - -static void ls_tree(const struct object_id *oid, const char *path, struct walk_tree_context *walk_tree_ctx) -{ - struct tree *tree; - struct pathspec paths = { - .nr = 0 - }; - - tree = parse_tree_indirect(oid); - if (!tree) { - cgit_print_error_page(404, "Not found", - "Not a tree object: %s", oid_to_hex(oid)); - return; - } - - ls_head(); - read_tree(the_repository, tree, &paths, ls_item, walk_tree_ctx); - ls_tail(); -} - - -static int walk_tree(const struct object_id *oid, struct strbuf *base, - const char *pathname, unsigned mode, void *cbdata) -{ - struct walk_tree_context *walk_tree_ctx = cbdata; - - if (walk_tree_ctx->state == 0) { - struct strbuf buffer = STRBUF_INIT; - - strbuf_addbuf(&buffer, base); - strbuf_addstr(&buffer, pathname); - if (strcmp(walk_tree_ctx->match_path, buffer.buf)) - return READ_TREE_RECURSIVE; - - if (S_ISDIR(mode)) { - walk_tree_ctx->state = 1; - cgit_set_title_from_path(buffer.buf); - strbuf_release(&buffer); - ls_head(); - return READ_TREE_RECURSIVE; - } else { - walk_tree_ctx->state = 2; - print_object(oid, buffer.buf, walk_tree_ctx->curr_rev); - strbuf_release(&buffer); - return 0; - } - } - ls_item(oid, base, pathname, mode, walk_tree_ctx); - return 0; -} - -/* - * Show a tree or a blob - * rev: the commit pointing at the root tree object - * path: path to tree or blob - */ -void cgit_print_tree(const char *rev, char *path) -{ - struct object_id oid; - struct commit *commit; - struct pathspec_item path_items = { - .match = path, - .len = path ? strlen(path) : 0 - }; - struct pathspec paths = { - .nr = path ? 1 : 0, - .items = &path_items - }; - struct walk_tree_context walk_tree_ctx = { - .match_path = path, - .state = 0 - }; - - if (!rev) - rev = ctx.qry.head; - - if (repo_get_oid(the_repository, rev, &oid)) { - cgit_print_error_page(404, "Not found", - "Invalid revision name: %s", rev); - return; - } - commit = lookup_commit_reference(the_repository, &oid); - if (!commit || repo_parse_commit(the_repository, commit)) { - cgit_print_error_page(404, "Not found", - "Invalid commit reference: %s", rev); - return; - } - - walk_tree_ctx.curr_rev = xstrdup(rev); - - if (path == NULL) { - ls_tree(get_commit_tree_oid(commit), NULL, &walk_tree_ctx); - goto cleanup; - } - - read_tree(the_repository, repo_get_commit_tree(the_repository, commit), - &paths, walk_tree, &walk_tree_ctx); - if (walk_tree_ctx.state == 1) - ls_tail(); - else if (walk_tree_ctx.state == 2) - cgit_print_layout_end(); - else - cgit_print_error_page(404, "Not found", "Path not found"); - -cleanup: - free(walk_tree_ctx.curr_rev); -} diff --git a/cgit/ui-tree.h b/cgit/ui-tree.h deleted file mode 100644 index ea1a520..0000000 --- a/cgit/ui-tree.h +++ /dev/null @@ -1,10 +0,0 @@ -/* SPDX-FileCopyrightText: cgit Development Team - * SPDX-License-Identifier: GPL-2.0-only - */ - -#ifndef UI_TREE_H -#define UI_TREE_H - -extern void cgit_print_tree(const char *rev, char *path); - -#endif /* UI_TREE_H */ diff --git a/crates/gilti-ssh/Cargo.toml b/crates/gilti-ssh/Cargo.toml index bbebe68..1fc17cd 100644 --- a/crates/gilti-ssh/Cargo.toml +++ b/crates/gilti-ssh/Cargo.toml @@ -12,6 +12,7 @@ authors.workspace = true edition.workspace = true license.workspace = true publish.workspace = true +rust-version.workspace = true [dependencies] libc.workspace = true diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml index fee4d7c..a20c3dd 100644 --- a/crates/gilti/Cargo.toml +++ b/crates/gilti/Cargo.toml @@ -12,9 +12,11 @@ license.workspace = true publish.workspace = true homepage.workspace = true repository.workspace = true +rust-version.workspace = true [dependencies] axum.workspace = true +git2.workspace = true percent-encoding.workspace = true tokio.workspace = true tower.workspace = true diff --git a/crates/gilti/src/lfs.rs b/crates/gilti/src/lfs.rs index 7f883fb..01371ee 100644 --- a/crates/gilti/src/lfs.rs +++ b/crates/gilti/src/lfs.rs @@ -74,12 +74,13 @@ pub async fn serve( 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 method == axum::http::Method::POST { + if let Some(oid) = path + .strip_prefix("objects/") + .and_then(|value| value.strip_suffix("/verify")) + { + return verify(&objects, oid, request).await; + } } if let Some(oid) = path.strip_prefix("objects/") { if !valid_oid(oid) { diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs index f7e4cd0..2fbc1f6 100644 --- a/crates/gilti/src/main.rs +++ b/crates/gilti/src/main.rs @@ -3,8 +3,9 @@ mod cgi; mod lfs; +mod models; pub mod router; -mod ui; +mod views; const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080"; const DEFAULT_CACHE_SECONDS: &str = "5"; @@ -26,26 +27,17 @@ const CGIT_LOGO: &str = "/usr/share/webapps/cgit/cgit.png"; const CGIT_FAVICON: &str = "/usr/share/webapps/cgit/favicon.ico"; const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ - ("CGIT_AGEFILE", "info/web/last-modified"), - ("CGIT_BRANCH_SORT", "0"), - ("CGIT_CASE_SENSITIVE_SORT", "1"), ("CGIT_CLONE_URL", ""), ("CGIT_COMMIT_SORT", "0"), ("CGIT_CSS", "/-/assets/cgit.css"), ("CGIT_DIFFTYPE", "0"), ("CGIT_EMBEDDED", "0"), - ("CGIT_ENABLE_BLAME", "1"), ("CGIT_ENABLE_COMMIT_GRAPH", "1"), ("CGIT_ENABLE_FOLLOW_LINKS", "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"), ("CGIT_ENABLE_LOG_LINECOUNT", "1"), ("CGIT_ENABLE_REMOTE_BRANCHES", "0"), ("CGIT_ENABLE_SUBJECT_LINKS", "0"), - ("CGIT_ENABLE_TREE_LINENUMBERS", "1"), ("CGIT_FAVICON", "/-/assets/favicon.ico"), ("CGIT_FOOTER", ""), ("CGIT_HEADER", ""), @@ -55,11 +47,8 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ ("CGIT_LOGO", "/-/assets/cgit.png"), ("CGIT_LOGO_LINK", ""), ("CGIT_MAX_ATOM_ITEMS", "10"), - ("CGIT_MAX_BLOB_SIZE", "0"), ("CGIT_MAX_COMMIT_COUNT", "50"), ("CGIT_MAX_MESSAGE_LENGTH", "80"), - ("CGIT_MAX_REPO_COUNT", "50"), - ("CGIT_MAX_REPODESC_LENGTH", "80"), ("CGIT_MAX_STATS", "4"), ("CGIT_MIMETYPE_FILE", ""), ("CGIT_MODULE_LINK", ""), @@ -67,22 +56,11 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ ("CGIT_NOPLAINEMAIL", "0"), ("CGIT_README_0", ":README.md"), ("CGIT_README_1", ":README"), - ("CGIT_REMOVE_SUFFIX", "1"), ("CGIT_REPO_DEFAULT_DESC", "[no description]"), ("CGIT_RENAMELIMIT", "-1"), - ("CGIT_REPOSITORY_SORT", "name"), ("CGIT_ROBOTS", "index, nofollow"), - ("CGIT_ROOT_README", ""), - ("CGIT_SCAN_HIDDEN_PATH", "0"), - ("CGIT_SCAN_PATH", "/var/lib/gilti/git/repositories"), ("CGIT_SECTION", ""), - ("CGIT_SECTION_FROM_PATH", "0"), - ("CGIT_SECTION_SORT", "1"), ("CGIT_SNAPSHOTS", "2147483647"), - ("CGIT_STRICT_EXPORT", ""), - ("CGIT_SUMMARY_BRANCHES", "10"), - ("CGIT_SUMMARY_LOG", "10"), - ("CGIT_SUMMARY_TAGS", "10"), ("CGIT_VIRTUAL_ROOT", "/"), ("GIT_ATTR_NOSYSTEM", "1"), ("GIT_CONFIG_NOSYSTEM", "1"), @@ -92,6 +70,7 @@ const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ struct RepositoryService { cgit: cgi::Cgi, git: cgi::Cgi, + views: views::shared::Context, write_enabled: bool, } @@ -166,6 +145,12 @@ async fn main() -> Result<(), Box> { return Ok(()); } + let views = views::shared::Context { + repositories: REPOSITORIES, + root_title: std::sync::Arc::from(config.root_title.clone()), + root_description: std::sync::Arc::from(config.root_description.clone()), + clone_prefix: std::sync::Arc::from(config.clone_prefix.clone()), + }; let mut cgit = cgi::Cgi::new(CGIT, GIT_HOME, config.listen_addr) .cache(config.cache) .env("CGIT_ROOT_TITLE", config.root_title) @@ -187,6 +172,7 @@ async fn main() -> Result<(), Box> { let repositories = RepositoryService { cgit, git, + views, write_enabled: config.http_write, }; let app = axum::Router::new() @@ -329,10 +315,91 @@ impl RepositoryService { { request.extensions_mut().insert(cgi::RemoteAddr(remote)); } + let migrated = matches!( + &route, + router::Route::Repositories + | router::Route::Overview(_) + | router::Route::About(_) + | router::Route::Object(_) + | router::Route::Refs(_) + | router::Route::Tree(_) + | router::Route::Blame(_) + | router::Route::ArchiveSignature(_) + ) || matches!( + &route, + router::Route::Revision(router::RepoRoute { + params: router::Revision::Ref(reference), + .. + }) if reference.starts_with("refs/tags/") + ); + let query = if migrated { + 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"); + } + Some(query) + } else { + None + }; + let format = query.as_ref().and_then(|query| query.format.as_deref()); match route { + router::Route::Repositories => { + views::repositories::serve( + &self.views, + views::repositories::Query::from_request(query.as_ref().expect("query parsed")), + request.method().clone(), + ) + .await + } router::Route::Summary(route) => redirect(&route.repo), router::Route::GitClone(route) => redirect(&route.repo), + router::Route::Overview(route) => { + views::overview::serve( + &self.views, + route, + request.headers(), + request.method().clone(), + ) + .await + } + router::Route::About(route) => { + views::about::serve(&self.views, route, request.method().clone()).await + } + router::Route::Object(route) => { + views::object::serve(REPOSITORIES, route, request.method().clone()).await + } + router::Route::Refs(route) => { + views::refs::serve(&self.views, route, request.method().clone()).await + } + router::Route::Tree(route) => { + views::tree::serve(&self.views, route, format, request.method().clone()).await + } + router::Route::Blame(route) => { + views::blame::serve(&self.views, route, request.method().clone()).await + } + router::Route::ArchiveSignature(route) => { + views::archive_signature::serve( + &self.views, + route, + format, + request.method().clone(), + ) + .await + } + router::Route::Revision(route) + if matches!( + &route.params, + router::Revision::Ref(reference) if reference.starts_with("refs/tags/") + ) => + { + views::tag::serve(&self.views, route, request.method().clone()).await + } router::Route::GitLfs(route) => { lfs::serve( std::path::Path::new(REPOSITORIES), @@ -419,16 +486,43 @@ impl RepositoryService { 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()) { + let mut environment = match cgit_environment(route, query, request.uri().path()) { Ok(environment) => environment, Err(()) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), }; + let repository = environment + .iter() + .find(|(name, _)| name == std::ffi::OsStr::new("GILTI_REPOSITORY")) + .and_then(|(_, value)| value.to_str()); + let repository_path = match repository { + Some(repository) => crate::models::repository::path( + std::path::Path::new(self.views.repositories), + repository, + ), + None => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), + }; + let repository_path = match repository_path { + Ok(path) => path, + Err(crate::models::Error::NotFound) => { + return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); + } + Err(crate::models::Error::Internal(error)) => { + eprintln!("gilti: repository lookup failed: {error}"); + return plain_response( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "internal server error\n", + ); + } + }; + environment.push(( + "GILTI_REPOSITORY_PATH".into(), + repository_path.into_os_string(), + )); 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, + Ok(response) => response, Err(error) => internal_error("cgit", error), } } @@ -440,6 +534,15 @@ struct RequestQuery { environment: Vec<(std::ffi::OsString, std::ffi::OsString)>, } +impl RequestQuery { + fn value(&self, name: &str) -> Option<&str> { + self.environment + .iter() + .find(|(key, _)| key == std::ffi::OsStr::new(name)) + .and_then(|(_, value)| value.to_str()) + } +} + fn request_query(query: Option<&str>) -> Result { let mut result = RequestQuery::default(); for pair in query @@ -519,28 +622,10 @@ fn cgit_environment( 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()); @@ -554,28 +639,6 @@ fn cgit_environment( 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()); @@ -585,13 +648,6 @@ fn cgit_environment( 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()); @@ -692,52 +748,6 @@ fn internal_error(context: &str, error: std::io::Error) -> axum::response::Respo ) } -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::INTERNAL_SERVER_ERROR, - "internal server error\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::INTERNAL_SERVER_ERROR, - "internal server error\n", - ) - } - } -} - fn response( status: axum::http::StatusCode, content_type: &'static str, @@ -809,78 +819,4 @@ mod tests { assert!(super::parse_cache("3601").is_err()); assert!(super::parse_cache("forever").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":"/-/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::INTERNAL_SERVER_ERROR - ); - } } diff --git a/crates/gilti/src/models/about.rs b/crates/gilti/src/models/about.rs new file mode 100644 index 0000000..c062a4b --- /dev/null +++ b/crates/gilti/src/models/about.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::Path; + +pub struct About { + pub repository: super::repository::Info, + pub bytes: Vec, +} + +impl About { + pub fn load(root: &Path, name: &str) -> Result { + let repository = super::repository::open(root, name)?; + let info = super::repository::info(&repository, name); + let commit = super::revision::commit(&repository, &crate::router::Revision::Head)?; + let tree = commit.tree().map_err(super::Error::from_git)?; + let blob = ["README.md", "README"] + .iter() + .find_map(|path| { + tree.get_path(Path::new(path)) + .ok() + .filter(|entry| entry.kind() == Some(git2::ObjectType::Blob)) + .and_then(|entry| repository.find_blob(entry.id()).ok()) + }) + .ok_or(super::Error::NotFound)?; + Ok(Self { + repository: info, + bytes: blob.content().to_vec(), + }) + } +} diff --git a/crates/gilti/src/models/archive_signature.rs b/crates/gilti/src/models/archive_signature.rs new file mode 100644 index 0000000..1330896 --- /dev/null +++ b/crates/gilti/src/models/archive_signature.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::Path; + +pub struct ArchiveSignature { + pub bytes: Vec, + pub oid: String, + pub filename: String, +} + +impl ArchiveSignature { + pub fn load( + root: &Path, + name: &str, + revision: crate::router::Revision, + format: &str, + ) -> Result { + let repository = super::repository::open(root, name)?; + let commit = super::revision::commit(&repository, &revision)?; + let notes_ref = format!("refs/notes/signatures/{format}"); + let note = repository + .find_note(Some(¬es_ref), commit.id()) + .map_err(super::Error::from_git)?; + Ok(Self { + bytes: note.message_bytes().to_vec(), + oid: note.id().to_string(), + filename: format!("{}.{}.asc", name.rsplit('/').next().unwrap_or(name), format), + }) + } +} diff --git a/crates/gilti/src/models/blame.rs b/crates/gilti/src/models/blame.rs new file mode 100644 index 0000000..52b85be --- /dev/null +++ b/crates/gilti/src/models/blame.rs @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::Path; + +pub struct Blame { + pub repository: super::repository::Info, + pub revision: String, + pub path: String, + pub oid: String, + pub bytes: Vec, + pub binary: bool, + pub hunks: Vec, +} + +pub struct Hunk { + pub oid: String, + pub short_oid: String, + pub start: usize, + pub lines: usize, + pub original_path: String, + pub author: String, + pub author_email: String, + pub committer: String, + pub committer_email: String, + pub timestamp: i64, + pub summary: String, + pub parent: Option, +} + +impl Blame { + pub fn load( + root: &Path, + name: &str, + revision: crate::router::Revision, + path: String, + ) -> Result { + let repository = super::repository::open(root, name)?; + let info = super::repository::info(&repository, name); + let selector = super::revision::selector(&revision); + let commit = super::revision::commit(&repository, &revision)?; + let tree = commit.tree().map_err(super::Error::from_git)?; + let entry = tree + .get_path(Path::new(&path)) + .map_err(super::Error::from_git)?; + if !matches!(entry.filemode_raw(), 0o100644 | 0o100755) { + return Err(super::Error::NotFound); + } + let blob = repository + .find_blob(entry.id()) + .map_err(super::Error::from_git)?; + let bytes = blob.content().to_vec(); + let binary = bytes.iter().take(8000).any(|byte| *byte == 0); + let mut hunks = Vec::new(); + if !binary { + let mut options = git2::BlameOptions::new(); + options.newest_commit(commit.id()); + let blame = repository + .blame_file(Path::new(&path), Some(&mut options)) + .map_err(super::Error::from_git)?; + for hunk in blame.iter() { + let oid = hunk.final_commit_id(); + let commit = repository + .find_commit(oid) + .map_err(super::Error::from_git)?; + let author = hunk.final_signature().unwrap_or_else(|| commit.author()); + let committer = hunk.final_committer().unwrap_or_else(|| commit.committer()); + hunks.push(Hunk { + oid: oid.to_string(), + short_oid: oid.to_string().chars().take(7).collect(), + start: hunk.final_start_line(), + lines: hunk.lines_in_hunk(), + original_path: hunk + .path() + .map_or_else(|| path.clone(), |path| path.to_string_lossy().into_owned()), + author: signature_name(&author), + author_email: signature_email(&author), + committer: signature_name(&committer), + committer_email: signature_email(&committer), + timestamp: author.when().seconds(), + summary: String::from_utf8_lossy( + hunk.summary_bytes() + .unwrap_or(commit.summary_bytes().unwrap_or_default()), + ) + .into_owned(), + parent: commit.parent_id(0).ok().map(|oid| oid.to_string()), + }); + } + } + Ok(Self { + repository: info, + revision: selector, + path, + oid: entry.id().to_string(), + bytes, + binary, + hunks, + }) + } +} + +fn signature_name(signature: &git2::Signature<'_>) -> String { + signature + .name() + .map(str::to_owned) + .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()) +} + +fn signature_email(signature: &git2::Signature<'_>) -> String { + signature + .email() + .map(str::to_owned) + .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()) +} diff --git a/crates/gilti/src/models/mod.rs b/crates/gilti/src/models/mod.rs new file mode 100644 index 0000000..4eafed6 --- /dev/null +++ b/crates/gilti/src/models/mod.rs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub mod about; +pub mod archive_signature; +pub mod blame; +pub mod object; +pub mod overview; +pub mod refs; +pub mod repositories; +pub mod repository; +pub mod revision; +pub mod tag; +pub mod tree; + +#[derive(Debug)] +pub enum Error { + NotFound, + Internal(String), +} + +impl Error { + pub fn from_git(error: git2::Error) -> Self { + if matches!( + error.code(), + git2::ErrorCode::NotFound | git2::ErrorCode::UnbornBranch + ) { + Self::NotFound + } else { + Self::Internal(error.message().to_owned()) + } + } +} diff --git a/crates/gilti/src/models/object.rs b/crates/gilti/src/models/object.rs new file mode 100644 index 0000000..36c7d67 --- /dev/null +++ b/crates/gilti/src/models/object.rs @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::Path; + +pub struct RawObject { + pub bytes: Vec, + pub binary: bool, +} + +impl RawObject { + pub fn load(root: &Path, repository: &str, oid: &str) -> Result { + let repository = super::repository::open(root, repository)?; + let oid = git2::Oid::from_str_ext(oid, repository.object_format()) + .map_err(|_| super::Error::NotFound)?; + let odb = repository.odb().map_err(super::Error::from_git)?; + let object = odb.read(oid).map_err(super::Error::from_git)?; + let bytes = object.data().to_vec(); + let binary = bytes.iter().take(8000).any(|byte| *byte == 0); + Ok(Self { bytes, binary }) + } +} + +#[cfg(test)] +mod tests { + fn fixture() -> (std::path::PathBuf, git2::Repository) { + let root = std::env::temp_dir().join(format!( + "gilti-object-test-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("thread") + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let repository = git2::Repository::init_bare(root.join("example.git")).unwrap(); + (root, repository) + } + + #[test] + fn reads_raw_text_and_binary_objects_like_cgit_blob() { + let (root, repository) = fixture(); + let odb = repository.odb().unwrap(); + let text_oid = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap(); + let binary_oid = odb + .write(git2::ObjectType::Blob, b"binary\0payload") + .unwrap(); + + let text = super::RawObject::load(&root, "example", &text_oid.to_string()).unwrap(); + assert_eq!(text.bytes, b"hello\n"); + assert!(!text.binary); + let binary = super::RawObject::load(&root, "example", &binary_oid.to_string()).unwrap(); + assert_eq!(binary.bytes, b"binary\0payload"); + assert!(binary.binary); + + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn reads_sha256_objects_with_the_repository_format() { + let root = + std::env::temp_dir().join(format!("gilti-object-sha256-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let mut options = git2::RepositoryInitOptions::new(); + options.bare(true).object_format(git2::ObjectFormat::Sha256); + let repository = git2::Repository::init_opts(root.join("example.git"), &options).unwrap(); + let oid = repository + .odb() + .unwrap() + .write(git2::ObjectType::Blob, b"sha256\n") + .unwrap(); + assert_eq!(oid.to_string().len(), 64); + + let object = super::RawObject::load(&root, "example", &oid.to_string()).unwrap(); + assert_eq!(object.bytes, b"sha256\n"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn missing_object_is_not_found() { + let (root, _repository) = fixture(); + assert!(matches!( + super::RawObject::load(&root, "example", "0000000000000000000000000000000000000000"), + Err(crate::models::Error::NotFound) + )); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/gilti/src/models/overview.rs b/crates/gilti/src/models/overview.rs new file mode 100644 index 0000000..f9be49d --- /dev/null +++ b/crates/gilti/src/models/overview.rs @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::HashMap; +use std::path::Path; + +pub struct Overview { + pub repository: super::repository::Info, + pub empty: bool, + pub branches: Vec, + pub tags: Vec, + pub commits: Vec, +} + +pub struct Commit { + pub oid: String, + pub subject: String, + pub author: String, + pub timestamp: i64, + pub files: usize, + pub insertions: usize, + pub deletions: usize, + pub decorations: Vec, +} + +pub struct Decoration { + pub label: String, + pub reference: Option, + pub tag: bool, +} + +impl Overview { + pub fn load(root: &Path, name: &str) -> Result { + let repository = super::repository::open(root, name)?; + let info = super::repository::info(&repository, name); + let head = match repository.head().and_then(|head| head.peel_to_commit()) { + Ok(head) => head, + Err(error) if error.code() == git2::ErrorCode::UnbornBranch => { + return Ok(Self { + repository: info, + empty: true, + branches: Vec::new(), + tags: Vec::new(), + commits: Vec::new(), + }); + } + Err(error) => return Err(super::Error::from_git(error)), + }; + let refs = super::refs::Refs::load(root, name)?; + let mut decorations = HashMap::>::new(); + decorations.entry(head.id()).or_default().push(Decoration { + label: "HEAD".to_owned(), + reference: None, + tag: false, + }); + for branch in &refs.branches { + if let Ok(oid) = repository.refname_to_id(&branch.reference) { + decorations.entry(oid).or_default().push(Decoration { + label: branch.name.clone(), + reference: Some(branch.reference.clone()), + tag: false, + }); + } + } + for tag in &refs.tags { + if let Ok(reference) = repository.find_reference(&tag.reference) { + if let Ok(commit) = reference.peel_to_commit() { + decorations + .entry(commit.id()) + .or_default() + .push(Decoration { + label: tag.name.clone(), + reference: Some(tag.reference.clone()), + tag: true, + }); + } + } + } + let mut walk = repository.revwalk().map_err(super::Error::from_git)?; + walk.push(head.id()).map_err(super::Error::from_git)?; + walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) + .map_err(super::Error::from_git)?; + let mut commits = Vec::new(); + for oid in walk.take(10) { + let oid = oid.map_err(super::Error::from_git)?; + let commit = repository + .find_commit(oid) + .map_err(super::Error::from_git)?; + let tree = commit.tree().map_err(super::Error::from_git)?; + let parent_tree = commit.parent(0).ok().and_then(|parent| parent.tree().ok()); + let diff = repository + .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) + .map_err(super::Error::from_git)?; + let stats = diff.stats().map_err(super::Error::from_git)?; + commits.push(Commit { + oid: oid.to_string(), + subject: String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default()) + .into_owned(), + author: signature_name(&commit.author()), + timestamp: commit.time().seconds(), + files: stats.files_changed(), + insertions: stats.insertions(), + deletions: stats.deletions(), + decorations: decorations.remove(&oid).unwrap_or_default(), + }); + } + Ok(Self { + repository: info, + empty: false, + branches: refs.branches.into_iter().take(10).collect(), + tags: refs.tags.into_iter().take(10).collect(), + commits, + }) + } +} + +fn signature_name(signature: &git2::Signature<'_>) -> String { + signature + .name() + .map(str::to_owned) + .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()) +} diff --git a/crates/gilti/src/models/refs.rs b/crates/gilti/src/models/refs.rs new file mode 100644 index 0000000..8cdd8b4 --- /dev/null +++ b/crates/gilti/src/models/refs.rs @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::Path; + +pub struct Refs { + pub repository: super::repository::Info, + pub branches: Vec, + pub tags: Vec, +} + +pub struct Branch { + pub name: String, + pub reference: String, + pub subject: String, + pub author: String, + pub timestamp: i64, +} + +pub struct Tag { + pub name: String, + pub reference: String, + pub target: String, + pub downloadable: bool, + pub author: String, + pub timestamp: i64, +} + +impl Refs { + pub fn load(root: &Path, name: &str) -> Result { + let repository = super::repository::open(root, name)?; + repository + .head() + .and_then(|head| head.peel_to_commit()) + .map_err(super::Error::from_git)?; + let info = super::repository::info(&repository, name); + let mut branches = Vec::new(); + let references = repository.references().map_err(super::Error::from_git)?; + for reference in references { + let reference = reference.map_err(super::Error::from_git)?; + let Ok(reference_name) = reference.name() else { + continue; + }; + if !reference_name.starts_with("refs/heads/") { + continue; + } + let commit = match reference.peel_to_commit() { + Ok(commit) => commit, + Err(_) => continue, + }; + branches.push(Branch { + name: reference_name["refs/heads/".len()..].to_owned(), + reference: reference_name.to_owned(), + subject: first_line(commit.message_bytes()), + author: signature_name(&commit.author()), + timestamp: commit.time().seconds(), + }); + } + branches.sort_by(|left, right| left.reference.cmp(&right.reference)); + + let mut tags = Vec::new(); + let references = repository.references().map_err(super::Error::from_git)?; + for reference in references { + let reference = reference.map_err(super::Error::from_git)?; + let Ok(reference_name) = reference.name() else { + continue; + }; + if !reference_name.starts_with("refs/tags/") { + continue; + } + let object = match reference + .resolve() + .and_then(|reference| { + reference + .target() + .ok_or_else(|| git2::Error::from_str("reference has no target")) + }) + .and_then(|oid| repository.find_object(oid, None)) + { + Ok(object) => object, + Err(_) => continue, + }; + let (author, timestamp) = if let Some(tag) = object.as_tag() { + let tagger = tag.tagger(); + ( + tagger.as_ref().map_or_else(String::new, signature_name), + tagger.as_ref().map_or(0, |tagger| tagger.when().seconds()), + ) + } else if let Some(commit) = object.as_commit() { + (signature_name(&commit.author()), commit.time().seconds()) + } else { + (String::new(), 0) + }; + let target = object + .as_tag() + .map_or_else(|| object.id(), git2::Tag::target_id); + let downloadable = object.peel_to_commit().is_ok(); + tags.push(Tag { + name: reference_name["refs/tags/".len()..].to_owned(), + reference: reference_name.to_owned(), + target: target.to_string(), + downloadable, + author, + timestamp, + }); + } + tags.sort_by(|left, right| { + right + .timestamp + .cmp(&left.timestamp) + .then_with(|| left.reference.cmp(&right.reference)) + }); + Ok(Self { + repository: info, + branches, + tags, + }) + } +} + +fn signature_name(signature: &git2::Signature<'_>) -> String { + signature + .name() + .map(str::to_owned) + .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()) +} + +fn first_line(message: &[u8]) -> String { + String::from_utf8_lossy( + message + .split(|byte| *byte == b'\n') + .next() + .unwrap_or_default(), + ) + .into_owned() +} + +#[cfg(test)] +mod tests { + #[test] + fn loads_local_branches_and_annotated_tags() { + let root = std::env::temp_dir().join(format!("gilti-refs-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let repository = git2::Repository::init_bare(root.join("example.git")).unwrap(); + let signature = git2::Signature::new( + "Example Author", + "author@example.test", + &git2::Time::new(1_700_000_000, 0), + ) + .unwrap(); + let tree_oid = repository.treebuilder(None).unwrap().write().unwrap(); + let tree = repository.find_tree(tree_oid).unwrap(); + let commit_oid = repository + .commit( + Some("refs/heads/main"), + &signature, + &signature, + "Subject line\n\nBody", + &tree, + &[], + ) + .unwrap(); + repository.set_head("refs/heads/main").unwrap(); + repository + .reference("refs/heads/feature/x", commit_oid, false, "test") + .unwrap(); + let commit = repository.find_object(commit_oid, None).unwrap(); + repository + .tag("v1.0", &commit, &signature, "Release", false) + .unwrap(); + drop(commit); + drop(tree); + drop(repository); + + let refs = super::Refs::load(&root, "example").unwrap(); + assert_eq!( + refs.branches + .iter() + .map(|branch| branch.name.as_str()) + .collect::>(), + ["feature/x", "main"] + ); + assert!( + refs.branches + .iter() + .all(|branch| branch.subject == "Subject line") + ); + assert_eq!(refs.tags.len(), 1); + assert_eq!(refs.tags[0].name, "v1.0"); + assert_eq!(refs.tags[0].author, "Example Author"); + assert!(refs.tags[0].downloadable); + + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/gilti/src/models/repositories.rs b/crates/gilti/src/models/repositories.rs new file mode 100644 index 0000000..2ea04ee --- /dev/null +++ b/crates/gilti/src/models/repositories.rs @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::Path; + +pub struct Repositories { + pub rows: Vec, + pub offset: usize, + pub has_next: bool, +} + +pub struct Repository { + pub name: String, + pub description: String, + pub timestamp: Option, + pub populated: bool, +} + +#[derive(Clone, Copy)] +pub enum Sort { + Name, + Description, + Owner, + Idle, +} + +pub struct Filter { + pub search: Option, + pub sort: Sort, + pub offset: usize, + pub limit: usize, +} + +impl Repositories { + pub fn load(root: &Path, filter: Filter) -> Result { + let mut rows = Vec::new(); + scan(root, root, &mut rows)?; + if let Some(search) = filter.search.as_deref() { + let search = search.to_lowercase(); + rows.retain(|repository| { + repository.name.to_lowercase().contains(&search) + || repository.description.to_lowercase().contains(&search) + }); + } + if rows.is_empty() { + return Err(super::Error::NotFound); + } + match filter.sort { + Sort::Name | Sort::Owner => rows.sort_by(|left, right| left.name.cmp(&right.name)), + Sort::Description => rows.sort_by(|left, right| { + left.description + .cmp(&right.description) + .then_with(|| left.name.cmp(&right.name)) + }), + Sort::Idle => rows.sort_by(|left, right| { + right + .timestamp + .cmp(&left.timestamp) + .then_with(|| left.name.cmp(&right.name)) + }), + } + let has_next = rows.len() > filter.offset.saturating_add(filter.limit); + let rows = rows + .into_iter() + .skip(filter.offset) + .take(filter.limit) + .collect(); + Ok(Self { + rows, + offset: filter.offset, + has_next, + }) + } +} + +fn scan(root: &Path, directory: &Path, rows: &mut Vec) -> Result<(), super::Error> { + let entries = + std::fs::read_dir(directory).map_err(|error| super::Error::Internal(error.to_string()))?; + for entry in entries { + let entry = entry.map_err(|error| super::Error::Internal(error.to_string()))?; + let file_type = entry + .file_type() + .map_err(|error| super::Error::Internal(error.to_string()))?; + if !file_type.is_dir() { + continue; + } + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + if file_name.starts_with('.') { + continue; + } + let path = entry.path(); + if let Some(name) = repository_name(root, &path) { + if let Some(repository) = load(&path, name) { + rows.push(repository); + } + } else { + scan(root, &path, rows)?; + } + } + Ok(()) +} + +fn repository_name<'a>(root: &Path, path: &'a Path) -> Option<&'a str> { + let relative = path.strip_prefix(root).ok()?.to_str()?; + relative.strip_suffix(".git") +} + +fn load(path: &Path, name: &str) -> Option { + let repository = git2::Repository::open_bare(path).ok()?; + let description = std::fs::read_to_string(path.join("description")) + .ok() + .map(|description| description.trim().to_owned()) + .filter(|description| !description.is_empty()) + .unwrap_or_else(|| "[no description]".to_owned()); + let commit = repository + .head() + .and_then(|head| head.peel_to_commit()) + .ok(); + Some(Repository { + name: name.to_owned(), + description, + timestamp: commit.as_ref().map(|commit| commit.time().seconds()), + populated: commit.is_some(), + }) +} + +#[cfg(test)] +mod tests { + fn filter(search: Option<&str>) -> super::Filter { + super::Filter { + search: search.map(str::to_owned), + sort: super::Sort::Name, + offset: 0, + limit: 50, + } + } + + #[test] + fn scans_nested_and_terminal_dot_git_names_and_filters() { + let root = + std::env::temp_dir().join(format!("gilti-repositories-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("group")).unwrap(); + git2::Repository::init_bare(root.join("group/project.git")).unwrap(); + git2::Repository::init_bare(root.join("group/dotted.git.git")).unwrap(); + git2::Repository::init_bare(root.join("empty.git")).unwrap(); + std::fs::write( + root.join("group/project.git/description"), + "Project description\n", + ) + .unwrap(); + + let repositories = super::Repositories::load(&root, filter(None)).unwrap(); + assert_eq!( + repositories + .rows + .iter() + .map(|repository| repository.name.as_str()) + .collect::>(), + ["empty", "group/dotted.git", "group/project"] + ); + assert!( + repositories + .rows + .iter() + .all(|repository| !repository.populated) + ); + let repositories = + super::Repositories::load(&root, filter(Some("Project description"))).unwrap(); + assert_eq!(repositories.rows.len(), 1); + assert_eq!(repositories.rows[0].name, "group/project"); + assert!(matches!( + super::Repositories::load(&root, filter(Some("missing"))), + Err(crate::models::Error::NotFound) + )); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/gilti/src/models/repository.rs b/crates/gilti/src/models/repository.rs new file mode 100644 index 0000000..b8a36ee --- /dev/null +++ b/crates/gilti/src/models/repository.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::{Path, PathBuf}; + +pub struct Info { + pub name: String, + pub description: String, +} + +pub fn info(repository: &git2::Repository, name: &str) -> Info { + let description = std::fs::read_to_string(repository.path().join("description")) + .ok() + .map(|description| description.trim().to_owned()) + .filter(|description| !description.is_empty()) + .unwrap_or_else(|| "[no description]".to_owned()); + Info { + name: name.to_owned(), + description, + } +} + +pub fn open(root: &Path, name: &str) -> Result { + git2::Repository::open_bare(path(root, name)?).map_err(super::Error::from_git) +} + +pub fn path(root: &Path, name: &str) -> Result { + let root = root + .canonicalize() + .map_err(|error| super::Error::Internal(error.to_string()))?; + let path = repository_path(&root, name) + .canonicalize() + .map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + super::Error::NotFound + } else { + super::Error::Internal(error.to_string()) + } + })?; + if !path.starts_with(&root) || !path.is_dir() { + return Err(super::Error::NotFound); + } + Ok(path) +} + +fn repository_path(root: &Path, name: &str) -> PathBuf { + let (parent, leaf) = name.rsplit_once('/').unwrap_or(("", name)); + root.join(parent).join(format!("{leaf}.git")) +} + +#[cfg(test)] +mod tests { + #[test] + fn appends_exactly_one_storage_suffix() { + let root = std::path::Path::new("/repositories"); + assert_eq!( + super::repository_path(root, "group/project"), + root.join("group/project.git") + ); + assert_eq!( + super::repository_path(root, "group/project.git"), + root.join("group/project.git.git") + ); + } + + #[test] + fn resolved_path_cannot_escape_the_repository_root() { + let parent = + std::env::temp_dir().join(format!("gilti-repository-path-test-{}", std::process::id())); + let root = parent.join("repositories"); + let outside = parent.join("outside.git"); + let _ = std::fs::remove_dir_all(&parent); + std::fs::create_dir_all(&root).unwrap(); + git2::Repository::init_bare(&outside).unwrap(); + assert!(matches!( + super::path(&root, "../outside"), + Err(crate::models::Error::NotFound) + )); + std::fs::remove_dir_all(parent).unwrap(); + } +} diff --git a/crates/gilti/src/models/revision.rs b/crates/gilti/src/models/revision.rs new file mode 100644 index 0000000..0583518 --- /dev/null +++ b/crates/gilti/src/models/revision.rs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub fn selector(revision: &crate::router::Revision) -> String { + match revision { + crate::router::Revision::Head => "HEAD".to_owned(), + crate::router::Revision::Ref(reference) | crate::router::Revision::Commit(reference) => { + reference.clone() + } + } +} + +pub fn commit<'repo>( + repository: &'repo git2::Repository, + revision: &crate::router::Revision, +) -> Result, super::Error> { + match revision { + crate::router::Revision::Head => repository + .head() + .and_then(|head| head.peel_to_commit()) + .map_err(|_| super::Error::NotFound), + crate::router::Revision::Ref(reference) => repository + .find_reference(reference) + .and_then(|reference| reference.peel_to_commit()) + .map_err(|_| super::Error::NotFound), + crate::router::Revision::Commit(oid) => { + let oid = git2::Oid::from_str_ext(oid, repository.object_format()) + .map_err(|_| super::Error::NotFound)?; + repository + .find_commit(oid) + .map_err(|_| super::Error::NotFound) + } + } +} diff --git a/crates/gilti/src/models/tag.rs b/crates/gilti/src/models/tag.rs new file mode 100644 index 0000000..7bfca74 --- /dev/null +++ b/crates/gilti/src/models/tag.rs @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::Path; + +pub struct Tag { + pub repository: super::repository::Info, + pub reference: String, + pub name: String, + pub oid: String, + pub targets: Vec, + pub annotated: bool, + pub tagger: String, + pub tagger_email: String, + pub timestamp: Option, + pub message: String, + pub downloadable: bool, +} + +pub struct Target { + pub oid: String, + pub commit: bool, +} + +impl Tag { + pub fn load(root: &Path, name: &str, reference: String) -> Result { + if !reference.starts_with("refs/tags/") { + return Err(super::Error::NotFound); + } + let repository = super::repository::open(root, name)?; + let info = super::repository::info(&repository, name); + let git_reference = repository + .find_reference(&reference) + .map_err(super::Error::from_git)?; + let oid = git_reference + .resolve() + .map_err(super::Error::from_git)? + .target() + .ok_or(super::Error::NotFound)?; + let object = repository + .find_object(oid, None) + .map_err(super::Error::from_git)?; + let downloadable = object.peel_to_commit().is_ok(); + let (targets, annotated, tagger, tagger_email, timestamp, message) = + if let Some(tag) = object.as_tag() { + let tagger = tag.tagger(); + ( + targets(tag)?, + true, + tagger.as_ref().map_or_else(String::new, signature_name), + tagger.as_ref().map_or_else(String::new, signature_email), + tagger.as_ref().map(|tagger| tagger.when().seconds()), + String::from_utf8_lossy(tag.message_bytes().unwrap_or_default()).into_owned(), + ) + } else { + ( + vec![Target { + oid: object.id().to_string(), + commit: object.kind() == Some(git2::ObjectType::Commit), + }], + false, + String::new(), + String::new(), + None, + String::new(), + ) + }; + Ok(Self { + repository: info, + reference: reference.clone(), + name: reference["refs/tags/".len()..].to_owned(), + oid: oid.to_string(), + targets, + annotated, + tagger, + tagger_email, + timestamp, + message, + downloadable, + }) + } +} + +fn targets(tag: &git2::Tag<'_>) -> Result, super::Error> { + let mut targets = Vec::new(); + let mut object = tag.target().map_err(super::Error::from_git)?; + loop { + targets.push(Target { + oid: object.id().to_string(), + commit: object.kind() == Some(git2::ObjectType::Commit), + }); + let Some(tag) = object.as_tag() else { + break; + }; + object = tag.target().map_err(super::Error::from_git)?; + } + Ok(targets) +} + +fn signature_name(signature: &git2::Signature<'_>) -> String { + signature + .name() + .map(str::to_owned) + .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()) +} + +fn signature_email(signature: &git2::Signature<'_>) -> String { + signature + .email() + .map(str::to_owned) + .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()) +} diff --git a/crates/gilti/src/models/tree.rs b/crates/gilti/src/models/tree.rs new file mode 100644 index 0000000..14985bb --- /dev/null +++ b/crates/gilti/src/models/tree.rs @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::path::Path; + +pub struct Tree { + pub repository: super::repository::Info, + pub revision: String, + pub path: Option, + pub content: Content, +} + +pub enum Content { + Directory { + oid: String, + entries: Vec, + }, + Blob { + oid: String, + bytes: Vec, + binary: bool, + }, +} + +pub struct Entry { + pub mode: u32, + pub name: String, + pub path: String, + pub oid: String, + pub kind: Kind, + pub size: usize, + pub symlink_target: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Kind { + Tree, + Blob, + Submodule, +} + +impl Tree { + pub fn load( + root: &Path, + name: &str, + revision: crate::router::Revision, + path: Option, + ) -> Result { + let repository = super::repository::open(root, name)?; + let info = super::repository::info(&repository, name); + let selector = super::revision::selector(&revision); + let commit = super::revision::commit(&repository, &revision)?; + let root_tree = commit.tree().map_err(super::Error::from_git)?; + let content = match path.as_deref() { + None => directory(&repository, &root_tree, "")?, + Some(path) => { + let entry = root_tree + .get_path(Path::new(path)) + .map_err(super::Error::from_git)?; + match entry.kind() { + Some(git2::ObjectType::Tree) => { + let tree = repository + .find_tree(entry.id()) + .map_err(super::Error::from_git)?; + directory(&repository, &tree, path)? + } + Some(git2::ObjectType::Blob) => { + let blob = repository + .find_blob(entry.id()) + .map_err(super::Error::from_git)?; + let bytes = blob.content().to_vec(); + let binary = bytes.iter().take(8000).any(|byte| *byte == 0); + Content::Blob { + oid: entry.id().to_string(), + bytes, + binary, + } + } + _ => return Err(super::Error::NotFound), + } + } + }; + Ok(Self { + repository: info, + revision: selector, + path, + content, + }) + } +} + +fn directory( + repository: &git2::Repository, + tree: &git2::Tree<'_>, + base: &str, +) -> Result { + let odb = repository.odb().map_err(super::Error::from_git)?; + let mut entries = Vec::with_capacity(tree.len()); + for entry in tree { + let name = String::from_utf8_lossy(entry.name_bytes()).into_owned(); + let path = if base.is_empty() { + name.clone() + } else { + format!("{base}/{name}") + }; + let kind = match entry.kind() { + Some(git2::ObjectType::Tree) => Kind::Tree, + Some(git2::ObjectType::Commit) => Kind::Submodule, + Some(git2::ObjectType::Blob) => Kind::Blob, + _ => continue, + }; + let size = if kind == Kind::Submodule { + 0 + } else { + odb.read_header(entry.id()) + .map_err(super::Error::from_git)? + .0 + }; + let symlink_target = if entry.filemode_raw() == 0o120000 { + repository + .find_blob(entry.id()) + .ok() + .map(|blob| String::from_utf8_lossy(blob.content()).into_owned()) + } else { + None + }; + entries.push(Entry { + mode: entry.filemode_raw() as u32, + name, + path, + oid: entry.id().to_string(), + kind, + size, + symlink_target, + }); + } + Ok(Content::Directory { + oid: tree.id().to_string(), + entries, + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn loads_directories_blobs_modes_and_symlinks() { + let root = std::env::temp_dir().join(format!("gilti-tree-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let repository = git2::Repository::init_bare(root.join("example.git")).unwrap(); + let odb = repository.odb().unwrap(); + let readme = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap(); + let executable = odb.write(git2::ObjectType::Blob, b"#!/bin/sh\n").unwrap(); + let symlink = odb.write(git2::ObjectType::Blob, b"README.md").unwrap(); + let nested = odb.write(git2::ObjectType::Blob, b"nested\n").unwrap(); + let subtree = { + let mut tree = repository.treebuilder(None).unwrap(); + tree.insert("file.txt", nested, 0o100644).unwrap(); + tree.write().unwrap() + }; + let tree_oid = { + let mut tree = repository.treebuilder(None).unwrap(); + tree.insert("README.md", readme, 0o100644).unwrap(); + tree.insert("run", executable, 0o100755).unwrap(); + tree.insert("link", symlink, 0o120000).unwrap(); + tree.insert("dir", subtree, 0o040000).unwrap(); + tree.write().unwrap() + }; + let tree = repository.find_tree(tree_oid).unwrap(); + let signature = git2::Signature::now("Author", "author@example.test").unwrap(); + repository + .commit( + Some("refs/heads/main"), + &signature, + &signature, + "Tree fixture", + &tree, + &[], + ) + .unwrap(); + repository.set_head("refs/heads/main").unwrap(); + drop(tree); + drop(odb); + drop(repository); + + let root_tree = + super::Tree::load(&root, "example", crate::router::Revision::Head, None).unwrap(); + let super::Content::Directory { entries, .. } = root_tree.content else { + panic!("expected directory") + }; + assert_eq!(entries.len(), 4); + assert_eq!( + entries + .iter() + .find(|entry| entry.name == "run") + .unwrap() + .mode, + 0o100755 + ); + assert_eq!( + entries + .iter() + .find(|entry| entry.name == "link") + .unwrap() + .symlink_target + .as_deref(), + Some("README.md") + ); + assert_eq!( + entries + .iter() + .find(|entry| entry.name == "dir") + .unwrap() + .kind, + super::Kind::Tree + ); + + let blob = super::Tree::load( + &root, + "example", + crate::router::Revision::Head, + Some("dir/file.txt".to_owned()), + ) + .unwrap(); + let super::Content::Blob { bytes, binary, .. } = blob.content else { + panic!("expected blob") + }; + assert_eq!(bytes, b"nested\n"); + assert!(!binary); + assert!(matches!( + super::Tree::load( + &root, + "example", + crate::router::Revision::Head, + Some("missing".to_owned()) + ), + Err(crate::models::Error::NotFound) + )); + assert!(matches!( + super::Tree::load( + &root, + "example", + crate::router::Revision::Commit(readme.to_string()), + None + ), + Err(crate::models::Error::NotFound) + )); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/gilti/src/ui.rs b/crates/gilti/src/ui.rs deleted file mode 100644 index 796baa5..0000000 --- a/crates/gilti/src/ui.rs +++ /dev/null @@ -1,296 +0,0 @@ -// 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, - pager: Vec, -} - -#[derive(Deserialize)] -struct Shell { - embedded: bool, - robots: String, - css: Vec, - js: Vec, - favicon: String, - head_include: Option, - header: Option, - footer_configured: bool, - footer: Option, - 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), -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Section { - section: Option, -} - -#[derive(Deserialize)] -struct Repo { - name: String, - section: Option, - url: String, - description: Description, - owner: String, - owner_url: String, - idle: Option, - log_url: Option, - tree_url: Option, -} - -#[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 { - 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) -> 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="/-/assets/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="/-/assets/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" } @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" } } } } - }, - } - } - } - @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":"","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":"","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":"","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("")); - } - - #[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("
configured
".into()); - assert!( - super::footer(&shell, true) - .into_string() - .contains("
configured
") - ); - } - - #[test] - fn rejects_invalid_model() { - assert!(super::render(br#"{"page":"repolist"}"#).is_err()); - } -} diff --git a/crates/gilti/src/views/about.rs b/crates/gilti/src/views/about.rs new file mode 100644 index 0000000..5e56a84 --- /dev/null +++ b/crates/gilti/src/views/about.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::html; + +pub async fn serve( + context: &super::shared::Context, + route: crate::router::RepoRoute<()>, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return super::method_not_allowed(); + } + let repositories = context.repositories; + let model = tokio::task::spawn_blocking(move || { + crate::models::about::About::load(std::path::Path::new(repositories), &route.repo) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + let readme = String::from_utf8_lossy(&model.bytes); + let content = html! { div id="summary" { pre { (readme) } } }; + super::shared::render( + context, + &model.repository, + "HEAD", + super::shared::Page::About, + content, + &method, + ) +} diff --git a/crates/gilti/src/views/archive_signature.rs b/crates/gilti/src/views/archive_signature.rs new file mode 100644 index 0000000..6f257ad --- /dev/null +++ b/crates/gilti/src/views/archive_signature.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub async fn serve( + context: &super::shared::Context, + route: crate::router::RepoRoute, + format: Option<&str>, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return super::method_not_allowed(); + } + let repositories = context.repositories; + let format = format.unwrap_or("tar.gz").to_owned(); + let model = tokio::task::spawn_blocking(move || { + crate::models::archive_signature::ArchiveSignature::load( + std::path::Path::new(repositories), + &route.repo, + route.params, + &format, + ) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + let length = model.bytes.len(); + let body = if method == axum::http::Method::HEAD { + axum::body::Body::empty() + } else { + axum::body::Body::from(model.bytes) + }; + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header( + axum::http::header::CONTENT_TYPE, + "application/pgp-signature; charset=UTF-8", + ) + .header(axum::http::header::CONTENT_LENGTH, length) + .header( + axum::http::header::CONTENT_DISPOSITION, + format!("inline; filename=\"{}\"", model.filename), + ) + .header(axum::http::header::ETAG, format!("\"{}\"", model.oid)) + .header("x-content-type-options", "nosniff") + .header("content-security-policy", "default-src 'none'") + .body(body) + .expect("archive signature response is valid") +} diff --git a/crates/gilti/src/views/blame.rs b/crates/gilti/src/views/blame.rs new file mode 100644 index 0000000..0a5f4eb --- /dev/null +++ b/crates/gilti/src/views/blame.rs @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{Markup, html}; + +pub async fn serve( + context: &super::shared::Context, + route: crate::router::RepoRoute, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return super::method_not_allowed(); + } + let repositories = context.repositories; + let model = tokio::task::spawn_blocking(move || { + crate::models::blame::Blame::load( + std::path::Path::new(repositories), + &route.repo, + route.params.rev, + route.params.path, + ) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + let content = content(&model); + super::shared::render( + context, + &model.repository, + &model.revision, + super::shared::Page::Tree, + content, + &method, + ) +} + +fn content(model: &crate::models::blame::Blame) -> Markup { + let repo = super::shared::repository_url(&model.repository.name); + let revision = super::shared::encode_path(&model.revision); + let path = super::shared::encode_path(&model.path); + html! { + div class="path" { "path: " (breadcrumbs(&repo, &revision, &model.path)) } + "blob: " (&model.oid) " (" + a href=(format!("{repo}/+/{revision}/+/tree/{path}?format=raw")) { "plain" } + ") (" + a href=(format!("{repo}/+/{revision}/+/tree/{path}")) { "tree" } + ")" + @if model.binary { + div class="error" { "blob is binary." } + } @else { + (blame_table(model, &repo)) + } + } +} + +fn blame_table(model: &crate::models::blame::Blame, repo: &str) -> Markup { + let lines = String::from_utf8_lossy(&model.bytes) + .split_inclusive('\n') + .map(str::to_owned) + .collect::>(); + html! { table class="blame blob" { + @for hunk in &model.hunks { + @for offset in 0..hunk.lines { + @let line = hunk.start + offset; + tr class=[(offset % 2 == 0).then_some("alt")] { + @if offset == 0 { + td class="hashes" rowspan=(hunk.lines) { + span class="oid" title=(format!( + "author {} <{}> {}\ncommitter {} <{}>\n\n{}", + hunk.author, hunk.author_email, + super::shared::age(hunk.timestamp), + hunk.committer, hunk.committer_email, + hunk.summary + )) { + a href=(format!("{repo}/+/{}", hunk.oid)) { (&hunk.short_oid) } + } + @if let Some(parent) = &hunk.parent { + " " a href=(format!( + "{repo}/+/{parent}/+/blame/{}", + super::shared::encode_path(&hunk.original_path) + )) title="Blame the previous revision" { "^" } + } + } + } + td class="linenumbers" { a id=(format!("n{line}")) href=(format!("#n{line}")) { (line) } } + td class="lines" { pre { code { (lines.get(line - 1).map_or("", String::as_str)) } } } + } + } + } + } } +} + +fn breadcrumbs(repository: &str, revision: &str, path: &str) -> Markup { + let parts = path.split('/').collect::>(); + html! { + a href=(format!("{repository}/+/{revision}/+/tree")) { "root" } + @for (index, part) in parts.iter().enumerate() { + "/" + @let current = parts[..=index].join("/"); + @if index + 1 == parts.len() { + a href=(format!("{repository}/+/{revision}/+/blame/{}", super::shared::encode_path(¤t))) { (part) } + } @else { + a href=(format!("{repository}/+/{revision}/+/tree/{}", super::shared::encode_path(¤t))) { (part) } + } + } + } +} diff --git a/crates/gilti/src/views/mod.rs b/crates/gilti/src/views/mod.rs new file mode 100644 index 0000000..177a25b --- /dev/null +++ b/crates/gilti/src/views/mod.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub mod about; +pub mod archive_signature; +pub mod blame; +pub mod object; +pub mod overview; +pub mod refs; +pub mod repositories; +pub mod shared; +pub mod tag; +pub mod tree; + +fn method_not_allowed() -> axum::response::Response { + axum::response::Response::builder() + .status(axum::http::StatusCode::METHOD_NOT_ALLOWED) + .header(axum::http::header::CONTENT_TYPE, "text/plain") + .body(axum::body::Body::from("method not allowed\n")) + .expect("static response is valid") +} + +fn error(error: crate::models::Error) -> axum::response::Response { + let (status, message) = match error { + crate::models::Error::NotFound => (axum::http::StatusCode::NOT_FOUND, "not found\n"), + crate::models::Error::Internal(message) => { + eprintln!("gilti: repository view failed: {message}"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "internal server error\n", + ) + } + }; + axum::response::Response::builder() + .status(status) + .header(axum::http::header::CONTENT_TYPE, "text/plain") + .body(axum::body::Body::from(message)) + .expect("static response is valid") +} diff --git a/crates/gilti/src/views/object.rs b/crates/gilti/src/views/object.rs new file mode 100644 index 0000000..4242c86 --- /dev/null +++ b/crates/gilti/src/views/object.rs @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub async fn serve( + repositories: &'static str, + route: crate::router::RepoRoute, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return axum::response::Response::builder() + .status(axum::http::StatusCode::METHOD_NOT_ALLOWED) + .header(axum::http::header::CONTENT_TYPE, "text/plain") + .body(axum::body::Body::from("method not allowed\n")) + .expect("static response is valid"); + } + let model = tokio::task::spawn_blocking(move || { + crate::models::object::RawObject::load( + std::path::Path::new(repositories), + &route.repo, + &route.params, + ) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + let length = model.bytes.len(); + let body = if method == axum::http::Method::HEAD { + axum::body::Body::empty() + } else { + axum::body::Body::from(model.bytes) + }; + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header( + axum::http::header::CONTENT_TYPE, + if model.binary { + "application/octet-stream; charset=UTF-8" + } else { + "text/plain; charset=UTF-8" + }, + ) + .header(axum::http::header::CONTENT_LENGTH, length) + .header("x-content-type-options", "nosniff") + .header("content-security-policy", "default-src 'none'") + .body(body) + .expect("object response is valid") +} diff --git a/crates/gilti/src/views/overview.rs b/crates/gilti/src/views/overview.rs new file mode 100644 index 0000000..0ab95d6 --- /dev/null +++ b/crates/gilti/src/views/overview.rs @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{Markup, html}; + +pub async fn serve( + context: &super::shared::Context, + route: crate::router::RepoRoute<()>, + headers: &axum::http::HeaderMap, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return super::method_not_allowed(); + } + let clone_url = clone_url(context, headers, &route.repo); + let repositories = context.repositories; + let model = tokio::task::spawn_blocking(move || { + crate::models::overview::Overview::load(std::path::Path::new(repositories), &route.repo) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + let content = content(&model, &clone_url); + super::shared::render( + context, + &model.repository, + "HEAD", + super::shared::Page::Summary, + content, + &method, + ) +} + +fn content(model: &crate::models::overview::Overview, clone_url: &str) -> Markup { + let repo = super::shared::repository_url(&model.repository.name); + html! { + @if model.empty { + div class="error" { "Repository seems to be empty" } + } + table summary="repository info" class="list nowrap" { + @if !model.empty { + tr class="nohover" { + th class="left" { "Branch" } + th class="left" { "Commit message" } + th class="left" { "Author" } + th class="left" colspan="2" { "Age" } + } + @for branch in &model.branches { + @let revision = super::shared::encode_path(&branch.reference); + tr { + td { a href=(format!("{repo}/+/{revision}/+/log")) { (&branch.name) } } + td { a href=(format!("{repo}/+/{revision}")) { (&branch.subject) } } + td { (&branch.author) } + td colspan="2" { (super::shared::age(branch.timestamp)) } + } + } + @if !model.tags.is_empty() { + tr class="nohover" { td colspan="5" { " " } } + tr class="nohover" { + th class="left" { "Tag" } + th class="left" { "Download" } + th class="left" { "Author" } + th class="left" colspan="2" { "Age" } + } + @for tag in &model.tags { + @let revision = super::shared::encode_path(&tag.reference); + tr { + td { a href=(format!("{repo}/+/{revision}")) { (&tag.name) } } + td { + @if tag.downloadable { + @for (index, format) in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"].iter().enumerate() { + @if index > 0 { "  " } + (archive_link(&repo, &revision, format)) + } + } @else { + a href=(format!("{repo}/+/object/{}", tag.target)) { (&tag.target) } + } + } + td { (&tag.author) } + td colspan="2" { (super::shared::age(tag.timestamp)) } + } + } + } + @if !model.commits.is_empty() { + tr class="nohover" { td colspan="5" { " " } } + tr class="nohover" { + th class="left" { "Age" } + th class="left" { "Commit message" } + th class="left" { "Author" } + th class="left" { "Files" } + th class="left" { "Lines" } + } + @for commit in &model.commits { + tr { + td { (super::shared::age(commit.timestamp)) } + td { + a href=(format!("{repo}/+/{}", commit.oid)) { (&commit.subject) } + @if !commit.decorations.is_empty() { span class="decoration" { + @for decoration in &commit.decorations { + @if let Some(reference) = &decoration.reference { + @let revision = super::shared::encode_path(reference); + a class=(if decoration.tag { "tag-annotated-deco" } else { "branch-deco" }) href=(if decoration.tag { format!("{repo}/+/{revision}") } else { format!("{repo}/+/{revision}/+/log") }) { (&decoration.label) } + } @else { + a class="deco" href=(format!("{repo}/+/{}", commit.oid)) { (&decoration.label) } + } + } + } } + } + td { (&commit.author) } + td { (commit.files) } + td { span class="deletions" { "-" (commit.deletions) } "/" span class="insertions" { "+" (commit.insertions) } } + } + } + } + } + tr class="nohover" { td colspan="5" { " " } } + tr class="nohover" { th class="left" colspan="5" { "Clone" } } + tr { td colspan="5" { a rel="vcs-git" href=(clone_url) { (clone_url) } } } + } + } +} + +fn archive_link(repository: &str, revision: &str, format: &str) -> Markup { + html! { a href=(format!("{repository}/+/{revision}/+/archive?format={format}")) { (format) } } +} + +fn clone_url( + context: &super::shared::Context, + headers: &axum::http::HeaderMap, + repository: &str, +) -> String { + let repository = super::shared::encode_path(repository); + if !context.clone_prefix.is_empty() { + return format!( + "{}/{}.git", + context.clone_prefix.trim_end_matches('/'), + repository + ); + } + let scheme = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .filter(|value| matches!(*value, "http" | "https")) + .unwrap_or("http"); + let host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or("localhost"); + format!("{scheme}://{host}/{repository}.git") +} diff --git a/crates/gilti/src/views/refs.rs b/crates/gilti/src/views/refs.rs new file mode 100644 index 0000000..e47538d --- /dev/null +++ b/crates/gilti/src/views/refs.rs @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{Markup, html}; + +pub async fn serve( + context: &super::shared::Context, + route: crate::router::RepoRoute<()>, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return super::method_not_allowed(); + } + let repositories = context.repositories; + let model = tokio::task::spawn_blocking(move || { + crate::models::refs::Refs::load(std::path::Path::new(repositories), &route.repo) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + let content = content(&model); + super::shared::render( + context, + &model.repository, + "HEAD", + super::shared::Page::Refs, + content, + &method, + ) +} + +fn content(model: &crate::models::refs::Refs) -> Markup { + let repo = super::shared::repository_url(&model.repository.name); + html! { table class="list nowrap" { + tr class="nohover" { + th class="left" { "Branch" } + th class="left" { "Commit message" } + th class="left" { "Author" } + th class="left" colspan="2" { "Age" } + } + @for branch in &model.branches { + @let revision = super::shared::encode_path(&branch.reference); + tr { + td { a href=(format!("{repo}/+/{revision}/+/log")) { (&branch.name) } } + td { a href=(format!("{repo}/+/{revision}")) { (&branch.subject) } } + td { (&branch.author) } + td colspan="2" { (super::shared::age(branch.timestamp)) } + } + } + @if !model.tags.is_empty() { + tr class="nohover" { td colspan="5" { " " } } + tr class="nohover" { + th class="left" { "Tag" } + th class="left" { "Download" } + th class="left" { "Author" } + th class="left" colspan="2" { "Age" } + } + } + @for tag in &model.tags { + @let revision = super::shared::encode_path(&tag.reference); + tr { + td { a href=(format!("{repo}/+/{revision}")) { (&tag.name) } } + td { + @if tag.downloadable { + @for (index, format) in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"].iter().enumerate() { + @if index > 0 { "  " } + (archive_link(&repo, &revision, format)) + } + } @else { + a href=(format!("{repo}/+/object/{}", tag.target)) { (&tag.target) } + } + } + td { (&tag.author) } + td colspan="2" { (super::shared::age(tag.timestamp)) } + } + } + } } +} + +fn archive_link(repository: &str, revision: &str, format: &str) -> Markup { + html! { a href=(format!("{repository}/+/{revision}/+/archive?format={format}")) { (format) } } +} diff --git a/crates/gilti/src/views/repositories.rs b/crates/gilti/src/views/repositories.rs new file mode 100644 index 0000000..0722d5c --- /dev/null +++ b/crates/gilti/src/views/repositories.rs @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{DOCTYPE, html}; + +pub struct Query { + search: Option, + sort: crate::models::repositories::Sort, + sort_name: &'static str, + offset: usize, +} + +impl Query { + pub fn from_request(query: &crate::RequestQuery) -> Self { + let (sort, sort_name) = match query.value("GILTI_QUERY_SORT") { + Some("desc") => (crate::models::repositories::Sort::Description, "desc"), + Some("owner") => (crate::models::repositories::Sort::Owner, "owner"), + Some("idle") => (crate::models::repositories::Sort::Idle, "idle"), + _ => (crate::models::repositories::Sort::Name, "name"), + }; + Self { + search: query.value("GILTI_QUERY_SEARCH").map(str::to_owned), + sort, + sort_name, + offset: query + .value("GILTI_QUERY_OFFSET") + .and_then(|offset| offset.parse().ok()) + .unwrap_or(0), + } + } +} + +pub async fn serve( + context: &super::shared::Context, + query: Query, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return super::method_not_allowed(); + } + let repositories = context.repositories; + let filter = crate::models::repositories::Filter { + search: query.search.clone(), + sort: query.sort, + offset: query.offset, + limit: 50, + }; + let model = tokio::task::spawn_blocking(move || { + crate::models::repositories::Repositories::load(std::path::Path::new(repositories), filter) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + let document = html! { + (DOCTYPE) + html lang="en" { + head { + title { (&*context.root_title) } + meta name="generator" content="Gilti"; + meta name="robots" content="index, nofollow"; + link rel="stylesheet" type="text/css" href="/-/assets/cgit.css"; + script type="text/javascript" src="/-/assets/cgit.js" {} + link rel="shortcut icon" href="/-/assets/favicon.ico"; + } + body { div id="cgit" { + table id="header" { + tr { + td class="logo" rowspan="2" { a href="/" { img src="/-/assets/cgit.png" alt="cgit logo"; } } + td class="main" { (&*context.root_title) } + } + tr { td class="sub" { (&*context.root_description) } } + } + table class="tabs" { tr { + td { a href="/" class="active" { "index" } } + td class="form" { form method="get" action="/" { + input type="search" name="q" size="10" value=(query.search.as_deref().unwrap_or_default()); + input type="submit" value="search"; + } } + } } + div class="content" { table summary="repository list" class="list nowrap" { + @if !model.rows.is_empty() { + tr class="nohover" { + th class="left" { a href=(sort_url("name", query.search.as_deref())) { "Name" } } + th class="left" { a href=(sort_url("desc", query.search.as_deref())) { "Description" } } + th class="left" { a href=(sort_url("owner", query.search.as_deref())) { "Owner" } } + th class="left" { a href=(sort_url("idle", query.search.as_deref())) { "Idle" } } + th class="left" { "Links" } + } + } + @for repository in &model.rows { + @let url = super::shared::repository_url(&repository.name); + tr { + td class="toplevel-repo" { a href=(&url) { (&repository.name) } } + td { a href=(&url) { (description(&repository.description)) } } + td { a href="/?q=" {} } + td { @if let Some(timestamp) = repository.timestamp { (super::shared::age(timestamp)) } } + td { + a class="button" href=(&url) { "summary" } + @if repository.populated { + a class="button" href=(format!("{url}/+/HEAD/+/log")) { "log" } + a class="button" href=(format!("{url}/+/HEAD/+/tree")) { "tree" } + } + } + } + } + } + @if model.offset > 0 || model.has_next { + ul class="pager" { + @if model.offset > 0 { + li { a href=(page_url(model.offset.saturating_sub(50), query.sort_name, query.search.as_deref())) { "[previous]" } } + } + @if model.has_next { + li { a href=(page_url(model.offset + 50, query.sort_name, query.search.as_deref())) { "[next]" } } + } + } + } + } + div class="footer" { "generated by Gilti" } + } } + } + } + .into_string(); + let length = document.len(); + let body = if method == axum::http::Method::HEAD { + axum::body::Body::empty() + } else { + axum::body::Body::from(document) + }; + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8") + .header(axum::http::header::CONTENT_LENGTH, length) + .body(body) + .expect("repository list response is valid") +} + +fn description(description: &str) -> String { + let mut characters = description.chars(); + let shown = characters.by_ref().take(80).collect::(); + if characters.next().is_some() { + format!("{shown}...") + } else { + shown + } +} + +fn sort_url(sort: &str, search: Option<&str>) -> String { + search.map_or_else( + || format!("/?s={sort}"), + |search| format!("/?s={sort}&q={}", query_value(search)), + ) +} + +fn page_url(offset: usize, sort: &str, search: Option<&str>) -> String { + let mut url = format!("/?ofs={offset}&s={sort}"); + if let Some(search) = search { + url.push_str("&q="); + url.push_str(&query_value(search)); + } + url +} + +fn query_value(value: &str) -> String { + percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string() +} diff --git a/crates/gilti/src/views/shared.rs b/crates/gilti/src/views/shared.rs new file mode 100644 index 0000000..5e429c6 --- /dev/null +++ b/crates/gilti/src/views/shared.rs @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{DOCTYPE, Markup, html}; + +#[derive(Clone)] +pub struct Context { + pub repositories: &'static str, + pub root_title: std::sync::Arc, + pub root_description: std::sync::Arc, + pub clone_prefix: std::sync::Arc, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum Page { + About, + Summary, + Refs, + Log, + Tree, + Revision, + Diff, + Stats, +} + +pub fn render( + context: &Context, + repository: &crate::models::repository::Info, + revision: &str, + active: Page, + content: Markup, + method: &axum::http::Method, +) -> axum::response::Response { + let repo = repository_url(&repository.name); + let rev = encode_path(revision); + let title = format!("{} - {}", repository.name, repository.description); + let document = html! { + (DOCTYPE) + html lang="en" { + head { + title { (title) } + meta name="generator" content="Gilti"; + meta name="robots" content="index, nofollow"; + link rel="stylesheet" type="text/css" href="/-/assets/cgit.css"; + script type="text/javascript" src="/-/assets/cgit.js" {} + link rel="shortcut icon" href="/-/assets/favicon.ico"; + } + body { div id="cgit" { + table id="header" { + tr { + td class="logo" rowspan="2" { a href="/" { img src="/-/assets/cgit.png" alt="cgit logo"; } } + td class="main" { (&*context.root_title) " : " a href=(&repo) { (&repository.name) } } + } + tr { td class="sub" { (&repository.description) } } + } + table class="tabs" { tr { td { + (tab(&format!("{repo}/+/about"), "about", active == Page::About)) + (tab(&repo, "summary", active == Page::Summary)) + (tab(&format!("{repo}/+/refs"), "refs", active == Page::Refs)) + (tab(&format!("{repo}/+/{rev}/+/log"), "log", active == Page::Log)) + (tab(&format!("{repo}/+/{rev}/+/tree"), "tree", active == Page::Tree)) + (tab(&format!("{repo}/+/{rev}"), "commit", active == Page::Revision)) + (tab(&format!("{repo}/+/diff/HEAD..{rev}"), "diff", active == Page::Diff)) + (tab(&format!("{repo}/+/stats"), "stats", active == Page::Stats)) + } } } + div class="content" { (content) } + div class="footer" { "generated by Gilti" } + } } + } + } + .into_string(); + let length = document.len(); + let body = if method == axum::http::Method::HEAD { + axum::body::Body::empty() + } else { + axum::body::Body::from(document) + }; + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8") + .header(axum::http::header::CONTENT_LENGTH, length) + .body(body) + .expect("HTML response is valid") +} + +fn tab(url: &str, label: &str, active: bool) -> Markup { + html! { a href=(url) class=[active.then_some("active")] { (label) } } +} + +pub fn repository_url(repository: &str) -> String { + format!("/{}", encode_path(repository)) +} + +pub 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 +} + +pub fn age(timestamp: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs() as i64); + let seconds = now.saturating_sub(timestamp).max(0); + let (amount, unit) = if seconds < 120 * 60 { + (seconds / 60, "min.") + } else if seconds < 48 * 60 * 60 { + (seconds / (60 * 60), "hours") + } else if seconds < 14 * 24 * 60 * 60 { + (seconds / (24 * 60 * 60), "days") + } else if seconds < 60 * 24 * 60 * 60 { + (seconds / (7 * 24 * 60 * 60), "weeks") + } else if seconds < 730 * 24 * 60 * 60 { + (seconds / (30 * 24 * 60 * 60), "months") + } else { + (seconds / (365 * 24 * 60 * 60), "years") + }; + format!("{amount} {unit} ago") +} diff --git a/crates/gilti/src/views/tag.rs b/crates/gilti/src/views/tag.rs new file mode 100644 index 0000000..e4256ae --- /dev/null +++ b/crates/gilti/src/views/tag.rs @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{Markup, html}; + +pub async fn serve( + context: &super::shared::Context, + route: crate::router::RepoRoute, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return super::method_not_allowed(); + } + let crate::router::Revision::Ref(reference) = route.params else { + return super::error(crate::models::Error::NotFound); + }; + let repositories = context.repositories; + let model = tokio::task::spawn_blocking(move || { + crate::models::tag::Tag::load(std::path::Path::new(repositories), &route.repo, reference) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + let content = content(&model); + super::shared::render( + context, + &model.repository, + &model.reference, + super::shared::Page::Revision, + content, + &method, + ) +} + +fn content(model: &crate::models::tag::Tag) -> Markup { + let repo = super::shared::repository_url(&model.repository.name); + let revision = super::shared::encode_path(&model.reference); + html! { + table class="commit-info" { + tr { td { "tag name" } td { (&model.name) @if model.annotated { " (" (&model.oid) ")" } } } + @if let Some(timestamp) = model.timestamp { + tr { td { "tag date" } td { (super::shared::age(timestamp)) } } + } + @if model.annotated { + tr { td { "tagged by" } td { (&model.tagger) " <" (&model.tagger_email) ">" } } + } + tr { td { "tagged object" } td class="oid" { + @for (index, target) in model.targets.iter().enumerate() { + @if index > 0 { " → " } + @if target.commit { + a href=(format!("{repo}/+/{}", target.oid)) { (&target.oid) } + } @else { + a href=(format!("{repo}/+/object/{}", target.oid)) { (&target.oid) } + } + } + } } + @if model.downloadable { + tr { td { "download" } td class="oid" { + @for format in ["tar", "tar.gz", "tar.bz2", "tar.lz", "tar.xz", "tar.zst", "zip"] { + a href=(format!("{repo}/+/{revision}/+/archive?format={format}")) { (format) } br; + } + } } + } + } + @if model.annotated { + @let mut lines = model.message.splitn(2, '\n'); + div class="commit-subject" { (lines.next().unwrap_or_default()) } + div class="commit-msg" { (lines.next().unwrap_or_default()) } + } + } +} diff --git a/crates/gilti/src/views/tree.rs b/crates/gilti/src/views/tree.rs new file mode 100644 index 0000000..503f182 --- /dev/null +++ b/crates/gilti/src/views/tree.rs @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{DOCTYPE, Markup, html}; + +pub async fn serve( + context: &super::shared::Context, + route: crate::router::RepoRoute, + format: Option<&str>, + method: axum::http::Method, +) -> axum::response::Response { + if method != axum::http::Method::GET && method != axum::http::Method::HEAD { + return super::method_not_allowed(); + } + let repositories = context.repositories; + let model = tokio::task::spawn_blocking(move || { + crate::models::tree::Tree::load( + std::path::Path::new(repositories), + &route.repo, + route.params.rev, + route.params.path, + ) + }) + .await; + let model = match model { + Ok(Ok(model)) => model, + Ok(Err(error)) => return super::error(error), + Err(error) => { + return super::error(crate::models::Error::Internal(error.to_string())); + } + }; + if format == Some("raw") { + return raw(model, &method); + } + let content = html_content(&model); + super::shared::render( + context, + &model.repository, + &model.revision, + super::shared::Page::Tree, + content, + &method, + ) +} + +fn html_content(model: &crate::models::tree::Tree) -> Markup { + let repo = super::shared::repository_url(&model.repository.name); + let revision = super::shared::encode_path(&model.revision); + let prefix = format!("{repo}/+/{revision}/+/tree"); + html! { + @if let Some(path) = &model.path { + div class="path" { "path: " (breadcrumbs(&prefix, path)) } + } + @match &model.content { + crate::models::tree::Content::Directory { entries, .. } => { + table summary="tree listing" class="list" { + tr class="nohover" { + th class="left" { "Mode" } + th class="left" { "Name" } + th class="right" { "Size" } + th {} + } + @for entry in entries { + @let path = super::shared::encode_path(&entry.path); + tr { + td class="ls-mode" { (filemode(entry.mode)) } + td { + @match entry.kind { + crate::models::tree::Kind::Submodule => { + span class="ls-mod" { (&entry.name) " @ " (&entry.oid) } + } + _ => { + a href=(format!("{prefix}/{path}")) class=(entry_class(entry)) { (&entry.name) } + @if let Some(target) = &entry.symlink_target { + " -> " a href=(format!("{prefix}/{}", super::shared::encode_path(&symlink_path(&entry.path, target)))) class="ls-blob" { (target) } + } + } + } + } + td class="ls-size" { (entry.size) } + td { + a class="button" href=(format!("{repo}/+/{revision}/+/log/{path}")) { "log" } + a class="button" href=(format!("{repo}/+/stats")) { "stats" } + @if entry.kind != crate::models::tree::Kind::Submodule { + a class="button" href=(format!("{prefix}/{path}?format=raw")) { "plain" } + } + @if entry.kind == crate::models::tree::Kind::Blob && entry.symlink_target.is_none() { + a class="button" href=(format!("{repo}/+/{revision}/+/blame/{path}")) { "blame" } + } + } + } + } + } + } + crate::models::tree::Content::Blob { oid, bytes, binary } => { + "blob: " (oid) " (" + a href=(format!("{prefix}/{}?format=raw", super::shared::encode_path(model.path.as_deref().unwrap_or_default()))) { "plain" } + @if !binary { ") (" a href=(format!("{repo}/+/{revision}/+/blame/{}", super::shared::encode_path(model.path.as_deref().unwrap_or_default()))) { "blame" } } + ")" + @if *binary { (binary_blob(bytes)) } @else { (text_blob(bytes)) } + } + } + } +} + +fn filemode(mode: u32) -> String { + let mut value = String::with_capacity(10); + value.push(match mode { + 0o040000 => 'd', + 0o120000 => 'l', + 0o160000 => 'm', + _ => '-', + }); + for bit in [ + 0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001, + ] { + value.push(if mode & bit == 0 { + '-' + } else { + match bit { + 0o400 | 0o040 | 0o004 => 'r', + 0o200 | 0o020 | 0o002 => 'w', + _ => 'x', + } + }); + } + value +} + +fn entry_class(entry: &crate::models::tree::Entry) -> String { + if entry.kind == crate::models::tree::Kind::Tree { + return "ls-dir".to_owned(); + } + let extension = entry.name.rsplit_once('.').map(|(_, extension)| extension); + extension.map_or_else( + || "ls-blob".to_owned(), + |extension| format!("ls-blob {extension}"), + ) +} + +fn symlink_path(path: &str, target: &str) -> String { + let mut parts = path.rsplit_once('/').map_or_else(Vec::new, |(parent, _)| { + parent.split('/').collect::>() + }); + for part in target.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + part => parts.push(part), + } + } + parts.join("/") +} + +fn breadcrumbs(prefix: &str, path: &str) -> Markup { + let parts = path.split('/').collect::>(); + html! { + a href=(prefix) { "root" } + @for (index, part) in parts.iter().enumerate() { + "/" + @let path = parts[..=index].join("/"); + a href=(format!("{prefix}/{}", super::shared::encode_path(&path))) { (part) } + } + } +} + +fn text_blob(bytes: &[u8]) -> Markup { + let text = String::from_utf8_lossy(bytes); + let lines = if bytes.is_empty() { + 0 + } else { + bytes.iter().filter(|byte| **byte == b'\n').count() + + usize::from(bytes.last() != Some(&b'\n')) + }; + html! { table summary="blob content" class="blob" { tr { + td class="linenumbers" { pre { + @for line in 1..=lines { a id=(format!("n{line}")) href=(format!("#n{line}")) { (line) } "\n" } + } } + td class="lines" { pre { code { (text) } } } + } } } +} + +fn binary_blob(bytes: &[u8]) -> Markup { + html! { table summary="blob content" class="bin-blob" { + tr { th { "ofs" } th { "hex dump" } th { "ascii" } } + @for (row, chunk) in bytes.chunks(32).enumerate() { + tr { + td class="right" { (format!("{:04x}", row * 32)) } + td class="hex" { (chunk.iter().map(|byte| format!("{byte:02x}")).collect::>().join(" ")) } + td class="hex" { (chunk.iter().map(|byte| if byte.is_ascii_graphic() { char::from(*byte) } else { '.' }).collect::()) } + } + } + } } +} + +fn raw(model: crate::models::tree::Tree, method: &axum::http::Method) -> axum::response::Response { + let filename = model.path.as_deref().map(content_disposition); + match model.content { + crate::models::tree::Content::Blob { oid, bytes, binary } => { + let length = bytes.len(); + let body = if method == axum::http::Method::HEAD { + axum::body::Body::empty() + } else { + axum::body::Body::from(bytes) + }; + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header( + axum::http::header::CONTENT_TYPE, + if binary { + "application/octet-stream" + } else { + "text/plain; charset=UTF-8" + }, + ) + .header(axum::http::header::CONTENT_LENGTH, length) + .header(axum::http::header::ETAG, format!("\"{oid}\"")) + .header( + axum::http::header::CONTENT_DISPOSITION, + filename.as_deref().unwrap_or("inline"), + ) + .body(body) + .expect("raw blob response is valid") + } + crate::models::tree::Content::Directory { oid, entries } => { + let repo = super::shared::repository_url(&model.repository.name); + let revision = super::shared::encode_path(&model.revision); + let current = model.path.as_deref().unwrap_or_default(); + let title = if current.is_empty() { + "/".to_owned() + } else { + format!("/{current}/") + }; + let document = html! { + (DOCTYPE) + html { head { title { (&title) } } body { + h2 { (&title) } + ul { + @if let Some((parent, _)) = current.rsplit_once('/') { + li { a href=(format!("{repo}/+/{revision}/+/tree/{}?format=raw", super::shared::encode_path(parent))) { "../" } } + } @else if !current.is_empty() { + li { a href=(format!("{repo}/+/{revision}/+/tree?format=raw")) { "../" } } + } + @for entry in entries { + @let path = super::shared::encode_path(&entry.path); + li { + @if entry.kind == crate::models::tree::Kind::Submodule { + (&entry.name) " @ " (&entry.oid) + } @else { + a href=(format!("{repo}/+/{revision}/+/tree/{path}?format=raw")) { (&entry.name) @if entry.kind == crate::models::tree::Kind::Tree { "/" } } + } + } + } + } + } } + } + .into_string(); + let length = document.len(); + let body = if method == axum::http::Method::HEAD { + axum::body::Body::empty() + } else { + axum::body::Body::from(document) + }; + axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header(axum::http::header::CONTENT_TYPE, "text/html; charset=UTF-8") + .header(axum::http::header::CONTENT_LENGTH, length) + .header(axum::http::header::ETAG, format!("\"{oid}\"")) + .body(body) + .expect("raw directory response is valid") + } + } +} + +fn content_disposition(path: &str) -> String { + let name = path.rsplit('/').next().unwrap_or(path); + let fallback = name + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { + character + } else { + '_' + } + }) + .collect::(); + format!( + "inline; filename=\"{fallback}\"; filename*=UTF-8''{}", + super::shared::encode_path(name) + ) +} -- Gilti