diff options
Diffstat
125 files changed, 11264 insertions, 7634 deletions
diff --git a/.dockerignore b/.dockerignore index 351a45c..e7c5618 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,5 +4,7 @@ .git .github .gilti-state +cgit dist +git target diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 --- a/.editorconfig +++ /dev/null @@ -1,26 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true -indent_style = space -indent_size = 4 - -[*.md] -# double whitespace at end of line -# denotes a line break in Markdown -trim_trailing_whitespace = false - -[*.rs] -max_line_length = 100 - -[*.yml] -indent_size = 2 - -[{Makefile,*.mk}] -indent_style = tab diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 09866d3..60a0f53 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,12 +17,8 @@ concurrency: group: gilti-build-${{ github.ref }} cancel-in-progress: true -env: - CARGO_TERM_COLOR: always - RELEASE_TAG: nightly - jobs: - lint: + check: name: Static checks runs-on: ubuntu-latest steps: @@ -37,16 +33,15 @@ jobs: run: mise bootstrap --locked --yes --update - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - - name: formatting + - name: Check formatting run: cargo fmt --all --check - - name: shellcheck - run: shellcheck scripts/*.sh tests/*.sh + - run: shellcheck scripts/*.sh tests/*.sh build: name: Build (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} - needs: [lint] + needs: [check] strategy: fail-fast: false matrix: @@ -57,6 +52,8 @@ jobs: arch: arm64 steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + submodules: true - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3 with: @@ -67,28 +64,25 @@ jobs: run: mise bootstrap --locked --yes --update - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - - run: cargo clippy --workspace --locked --all-targets --all-features --release -- -D warnings + - run: cargo clippy --locked --all-targets --all-features --release -- -D warnings - run: cargo test --all-features --release --locked - run: tests/chart.sh - name: Build container artifacts env: - RUST_IMAGE: docker.io/library/rust:1.97.1-alpine3.22@sha256:df4efa4e0cdfb5245fa06e3f431387b2bcc96782ce5681b7fb6b0297d745bc29 + ALPINE_IMAGE: docker.io/library/alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 run: | docker run --rm \ --volume "$PWD:/src" \ --workdir /src \ --env "HOST_UID=$(id -u)" \ --env "HOST_GID=$(id -g)" \ - "$RUST_IMAGE" sh -euxc ' - rustc --version | grep -qx "rustc 1.97.1 (8bab26f4f 2026-07-14)" - cargo --version | grep -q "^cargo 1.97.1 " + "$ALPINE_IMAGE" sh -euxc ' apk add --no-cache \ build-base=0.5-r3 \ - cmake \ - python3 \ - zlib-dev=1.3.2-r0 \ - zlib-static=1.3.2-r0 + cargo=1.87.0-r1 \ + rust=1.87.0-r1 \ + zlib-dev=1.3.2-r0 scripts/build-artifacts.sh "${{ matrix.arch }}" chown -R "$HOST_UID:$HOST_GID" .container ' diff --git a/.gitignore b/.gitignore index bc1d763..2127774 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,9 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later +/.container /dist /target +/.gilti-state mise.local.toml mise.*.local.toml - -/.container -/.gilti-state diff --git a/.gitmodules b/.gitmodules new file mode 100644 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +[submodule "git"] + path = git + url = https://git.kernel.org/pub/scm/git/git.git diff --git a/CODEOWNERS b/CODEOWNERS index faf8e2e..7aeb094 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later -* @mrdimidium +* @mrdimidium diff --git a/Cargo.lock b/Cargo.lock index 0e053dd..ba66bad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,74 +55,12 @@ dependencies = [ ] [[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] -name = "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] name = "errno" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -133,12 +71,6 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -172,38 +104,14 @@ dependencies = [ ] [[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "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", "serde_json", - "sha2", "tokio", "tower", ] @@ -216,18 +124,6 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -314,52 +210,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -423,12 +279,6 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -459,12 +309,6 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -508,23 +352,6 @@ dependencies = [ ] [[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -639,24 +466,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" 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" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index fc8833e..6fb0143 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,24 +8,21 @@ resolver = "3" [workspace.package] publish = false edition = "2024" -rust-version = "1.97" version = "0.1.0" license = "AGPL-3.0-or-later" authors = ["Nikolay Govorov <me@govorov.online>"] -homepage = "https://git.dimidiumlabs.io/gilti" -repository = "https://git.dimidiumlabs.io/gilti" +homepage = "https://github.com/dimidiumlabs/gilti" +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"] } +tokio = { version = "1.44.2", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal"] } tower = { version = "0.5.2", features = ["util"] } maud = "0.27.0" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" -sha2 = "0.10.9" [profile.release] codegen-units = 1 diff --git a/Dockerfile b/Dockerfile index a625eb1..8c9c369 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,23 +13,18 @@ ARG REVISION=unknown LABEL org.opencontainers.image.title="Gilti" \ org.opencontainers.image.description="Tiny Git server in a box" \ - org.opencontainers.image.source="https://git.dimidiumlabs.io/gilti" \ + org.opencontainers.image.source="https://github.com/dimidiumlabs/gilti" \ org.opencontainers.image.version="$VERSION" \ org.opencontainers.image.revision="$REVISION" \ 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 \ - xz=5.8.3-r0 \ - zstd=1.5.7-r0 && \ + tini=0.19.0-r3 && \ 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 && \ @@ -38,23 +33,30 @@ RUN apk add --no-cache \ /var/lib/gilti/git /var/lib/gilti/git/repositories && \ install -d -m 0700 -o root -g root /var/lib/gilti/ssh && \ install -d -m 0755 -o root -g root /run/gilti && \ + install -d -m 0750 -o git -g git /run/gilti/http && \ install -d -m 0750 -o root -g git /run/gilti/ssh && \ install -d -m 0755 /etc/gilti && \ rm -rf /var/cache/apk/* COPY --chown=root:root --chmod=0755 \ .container/binary-${TARGETARCH}/gilti \ + .container/binary-${TARGETARCH}/gilti-cgit \ .container/binary-${TARGETARCH}/gilti-ssh \ /usr/local/bin/ COPY --chown=root:root \ - .container/binary-${TARGETARCH}/gilti.css \ - .container/binary-${TARGETARCH}/gilti.js \ - .container/binary-${TARGETARCH}/gilti.png \ + .container/binary-${TARGETARCH}/cgit.css \ + .container/binary-${TARGETARCH}/cgit.js \ + .container/binary-${TARGETARCH}/cgit.png \ .container/binary-${TARGETARCH}/favicon.ico \ - /usr/share/gilti/ + /usr/share/webapps/cgit/ +COPY --chown=root:root \ + .container/binary-${TARGETARCH}/COPYING.cgit.txt \ + .container/binary-${TARGETARCH}/COPYING.git.txt \ + /usr/share/doc/gilti-cgit/ +COPY --chown=root:root config/cgitrc /etc/cgitrc COPY --chown=root:root config/sshd_config /etc/ssh/sshd_config COPY --chown=root:root --chmod=0755 scripts/entrypoint.sh /usr/local/bin/gilti-entrypoint -COPY --chown=root:root LICENSE README.md LICENSES/ /usr/share/doc/gilti/ +COPY --chown=root:root LICENSE README.md /usr/share/doc/gilti/ RUN /usr/local/bin/gilti --check && \ /usr/local/bin/gilti-ssh --check diff --git a/README.md b/README.md index 2db136f..50892bf 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,12 @@ platforms but aren't ready to host complex services like Forgejo. ## Security boundary -- Git fetch is available anonymously over smart HTTP; authenticated fetch and - push use SSH public-key authentication through `gilti-ssh`. +- Git fetch and push use SSH public-key authentication through `gilti-ssh`. - Every configured key has read/write access to every repository and may create a repository by pushing to its name for the first time. -- Repository browsing, archives, LFS downloads, and smart HTTP fetches are - anonymous and read-only; every repository is publicly visible. -- Password authentication, shells, forwarding, and tunnels are disabled; content - filters are not supported. Optional unauthenticated HTTP writes must be - enabled explicitly. +- cgit is anonymous and read-only; every repository is publicly visible. +- Smart HTTP, password authentication, shells, forwarding, and tunnels are + disabled; cgit filters are not supported. - Gilti is a single-replica service backed by one POSIX persistent volume. It is not an HA system. @@ -42,16 +39,8 @@ docker run --rm \ ghcr.io/dimidiumlabs/gilti:nightly ``` -The HTTP configuration is read from the environment at startup: - -- `GILTI_ROOT_TITLE` (default: `Gilti`); -- `GILTI_ROOT_DESCRIPTION` (default: `A tiny Git server`); -- `GILTI_CLONE_PREFIX` (empty by default); -- `GILTI_HTTP_WRITE` (`0` by default; `1` enables unauthenticated HTTP pushes and LFS uploads). - -Gilti snapshots the authorized keys file at process startup; changing it takes -effect after a restart. Repositories and the persistent SSH host key live on -the state volume. +Gilti snapshots this file at process startup; changing it takes effect after a +restart. Repositories and the persistent SSH host key live on the state volume. ## Helm @@ -67,7 +56,7 @@ ssh: helm upgrade --install gilti ./charts/gilti \ --namespace gilti --create-namespace \ --values values.yaml \ - --set web.clonePrefix='ssh://git@git.dimidiumlabs.io/' + --set cgit.clonePrefix='ssh://git@vcs.dimidiumlabs.io/' ``` See [`charts/gilti/README.md`](charts/gilti/README.md) for persistence, routing, @@ -75,9 +64,10 @@ and public-repository configuration. ## Development -Provision tools and run static checks: +Initialize the Git source used to build cgit, provision tools, and run static checks: ```console +git submodule update --init mise bootstrap cargo fmt -- --check cargo test --locked diff --git a/REUSE.toml b/REUSE.toml index 901485f..aa786bb 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -30,8 +30,8 @@ SPDX-License-Identifier = "CC-BY-3.0" [[annotations]] path = [ - "crates/gilti/assets/gilti.png", - "crates/gilti/assets/favicon.ico", + "cgit/cgit.png", + "cgit/favicon.ico", ] SPDX-FileCopyrightText = "cgit Development Team <cgit@lists.zx2c4.com>" SPDX-License-Identifier = "GPL-2.0-only" diff --git a/cgit/COPYING b/cgit/COPYING new file mode 100644 --- /dev/null +++ b/cgit/COPYING @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/cgit/Makefile b/cgit/Makefile new file mode 100644 --- /dev/null +++ b/cgit/Makefile @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: GPL-2.0-only + +# Run from Git's source directory so cgit can reuse Git's build variables, +# compatibility layer, and static libraries. +include Makefile + +CGIT_PREFIX = ../ +CGIT_VERSION = v1.3.1 +CGIT_SCRIPT_NAME = gilti-cgit +CGIT_CONFIG = /etc/cgitrc + +CGIT_CFLAGS += -DCGIT_CONFIG='"$(CGIT_CONFIG)"' +CGIT_CFLAGS += -DCGIT_SCRIPT_NAME='"$(CGIT_SCRIPT_NAME)"' +CGIT_CFLAGS += -DCGIT_VERSION='"$(CGIT_VERSION)"' + +CGIT_OBJS := $(patsubst %.c,%.o,$(wildcard $(CGIT_PREFIX)*.c)) +cgit_dep_files := $(foreach f,$(CGIT_OBJS),$(dir $(f)).depend/$(notdir $(f)).d) +-include $(wildcard $(cgit_dep_files)) + +$(CGIT_PREFIX).depend: + @mkdir -p $@ + +$(CGIT_OBJS): %.o: %.c GIT-CFLAGS $(CGIT_PREFIX)Makefile | $(CGIT_PREFIX).depend + $(QUIET_CC)$(CC) -o $*.o -c $(dep_args) $(ALL_CFLAGS) $(EXTRA_CPPFLAGS) $(CGIT_CFLAGS) $< + +$(CGIT_PREFIX)cgit: $(CGIT_OBJS) GIT-LDFLAGS $(GITLIBS) + $(QUIET_LINK)$(CC) $(ALL_CFLAGS) -o $@ $(ALL_LDFLAGS) $(filter %.o,$^) $(LIBS) $(CGIT_LIBS) diff --git a/cgit/cgit.c b/cgit/cgit.c new file mode 100644 --- /dev/null +++ b/cgit/cgit.c @@ -0,0 +1,830 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-FileCopyrightText: 2026 Nikolay Govorov + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* cgit.c: cgi for the git scm + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "cmd.h" +#include "configfile.h" +#include "html.h" +#include "ui-shared.h" +#include "ui-stats.h" +#include "ui-blob.h" +#include "ui-summary.h" +#include "scan-tree.h" + +const char *cgit_version = CGIT_VERSION; + +__attribute__((constructor)) +static void constructor_environment() +{ + /* Do not look in /etc/ for gitconfig and gitattributes. */ + setenv("GIT_CONFIG_NOSYSTEM", "1", 1); + setenv("GIT_ATTR_NOSYSTEM", "1", 1); + unsetenv("HOME"); + unsetenv("XDG_CONFIG_HOME"); +} + +static void add_mimetype(const char *name, const char *value) +{ + struct string_list_item *item; + + item = string_list_insert(&ctx.cfg.mimetypes, name); + item->util = xstrdup(value); +} + +void cgit_repo_config(struct cgit_repo *repo, const char *name, const char *value) +{ + const char *path; + struct string_list_item *item; + + if (!strcmp(name, "name")) + repo->name = strdup_first_line(value); + else if (!strcmp(name, "clone-url")) + repo->clone_url = strdup_first_line(value); + else if (!strcmp(name, "desc")) + repo->desc = strdup_first_line(value); + else if (!strcmp(name, "owner")) + repo->owner = strdup_first_line(value); + else if (!strcmp(name, "homepage")) + repo->homepage = strdup_first_line(value); + else if (!strcmp(name, "defbranch")) + repo->defbranch = strdup_first_line(value); + else if (!strcmp(name, "extra-head-content")) + repo->extra_head_content = strdup_first_line(value); + else if (!strcmp(name, "snapshots")) + repo->snapshots = ctx.cfg.snapshots & cgit_parse_snapshots_mask(value); + else if (!strcmp(name, "enable-blame")) + repo->enable_blame = atoi(value); + else if (!strcmp(name, "enable-commit-graph")) + repo->enable_commit_graph = atoi(value); + else if (!strcmp(name, "enable-follow-links")) + repo->enable_follow_links = atoi(value); + else if (!strcmp(name, "enable-log-filecount")) + repo->enable_log_filecount = atoi(value); + else if (!strcmp(name, "enable-log-linecount")) + repo->enable_log_linecount = atoi(value); + else if (!strcmp(name, "enable-remote-branches")) + repo->enable_remote_branches = atoi(value); + else if (!strcmp(name, "enable-subject-links")) + repo->enable_subject_links = atoi(value); + else if (!strcmp(name, "enable-html-serving")) + repo->enable_html_serving = atoi(value); + else if (!strcmp(name, "branch-sort")) { + if (!strcmp(value, "age")) + repo->branch_sort = 1; + if (!strcmp(value, "name")) + repo->branch_sort = 0; + } else if (!strcmp(name, "commit-sort")) { + if (!strcmp(value, "date")) + repo->commit_sort = 1; + if (!strcmp(value, "topo")) + repo->commit_sort = 2; + } else if (!strcmp(name, "max-stats")) + repo->max_stats = cgit_find_stats_period(value, NULL); + else if (!strcmp(name, "module-link")) + repo->module_link= strdup_first_line(value); + else if (skip_prefix(name, "module-link.", &path)) { + item = string_list_append(&repo->submodules, strdup_first_line(path)); + item->util = strdup_first_line(value); + } else if (!strcmp(name, "section")) + repo->section = strdup_first_line(value); + else if (!strcmp(name, "snapshot-prefix")) + repo->snapshot_prefix = strdup_first_line(value); + else if (!strcmp(name, "readme") && value != NULL) { + if (repo->readme.items == ctx.cfg.readme.items) + memset(&repo->readme, 0, sizeof(repo->readme)); + string_list_append(&repo->readme, strdup_first_line(value)); + } else if (!strcmp(name, "logo") && value != NULL) + repo->logo = strdup_first_line(value); + else if (!strcmp(name, "logo-link") && value != NULL) + repo->logo_link = strdup_first_line(value); + else if (!strcmp(name, "hide")) + repo->hide = atoi(value); + else if (!strcmp(name, "ignore")) + repo->ignore = atoi(value); +} + +static void config_cb(const char *name, const char *value) +{ + const char *arg; + + if (!strcmp(name, "section")) + ctx.cfg.section = strdup_first_line(value); + else if (!strcmp(name, "repo.url")) + ctx.repo = cgit_add_repo(value); + else if (ctx.repo && !strcmp(name, "repo.path")) + ctx.repo->path = trim_end(value, '/'); + else if (ctx.repo && skip_prefix(name, "repo.", &arg)) + cgit_repo_config(ctx.repo, arg, value); + else if (!strcmp(name, "readme")) + string_list_append(&ctx.cfg.readme, strdup_first_line(value)); + else if (!strcmp(name, "root-title")) + ctx.cfg.root_title = strdup_first_line(value); + else if (!strcmp(name, "root-desc")) + ctx.cfg.root_desc = strdup_first_line(value); + else if (!strcmp(name, "root-readme")) + ctx.cfg.root_readme = strdup_first_line(value); + else if (!strcmp(name, "css")) + string_list_append(&ctx.cfg.css, strdup_first_line(value)); + else if (!strcmp(name, "js")) + string_list_append(&ctx.cfg.js, strdup_first_line(value)); + else if (!strcmp(name, "favicon")) + ctx.cfg.favicon = strdup_first_line(value); + else if (!strcmp(name, "footer")) + ctx.cfg.footer = strdup_first_line(value); + else if (!strcmp(name, "head-include")) + ctx.cfg.head_include = strdup_first_line(value); + else if (!strcmp(name, "header")) + ctx.cfg.header = strdup_first_line(value); + else if (!strcmp(name, "logo")) + ctx.cfg.logo = strdup_first_line(value); + else if (!strcmp(name, "logo-link")) + ctx.cfg.logo_link = strdup_first_line(value); + else if (!strcmp(name, "module-link")) + ctx.cfg.module_link = strdup_first_line(value); + else if (!strcmp(name, "strict-export")) + ctx.cfg.strict_export = strdup_first_line(value); + else if (!strcmp(name, "virtual-root")) + ctx.cfg.virtual_root = ensure_end(value, '/'); + else if (!strcmp(name, "noplainemail")) + ctx.cfg.noplainemail = atoi(value); + else if (!strcmp(name, "noheader")) + ctx.cfg.noheader = atoi(value); + else if (!strcmp(name, "snapshots")) + ctx.cfg.snapshots = cgit_parse_snapshots_mask(value); + else if (!strcmp(name, "enable-follow-links")) + ctx.cfg.enable_follow_links = atoi(value); + else if (!strcmp(name, "enable-http-clone")) + ctx.cfg.enable_http_clone = atoi(value); + else if (!strcmp(name, "enable-index-links")) + ctx.cfg.enable_index_links = atoi(value); + else if (!strcmp(name, "enable-index-owner")) + ctx.cfg.enable_index_owner = atoi(value); + else if (!strcmp(name, "enable-blame")) + ctx.cfg.enable_blame = atoi(value); + else if (!strcmp(name, "enable-commit-graph")) + ctx.cfg.enable_commit_graph = atoi(value); + else if (!strcmp(name, "enable-log-filecount")) + ctx.cfg.enable_log_filecount = atoi(value); + else if (!strcmp(name, "enable-log-linecount")) + ctx.cfg.enable_log_linecount = atoi(value); + else if (!strcmp(name, "enable-remote-branches")) + ctx.cfg.enable_remote_branches = atoi(value); + else if (!strcmp(name, "enable-subject-links")) + ctx.cfg.enable_subject_links = atoi(value); + else if (!strcmp(name, "enable-html-serving")) + ctx.cfg.enable_html_serving = atoi(value); + else if (!strcmp(name, "enable-tree-linenumbers")) + ctx.cfg.enable_tree_linenumbers = atoi(value); + else if (!strcmp(name, "enable-git-config")) + ctx.cfg.enable_git_config = atoi(value); + else if (!strcmp(name, "max-stats")) + ctx.cfg.max_stats = cgit_find_stats_period(value, NULL); + else if (!strcmp(name, "case-sensitive-sort")) + ctx.cfg.case_sensitive_sort = atoi(value); + else if (!strcmp(name, "embedded")) + ctx.cfg.embedded = atoi(value); + else if (!strcmp(name, "max-atom-items")) + ctx.cfg.max_atom_items = atoi(value); + else if (!strcmp(name, "max-message-length")) + ctx.cfg.max_msg_len = atoi(value); + else if (!strcmp(name, "max-repodesc-length")) + ctx.cfg.max_repodesc_len = atoi(value); + else if (!strcmp(name, "max-blob-size")) + ctx.cfg.max_blob_size = atoi(value); + else if (!strcmp(name, "max-repo-count")) { + ctx.cfg.max_repo_count = atoi(value); + if (ctx.cfg.max_repo_count <= 0) + ctx.cfg.max_repo_count = INT_MAX; + } else if (!strcmp(name, "max-commit-count")) + ctx.cfg.max_commit_count = atoi(value); + else if (!strcmp(name, "project-list")) + ctx.cfg.project_list = strdup_first_line(expand_macros(value)); + else if (!strcmp(name, "scan-path")) + if (ctx.cfg.project_list) + scan_projects(expand_macros(value), + ctx.cfg.project_list); + else + scan_tree(expand_macros(value)); + else if (!strcmp(name, "scan-hidden-path")) + ctx.cfg.scan_hidden_path = atoi(value); + else if (!strcmp(name, "section-from-path")) + ctx.cfg.section_from_path = atoi(value); + else if (!strcmp(name, "repository-sort")) + ctx.cfg.repository_sort = strdup_first_line(value); + else if (!strcmp(name, "section-sort")) + ctx.cfg.section_sort = atoi(value); + else if (!strcmp(name, "summary-log")) + ctx.cfg.summary_log = atoi(value); + else if (!strcmp(name, "summary-branches")) + ctx.cfg.summary_branches = atoi(value); + else if (!strcmp(name, "summary-tags")) + ctx.cfg.summary_tags = atoi(value); + else if (!strcmp(name, "side-by-side-diffs")) + ctx.cfg.difftype = atoi(value) ? DIFF_SSDIFF : DIFF_UNIFIED; + else if (!strcmp(name, "agefile")) + ctx.cfg.agefile = strdup_first_line(value); + else if (!strcmp(name, "mimetype-file")) + ctx.cfg.mimetype_file = strdup_first_line(value); + else if (!strcmp(name, "renamelimit")) + ctx.cfg.renamelimit = atoi(value); + else if (!strcmp(name, "remove-suffix")) + ctx.cfg.remove_suffix = atoi(value); + else if (!strcmp(name, "robots")) + ctx.cfg.robots = strdup_first_line(value); + else if (!strcmp(name, "clone-prefix")) + ctx.cfg.clone_prefix = strdup_first_line(value); + else if (!strcmp(name, "clone-url")) + ctx.cfg.clone_url = strdup_first_line(value); + else if (!strcmp(name, "local-time")) + ctx.cfg.local_time = atoi(value); + else if (!strcmp(name, "commit-sort")) { + if (!strcmp(value, "date")) + ctx.cfg.commit_sort = 1; + if (!strcmp(value, "topo")) + ctx.cfg.commit_sort = 2; + } else if (!strcmp(name, "branch-sort")) { + if (!strcmp(value, "age")) + ctx.cfg.branch_sort = 1; + if (!strcmp(value, "name")) + ctx.cfg.branch_sort = 0; + } else if (skip_prefix(name, "mimetype.", &arg)) + add_mimetype(arg, value); + else if (!strcmp(name, "include")) + parse_configfile(expand_macros(value), config_cb); +} + +static void querystring_cb(const char *name, const char *value) +{ + if (!value) + value = ""; + + if (!strcmp(name,"r")) { + ctx.qry.repo = xstrdup(value); + ctx.repo = cgit_get_repoinfo(value); + } else if (!strcmp(name, "p")) { + ctx.qry.page = xstrdup(value); + } else if (!strcmp(name, "url")) { + if (*value == '/') + value++; + ctx.qry.url = xstrdup(value); + cgit_parse_url(value); + } else if (!strcmp(name, "qt")) { + ctx.qry.grep = xstrdup(value); + } else if (!strcmp(name, "q")) { + ctx.qry.search = xstrdup(value); + } else if (!strcmp(name, "h")) { + ctx.qry.head = xstrdup(value); + } else if (!strcmp(name, "id")) { + ctx.qry.oid = xstrdup(value); + ctx.qry.has_oid = 1; + } else if (!strcmp(name, "id2")) { + ctx.qry.oid2 = xstrdup(value); + ctx.qry.has_oid = 1; + } else if (!strcmp(name, "ofs")) { + ctx.qry.ofs = atoi(value); + } else if (!strcmp(name, "path")) { + ctx.qry.path = trim_end(value, '/'); + } else if (!strcmp(name, "name")) { + ctx.qry.name = xstrdup(value); + } else if (!strcmp(name, "s")) { + ctx.qry.sort = xstrdup(value); + } else if (!strcmp(name, "showmsg")) { + ctx.qry.showmsg = atoi(value); + } else if (!strcmp(name, "period")) { + ctx.qry.period = xstrdup(value); + } else if (!strcmp(name, "dt")) { + ctx.qry.difftype = atoi(value); + ctx.qry.has_difftype = 1; + } else if (!strcmp(name, "ss")) { + /* No longer generated, but there may be links out there. */ + ctx.qry.difftype = atoi(value) ? DIFF_SSDIFF : DIFF_UNIFIED; + ctx.qry.has_difftype = 1; + } else if (!strcmp(name, "all")) { + ctx.qry.show_all = atoi(value); + } else if (!strcmp(name, "context")) { + ctx.qry.context = atoi(value); + } else if (!strcmp(name, "ignorews")) { + ctx.qry.ignorews = atoi(value); + } else if (!strcmp(name, "follow")) { + ctx.qry.follow = atoi(value); + } +} + +static void prepare_context(void) +{ + memset(&ctx, 0, sizeof(ctx)); + ctx.cfg.agefile = "info/web/last-modified"; + ctx.cfg.case_sensitive_sort = 1; + ctx.cfg.branch_sort = 0; + ctx.cfg.commit_sort = 0; + ctx.cfg.logo = "/cgit.png"; + ctx.cfg.favicon = "/favicon.ico"; + ctx.cfg.local_time = 0; + ctx.cfg.enable_http_clone = 1; + ctx.cfg.enable_index_owner = 1; + ctx.cfg.enable_tree_linenumbers = 1; + ctx.cfg.enable_git_config = 0; + ctx.cfg.max_repo_count = 50; + ctx.cfg.max_commit_count = 50; + ctx.cfg.max_lock_attempts = 5; + ctx.cfg.max_msg_len = 80; + ctx.cfg.max_repodesc_len = 80; + ctx.cfg.max_blob_size = 0; + ctx.cfg.max_stats = 0; + ctx.cfg.project_list = NULL; + ctx.cfg.renamelimit = -1; + ctx.cfg.remove_suffix = 0; + ctx.cfg.robots = "index, nofollow"; + ctx.cfg.root_title = "Git repository browser"; + ctx.cfg.root_desc = "a fast webinterface for the git dscm"; + ctx.cfg.scan_hidden_path = 0; + ctx.cfg.script_name = CGIT_SCRIPT_NAME; + ctx.cfg.section = ""; + ctx.cfg.repository_sort = "name"; + ctx.cfg.section_sort = 1; + ctx.cfg.summary_branches = 10; + ctx.cfg.summary_log = 10; + ctx.cfg.summary_tags = 10; + ctx.cfg.max_atom_items = 10; + ctx.cfg.difftype = DIFF_UNIFIED; + ctx.env.cgit_config = getenv("CGIT_CONFIG"); + ctx.env.http_host = getenv("HTTP_HOST"); + ctx.env.https = getenv("HTTPS"); + ctx.env.no_http = getenv("NO_HTTP"); + ctx.env.path_info = getenv("PATH_INFO"); + ctx.env.query_string = getenv("QUERY_STRING"); + ctx.env.request_method = getenv("REQUEST_METHOD"); + ctx.env.script_name = getenv("SCRIPT_NAME"); + ctx.env.server_name = getenv("SERVER_NAME"); + ctx.env.server_port = getenv("SERVER_PORT"); + ctx.page.mimetype = "text/html"; + ctx.page.charset = PAGE_ENCODING; + ctx.page.filename = NULL; + ctx.page.size = 0; + ctx.page.modified = time(NULL); + ctx.page.etag = NULL; + string_list_init_dup(&ctx.cfg.mimetypes); + if (ctx.env.script_name) + ctx.cfg.script_name = xstrdup(ctx.env.script_name); + if (ctx.env.query_string) + ctx.qry.raw = xstrdup(ctx.env.query_string); + if (!ctx.env.cgit_config) + ctx.env.cgit_config = CGIT_CONFIG; +} + +struct refmatch { + char *req_ref; + char *first_ref; + int match; +}; + +static int find_current_ref(const struct reference *ref, void *cb_data) +{ + struct refmatch *info; + + info = (struct refmatch *)cb_data; + if (!strcmp(ref->name, info->req_ref)) + info->match = 1; + if (!info->first_ref) + info->first_ref = xstrdup(ref->name); + return info->match; +} + +static void free_refmatch_inner(struct refmatch *info) +{ + if (info->first_ref) + free(info->first_ref); +} + +static char *find_default_branch(struct cgit_repo *repo) +{ + struct refmatch info; + char *ref; + + info.req_ref = repo->defbranch; + info.first_ref = NULL; + info.match = 0; + refs_for_each_branch_ref(get_main_ref_store(the_repository), + find_current_ref, &info); + if (info.match) + ref = info.req_ref; + else + ref = info.first_ref; + if (ref) + ref = xstrdup(ref); + free_refmatch_inner(&info); + + return ref; +} + +static char *guess_defbranch(void) +{ + const char *ref, *refname; + struct object_id oid; + + ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository), + "HEAD", 0, &oid, NULL); + if (!ref || !skip_prefix(ref, "refs/heads/", &refname)) + return "master"; + return xstrdup(refname); +} + +/* 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) +{ + const char *colon; + + *filename = NULL; + *ref = NULL; + + if (!readme || !readme[0]) + 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); + 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); +} +static void choose_readme(struct cgit_repo *repo) +{ + 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; + } + 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("<tr><td><a rel='vcs-git' href='"); + html_url_path(url); + html("' title='"); + html_attr(ctx.repo->name); + html(" Git repository'>"); + html_txt(url); + html("</a></td></tr>\n"); +} + +static void prepare_repo_env(int *nongit) +{ + /* The path to the git repository. */ + setenv("GIT_DIR", ctx.repo->path, 1); + + /* Setup the git directory and initialize the notes system. Both of these + * load local configuration from the git repository, so we do them both while + * the HOME variables are unset. */ + setup_git_directory_gently(nongit); + load_display_notes(NULL); +} + +static int prepare_repo_cmd(int nongit) +{ + struct object_id oid; + int rc; + + if (nongit) { + const char *name = ctx.repo->name; + rc = errno; + ctx.page.title = fmtalloc("%s - %s", ctx.cfg.root_title, + "config error"); + ctx.repo = NULL; + cgit_print_http_headers(); + cgit_print_docstart(); + cgit_print_pageheader(); + cgit_print_error("Failed to open %s: %s", name, + rc ? strerror(rc) : "Not a valid git repository"); + cgit_print_docend(); + return 1; + } + ctx.page.title = fmtalloc("%s - %s", ctx.repo->name, ctx.repo->desc); + + if (!ctx.repo->defbranch) + ctx.repo->defbranch = guess_defbranch(); + + if (!ctx.qry.head) { + ctx.qry.nohead = 1; + ctx.qry.head = find_default_branch(ctx.repo); + } + + if (!ctx.qry.head) { + cgit_print_http_headers(); + cgit_print_docstart(); + cgit_print_pageheader(); + cgit_print_error("Repository seems to be empty"); + if (!strcmp(ctx.qry.page, "summary")) { + html("<table class='list'><tr class='nohover'><td> </td></tr><tr class='nohover'><th class='left'>Clone</th></tr>\n"); + cgit_prepare_repo_env(ctx.repo); + cgit_add_clone_urls(print_no_repo_clone_urls); + html("</table>\n"); + } + cgit_print_docend(); + return 1; + } + + if (repo_get_oid(the_repository, ctx.qry.head, &oid)) { + char *old_head = ctx.qry.head; + ctx.qry.head = xstrdup(ctx.repo->defbranch); + cgit_print_error_page(404, "Not found", + "Invalid branch: %s", old_head); + free(old_head); + return 1; + } + string_list_sort(&ctx.repo->submodules); + cgit_prepare_repo_env(ctx.repo); + choose_readme(ctx.repo); + return 0; +} + +static void process_request(void) +{ + struct cgit_cmd *cmd; + int nongit = 0; + + if (ctx.repo) + prepare_repo_env(&nongit); + + cmd = cgit_get_cmd(); + if (!cmd) { + ctx.page.title = "cgit error"; + cgit_print_error_page(404, "Not found", "Invalid request"); + 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"); + return; + } + + /* If cmd->want_vpath is set, assume ctx.qry.path contains a "virtual" + * in-project path limit to be made available at ctx.qry.vpath. + * Otherwise, no path limit is in effect (ctx.qry.vpath = NULL). + */ + ctx.qry.vpath = cmd->want_vpath ? ctx.qry.path : NULL; + + if (ctx.repo && prepare_repo_cmd(nongit)) + return; + + cmd->fn(); +} + +static int cmp_repos(const void *a, const void *b) +{ + const struct cgit_repo *ra = a, *rb = b; + return strcmp(ra->url, rb->url); +} + +static char *build_snapshot_setting(int bitmap) +{ + const struct cgit_snapshot_format *f; + struct strbuf result = STRBUF_INIT; + + for (f = cgit_snapshot_formats; f->suffix; f++) { + if (cgit_snapshot_format_bit(f) & bitmap) { + if (result.len) + strbuf_addch(&result, ' '); + strbuf_addstr(&result, f->suffix); + } + } + return strbuf_detach(&result, NULL); +} + +static void print_repo(FILE *f, struct cgit_repo *repo) +{ + struct string_list_item *item; + fprintf(f, "repo.url=%s\n", repo->url); + fprintf(f, "repo.name=%s\n", repo->name); + fprintf(f, "repo.path=%s\n", repo->path); + if (repo->owner) + fprintf(f, "repo.owner=%s\n", repo->owner); + if (repo->desc) + fprintf(f, "repo.desc=%s\n", repo->desc); + for_each_string_list_item(item, &repo->readme) { + if (item->util) + fprintf(f, "repo.readme=%s:%s\n", (char *)item->util, item->string); + else + fprintf(f, "repo.readme=%s\n", item->string); + } + if (repo->defbranch) + fprintf(f, "repo.defbranch=%s\n", repo->defbranch); + if (repo->extra_head_content) + fprintf(f, "repo.extra-head-content=%s\n", repo->extra_head_content); + if (repo->module_link) + fprintf(f, "repo.module-link=%s\n", repo->module_link); + if (repo->section) + fprintf(f, "repo.section=%s\n", repo->section); + if (repo->homepage) + fprintf(f, "repo.homepage=%s\n", repo->homepage); + if (repo->clone_url) + fprintf(f, "repo.clone-url=%s\n", repo->clone_url); + fprintf(f, "repo.enable-blame=%d\n", + repo->enable_blame); + fprintf(f, "repo.enable-commit-graph=%d\n", + repo->enable_commit_graph); + fprintf(f, "repo.enable-follow-links=%d\n", + repo->enable_follow_links); + fprintf(f, "repo.enable-log-filecount=%d\n", + repo->enable_log_filecount); + fprintf(f, "repo.enable-log-linecount=%d\n", + repo->enable_log_linecount); + if (repo->snapshots != ctx.cfg.snapshots) { + char *tmp = build_snapshot_setting(repo->snapshots); + fprintf(f, "repo.snapshots=%s\n", tmp ? tmp : ""); + free(tmp); + } + if (repo->snapshot_prefix) + fprintf(f, "repo.snapshot-prefix=%s\n", repo->snapshot_prefix); + if (repo->max_stats != ctx.cfg.max_stats) + fprintf(f, "repo.max-stats=%s\n", + cgit_find_stats_periodname(repo->max_stats)); + if (repo->logo) + fprintf(f, "repo.logo=%s\n", repo->logo); + if (repo->logo_link) + fprintf(f, "repo.logo-link=%s\n", repo->logo_link); + fprintf(f, "repo.enable-remote-branches=%d\n", repo->enable_remote_branches); + fprintf(f, "repo.enable-subject-links=%d\n", repo->enable_subject_links); + fprintf(f, "repo.enable-html-serving=%d\n", repo->enable_html_serving); + if (repo->branch_sort == 1) + fprintf(f, "repo.branch-sort=age\n"); + if (repo->commit_sort) { + if (repo->commit_sort == 1) + fprintf(f, "repo.commit-sort=date\n"); + else if (repo->commit_sort == 2) + fprintf(f, "repo.commit-sort=topo\n"); + } + fprintf(f, "repo.hide=%d\n", repo->hide); + fprintf(f, "repo.ignore=%d\n", repo->ignore); + fprintf(f, "\n"); +} + +static void print_repolist(FILE *f, struct cgit_repolist *list, int start) +{ + int i; + + for (i = start; i < list->count; i++) + print_repo(f, &list->repos[i]); +} + +static void cgit_parse_args(int argc, const char **argv) +{ + int i; + const char *arg; + int scan = 0; + + for (i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--version")) { + printf("CGit %s | https://git.zx2c4.com/cgit/\n", CGIT_VERSION); + exit(0); + } + if (!strcmp(argv[i], "--nohttp")) { + ctx.env.no_http = "1"; + } else if (skip_prefix(argv[i], "--query=", &arg)) { + ctx.qry.raw = xstrdup(arg); + } else if (skip_prefix(argv[i], "--repo=", &arg)) { + ctx.qry.repo = xstrdup(arg); + } else if (skip_prefix(argv[i], "--page=", &arg)) { + ctx.qry.page = xstrdup(arg); + } else if (skip_prefix(argv[i], "--head=", &arg)) { + ctx.qry.head = xstrdup(arg); + } else if (skip_prefix(argv[i], "--oid=", &arg)) { + ctx.qry.oid = xstrdup(arg); + ctx.qry.has_oid = 1; + } else if (skip_prefix(argv[i], "--ofs=", &arg)) { + ctx.qry.ofs = atoi(arg); + } else if (skip_prefix(argv[i], "--scan-tree=", &arg) || + skip_prefix(argv[i], "--scan-path=", &arg)) { + /* + * HACK: The global snapshot bit mask defines the set + * of allowed snapshot formats, but the config file + * hasn't been parsed yet so the mask is currently 0. + * By setting all bits high before scanning we make + * sure that any in-repo cgitrc snapshot setting is + * respected by scan_tree(). + * + * NOTE: We assume that there aren't more than 8 + * different snapshot formats supported by cgit... + */ + ctx.cfg.snapshots = 0xFF; + scan++; + scan_tree(arg); + } + } + if (scan) { + qsort(cgit_repolist.repos, cgit_repolist.count, + sizeof(struct cgit_repo), cmp_repos); + print_repolist(stdout, &cgit_repolist, 0); + exit(0); + } +} + +static NORETURN void cgit_die_routine(const char *msg, va_list params) +{ + cgit_vprint_error_page(400, "Bad request", msg, params); + exit(0); +} + +int cmd_main(int argc, const char **argv) +{ + const char *path; + + set_die_routine(cgit_die_routine); + + prepare_context(); + cgit_repolist.length = 0; + cgit_repolist.count = 0; + cgit_repolist.repos = NULL; + + cgit_parse_args(argc, argv); + parse_configfile(expand_macros(ctx.env.cgit_config), config_cb); + ctx.repo = NULL; + http_parse_querystring(ctx.qry.raw, querystring_cb); + + /* If virtual-root isn't specified in cgitrc, lets pretend + * that virtual-root equals SCRIPT_NAME, minus any possibly + * trailing slashes. + */ + if (!ctx.cfg.virtual_root && ctx.cfg.script_name) + ctx.cfg.virtual_root = ensure_end(ctx.cfg.script_name, '/'); + + /* If no url parameter is specified on the querystring, use PATH_INFO + * as url. This allows cgit to work with virtual urls without the need + * for rewriterules in the webserver. + */ + path = ctx.env.path_info; + if (!ctx.qry.url && path) { + if (path[0] == '/') + path++; + ctx.qry.url = xstrdup(path); + if (ctx.qry.raw) { + char *newqry = fmtalloc("%s?%s", path, ctx.qry.raw); + free(ctx.qry.raw); + ctx.qry.raw = newqry; + } else + ctx.qry.raw = xstrdup(ctx.qry.url); + cgit_parse_url(ctx.qry.url); + } + + process_request(); + return 0; +} diff --git a/crates/gilti/assets/gilti.css b/cgit/cgit.css index e9a423e..1153711 100644 --- a/crates/gilti/assets/gilti.css +++ b/cgit/cgit.css @@ -2,7 +2,7 @@ * SPDX-License-Identifier: GPL-2.0-only */ -div#gilti { +div#cgit { padding: 0em; margin: 0em; font-family: sans-serif; @@ -12,40 +12,40 @@ div#gilti { padding: 4px; } -div#gilti a { +div#cgit a { color: blue; text-decoration: none; } -div#gilti a:hover { +div#cgit a:hover { text-decoration: underline; } -div#gilti table { +div#cgit table { border-collapse: collapse; } -div#gilti table#header { +div#cgit table#header { width: 100%; margin-bottom: 1em; } -div#gilti table#header td.logo { +div#cgit table#header td.logo { width: 96px; vertical-align: top; } -div#gilti table#header td.main { +div#cgit table#header td.main { font-size: 250%; padding-left: 10px; white-space: nowrap; } -div#gilti table#header td.main a { +div#cgit table#header td.main a { color: #000; } -div#gilti table#header td.form { +div#cgit table#header td.form { text-align: right; vertical-align: bottom; padding-right: 1em; @@ -53,19 +53,19 @@ div#gilti table#header td.form { white-space: nowrap; } -div#gilti table#header td.form form, -div#gilti table#header td.form input, -div#gilti table#header td.form select { +div#cgit table#header td.form form, +div#cgit table#header td.form input, +div#cgit table#header td.form select { font-size: 90%; } -div#gilti table#header td.sub { +div#cgit table#header td.sub { color: #777; border-top: solid 1px #ccc; padding-left: 10px; } -div#gilti table.tabs { +div#cgit table.tabs { border-bottom: solid 3px #ccc; border-collapse: collapse; margin-top: 2em; @@ -73,100 +73,100 @@ div#gilti table.tabs { width: 100%; } -div#gilti table.tabs td { +div#cgit table.tabs td { padding: 0px 1em; vertical-align: bottom; } -div#gilti table.tabs td a { +div#cgit table.tabs td a { padding: 2px 0.75em; color: #777; font-size: 110%; } -div#gilti table.tabs td a.active { +div#cgit table.tabs td a.active { color: #000; background-color: #ccc; } -div#gilti table.tabs a[href^="http://"]:after, div#gilti table.tabs a[href^="https://"]:after { +div#cgit table.tabs a[href^="http://"]:after, div#cgit table.tabs a[href^="https://"]:after { content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfgAhcJDQY+gm2TAAAAHWlUWHRDb21tZW50AAAAAABDcmVhdGVkIHdpdGggR0lNUGQuZQcAAABbSURBVAhbY2BABs4MU4CwhYHBh2Erww4wrGFQZHjI8B8IgUIscJWyDHcggltQhI4zGDCcRwhChPggHIggP1QoAVmQkSETrGoHsiAEsACtBYN0oDAMbgU6EBcAAL2eHUt4XUU4AAAAAElFTkSuQmCC); opacity: 0.5; margin: 0 0 0 5px; } -div#gilti table.tabs td.form { +div#cgit table.tabs td.form { text-align: right; } -div#gilti table.tabs td.form form { +div#cgit table.tabs td.form form { padding-bottom: 2px; font-size: 90%; white-space: nowrap; } -div#gilti table.tabs td.form input, -div#gilti table.tabs td.form select { +div#cgit table.tabs td.form input, +div#cgit table.tabs td.form select { font-size: 90%; } -div#gilti div.path { +div#cgit div.path { margin: 0px; padding: 5px 2em 2px 2em; color: #000; background-color: #eee; } -div#gilti div.content { +div#cgit div.content { margin: 0px; padding: 2em; border-bottom: solid 3px #ccc; } -div#gilti table.list { +div#cgit table.list { width: 100%; border: none; border-collapse: collapse; } -div#gilti table.list tr { +div#cgit table.list tr { background: white; } -div#gilti table.list tr.logheader { +div#cgit table.list tr.logheader { background: #eee; } -div#gilti table.list tr:nth-child(even) { +div#cgit table.list tr:nth-child(even) { background: #f7f7f7; } -div#gilti table.list tr:nth-child(odd) { +div#cgit table.list tr:nth-child(odd) { background: white; } -div#gilti table.list tr:hover { +div#cgit table.list tr:hover { background: #eee; } -div#gilti table.list tr.nohover { +div#cgit table.list tr.nohover { background: white; } -div#gilti table.list tr.nohover:hover { +div#cgit table.list tr.nohover:hover { background: white; } -div#gilti table.list tr.nohover-highlight:hover:nth-child(even) { +div#cgit table.list tr.nohover-highlight:hover:nth-child(even) { background: #f7f7f7; } -div#gilti table.list tr.nohover-highlight:hover:nth-child(odd) { +div#cgit table.list tr.nohover-highlight:hover:nth-child(odd) { background: white; } -div#gilti table.list th { +div#cgit table.list th { font-weight: bold; /* color: #888; border-top: dashed 1px #888; @@ -176,98 +176,98 @@ div#gilti table.list th { vertical-align: baseline; } -div#gilti table.list td { +div#cgit table.list td { border: none; padding: 0.1em 0.5em 0.1em 0.5em; } -div#gilti table.list td.commitgraph { +div#cgit table.list td.commitgraph { font-family: monospace; white-space: pre; } -div#gilti table.list td.commitgraph .column1 { +div#cgit table.list td.commitgraph .column1 { color: #a00; } -div#gilti table.list td.commitgraph .column2 { +div#cgit table.list td.commitgraph .column2 { color: #0a0; } -div#gilti table.list td.commitgraph .column3 { +div#cgit table.list td.commitgraph .column3 { color: #aa0; } -div#gilti table.list td.commitgraph .column4 { +div#cgit table.list td.commitgraph .column4 { color: #00a; } -div#gilti table.list td.commitgraph .column5 { +div#cgit table.list td.commitgraph .column5 { color: #a0a; } -div#gilti table.list td.commitgraph .column6 { +div#cgit table.list td.commitgraph .column6 { color: #0aa; } -div#gilti table.list td.logsubject { +div#cgit table.list td.logsubject { font-family: monospace; font-weight: bold; } -div#gilti table.list td.logmsg { +div#cgit table.list td.logmsg { font-family: monospace; white-space: pre; padding: 0 0.5em; } -div#gilti table.list td a { +div#cgit table.list td a { color: black; } -div#gilti table.list td a.ls-dir { +div#cgit table.list td a.ls-dir { font-weight: bold; color: #00f; } -div#gilti table.list td a:hover { +div#cgit table.list td a:hover { color: #00f; } -div#gilti img { +div#cgit img { border: none; } -div#gilti input#switch-btn { +div#cgit input#switch-btn { margin: 2px 0px 0px 0px; } -div#gilti td#sidebar input.txt { +div#cgit td#sidebar input.txt { width: 100%; margin: 2px 0px 0px 0px; } -div#gilti table#grid { +div#cgit table#grid { margin: 0px; } -div#gilti td#content { +div#cgit td#content { vertical-align: top; padding: 1em 2em 1em 1em; border: none; } -div#gilti div#summary { +div#cgit div#summary { vertical-align: top; margin-bottom: 1em; } -div#gilti div#summary pre { +div#cgit div#summary pre { margin: 0; white-space: pre-wrap; } -div#gilti table#downloads { +div#cgit table#downloads { float: right; border-collapse: collapse; border: solid 1px #777; @@ -275,197 +275,197 @@ div#gilti table#downloads { margin-bottom: 0.5em; } -div#gilti table#downloads th { +div#cgit table#downloads th { background-color: #ccc; } -div#gilti div#blob { +div#cgit div#blob { border: solid 1px black; } -div#gilti div.error { +div#cgit div.error { color: red; font-weight: bold; margin: 1em 2em; } -div#gilti a.ls-blob, div#gilti a.ls-dir, div#gilti .ls-mod { +div#cgit a.ls-blob, div#cgit a.ls-dir, div#cgit .ls-mod { font-family: monospace; } -div#gilti td.ls-size { +div#cgit td.ls-size { text-align: right; font-family: monospace; width: 10em; } -div#gilti td.ls-mode { +div#cgit td.ls-mode { font-family: monospace; width: 10em; } -div#gilti table.blob { +div#cgit table.blob { margin-top: 0.5em; border-top: solid 1px black; } -div#gilti table.blob td.hashes, -div#gilti table.blob td.lines { +div#cgit table.blob td.hashes, +div#cgit table.blob td.lines { margin: 0; padding: 0 0 0 0.5em; vertical-align: top; color: black; } -div#gilti table.blob td.linenumbers { +div#cgit table.blob td.linenumbers { margin: 0; padding: 0 0.5em 0 0.5em; vertical-align: top; text-align: right; border-right: 1px solid gray; } -div#gilti table.blob pre { +div#cgit table.blob pre { padding: 0; margin: 0; } -div#gilti table.blob td.linenumbers a, -div#gilti table.ssdiff td.lineno a { +div#cgit table.blob td.linenumbers a, +div#cgit table.ssdiff td.lineno a { color: gray; text-align: right; text-decoration: none; } -div#gilti table.blob td.linenumbers a:hover, -div#gilti table.ssdiff td.lineno a:hover { +div#cgit table.blob td.linenumbers a:hover, +div#cgit table.ssdiff td.lineno a:hover { color: black; } -div#gilti table.blob td.linenumbers a:target:before { +div#cgit table.blob td.linenumbers a:target:before { color: red; content: "\2BA9"; } -div#gilti table.blame td.hashes, -div#gilti table.blame td.lines, -div#gilti table.blame td.linenumbers { +div#cgit table.blame td.hashes, +div#cgit table.blame td.lines, +div#cgit table.blame td.linenumbers { padding: 0; } -div#gilti table.blame td.hashes div.alt, -div#gilti table.blame td.lines div.alt { +div#cgit table.blame td.hashes div.alt, +div#cgit table.blame td.lines div.alt { padding: 0 0.5em 0 0.5em; } -div#gilti table.blame td.linenumbers div.alt { +div#cgit table.blame td.linenumbers div.alt { padding: 0 0.5em 0 0; } -div#gilti table.blame div.alt:nth-child(even) { +div#cgit table.blame div.alt:nth-child(even) { background: #eee; } -div#gilti table.blame div.alt:nth-child(odd) { +div#cgit table.blame div.alt:nth-child(odd) { background: white; } -div#gilti table.blame td.lines > div { +div#cgit table.blame td.lines > div { position: relative; } -div#gilti table.blame td.lines > div > pre { +div#cgit table.blame td.lines > div > pre { padding: 0 0 0 0.5em; position: absolute; top: 0; } -div#gilti table.blame .oid { +div#cgit table.blame .oid { font-size: 100%; } -div#gilti table.bin-blob { +div#cgit table.bin-blob { margin-top: 0.5em; border: solid 1px black; } -div#gilti table.bin-blob th { +div#cgit table.bin-blob th { font-family: monospace; white-space: pre; border: solid 1px #777; padding: 0.5em 1em; } -div#gilti table.bin-blob td { +div#cgit table.bin-blob td { font-family: monospace; white-space: pre; border-left: solid 1px #777; padding: 0em 1em; } -div#gilti table.nowrap td { +div#cgit table.nowrap td { white-space: nowrap; } -div#gilti table.commit-info { +div#cgit table.commit-info { border-collapse: collapse; margin-top: 1.5em; } -div#gilti div.gilti-panel { +div#cgit div.cgit-panel { float: right; margin-top: 1.5em; } -div#gilti div.gilti-panel table { +div#cgit div.cgit-panel table { border-collapse: collapse; border: solid 1px #aaa; background-color: #eee; } -div#gilti div.gilti-panel th { +div#cgit div.cgit-panel th { text-align: center; } -div#gilti div.gilti-panel td { +div#cgit div.cgit-panel td { padding: 0.25em 0.5em; } -div#gilti div.gilti-panel td.label { +div#cgit div.cgit-panel td.label { padding-right: 0.5em; } -div#gilti div.gilti-panel td.ctrl { +div#cgit div.cgit-panel td.ctrl { padding-left: 0.5em; } -div#gilti table.commit-info th { +div#cgit table.commit-info th { text-align: left; font-weight: normal; padding: 0.1em 1em 0.1em 0.1em; vertical-align: top; } -div#gilti table.commit-info td { +div#cgit table.commit-info td { font-weight: normal; padding: 0.1em 1em 0.1em 0.1em; } -div#gilti div.commit-subject { +div#cgit div.commit-subject { font-weight: bold; font-size: 125%; margin: 1.5em 0em 0.5em 0em; padding: 0em; } -div#gilti div.commit-msg { +div#cgit div.commit-msg { white-space: pre; font-family: monospace; } -div#gilti div.notes-header { +div#cgit div.notes-header { font-weight: bold; padding-top: 1.5em; } -div#gilti div.notes { +div#cgit div.notes { white-space: pre; font-family: monospace; border: solid 1px #ee9; @@ -474,22 +474,22 @@ div#gilti div.notes { float: left; } -div#gilti div.notes-footer { +div#cgit div.notes-footer { clear: left; } -div#gilti div.diffstat-header { +div#cgit div.diffstat-header { font-weight: bold; padding-top: 1.5em; } -div#gilti table.diffstat { +div#cgit table.diffstat { border-collapse: collapse; border: solid 1px #aaa; background-color: #eee; } -div#gilti table.diffstat th { +div#cgit table.diffstat th { font-weight: normal; text-align: left; text-decoration: underline; @@ -497,197 +497,197 @@ div#gilti table.diffstat th { font-size: 100%; } -div#gilti table.diffstat td { +div#cgit table.diffstat td { padding: 0.2em 0.2em 0.1em 0.1em; font-size: 100%; border: none; } -div#gilti table.diffstat td.mode { +div#cgit table.diffstat td.mode { white-space: nowrap; } -div#gilti table.diffstat td span.modechange { +div#cgit table.diffstat td span.modechange { padding-left: 1em; color: red; } -div#gilti table.diffstat td.add a { +div#cgit table.diffstat td.add a { color: green; } -div#gilti table.diffstat td.del a { +div#cgit table.diffstat td.del a { color: red; } -div#gilti table.diffstat td.upd a { +div#cgit table.diffstat td.upd a { color: blue; } -div#gilti table.diffstat td.graph { +div#cgit table.diffstat td.graph { width: 500px; vertical-align: middle; } -div#gilti table.diffstat td.graph table { +div#cgit table.diffstat td.graph table { border: none; } -div#gilti table.diffstat td.graph td { +div#cgit table.diffstat td.graph td { padding: 0px; border: 0px; height: 7pt; } -div#gilti table.diffstat td.graph td.add { +div#cgit table.diffstat td.graph td.add { background-color: #5c5; } -div#gilti table.diffstat td.graph td.rem { +div#cgit table.diffstat td.graph td.rem { background-color: #c55; } -div#gilti div.diffstat-summary { +div#cgit div.diffstat-summary { color: #888; padding-top: 0.5em; } -div#gilti table.diff { +div#cgit table.diff { width: 100%; } -div#gilti table.diff td { +div#cgit table.diff td { font-family: monospace; white-space: pre; } -div#gilti table.diff td div.head { +div#cgit table.diff td div.head { font-weight: bold; margin-top: 1em; color: black; } -div#gilti table.diff td div.hunk { +div#cgit table.diff td div.hunk { color: #009; } -div#gilti table.diff td div.add { +div#cgit table.diff td div.add { color: green; } -div#gilti table.diff td div.del { +div#cgit table.diff td div.del { color: red; } -div#gilti .oid { +div#cgit .oid { font-family: monospace; font-size: 90%; } -div#gilti .left { +div#cgit .left { text-align: left; } -div#gilti .right { +div#cgit .right { text-align: right; } -div#gilti table.list td.reposection { +div#cgit table.list td.reposection { font-style: italic; color: #888; } -div#gilti a.button { +div#cgit a.button { font-size: 80%; padding: 0em 0.5em; } -div#gilti a.primary { +div#cgit a.primary { font-size: 100%; } -div#gilti a.secondary { +div#cgit a.secondary { font-size: 90%; } -div#gilti td.toplevel-repo { +div#cgit td.toplevel-repo { } -div#gilti table.list td.sublevel-repo { +div#cgit table.list td.sublevel-repo { padding-left: 1.5em; } -div#gilti ul.pager { +div#cgit ul.pager { list-style-type: none; text-align: center; margin: 1em 0em 0em 0em; padding: 0; } -div#gilti ul.pager li { +div#cgit ul.pager li { display: inline-block; margin: 0.25em 0.5em; } -div#gilti ul.pager a { +div#cgit ul.pager a { color: #777; } -div#gilti ul.pager .current { +div#cgit ul.pager .current { font-weight: bold; } -div#gilti span.age-mins { +div#cgit span.age-mins { font-weight: bold; color: #080; } -div#gilti span.age-hours { +div#cgit span.age-hours { color: #080; } -div#gilti span.age-days { +div#cgit span.age-days { color: #040; } -div#gilti span.age-weeks { +div#cgit span.age-weeks { color: #444; } -div#gilti span.age-months { +div#cgit span.age-months { color: #888; } -div#gilti span.age-years { +div#cgit span.age-years { color: #bbb; } -div#gilti span.insertions { +div#cgit span.insertions { color: #080; } -div#gilti span.deletions { +div#cgit span.deletions { color: #800; } -div#gilti div.footer { +div#cgit div.footer { margin-top: 0.5em; text-align: center; font-size: 80%; color: #ccc; } -div#gilti div.footer a { +div#cgit div.footer a { color: #ccc; text-decoration: none; } -div#gilti div.footer a:hover { +div#cgit div.footer a:hover { text-decoration: underline; } -div#gilti a.branch-deco { +div#cgit a.branch-deco { color: #000; margin: 0px 0.5em; padding: 0px 0.25em; @@ -696,7 +696,7 @@ div#gilti a.branch-deco { border-radius: 2px; } -div#gilti a.tag-deco { +div#cgit a.tag-deco { color: #000; margin: 0px 0.5em; padding: 0px 0.25em; @@ -705,7 +705,7 @@ div#gilti a.tag-deco { border-radius: 2px; } -div#gilti a.tag-annotated-deco { +div#cgit a.tag-annotated-deco { color: #000; margin: 0px 0.5em; padding: 0px 0.25em; @@ -714,7 +714,7 @@ div#gilti a.tag-annotated-deco { border-radius: 2px; } -div#gilti a.remote-deco { +div#cgit a.remote-deco { color: #000; margin: 0px 0.5em; padding: 0px 0.25em; @@ -723,7 +723,7 @@ div#gilti a.remote-deco { border-radius: 2px; } -div#gilti a.deco { +div#cgit a.deco { color: #000; margin: 0px 0.5em; padding: 0px 0.25em; @@ -732,97 +732,97 @@ div#gilti a.deco { border-radius: 2px; } -div#gilti div.commit-subject a.branch-deco, -div#gilti div.commit-subject a.tag-deco, -div#gilti div.commit-subject a.tag-annotated-deco, -div#gilti div.commit-subject a.remote-deco, -div#gilti div.commit-subject a.deco { +div#cgit div.commit-subject a.branch-deco, +div#cgit div.commit-subject a.tag-deco, +div#cgit div.commit-subject a.tag-annotated-deco, +div#cgit div.commit-subject a.remote-deco, +div#cgit div.commit-subject a.deco { margin-left: 1em; font-size: 75%; } -div#gilti table.stats { +div#cgit table.stats { border: solid 1px black; border-collapse: collapse; } -div#gilti table.stats th { +div#cgit table.stats th { text-align: left; padding: 1px 0.5em; background-color: #eee; border: solid 1px black; } -div#gilti table.stats td { +div#cgit table.stats td { text-align: right; padding: 1px 0.5em; border: solid 1px black; } -div#gilti table.stats td.total { +div#cgit table.stats td.total { font-weight: bold; text-align: left; } -div#gilti table.stats td.sum { +div#cgit table.stats td.sum { color: #c00; font-weight: bold; /* background-color: #eee; */ } -div#gilti table.stats td.left { +div#cgit table.stats td.left { text-align: left; } -div#gilti table.vgraph { +div#cgit table.vgraph { border-collapse: separate; border: solid 1px black; height: 200px; } -div#gilti table.vgraph th { +div#cgit table.vgraph th { background-color: #eee; font-weight: bold; border: solid 1px white; padding: 1px 0.5em; } -div#gilti table.vgraph td { +div#cgit table.vgraph td { vertical-align: bottom; padding: 0px 10px; } -div#gilti table.vgraph div.bar { +div#cgit table.vgraph div.bar { background-color: #eee; } -div#gilti table.hgraph { +div#cgit table.hgraph { border: solid 1px black; width: 800px; } -div#gilti table.hgraph th { +div#cgit table.hgraph th { background-color: #eee; font-weight: bold; border: solid 1px black; padding: 1px 0.5em; } -div#gilti table.hgraph td { +div#cgit table.hgraph td { vertical-align: middle; padding: 2px 2px; } -div#gilti table.hgraph div.bar { +div#cgit table.hgraph div.bar { background-color: #eee; height: 1em; } -div#gilti table.ssdiff { +div#cgit table.ssdiff { width: 100%; } -div#gilti table.ssdiff td { +div#cgit table.ssdiff td { font-size: 75%; font-family: monospace; white-space: pre; @@ -831,53 +831,53 @@ div#gilti table.ssdiff td { border-right: solid 1px #aaa; } -div#gilti table.ssdiff td.add { +div#cgit table.ssdiff td.add { color: black; background: #cfc; min-width: 50%; } -div#gilti table.ssdiff td.add_dark { +div#cgit table.ssdiff td.add_dark { color: black; background: #aca; min-width: 50%; } -div#gilti table.ssdiff span.add { +div#cgit table.ssdiff span.add { background: #cfc; font-weight: bold; } -div#gilti table.ssdiff td.del { +div#cgit table.ssdiff td.del { color: black; background: #fcc; min-width: 50%; } -div#gilti table.ssdiff td.del_dark { +div#cgit table.ssdiff td.del_dark { color: black; background: #caa; min-width: 50%; } -div#gilti table.ssdiff span.del { +div#cgit table.ssdiff span.del { background: #fcc; font-weight: bold; } -div#gilti table.ssdiff td.changed { +div#cgit table.ssdiff td.changed { color: black; background: #ffc; min-width: 50%; } -div#gilti table.ssdiff td.changed_dark { +div#cgit table.ssdiff td.changed_dark { color: black; background: #cca; min-width: 50%; } -div#gilti table.ssdiff td.lineno { +div#cgit table.ssdiff td.lineno { color: black; background: #eee; text-align: right; @@ -885,34 +885,34 @@ div#gilti table.ssdiff td.lineno { min-width: 3em; } -div#gilti table.ssdiff td.hunk { +div#cgit table.ssdiff td.hunk { color: black; background: #ccf; border-top: solid 1px #aaa; border-bottom: solid 1px #aaa; } -div#gilti table.ssdiff td.head { +div#cgit table.ssdiff td.head { border-top: solid 1px #aaa; border-bottom: solid 1px #aaa; } -div#gilti table.ssdiff td.head div.head { +div#cgit table.ssdiff td.head div.head { font-weight: bold; color: black; } -div#gilti table.ssdiff td.foot { +div#cgit table.ssdiff td.foot { border-top: solid 1px #aaa; border-left: none; border-right: none; border-bottom: none; } -div#gilti table.ssdiff td.space { +div#cgit table.ssdiff td.space { border: none; } -div#gilti table.ssdiff td.space div { +div#cgit table.ssdiff td.space div { min-height: 3em; } diff --git a/cgit/cgit.h b/cgit/cgit.h new file mode 100644 --- /dev/null +++ b/cgit/cgit.h @@ -0,0 +1,364 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-FileCopyrightText: 2026 Nikolay Govorov + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef CGIT_H +#define CGIT_H + +#include <stdbool.h> + +#include <git-compat-util.h> + +#include <archive.h> +#include <commit.h> +#include <diffcore.h> +#include <diff.h> +#include <environment.h> +#include <graph.h> +#include <grep.h> +#include <hex.h> +#include <log-tree.h> +#include <notes.h> +#include <object.h> +#include <object-name.h> +#include <odb.h> +#include <path.h> +#include <refs.h> +#include <revision.h> +#include <setup.h> +#include <string-list.h> +#include <strvec.h> +#include <tag.h> +#include <tree.h> +#include <utf8.h> +#include <wrapper.h> +#include <xdiff-interface.h> +#include <xdiff/xdiff.h> + +/* Add isgraph(x) to Git's sane ctype support (see git-compat-util.h) */ +#undef isgraph +#define isgraph(x) (isprint((x)) && !isspace((x))) + + +/* + * Limits used for relative dates + */ +#define TM_MIN 60 +#define TM_HOUR (TM_MIN * 60) +#define TM_DAY (TM_HOUR * 24) +#define TM_WEEK (TM_DAY * 7) +#define TM_YEAR (TM_DAY * 365) +#define TM_MONTH (TM_YEAR / 12.0) + + +/* + * Default encoding + */ +#define PAGE_ENCODING "UTF-8" + +#define BIT(x) (1U << (x)) + +typedef void (*configfn)(const char *name, const char *value); +typedef void (*filepair_fn)(struct diff_filepair *pair); +typedef void (*linediff_fn)(char *line, int len); + +typedef enum { + DIFF_UNIFIED, DIFF_SSDIFF, DIFF_STATONLY +} diff_type; + +struct cgit_exec_filter { + char *cmd; + char **argv; + int old_stdout; + int pid; +}; + +struct cgit_repo { + char *url; + char *name; + char *path; + char *desc; + char *extra_head_content; + char *owner; + char *homepage; + char *defbranch; + char *module_link; + struct string_list readme; + char *section; + char *clone_url; + char *logo; + 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; + int hide; + int ignore; +}; + +struct cgit_repolist { + int length; + int count; + struct cgit_repo *repos; +}; + +struct commitinfo { + struct commit *commit; + char *author; + char *author_email; + unsigned long author_date; + int author_tz; + char *committer; + char *committer_email; + unsigned long committer_date; + int committer_tz; + char *subject; + char *msg; + char *msg_encoding; +}; + +struct taginfo { + char *tagger; + char *tagger_email; + unsigned long tagger_date; + int tagger_tz; + char *msg; +}; + +struct refinfo { + const char *refname; + struct object *object; + union { + struct taginfo *tag; + struct commitinfo *commit; + }; +}; + +struct reflist { + struct refinfo **refs; + int alloc; + int count; +}; + +struct cgit_query { + int has_oid; + int has_difftype; + char *raw; + char *repo; + char *page; + char *search; + char *grep; + char *head; + char *oid; + char *oid2; + char *path; + char *name; + char *url; + char *period; + int ofs; + int nohead; + char *sort; + int showmsg; + diff_type difftype; + int show_all; + int context; + int ignorews; + int follow; + char *vpath; +}; + +struct cgit_config { + char *agefile; + char *clone_prefix; + char *clone_url; + char *favicon; + char *footer; + char *head_include; + char *header; + char *logo; + char *logo_link; + char *mimetype_file; + char *module_link; + char *project_list; + struct string_list readme; + struct string_list css; + char *robots; + char *root_title; + char *root_desc; + 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 enable_git_config; + int local_time; + int max_atom_items; + int max_repo_count; + int max_commit_count; + int max_lock_attempts; + 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; +}; + +struct cgit_page { + time_t modified; + size_t size; + const char *mimetype; + const char *charset; + const char *filename; + const char *etag; + const char *title; + int status; + const char *statusmsg; +}; + +struct cgit_environment { + const char *cgit_config; + const char *http_host; + const char *https; + const char *no_http; + const char *path_info; + const char *query_string; + const char *request_method; + const char *script_name; + const char *server_name; + const char *server_port; +}; + +struct cgit_context { + struct cgit_environment env; + struct cgit_query qry; + struct cgit_config cfg; + struct cgit_repo *repo; + struct cgit_page page; +}; + +typedef int (*write_archive_fn_t)(const char *, const char *); + +struct cgit_snapshot_format { + const char *suffix; + const char *mimetype; + write_archive_fn_t write_func; +}; + +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 void cgit_repo_config(struct cgit_repo *repo, const char *name, + const char *value); + +extern int chk_zero(int result, char *msg); +extern int chk_positive(int result, char *msg); +extern int chk_non_negative(int result, char *msg); + +extern char *trim_end(const char *str, char c); +extern char *ensure_end(const char *str, char c); + +extern void strbuf_ensure_end(struct strbuf *sb, char c); + +extern void cgit_add_ref(struct reflist *list, struct refinfo *ref); +extern void cgit_free_reflist_inner(struct reflist *list); +extern int cgit_refs_cb(const struct reference *ref, void *cb_data); + +extern void cgit_free_commitinfo(struct commitinfo *info); +extern void cgit_free_taginfo(struct taginfo *info); + +void cgit_diff_tree_cb(struct diff_queue_struct *q, + struct diff_options *options, void *data); + +extern int cgit_diff_files(const struct object_id *old_oid, + const struct object_id *new_oid, + unsigned long *old_size, unsigned long *new_size, + int *binary, int context, int ignorews, + linediff_fn fn); + +extern void cgit_diff_tree(const struct object_id *old_oid, + const struct object_id *new_oid, + filepair_fn fn, const char *prefix, int ignorews); + +extern void cgit_diff_commit(struct commit *commit, filepair_fn fn, + const char *prefix); + +__attribute__((format (printf,1,2))) +extern char *fmt(const char *format,...); + +__attribute__((format (printf,1,2))) +extern char *fmtalloc(const char *format,...); + +extern struct commitinfo *cgit_parse_commit(struct commit *commit); +extern struct taginfo *cgit_parse_tag(struct tag *tag); +extern void cgit_parse_url(const char *url); + +extern const char *cgit_repobasename(const char *reponame); + +extern int cgit_parse_snapshots_mask(const char *str); +extern const struct object_id *cgit_snapshot_get_sig(const char *ref, + const struct cgit_snapshot_format *f); +extern const unsigned cgit_snapshot_format_bit(const struct cgit_snapshot_format *f); + +extern void cgit_exec_filter_init(struct cgit_exec_filter *filter, char *cmd, char **argv); +extern int cgit_open_exec_filter(struct cgit_exec_filter *filter); +extern int cgit_close_exec_filter(struct cgit_exec_filter *filter); + +extern void cgit_prepare_repo_env(struct cgit_repo * repo); + +extern int read_first_line(const char *path, char **buf, size_t *size); + +extern char *strdup_first_line(const char *txt); + +extern char *expand_macros(const char *txt); + +extern char *get_mimetype_for_filename(const char *filename); + +#endif /* CGIT_H */ diff --git a/crates/gilti/assets/gilti.js b/cgit/cgit.js index b4ade48..5702ed2 100644 --- a/crates/gilti/assets/gilti.js +++ b/cgit/cgit.js @@ -2,17 +2,17 @@ * SPDX-License-Identifier: GPL-2.0-only */ -/* gilti.js: JavaScript helpers for Gilti +/* cgit.js: javacript functions for cgit * * Copyright (C) 2006-2018 cgit Development Team <cgit@lists.zx2c4.com> * * Licensed under GNU General Public License v2 - * (see LICENSES/GPL-2.0-only.txt for full license text) + * (see COPYING for full license text) */ (function () { -/* Relative-age rendering shared by Gilti browser views. */ +/* This follows the logic and suffixes used in ui-shared.c */ var age_classes = [ "age-mins", "age-hours", "age-days", "age-weeks", "age-months", "age-years" ]; var age_suffix = [ "min.", "hours", "days", "weeks", "months", "years", "years" ]; diff --git a/crates/gilti/assets/gilti.png b/cgit/cgit.png index 425528e..425528e 100644 --- a/crates/gilti/assets/gilti.png +++ b/cgit/cgit.png Binary files differ diff --git a/cgit/cmd.c b/cgit/cmd.c new file mode 100644 --- /dev/null +++ b/cgit/cmd.c @@ -0,0 +1,203 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-FileCopyrightText: 2026 Nikolay Govorov + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* cmd.c: the cgit command dispatcher + * + * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#include "cgit.h" +#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) { + size_t path_info_len = ctx.env.path_info ? strlen(ctx.env.path_info) : 0; + if (!ctx.qry.path && + ctx.qry.url[strlen(ctx.qry.url) - 1] != '/' && + (!path_info_len || ctx.env.path_info[path_info_len - 1] != '/')) { + char *currenturl = cgit_currenturl(); + char *redirect = fmtalloc("%s/", currenturl); + cgit_redirect(redirect, true); + free(currenturl); + free(redirect); + } else if (ctx.repo->readme.nr) + cgit_print_repo_readme(ctx.qry.path); + else if (ctx.repo->homepage) + cgit_redirect(ctx.repo->homepage, false); + else { + char *currenturl = cgit_currenturl(); + char *redirect = fmtalloc("%s../", currenturl); + cgit_redirect(redirect, false); + free(currenturl); + free(redirect); + } + } else + cgit_print_site_readme(); +} + +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 diff_fn(void) +{ + cgit_print_diff(ctx.qry.oid, ctx.qry.oid2, ctx.qry.path, 1, 0); +} + +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, + ctx.qry.grep, ctx.qry.search, ctx.qry.path, 1, + ctx.repo->enable_commit_graph, + 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) +{ + cgit_print_snapshot(ctx.qry.head, ctx.qry.oid, ctx.qry.path, + ctx.qry.nohead); +} + +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} + +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(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), + }; + 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]; + return NULL; +} diff --git a/cgit/cmd.h b/cgit/cmd.h new file mode 100644 --- /dev/null +++ b/cgit/cmd.h @@ -0,0 +1,20 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef CMD_H +#define CMD_H + +typedef void (*cgit_cmd_fn)(void); + +struct cgit_cmd { + const char *name; + cgit_cmd_fn fn; + unsigned int want_repo:1, + want_vpath:1, + is_clone:1; +}; + +extern struct cgit_cmd *cgit_get_cmd(void); + +#endif /* CMD_H */ diff --git a/cgit/configfile.c b/cgit/configfile.c new file mode 100644 --- /dev/null +++ b/cgit/configfile.c @@ -0,0 +1,94 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* configfile.c: parsing of config files + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#include <git-compat-util.h> +#include "configfile.h" + +static int next_char(FILE *f) +{ + int c = fgetc(f); + if (c == '\r') { + c = fgetc(f); + if (c != '\n') { + ungetc(c, f); + c = '\r'; + } + } + return c; +} + +static void skip_line(FILE *f) +{ + int c; + + while ((c = next_char(f)) && c != '\n' && c != EOF) + ; +} + +static int read_config_line(FILE *f, struct strbuf *name, struct strbuf *value) +{ + int c = next_char(f); + + strbuf_reset(name); + strbuf_reset(value); + + /* Skip comments and preceding spaces. */ + for(;;) { + if (c == EOF) + return 0; + else if (c == '#' || c == ';') + skip_line(f); + else if (!isspace(c)) + break; + c = next_char(f); + } + + /* Read variable name. */ + while (c != '=') { + if (c == '\n' || c == EOF) + return 0; + strbuf_addch(name, c); + c = next_char(f); + } + + /* Read variable value. */ + c = next_char(f); + while (c != '\n' && c != EOF) { + strbuf_addch(value, c); + c = next_char(f); + } + + return 1; +} + +int parse_configfile(const char *filename, configfile_value_fn fn) +{ + static int nesting; + struct strbuf name = STRBUF_INIT; + struct strbuf value = STRBUF_INIT; + FILE *f; + + /* cancel deeply nested include-commands */ + if (nesting > 8) + return -1; + if (!(f = fopen(filename, "r"))) + return -1; + nesting++; + while (read_config_line(f, &name, &value)) + fn(name.buf, value.buf); + nesting--; + fclose(f); + strbuf_release(&name); + strbuf_release(&value); + return 0; +} + diff --git a/cgit/configfile.h b/cgit/configfile.h new file mode 100644 --- /dev/null +++ b/cgit/configfile.h @@ -0,0 +1,14 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef CONFIGFILE_H +#define CONFIGFILE_H + +#include "cgit.h" + +typedef void (*configfile_value_fn)(const char *name, const char *value); + +extern int parse_configfile(const char *filename, configfile_value_fn fn); + +#endif /* CONFIGFILE_H */ diff --git a/crates/gilti/assets/favicon.ico b/cgit/favicon.ico index 56ff593..56ff593 100644 --- a/crates/gilti/assets/favicon.ico +++ b/cgit/favicon.ico Binary files differ diff --git a/cgit/filter.c b/cgit/filter.c new file mode 100644 --- /dev/null +++ b/cgit/filter.c @@ -0,0 +1,57 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* filter.c: executable filter used by snapshot compression + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#include "cgit.h" + +void cgit_exec_filter_init(struct cgit_exec_filter *filter, char *cmd, char **argv) +{ + memset(filter, 0, sizeof(*filter)); + filter->cmd = cmd; + filter->argv = argv; +} + +int cgit_open_exec_filter(struct cgit_exec_filter *filter) +{ + int pipe_fh[2]; + + filter->old_stdout = chk_positive(dup(STDOUT_FILENO), + "Unable to duplicate STDOUT"); + chk_zero(pipe(pipe_fh), "Unable to create pipe to subprocess"); + filter->pid = chk_non_negative(fork(), "Unable to create subprocess"); + if (filter->pid == 0) { + close(pipe_fh[1]); + chk_non_negative(dup2(pipe_fh[0], STDIN_FILENO), + "Unable to use pipe as STDIN"); + execvp(filter->cmd, filter->argv); + die_errno("Unable to exec subprocess %s", filter->cmd); + } + close(pipe_fh[0]); + chk_non_negative(dup2(pipe_fh[1], STDOUT_FILENO), + "Unable to use pipe as STDOUT"); + close(pipe_fh[1]); + return 0; +} + +int cgit_close_exec_filter(struct cgit_exec_filter *filter) +{ + int exit_status = 0; + + chk_non_negative(dup2(filter->old_stdout, STDOUT_FILENO), + "Unable to restore STDOUT"); + close(filter->old_stdout); + if (filter->pid < 0) + return WEXITSTATUS(exit_status); + waitpid(filter->pid, &exit_status, 0); + if (!WIFEXITED(exit_status)) + die("Subprocess %s exited abnormally", filter->cmd); + return WEXITSTATUS(exit_status); +} diff --git a/cgit/html.c b/cgit/html.c new file mode 100644 --- /dev/null +++ b/cgit/html.c @@ -0,0 +1,365 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* html.c: helper functions for html output + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#include "cgit.h" +#include "html.h" +#include "url.h" + +/* Percent-encoding of each character, except: a-zA-Z0-9!$()*,./:;@- */ +static const char* url_escape_table[256] = { + "%00", "%01", "%02", "%03", "%04", "%05", "%06", "%07", + "%08", "%09", "%0a", "%0b", "%0c", "%0d", "%0e", "%0f", + "%10", "%11", "%12", "%13", "%14", "%15", "%16", "%17", + "%18", "%19", "%1a", "%1b", "%1c", "%1d", "%1e", "%1f", + "%20", NULL, "%22", "%23", NULL, "%25", "%26", "%27", + NULL, NULL, NULL, "%2b", NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, "%3c", "%3d", "%3e", "%3f", + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, "%5c", NULL, "%5e", NULL, + "%60", NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, "%7b", "%7c", "%7d", NULL, "%7f", + "%80", "%81", "%82", "%83", "%84", "%85", "%86", "%87", + "%88", "%89", "%8a", "%8b", "%8c", "%8d", "%8e", "%8f", + "%90", "%91", "%92", "%93", "%94", "%95", "%96", "%97", + "%98", "%99", "%9a", "%9b", "%9c", "%9d", "%9e", "%9f", + "%a0", "%a1", "%a2", "%a3", "%a4", "%a5", "%a6", "%a7", + "%a8", "%a9", "%aa", "%ab", "%ac", "%ad", "%ae", "%af", + "%b0", "%b1", "%b2", "%b3", "%b4", "%b5", "%b6", "%b7", + "%b8", "%b9", "%ba", "%bb", "%bc", "%bd", "%be", "%bf", + "%c0", "%c1", "%c2", "%c3", "%c4", "%c5", "%c6", "%c7", + "%c8", "%c9", "%ca", "%cb", "%cc", "%cd", "%ce", "%cf", + "%d0", "%d1", "%d2", "%d3", "%d4", "%d5", "%d6", "%d7", + "%d8", "%d9", "%da", "%db", "%dc", "%dd", "%de", "%df", + "%e0", "%e1", "%e2", "%e3", "%e4", "%e5", "%e6", "%e7", + "%e8", "%e9", "%ea", "%eb", "%ec", "%ed", "%ee", "%ef", + "%f0", "%f1", "%f2", "%f3", "%f4", "%f5", "%f6", "%f7", + "%f8", "%f9", "%fa", "%fb", "%fc", "%fd", "%fe", "%ff" +}; + +char *fmt(const char *format, ...) +{ + static char buf[8][1024]; + static int bufidx; + int len; + va_list args; + + bufidx++; + bufidx &= 7; + + va_start(args, format); + len = vsnprintf(buf[bufidx], sizeof(buf[bufidx]), format, args); + va_end(args); + if (len >= sizeof(buf[bufidx])) { + fprintf(stderr, "[html.c] string truncated: %s\n", format); + exit(1); + } + return buf[bufidx]; +} + +char *fmtalloc(const char *format, ...) +{ + struct strbuf sb = STRBUF_INIT; + va_list args; + + va_start(args, format); + strbuf_vaddf(&sb, format, args); + va_end(args); + + return strbuf_detach(&sb, NULL); +} + +void html_raw(const char *data, size_t size) +{ + if (write(STDOUT_FILENO, data, size) != size) + die_errno("write error on html output"); +} + +void html(const char *txt) +{ + html_raw(txt, strlen(txt)); +} + +void htmlf(const char *format, ...) +{ + va_list args; + struct strbuf buf = STRBUF_INIT; + + va_start(args, format); + strbuf_vaddf(&buf, format, args); + va_end(args); + html(buf.buf); + strbuf_release(&buf); +} + +void html_txtf(const char *format, ...) +{ + va_list args; + + va_start(args, format); + html_vtxtf(format, args); + va_end(args); +} + +void html_vtxtf(const char *format, va_list ap) +{ + va_list cp; + struct strbuf buf = STRBUF_INIT; + + va_copy(cp, ap); + strbuf_vaddf(&buf, format, cp); + va_end(cp); + html_txt(buf.buf); + strbuf_release(&buf); +} + +void html_txt(const char *txt) +{ + if (txt) + html_ntxt(txt, strlen(txt)); +} + +ssize_t html_ntxt(const char *txt, size_t len) +{ + const char *t = txt; + ssize_t slen; + + if (len > SSIZE_MAX) + return -1; + + slen = (ssize_t) len; + while (t && *t && slen--) { + int c = *t; + if (c == '<' || c == '>' || c == '&') { + html_raw(txt, t - txt); + if (c == '>') + html(">"); + else if (c == '<') + html("<"); + else if (c == '&') + html("&"); + txt = t + 1; + } + t++; + } + if (t != txt) + html_raw(txt, t - txt); + return slen; +} + +void html_attrf(const char *fmt, ...) +{ + va_list ap; + struct strbuf sb = STRBUF_INIT; + + va_start(ap, fmt); + strbuf_vaddf(&sb, fmt, ap); + va_end(ap); + + html_attr(sb.buf); + strbuf_release(&sb); +} + +void html_attr(const char *txt) +{ + const char *t = txt; + while (t && *t) { + int c = *t; + if (c == '<' || c == '>' || c == '\'' || c == '\"' || c == '&') { + html_raw(txt, t - txt); + if (c == '>') + html(">"); + else if (c == '<') + html("<"); + else if (c == '\'') + html("'"); + else if (c == '"') + html("""); + else if (c == '&') + html("&"); + txt = t + 1; + } + t++; + } + if (t != txt) + html(txt); +} + +void html_url_path(const char *txt) +{ + const char *t = txt; + while (t && *t) { + unsigned char c = *t; + const char *e = url_escape_table[c]; + if (e && c != '+' && c != '&') { + html_raw(txt, t - txt); + html(e); + txt = t + 1; + } + t++; + } + if (t != txt) + html(txt); +} + +void html_url_arg(const char *txt) +{ + const char *t = txt; + while (t && *t) { + unsigned char c = *t; + const char *e = url_escape_table[c]; + if (c == ' ') + e = "+"; + if (e) { + html_raw(txt, t - txt); + html(e); + txt = t + 1; + } + t++; + } + if (t != txt) + html(txt); +} + +void html_header_arg_in_quotes(const char *txt) +{ + const char *t = txt; + while (t && *t) { + unsigned char c = *t; + const char *e = NULL; + if (c == '\\') + e = "\\\\"; + else if (c == '\r') + e = "\\r"; + else if (c == '\n') + e = "\\n"; + else if (c == '"') + e = "\\\""; + if (e) { + html_raw(txt, t - txt); + html(e); + txt = t + 1; + } + t++; + } + if (t != txt) + html(txt); + +} + +void html_hidden(const char *name, const char *value) +{ + html("<input type='hidden' name='"); + html_attr(name); + html("' value='"); + html_attr(value); + html("'/>"); +} + +void html_option(const char *value, const char *text, const char *selected_value) +{ + html("<option value='"); + html_attr(value); + html("'"); + if (selected_value && !strcmp(selected_value, value)) + html(" selected='selected'"); + html(">"); + html_txt(text); + html("</option>\n"); +} + +void html_intoption(int value, const char *text, int selected_value) +{ + htmlf("<option value='%d'%s>", value, + value == selected_value ? " selected='selected'" : ""); + html_txt(text); + html("</option>"); +} + +void html_link_open(const char *url, const char *title, const char *class) +{ + html("<a href='"); + html_attr(url); + if (title) { + html("' title='"); + html_attr(title); + } + if (class) { + html("' class='"); + html_attr(class); + } + html("'>"); +} + +void html_link_close(void) +{ + html("</a>"); +} + +void html_fileperm(unsigned short mode) +{ + htmlf("%c%c%c", (mode & 4 ? 'r' : '-'), + (mode & 2 ? 'w' : '-'), (mode & 1 ? 'x' : '-')); +} + +int html_include(const char *filename) +{ + FILE *f; + char buf[4096]; + size_t len; + + if (!(f = fopen(filename, "r"))) { + fprintf(stderr, "[cgit] Failed to include file %s: %s (%d).\n", + filename, strerror(errno), errno); + return -1; + } + while ((len = fread(buf, 1, 4096, f)) > 0) + html_raw(buf, len); + fclose(f); + return 0; +} + +int html_include_text(const char *filename) +{ + FILE *f; + char buf[4096]; + size_t len; + + if (!(f = fopen(filename, "r"))) { + fprintf(stderr, "[cgit] Failed to include file %s: %s (%d).\n", + filename, strerror(errno), errno); + return -1; + } + while ((len = fread(buf, 1, sizeof(buf), f)) > 0) + html_ntxt(buf, len); + fclose(f); + return 0; +} + +void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value)) +{ + const char *t = txt; + + while (t && *t) { + char *name = url_decode_parameter_name(&t); + if (*name) { + char *value = url_decode_parameter_value(&t); + fn(name, value); + free(value); + } + free(name); + } +} diff --git a/cgit/html.h b/cgit/html.h new file mode 100644 --- /dev/null +++ b/cgit/html.h @@ -0,0 +1,42 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef HTML_H +#define HTML_H + +#include "cgit.h" + +extern void html_raw(const char *txt, size_t size); +extern void html(const char *txt); + +__attribute__((format (printf,1,2))) +extern void htmlf(const char *format,...); + +__attribute__((format (printf,1,2))) +extern void html_txtf(const char *format,...); + +__attribute__((format (printf,1,0))) +extern void html_vtxtf(const char *format, va_list ap); + +__attribute__((format (printf,1,2))) +extern void html_attrf(const char *format,...); + +extern void html_txt(const char *txt); +extern ssize_t html_ntxt(const char *txt, size_t len); +extern void html_attr(const char *txt); +extern void html_url_path(const char *txt); +extern void html_url_arg(const char *txt); +extern void html_header_arg_in_quotes(const char *txt); +extern void html_hidden(const char *name, const char *value); +extern void html_option(const char *value, const char *text, const char *selected_value); +extern void html_intoption(int value, const char *text, int selected_value); +extern void html_link_open(const char *url, const char *title, const char *class); +extern void html_link_close(void); +extern void html_fileperm(unsigned short mode); +extern int html_include(const char *filename); +extern int html_include_text(const char *filename); + +extern void http_parse_querystring(const char *txt, void (*fn)(const char *name, const char *value)); + +#endif /* HTML_H */ diff --git a/cgit/json.c b/cgit/json.c new file mode 100644 --- /dev/null +++ b/cgit/json.c @@ -0,0 +1,118 @@ +/* SPDX-FileCopyrightText: 2026 Nikolay Govorov */ +/* SPDX-License-Identifier: GPL-2.0-only */ + +#include "json.h" +#include "cgit.h" + +void json(const char *raw) { + if (write(STDOUT_FILENO, raw, strlen(raw)) != strlen(raw)) + die_errno("write error on json output"); +} + +void jsonf(const char *format, ...) { + va_list args; + struct strbuf buf = STRBUF_INIT; + + va_start(args, format); + strbuf_vaddf(&buf, format, args); + va_end(args); + json(buf.buf); + strbuf_release(&buf); +} + +static int utf8_len(const unsigned char *p) { + int len; + + if (p[0] < 0x80) { + return 1; + } + + if (p[0] >= 0xc2 && p[0] <= 0xdf) { + len = 2; + } else if (p[0] >= 0xe0 && p[0] <= 0xef) { + len = 3; + } else if (p[0] >= 0xf0 && p[0] <= 0xf4) { + len = 4; + } else { + return 0; + } + + if (!p[1] || (len > 2 && !p[2]) || (len > 3 && !p[3])) { + return 0; + } + + if ((p[1] & 0xc0) != 0x80 || (len > 2 && (p[2] & 0xc0) != 0x80) || + (len > 3 && (p[3] & 0xc0) != 0x80)) { + return 0; + } + + if ((p[0] == 0xe0 && p[1] < 0xa0) || (p[0] == 0xed && p[1] > 0x9f) || + (p[0] == 0xf0 && p[1] < 0x90) || (p[0] == 0xf4 && p[1] > 0x8f)) { + return 0; + } + + return len; +} + +void json_value(const char *value) { + const unsigned char *p = (const unsigned char *)(value ? value : ""); + + json("\""); + while (*p) { + int len; + switch (*p) { + case '\"': + json("\\\""); + p++; + break; + case '\\': + json("\\\\"); + p++; + break; + case '\b': + json("\\b"); + p++; + break; + case '\f': + json("\\f"); + p++; + break; + case '\n': + json("\\n"); + p++; + break; + case '\r': + json("\\r"); + p++; + break; + case '\t': + json("\\t"); + p++; + break; + default: + if (*p < 0x20) { + jsonf("\\u%04x", *p++); + } else if (*p < 0x80) { + char ch[] = {*p++, '\0'}; + json(ch); + } else if ((len = utf8_len(p))) { + if (write(STDOUT_FILENO, p, len) != len) + die_errno("write error on json output"); + p += len; + } else { + json("\xef\xbf\xbd"); + p++; + } + } + } + json("\""); +} + +void json_key(const char *key) { + json_value(key); + json(":"); +} + +void json_int(intmax_t value) { jsonf("%" PRIdMAX, value); } + +void json_bool(int value) { json(value ? "true" : "false"); } diff --git a/cgit/json.h b/cgit/json.h new file mode 100644 --- /dev/null +++ b/cgit/json.h @@ -0,0 +1,16 @@ +/* SPDX-FileCopyrightText: 2026 Nikolay Govorov */ +/* SPDX-License-Identifier: GPL-2.0-only */ + +#ifndef JSON_H +#define JSON_H + +#include "cgit.h" + +extern void json(const char *raw); +extern void jsonf(const char *format, ...); +extern void json_key(const char *key); +extern void json_value(const char *value); +extern void json_int(intmax_t value); +extern void json_bool(int value); + +#endif diff --git a/cgit/parsing.c b/cgit/parsing.c new file mode 100644 --- /dev/null +++ b/cgit/parsing.c @@ -0,0 +1,231 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* parsing.c: parsing of config files + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" + +/* + * url syntax: [repo ['/' cmd [ '/' path]]] + * repo: any valid repo url, may contain '/' + * cmd: log | commit | diff | tree | view | blob | snapshot + * path: any valid path, may contain '/' + * + */ +void cgit_parse_url(const char *url) +{ + char *c, *cmd, *p, *buf; + struct cgit_repo *repo; + + if (!url || url[0] == '\0') + return; + + ctx.qry.page = NULL; + ctx.repo = cgit_get_repoinfo(url); + if (ctx.repo) { + ctx.qry.repo = ctx.repo->url; + return; + } + + buf = xstrdup(url); + cmd = NULL; + c = strchr(buf, '/'); + while (c) { + c[0] = '\0'; + repo = cgit_get_repoinfo(buf); + if (repo) { + ctx.repo = repo; + cmd = c; + } + c[0] = '/'; + c = strchr(c + 1, '/'); + } + + if (ctx.repo) { + ctx.qry.repo = ctx.repo->url; + p = strchr(cmd + 1, '/'); + if (p) { + p[0] = '\0'; + if (p[1]) + ctx.qry.path = trim_end(p + 1, '/'); + } + if (cmd[1]) + ctx.qry.page = xstrdup(cmd + 1); + } + free(buf); +} + +static char *substr(const char *head, const char *tail) +{ + char *buf; + + if (tail < head) + return xstrdup(""); + buf = xmalloc(tail - head + 1); + strlcpy(buf, head, tail - head + 1); + return buf; +} + +static void parse_user(const char *t, char **name, char **email, unsigned long *date, int *tz) +{ + struct ident_split ident; + unsigned email_len; + + if (!split_ident_line(&ident, t, strchrnul(t, '\n') - t)) { + *name = substr(ident.name_begin, ident.name_end); + + email_len = ident.mail_end - ident.mail_begin; + *email = xmalloc(strlen("<") + email_len + strlen(">") + 1); + xsnprintf(*email, email_len + 3, "<%.*s>", email_len, ident.mail_begin); + + if (ident.date_begin) + *date = strtoul(ident.date_begin, NULL, 10); + if (ident.tz_begin) + *tz = atoi(ident.tz_begin); + } +} + +#ifdef NO_ICONV +#define reencode(a, b, c) +#else +static const char *reencode(char **txt, const char *src_enc, const char *dst_enc) +{ + char *tmp; + + if (!txt) + return NULL; + + if (!*txt || !src_enc || !dst_enc) + return *txt; + + /* no encoding needed if src_enc equals dst_enc */ + if (!strcasecmp(src_enc, dst_enc)) + return *txt; + + tmp = reencode_string(*txt, dst_enc, src_enc); + if (tmp) { + free(*txt); + *txt = tmp; + } + return *txt; +} +#endif + +static const char *next_header_line(const char *p) +{ + p = strchr(p, '\n'); + if (!p) + return NULL; + return p + 1; +} + +static int end_of_header(const char *p) +{ + return !p || (*p == '\n'); +} + +struct commitinfo *cgit_parse_commit(struct commit *commit) +{ + struct commitinfo *ret; + const char *p = repo_get_commit_buffer(the_repository, commit, NULL); + const char *t; + + ret = xcalloc(1, sizeof(struct commitinfo)); + ret->commit = commit; + + if (!p) + return ret; + + if (!skip_prefix(p, "tree ", &p)) + die("Bad commit: %s", oid_to_hex(&commit->object.oid)); + p += the_hash_algo->hexsz + 1; + + while (skip_prefix(p, "parent ", &p)) + p += the_hash_algo->hexsz + 1; + + if (p && skip_prefix(p, "author ", &p)) { + parse_user(p, &ret->author, &ret->author_email, + &ret->author_date, &ret->author_tz); + p = next_header_line(p); + } + + if (p && skip_prefix(p, "committer ", &p)) { + parse_user(p, &ret->committer, &ret->committer_email, + &ret->committer_date, &ret->committer_tz); + p = next_header_line(p); + } + + if (p && skip_prefix(p, "encoding ", &p)) { + t = strchr(p, '\n'); + if (t) { + ret->msg_encoding = substr(p, t + 1); + p = t + 1; + } + } + + if (!ret->msg_encoding) + ret->msg_encoding = xstrdup("UTF-8"); + + while (!end_of_header(p)) + p = next_header_line(p); + while (p && *p == '\n') + p++; + if (!p) + return ret; + + t = strchrnul(p, '\n'); + ret->subject = substr(p, t); + while (*t == '\n') + t++; + ret->msg = xstrdup(t); + + reencode(&ret->author, ret->msg_encoding, PAGE_ENCODING); + reencode(&ret->author_email, ret->msg_encoding, PAGE_ENCODING); + reencode(&ret->committer, ret->msg_encoding, PAGE_ENCODING); + reencode(&ret->committer_email, ret->msg_encoding, PAGE_ENCODING); + reencode(&ret->subject, ret->msg_encoding, PAGE_ENCODING); + reencode(&ret->msg, ret->msg_encoding, PAGE_ENCODING); + + return ret; +} + +struct taginfo *cgit_parse_tag(struct tag *tag) +{ + void *data; + enum object_type type; + unsigned long size; + const char *p; + struct taginfo *ret = NULL; + + data = odb_read_object(the_repository->objects, &tag->object.oid, &type, &size); + if (!data || type != OBJ_TAG) + goto cleanup; + + ret = xcalloc(1, sizeof(struct taginfo)); + + for (p = data; !end_of_header(p); p = next_header_line(p)) { + if (skip_prefix(p, "tagger ", &p)) { + parse_user(p, &ret->tagger, &ret->tagger_email, + &ret->tagger_date, &ret->tagger_tz); + } + } + + while (p && *p == '\n') + p++; + + if (p && *p) + ret->msg = xstrdup(p); + +cleanup: + free(data); + return ret; +} diff --git a/cgit/scan-tree.c b/cgit/scan-tree.c new file mode 100644 --- /dev/null +++ b/cgit/scan-tree.c @@ -0,0 +1,273 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* scan-tree.c + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#include "cgit.h" +#include "scan-tree.h" +#include "configfile.h" +#include "html.h" +#include <config.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 struct cgit_repo *repo; + +static void scan_tree_repo_config(const char *name, const char *value) +{ + cgit_repo_config(repo, name, value); +} + +static int gitconfig_config(const char *key, const char *value, + const __attribute__((unused)) struct config_context *ctx, void *cb) +{ + const char *name; + + if (!strcmp(key, "gitweb.owner")) + cgit_repo_config(repo, "owner", value); + else if (!strcmp(key, "gitweb.description")) + cgit_repo_config(repo, "desc", value); + else if (!strcmp(key, "gitweb.category")) + cgit_repo_config(repo, "section", value); + else if (!strcmp(key, "gitweb.homepage")) + cgit_repo_config(repo, "homepage", value); + else if (skip_prefix(key, "cgit.", &name)) + cgit_repo_config(repo, name, value); + + return 0; +} + +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; + 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.enable_git_config) { + strbuf_addstr(path, "config"); + git_config_from_file(gitconfig_config, path->buf, NULL); + strbuf_setlen(path, pathlen); + } + + 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_addstr(path, "cgitrc"); + if (!stat(path->buf, &st)) + parse_configfile(path->buf, &scan_tree_repo_config); + + 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_projects(const char *path, const char *projectsfile) +{ + struct strbuf line = STRBUF_INIT; + FILE *projects; + int err; + + projects = fopen(projectsfile, "r"); + if (!projects) { + fprintf(stderr, "Error opening projectsfile %s: %s (%d)\n", + projectsfile, strerror(errno), errno); + return; + } + while (strbuf_getline(&line, projects) != EOF) { + if (!line.len) + continue; + strbuf_insert(&line, 0, "/", 1); + strbuf_insert(&line, 0, path, strlen(path)); + scan_path(path, line.buf); + } + if ((err = ferror(projects))) { + fprintf(stderr, "Error reading from projectsfile %s: %s (%d)\n", + projectsfile, strerror(err), err); + } + fclose(projects); + strbuf_release(&line); +} + +void scan_tree(const char *path) +{ + scan_path(path, path); +} diff --git a/cgit/scan-tree.h b/cgit/scan-tree.h new file mode 100644 --- /dev/null +++ b/cgit/scan-tree.h @@ -0,0 +1,6 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +extern void scan_projects(const char *path, const char *projectsfile); +extern void scan_tree(const char *path); diff --git a/cgit/shared.c b/cgit/shared.c new file mode 100644 --- /dev/null +++ b/cgit/shared.c @@ -0,0 +1,594 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* shared.c: global vars + some callback functions + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" + +struct cgit_repolist cgit_repolist; +struct cgit_context ctx; + +int chk_zero(int result, char *msg) +{ + if (result != 0) + die_errno("%s", msg); + return result; +} + +int chk_positive(int result, char *msg) +{ + if (result <= 0) + die_errno("%s", msg); + return result; +} + +int chk_non_negative(int result, char *msg) +{ + if (result < 0) + die_errno("%s", msg); + return result; +} + +char *cgit_default_repo_desc = "[no description]"; +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)); + } + + 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; + ret->path = NULL; + ret->desc = cgit_default_repo_desc; + ret->extra_head_content = NULL; + ret->owner = NULL; + 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; + ret->mtime = -1; + ret->clone_url = ctx.cfg.clone_url; + ret->submodules.strdup_strings = 1; + ret->hide = ret->ignore = 0; + 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); + free(info->author_email); + free(info->committer); + free(info->committer_email); + free(info->subject); + free(info->msg); + free(info->msg_encoding); + free(info); +} + +char *trim_end(const char *str, char c) +{ + int len; + + if (str == NULL) + return NULL; + len = strlen(str); + while (len > 0 && str[len - 1] == c) + len--; + if (len == 0) + return NULL; + return xstrndup(str, len); +} + +char *ensure_end(const char *str, char c) +{ + size_t len = strlen(str); + char *result; + + if (len && str[len - 1] == c) + return xstrndup(str, len); + + result = xmalloc(len + 2); + memcpy(result, str, len); + result[len] = '/'; + result[len + 1] = '\0'; + return result; +} + +void strbuf_ensure_end(struct strbuf *sb, char c) +{ + if (!sb->len || sb->buf[sb->len - 1] != c) + strbuf_addch(sb, c); +} + +void cgit_add_ref(struct reflist *list, struct refinfo *ref) +{ + size_t size; + + if (list->count >= list->alloc) { + list->alloc += (list->alloc ? list->alloc : 4); + size = list->alloc * sizeof(struct refinfo *); + list->refs = xrealloc(list->refs, size); + } + list->refs[list->count++] = ref; +} + +static struct refinfo *cgit_mk_refinfo(const char *refname, const struct object_id *oid) +{ + struct refinfo *ref; + + ref = xmalloc(sizeof (struct refinfo)); + ref->refname = xstrdup(refname); + ref->object = parse_object(the_repository, oid); + switch (ref->object->type) { + case OBJ_TAG: + ref->tag = cgit_parse_tag((struct tag *)ref->object); + break; + case OBJ_COMMIT: + ref->commit = cgit_parse_commit((struct commit *)ref->object); + break; + } + return ref; +} + +void cgit_free_taginfo(struct taginfo *tag) +{ + if (tag->tagger) + free(tag->tagger); + if (tag->tagger_email) + free(tag->tagger_email); + if (tag->msg) + free(tag->msg); + free(tag); +} + +static void cgit_free_refinfo(struct refinfo *ref) +{ + if (ref->refname) + free((char *)ref->refname); + switch (ref->object->type) { + case OBJ_TAG: + cgit_free_taginfo(ref->tag); + break; + case OBJ_COMMIT: + cgit_free_commitinfo(ref->commit); + break; + } + free(ref); +} + +void cgit_free_reflist_inner(struct reflist *list) +{ + int i; + + for (i = 0; i < list->count; i++) { + cgit_free_refinfo(list->refs[i]); + } + free(list->refs); +} + +int cgit_refs_cb(const struct reference *ref, void *cb_data) +{ + struct reflist *list = (struct reflist *)cb_data; + struct refinfo *info = cgit_mk_refinfo(ref->name, ref->oid); + + if (info) + cgit_add_ref(list, info); + return 0; +} + +void cgit_diff_tree_cb(struct diff_queue_struct *q, + struct diff_options *options, void *data) +{ + int i; + + for (i = 0; i < q->nr; i++) { + if (q->queue[i]->status == 'U') + continue; + ((filepair_fn)data)(q->queue[i]); + } +} + +static int load_mmfile(mmfile_t *file, const struct object_id *oid) +{ + enum object_type type; + + if (is_null_oid(oid)) { + file->ptr = (char *)""; + file->size = 0; + } else { + file->ptr = odb_read_object(the_repository->objects, oid, &type, + (unsigned long *)&file->size); + } + return 1; +} + +/* + * Receive diff-buffers from xdiff and concatenate them as + * needed across multiple callbacks. + * + * This is basically a copy of xdiff-interface.c/xdiff_outf(), + * ripped from git and modified to use globals instead of + * a special callback-struct. + */ +static char *diffbuf = NULL; +static int buflen = 0; + +static int filediff_cb(void *priv, mmbuffer_t *mb, int nbuf) +{ + int i; + + for (i = 0; i < nbuf; i++) { + if (mb[i].ptr[mb[i].size-1] != '\n') { + /* Incomplete line */ + diffbuf = xrealloc(diffbuf, buflen + mb[i].size); + memcpy(diffbuf + buflen, mb[i].ptr, mb[i].size); + buflen += mb[i].size; + continue; + } + + /* we have a complete line */ + if (!diffbuf) { + ((linediff_fn)priv)(mb[i].ptr, mb[i].size); + continue; + } + diffbuf = xrealloc(diffbuf, buflen + mb[i].size); + memcpy(diffbuf + buflen, mb[i].ptr, mb[i].size); + ((linediff_fn)priv)(diffbuf, buflen + mb[i].size); + free(diffbuf); + diffbuf = NULL; + buflen = 0; + } + if (diffbuf) { + ((linediff_fn)priv)(diffbuf, buflen); + free(diffbuf); + diffbuf = NULL; + buflen = 0; + } + return 0; +} + +int cgit_diff_files(const struct object_id *old_oid, + const struct object_id *new_oid, unsigned long *old_size, + unsigned long *new_size, int *binary, int context, + int ignorews, linediff_fn fn) +{ + mmfile_t file1, file2; + xpparam_t diff_params; + xdemitconf_t emit_params; + xdemitcb_t emit_cb; + + if (!load_mmfile(&file1, old_oid) || !load_mmfile(&file2, new_oid)) + return 1; + + *old_size = file1.size; + *new_size = file2.size; + + if ((file1.ptr && buffer_is_binary(file1.ptr, file1.size)) || + (file2.ptr && buffer_is_binary(file2.ptr, file2.size))) { + *binary = 1; + if (file1.size) + free(file1.ptr); + if (file2.size) + free(file2.ptr); + return 0; + } + + memset(&diff_params, 0, sizeof(diff_params)); + memset(&emit_params, 0, sizeof(emit_params)); + memset(&emit_cb, 0, sizeof(emit_cb)); + diff_params.flags = XDF_NEED_MINIMAL; + if (ignorews) + diff_params.flags |= XDF_IGNORE_WHITESPACE; + emit_params.ctxlen = context > 0 ? context : 3; + emit_params.flags = XDL_EMIT_FUNCNAMES; + emit_cb.out_line = filediff_cb; + emit_cb.priv = fn; + xdl_diff(&file1, &file2, &diff_params, &emit_params, &emit_cb); + if (file1.size) + free(file1.ptr); + if (file2.size) + free(file2.ptr); + return 0; +} + +void cgit_diff_tree(const struct object_id *old_oid, + const struct object_id *new_oid, + filepair_fn fn, const char *prefix, int ignorews) +{ + struct diff_options opt; + struct pathspec_item *item; + + repo_diff_setup(the_repository, &opt); + opt.output_format = DIFF_FORMAT_CALLBACK; + opt.detect_rename = 1; + opt.rename_limit = ctx.cfg.renamelimit; + opt.flags.recursive = 1; + if (ignorews) + DIFF_XDL_SET(&opt, IGNORE_WHITESPACE); + opt.format_callback = cgit_diff_tree_cb; + opt.format_callback_data = fn; + if (prefix) { + item = xcalloc(1, sizeof(*item)); + item->match = xstrdup(prefix); + item->len = strlen(prefix); + opt.pathspec.nr = 1; + opt.pathspec.items = item; + } + diff_setup_done(&opt); + + if (old_oid && !is_null_oid(old_oid)) + diff_tree_oid(old_oid, new_oid, "", &opt); + else + diff_root_tree_oid(new_oid, "", &opt); + diffcore_std(&opt); + diff_flush(&opt); +} + +void cgit_diff_commit(struct commit *commit, filepair_fn fn, const char *prefix) +{ + const struct object_id *old_oid = NULL; + + if (commit->parents) + old_oid = &commit->parents->item->object.oid; + cgit_diff_tree(old_oid, &commit->object.oid, fn, prefix, + ctx.qry.ignorews); +} + +int cgit_parse_snapshots_mask(const char *str) +{ + struct string_list tokens = STRING_LIST_INIT_DUP; + struct string_list_item *item; + const struct cgit_snapshot_format *f; + int rv = 0; + + /* favor legacy setting */ + if (atoi(str)) + return 1; + + if (strcmp(str, "all") == 0) + return INT_MAX; + + string_list_split(&tokens, str, " ", -1); + string_list_remove_empty_items(&tokens, 0); + + for_each_string_list_item(item, &tokens) { + for (f = cgit_snapshot_formats; f->suffix; f++) { + if (!strcmp(item->string, f->suffix) || + !strcmp(item->string, f->suffix + 1)) { + rv |= cgit_snapshot_format_bit(f); + break; + } + } + } + + string_list_clear(&tokens, 0); + return rv; +} + +typedef struct { + char * name; + char * value; +} cgit_env_var; + +void cgit_prepare_repo_env(struct cgit_repo * repo) +{ + cgit_env_var env_vars[] = { + { .name = "CGIT_REPO_URL", .value = repo->url }, + { .name = "CGIT_REPO_NAME", .value = repo->name }, + { .name = "CGIT_REPO_PATH", .value = repo->path }, + { .name = "CGIT_REPO_OWNER", .value = repo->owner }, + { .name = "CGIT_REPO_DEFBRANCH", .value = repo->defbranch }, + { .name = "CGIT_REPO_SECTION", .value = repo->section }, + { .name = "CGIT_REPO_CLONE_URL", .value = repo->clone_url } + }; + int env_var_count = ARRAY_SIZE(env_vars); + cgit_env_var *p, *q; + static char *warn = "cgit warning: failed to set env: %s=%s\n"; + + p = env_vars; + q = p + env_var_count; + for (; p < q; p++) + if (p->value && setenv(p->name, p->value, 1)) + fprintf(stderr, warn, p->name, p->value); +} + +/* Read the content of the specified file into a newly allocated buffer, + * zeroterminate the buffer, truncate at a new line, and return 0 on success, + * errno otherwise. + */ +int read_first_line(const char *path, char **buf, size_t *size) +{ + int fd, e; + struct stat st; + + fd = open(path, O_RDONLY); + if (fd == -1) + return errno; + if (fstat(fd, &st)) { + e = errno; + close(fd); + return e; + } + if (!S_ISREG(st.st_mode)) { + close(fd); + return EISDIR; + } + *buf = xmalloc(st.st_size + 1); + *size = read_in_full(fd, *buf, st.st_size); + e = errno; + (*buf)[*size] = '\0'; + *strchrnul(*buf, '\n') = '\0'; + close(fd); + return (*size == st.st_size ? 0 : e); +} + +char *strdup_first_line(const char *txt) +{ + char *t = xstrdup(txt); + *strchrnul(t, '\n') = '\0'; + return t; +} + +static int is_token_char(char c) +{ + return isalnum(c) || c == '_'; +} + +/* Replace name with getenv(name), return pointer to zero-terminating char + */ +static char *expand_macro(char *name, int maxlength) +{ + char *value; + size_t len; + + len = 0; + value = getenv(name); + if (value) { + len = strlen(value) + 1; + if (len > maxlength) + len = maxlength; + strlcpy(name, value, len); + --len; + } + return name + len; +} + +#define EXPBUFSIZE (1024 * 8) + +/* Replace all tokens prefixed by '$' in the specified text with the + * value of the named environment variable. + * NB: the return value is a static buffer, i.e. it must be strdup'd + * by the caller. + */ +char *expand_macros(const char *txt) +{ + static char result[EXPBUFSIZE]; + char *p, *start; + int len; + + p = result; + start = NULL; + while (p < result + EXPBUFSIZE - 1 && txt && *txt) { + *p = *txt; + if (start) { + if (!is_token_char(*txt)) { + if (p - start > 0) { + *p = '\0'; + len = result + EXPBUFSIZE - start - 1; + p = expand_macro(start, len) - 1; + } + start = NULL; + txt--; + } + p++; + txt++; + continue; + } + if (*txt == '$') { + start = p; + txt++; + continue; + } + p++; + txt++; + } + *p = '\0'; + if (start && p - start > 0) { + len = result + EXPBUFSIZE - start - 1; + p = expand_macro(start, len); + *p = '\0'; + } + return result; +} + +char *get_mimetype_for_filename(const char *filename) +{ + const char *ext; + char *mimetype, line[1024]; + struct string_list list = STRING_LIST_INIT_NODUP; + int i; + FILE *file; + struct string_list_item *mime; + + if (!filename) + return NULL; + + ext = strrchr(filename, '.'); + if (!ext) + return NULL; + ++ext; + if (!ext[0]) + return NULL; + mime = string_list_lookup(&ctx.cfg.mimetypes, ext); + if (mime) + return xstrdup(mime->util); + + if (!ctx.cfg.mimetype_file) + return NULL; + file = fopen(ctx.cfg.mimetype_file, "r"); + if (!file) + return NULL; + while (fgets(line, sizeof(line), file)) { + if (!line[0] || line[0] == '#') + continue; + string_list_split_in_place(&list, line, " \t\r\n", -1); + string_list_remove_empty_items(&list, 0); + mimetype = list.items[0].string; + for (i = 1; i < list.nr; i++) { + if (!strcasecmp(ext, list.items[i].string)) { + fclose(file); + return xstrdup(mimetype); + } + } + string_list_clear(&list, 0); + } + fclose(file); + return NULL; +} diff --git a/cgit/ui-atom.c b/cgit/ui-atom.c new file mode 100644 --- /dev/null +++ b/cgit/ui-atom.c @@ -0,0 +1,163 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-atom.c: functions for atom feeds + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-atom.h" +#include "html.h" +#include "ui-shared.h" + +static void add_entry(struct commit *commit, const char *host) +{ + char delim = '&'; + char *hex; + char *mail, *t, *t2; + struct commitinfo *info; + + info = cgit_parse_commit(commit); + hex = oid_to_hex(&commit->object.oid); + html("<entry>\n"); + html("<title>"); + html_txt(info->subject); + html("</title>\n"); + html("<updated>"); + html_txt(show_date(info->committer_date, 0, + date_mode_from_type(DATE_ISO8601_STRICT))); + html("</updated>\n"); + html("<author>\n"); + if (info->author) { + html("<name>"); + html_txt(info->author); + html("</name>\n"); + } + if (info->author_email && !ctx.cfg.noplainemail) { + mail = xstrdup(info->author_email); + t = strchr(mail, '<'); + if (t) + t++; + else + t = mail; + t2 = strchr(t, '>'); + if (t2) + *t2 = '\0'; + html("<email>"); + html_txt(t); + html("</email>\n"); + free(mail); + } + html("</author>\n"); + html("<published>"); + html_txt(show_date(info->author_date, 0, + date_mode_from_type(DATE_ISO8601_STRICT))); + html("</published>\n"); + if (host) { + char *pageurl; + html("<link rel='alternate' type='text/html' href='"); + html(cgit_httpscheme()); + html_attr(host); + pageurl = cgit_pageurl(ctx.repo->url, "commit", NULL); + html_attr(pageurl); + if (ctx.cfg.virtual_root) + delim = '?'; + html_attrf("%cid=%s", delim, hex); + html("'/>\n"); + free(pageurl); + } + html("<id>"); + html_txtf("urn:%s:%s", the_hash_algo->name, hex); + html("</id>\n"); + html("<content type='text'>\n"); + html_txt(info->msg); + html("</content>\n"); + html("</entry>\n"); + cgit_free_commitinfo(info); +} + + +void cgit_print_atom(char *tip, const char *path, int max_count) +{ + char *host; + const char *argv[] = {NULL, tip, NULL, NULL, NULL}; + struct commit *commit; + struct rev_info rev; + int argc = 2; + bool first = true; + + if (ctx.qry.show_all) + argv[1] = "--all"; + else if (!tip) + argv[1] = ctx.qry.head; + + if (path) { + argv[argc++] = "--"; + argv[argc++] = path; + } + + repo_init_revisions(the_repository, &rev, NULL); + rev.abbrev = DEFAULT_ABBREV; + rev.commit_format = CMIT_FMT_DEFAULT; + rev.verbose_header = 1; + rev.show_root_diff = 0; + rev.max_count = max_count; + setup_revisions(argc, argv, &rev, NULL); + prepare_revision_walk(&rev); + + host = cgit_hosturl(); + ctx.page.mimetype = "text/xml"; + ctx.page.charset = "utf-8"; + cgit_print_http_headers(); + html("<feed xmlns='http://www.w3.org/2005/Atom'>\n"); + html("<title>"); + html_txt(ctx.repo->name); + if (path) { + html("/"); + html_txt(path); + } + if (tip && !ctx.qry.show_all) { + html(", branch "); + html_txt(tip); + } + html("</title>\n"); + html("<subtitle>"); + html_txt(ctx.repo->desc); + html("</subtitle>\n"); + if (host) { + char *fullurl = cgit_currentfullurl(); + char *repourl = cgit_repourl(ctx.repo->url); + html("<id>"); + html_txtf("%s%s%s", cgit_httpscheme(), host, fullurl); + html("</id>\n"); + html("<link rel='self' href='"); + html_attrf("%s%s%s", cgit_httpscheme(), host, fullurl); + html("'/>\n"); + html("<link rel='alternate' type='text/html' href='"); + html_attrf("%s%s%s", cgit_httpscheme(), host, repourl); + html("'/>\n"); + free(fullurl); + free(repourl); + } + while ((commit = get_revision(&rev)) != NULL) { + if (first) { + html("<updated>"); + html_txt(show_date(commit->date, 0, + date_mode_from_type(DATE_ISO8601_STRICT))); + html("</updated>\n"); + first = false; + } + add_entry(commit, host); + release_commit_memory(the_repository->parsed_objects, commit); + commit->parents = NULL; + } + html("</feed>\n"); + free(host); +} diff --git a/cgit/ui-atom.h b/cgit/ui-atom.h new file mode 100644 --- /dev/null +++ b/cgit/ui-atom.h @@ -0,0 +1,10 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_ATOM_H +#define UI_ATOM_H + +extern void cgit_print_atom(char *tip, const char *path, int max_count); + +#endif diff --git a/cgit/ui-blame.c b/cgit/ui-blame.c new file mode 100644 --- /dev/null +++ b/cgit/ui-blame.c @@ -0,0 +1,312 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-blame.c: functions for blame output + * + * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com> + * + * 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("<span class='oid'>"); + cgit_commit_link(repo_find_unique_abbrev(the_repository, oid, DEFAULT_ABBREV), detail, + NULL, ctx.qry.head, oid_to_hex(oid), suspect->path); + html("</span>"); + 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 = "<a id='n%1$d' href='#n%1$d'>%1$d</a>\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("<div class='error'>blob is binary.</div>"); + goto cleanup; + } + if (ctx.cfg.max_blob_size && size / 1024 > ctx.cfg.max_blob_size) { + htmlf("<div class='error'>blob size (%ldKB)" + " exceeds display size limit (%dKB).</div>", + size / 1024, ctx.cfg.max_blob_size); + goto cleanup; + } + + html("<table class='blame blob'>\n<tr>\n"); + + /* Commit hashes */ + html("<td class='hashes'>"); + for (ent = sb.ent; ent; ent = ent->next) { + html("<div class='alt'><pre>"); + emit_blame_entry_hash(ent); + html("</pre></div>"); + } + html("</td>\n"); + + /* Line numbers */ + if (ctx.cfg.enable_tree_linenumbers) { + html("<td class='linenumbers'>"); + for (ent = sb.ent; ent; ent = ent->next) { + html("<div class='alt'><pre>"); + emit_blame_entry_linenumber(ent); + html("</pre></div>"); + } + html("</td>\n"); + } + + html("<td class='lines'><div>"); + + /* Colored bars behind lines */ + html("<div>"); + for (ent = sb.ent; ent; ) { + struct blame_entry *e = ent->next; + html("<div class='alt'><pre>"); + emit_blame_entry_line_background(&sb, ent); + html("</pre></div>"); + free(ent); + ent = e; + } + html("</div>"); + + free((void *)sb.final_buf); + + /* Lines */ + html("<pre><code>"); + html_txt(buf); + html("</code></pre>"); + + html("</div></td>\n"); + + html("</tr>\n</table>\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 new file mode 100644 --- /dev/null +++ b/cgit/ui-blame.h @@ -0,0 +1,10 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-blob.c @@ -0,0 +1,188 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-blob.c: show blob content + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-blob.h @@ -0,0 +1,12 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-clone.c @@ -0,0 +1,142 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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 <cgit@lists.zx2c4.com> + * + * 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-clone.h @@ -0,0 +1,12 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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-commit.c b/cgit/ui-commit.c new file mode 100644 --- /dev/null +++ b/cgit/ui-commit.c @@ -0,0 +1,144 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-commit.c: generate commit view + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-commit.h" +#include "html.h" +#include "ui-shared.h" +#include "ui-diff.h" +#include "ui-log.h" + +void cgit_print_commit(char *hex, const char *prefix) +{ + struct commit *commit, *parent; + struct commitinfo *info, *parent_info; + struct commit_list *p; + struct strbuf notes = STRBUF_INIT; + struct object_id oid; + char *tmp, *tmp2; + int parents = 0; + + if (!hex) + hex = ctx.qry.head; + + if (repo_get_oid(the_repository, hex, &oid)) { + cgit_print_error_page(400, "Bad request", + "Bad object id: %s", hex); + return; + } + commit = lookup_commit_reference(the_repository, &oid); + if (!commit) { + cgit_print_error_page(404, "Not found", + "Bad commit reference: %s", hex); + return; + } + info = cgit_parse_commit(commit); + + format_display_notes(&oid, ¬es, PAGE_ENCODING, 1); + + load_ref_decorations(NULL, DECORATE_FULL_REFS); + + ctx.page.title = fmtalloc("%s - %s", info->subject, ctx.page.title); + cgit_print_layout_start(); + cgit_print_diff_ctrls(); + html("<table summary='commit info' class='commit-info'>\n"); + html("<tr><th>author</th><td>"); + html_txt(info->author); + if (!ctx.cfg.noplainemail) { + html(" "); + html_txt(info->author_email); + } + html("</td><td class='right'>"); + html_txt(show_date(info->author_date, info->author_tz, + cgit_date_mode(DATE_ISO8601))); + html("</td></tr>\n"); + html("<tr><th>committer</th><td>"); + html_txt(info->committer); + if (!ctx.cfg.noplainemail) { + html(" "); + html_txt(info->committer_email); + } + html("</td><td class='right'>"); + html_txt(show_date(info->committer_date, info->committer_tz, + cgit_date_mode(DATE_ISO8601))); + html("</td></tr>\n"); + html("<tr><th>commit</th><td colspan='2' class='oid'>"); + tmp = oid_to_hex(&commit->object.oid); + cgit_commit_link(tmp, NULL, NULL, ctx.qry.head, tmp, prefix); + html(" ("); + cgit_patch_link("patch", NULL, NULL, NULL, tmp, prefix); + html(")</td></tr>\n"); + html("<tr><th>tree</th><td colspan='2' class='oid'>"); + tmp = xstrdup(hex); + cgit_tree_link(oid_to_hex(get_commit_tree_oid(commit)), NULL, NULL, + ctx.qry.head, tmp, NULL); + if (prefix) { + html(" /"); + cgit_tree_link(prefix, NULL, NULL, ctx.qry.head, tmp, prefix); + } + free(tmp); + html("</td></tr>\n"); + for (p = commit->parents; p; p = p->next) { + parent = lookup_commit_reference(the_repository, &p->item->object.oid); + if (!parent) { + html("<tr><td colspan='3'>"); + cgit_print_error("Error reading parent commit"); + html("</td></tr>"); + continue; + } + html("<tr><th>parent</th>" + "<td colspan='2' class='oid'>"); + tmp = tmp2 = oid_to_hex(&p->item->object.oid); + if (ctx.repo->enable_subject_links) { + parent_info = cgit_parse_commit(parent); + tmp2 = parent_info->subject; + } + cgit_commit_link(tmp2, NULL, NULL, ctx.qry.head, tmp, prefix); + html(" ("); + cgit_diff_link("diff", NULL, NULL, ctx.qry.head, hex, + oid_to_hex(&p->item->object.oid), prefix); + html(")</td></tr>"); + parents++; + } + if (ctx.repo->snapshots) { + html("<tr><th>download</th><td colspan='2' class='oid'>"); + cgit_print_snapshot_links(ctx.repo, hex, "<br/>"); + html("</td></tr>"); + } + html("</table>\n"); + html("<div class='commit-subject'>"); + html_txt(info->subject); + show_commit_decorations(commit); + html("</div>"); + html("<div class='commit-msg'>"); + html_txt(info->msg); + html("</div>"); + if (notes.len != 0) { + html("<div class='notes-header'>Notes</div>"); + html("<div class='notes'>"); + html_txt(notes.buf); + html("</div>"); + html("<div class='notes-footer'></div>"); + } + if (parents < 3) { + if (parents) + tmp = oid_to_hex(&commit->parents->item->object.oid); + else + tmp = NULL; + cgit_print_diff(ctx.qry.oid, tmp, prefix, 0, 0); + } + strbuf_release(¬es); + cgit_free_commitinfo(info); + cgit_print_layout_end(); +} diff --git a/cgit/ui-commit.h b/cgit/ui-commit.h new file mode 100644 --- /dev/null +++ b/cgit/ui-commit.h @@ -0,0 +1,10 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_COMMIT_H +#define UI_COMMIT_H + +extern void cgit_print_commit(char *hex, const char *prefix); + +#endif /* UI_COMMIT_H */ diff --git a/cgit/ui-diff.c b/cgit/ui-diff.c new file mode 100644 --- /dev/null +++ b/cgit/ui-diff.c @@ -0,0 +1,507 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-diff.c: show diff between two blobs + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-diff.h" +#include "html.h" +#include "ui-shared.h" +#include "ui-ssdiff.h" + +struct object_id old_rev_oid[1]; +struct object_id new_rev_oid[1]; + +static int files, slots; +static int total_adds, total_rems, max_changes; +static int lines_added, lines_removed; + +static struct fileinfo { + char status; + struct object_id old_oid[1]; + struct object_id new_oid[1]; + unsigned short old_mode; + unsigned short new_mode; + char *old_path; + char *new_path; + unsigned int added; + unsigned int removed; + unsigned long old_size; + unsigned long new_size; + unsigned int binary:1; +} *items; + +static int use_ssdiff = 0; +static struct diff_filepair *current_filepair; +static const char *current_prefix; + +struct diff_filespec *cgit_get_current_old_file(void) +{ + return current_filepair->one; +} + +struct diff_filespec *cgit_get_current_new_file(void) +{ + return current_filepair->two; +} + +static void print_fileinfo(struct fileinfo *info) +{ + char *class; + + switch (info->status) { + case DIFF_STATUS_ADDED: + class = "add"; + break; + case DIFF_STATUS_COPIED: + class = "cpy"; + break; + case DIFF_STATUS_DELETED: + class = "del"; + break; + case DIFF_STATUS_MODIFIED: + class = "upd"; + break; + case DIFF_STATUS_RENAMED: + class = "mov"; + break; + case DIFF_STATUS_TYPE_CHANGED: + class = "typ"; + break; + case DIFF_STATUS_UNKNOWN: + class = "unk"; + break; + case DIFF_STATUS_UNMERGED: + class = "stg"; + break; + default: + die("bug: unhandled diff status %c", info->status); + } + + html("<tr>"); + html("<td class='mode'>"); + if (is_null_oid(info->new_oid)) { + cgit_print_filemode(info->old_mode); + } else { + cgit_print_filemode(info->new_mode); + } + + if (info->old_mode != info->new_mode && + !is_null_oid(info->old_oid) && + !is_null_oid(info->new_oid)) { + html("<span class='modechange'>["); + cgit_print_filemode(info->old_mode); + html("]</span>"); + } + htmlf("</td><td class='%s'>", class); + cgit_diff_link(info->new_path, NULL, NULL, ctx.qry.head, ctx.qry.oid, + ctx.qry.oid2, info->new_path); + if (info->status == DIFF_STATUS_COPIED || info->status == DIFF_STATUS_RENAMED) { + htmlf(" (%s from ", + info->status == DIFF_STATUS_COPIED ? "copied" : "renamed"); + html_txt(info->old_path); + html(")"); + } + html("</td><td class='right'>"); + if (info->binary) { + htmlf("bin</td><td class='graph'>%ld -> %ld bytes", + info->old_size, info->new_size); + return; + } + htmlf("%d", info->added + info->removed); + html("</td><td class='graph'>"); + htmlf("<table summary='file diffstat' width='%d%%'><tr>", (max_changes > 100 ? 100 : max_changes)); + htmlf("<td class='add' style='width: %.1f%%;'/>", + info->added * 100.0 / max_changes); + htmlf("<td class='rem' style='width: %.1f%%;'/>", + info->removed * 100.0 / max_changes); + htmlf("<td class='none' style='width: %.1f%%;'/>", + (max_changes - info->removed - info->added) * 100.0 / max_changes); + html("</tr></table></td></tr>\n"); +} + +static void count_diff_lines(char *line, int len) +{ + if (line && (len > 0)) { + if (line[0] == '+') + lines_added++; + else if (line[0] == '-') + lines_removed++; + } +} + +static int show_filepair(struct diff_filepair *pair) +{ + /* Always show if we have no limiting prefix. */ + if (!current_prefix) + return 1; + + /* Show if either path in the pair begins with the prefix. */ + if (starts_with(pair->one->path, current_prefix) || + starts_with(pair->two->path, current_prefix)) + return 1; + + /* Otherwise we don't want to show this filepair. */ + return 0; +} + +static void inspect_filepair(struct diff_filepair *pair) +{ + int binary = 0; + unsigned long old_size = 0; + unsigned long new_size = 0; + + if (!show_filepair(pair)) + return; + + files++; + lines_added = 0; + lines_removed = 0; + cgit_diff_files(&pair->one->oid, &pair->two->oid, &old_size, &new_size, + &binary, 0, ctx.qry.ignorews, count_diff_lines); + if (files >= slots) { + if (slots == 0) + slots = 4; + else + slots = slots * 2; + items = xrealloc(items, slots * sizeof(struct fileinfo)); + } + items[files-1].status = pair->status; + oidcpy(items[files-1].old_oid, &pair->one->oid); + oidcpy(items[files-1].new_oid, &pair->two->oid); + items[files-1].old_mode = pair->one->mode; + items[files-1].new_mode = pair->two->mode; + items[files-1].old_path = xstrdup(pair->one->path); + items[files-1].new_path = xstrdup(pair->two->path); + items[files-1].added = lines_added; + items[files-1].removed = lines_removed; + items[files-1].old_size = old_size; + items[files-1].new_size = new_size; + items[files-1].binary = binary; + if (lines_added + lines_removed > max_changes) + max_changes = lines_added + lines_removed; + total_adds += lines_added; + total_rems += lines_removed; +} + +static void cgit_print_diffstat(const struct object_id *old_oid, + const struct object_id *new_oid, + const char *prefix) +{ + int i; + + html("<div class='diffstat-header'>"); + cgit_diff_link("Diffstat", NULL, NULL, ctx.qry.head, ctx.qry.oid, + ctx.qry.oid2, NULL); + if (prefix) { + html(" (limited to '"); + html_txt(prefix); + html("')"); + } + html("</div>"); + html("<table summary='diffstat' class='diffstat'>"); + max_changes = 0; + cgit_diff_tree(old_oid, new_oid, inspect_filepair, prefix, + ctx.qry.ignorews); + for (i = 0; i<files; i++) + print_fileinfo(&items[i]); + html("</table>"); + html("<div class='diffstat-summary'>"); + htmlf("%d files changed, %d insertions, %d deletions", + files, total_adds, total_rems); + html("</div>"); +} + + +/* + * print a single line returned from xdiff + */ +static void print_line(char *line, int len) +{ + char *class = "ctx"; + char c = line[len-1]; + + if (line[0] == '+') + class = "add"; + else if (line[0] == '-') + class = "del"; + else if (line[0] == '@') + class = "hunk"; + + htmlf("<div class='%s'>", class); + line[len-1] = '\0'; + html_txt(line); + html("</div>"); + line[len-1] = c; +} + +static void header(const struct object_id *oid1, char *path1, int mode1, + const struct object_id *oid2, char *path2, int mode2) +{ + char *abbrev1, *abbrev2; + int subproject; + + subproject = (S_ISGITLINK(mode1) || S_ISGITLINK(mode2)); + html("<div class='head'>"); + html("diff --git a/"); + html_txt(path1); + html(" b/"); + html_txt(path2); + + if (mode1 == 0) + htmlf("<br/>new file mode %.6o", mode2); + + if (mode2 == 0) + htmlf("<br/>deleted file mode %.6o", mode1); + + if (!subproject) { + abbrev1 = xstrdup(repo_find_unique_abbrev(the_repository, oid1, DEFAULT_ABBREV)); + abbrev2 = xstrdup(repo_find_unique_abbrev(the_repository, oid2, DEFAULT_ABBREV)); + htmlf("<br/>index %s..%s", abbrev1, abbrev2); + free(abbrev1); + free(abbrev2); + if (mode1 != 0 && mode2 != 0) { + htmlf(" %.6o", mode1); + if (mode2 != mode1) + htmlf("..%.6o", mode2); + } + if (is_null_oid(oid1)) { + path1 = "dev/null"; + html("<br/>--- /"); + } else + html("<br/>--- a/"); + if (mode1 != 0) + cgit_tree_link(path1, NULL, NULL, ctx.qry.head, + oid_to_hex(old_rev_oid), path1); + else + html_txt(path1); + if (is_null_oid(oid2)) { + path2 = "dev/null"; + html("<br/>+++ /"); + } else + html("<br/>+++ b/"); + if (mode2 != 0) + cgit_tree_link(path2, NULL, NULL, ctx.qry.head, + oid_to_hex(new_rev_oid), path2); + else + html_txt(path2); + } + html("</div>"); +} + +static void filepair_cb(struct diff_filepair *pair) +{ + unsigned long old_size = 0; + unsigned long new_size = 0; + int binary = 0; + linediff_fn print_line_fn = print_line; + + if (!show_filepair(pair)) + return; + + current_filepair = pair; + if (use_ssdiff) { + cgit_ssdiff_header_begin(); + print_line_fn = cgit_ssdiff_line_cb; + } + header(&pair->one->oid, pair->one->path, pair->one->mode, + &pair->two->oid, pair->two->path, pair->two->mode); + if (use_ssdiff) + cgit_ssdiff_header_end(); + if (S_ISGITLINK(pair->one->mode) || S_ISGITLINK(pair->two->mode)) { + if (S_ISGITLINK(pair->one->mode)) + print_line_fn(fmt("-Subproject %s", oid_to_hex(&pair->one->oid)), 52); + if (S_ISGITLINK(pair->two->mode)) + print_line_fn(fmt("+Subproject %s", oid_to_hex(&pair->two->oid)), 52); + if (use_ssdiff) + cgit_ssdiff_footer(); + return; + } + if (cgit_diff_files(&pair->one->oid, &pair->two->oid, &old_size, + &new_size, &binary, ctx.qry.context, + ctx.qry.ignorews, print_line_fn)) + cgit_print_error("Error running diff"); + if (binary) { + if (use_ssdiff) + html("<tr><td colspan='4'>Binary files differ</td></tr>"); + else + html("Binary files differ"); + } + if (use_ssdiff) + cgit_ssdiff_footer(); +} + +void cgit_print_diff_ctrls(void) +{ + int i, curr; + + html("<div class='cgit-panel'>"); + html("<b>diff options</b>"); + html("<form method='get'>"); + cgit_add_hidden_formfields(1, 0, ctx.qry.page); + html("<table>"); + html("<tr><td colspan='2'/></tr>"); + html("<tr>"); + html("<td class='label'>context:</td>"); + html("<td class='ctrl'>"); + html("<select name='context' onchange='this.form.submit();'>"); + curr = ctx.qry.context; + if (!curr) + curr = 3; + for (i = 1; i <= 10; i++) + html_intoption(i, fmt("%d", i), curr); + for (i = 15; i <= 40; i += 5) + html_intoption(i, fmt("%d", i), curr); + html("</select>"); + html("</td>"); + html("</tr><tr>"); + html("<td class='label'>space:</td>"); + html("<td class='ctrl'>"); + html("<select name='ignorews' onchange='this.form.submit();'>"); + html_intoption(0, "include", ctx.qry.ignorews); + html_intoption(1, "ignore", ctx.qry.ignorews); + html("</select>"); + html("</td>"); + html("</tr><tr>"); + html("<td class='label'>mode:</td>"); + html("<td class='ctrl'>"); + html("<select name='dt' onchange='this.form.submit();'>"); + curr = ctx.qry.has_difftype ? ctx.qry.difftype : ctx.cfg.difftype; + html_intoption(0, "unified", curr); + html_intoption(1, "ssdiff", curr); + html_intoption(2, "stat only", curr); + html("</select></td></tr>"); + html("<tr><td/><td class='ctrl'>"); + html("<noscript><input type='submit' value='reload'/></noscript>"); + html("</td></tr></table>"); + html("</form>"); + html("</div>"); +} + +void cgit_print_diff(const char *new_rev, const char *old_rev, + const char *prefix, int show_ctrls, int raw) +{ + struct commit *commit, *commit2; + const struct object_id *old_tree_oid, *new_tree_oid; + diff_type difftype; + + /* + * If "follow" is set then the diff machinery needs to examine the + * entire commit to detect renames so we must limit the paths in our + * own callbacks and not pass the prefix to the diff machinery. + */ + if (ctx.qry.follow && ctx.repo->enable_follow_links) { + current_prefix = prefix; + prefix = ""; + } else { + current_prefix = NULL; + } + + if (!new_rev) + new_rev = ctx.qry.head; + if (repo_get_oid(the_repository, new_rev, new_rev_oid)) { + cgit_print_error_page(404, "Not found", + "Bad object name: %s", new_rev); + return; + } + commit = lookup_commit_reference(the_repository, new_rev_oid); + if (!commit || repo_parse_commit(the_repository, commit)) { + cgit_print_error_page(404, "Not found", + "Bad commit: %s", oid_to_hex(new_rev_oid)); + return; + } + new_tree_oid = get_commit_tree_oid(commit); + + if (old_rev) { + if (repo_get_oid(the_repository, old_rev, old_rev_oid)) { + cgit_print_error_page(404, "Not found", + "Bad object name: %s", old_rev); + return; + } + } else if (commit->parents && commit->parents->item) { + oidcpy(old_rev_oid, &commit->parents->item->object.oid); + } else { + oidclr(old_rev_oid, the_repository->hash_algo); + } + + if (!is_null_oid(old_rev_oid)) { + commit2 = lookup_commit_reference(the_repository, old_rev_oid); + if (!commit2 || repo_parse_commit(the_repository, commit2)) { + cgit_print_error_page(404, "Not found", + "Bad commit: %s", oid_to_hex(old_rev_oid)); + return; + } + old_tree_oid = get_commit_tree_oid(commit2); + } else { + old_tree_oid = NULL; + } + + if (raw) { + struct diff_options diffopt; + + repo_diff_setup(the_repository, &diffopt); + diffopt.output_format = DIFF_FORMAT_PATCH; + diffopt.flags.recursive = 1; + diff_setup_done(&diffopt); + + ctx.page.mimetype = "text/plain"; + cgit_print_http_headers(); + if (old_tree_oid) { + diff_tree_oid(old_tree_oid, new_tree_oid, "", + &diffopt); + } else { + diff_root_tree_oid(new_tree_oid, "", &diffopt); + } + diffcore_std(&diffopt); + diff_flush(&diffopt); + + return; + } + + difftype = ctx.qry.has_difftype ? ctx.qry.difftype : ctx.cfg.difftype; + use_ssdiff = difftype == DIFF_SSDIFF; + + if (show_ctrls) { + cgit_print_layout_start(); + cgit_print_diff_ctrls(); + } + + /* + * Clicking on a link to a file in the diff stat should show a diff + * of the file, showing the diff stat limited to a single file is + * pretty useless. All links from this point on will be to + * individual files, so we simply reset the difftype in the query + * here to avoid propagating DIFF_STATONLY to the individual files. + */ + if (difftype == DIFF_STATONLY) + ctx.qry.difftype = ctx.cfg.difftype; + + cgit_print_diffstat(old_rev_oid, new_rev_oid, prefix); + + if (difftype == DIFF_STATONLY) + return; + + if (use_ssdiff) { + html("<table summary='ssdiff' class='ssdiff'>"); + } else { + html("<table summary='diff' class='diff'>"); + html("<tr><td>"); + } + cgit_diff_tree(old_rev_oid, new_rev_oid, filepair_cb, prefix, + ctx.qry.ignorews); + if (!use_ssdiff) + html("</td></tr>"); + html("</table>"); + + if (show_ctrls) + cgit_print_layout_end(); +} diff --git a/cgit/ui-diff.h b/cgit/ui-diff.h new file mode 100644 --- /dev/null +++ b/cgit/ui-diff.h @@ -0,0 +1,19 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_DIFF_H +#define UI_DIFF_H + +extern void cgit_print_diff_ctrls(void); + +extern void cgit_print_diff(const char *new_hex, const char *old_hex, + const char *prefix, int show_ctrls, int raw); + +extern struct diff_filespec *cgit_get_current_old_file(void); +extern struct diff_filespec *cgit_get_current_new_file(void); + +extern struct object_id old_rev_oid[1]; +extern struct object_id new_rev_oid[1]; + +#endif /* UI_DIFF_H */ diff --git a/cgit/ui-log.c b/cgit/ui-log.c new file mode 100644 --- /dev/null +++ b/cgit/ui-log.c @@ -0,0 +1,555 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-log.c: functions for log output + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-log.h" +#include "html.h" +#include "ui-shared.h" +#include "strvec.h" + +static int files, add_lines, rem_lines, lines_counted; + +/* + * The list of available column colors in the commit graph. + */ +static const char *column_colors_html[] = { + "<span class='column1'>", + "<span class='column2'>", + "<span class='column3'>", + "<span class='column4'>", + "<span class='column5'>", + "<span class='column6'>", + "</span>", +}; + +#define COLUMN_COLORS_HTML_MAX (ARRAY_SIZE(column_colors_html) - 1) + +static void count_lines(char *line, int size) +{ + if (size <= 0) + return; + + if (line[0] == '+') + add_lines++; + + else if (line[0] == '-') + rem_lines++; +} + +static void inspect_files(struct diff_filepair *pair) +{ + unsigned long old_size = 0; + unsigned long new_size = 0; + int binary = 0; + + files++; + if (ctx.repo->enable_log_linecount) + cgit_diff_files(&pair->one->oid, &pair->two->oid, &old_size, + &new_size, &binary, 0, ctx.qry.ignorews, + count_lines); +} + +void show_commit_decorations(struct commit *commit) +{ + const struct name_decoration *deco; + static char buf[1024]; + + buf[sizeof(buf) - 1] = 0; + deco = get_name_decoration(&commit->object); + if (!deco) + return; + html("<span class='decoration'>"); + while (deco) { + struct object_id oid_tag, peeled; + int is_annotated = 0; + + strlcpy(buf, prettify_refname(deco->name), sizeof(buf)); + switch(deco->type) { + case DECORATION_NONE: + /* If the git-core doesn't recognize it, + * don't display anything. */ + break; + case DECORATION_REF_LOCAL: + cgit_log_link(buf, NULL, "branch-deco", buf, NULL, + ctx.qry.vpath, 0, NULL, NULL, + ctx.qry.showmsg, 0); + break; + case DECORATION_REF_TAG: + if (!refs_read_ref(get_main_ref_store(the_repository), deco->name, &oid_tag) && + !peel_object(the_repository, &oid_tag, &peeled, PEEL_OBJECT_VERIFY_TAGGED_OBJECT_TYPE)) + is_annotated = !oideq(&oid_tag, &peeled); + cgit_tag_link(buf, NULL, is_annotated ? "tag-annotated-deco" : "tag-deco", buf); + break; + case DECORATION_REF_REMOTE: + if (!ctx.repo->enable_remote_branches) + break; + cgit_log_link(buf, NULL, "remote-deco", NULL, + oid_to_hex(&commit->object.oid), + ctx.qry.vpath, 0, NULL, NULL, + ctx.qry.showmsg, 0); + break; + default: + cgit_commit_link(buf, NULL, "deco", ctx.qry.head, + oid_to_hex(&commit->object.oid), + ctx.qry.vpath); + break; + } + deco = deco->next; + } + html("</span>"); +} + +static void handle_rename(struct diff_filepair *pair) +{ + /* + * After we have seen a rename, we generate links to the previous + * name of the file so that commit & diff views get fed the path + * that is correct for the commit they are showing, avoiding the + * need to walk the entire history leading back to every commit we + * show in order detect renames. + */ + if (0 != strcmp(ctx.qry.vpath, pair->two->path)) { + free(ctx.qry.vpath); + ctx.qry.vpath = xstrdup(pair->two->path); + } + inspect_files(pair); +} + +static int show_commit(struct commit *commit, struct rev_info *revs) +{ + struct commit_list *parents = commit->parents; + struct commit *parent; + int found = 0, saved_fmt; + struct diff_flags saved_flags = revs->diffopt.flags; + + /* Always show if we're not in "follow" mode with a single file. */ + if (!ctx.qry.follow) + return 1; + + /* + * In "follow" mode, we don't show merges. This is consistent with + * "git log --follow -- <file>". + */ + if (parents && parents->next) + return 0; + + /* + * If this is the root commit, do what rev_info tells us. + */ + if (!parents) + return revs->show_root_diff; + + /* When we get here we have precisely one parent. */ + parent = parents->item; + /* If we can't parse the commit, let print_commit() report an error. */ + if (repo_parse_commit(the_repository, parent)) + return 1; + + files = 0; + add_lines = 0; + rem_lines = 0; + + revs->diffopt.flags.recursive = 1; + diff_tree_oid(get_commit_tree_oid(parent), + get_commit_tree_oid(commit), + "", &revs->diffopt); + diffcore_std(&revs->diffopt); + + found = !diff_queue_is_empty(&revs->diffopt); + saved_fmt = revs->diffopt.output_format; + revs->diffopt.output_format = DIFF_FORMAT_CALLBACK; + revs->diffopt.format_callback = cgit_diff_tree_cb; + revs->diffopt.format_callback_data = handle_rename; + revs->diffopt.no_free = 1; + diff_flush(&revs->diffopt); + revs->diffopt.output_format = saved_fmt; + revs->diffopt.flags = saved_flags; + + lines_counted = 1; + return found; +} + +static void print_commit(struct commit *commit, struct rev_info *revs) +{ + struct commitinfo *info; + int columns = revs->graph ? 4 : 3; + struct strbuf graphbuf = STRBUF_INIT; + struct strbuf msgbuf = STRBUF_INIT; + + if (ctx.repo->enable_log_filecount) + columns++; + if (ctx.repo->enable_log_linecount) + columns++; + + if (revs->graph) { + /* Advance graph until current commit */ + while (!graph_next_line(revs->graph, &graphbuf)) { + /* Print graph segment in otherwise empty table row */ + html("<tr class='nohover'><td class='commitgraph'>"); + html(graphbuf.buf); + htmlf("</td><td colspan='%d' /></tr>\n", columns); + strbuf_setlen(&graphbuf, 0); + } + /* Current commit's graph segment is now ready in graphbuf */ + } + + info = cgit_parse_commit(commit); + htmlf("<tr%s>", ctx.qry.showmsg ? " class='logheader'" : ""); + + if (revs->graph) { + /* Print graph segment for current commit */ + html("<td class='commitgraph'>"); + html(graphbuf.buf); + html("</td>"); + strbuf_setlen(&graphbuf, 0); + } + else { + html("<td>"); + cgit_print_age(info->committer_date, info->committer_tz, TM_WEEK * 2); + html("</td>"); + } + + htmlf("<td%s>", ctx.qry.showmsg ? " class='logsubject'" : ""); + if (ctx.qry.showmsg) { + /* line-wrap long commit subjects instead of truncating them */ + size_t subject_len = strlen(info->subject); + + if (subject_len > ctx.cfg.max_msg_len && + ctx.cfg.max_msg_len >= 15) { + /* symbol for signaling line-wrap (in PAGE_ENCODING) */ + const char wrap_symbol[] = { ' ', 0xE2, 0x86, 0xB5, 0 }; + int i = ctx.cfg.max_msg_len - strlen(wrap_symbol); + + /* Rewind i to preceding space character */ + while (i > 0 && !isspace(info->subject[i])) + --i; + if (!i) /* Oops, zero spaces. Reset i */ + i = ctx.cfg.max_msg_len - strlen(wrap_symbol); + + /* add remainder starting at i to msgbuf */ + strbuf_add(&msgbuf, info->subject + i, subject_len - i); + strbuf_trim(&msgbuf); + strbuf_add(&msgbuf, "\n\n", 2); + + /* Place wrap_symbol at position i in info->subject */ + strlcpy(info->subject + i, wrap_symbol, subject_len - i + 1); + } + } + cgit_commit_link(info->subject, NULL, NULL, ctx.qry.head, + oid_to_hex(&commit->object.oid), ctx.qry.vpath); + show_commit_decorations(commit); + html("</td><td>"); + html_txt(info->author); + + if (revs->graph) { + html("</td><td>"); + cgit_print_age(info->committer_date, info->committer_tz, TM_WEEK * 2); + } + + if (!lines_counted && (ctx.repo->enable_log_filecount || + ctx.repo->enable_log_linecount)) { + files = 0; + add_lines = 0; + rem_lines = 0; + cgit_diff_commit(commit, inspect_files, ctx.qry.vpath); + } + + if (ctx.repo->enable_log_filecount) + htmlf("</td><td>%d", files); + if (ctx.repo->enable_log_linecount) + htmlf("</td><td><span class='deletions'>-%d</span>/" + "<span class='insertions'>+%d</span>", rem_lines, add_lines); + + html("</td></tr>\n"); + + if ((revs->graph && !graph_is_commit_finished(revs->graph)) + || ctx.qry.showmsg) { /* Print a second table row */ + html("<tr class='nohover-highlight'>"); + + if (ctx.qry.showmsg) { + /* Concatenate commit message + notes in msgbuf */ + if (info->msg && *(info->msg)) { + strbuf_addstr(&msgbuf, info->msg); + strbuf_addch(&msgbuf, '\n'); + } + format_display_notes(&commit->object.oid, + &msgbuf, PAGE_ENCODING, 0); + strbuf_addch(&msgbuf, '\n'); + strbuf_ltrim(&msgbuf); + } + + if (revs->graph) { + int lines = 0; + + /* Calculate graph padding */ + if (ctx.qry.showmsg) { + /* Count #lines in commit message + notes */ + const char *p = msgbuf.buf; + lines = 1; + while ((p = strchr(p, '\n'))) { + p++; + lines++; + } + } + + /* Print graph padding */ + html("<td class='commitgraph'>"); + while (lines > 0 || !graph_is_commit_finished(revs->graph)) { + if (graphbuf.len) + html("\n"); + strbuf_setlen(&graphbuf, 0); + graph_next_line(revs->graph, &graphbuf); + html(graphbuf.buf); + lines--; + } + html("</td>\n"); + } + else + html("<td/>"); /* Empty 'Age' column */ + + /* Print msgbuf into remainder of table row */ + htmlf("<td colspan='%d'%s>\n", columns - (revs->graph ? 1 : 0), + ctx.qry.showmsg ? " class='logmsg'" : ""); + html_txt(msgbuf.buf); + html("</td></tr>\n"); + } + + strbuf_release(&msgbuf); + strbuf_release(&graphbuf); + cgit_free_commitinfo(info); +} + +static const char *disambiguate_ref(const char *ref, int *must_free_result) +{ + struct object_id oid; + struct strbuf longref = STRBUF_INIT; + + strbuf_addf(&longref, "refs/heads/%s", ref); + if (repo_get_oid(the_repository, longref.buf, &oid) == 0) { + *must_free_result = 1; + return strbuf_detach(&longref, NULL); + } + + *must_free_result = 0; + strbuf_release(&longref); + return ref; +} + +static char *next_token(char **src) +{ + char *result; + + if (!src || !*src) + return NULL; + while (isspace(**src)) + (*src)++; + if (!**src) + return NULL; + result = *src; + while (**src) { + if (isspace(**src)) { + **src = '\0'; + (*src)++; + break; + } + (*src)++; + } + return result; +} + +void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, char *pattern, + const char *path, int pager, int commit_graph, int commit_sort) +{ + struct rev_info rev; + struct commit *commit; + struct strvec rev_argv = STRVEC_INIT; + int i, columns = commit_graph ? 4 : 3; + int must_free_tip = 0; + + /* rev_argv.argv[0] will be ignored by setup_revisions */ + strvec_push(&rev_argv, "log_rev_setup"); + + if (!tip) + tip = ctx.qry.head; + tip = disambiguate_ref(tip, &must_free_tip); + strvec_push(&rev_argv, tip); + + if (grep && pattern && *pattern) { + pattern = xstrdup(pattern); + if (!strcmp(grep, "grep") || !strcmp(grep, "author") || + !strcmp(grep, "committer")) { + strvec_pushf(&rev_argv, "--%s=%s", grep, pattern); + } else if (!strcmp(grep, "range")) { + char *arg; + /* Split the pattern at whitespace and add each token + * as a revision expression. Do not accept other + * rev-list options. Also, replace the previously + * pushed tip (it's no longer relevant). + */ + strvec_pop(&rev_argv); + while ((arg = next_token(&pattern))) { + if (*arg == '-') { + fprintf(stderr, "Bad range expr: %s\n", + arg); + break; + } + strvec_push(&rev_argv, arg); + } + } + } + + if (!path || !ctx.repo->enable_follow_links) { + /* + * If we don't have a path, "follow" is a no-op so make sure + * the variable is set to false to avoid needing to check + * both this and whether we have a path everywhere. + */ + ctx.qry.follow = 0; + } + + if (commit_graph && !ctx.qry.follow) { + strvec_push(&rev_argv, "--graph"); + strvec_push(&rev_argv, "--color"); + graph_set_column_colors(column_colors_html, + COLUMN_COLORS_HTML_MAX); + } + + if (commit_sort == 1) + strvec_push(&rev_argv, "--date-order"); + else if (commit_sort == 2) + strvec_push(&rev_argv, "--topo-order"); + + if (path && ctx.qry.follow) + strvec_push(&rev_argv, "--follow"); + strvec_push(&rev_argv, "--"); + if (path) + strvec_push(&rev_argv, path); + + repo_init_revisions(the_repository, &rev, NULL); + rev.abbrev = DEFAULT_ABBREV; + rev.commit_format = CMIT_FMT_DEFAULT; + rev.verbose_header = 1; + rev.show_root_diff = 0; + rev.ignore_missing = 1; + rev.simplify_history = 1; + setup_revisions(rev_argv.nr, rev_argv.v, &rev, NULL); + load_ref_decorations(NULL, DECORATE_FULL_REFS); + rev.show_decorations = 1; + rev.grep_filter.ignore_case = 1; + + rev.diffopt.detect_rename = 1; + rev.diffopt.rename_limit = ctx.cfg.renamelimit; + if (ctx.qry.ignorews) + DIFF_XDL_SET(&rev.diffopt, IGNORE_WHITESPACE); + + compile_grep_patterns(&rev.grep_filter); + prepare_revision_walk(&rev); + + if (pager) { + cgit_print_layout_start(); + html("<table class='list nowrap'>"); + } + + html("<tr class='nohover'>"); + if (commit_graph) + html("<th></th>"); + else + html("<th class='left'>Age</th>"); + html("<th class='left'>Commit message"); + if (pager) { + html(" ("); + cgit_log_link(ctx.qry.showmsg ? "Collapse" : "Expand", NULL, + NULL, ctx.qry.head, ctx.qry.oid, + ctx.qry.vpath, ctx.qry.ofs, ctx.qry.grep, + ctx.qry.search, ctx.qry.showmsg ? 0 : 1, + ctx.qry.follow); + html(")"); + } + html("</th><th class='left'>Author</th>"); + if (rev.graph) + html("<th class='left'>Age</th>"); + if (ctx.repo->enable_log_filecount) { + html("<th class='left'>Files</th>"); + columns++; + } + if (ctx.repo->enable_log_linecount) { + html("<th class='left'>Lines</th>"); + columns++; + } + html("</tr>\n"); + + if (ofs<0) + ofs = 0; + + for (i = 0; i < ofs && (commit = get_revision(&rev)) != NULL; /* nop */) { + if (show_commit(commit, &rev)) + i++; + release_commit_memory(the_repository->parsed_objects, commit); + commit->parents = NULL; + } + + for (i = 0; i < cnt && (commit = get_revision(&rev)) != NULL; /* nop */) { + /* + * In "follow" mode, we must count the files and lines the + * first time we invoke diff on a given commit, and we need + * to do that to see if the commit touches the path we care + * about, so we do it in show_commit. Hence we must clear + * lines_counted here. + * + * This has the side effect of avoiding running diff twice + * when we are both following renames and showing file + * and/or line counts. + */ + lines_counted = 0; + if (show_commit(commit, &rev)) { + i++; + print_commit(commit, &rev); + } + release_commit_memory(the_repository->parsed_objects, commit); + commit->parents = NULL; + } + if (pager) { + html("</table><ul class='pager'>"); + if (ofs > 0) { + html("<li>"); + cgit_log_link("[prev]", NULL, NULL, ctx.qry.head, + ctx.qry.oid, ctx.qry.vpath, + ofs - cnt, ctx.qry.grep, + ctx.qry.search, ctx.qry.showmsg, + ctx.qry.follow); + html("</li>"); + } + if ((commit = get_revision(&rev)) != NULL) { + html("<li>"); + cgit_log_link("[next]", NULL, NULL, ctx.qry.head, + ctx.qry.oid, ctx.qry.vpath, + ofs + cnt, ctx.qry.grep, + ctx.qry.search, ctx.qry.showmsg, + ctx.qry.follow); + html("</li>"); + } + html("</ul>"); + cgit_print_layout_end(); + } else if ((commit = get_revision(&rev)) != NULL) { + htmlf("<tr class='nohover'><td colspan='%d'>", columns); + cgit_log_link("[...]", NULL, NULL, ctx.qry.head, NULL, + ctx.qry.vpath, 0, NULL, NULL, ctx.qry.showmsg, + ctx.qry.follow); + html("</td></tr>\n"); + } + + /* If we allocated tip then it is safe to cast away const. */ + if (must_free_tip) + free((char*) tip); +} diff --git a/cgit/ui-log.h b/cgit/ui-log.h new file mode 100644 --- /dev/null +++ b/cgit/ui-log.h @@ -0,0 +1,13 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_LOG_H +#define UI_LOG_H + +extern void cgit_print_log(const char *tip, int ofs, int cnt, char *grep, + char *pattern, const char *path, int pager, + int commit_graph, int commit_sort); +extern void show_commit_decorations(struct commit *commit); + +#endif /* UI_LOG_H */ diff --git a/cgit/ui-patch.c b/cgit/ui-patch.c new file mode 100644 --- /dev/null +++ b/cgit/ui-patch.c @@ -0,0 +1,104 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-patch.c: generate patch view + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-patch.h" +#include "html.h" +#include "ui-shared.h" + +/* two commit hashes with two dots in between and termination */ +#define REV_RANGE_LEN 2 * GIT_MAX_HEXSZ + 3 + +void cgit_print_patch(const char *new_rev, const char *old_rev, + const char *prefix) +{ + struct rev_info rev; + struct commit *commit; + struct object_id new_rev_oid, old_rev_oid; + char rev_range[REV_RANGE_LEN]; + const char *rev_argv[] = { NULL, "--reverse", "--format=email", rev_range, "--", prefix, NULL }; + int rev_argc = ARRAY_SIZE(rev_argv) - 1; + char *patchname; + + if (!prefix) + rev_argc--; + + if (!new_rev) + new_rev = ctx.qry.head; + + if (repo_get_oid(the_repository, new_rev, &new_rev_oid)) { + cgit_print_error_page(404, "Not found", + "Bad object id: %s", new_rev); + return; + } + commit = lookup_commit_reference(the_repository, &new_rev_oid); + if (!commit) { + cgit_print_error_page(404, "Not found", + "Bad commit reference: %s", new_rev); + return; + } + + if (old_rev) { + if (repo_get_oid(the_repository, old_rev, &old_rev_oid)) { + cgit_print_error_page(404, "Not found", + "Bad object id: %s", old_rev); + return; + } + if (!lookup_commit_reference(the_repository, &old_rev_oid)) { + cgit_print_error_page(404, "Not found", + "Bad commit reference: %s", old_rev); + return; + } + } else if (commit->parents && commit->parents->item) { + oidcpy(&old_rev_oid, &commit->parents->item->object.oid); + } else { + oidclr(&old_rev_oid, the_repository->hash_algo); + } + + if (is_null_oid(&old_rev_oid)) { + memcpy(rev_range, oid_to_hex(&new_rev_oid), the_hash_algo->hexsz + 1); + } else { + xsnprintf(rev_range, REV_RANGE_LEN, "%s..%s", oid_to_hex(&old_rev_oid), + oid_to_hex(&new_rev_oid)); + } + + patchname = fmt("%s.patch", rev_range); + ctx.page.mimetype = "text/plain"; + ctx.page.filename = patchname; + cgit_print_http_headers(); + + if (ctx.cfg.noplainemail) { + rev_argv[2] = "--format=format:From %H Mon Sep 17 00:00:00 " + "2001%nFrom: %an%nDate: %aD%n%w(78,0,1)Subject: " + "%s%n%n%w(0)%b"; + } + + repo_init_revisions(the_repository, &rev, NULL); + rev.abbrev = DEFAULT_ABBREV; + rev.verbose_header = 1; + rev.diff = 1; + rev.show_root_diff = 1; + rev.max_parents = 1; + rev.diffopt.output_format |= DIFF_FORMAT_DIFFSTAT | + DIFF_FORMAT_PATCH | DIFF_FORMAT_SUMMARY; + if (prefix) + rev.diffopt.stat_sep = fmt("(limited to '%s')\n\n", prefix); + setup_revisions(rev_argc, rev_argv, &rev, NULL); + prepare_revision_walk(&rev); + + while ((commit = get_revision(&rev)) != NULL) { + log_tree_commit(&rev, commit); + printf("-- \ncgit %s\n\n", cgit_version); + } +} diff --git a/cgit/ui-patch.h b/cgit/ui-patch.h new file mode 100644 --- /dev/null +++ b/cgit/ui-patch.h @@ -0,0 +1,11 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_PATCH_H +#define UI_PATCH_H + +extern void cgit_print_patch(const char *new_rev, const char *old_rev, + const char *prefix); + +#endif /* UI_PATCH_H */ diff --git a/cgit/ui-plain.c b/cgit/ui-plain.c new file mode 100644 --- /dev/null +++ b/cgit/ui-plain.c @@ -0,0 +1,213 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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 <cgit@lists.zx2c4.com> + * + * 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("<html><head><title>%s", slash); + html_txt(fullpath); + htmlf("</title></head>\n<body>\n<h2>%s", slash); + html_txt(fullpath); + html("</h2>\n<ul>\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("<li>"); + cgit_plain_link("../", NULL, NULL, ctx.qry.head, ctx.qry.oid, + fullpath); + html("</li>\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(" <li>"); + 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("</li>\n"); + free(fullpath); +} + +static void print_dir_tail(void) +{ + html(" </ul>\n</body></html>\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 new file mode 100644 --- /dev/null +++ b/cgit/ui-plain.h @@ -0,0 +1,10 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-refs.c @@ -0,0 +1,221 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-refs.c: browse symbolic refs + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * 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("<tr><td>"); + cgit_log_link(name, NULL, NULL, name, NULL, NULL, 0, NULL, NULL, + ctx.qry.showmsg, 0); + html("</td><td>"); + + if (ref->object->type == OBJ_COMMIT) { + cgit_commit_link(info->subject, NULL, NULL, name, NULL, NULL); + html("</td><td>"); + html_txt(info->author); + html("</td><td colspan='2'>"); + cgit_print_age(info->committer_date, info->committer_tz, -1); + } else { + html("</td><td></td><td>"); + cgit_object_link(ref->object); + } + html("</td></tr>\n"); + return 0; +} + +static void print_tag_header(void) +{ + html("<tr class='nohover'><th class='left'>Tag</th>" + "<th class='left'>Download</th>" + "<th class='left'>Author</th>" + "<th class='left' colspan='2'>Age</th></tr>\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("<tr><td>"); + cgit_tag_link(name, NULL, NULL, name); + html("</td><td>"); + if (ctx.repo->snapshots && (obj->type == OBJ_COMMIT)) + cgit_print_snapshot_links(ctx.repo, name, " "); + else + cgit_object_link(obj); + html("</td><td>"); + if (info) { + if (info->tagger) + html_txt(info->tagger); + } else if (ref->object->type == OBJ_COMMIT) { + html_txt(ref->commit->author); + } + html("</td><td colspan='2'>"); + 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("</td></tr>\n"); + + return 0; +} + +static void print_refs_link(const char *path) +{ + html("<tr class='nohover'><td colspan='5'>"); + cgit_refs_link("[...]", NULL, NULL, ctx.qry.head, NULL, path); + html("</td></tr>"); +} + +void cgit_print_branches(int maxcount) +{ + struct reflist list; + int i; + + html("<tr class='nohover'><th class='left'>Branch</th>" + "<th class='left'>Commit message</th>" + "<th class='left'>Author</th>" + "<th class='left' colspan='2'>Age</th></tr>\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("<table class='list nowrap'>"); + + 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("<tr class='nohover'><td colspan='5'> </td></tr>"); + cgit_print_tags(0); + } + html("</table>"); + cgit_print_layout_end(); +} diff --git a/cgit/ui-refs.h b/cgit/ui-refs.h new file mode 100644 --- /dev/null +++ b/cgit/ui-refs.h @@ -0,0 +1,12 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-repolist.c @@ -0,0 +1,491 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-repolist.c: functions for generating the repolist page + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * 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 stat s; + struct cgit_repo *r = (struct cgit_repo *)repo; + + if (repo->mtime != -1) { + *mtime = repo->mtime; + return 1; + } + 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/refs/heads/%s", repo->path, + repo->defbranch ? repo->defbranch : "master"); + if (stat(path.buf, &s) == 0) { + *mtime = s.st_mtime; + r->mtime = *mtime; + goto end; + } + + strbuf_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(&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_in_url(struct cgit_repo *repo) +{ + if (!ctx.qry.url) + return 1; + if (repo->url && starts_with(repo->url, ctx.qry.url)) + return 1; + return 0; +} + +static int is_visible(struct cgit_repo *repo) +{ + if (repo->hide || repo->ignore) + return 0; + if (!(is_match(repo) && is_in_url(repo))) + return 0; + return 1; +} + +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) || strchr("!$()*,./:;@-[]_~+&", *p)) + strbuf_addch(url, *p); + else + strbuf_addf(url, "%%%02x", *p); + } +} + +static char *sort_url(const char *sort) +{ + char *currenturl = cgit_currenturl(); + struct strbuf url = STRBUF_INIT; + + strbuf_addf(&url, "%s?s=%s", currenturl, sort); + free(currenturl); + if (ctx.qry.search) { + strbuf_addstr(&url, "&q="); + url_arg(&url, ctx.qry.search); + } + return strbuf_detach(&url, NULL); +} + +static char *pager_url(const char *search, const char *sort, int ofs) +{ + char *currenturl = cgit_currenturl(); + struct strbuf url = STRBUF_INIT; + const char *delimiter = "?"; + + strbuf_addstr(&url, currenturl); + free(currenturl); + if (search) { + strbuf_addf(&url, "?q=%s", search); + delimiter = "&"; + } + if (sort) { + strbuf_addf(&url, "%ss=%s", delimiter, sort); + delimiter = "&"; + } + if (ofs) + strbuf_addf(&url, "%sofs=%d", delimiter, ofs); + return strbuf_detach(&url, NULL); +} + +static char *repo_url(const struct cgit_repo *repo, const char *page, + const char *query) +{ + struct strbuf url = STRBUF_INIT; + const char *delimiter = "?"; + + if (ctx.cfg.virtual_root) { + url_path(&url, ctx.cfg.virtual_root); + url_path(&url, repo->url); + if (repo->url[strlen(repo->url) - 1] != '/') + strbuf_addch(&url, '/'); + if (page) { + url_path(&url, page); + strbuf_addch(&url, '/'); + } + } else { + url_path(&url, ctx.cfg.script_name); + strbuf_addstr(&url, "?url="); + url_arg(&url, repo->url); + if (repo->url[strlen(repo->url) - 1] != '/') + strbuf_addch(&url, '/'); + if (page) { + url_arg(&url, page); + strbuf_addch(&url, '/'); + } + delimiter = "&"; + } + if (query) + strbuf_addf(&url, "%s%s", delimiter, query); + return strbuf_detach(&url, NULL); +} + +static void json_description(const char *description) +{ + size_t len = description ? strlen(description) : 0; + size_t shown = len; + + if (shown > ctx.cfg.max_repodesc_len) + shown = ctx.cfg.max_repodesc_len; + char *text = xmemdupz(description ? description : "", shown); + json("{"); + json_key("text"); + json_value(text); + free(text); + json(","); + json_key("truncated"); + json_bool(len > shown); + json("}"); +} + +static void json_age(struct cgit_repo *repo) +{ + time_t t, now, seconds; + const char *unit; + double amount; + + if (!get_repo_modtime(repo, &t)) { + json("null"); + return; + } + time(&now); + seconds = now > t ? now - t : 0; + if (seconds < TM_HOUR * 2) { unit = "minutes"; amount = seconds * 1.0 / TM_MIN; } + else if (seconds < TM_DAY * 2) { unit = "hours"; amount = seconds * 1.0 / TM_HOUR; } + else if (seconds < TM_WEEK * 2) { unit = "days"; amount = seconds * 1.0 / TM_DAY; } + else if (seconds < TM_MONTH * 2) { unit = "weeks"; amount = seconds * 1.0 / TM_WEEK; } + else if (seconds < TM_YEAR * 2) { unit = "months"; amount = seconds * 1.0 / TM_MONTH; } + else { unit = "years"; amount = seconds * 1.0 / TM_YEAR; } + json("{"); + json_key("timestamp"); json_int(t); json(","); + json_key("title"); json_value(show_date(t, 0, cgit_date_mode(DATE_ISO8601))); json(","); + json_key("unit"); json_value(unit); json(","); + json_key("amount"); jsonf("%.0f", amount); + json("}"); +} + +static void json_shell(void) +{ + json_key("shell"); json("{"); + json_key("embedded"); json_bool(ctx.cfg.embedded); json(","); + json_key("robots"); json_value(ctx.cfg.robots); json(","); + json_key("css"); json_string_list(&ctx.cfg.css); json(","); + json_key("js"); json_string_list(&ctx.cfg.js); json(","); + json_key("favicon"); json_value(ctx.cfg.favicon); json(","); + json_key("head_include"); json_include(ctx.cfg.head_include); json(","); + json_key("header"); json_include(ctx.cfg.header); json(","); + json_key("footer_configured"); json_bool(ctx.cfg.footer != NULL); json(","); + json_key("footer"); json_include(ctx.cfg.footer); json(","); + json_key("logo"); json_value(ctx.cfg.logo); json(","); + json_key("logo_link"); json_value(ctx.cfg.logo_link); json(","); + json_key("cgit_version"); json_value(cgit_version); json(","); + json_key("git_version"); json_value(git_version_string); json(","); + json_key("generated_at"); json_value(show_date(time(NULL), 0, cgit_date_mode(DATE_ISO8601))); + json("}"); +} + +void cgit_print_repolist(void) +{ + int i, 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"); { struct strbuf about = STRBUF_INIT; strbuf_addf(&about, "%s?p=about", cgit_rooturl()); json_value(about.buf); strbuf_release(&about); } json(","); + json_key("noheader"); json_bool(ctx.cfg.noheader); json(","); + json_key("search"); json_value(ctx.qry.search); json(","); + json_key("current_url"); { char *url = cgit_currenturl(); json_value(url); free(url); } json(","); + json_key("root_readme"); json_bool(ctx.cfg.root_readme != NULL); json(","); + json_key("owner_enabled"); json_bool(ctx.cfg.enable_index_owner); json(","); + json_key("links_enabled"); json_bool(ctx.cfg.enable_index_links); json(","); + json_key("section_grouping"); json_bool(!sorted); json(","); + json_shell(); json(","); + json_key("sort_urls"); json("{"); + { const char *names[] = {"name", "desc", "owner", "idle"}; for (i = 0; i < 4; i++) { char *url = sort_url(names[i]); if (i) json(","); json_key(names[i]); json_value(url); free(url); } } + json("},"); + json_key("rows"); json("["); + for (i = 0; i < cgit_repolist.count; i++) { + struct cgit_repo *repo = &cgit_repolist.repos[i]; + 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(","); + json_key("log_url"); url = repo_url(repo, "log", ctx.qry.showmsg ? "showmsg=1" : NULL); json_value(url); free(url); json(","); + json_key("tree_url"); url = repo_url(repo, "tree", NULL); json_value(url); free(url); + json("}"); first = 0; + } + 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-repolist.h @@ -0,0 +1,11 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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.c b/cgit/ui-shared.c new file mode 100644 --- /dev/null +++ b/cgit/ui-shared.c @@ -0,0 +1,1250 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-FileCopyrightText: 2026 Nikolay Govorov + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-shared.c: common web output functions + * + * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-shared.h" +#include "cmd.h" +#include "html.h" +#include "version.h" + +static const char cgit_doctype[] = +"<!DOCTYPE html>\n"; + +static char *http_date(time_t t) +{ + static char day[][4] = + {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + static char month[][4] = + {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + struct tm tm; + gmtime_r(&t, &tm); + return fmt("%s, %02d %s %04d %02d:%02d:%02d GMT", day[tm.tm_wday], + tm.tm_mday, month[tm.tm_mon], 1900 + tm.tm_year, + tm.tm_hour, tm.tm_min, tm.tm_sec); +} + +void cgit_print_error(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + cgit_vprint_error(fmt, ap); + va_end(ap); +} + +void cgit_vprint_error(const char *fmt, va_list ap) +{ + va_list cp; + html("<div class='error'>"); + va_copy(cp, ap); + html_vtxtf(fmt, cp); + va_end(cp); + html("</div>\n"); +} + +const char *cgit_httpscheme(void) +{ + if (ctx.env.https && !strcmp(ctx.env.https, "on")) + return "https://"; + else + return "http://"; +} + +char *cgit_hosturl(void) +{ + if (ctx.env.http_host) + return xstrdup(ctx.env.http_host); + if (!ctx.env.server_name) + return NULL; + if (!ctx.env.server_port || atoi(ctx.env.server_port) == 80) + return xstrdup(ctx.env.server_name); + return fmtalloc("%s:%s", ctx.env.server_name, ctx.env.server_port); +} + +char *cgit_currenturl(void) +{ + const char *root = cgit_rooturl(); + + if (!ctx.qry.url) + return xstrdup(root); + if (root[0] && root[strlen(root) - 1] == '/') + return fmtalloc("%s%s", root, ctx.qry.url); + return fmtalloc("%s/%s", root, ctx.qry.url); +} + +char *cgit_currentfullurl(void) +{ + const char *root = cgit_rooturl(); + const char *orig_query = ctx.env.query_string ? ctx.env.query_string : ""; + size_t len = strlen(orig_query); + char *query = xmalloc(len + 2), *start_url, *ret; + + /* Remove all url=... parts from query string */ + memcpy(query + 1, orig_query, len + 1); + query[0] = '?'; + start_url = query; + while ((start_url = strstr(start_url, "url=")) != NULL) { + if (start_url[-1] == '?' || start_url[-1] == '&') { + const char *end_url = strchr(start_url, '&'); + if (end_url) + memmove(start_url, end_url + 1, strlen(end_url)); + else + start_url[0] = '\0'; + } else + ++start_url; + } + if (!query[1]) + query[0] = '\0'; + + if (!ctx.qry.url) + ret = fmtalloc("%s%s", root, query); + else if (root[0] && root[strlen(root) - 1] == '/') + ret = fmtalloc("%s%s%s", root, ctx.qry.url, query); + else + ret = fmtalloc("%s/%s%s", root, ctx.qry.url, query); + free(query); + return ret; +} + +const char *cgit_rooturl(void) +{ + if (ctx.cfg.virtual_root) + return ctx.cfg.virtual_root; + else + return ctx.cfg.script_name; +} + +char *cgit_repourl(const char *reponame) +{ + if (ctx.cfg.virtual_root) + return fmtalloc("%s%s/", ctx.cfg.virtual_root, reponame); + else + return fmtalloc("?r=%s", reponame); +} + +char *cgit_fileurl(const char *reponame, const char *pagename, + const char *filename, const char *query) +{ + struct strbuf sb = STRBUF_INIT; + char *delim; + + if (ctx.cfg.virtual_root) { + strbuf_addf(&sb, "%s%s/%s/%s", ctx.cfg.virtual_root, reponame, + pagename, (filename ? filename:"")); + delim = "?"; + } else { + strbuf_addf(&sb, "?url=%s/%s/%s", reponame, pagename, + (filename ? filename : "")); + delim = "&"; + } + if (query) + strbuf_addf(&sb, "%s%s", delim, query); + return strbuf_detach(&sb, NULL); +} + +char *cgit_pageurl(const char *reponame, const char *pagename, + const char *query) +{ + return cgit_fileurl(reponame, pagename, NULL, query); +} + +const char *cgit_repobasename(const char *reponame) +{ + /* I assume we don't need to store more than one repo basename */ + static char rvbuf[1024]; + int p; + const char *rv; + size_t len; + + len = strlcpy(rvbuf, reponame, sizeof(rvbuf)); + if (len >= sizeof(rvbuf)) + die("cgit_repobasename: truncated repository name '%s'", reponame); + p = len - 1; + /* strip trailing slashes */ + while (p && rvbuf[p] == '/') + rvbuf[p--] = '\0'; + /* strip trailing .git */ + if (p >= 3 && starts_with(&rvbuf[p-3], ".git")) { + p -= 3; + rvbuf[p--] = '\0'; + } + /* strip more trailing slashes if any */ + while (p && rvbuf[p] == '/') + rvbuf[p--] = '\0'; + /* find last slash in the remaining string */ + rv = strrchr(rvbuf, '/'); + if (rv) + return ++rv; + return rvbuf; +} + +const char *cgit_snapshot_prefix(const struct cgit_repo *repo) +{ + if (repo->snapshot_prefix) + return repo->snapshot_prefix; + + return cgit_repobasename(repo->url); +} + +static void site_url(const char *page, const char *search, const char *sort, int ofs, int always_root) +{ + char *delim = "?"; + + if (always_root || page) + html_attr(cgit_rooturl()); + else { + char *currenturl = cgit_currenturl(); + html_attr(currenturl); + free(currenturl); + } + + if (page) { + htmlf("?p=%s", page); + delim = "&"; + } + if (search) { + html(delim); + html("q="); + html_attr(search); + delim = "&"; + } + if (sort) { + html(delim); + html("s="); + html_attr(sort); + delim = "&"; + } + if (ofs) { + html(delim); + htmlf("ofs=%d", ofs); + } +} + +static void site_link(const char *page, const char *name, const char *title, + const char *class, const char *search, const char *sort, int ofs, int always_root) +{ + html("<a"); + if (title) { + html(" title='"); + html_attr(title); + html("'"); + } + if (class) { + html(" class='"); + html_attr(class); + html("'"); + } + html(" href='"); + site_url(page, search, sort, ofs, always_root); + html("'>"); + html_txt(name); + html("</a>"); +} + +void cgit_index_link(const char *name, const char *title, const char *class, + const char *pattern, const char *sort, int ofs, int always_root) +{ + site_link(NULL, name, title, class, pattern, sort, ofs, always_root); +} + +static char *repolink(const char *title, const char *class, const char *page, + const char *head, const char *path) +{ + char *delim = "?"; + + html("<a"); + if (title) { + html(" title='"); + html_attr(title); + html("'"); + } + if (class) { + html(" class='"); + html_attr(class); + html("'"); + } + html(" href='"); + if (ctx.cfg.virtual_root) { + html_url_path(ctx.cfg.virtual_root); + html_url_path(ctx.repo->url); + if (ctx.repo->url[strlen(ctx.repo->url) - 1] != '/') + html("/"); + if (page) { + html_url_path(page); + html("/"); + if (path) + html_url_path(path); + } + } else { + html_url_path(ctx.cfg.script_name); + html("?url="); + html_url_arg(ctx.repo->url); + if (ctx.repo->url[strlen(ctx.repo->url) - 1] != '/') + html("/"); + if (page) { + html_url_arg(page); + html("/"); + if (path) + html_url_arg(path); + } + delim = "&"; + } + if (head && ctx.repo->defbranch && strcmp(head, ctx.repo->defbranch)) { + html(delim); + html("h="); + html_url_arg(head); + delim = "&"; + } + return fmt("%s", delim); +} + +static void reporevlink(const char *page, const char *name, const char *title, + const char *class, const char *head, const char *rev, + const char *path) +{ + char *delim; + + delim = repolink(title, class, page, head, path); + if (rev && ctx.qry.head != NULL && strcmp(rev, ctx.qry.head)) { + html(delim); + html("id="); + html_url_arg(rev); + } + html("'>"); + html_txt(name); + html("</a>"); +} + +void cgit_summary_link(const char *name, const char *title, const char *class, + const char *head) +{ + reporevlink(NULL, name, title, class, head, NULL, NULL); +} + +void cgit_tag_link(const char *name, const char *title, const char *class, + const char *tag) +{ + reporevlink("tag", name, title, class, tag, NULL, NULL); +} + +void cgit_tree_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, const char *path) +{ + reporevlink("tree", name, title, class, head, rev, path); +} + +void cgit_plain_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, const char *path) +{ + reporevlink("plain", name, title, class, head, rev, path); +} + +void cgit_blame_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, const char *path) +{ + reporevlink("blame", name, title, class, head, rev, path); +} + +void cgit_log_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, const char *path, + int ofs, const char *grep, const char *pattern, int showmsg, + int follow) +{ + char *delim; + + delim = repolink(title, class, "log", head, path); + if (rev && ctx.qry.head && strcmp(rev, ctx.qry.head)) { + html(delim); + html("id="); + html_url_arg(rev); + delim = "&"; + } + if (grep && pattern) { + html(delim); + html("qt="); + html_url_arg(grep); + delim = "&"; + html(delim); + html("q="); + html_url_arg(pattern); + } + if (ofs > 0) { + html(delim); + html("ofs="); + htmlf("%d", ofs); + delim = "&"; + } + if (showmsg) { + html(delim); + html("showmsg=1"); + delim = "&"; + } + if (follow) { + html(delim); + html("follow=1"); + } + html("'>"); + html_txt(name); + html("</a>"); +} + +void cgit_commit_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, const char *path) +{ + char *delim; + + delim = repolink(title, class, "commit", head, path); + if (rev && ctx.qry.head && strcmp(rev, ctx.qry.head)) { + html(delim); + html("id="); + html_url_arg(rev); + delim = "&"; + } + if (ctx.qry.difftype) { + html(delim); + htmlf("dt=%d", ctx.qry.difftype); + delim = "&"; + } + if (ctx.qry.context > 0 && ctx.qry.context != 3) { + html(delim); + html("context="); + htmlf("%d", ctx.qry.context); + delim = "&"; + } + if (ctx.qry.ignorews) { + html(delim); + html("ignorews=1"); + delim = "&"; + } + if (ctx.qry.follow) { + html(delim); + html("follow=1"); + } + html("'>"); + if (name[0] != '\0') { + if (strlen(name) > ctx.cfg.max_msg_len && ctx.cfg.max_msg_len >= 15) { + html_ntxt(name, ctx.cfg.max_msg_len - 3); + html("..."); + } else + html_txt(name); + } else + html_txt("(no commit message)"); + html("</a>"); +} + +void cgit_refs_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, const char *path) +{ + reporevlink("refs", name, title, class, head, rev, path); +} + +void cgit_snapshot_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, + const char *archivename) +{ + reporevlink("snapshot", name, title, class, head, rev, archivename); +} + +void cgit_diff_link(const char *name, const char *title, const char *class, + const char *head, const char *new_rev, const char *old_rev, + const char *path) +{ + char *delim; + + delim = repolink(title, class, "diff", head, path); + if (new_rev && ctx.qry.head != NULL && strcmp(new_rev, ctx.qry.head)) { + html(delim); + html("id="); + html_url_arg(new_rev); + delim = "&"; + } + if (old_rev) { + html(delim); + html("id2="); + html_url_arg(old_rev); + delim = "&"; + } + if (ctx.qry.difftype) { + html(delim); + htmlf("dt=%d", ctx.qry.difftype); + delim = "&"; + } + if (ctx.qry.context > 0 && ctx.qry.context != 3) { + html(delim); + html("context="); + htmlf("%d", ctx.qry.context); + delim = "&"; + } + if (ctx.qry.ignorews) { + html(delim); + html("ignorews=1"); + delim = "&"; + } + if (ctx.qry.follow) { + html(delim); + html("follow=1"); + } + html("'>"); + html_txt(name); + html("</a>"); +} + +void cgit_patch_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, const char *path) +{ + reporevlink("patch", name, title, class, head, rev, path); +} + +void cgit_stats_link(const char *name, const char *title, const char *class, + const char *head, const char *path) +{ + reporevlink("stats", name, title, class, head, NULL, path); +} + +static void cgit_self_link(char *name, const char *title, const char *class) +{ + if (!strcmp(ctx.qry.page, "repolist")) + cgit_index_link(name, title, class, ctx.qry.search, ctx.qry.sort, + ctx.qry.ofs, 1); + else if (!strcmp(ctx.qry.page, "summary")) + cgit_summary_link(name, title, class, ctx.qry.head); + else if (!strcmp(ctx.qry.page, "tag")) + cgit_tag_link(name, title, class, ctx.qry.has_oid ? + ctx.qry.oid : ctx.qry.head); + else if (!strcmp(ctx.qry.page, "tree")) + cgit_tree_link(name, title, class, ctx.qry.head, + ctx.qry.has_oid ? ctx.qry.oid : NULL, + ctx.qry.path); + else if (!strcmp(ctx.qry.page, "plain")) + cgit_plain_link(name, title, class, ctx.qry.head, + ctx.qry.has_oid ? ctx.qry.oid : NULL, + ctx.qry.path); + else if (!strcmp(ctx.qry.page, "blame")) + cgit_blame_link(name, title, class, ctx.qry.head, + ctx.qry.has_oid ? ctx.qry.oid : NULL, + ctx.qry.path); + else if (!strcmp(ctx.qry.page, "log")) + cgit_log_link(name, title, class, ctx.qry.head, + ctx.qry.has_oid ? ctx.qry.oid : NULL, + ctx.qry.path, ctx.qry.ofs, + ctx.qry.grep, ctx.qry.search, + ctx.qry.showmsg, ctx.qry.follow); + else if (!strcmp(ctx.qry.page, "commit")) + cgit_commit_link(name, title, class, ctx.qry.head, + ctx.qry.has_oid ? ctx.qry.oid : NULL, + ctx.qry.path); + else if (!strcmp(ctx.qry.page, "patch")) + cgit_patch_link(name, title, class, ctx.qry.head, + ctx.qry.has_oid ? ctx.qry.oid : NULL, + ctx.qry.path); + else if (!strcmp(ctx.qry.page, "refs")) + cgit_refs_link(name, title, class, ctx.qry.head, + ctx.qry.has_oid ? ctx.qry.oid : NULL, + ctx.qry.path); + else if (!strcmp(ctx.qry.page, "snapshot")) + cgit_snapshot_link(name, title, class, ctx.qry.head, + ctx.qry.has_oid ? ctx.qry.oid : NULL, + ctx.qry.path); + else if (!strcmp(ctx.qry.page, "diff")) + cgit_diff_link(name, title, class, ctx.qry.head, + ctx.qry.oid, ctx.qry.oid2, + ctx.qry.path); + else if (!strcmp(ctx.qry.page, "stats")) + cgit_stats_link(name, title, class, ctx.qry.head, + ctx.qry.path); + else { + /* Don't known how to make link for this page */ + repolink(title, class, ctx.qry.page, ctx.qry.head, ctx.qry.path); + html("><!-- cgit_self_link() doesn't know how to make link for page '"); + html_txt(ctx.qry.page); + html("' -->"); + html_txt(name); + html("</a>"); + } +} + +void cgit_object_link(struct object *obj) +{ + char *page, *shortrev, *fullrev, *name; + + fullrev = oid_to_hex(&obj->oid); + shortrev = xstrdup(fullrev); + shortrev[10] = '\0'; + if (obj->type == OBJ_COMMIT) { + cgit_commit_link(fmt("commit %s...", shortrev), NULL, NULL, + ctx.qry.head, fullrev, NULL); + return; + } else if (obj->type == OBJ_TREE) + page = "tree"; + else if (obj->type == OBJ_TAG) + page = "tag"; + else + page = "blob"; + name = fmt("%s %s...", type_name(obj->type), shortrev); + reporevlink(page, name, NULL, NULL, ctx.qry.head, fullrev, NULL); +} + +static struct string_list_item *lookup_path(struct string_list *list, + const char *path) +{ + struct string_list_item *item; + + while (path && path[0]) { + if ((item = string_list_lookup(list, path))) + return item; + if (!(path = strchr(path, '/'))) + break; + path++; + } + return NULL; +} + +void cgit_submodule_link(const char *class, char *path, const char *rev) +{ + struct string_list *list; + struct string_list_item *item; + char tail, *dir; + size_t len; + + len = 0; + tail = 0; + list = &ctx.repo->submodules; + item = lookup_path(list, path); + if (!item) { + len = strlen(path); + tail = path[len - 1]; + if (tail == '/') { + path[len - 1] = 0; + item = lookup_path(list, path); + } + } + if (item || ctx.repo->module_link) { + html("<a "); + if (class) + htmlf("class='%s' ", class); + html("href='"); + if (item) { + html_attrf(item->util, rev); + } else { + dir = strrchr(path, '/'); + if (dir) + dir++; + else + dir = path; + html_attrf(ctx.repo->module_link, dir, rev); + } + html("'>"); + html_txt(path); + html("</a>"); + } else { + html("<span"); + if (class) + htmlf(" class='%s'", class); + html(">"); + html_txt(path); + html("</span>"); + } + html_txtf(" @ %.7s", rev); + if (item && tail) + path[len - 1] = tail; +} + +const struct date_mode cgit_date_mode(enum date_mode_type type) +{ + static struct date_mode mode; + mode.type = type; + mode.local = ctx.cfg.local_time; + return mode; +} + +static void print_rel_date(time_t t, int tz, double value, + const char *class, const char *suffix) +{ + htmlf("<span class='%s' data-ut='%" PRIu64 "' title='", class, (uint64_t)t); + html_attr(show_date(t, tz, cgit_date_mode(DATE_ISO8601))); + htmlf("'>%.0f %s</span>", value, suffix); +} + +void cgit_print_age(time_t t, int tz, time_t max_relative) +{ + time_t now, secs; + + if (!t) + return; + time(&now); + secs = now - t; + if (secs < 0) + secs = 0; + + if (secs > max_relative && max_relative >= 0) { + html("<span title='"); + html_attr(show_date(t, tz, cgit_date_mode(DATE_ISO8601))); + html("'>"); + html_txt(show_date(t, tz, cgit_date_mode(DATE_SHORT))); + html("</span>"); + return; + } + + if (secs < TM_HOUR * 2) { + print_rel_date(t, tz, secs * 1.0 / TM_MIN, "age-mins", "min."); + return; + } + if (secs < TM_DAY * 2) { + print_rel_date(t, tz, secs * 1.0 / TM_HOUR, "age-hours", "hours"); + return; + } + if (secs < TM_WEEK * 2) { + print_rel_date(t, tz, secs * 1.0 / TM_DAY, "age-days", "days"); + return; + } + if (secs < TM_MONTH * 2) { + print_rel_date(t, tz, secs * 1.0 / TM_WEEK, "age-weeks", "weeks"); + return; + } + if (secs < TM_YEAR * 2) { + print_rel_date(t, tz, secs * 1.0 / TM_MONTH, "age-months", "months"); + return; + } + print_rel_date(t, tz, secs * 1.0 / TM_YEAR, "age-years", "years"); +} + +void cgit_print_http_headers(void) +{ + if (ctx.env.no_http && !strcmp(ctx.env.no_http, "1")) + return; + + if (ctx.page.status) + htmlf("Status: %d %s\n", ctx.page.status, ctx.page.statusmsg); + if (ctx.page.mimetype && ctx.page.charset) + htmlf("Content-Type: %s; charset=%s\n", ctx.page.mimetype, + ctx.page.charset); + else if (ctx.page.mimetype) + htmlf("Content-Type: %s\n", ctx.page.mimetype); + if (ctx.page.size) + htmlf("Content-Length: %zd\n", ctx.page.size); + if (ctx.page.filename) { + html("Content-Disposition: inline; filename=\""); + html_header_arg_in_quotes(ctx.page.filename); + html("\"\n"); + } + htmlf("Last-Modified: %s\n", http_date(ctx.page.modified)); + if (ctx.page.etag) + htmlf("ETag: \"%s\"\n", ctx.page.etag); + html("\n"); + if (ctx.env.request_method && !strcmp(ctx.env.request_method, "HEAD")) + exit(0); +} + +void cgit_redirect(const char *url, bool permanent) +{ + htmlf("Status: %d %s\n", permanent ? 301 : 302, permanent ? "Moved" : "Found"); + html("Location: "); + html_url_path(url); + html("\n\n"); +} + +static void print_rel_vcs_link(const char *url) +{ + html("<link rel='vcs-git' href='"); + html_attr(url); + html("' title='"); + html_attr(ctx.repo->name); + html(" Git repository'/>\n"); +} + +static int emit_css_link(struct string_list_item *s, void *arg) +{ + /* Do not emit anything if css= is specified. */ + if (s && *s->string == '\0') + return 0; + + html("<link rel='stylesheet' type='text/css' href='"); + if (s) + html_attr(s->string); + else + html_attr((const char *)arg); + html("'/>\n"); + + return 0; +} + +static int emit_js_link(struct string_list_item *s, void *arg) +{ + /* Do not emit anything if js= is specified. */ + if (s && *s->string == '\0') + return 0; + + html("<script type='text/javascript' src='"); + if (s) + html_attr(s->string); + else + html_attr((const char *)arg); + html("'></script>\n"); + + return 0; +} + +void cgit_print_docstart(void) +{ + char *host = cgit_hosturl(); + + if (ctx.cfg.embedded) { + if (ctx.cfg.header) + html_include(ctx.cfg.header); + return; + } + + html(cgit_doctype); + html("<html lang='en'>\n"); + html("<head>\n"); + html("<title>"); + html_txt(ctx.page.title); + html("</title>\n"); + htmlf("<meta name='generator' content='cgit %s'/>\n", cgit_version); + if (ctx.cfg.robots && *ctx.cfg.robots) + htmlf("<meta name='robots' content='%s'/>\n", ctx.cfg.robots); + + if (ctx.cfg.css.items) + for_each_string_list(&ctx.cfg.css, emit_css_link, NULL); + else + emit_css_link(NULL, "/cgit.css"); + + if (ctx.cfg.js.items) + for_each_string_list(&ctx.cfg.js, emit_js_link, NULL); + else + emit_js_link(NULL, "/cgit.js"); + + if (ctx.cfg.favicon && *ctx.cfg.favicon) { + html("<link rel='shortcut icon' href='"); + html_attr(ctx.cfg.favicon); + html("'/>\n"); + } + if (host && ctx.repo && ctx.qry.head) { + char *fileurl; + struct strbuf sb = STRBUF_INIT; + strbuf_addf(&sb, "h=%s", ctx.qry.head); + + html("<link rel='alternate' title='Atom feed' href='"); + html(cgit_httpscheme()); + html_attr(host); + fileurl = cgit_fileurl(ctx.repo->url, "atom", ctx.qry.vpath, + sb.buf); + html_attr(fileurl); + html("' type='application/atom+xml'/>\n"); + strbuf_release(&sb); + free(fileurl); + } + if (ctx.repo) + cgit_add_clone_urls(print_rel_vcs_link); + if (ctx.cfg.head_include) + html_include(ctx.cfg.head_include); + if (ctx.repo && ctx.repo->extra_head_content) + html(ctx.repo->extra_head_content); + html("</head>\n"); + html("<body>\n"); + if (ctx.cfg.header) + html_include(ctx.cfg.header); + free(host); +} + +void cgit_print_docend(void) +{ + html("</div> <!-- class=content -->\n"); + if (ctx.cfg.embedded) { + html("</div> <!-- id=cgit -->\n"); + if (ctx.cfg.footer) + html_include(ctx.cfg.footer); + return; + } + if (ctx.cfg.footer) + html_include(ctx.cfg.footer); + else { + htmlf("<div class='footer'>generated by <a href='https://git.zx2c4.com/cgit/about/'>cgit %s</a> " + "(<a href='https://git-scm.com/'>git %s</a>) at ", cgit_version, git_version_string); + html_txt(show_date(time(NULL), 0, cgit_date_mode(DATE_ISO8601))); + html("</div>\n"); + } + html("</div> <!-- id=cgit -->\n"); + html("</body>\n</html>\n"); +} + +void cgit_print_error_page(int code, const char *msg, const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + cgit_vprint_error_page(code, msg, fmt, ap); + va_end(ap); +} + +void cgit_vprint_error_page(int code, const char *msg, const char *fmt, va_list ap) +{ + ctx.page.status = code; + ctx.page.statusmsg = msg; + cgit_print_layout_start(); + cgit_vprint_error(fmt, ap); + cgit_print_layout_end(); +} + +void cgit_print_layout_start(void) +{ + cgit_print_http_headers(); + cgit_print_docstart(); + cgit_print_pageheader(); +} + +void cgit_print_layout_end(void) +{ + cgit_print_docend(); +} + +static void add_clone_urls(void (*fn)(const char *), char *txt, char *suffix) +{ + struct strbuf **url_list = strbuf_split_str(txt, ' ', 0); + int i; + + for (i = 0; url_list[i]; i++) { + strbuf_rtrim(url_list[i]); + if (url_list[i]->len == 0) + continue; + if (suffix && *suffix) + strbuf_addf(url_list[i], "/%s", suffix); + fn(url_list[i]->buf); + } + + strbuf_list_free(url_list); +} + +void cgit_add_clone_urls(void (*fn)(const char *)) +{ + if (ctx.repo->clone_url) + add_clone_urls(fn, expand_macros(ctx.repo->clone_url), NULL); + else if (ctx.cfg.clone_prefix) + add_clone_urls(fn, ctx.cfg.clone_prefix, ctx.repo->url); +} + +static int print_branch_option(const struct reference *ref, void *cb_data) +{ + char *name = (char *)ref->name; + html_option(name, name, ctx.qry.head); + return 0; +} + +void cgit_add_hidden_formfields(int incl_head, int incl_search, + const char *page) +{ + if (!ctx.cfg.virtual_root) { + struct strbuf url = STRBUF_INIT; + + strbuf_addf(&url, "%s/%s", ctx.qry.repo, page); + if (ctx.qry.vpath) + strbuf_addf(&url, "/%s", ctx.qry.vpath); + html_hidden("url", url.buf); + strbuf_release(&url); + } + + if (incl_head && ctx.qry.head && ctx.repo->defbranch && + strcmp(ctx.qry.head, ctx.repo->defbranch)) + html_hidden("h", ctx.qry.head); + + if (ctx.qry.oid) + html_hidden("id", ctx.qry.oid); + if (ctx.qry.oid2) + html_hidden("id2", ctx.qry.oid2); + if (ctx.qry.showmsg) + html_hidden("showmsg", "1"); + + if (incl_search) { + if (ctx.qry.grep) + html_hidden("qt", ctx.qry.grep); + if (ctx.qry.search) + html_hidden("q", ctx.qry.search); + } +} + +static const char *hc(const char *page) +{ + if (!ctx.qry.page) + return NULL; + + return strcmp(ctx.qry.page, page) ? NULL : "active"; +} + +static void cgit_print_path_crumbs(char *path) +{ + char *old_path = ctx.qry.path; + char *p = path, *q, *end = path + strlen(path); + int levels = 0; + + ctx.qry.path = NULL; + cgit_self_link("root", NULL, NULL); + ctx.qry.path = p = path; + while (p < end) { + if (!(q = strchr(p, '/')) || levels > 15) + q = end; + *q = '\0'; + html_txt("/"); + cgit_self_link(p, NULL, NULL); + if (q < end) + *q = '/'; + p = q + 1; + ++levels; + } + ctx.qry.path = old_path; +} + +static void print_header(void) +{ + char *logo = NULL, *logo_link = NULL; + + html("<table id='header'>\n"); + html("<tr>\n"); + + if (ctx.repo && ctx.repo->logo && *ctx.repo->logo) + logo = ctx.repo->logo; + else + logo = ctx.cfg.logo; + if (ctx.repo && ctx.repo->logo_link && *ctx.repo->logo_link) + logo_link = ctx.repo->logo_link; + else + logo_link = ctx.cfg.logo_link; + if (logo && *logo) { + html("<td class='logo' rowspan='2'><a href='"); + if (logo_link && *logo_link) + html_attr(logo_link); + else + html_attr(cgit_rooturl()); + html("'><img src='"); + html_attr(logo); + html("' alt='cgit logo'/></a></td>\n"); + } + + html("<td class='main'>"); + if (ctx.repo) { + cgit_index_link("index", NULL, NULL, NULL, NULL, 0, 1); + html(" : "); + cgit_summary_link(ctx.repo->name, NULL, NULL, NULL); + html("</td><td class='form'>"); + html("<form method='get'>\n"); + cgit_add_hidden_formfields(0, 1, ctx.qry.page); + html("<select name='h' onchange='this.form.submit();'>\n"); + refs_for_each_branch_ref(get_main_ref_store(the_repository), + print_branch_option, ctx.qry.head); + if (ctx.repo->enable_remote_branches) + refs_for_each_remote_ref(get_main_ref_store(the_repository), + print_branch_option, ctx.qry.head); + html("</select> "); + html("<input type='submit' value='switch'/>"); + html("</form>"); + } else + html_txt(ctx.cfg.root_title); + html("</td></tr>\n"); + + html("<tr><td class='sub'>"); + if (ctx.repo) { + html_txt(ctx.repo->desc); + html("</td><td class='sub right'>"); + html_txt(ctx.repo->owner); + } else { + if (ctx.cfg.root_desc) + html_txt(ctx.cfg.root_desc); + } + html("</td></tr></table>\n"); +} + +void cgit_print_pageheader(void) +{ + html("<div id='cgit'>"); + if (!ctx.cfg.noheader) + print_header(); + + html("<table class='tabs'><tr><td>\n"); + if (ctx.repo) { + if (ctx.repo->readme.nr) + reporevlink("about", "about", NULL, + hc("about"), ctx.qry.head, NULL, + NULL); + cgit_summary_link("summary", NULL, hc("summary"), + ctx.qry.head); + cgit_refs_link("refs", NULL, hc("refs"), ctx.qry.head, + ctx.qry.oid, NULL); + cgit_log_link("log", NULL, hc("log"), ctx.qry.head, + NULL, ctx.qry.vpath, 0, NULL, NULL, + ctx.qry.showmsg, ctx.qry.follow); + if (ctx.qry.page && !strcmp(ctx.qry.page, "blame")) + cgit_blame_link("blame", NULL, hc("blame"), ctx.qry.head, + ctx.qry.oid, ctx.qry.vpath); + else + cgit_tree_link("tree", NULL, hc("tree"), ctx.qry.head, + ctx.qry.oid, ctx.qry.vpath); + cgit_commit_link("commit", NULL, hc("commit"), + ctx.qry.head, ctx.qry.oid, ctx.qry.vpath); + cgit_diff_link("diff", NULL, hc("diff"), ctx.qry.head, + ctx.qry.oid, ctx.qry.oid2, ctx.qry.vpath); + if (ctx.repo->max_stats) + cgit_stats_link("stats", NULL, hc("stats"), + ctx.qry.head, ctx.qry.vpath); + if (ctx.repo->homepage) { + html("<a href='"); + html_attr(ctx.repo->homepage); + html("'>homepage</a>"); + } + html("</td><td class='form'>"); + html("<form class='right' method='get' action='"); + if (ctx.cfg.virtual_root) { + char *fileurl = cgit_fileurl(ctx.qry.repo, "log", + ctx.qry.vpath, NULL); + html_url_path(fileurl); + free(fileurl); + } + html("'>\n"); + cgit_add_hidden_formfields(1, 0, "log"); + html("<select name='qt'>\n"); + html_option("grep", "log msg", ctx.qry.grep); + html_option("author", "author", ctx.qry.grep); + html_option("committer", "committer", ctx.qry.grep); + html_option("range", "range", ctx.qry.grep); + html("</select>\n"); + html("<input class='txt' type='search' size='10' name='q' value='"); + html_attr(ctx.qry.search); + html("'/>\n"); + html("<input type='submit' value='search'/>\n"); + html("</form>\n"); + } else { + char *currenturl = cgit_currenturl(); + site_link(NULL, "index", NULL, hc("repolist"), NULL, NULL, 0, 1); + if (ctx.cfg.root_readme) + site_link("about", "about", NULL, hc("about"), + NULL, NULL, 0, 1); + html("</td><td class='form'>"); + html("<form method='get' action='"); + html_attr(currenturl); + html("'>\n"); + html("<input type='search' name='q' size='10' value='"); + html_attr(ctx.qry.search); + html("'/>\n"); + html("<input type='submit' value='search'/>\n"); + html("</form>"); + free(currenturl); + } + html("</td></tr></table>\n"); + if (ctx.repo && ctx.qry.vpath) { + html("<div class='path'>"); + html("path: "); + cgit_print_path_crumbs(ctx.qry.vpath); + if (ctx.repo->enable_follow_links && !strcmp(ctx.qry.page, "log")) { + html(" ("); + ctx.qry.follow = !ctx.qry.follow; + cgit_self_link(ctx.qry.follow ? "follow" : "unfollow", + NULL, NULL); + ctx.qry.follow = !ctx.qry.follow; + html(")"); + } + html("</div>"); + } + html("<div class='content'>"); +} + +void cgit_print_filemode(unsigned short mode) +{ + if (S_ISDIR(mode)) + html("d"); + else if (S_ISLNK(mode)) + html("l"); + else if (S_ISGITLINK(mode)) + html("m"); + else + html("-"); + html_fileperm(mode >> 6); + html_fileperm(mode >> 3); + html_fileperm(mode); +} + +void cgit_compose_snapshot_prefix(struct strbuf *filename, const char *base, + const char *ref) +{ + struct object_id oid; + + /* + * Prettify snapshot names by stripping leading "v" or "V" if the tag + * name starts with {v,V}[0-9] and the prettify mapping is injective, + * i.e. each stripped tag can be inverted without ambiguities. + */ + if (repo_get_oid(the_repository, fmt("refs/tags/%s", ref), &oid) == 0 && + (ref[0] == 'v' || ref[0] == 'V') && isdigit(ref[1]) && + ((repo_get_oid(the_repository, fmt("refs/tags/%s", ref + 1), &oid) == 0) + + (repo_get_oid(the_repository, fmt("refs/tags/v%s", ref + 1), &oid) == 0) + + (repo_get_oid(the_repository, fmt("refs/tags/V%s", ref + 1), &oid) == 0) == 1)) + ref++; + + strbuf_addf(filename, "%s-%s", base, ref); +} + +void cgit_print_snapshot_links(const struct cgit_repo *repo, const char *ref, + const char *separator) +{ + const struct cgit_snapshot_format *f; + struct strbuf filename = STRBUF_INIT; + const char *basename; + size_t prefixlen; + + basename = cgit_snapshot_prefix(repo); + if (starts_with(ref, basename)) + strbuf_addstr(&filename, ref); + else + cgit_compose_snapshot_prefix(&filename, basename, ref); + + prefixlen = filename.len; + for (f = cgit_snapshot_formats; f->suffix; f++) { + if (!(repo->snapshots & cgit_snapshot_format_bit(f))) + continue; + strbuf_setlen(&filename, prefixlen); + strbuf_addstr(&filename, f->suffix); + cgit_snapshot_link(filename.buf, NULL, NULL, NULL, NULL, + filename.buf); + if (cgit_snapshot_get_sig(ref, f)) { + strbuf_addstr(&filename, ".asc"); + html(" ("); + cgit_snapshot_link("sig", NULL, NULL, NULL, NULL, + filename.buf); + html(")"); + } else if (starts_with(f->suffix, ".tar") && cgit_snapshot_get_sig(ref, &cgit_snapshot_formats[0])) { + strbuf_setlen(&filename, strlen(filename.buf) - strlen(f->suffix)); + strbuf_addstr(&filename, ".tar.asc"); + html(" ("); + cgit_snapshot_link("sig", NULL, NULL, NULL, NULL, + filename.buf); + html(")"); + } + html(separator); + } + strbuf_release(&filename); +} + +void cgit_set_title_from_path(const char *path) +{ + struct strbuf sb = STRBUF_INIT; + const char *slash, *last_slash; + + if (!path) + return; + + for (last_slash = path + strlen(path); (slash = memrchr(path, '/', last_slash - path)) != NULL; last_slash = slash) { + strbuf_add(&sb, slash + 1, last_slash - slash - 1); + strbuf_addstr(&sb, " \xc2\xab "); + } + strbuf_add(&sb, path, last_slash - path); + strbuf_addf(&sb, " - %s", ctx.page.title); + ctx.page.title = strbuf_detach(&sb, NULL); +} diff --git a/cgit/ui-shared.h b/cgit/ui-shared.h new file mode 100644 --- /dev/null +++ b/cgit/ui-shared.h @@ -0,0 +1,91 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_SHARED_H +#define UI_SHARED_H + +extern const char *cgit_httpscheme(void); +extern char *cgit_hosturl(void); +extern const char *cgit_rooturl(void); +extern char *cgit_currenturl(void); +extern char *cgit_currentfullurl(void); +extern char *cgit_repourl(const char *reponame); +extern char *cgit_fileurl(const char *reponame, const char *pagename, + const char *filename, const char *query); +extern char *cgit_pageurl(const char *reponame, const char *pagename, + const char *query); + +extern 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); +extern void cgit_summary_link(const char *name, const char *title, + const char *class, const char *head); +extern void cgit_tag_link(const char *name, const char *title, + const char *class, const char *tag); +extern void cgit_tree_link(const char *name, const char *title, + const char *class, const char *head, + const char *rev, const char *path); +extern void cgit_plain_link(const char *name, const char *title, + const char *class, const char *head, + const char *rev, const char *path); +extern void cgit_blame_link(const char *name, const char *title, + const char *class, const char *head, + const char *rev, const char *path); +extern void cgit_log_link(const char *name, const char *title, + const char *class, const char *head, const char *rev, + const char *path, int ofs, const char *grep, + const char *pattern, int showmsg, int follow); +extern void cgit_commit_link(const char *name, const char *title, + const char *class, const char *head, + const char *rev, const char *path); +extern void cgit_patch_link(const char *name, const char *title, + const char *class, const char *head, + const char *rev, const char *path); +extern void cgit_refs_link(const char *name, const char *title, + const char *class, const char *head, + const char *rev, const char *path); +extern void cgit_snapshot_link(const char *name, const char *title, + const char *class, const char *head, + const char *rev, const char *archivename); +extern void cgit_diff_link(const char *name, const char *title, + const char *class, const char *head, + const char *new_rev, const char *old_rev, + const char *path); +extern void cgit_stats_link(const char *name, const char *title, + const char *class, const char *head, + const char *path); +extern void cgit_object_link(struct object *obj); + +extern void cgit_submodule_link(const char *class, char *path, + const char *rev); + +extern void cgit_print_layout_start(void); +extern void cgit_print_layout_end(void); + +__attribute__((format (printf,1,2))) +extern void cgit_print_error(const char *fmt, ...); +__attribute__((format (printf,1,0))) +extern void cgit_vprint_error(const char *fmt, va_list ap); +extern const struct date_mode cgit_date_mode(enum date_mode_type type); +extern void cgit_print_age(time_t t, int tz, time_t max_relative); +extern void cgit_print_http_headers(void); +extern void cgit_redirect(const char *url, bool permanent); +extern void cgit_print_docstart(void); +extern void cgit_print_docend(void); +__attribute__((format (printf,3,4))) +extern void cgit_print_error_page(int code, const char *msg, const char *fmt, ...); +extern void cgit_vprint_error_page(int code, const char *msg, const char *fmt, va_list ap); +extern void cgit_print_pageheader(void); +extern void cgit_print_filemode(unsigned short mode); +extern void cgit_compose_snapshot_prefix(struct strbuf *filename, + const char *base, const char *ref); +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_hidden_formfields(int incl_head, int incl_search, + const char *page); + +extern void cgit_set_title_from_path(const char *path); +#endif /* UI_SHARED_H */ diff --git a/cgit/ui-snapshot.c b/cgit/ui-snapshot.c new file mode 100644 --- /dev/null +++ b/cgit/ui-snapshot.c @@ -0,0 +1,322 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-snapshot.c: generate snapshot of a commit + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-snapshot.h" +#include "html.h" +#include "ui-shared.h" + +static int write_archive_type(const char *format, const char *hex, const char *prefix) +{ + struct strvec argv = STRVEC_INIT; + const char **nargv; + int result; + strvec_push(&argv, "snapshot"); + strvec_push(&argv, format); + if (prefix) { + struct strbuf buf = STRBUF_INIT; + strbuf_addstr(&buf, prefix); + strbuf_addch(&buf, '/'); + strvec_push(&argv, "--prefix"); + strvec_push(&argv, buf.buf); + strbuf_release(&buf); + } + strvec_push(&argv, hex); + /* + * Now we need to copy the pointers to arguments into a new + * structure because write_archive will rearrange its arguments + * which may result in duplicated/missing entries causing leaks + * or double-frees in strvec_clear. + */ + nargv = xmalloc(sizeof(char *) * (argv.nr + 1)); + /* strvec guarantees a trailing NULL entry. */ + memcpy(nargv, argv.v, sizeof(char *) * (argv.nr + 1)); + + result = write_archive(argv.nr, nargv, NULL, the_repository, NULL, 0); + strvec_clear(&argv); + free(nargv); + return result; +} + +static int write_tar_archive(const char *hex, const char *prefix) +{ + return write_archive_type("--format=tar", hex, prefix); +} + +static int write_zip_archive(const char *hex, const char *prefix) +{ + return write_archive_type("--format=zip", hex, prefix); +} + +static int write_compressed_tar_archive(const char *hex, + const char *prefix, + char *filter_argv[]) +{ + int rv; + struct cgit_exec_filter f; + cgit_exec_filter_init(&f, filter_argv[0], filter_argv); + + cgit_open_exec_filter(&f); + rv = write_tar_archive(hex, prefix); + cgit_close_exec_filter(&f); + return rv; +} + +static int write_tar_gzip_archive(const char *hex, const char *prefix) +{ + char *argv[] = { "gzip", "-n", NULL }; + return write_compressed_tar_archive(hex, prefix, argv); +} + +static int write_tar_bzip2_archive(const char *hex, const char *prefix) +{ + char *argv[] = { "bzip2", NULL }; + return write_compressed_tar_archive(hex, prefix, argv); +} + +static int write_tar_lzip_archive(const char *hex, const char *prefix) +{ + char *argv[] = { "lzip", NULL }; + return write_compressed_tar_archive(hex, prefix, argv); +} + +static int write_tar_xz_archive(const char *hex, const char *prefix) +{ + char *argv[] = { "xz", NULL }; + return write_compressed_tar_archive(hex, prefix, argv); +} + +static int write_tar_zstd_archive(const char *hex, const char *prefix) +{ + char *argv[] = { "zstd", "-T0", NULL }; + return write_compressed_tar_archive(hex, prefix, argv); +} + +const struct cgit_snapshot_format cgit_snapshot_formats[] = { + /* .tar must remain the 0 index */ + { ".tar", "application/x-tar", write_tar_archive }, + { ".tar.gz", "application/x-gzip", write_tar_gzip_archive }, + { ".tar.bz2", "application/x-bzip2", write_tar_bzip2_archive }, + { ".tar.lz", "application/x-lzip", write_tar_lzip_archive }, + { ".tar.xz", "application/x-xz", write_tar_xz_archive }, + { ".tar.zst", "application/x-zstd", write_tar_zstd_archive }, + { ".zip", "application/x-zip", write_zip_archive }, + { NULL } +}; + +static struct notes_tree snapshot_sig_notes[ARRAY_SIZE(cgit_snapshot_formats)]; + +const struct object_id *cgit_snapshot_get_sig(const char *ref, + const struct cgit_snapshot_format *f) +{ + struct notes_tree *tree; + struct object_id oid; + + if (repo_get_oid(the_repository, ref, &oid)) + return NULL; + + tree = &snapshot_sig_notes[f - &cgit_snapshot_formats[0]]; + if (!tree->initialized) { + struct strbuf notes_ref = STRBUF_INIT; + + strbuf_addf(¬es_ref, "refs/notes/signatures/%s", + f->suffix + 1); + + init_notes(tree, notes_ref.buf, combine_notes_ignore, 0); + strbuf_release(¬es_ref); + } + + return get_note(tree, &oid); +} + +static const struct cgit_snapshot_format *get_format(const char *filename) +{ + const struct cgit_snapshot_format *fmt; + + for (fmt = cgit_snapshot_formats; fmt->suffix; fmt++) { + if (ends_with(filename, fmt->suffix)) + return fmt; + } + return NULL; +} + +const unsigned cgit_snapshot_format_bit(const struct cgit_snapshot_format *f) +{ + return BIT(f - &cgit_snapshot_formats[0]); +} + +static int make_snapshot(const struct cgit_snapshot_format *format, + const char *hex, const char *prefix, + const char *filename) +{ + struct object_id oid; + + if (repo_get_oid(the_repository, hex, &oid)) { + cgit_print_error_page(404, "Not found", + "Bad object id: %s", hex); + return 1; + } + if (!lookup_commit_reference(the_repository, &oid)) { + cgit_print_error_page(400, "Bad request", + "Not a commit reference: %s", hex); + return 1; + } + ctx.page.etag = oid_to_hex(&oid); + ctx.page.mimetype = xstrdup(format->mimetype); + ctx.page.filename = xstrdup(filename); + cgit_print_http_headers(); + init_archivers(); + format->write_func(hex, prefix); + return 0; +} + +static int write_sig(const struct cgit_snapshot_format *format, + const char *hex, const char *archive, + const char *filename) +{ + const struct object_id *note = cgit_snapshot_get_sig(hex, format); + enum object_type type; + unsigned long size; + char *buf; + + if (!note) { + cgit_print_error_page(404, "Not found", + "No signature for %s", archive); + return 0; + } + + buf = odb_read_object(the_repository->objects, note, &type, &size); + if (!buf) { + cgit_print_error_page(404, "Not found", "Not found"); + return 0; + } + + html("X-Content-Type-Options: nosniff\n"); + html("Content-Security-Policy: default-src 'none'\n"); + ctx.page.etag = oid_to_hex(note); + ctx.page.mimetype = xstrdup("application/pgp-signature"); + ctx.page.filename = xstrdup(filename); + cgit_print_http_headers(); + + html_raw(buf, size); + free(buf); + return 0; +} + +/* Try to guess the requested revision from the requested snapshot name. + * First the format extension is stripped, e.g. "cgit-0.7.2.tar.gz" become + * "cgit-0.7.2". If this is a valid commit object name we've got a winner. + * Otherwise, if the snapshot name has a prefix matching the result from + * repo_basename(), we strip the basename and any following '-' and '_' + * characters ("cgit-0.7.2" -> "0.7.2") and check the resulting name once + * more. If this still isn't a valid commit object name, we check if pre- + * pending a 'v' or a 'V' to the remaining snapshot name ("0.7.2" -> + * "v0.7.2") gives us something valid. + */ +static const char *get_ref_from_filename(const struct cgit_repo *repo, + const char *filename, + const struct cgit_snapshot_format *format) +{ + const char *reponame; + struct object_id oid; + struct strbuf snapshot = STRBUF_INIT; + int result = 1; + + strbuf_addstr(&snapshot, filename); + strbuf_setlen(&snapshot, snapshot.len - strlen(format->suffix)); + + if (repo_get_oid(the_repository, snapshot.buf, &oid) == 0) + goto out; + + reponame = cgit_snapshot_prefix(repo); + if (starts_with(snapshot.buf, reponame)) { + const char *new_start = snapshot.buf; + new_start += strlen(reponame); + while (new_start && (*new_start == '-' || *new_start == '_')) + new_start++; + strbuf_splice(&snapshot, 0, new_start - snapshot.buf, "", 0); + } + + if (repo_get_oid(the_repository, snapshot.buf, &oid) == 0) + goto out; + + strbuf_insert(&snapshot, 0, "v", 1); + if (repo_get_oid(the_repository, snapshot.buf, &oid) == 0) + goto out; + + strbuf_splice(&snapshot, 0, 1, "V", 1); + if (repo_get_oid(the_repository, snapshot.buf, &oid) == 0) + goto out; + + result = 0; + strbuf_release(&snapshot); + +out: + return result ? strbuf_detach(&snapshot, NULL) : NULL; +} + +void cgit_print_snapshot(const char *head, const char *hex, + const char *filename, int dwim) +{ + const struct cgit_snapshot_format* f; + const char *sig_filename = NULL; + char *adj_filename = NULL; + char *prefix = NULL; + + if (!filename) { + cgit_print_error_page(400, "Bad request", + "No snapshot name specified"); + return; + } + + if (ends_with(filename, ".asc")) { + sig_filename = filename; + + /* Strip ".asc" from filename for common format processing */ + adj_filename = xstrdup(filename); + adj_filename[strlen(adj_filename) - 4] = '\0'; + filename = adj_filename; + } + + f = get_format(filename); + if (!f || (!sig_filename && !(ctx.repo->snapshots & cgit_snapshot_format_bit(f)))) { + cgit_print_error_page(400, "Bad request", + "Unsupported snapshot format: %s", filename); + return; + } + + if (!hex && dwim) { + hex = get_ref_from_filename(ctx.repo, filename, f); + if (hex == NULL) { + cgit_print_error_page(404, "Not found", "Not found"); + return; + } + prefix = xstrdup(filename); + prefix[strlen(filename) - strlen(f->suffix)] = '\0'; + } + + if (!hex) + hex = head; + + if (!prefix) + prefix = xstrdup(cgit_snapshot_prefix(ctx.repo)); + + if (sig_filename) + write_sig(f, hex, filename, sig_filename); + else + make_snapshot(f, hex, prefix, filename); + + free(prefix); + free(adj_filename); +} diff --git a/cgit/ui-snapshot.h b/cgit/ui-snapshot.h new file mode 100644 --- /dev/null +++ b/cgit/ui-snapshot.h @@ -0,0 +1,11 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_SNAPSHOT_H +#define UI_SNAPSHOT_H + +extern void cgit_print_snapshot(const char *head, const char *hex, + const char *filename, int dwim); + +#endif /* UI_SNAPSHOT_H */ diff --git a/cgit/ui-ssdiff.c b/cgit/ui-ssdiff.c new file mode 100644 --- /dev/null +++ b/cgit/ui-ssdiff.c @@ -0,0 +1,424 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#include "cgit.h" +#include "ui-ssdiff.h" +#include "html.h" +#include "ui-shared.h" +#include "ui-diff.h" + +extern int use_ssdiff; + +static int current_old_line, current_new_line; +static int **L = NULL; + +struct deferred_lines { + int line_no; + char *line; + struct deferred_lines *next; +}; + +static struct deferred_lines *deferred_old, *deferred_old_last; +static struct deferred_lines *deferred_new, *deferred_new_last; + +static void create_or_reset_lcs_table(void) +{ + int i; + + if (L != NULL) { + memset(*L, 0, sizeof(int) * MAX_SSDIFF_SIZE); + return; + } + + // xcalloc will die if we ran out of memory; + // not very helpful for debugging + L = (int**)xcalloc(MAX_SSDIFF_M, sizeof(int *)); + *L = (int*)xcalloc(MAX_SSDIFF_SIZE, sizeof(int)); + + for (i = 1; i < MAX_SSDIFF_M; i++) { + L[i] = *L + i * MAX_SSDIFF_N; + } +} + +static char *longest_common_subsequence(char *A, char *B) +{ + int i, j, ri; + int m = strlen(A); + int n = strlen(B); + int tmp1, tmp2; + int lcs_length; + char *result; + + // We bail if the lines are too long + if (m >= MAX_SSDIFF_M || n >= MAX_SSDIFF_N) + return NULL; + + create_or_reset_lcs_table(); + + for (i = m; i >= 0; i--) { + for (j = n; j >= 0; j--) { + if (A[i] == '\0' || B[j] == '\0') { + L[i][j] = 0; + } else if (A[i] == B[j]) { + L[i][j] = 1 + L[i + 1][j + 1]; + } else { + tmp1 = L[i + 1][j]; + tmp2 = L[i][j + 1]; + L[i][j] = (tmp1 > tmp2 ? tmp1 : tmp2); + } + } + } + + lcs_length = L[0][0]; + result = xmalloc(lcs_length + 2); + memset(result, 0, sizeof(*result) * (lcs_length + 2)); + + ri = 0; + i = 0; + j = 0; + while (i < m && j < n) { + if (A[i] == B[j]) { + result[ri] = A[i]; + ri += 1; + i += 1; + j += 1; + } else if (L[i + 1][j] >= L[i][j + 1]) { + i += 1; + } else { + j += 1; + } + } + + return result; +} + +static int line_from_hunk(char *line, char type) +{ + char *buf1, *buf2; + int len, res; + + buf1 = strchr(line, type); + if (buf1 == NULL) + return 0; + buf1 += 1; + buf2 = strchr(buf1, ','); + if (buf2 == NULL) + return 0; + len = buf2 - buf1; + buf2 = xmalloc(len + 1); + strlcpy(buf2, buf1, len + 1); + res = atoi(buf2); + free(buf2); + return res; +} + +static char *replace_tabs(char *line) +{ + char *prev_buf = line; + char *cur_buf; + size_t linelen = strlen(line); + int n_tabs = 0; + int i; + char *result; + size_t result_len; + + if (linelen == 0) { + result = xmalloc(1); + result[0] = '\0'; + return result; + } + + for (i = 0; i < linelen; i++) { + if (line[i] == '\t') + n_tabs += 1; + } + result_len = linelen + n_tabs * 8; + result = xmalloc(result_len + 1); + result[0] = '\0'; + + for (;;) { + cur_buf = strchr(prev_buf, '\t'); + if (!cur_buf) { + linelen = strlen(result); + strlcpy(&result[linelen], prev_buf, result_len - linelen + 1); + break; + } else { + linelen = strlen(result); + strlcpy(&result[linelen], prev_buf, cur_buf - prev_buf + 1); + linelen = strlen(result); + memset(&result[linelen], ' ', 8 - (linelen % 8)); + result[linelen + 8 - (linelen % 8)] = '\0'; + } + prev_buf = cur_buf + 1; + } + return result; +} + +static int calc_deferred_lines(struct deferred_lines *start) +{ + struct deferred_lines *item = start; + int result = 0; + while (item) { + result += 1; + item = item->next; + } + return result; +} + +static void deferred_old_add(char *line, int line_no) +{ + struct deferred_lines *item = xmalloc(sizeof(struct deferred_lines)); + item->line = xstrdup(line); + item->line_no = line_no; + item->next = NULL; + if (deferred_old) { + deferred_old_last->next = item; + deferred_old_last = item; + } else { + deferred_old = deferred_old_last = item; + } +} + +static void deferred_new_add(char *line, int line_no) +{ + struct deferred_lines *item = xmalloc(sizeof(struct deferred_lines)); + item->line = xstrdup(line); + item->line_no = line_no; + item->next = NULL; + if (deferred_new) { + deferred_new_last->next = item; + deferred_new_last = item; + } else { + deferred_new = deferred_new_last = item; + } +} + +static void print_part_with_lcs(char *class, char *line, char *lcs) +{ + int line_len = strlen(line); + int i, j; + char c[2] = " "; + int same = 1; + + j = 0; + for (i = 0; i < line_len; i++) { + c[0] = line[i]; + if (same) { + if (line[i] == lcs[j]) + j += 1; + else { + same = 0; + htmlf("<span class='%s'>", class); + } + } else if (line[i] == lcs[j]) { + same = 1; + html("</span>"); + j += 1; + } + html_txt(c); + } + if (!same) + html("</span>"); +} + +static void print_ssdiff_line(char *class, + int old_line_no, + char *old_line, + int new_line_no, + char *new_line, int individual_chars) +{ + char *lcs = NULL; + + if (old_line) + old_line = replace_tabs(old_line + 1); + if (new_line) + new_line = replace_tabs(new_line + 1); + if (individual_chars && old_line && new_line) + lcs = longest_common_subsequence(old_line, new_line); + html("<tr>\n"); + if (old_line_no > 0) { + struct diff_filespec *old_file = cgit_get_current_old_file(); + char *lineno_str = fmt("n%d", old_line_no); + char *id_str = fmt("id=%s#%s", is_null_oid(&old_file->oid)?"HEAD":oid_to_hex(old_rev_oid), lineno_str); + char *fileurl = cgit_fileurl(ctx.repo->url, "tree", old_file->path, id_str); + html("<td class='lineno'><a href='"); + html(fileurl); + htmlf("'>%s</a>", lineno_str + 1); + html("</td>"); + htmlf("<td class='%s'>", class); + free(fileurl); + } else if (old_line) + htmlf("<td class='lineno'></td><td class='%s'>", class); + else + htmlf("<td class='lineno'></td><td class='%s_dark'>", class); + if (old_line) { + if (lcs) + print_part_with_lcs("del", old_line, lcs); + else + html_txt(old_line); + } + + html("</td>\n"); + if (new_line_no > 0) { + struct diff_filespec *new_file = cgit_get_current_new_file(); + char *lineno_str = fmt("n%d", new_line_no); + char *id_str = fmt("id=%s#%s", is_null_oid(&new_file->oid)?"HEAD":oid_to_hex(new_rev_oid), lineno_str); + char *fileurl = cgit_fileurl(ctx.repo->url, "tree", new_file->path, id_str); + html("<td class='lineno'><a href='"); + html(fileurl); + htmlf("'>%s</a>", lineno_str + 1); + html("</td>"); + htmlf("<td class='%s'>", class); + free(fileurl); + } else if (new_line) + htmlf("<td class='lineno'></td><td class='%s'>", class); + else + htmlf("<td class='lineno'></td><td class='%s_dark'>", class); + if (new_line) { + if (lcs) + print_part_with_lcs("add", new_line, lcs); + else + html_txt(new_line); + } + + html("</td></tr>"); + if (lcs) + free(lcs); + if (new_line) + free(new_line); + if (old_line) + free(old_line); +} + +static void print_deferred_old_lines(void) +{ + struct deferred_lines *iter_old, *tmp; + iter_old = deferred_old; + while (iter_old) { + print_ssdiff_line("del", iter_old->line_no, + iter_old->line, -1, NULL, 0); + tmp = iter_old->next; + free(iter_old); + iter_old = tmp; + } +} + +static void print_deferred_new_lines(void) +{ + struct deferred_lines *iter_new, *tmp; + iter_new = deferred_new; + while (iter_new) { + print_ssdiff_line("add", -1, NULL, + iter_new->line_no, iter_new->line, 0); + tmp = iter_new->next; + free(iter_new); + iter_new = tmp; + } +} + +static void print_deferred_changed_lines(void) +{ + struct deferred_lines *iter_old, *iter_new, *tmp; + int n_old_lines = calc_deferred_lines(deferred_old); + int n_new_lines = calc_deferred_lines(deferred_new); + int individual_chars = (n_old_lines == n_new_lines ? 1 : 0); + + iter_old = deferred_old; + iter_new = deferred_new; + while (iter_old || iter_new) { + if (iter_old && iter_new) + print_ssdiff_line("changed", iter_old->line_no, + iter_old->line, + iter_new->line_no, iter_new->line, + individual_chars); + else if (iter_old) + print_ssdiff_line("changed", iter_old->line_no, + iter_old->line, -1, NULL, 0); + else if (iter_new) + print_ssdiff_line("changed", -1, NULL, + iter_new->line_no, iter_new->line, 0); + if (iter_old) { + tmp = iter_old->next; + free(iter_old); + iter_old = tmp; + } + + if (iter_new) { + tmp = iter_new->next; + free(iter_new); + iter_new = tmp; + } + } +} + +void cgit_ssdiff_print_deferred_lines(void) +{ + if (!deferred_old && !deferred_new) + return; + if (deferred_old && !deferred_new) + print_deferred_old_lines(); + else if (!deferred_old && deferred_new) + print_deferred_new_lines(); + else + print_deferred_changed_lines(); + deferred_old = deferred_old_last = NULL; + deferred_new = deferred_new_last = NULL; +} + +/* + * print a single line returned from xdiff + */ +void cgit_ssdiff_line_cb(char *line, int len) +{ + char c = line[len - 1]; + line[len - 1] = '\0'; + if (line[0] == '@') { + current_old_line = line_from_hunk(line, '-'); + current_new_line = line_from_hunk(line, '+'); + } + + if (line[0] == ' ') { + if (deferred_old || deferred_new) + cgit_ssdiff_print_deferred_lines(); + print_ssdiff_line("ctx", current_old_line, line, + current_new_line, line, 0); + current_old_line += 1; + current_new_line += 1; + } else if (line[0] == '+') { + deferred_new_add(line, current_new_line); + current_new_line += 1; + } else if (line[0] == '-') { + deferred_old_add(line, current_old_line); + current_old_line += 1; + } else if (line[0] == '@') { + html("<tr><td colspan='4' class='hunk'>"); + html_txt(line); + html("</td></tr>"); + } else { + html("<tr><td colspan='4' class='ctx'>"); + html_txt(line); + html("</td></tr>"); + } + line[len - 1] = c; +} + +void cgit_ssdiff_header_begin(void) +{ + current_old_line = -1; + current_new_line = -1; + html("<tr><td class='space' colspan='4'><div></div></td></tr>"); + html("<tr><td class='head' colspan='4'>"); +} + +void cgit_ssdiff_header_end(void) +{ + html("</td></tr>"); +} + +void cgit_ssdiff_footer(void) +{ + if (deferred_old || deferred_new) + cgit_ssdiff_print_deferred_lines(); + html("<tr><td class='foot' colspan='4'></td></tr>"); +} diff --git a/cgit/ui-ssdiff.h b/cgit/ui-ssdiff.h new file mode 100644 --- /dev/null +++ b/cgit/ui-ssdiff.h @@ -0,0 +1,29 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_SSDIFF_H +#define UI_SSDIFF_H + +/* + * ssdiff line limits + */ +#ifndef MAX_SSDIFF_M +#define MAX_SSDIFF_M 128 +#endif + +#ifndef MAX_SSDIFF_N +#define MAX_SSDIFF_N 128 +#endif +#define MAX_SSDIFF_SIZE ((MAX_SSDIFF_M) * (MAX_SSDIFF_N)) + +extern void cgit_ssdiff_print_deferred_lines(void); + +extern void cgit_ssdiff_line_cb(char *line, int len); + +extern void cgit_ssdiff_header_begin(void); +extern void cgit_ssdiff_header_end(void); + +extern void cgit_ssdiff_footer(void); + +#endif /* UI_SSDIFF_H */ diff --git a/cgit/ui-stats.c b/cgit/ui-stats.c new file mode 100644 --- /dev/null +++ b/cgit/ui-stats.c @@ -0,0 +1,439 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-stats.c: generate stats view + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * Licensed under GNU General Public License v2 + * (see COPYING for full license text) + */ + +#define USE_THE_REPOSITORY_VARIABLE + +#include "cgit.h" +#include "ui-stats.h" +#include "html.h" +#include "ui-shared.h" + +struct authorstat { + long total; + struct string_list list; +}; + +#define DAY_SECS (60 * 60 * 24) +#define WEEK_SECS (DAY_SECS * 7) + +static void trunc_week(struct tm *tm) +{ + time_t t = timegm(tm); + t -= ((tm->tm_wday + 6) % 7) * DAY_SECS; + gmtime_r(&t, tm); +} + +static void dec_week(struct tm *tm) +{ + time_t t = timegm(tm); + t -= WEEK_SECS; + gmtime_r(&t, tm); +} + +static void inc_week(struct tm *tm) +{ + time_t t = timegm(tm); + t += WEEK_SECS; + gmtime_r(&t, tm); +} + +static char *pretty_week(struct tm *tm) +{ + static char buf[10]; + + strftime(buf, sizeof(buf), "W%V %G", tm); + return buf; +} + +static void trunc_month(struct tm *tm) +{ + tm->tm_mday = 1; +} + +static void dec_month(struct tm *tm) +{ + tm->tm_mon--; + if (tm->tm_mon < 0) { + tm->tm_year--; + tm->tm_mon = 11; + } +} + +static void inc_month(struct tm *tm) +{ + tm->tm_mon++; + if (tm->tm_mon > 11) { + tm->tm_year++; + tm->tm_mon = 0; + } +} + +static char *pretty_month(struct tm *tm) +{ + static const char *months[] = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + }; + return fmt("%s %d", months[tm->tm_mon], tm->tm_year + 1900); +} + +static void trunc_quarter(struct tm *tm) +{ + trunc_month(tm); + while (tm->tm_mon % 3 != 0) + dec_month(tm); +} + +static void dec_quarter(struct tm *tm) +{ + dec_month(tm); + dec_month(tm); + dec_month(tm); +} + +static void inc_quarter(struct tm *tm) +{ + inc_month(tm); + inc_month(tm); + inc_month(tm); +} + +static char *pretty_quarter(struct tm *tm) +{ + return fmt("Q%d %d", tm->tm_mon / 3 + 1, tm->tm_year + 1900); +} + +static void trunc_year(struct tm *tm) +{ + trunc_month(tm); + tm->tm_mon = 0; +} + +static void dec_year(struct tm *tm) +{ + tm->tm_year--; +} + +static void inc_year(struct tm *tm) +{ + tm->tm_year++; +} + +static char *pretty_year(struct tm *tm) +{ + return fmt("%d", tm->tm_year + 1900); +} + +static const struct cgit_period periods[] = { + {'w', "week", 12, 4, trunc_week, dec_week, inc_week, pretty_week}, + {'m', "month", 12, 4, trunc_month, dec_month, inc_month, pretty_month}, + {'q', "quarter", 12, 4, trunc_quarter, dec_quarter, inc_quarter, pretty_quarter}, + {'y', "year", 12, 4, trunc_year, dec_year, inc_year, pretty_year}, +}; + +/* Given a period code or name, return a period index (1, 2, 3 or 4) + * and update the period pointer to the correcsponding struct. + * If no matching code is found, return 0. + */ +int cgit_find_stats_period(const char *expr, const struct cgit_period **period) +{ + int i; + char code = '\0'; + + if (!expr) + return 0; + + if (strlen(expr) == 1) + code = expr[0]; + + for (i = 0; i < sizeof(periods) / sizeof(periods[0]); i++) + if (periods[i].code == code || !strcmp(periods[i].name, expr)) { + if (period) + *period = &periods[i]; + return i + 1; + } + return 0; +} + +const char *cgit_find_stats_periodname(int idx) +{ + if (idx > 0 && idx < 4) + return periods[idx - 1].name; + else + return ""; +} + +static void add_commit(struct string_list *authors, struct commit *commit, + const struct cgit_period *period) +{ + struct commitinfo *info; + struct string_list_item *author, *item; + struct authorstat *authorstat; + struct string_list *items; + char *tmp; + struct tm date; + time_t t; + uintptr_t *counter; + + info = cgit_parse_commit(commit); + tmp = xstrdup(info->author); + author = string_list_insert(authors, tmp); + if (!author->util) + author->util = xcalloc(1, sizeof(struct authorstat)); + else + free(tmp); + authorstat = author->util; + items = &authorstat->list; + t = info->committer_date; + gmtime_r(&t, &date); + period->trunc(&date); + tmp = xstrdup(period->pretty(&date)); + item = string_list_insert(items, tmp); + counter = (uintptr_t *)&item->util; + if (*counter) + free(tmp); + (*counter)++; + + authorstat->total++; + cgit_free_commitinfo(info); +} + +static int cmp_total_commits(const void *a1, const void *a2) +{ + const struct string_list_item *i1 = a1; + const struct string_list_item *i2 = a2; + const struct authorstat *auth1 = i1->util; + const struct authorstat *auth2 = i2->util; + + return auth2->total - auth1->total; +} + +/* Walk the commit DAG and collect number of commits per author per + * timeperiod into a nested string_list collection. + */ +static struct string_list collect_stats(const struct cgit_period *period) +{ + struct string_list authors; + struct rev_info rev; + struct commit *commit; + const char *argv[] = {NULL, ctx.qry.head, NULL, NULL, NULL, NULL}; + int argc = 3; + time_t now; + long i; + struct tm tm; + char tmp[11]; + + time(&now); + gmtime_r(&now, &tm); + period->trunc(&tm); + for (i = 1; i < period->count; i++) + period->dec(&tm); + strftime(tmp, sizeof(tmp), "%Y-%m-%d", &tm); + argv[2] = xstrdup(fmt("--since=%s", tmp)); + if (ctx.qry.path) { + argv[3] = "--"; + argv[4] = ctx.qry.path; + argc += 2; + } + repo_init_revisions(the_repository, &rev, NULL); + rev.abbrev = DEFAULT_ABBREV; + rev.commit_format = CMIT_FMT_DEFAULT; + rev.max_parents = 1; + rev.verbose_header = 1; + rev.show_root_diff = 0; + setup_revisions(argc, argv, &rev, NULL); + prepare_revision_walk(&rev); + memset(&authors, 0, sizeof(authors)); + while ((commit = get_revision(&rev)) != NULL) { + add_commit(&authors, commit, period); + release_commit_memory(the_repository->parsed_objects, commit); + commit->parents = NULL; + } + return authors; +} + +static void print_combined_authorrow(struct string_list *authors, int from, + int to, const char *name, + const char *leftclass, + const char *centerclass, + const char *rightclass, + const struct cgit_period *period) +{ + struct string_list_item *author; + struct authorstat *authorstat; + struct string_list *items; + struct string_list_item *date; + time_t now; + long i, j, total, subtotal; + struct tm tm; + char *tmp; + + time(&now); + gmtime_r(&now, &tm); + period->trunc(&tm); + for (i = 1; i < period->count; i++) + period->dec(&tm); + + total = 0; + htmlf("<tr><td class='%s'>%s</td>", leftclass, + fmt(name, to - from + 1)); + for (j = 0; j < period->count; j++) { + tmp = period->pretty(&tm); + period->inc(&tm); + subtotal = 0; + for (i = from; i <= to; i++) { + author = &authors->items[i]; + authorstat = author->util; + items = &authorstat->list; + date = string_list_lookup(items, tmp); + if (date) + subtotal += (uintptr_t)date->util; + } + htmlf("<td class='%s'>%ld</td>", centerclass, subtotal); + total += subtotal; + } + htmlf("<td class='%s'>%ld</td></tr>", rightclass, total); +} + +static void print_authors(struct string_list *authors, int top, + const struct cgit_period *period) +{ + struct string_list_item *author; + struct authorstat *authorstat; + struct string_list *items; + struct string_list_item *date; + time_t now; + long i, j, total; + struct tm tm; + char *tmp; + + time(&now); + gmtime_r(&now, &tm); + period->trunc(&tm); + for (i = 1; i < period->count; i++) + period->dec(&tm); + + html("<table class='stats'><tr><th>Author</th>"); + for (j = 0; j < period->count; j++) { + tmp = period->pretty(&tm); + htmlf("<th>%s</th>", tmp); + period->inc(&tm); + } + html("<th>Total</th></tr>\n"); + + if (top <= 0 || top > authors->nr) + top = authors->nr; + + for (i = 0; i < top; i++) { + author = &authors->items[i]; + html("<tr><td class='left'>"); + html_txt(author->string); + html("</td>"); + authorstat = author->util; + items = &authorstat->list; + total = 0; + for (j = 0; j < period->count; j++) + period->dec(&tm); + for (j = 0; j < period->count; j++) { + tmp = period->pretty(&tm); + period->inc(&tm); + date = string_list_lookup(items, tmp); + if (!date) + html("<td>0</td>"); + else { + htmlf("<td>%lu</td>", (uintptr_t)date->util); + total += (uintptr_t)date->util; + } + } + htmlf("<td class='sum'>%ld</td></tr>", total); + } + + if (top < authors->nr) + print_combined_authorrow(authors, top, authors->nr - 1, + "Others (%ld)", "left", "", "sum", period); + + print_combined_authorrow(authors, 0, authors->nr - 1, "Total", + "total", "sum", "sum", period); + html("</table>"); +} + +/* Create a sorted string_list with one entry per author. The util-field + * for each author is another string_list which is used to calculate the + * number of commits per time-interval. + */ +void cgit_show_stats(void) +{ + struct string_list authors; + const struct cgit_period *period; + int top, i; + const char *code = "w"; + + if (ctx.qry.period) + code = ctx.qry.period; + + i = cgit_find_stats_period(code, &period); + if (!i) { + cgit_print_error_page(404, "Not found", + "Unknown statistics type: %c", code[0]); + return; + } + if (i > ctx.repo->max_stats) { + cgit_print_error_page(400, "Bad request", + "Statistics type disabled: %s", period->name); + return; + } + authors = collect_stats(period); + qsort(authors.items, authors.nr, sizeof(struct string_list_item), + cmp_total_commits); + + top = ctx.qry.ofs; + if (!top) + top = 10; + + cgit_print_layout_start(); + html("<div class='cgit-panel'>"); + html("<b>stat options</b>"); + html("<form method='get'>"); + cgit_add_hidden_formfields(1, 0, "stats"); + html("<table><tr><td colspan='2'/></tr>"); + if (ctx.repo->max_stats > 1) { + html("<tr><td class='label'>Period:</td>"); + html("<td class='ctrl'><select name='period' onchange='this.form.submit();'>"); + for (i = 0; i < ctx.repo->max_stats; i++) + html_option(fmt("%c", periods[i].code), + periods[i].name, fmt("%c", period->code)); + html("</select></td></tr>"); + } + html("<tr><td class='label'>Authors:</td>"); + html("<td class='ctrl'><select name='ofs' onchange='this.form.submit();'>"); + html_intoption(10, "10", top); + html_intoption(25, "25", top); + html_intoption(50, "50", top); + html_intoption(100, "100", top); + html_intoption(-1, "all", top); + html("</select></td></tr>"); + html("<tr><td/><td class='ctrl'>"); + html("<noscript><input type='submit' value='Reload'/></noscript>"); + html("</td></tr></table>"); + html("</form>"); + html("</div>"); + htmlf("<h2>Commits per author per %s", period->name); + if (ctx.qry.path) { + html(" (path '"); + html_txt(ctx.qry.path); + html("')"); + } + html("</h2>"); + print_authors(&authors, top, period); + cgit_print_layout_end(); +} + diff --git a/cgit/ui-stats.h b/cgit/ui-stats.h new file mode 100644 --- /dev/null +++ b/cgit/ui-stats.h @@ -0,0 +1,32 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +#ifndef UI_STATS_H +#define UI_STATS_H + +#include "cgit.h" + +struct cgit_period { + const char code; + const char *name; + int max_periods; + int count; + + /* Convert a tm value to the first day in the period */ + void (*trunc)(struct tm *tm); + + /* Update tm value to start of next/previous period */ + void (*dec)(struct tm *tm); + void (*inc)(struct tm *tm); + + /* Pretty-print a tm value */ + char *(*pretty)(struct tm *tm); +}; + +extern int cgit_find_stats_period(const char *expr, const struct cgit_period **period); +extern const char *cgit_find_stats_periodname(int idx); + +extern void cgit_show_stats(void); + +#endif /* UI_STATS_H */ diff --git a/cgit/ui-summary.c b/cgit/ui-summary.c new file mode 100644 --- /dev/null +++ b/cgit/ui-summary.c @@ -0,0 +1,148 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-summary.c: functions for generating repo summary page + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * 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("<tr class='nohover'><td colspan='%d'> </td></tr>", columns); + htmlf("<tr class='nohover'><th class='left' colspan='%d'>Clone</th></tr>\n", columns); + } + + htmlf("<tr><td colspan='%d'><a rel='vcs-git' href='", columns); + html_url_path(url); + html("' title='"); + html_attr(ctx.repo->name); + html(" Git repository'>"); + html_txt(url); + html("</a></td></tr>\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("<table summary='repository info' class='list nowrap'>"); + cgit_print_branches(ctx.cfg.summary_branches); + htmlf("<tr class='nohover'><td colspan='%d'> </td></tr>", columns); + cgit_print_tags(ctx.cfg.summary_tags); + if (ctx.cfg.summary_log > 0) { + htmlf("<tr class='nohover'><td colspan='%d'> </td></tr>", 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("</table>"); + 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("<div id='summary'><pre>"); + if (ref) + cgit_print_file(filename, ref, 1); + else + html_include_text(filename); + + html("</pre></div>"); + if (free_filename) + free(filename); + +done: + cgit_print_layout_end(); +} diff --git a/cgit/ui-summary.h b/cgit/ui-summary.h new file mode 100644 --- /dev/null +++ b/cgit/ui-summary.h @@ -0,0 +1,11 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-tag.c @@ -0,0 +1,124 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-tag.c: display a tag + * + * Copyright (C) 2006-2014 cgit Development Team <cgit@lists.zx2c4.com> + * + * 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("<div class='commit-subject'>"); + p = strchr(buf, '\n'); + if (p) + *p = '\0'; + html_txt(buf); + html("</div>"); + if (p) { + html("<div class='commit-msg'>"); + html_txt(++p); + html("</div>"); + } +} + +static void print_download_links(char *revname) +{ + html("<tr><th>download</th><td class='oid'>"); + cgit_print_snapshot_links(ctx.repo, revname, "<br/>"); + html("</td></tr>"); +} + +void cgit_print_tag(char *revname) +{ + struct strbuf fullref = STRBUF_INIT; + struct object_id oid; + struct object *obj; + + if (!revname) + revname = ctx.qry.head; + + strbuf_addf(&fullref, "refs/tags/%s", revname); + if (repo_get_oid(the_repository, fullref.buf, &oid)) { + cgit_print_error_page(404, "Not found", + "Bad tag reference: %s", revname); + 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", revname); + goto cleanup; + } + cgit_print_layout_start(); + html("<table class='commit-info'>\n"); + html("<tr><td>tag name</td><td>"); + html_txt(revname); + htmlf(" (%s)</td></tr>\n", oid_to_hex(&oid)); + if (info->tagger_date > 0) { + html("<tr><td>tag date</td><td>"); + html_txt(show_date(info->tagger_date, info->tagger_tz, + cgit_date_mode(DATE_ISO8601))); + html("</td></tr>\n"); + } + if (info->tagger) { + html("<tr><td>tagged by</td><td>"); + html_txt(info->tagger); + if (info->tagger_email && !ctx.cfg.noplainemail) { + html(" "); + html_txt(info->tagger_email); + } + html("</td></tr>\n"); + } + html("<tr><td>tagged object</td><td class='oid'>"); + cgit_object_link(tag->tagged); + html("</td></tr>\n"); + if (ctx.repo->snapshots) + print_download_links(revname); + html("</table>\n"); + print_tag_content(info->msg); + cgit_print_layout_end(); + cgit_free_taginfo(info); + } else { + cgit_print_layout_start(); + html("<table class='commit-info'>\n"); + html("<tr><td>tag name</td><td>"); + html_txt(revname); + html("</td></tr>\n"); + html("<tr><td>tagged object</td><td class='oid'>"); + cgit_object_link(obj); + html("</td></tr>\n"); + if (ctx.repo->snapshots) + print_download_links(revname); + html("</table>\n"); + cgit_print_layout_end(); + } + +cleanup: + strbuf_release(&fullref); +} diff --git a/cgit/ui-tag.h b/cgit/ui-tag.h new file mode 100644 --- /dev/null +++ b/cgit/ui-tag.h @@ -0,0 +1,10 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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 new file mode 100644 --- /dev/null +++ b/cgit/ui-tree.c @@ -0,0 +1,400 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * SPDX-License-Identifier: GPL-2.0-only + */ + +/* ui-tree.c: functions for tree output + * + * Copyright (C) 2006-2017 cgit Development Team <cgit@lists.zx2c4.com> + * + * 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 = "<a id='n%1$d' href='#n%1$d'>%1$d</a>\n"; + + html("<table summary='blob content' class='blob'>\n"); + + if (ctx.cfg.enable_tree_linenumbers) { + html("<tr><td class='linenumbers'><pre>"); + 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("</pre></td>\n"); + } + else { + html("<tr>\n"); + } + + html("<td class='lines'><pre><code>"); + html_txt(buf); + html("</code></pre></td></tr></table>\n"); +} + +#define ROWLEN 32 + +static void print_binary_buffer(char *buf, unsigned long size) +{ + unsigned long ofs, idx; + static char ascii[ROWLEN + 1]; + + html("<table summary='blob content' class='bin-blob'>\n"); + html("<tr><th>ofs</th><th>hex dump</th><th>ascii</th></tr>"); + for (ofs = 0; ofs < size; ofs += ROWLEN, buf += ROWLEN) { + htmlf("<tr><td class='right'>%04lx</td><td class='hex'>", ofs); + for (idx = 0; idx < ROWLEN && ofs + idx < size; idx++) + htmlf("%*s%02x", + idx == 16 ? 4 : 1, "", + buf[idx] & 0xff); + html(" </td><td class='hex'>"); + for (idx = 0; idx < ROWLEN && ofs + idx < size; idx++) + ascii[idx] = isgraph(buf[idx]) ? buf[idx] : '.'; + ascii[idx] = '\0'; + html_txt(ascii); + html("</td></tr>\n"); + } + html("</table>\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("<div class='error'>blob size (%ldKB) exceeds display size limit (%dKB).</div>", + 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("<tr><td colspan='3'>Bad object: %s %s</td></tr>", + name, + oid_to_hex(oid)); + goto cleanup; + } + } + + html("<tr><td class='ls-mode'>"); + cgit_print_filemode(mode); + html("</td><td>"); + 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("</td><td class='ls-size'>%li</td>", size); + + html("<td>"); + 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("</td></tr>\n"); + +cleanup: + free(name); + strbuf_release(&fullpath); + strbuf_release(&class); + return 0; +} + +static void ls_head(void) +{ + cgit_print_layout_start(); + html("<table summary='tree listing' class='list'>\n"); + html("<tr class='nohover'>"); + html("<th class='left'>Mode</th>"); + html("<th class='left'>Name</th>"); + html("<th class='right'>Size</th>"); + html("<th/>"); + html("</tr>\n"); +} + +static void ls_tail(void) +{ + html("</table>\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 new file mode 100644 --- /dev/null +++ b/cgit/ui-tree.h @@ -0,0 +1,10 @@ +/* SPDX-FileCopyrightText: cgit Development Team <cgit@lists.zx2c4.com> + * 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/charts/gilti/Chart.yaml b/charts/gilti/Chart.yaml index 9a4b918..34cd2c0 100644 --- a/charts/gilti/Chart.yaml +++ b/charts/gilti/Chart.yaml @@ -7,5 +7,5 @@ version: 0.1.0 appVersion: 0.1.0 name: gilti -home: https://git.dimidiumlabs.io/gilti -description: Boxed tiny Git server powered by Rust and OpenSSH +home: https://github.com/dimidiumlabs/gilti +description: Boxed tiny Git server powered by cgit and OpenSSH diff --git a/charts/gilti/README.md b/charts/gilti/README.md index 71f45ea..14a31f4 100644 --- a/charts/gilti/README.md +++ b/charts/gilti/README.md @@ -31,11 +31,9 @@ git push -u origin main ## Repository visibility -Repository browsing, smart HTTP cloning, archives, and LFS downloads are -anonymous and read-only. Consequently every repository available over SSH is -also publicly visible over HTTP. HTTP pushes and LFS uploads remain disabled by -default; setting `http.write: true` enables them without authentication and is -appropriate only behind a trusted access-control proxy. +cgit is anonymous and read-only and scans the complete repository directory. +Consequently every repository available over SSH is also publicly visible over +HTTP. Web cloning and snapshots are disabled in the default policy. ## Networking @@ -53,11 +51,11 @@ Example for the first Dimidium Labs installation: ssh: authorizedKeys: - ssh-ed25519 AAAA... operator@example -web: - clonePrefix: ssh://git@git.dimidiumlabs.io/ +cgit: + clonePrefix: ssh://git@vcs.dimidiumlabs.io/ httpRoute: enabled: true - hostnames: [git.dimidiumlabs.io] + hostnames: [vcs.dimidiumlabs.io] parentRefs: - name: public namespace: network @@ -76,9 +74,11 @@ are stored in the generated ConfigMap and must be supplied on every deployment. ## Security context -The Rust HTTP gateway and its native views run without privileges. OpenSSH +The Rust HTTP gateway and its cgit children run without privileges. OpenSSH intentionally keeps a root master so it can enter the `git` account (UID/GID 10000). The chart drops all capabilities and restores only `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETGID`, `SETUID`, and `SYS_CHROOT`. The root filesystem is read-only; state, `/run`, and `/tmp` are explicit writable -mounts. +mounts. CGI responses use an in-memory LRU cache bounded to 64 entries and +4 MiB; configure its lifetime with `cgit.cache` in seconds, or set it to zero +to disable caching. diff --git a/charts/gilti/templates/configmap.yaml b/charts/gilti/templates/configmap.yaml index aa6ab25..1376076 100644 --- a/charts/gilti/templates/configmap.yaml +++ b/charts/gilti/templates/configmap.yaml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2026 Nikolay Govorov # SPDX-License-Identifier: AGPL-3.0-or-later -{{- range $name, $value := dict "web.rootTitle" .Values.web.rootTitle "web.rootDescription" .Values.web.rootDescription "web.clonePrefix" .Values.web.clonePrefix }} +{{- range $name, $value := dict "cgit.rootTitle" .Values.cgit.rootTitle "cgit.rootDescription" .Values.cgit.rootDescription "cgit.clonePrefix" .Values.cgit.clonePrefix }} {{- if regexMatch "[\\r\\n]" $value }} {{- fail (printf "%s must be a single line" $name) }} {{- end }} @@ -18,6 +18,30 @@ metadata: labels: {{- include "gilti.labels" . | nindent 4 }} data: + cgitrc: | + root-title={{ .Values.cgit.rootTitle }} + root-desc={{ .Values.cgit.rootDescription }} + virtual-root=/ + css=/cgit.css + logo=/cgit.png + favicon=/favicon.ico + # Gilti consumes this setting before invoking cgit. + cache={{ .Values.cgit.cache }} + enable-http-clone=0 + enable-index-owner=1 + enable-index-links=1 + enable-commit-graph=1 + enable-log-filecount=1 + enable-log-linecount=1 + enable-tree-linenumbers=1 + snapshots= + remove-suffix=1 + readme=:README.md + readme=:README + {{- with .Values.cgit.clonePrefix }} + clone-prefix={{ . }} + {{- end }} + scan-path=/var/lib/gilti/git/repositories authorized_keys: | {{- range .Values.ssh.authorizedKeys }} {{ . }} diff --git a/charts/gilti/templates/deployment.yaml b/charts/gilti/templates/deployment.yaml index 08bd3c7..19eab1b 100644 --- a/charts/gilti/templates/deployment.yaml +++ b/charts/gilti/templates/deployment.yaml @@ -47,15 +47,6 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} - env: - - name: GILTI_ROOT_TITLE - value: {{ .Values.web.rootTitle | quote }} - - name: GILTI_ROOT_DESCRIPTION - value: {{ .Values.web.rootDescription | quote }} - - name: GILTI_CLONE_PREFIX - value: {{ .Values.web.clonePrefix | quote }} - - name: GILTI_HTTP_WRITE - value: {{ ternary "1" "0" .Values.http.write | quote }} ports: - name: http containerPort: 8080 @@ -65,18 +56,18 @@ spec: protocol: TCP startupProbe: httpGet: - path: /-/health + path: /healthz port: http failureThreshold: 60 periodSeconds: 5 readinessProbe: httpGet: - path: /-/health + path: /healthz port: http periodSeconds: 10 livenessProbe: httpGet: - path: /-/health + path: /healthz port: http periodSeconds: 20 resources: @@ -85,11 +76,15 @@ spec: - name: state mountPath: /var/lib/gilti - name: config + mountPath: /etc/cgitrc + subPath: cgitrc + readOnly: true + - name: config mountPath: /etc/gilti/authorized_keys subPath: authorized_keys readOnly: true - name: run - mountPath: /run/gilti + mountPath: /run - name: tmp mountPath: /tmp volumes: diff --git a/charts/gilti/values.schema.json b/charts/gilti/values.schema.json index 3fc6618..c15674a 100644 --- a/charts/gilti/values.schema.json +++ b/charts/gilti/values.schema.json @@ -22,18 +22,13 @@ } } }, - "web": { + "cgit": { "type": "object", "properties": { "rootTitle": { "type": "string", "pattern": "^[^\\r\\n]*$" }, "rootDescription": { "type": "string", "pattern": "^[^\\r\\n]*$" }, - "clonePrefix": { "type": "string", "pattern": "^[^\\r\\n]*$" } - } - }, - "http": { - "type": "object", - "properties": { - "write": { "type": "boolean" } + "clonePrefix": { "type": "string", "pattern": "^[^\\r\\n]*$" }, + "cache": { "type": "integer", "minimum": 0, "maximum": 3600 } } }, "persistence": { diff --git a/charts/gilti/values.yaml b/charts/gilti/values.yaml index 2924782..03c480b 100644 --- a/charts/gilti/values.yaml +++ b/charts/gilti/values.yaml @@ -17,14 +17,12 @@ ssh: # Every listed key has read/write access to every repository. authorizedKeys: [] -web: +cgit: rootTitle: Gilti rootDescription: A tiny Git server clonePrefix: "" - -http: - # Allow unauthenticated Git receive-pack and LFS uploads. - write: false + # In-memory CGI response lifetime in seconds; zero disables the cache. + cache: 5 persistence: enabled: true diff --git a/config/cgitrc b/config/cgitrc new file mode 100644 --- /dev/null +++ b/config/cgitrc @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +root-title=Gilti +root-desc=A tiny Git server +virtual-root=/ + +css=/cgit.css +logo=/cgit.png +favicon=/favicon.ico + +# In-memory CGI response lifetime in seconds; zero disables the cache. +cache=5 + +enable-http-clone=0 +enable-index-owner=1 +enable-index-links=1 +enable-commit-graph=1 +enable-log-filecount=1 +enable-log-linecount=1 +enable-tree-linenumbers=1 +snapshots= + +remove-suffix=1 +readme=:README.md +readme=:README +scan-path=/var/lib/gilti/git/repositories diff --git a/crates/gilti-ssh/Cargo.toml b/crates/gilti-ssh/Cargo.toml index 1fc17cd..bbebe68 100644 --- a/crates/gilti-ssh/Cargo.toml +++ b/crates/gilti-ssh/Cargo.toml @@ -12,7 +12,6 @@ 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 8296cd9..5752260 100644 --- a/crates/gilti/Cargo.toml +++ b/crates/gilti/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "gilti" -description = "Gilti daemon" +description = "Gilti web-server and daemon" version.workspace = true authors.workspace = true @@ -12,16 +12,12 @@ license.workspace = true publish.workspace = true homepage.workspace = true repository.workspace = true -rust-version.workspace = true [dependencies] axum.workspace = true -git2.workspace = true -libc.workspace = true percent-encoding.workspace = true tokio.workspace = true tower.workspace = true maud.workspace = true serde.workspace = true serde_json.workspace = true -sha2.workspace = true diff --git a/crates/gilti/src/cgi.rs b/crates/gilti/src/cgi.rs index ce5989a..ffcc081 100644 --- a/crates/gilti/src/cgi.rs +++ b/crates/gilti/src/cgi.rs @@ -3,8 +3,187 @@ //! HTTP-to-CGI Tower service. -#[derive(Clone, Default)] -pub struct Environment(pub Vec<(std::ffi::OsString, std::ffi::OsString)>); +const CACHE_MAX_ENTRIES: usize = 64; +const CACHE_MAX_BYTES: usize = 4 * 1024 * 1024; + +fn has_cache_directive(headers: &axum::http::HeaderMap, expected: &[&str]) -> bool { + headers + .get_all(axum::http::header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .any(|value| { + let name = value.split_once('=').map_or(value, |(name, _)| name); + expected + .iter() + .any(|expected| name.eq_ignore_ascii_case(expected)) + }) +} + +#[derive(Clone, Eq, Hash, PartialEq)] +struct CacheKey { + uri: String, + host: Option<Vec<u8>>, + cookie: Option<Vec<u8>>, + referer: Option<Vec<u8>>, +} + +impl CacheKey { + fn from_request(parts: &axum::http::request::Parts, body: &[u8]) -> Option<Self> { + if parts.method != axum::http::Method::GET + || !body.is_empty() + || parts + .headers + .contains_key(axum::http::header::AUTHORIZATION) + || parts.headers.contains_key(axum::http::header::RANGE) + || has_cache_directive(&parts.headers, &["no-cache", "no-store"]) + || parts + .headers + .get(axum::http::header::PRAGMA) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("no-cache")) + { + return None; + } + let header = |name| { + parts + .headers + .get(name) + .map(|value| value.as_bytes().to_vec()) + }; + Some(Self { + uri: parts.uri.to_string(), + host: header(axum::http::header::HOST), + cookie: header(axum::http::header::COOKIE), + referer: header(axum::http::header::REFERER), + }) + } +} + +#[derive(Clone)] +struct CachedResponse { + status: axum::http::StatusCode, + headers: axum::http::HeaderMap, + body: Vec<u8>, +} + +impl CachedResponse { + fn is_cacheable(&self) -> bool { + if self.status != axum::http::StatusCode::OK + || self.headers.contains_key(axum::http::header::SET_COOKIE) + || self.headers.contains_key(axum::http::header::VARY) + { + return false; + } + !has_cache_directive(&self.headers, &["no-cache", "no-store", "private"]) + } + + fn into_response(self) -> std::io::Result<axum::http::Response<axum::body::Body>> { + let mut response = axum::http::Response::builder() + .status(self.status) + .body(axum::body::Body::from(self.body)) + .map_err(std::io::Error::other)?; + *response.headers_mut() = self.headers; + Ok(response) + } +} + +struct CacheEntry { + response: CachedResponse, + expires: std::time::Instant, +} + +#[derive(Default)] +struct CacheState { + entries: std::collections::VecDeque<(CacheKey, CacheEntry)>, + bytes: usize, +} + +impl CacheState { + fn prune_expired(&mut self, now: std::time::Instant) { + let mut bytes = 0; + self.entries.retain(|(_, entry)| { + if entry.expires <= now { + false + } else { + bytes += entry.response.body.len(); + true + } + }); + self.bytes = bytes; + } +} + +#[derive(Clone)] +struct ResponseCache { + ttl: std::time::Duration, + state: std::sync::Arc<std::sync::Mutex<CacheState>>, +} + +impl ResponseCache { + fn new(ttl: std::time::Duration) -> Self { + Self { + ttl, + state: std::sync::Arc::new(std::sync::Mutex::new(CacheState::default())), + } + } + + fn get(&self, key: &CacheKey) -> Option<CachedResponse> { + self.get_at(key, std::time::Instant::now()) + } + + fn get_at(&self, key: &CacheKey, now: std::time::Instant) -> Option<CachedResponse> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.prune_expired(now); + let index = state + .entries + .iter() + .position(|(entry_key, _)| entry_key == key)?; + let entry = state.entries.remove(index)?; + let response = entry.1.response.clone(); + state.entries.push_back(entry); + Some(response) + } + + fn insert(&self, key: CacheKey, response: CachedResponse) { + self.insert_at(key, response, std::time::Instant::now()); + } + + fn insert_at(&self, key: CacheKey, response: CachedResponse, now: std::time::Instant) { + let size = response.body.len(); + if !response.is_cacheable() || size > CACHE_MAX_BYTES { + return; + } + + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.prune_expired(now); + let previous = state + .entries + .iter() + .position(|(entry_key, _)| entry_key == &key) + .and_then(|index| state.entries.remove(index)); + if let Some(previous) = previous { + state.bytes -= previous.1.response.body.len(); + } + while state.entries.len() >= CACHE_MAX_ENTRIES + || state.bytes.saturating_add(size) > CACHE_MAX_BYTES + { + let Some(previous) = state.entries.pop_front() else { + break; + }; + state.bytes -= previous.1.response.body.len(); + } + state.bytes += size; + state.entries.push_back(( + key, + CacheEntry { + response, + expires: now + self.ttl, + }, + )); + } +} #[derive(Clone, Copy)] pub struct RemoteAddr(pub std::net::SocketAddr); @@ -15,6 +194,7 @@ pub struct Cgi { current_dir: std::path::PathBuf, environment: Vec<(std::ffi::OsString, std::ffi::OsString)>, server_addr: std::net::SocketAddr, + cache: Option<ResponseCache>, } impl Cgi { @@ -28,9 +208,17 @@ impl Cgi { current_dir: current_dir.into(), environment: Vec::new(), server_addr, + cache: None, } } + pub fn cache(mut self, ttl: std::time::Duration) -> Self { + if !ttl.is_zero() { + self.cache = Some(ResponseCache::new(ttl)); + } + self + } + pub fn env( mut self, name: impl Into<std::ffi::OsString>, @@ -49,9 +237,18 @@ impl Cgi { |value| value.0, ); let (parts, body) = request.into_parts(); - let body = axum::body::to_bytes(body, 1024 * 1024 * 1024) + let body = axum::body::to_bytes(body, 1024 * 1024) .await .map_err(std::io::Error::other)?; + let cache_key = CacheKey::from_request(&parts, &body); + if let Some(response) = self + .cache + .as_ref() + .zip(cache_key.as_ref()) + .and_then(|(cache, key)| cache.get(key)) + { + return response.into_response(); + } let path = percent_encoding::percent_decode_str(parts.uri.path()).collect::<Vec<_>>(); if path.contains(&0) { return Err(invalid("request path contains a null byte")); @@ -116,10 +313,6 @@ impl Cgi { } } - if let Some(environment) = parts.extensions.get::<Environment>() { - command.envs(environment.0.iter().cloned()); - } - let mut child = command.spawn()?; let mut stdin = child .stdin @@ -140,12 +333,15 @@ impl Cgi { } let (status, headers, body) = parse_response(&output.stdout)?; - let mut response = axum::http::Response::builder() - .status(status) - .body(axum::body::Body::from(body.to_vec())) - .map_err(std::io::Error::other)?; - *response.headers_mut() = headers; - Ok(response) + let response = CachedResponse { + status, + headers, + body: body.to_vec(), + }; + if let (Some(cache), Some(key)) = (&self.cache, cache_key) { + cache.insert(key, response.clone()); + } + response.into_response() } } @@ -219,6 +415,67 @@ fn invalid(message: &'static str) -> std::io::Error { #[cfg(test)] mod tests { + fn cache_key(uri: impl Into<String>) -> super::CacheKey { + super::CacheKey { + uri: uri.into(), + host: None, + cookie: None, + referer: None, + } + } + + fn cached_response(body: impl Into<Vec<u8>>) -> super::CachedResponse { + super::CachedResponse { + status: axum::http::StatusCode::OK, + headers: axum::http::HeaderMap::new(), + body: body.into(), + } + } + + #[test] + fn memory_cache_expires_entries() { + let cache = super::ResponseCache::new(std::time::Duration::from_secs(5)); + let now = std::time::Instant::now(); + cache.insert_at(cache_key("/repo/"), cached_response(b"first".to_vec()), now); + assert_eq!( + cache.get_at(&cache_key("/repo/"), now).unwrap().body, + b"first" + ); + assert!( + cache + .get_at( + &cache_key("/repo/"), + now + std::time::Duration::from_secs(5) + ) + .is_none() + ); + } + + #[test] + fn memory_cache_is_bounded_and_honors_response_directives() { + let cache = super::ResponseCache::new(std::time::Duration::from_secs(5)); + let now = std::time::Instant::now(); + for index in 0..super::CACHE_MAX_ENTRIES { + cache.insert_at( + cache_key(format!("/{index}")), + cached_response(vec![index as u8]), + now, + ); + } + assert!(cache.get_at(&cache_key("/0"), now).is_some()); + cache.insert_at(cache_key("/new"), cached_response(b"new".to_vec()), now); + assert!(cache.get_at(&cache_key("/1"), now).is_none()); + assert!(cache.get_at(&cache_key("/0"), now).is_some()); + + let mut private = cached_response(b"private".to_vec()); + private.headers.insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("private"), + ); + cache.insert_at(cache_key("/private"), private, now); + assert!(cache.get_at(&cache_key("/private"), now).is_none()); + } + #[test] fn parses_cgi_response() { let (status, headers, body) = diff --git a/crates/gilti/src/lfs.rs b/crates/gilti/src/lfs.rs deleted file mode 100644 --- a/crates/gilti/src/lfs.rs +++ /dev/null @@ -1,389 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -const MAX_OBJECT_SIZE: usize = 1024 * 1024 * 1024; - -#[derive(serde::Deserialize)] -struct BatchRequest { - operation: String, - objects: Vec<ObjectRequest>, -} - -#[derive(serde::Deserialize)] -struct ObjectRequest { - oid: String, - size: u64, -} - -#[derive(serde::Deserialize)] -struct VerifyRequest { - oid: String, - size: u64, -} - -#[derive(serde::Serialize)] -struct BatchResponse { - transfer: &'static str, - objects: Vec<ObjectResponse>, -} - -#[derive(serde::Serialize)] -struct ObjectResponse { - oid: String, - size: u64, - #[serde(skip_serializing_if = "Option::is_none")] - actions: Option<Actions>, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option<ObjectError>, -} - -#[derive(serde::Serialize)] -struct Actions { - #[serde(skip_serializing_if = "Option::is_none")] - download: Option<Action>, - #[serde(skip_serializing_if = "Option::is_none")] - upload: Option<Action>, - #[serde(skip_serializing_if = "Option::is_none")] - verify: Option<Action>, -} - -#[derive(serde::Serialize)] -struct Action { - href: String, -} - -#[derive(serde::Serialize)] -struct ObjectError { - code: u16, - message: &'static str, -} - -pub async fn serve( - repositories: &std::path::Path, - repo: &str, - path: &str, - write_enabled: bool, - request: axum::extract::Request, -) -> axum::response::Response { - let Some(repository) = repository(repositories, repo) else { - return plain(axum::http::StatusCode::NOT_FOUND, "repository not found\n"); - }; - let objects = repository.join("lfs/objects"); - let method = request.method().clone(); - - if path == "objects/batch" && method == axum::http::Method::POST { - return batch(objects, write_enabled, request).await; - } - if method == axum::http::Method::POST - && 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) { - return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n"); - } - return match method { - axum::http::Method::GET | axum::http::Method::HEAD => { - download(&objects, oid, method == axum::http::Method::HEAD).await - } - axum::http::Method::PUT if write_enabled => upload(&objects, oid, request).await, - axum::http::Method::PUT => plain( - axum::http::StatusCode::FORBIDDEN, - "LFS uploads are disabled\n", - ), - _ => plain( - axum::http::StatusCode::METHOD_NOT_ALLOWED, - "method not allowed\n", - ), - }; - } - plain(axum::http::StatusCode::NOT_FOUND, "not found\n") -} - -async fn batch( - objects: std::path::PathBuf, - write_enabled: bool, - request: axum::extract::Request, -) -> axum::response::Response { - let base = request - .uri() - .path() - .strip_suffix("/objects/batch") - .unwrap_or(request.uri().path()); - let scheme = request - .headers() - .get("x-forwarded-proto") - .and_then(|value| value.to_str().ok()) - .filter(|value| matches!(*value, "http" | "https")) - .unwrap_or("http"); - let host = request - .headers() - .get(axum::http::header::HOST) - .and_then(|value| value.to_str().ok()) - .unwrap_or("localhost"); - let prefix = format!("{scheme}://{host}{base}/objects"); - let body = match axum::body::to_bytes(request.into_body(), 1024 * 1024).await { - Ok(body) => body, - Err(_) => { - return plain( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - "request too large\n", - ); - } - }; - let request: BatchRequest = match serde_json::from_slice(&body) { - Ok(request) => request, - Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"), - }; - if !matches!(request.operation.as_str(), "download" | "upload") { - return plain( - axum::http::StatusCode::BAD_REQUEST, - "invalid LFS operation\n", - ); - } - - let objects = request - .objects - .into_iter() - .map(|object| { - if !valid_oid(&object.oid) { - return error(object, 422, "invalid object id"); - } - let stored = object_path(&objects, &object.oid); - let present = std::fs::metadata(&stored) - .is_ok_and(|metadata| metadata.is_file() && metadata.len() == object.size); - match request.operation.as_str() { - "download" if present => response( - object, - Actions { - download: Some(Action { - href: format!( - "{prefix}/{}", - stored.file_name().unwrap().to_string_lossy() - ), - }), - upload: None, - verify: None, - }, - ), - "download" => error(object, 404, "object not found"), - "upload" if present => response( - object, - Actions { - download: Some(Action { - href: format!( - "{prefix}/{}", - stored.file_name().unwrap().to_string_lossy() - ), - }), - upload: None, - verify: None, - }, - ), - "upload" if !write_enabled => error(object, 403, "LFS uploads are disabled"), - "upload" => response( - object, - Actions { - download: None, - upload: Some(Action { - href: format!( - "{prefix}/{}", - stored.file_name().unwrap().to_string_lossy() - ), - }), - verify: Some(Action { - href: format!( - "{prefix}/{}/verify", - stored.file_name().unwrap().to_string_lossy() - ), - }), - }, - ), - _ => unreachable!(), - } - }) - .collect(); - json( - axum::http::StatusCode::OK, - &BatchResponse { - transfer: "basic", - objects, - }, - ) -} - -async fn download(objects: &std::path::Path, oid: &str, head: bool) -> axum::response::Response { - let path = object_path(objects, oid); - let metadata = match tokio::fs::metadata(&path).await { - Ok(metadata) if metadata.is_file() => metadata, - _ => return plain(axum::http::StatusCode::NOT_FOUND, "object not found\n"), - }; - let body = if head { - axum::body::Body::empty() - } else { - match tokio::fs::read(path).await { - Ok(body) => axum::body::Body::from(body), - Err(_) => { - return plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "read failed\n", - ); - } - } - }; - axum::http::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, "application/octet-stream") - .header(axum::http::header::CONTENT_LENGTH, metadata.len()) - .body(body) - .expect("valid LFS response") -} - -async fn upload( - objects: &std::path::Path, - oid: &str, - request: axum::extract::Request, -) -> axum::response::Response { - use sha2::Digest; - - let body = match axum::body::to_bytes(request.into_body(), MAX_OBJECT_SIZE).await { - Ok(body) => body, - Err(_) => { - return plain( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - "object too large\n", - ); - } - }; - let actual = format!("{:x}", sha2::Sha256::digest(&body)); - if actual != oid { - return plain( - axum::http::StatusCode::UNPROCESSABLE_ENTITY, - "object hash mismatch\n", - ); - } - let path = object_path(objects, oid); - let Some(parent) = path.parent() else { - return plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "invalid storage path\n", - ); - }; - if tokio::fs::create_dir_all(parent).await.is_err() { - return plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "storage failed\n", - ); - } - let temporary = parent.join(format!(".{oid}.{}.tmp", std::process::id())); - if tokio::fs::write(&temporary, &body).await.is_err() - || tokio::fs::rename(&temporary, &path).await.is_err() - { - let _ = tokio::fs::remove_file(temporary).await; - return plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "storage failed\n", - ); - } - plain(axum::http::StatusCode::OK, "") -} - -async fn verify( - objects: &std::path::Path, - oid: &str, - request: axum::extract::Request, -) -> axum::response::Response { - if !valid_oid(oid) { - return plain(axum::http::StatusCode::BAD_REQUEST, "invalid object id\n"); - } - let body = match axum::body::to_bytes(request.into_body(), 64 * 1024).await { - Ok(body) => body, - Err(_) => { - return plain( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - "request too large\n", - ); - } - }; - let request: VerifyRequest = match serde_json::from_slice(&body) { - Ok(request) => request, - Err(_) => return plain(axum::http::StatusCode::BAD_REQUEST, "invalid LFS request\n"), - }; - let valid = request.oid == oid - && std::fs::metadata(object_path(objects, oid)) - .is_ok_and(|metadata| metadata.is_file() && metadata.len() == request.size); - if valid { - plain(axum::http::StatusCode::OK, "") - } else { - plain(axum::http::StatusCode::NOT_FOUND, "object not found\n") - } -} - -fn repository(root: &std::path::Path, repo: &str) -> Option<std::path::PathBuf> { - let root = std::fs::canonicalize(root).ok()?; - let repository = std::fs::canonicalize(root.join(format!("{repo}.git"))).ok()?; - repository - .is_dir() - .then_some(repository) - .filter(|repository| repository.starts_with(root)) -} - -fn object_path(root: &std::path::Path, oid: &str) -> std::path::PathBuf { - root.join(&oid[..2]).join(&oid[2..4]).join(oid) -} - -fn valid_oid(oid: &str) -> bool { - oid.len() == 64 - && oid - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -fn response(object: ObjectRequest, actions: Actions) -> ObjectResponse { - ObjectResponse { - oid: object.oid, - size: object.size, - actions: Some(actions), - error: None, - } -} - -fn error(object: ObjectRequest, code: u16, message: &'static str) -> ObjectResponse { - ObjectResponse { - oid: object.oid, - size: object.size, - actions: None, - error: Some(ObjectError { code, message }), - } -} - -fn json<T: serde::Serialize>( - status: axum::http::StatusCode, - value: &T, -) -> axum::response::Response { - match serde_json::to_vec(value) { - Ok(body) => axum::http::Response::builder() - .status(status) - .header( - axum::http::header::CONTENT_TYPE, - "application/vnd.git-lfs+json", - ) - .body(axum::body::Body::from(body)) - .expect("valid LFS response"), - Err(_) => plain( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "encoding failed\n", - ), - } -} - -fn plain(status: axum::http::StatusCode, message: &'static str) -> axum::response::Response { - axum::http::Response::builder() - .status(status) - .header(axum::http::header::CONTENT_TYPE, "text/plain") - .body(axum::body::Body::from(message)) - .expect("valid LFS response") -} diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs index 70a0b3d..1e323b5 100644 --- a/crates/gilti/src/main.rs +++ b/crates/gilti/src/main.rs @@ -2,156 +2,134 @@ // SPDX-License-Identifier: AGPL-3.0-or-later mod cgi; -mod lfs; -mod models; -pub mod router; -mod views; +mod ui; const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080"; -const DEFAULT_ROOT_TITLE: &str = "Gilti"; -const DEFAULT_ROOT_DESCRIPTION: &str = "A tiny Git server"; -const GIT_HTTP_BACKENDS: &[&str] = &[ - "/usr/libexec/git-core/git-http-backend", - "/usr/lib/git-core/git-http-backend", -]; -const GIT: &str = "/usr/bin/git"; +const MAX_CACHE_SECONDS: u64 = 3600; + +const CGIT: &str = "/usr/local/bin/gilti-cgit"; +const CGIT_CONFIG: &str = "/etc/cgitrc"; const GIT_HOME: &str = "/var/lib/gilti/git"; -const REPOSITORIES: &str = "/var/lib/gilti/git/repositories"; -const ARCHIVE_COMPRESSORS: &[&str] = &[ - "/usr/bin/bzip2", - "/usr/bin/lzip", - "/usr/bin/xz", - "/usr/bin/zstd", -]; - -const ASSET_CSS: &str = "/usr/share/gilti/gilti.css"; -const ASSET_JS: &str = "/usr/share/gilti/gilti.js"; -const ASSET_LOGO: &str = "/usr/share/gilti/gilti.png"; -const ASSET_FAVICON: &str = "/usr/share/gilti/favicon.ico"; +const RUN_DIR: &str = "/run/gilti/http"; + +const CGIT_CSS: &str = "/usr/share/webapps/cgit/cgit.css"; +const CGIT_JS: &str = "/usr/share/webapps/cgit/cgit.js"; +const CGIT_LOGO: &str = "/usr/share/webapps/cgit/cgit.png"; +const CGIT_FAVICON: &str = "/usr/share/webapps/cgit/favicon.ico"; #[derive(Clone)] -struct RepositoryService { - git: cgi::Cgi, - views: views::shared::Context, - write_enabled: bool, +struct AppState { + cgit: cgi::Cgi, } -struct Config { - listen_addr: std::net::SocketAddr, - root_title: String, - root_description: String, - clone_prefix: String, - http_write: bool, +struct CgitConfig { + path: std::path::PathBuf, + cache: std::time::Duration, } -impl Config { - fn from_environment() -> std::io::Result<Self> { - let listen_addr = environment("GILTI_HTTP_ADDR", DEFAULT_LISTEN_ADDR)? - .parse() - .map_err(|_| invalid_config("GILTI_HTTP_ADDR must be a socket address"))?; - Ok(Self { - listen_addr, - root_title: environment("GILTI_ROOT_TITLE", DEFAULT_ROOT_TITLE)?, - root_description: environment("GILTI_ROOT_DESCRIPTION", DEFAULT_ROOT_DESCRIPTION)?, - clone_prefix: environment("GILTI_CLONE_PREFIX", "")?, - http_write: parse_bool("GILTI_HTTP_WRITE", &environment("GILTI_HTTP_WRITE", "0")?)?, - }) +impl CgitConfig { + fn create() -> std::io::Result<Self> { + let contents = std::fs::read_to_string(CGIT_CONFIG)?; + let (contents, cache) = prepare_cgit_config(&contents)?; + let path = std::path::PathBuf::from(format!("{RUN_DIR}/cgitrc.{}", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(&path)?; + let config = Self { path, cache }; + std::io::Write::write_all(&mut file, contents.as_bytes())?; + Ok(config) } } -fn environment(name: &str, default: &str) -> std::io::Result<String> { - match std::env::var(name) { - Ok(value) => Ok(value), - Err(std::env::VarError::NotPresent) => Ok(default.to_owned()), - Err(std::env::VarError::NotUnicode(_)) => { - Err(invalid_config(format!("{name} must be valid UTF-8"))) +fn prepare_cgit_config(contents: &str) -> std::io::Result<(String, std::time::Duration)> { + let mut output = String::with_capacity(contents.len()); + let mut cache = None; + + for line in contents.split_inclusive('\n') { + let value = line + .trim_end_matches(['\r', '\n']) + .trim_start() + .strip_prefix("cache="); + let Some(value) = value else { + output.push_str(line); + continue; + }; + if cache.is_some() { + return Err(invalid_config("cache is configured more than once")); } + let seconds = value + .trim() + .parse::<u64>() + .map_err(|_| invalid_config("cache must be an integer number of seconds"))?; + if seconds > MAX_CACHE_SECONDS { + return Err(invalid_config(format!( + "cache must not exceed {MAX_CACHE_SECONDS} seconds" + ))); + } + cache = Some(std::time::Duration::from_secs(seconds)); } -} -fn parse_bool(name: &str, value: &str) -> std::io::Result<bool> { - match value { - "0" => Ok(false), - "1" => Ok(true), - _ => Err(invalid_config(format!("{name} must be 0 or 1"))), - } + Ok((output, cache.unwrap_or_default())) } fn invalid_config(message: impl Into<String>) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::InvalidData, message.into()) } +impl Drop for CgitConfig { + fn drop(&mut self) { + match std::fs::remove_file(&self.path) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + eprintln!("gilti: cannot remove {}: {error}", self.path.display()); + } + _ => {} + } + } +} + #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { - let config = Config::from_environment()?; - let listen_addr = config.listen_addr; - let git_http_backend = git_http_backend()?; - check_files(&git_http_backend)?; + let listen_addr = std::env::var("GILTI_HTTP_ADDR") + .unwrap_or_else(|_| DEFAULT_LISTEN_ADDR.to_owned()) + .parse::<std::net::SocketAddr>()?; + check_files()?; + let cgit_config = CgitConfig::create()?; if std::env::args().nth(1).as_deref() == Some("--check") { 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 git = cgi::Cgi::new(git_http_backend, GIT_HOME, config.listen_addr) - .env("GIT_PROJECT_ROOT", REPOSITORIES) - .env("GIT_HTTP_EXPORT_ALL", "1") - .env("HOME", GIT_HOME) - .env("USER", "git") - .env("LOGNAME", "git") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("PATH", "/usr/bin:/bin"); - let repositories = RepositoryService { - git, - views, - write_enabled: config.http_write, + let state = AppState { + cgit: cgi::Cgi::new(CGIT, GIT_HOME, listen_addr) + .cache(cgit_config.cache) + .env("CGIT_CONFIG", cgit_config.path.as_os_str()) + .env("HOME", GIT_HOME) + .env("PATH", "/usr/bin:/bin"), }; let app = axum::Router::new() .route( - "/-/health", - axum::routing::get(async || { - response( - axum::http::StatusCode::OK, - "application/json", - b"{\"status\":\"ok\"}\n".to_vec(), - ) - }), + "/healthz", + axum::routing::get(async || plain_response(axum::http::StatusCode::OK, "ok\n")), ) .route( - "/-/about", - axum::routing::get(async || { - plain_response(axum::http::StatusCode::OK, "Gilti Git server\n") - }), + "/cgit.css", + axum::routing::get(async || static_file(CGIT_CSS, "text/css")), ) .route( - "/-/terms", - axum::routing::get(async || { - plain_response(axum::http::StatusCode::OK, "No additional terms of use.\n") - }), + "/cgit.js", + axum::routing::get(async || static_file(CGIT_JS, "text/javascript")), ) .route( - "/-/assets/gilti.css", - axum::routing::get(async || static_file(ASSET_CSS, "text/css")), + "/cgit.png", + axum::routing::get(async || static_file(CGIT_LOGO, "image/png")), ) .route( - "/-/assets/gilti.js", - axum::routing::get(async || static_file(ASSET_JS, "text/javascript")), + "/favicon.ico", + axum::routing::get(async || static_file(CGIT_FAVICON, "image/x-icon")), ) - .route( - "/-/assets/gilti.png", - axum::routing::get(async || static_file(ASSET_LOGO, "image/png")), - ) - .route( - "/-/assets/favicon.ico", - axum::routing::get(async || static_file(ASSET_FAVICON, "image/x-icon")), - ) - .fallback_service(repositories); + .fallback(proxy_to_cgit) + .with_state(state); let listener = tokio::net::TcpListener::bind(listen_addr).await?; eprintln!("gilti: listening on {listen_addr}"); @@ -166,32 +144,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { Ok(()) } -fn git_http_backend() -> std::io::Result<std::path::PathBuf> { - GIT_HTTP_BACKENDS - .iter() - .map(std::path::PathBuf::from) - .find(|path| path.is_file()) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "git-http-backend not found") - }) -} - -fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> { - for path in std::iter::once(git_http_backend) - .chain(std::iter::once(std::path::Path::new(GIT))) - .chain(ARCHIVE_COMPRESSORS.iter().map(std::path::Path::new)) +fn check_files() -> std::io::Result<()> { + let metadata = std::fs::metadata(CGIT)?; + if !metadata.is_file() + || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 { - let metadata = std::fs::metadata(path)?; - if !metadata.is_file() - || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 - { - return Err(std::io::Error::other(format!( - "{} is not executable", - path.display() - ))); - } + return Err(std::io::Error::other(format!("{CGIT} is not executable"))); } - for path in [ASSET_CSS, ASSET_JS, ASSET_LOGO, ASSET_FAVICON] { + for path in [CGIT_CONFIG, CGIT_CSS, CGIT_JS, CGIT_LOGO, CGIT_FAVICON] { if !std::fs::metadata(path)?.is_file() { return Err(std::io::Error::other(format!( "{path} is not a regular file" @@ -222,376 +182,64 @@ fn static_file(path: &str, content_type: &'static str) -> axum::response::Respon } } -impl tower::Service<axum::extract::Request> for RepositoryService { - type Response = axum::response::Response; - type Error = std::convert::Infallible; - type Future = std::pin::Pin< - Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>, - >; - - fn poll_ready( - &mut self, - _context: &mut std::task::Context<'_>, - ) -> std::task::Poll<Result<(), Self::Error>> { - std::task::Poll::Ready(Ok(())) - } - - fn call(&mut self, request: axum::extract::Request) -> Self::Future { - let service = self.clone(); - Box::pin(async move { Ok(service.execute(request).await) }) - } -} - -impl RepositoryService { - async fn execute(&self, mut request: axum::extract::Request) -> axum::response::Response { - let route = match router::parse(request.uri().path()) { - Ok(route) => route, - Err(_) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), - }; - if let Some(axum::extract::ConnectInfo(remote)) = request - .extensions() - .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() - .copied() - { - request.extensions_mut().insert(cgi::RemoteAddr(remote)); - } - let browser_view = matches!( - &route, - router::Route::Repositories - | router::Route::Overview(_) - | router::Route::About(_) - | router::Route::Stats(_) - | router::Route::Object(_) - | router::Route::Refs(_) - | router::Route::Revision(_) - | router::Route::Tree(_) - | router::Route::Blame(_) - | router::Route::Archive(_) - | router::Route::ArchiveSignature(_) - | router::Route::Diff(_) - | router::Route::Patch(_) - | router::Route::Log(_) - | router::Route::AtomFeed(_) - ); - let query = if browser_view { - 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::Stats(route) => { - let query = match views::stats::Query::from_request( - query.as_ref().expect("query parsed"), - ) { - Ok(query) => query, - Err(views::stats::QueryError::BadRequest) => { - return views::bad_request("bad query\n"); - } - Err(views::stats::QueryError::NotFound) => { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - } - }; - views::stats::serve(&self.views, route, query, 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::Archive(route) => { - let Some(format) = views::archive::Format::parse(format) else { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - }; - views::archive::serve(&self.views, route, format, 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::Revision(route) => { - let query = - match views::diff::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::revision::serve(&self.views, route, query, request.method().clone()).await - } - router::Route::Diff(route) => { - let query = - match views::diff::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::diff::serve( - &self.views, - route, - query, - format == Some("raw"), - request.method().clone(), - ) - .await - } - router::Route::Patch(route) => { - views::patch::serve(&self.views, route, request.method().clone()).await - } - router::Route::Log(route) => { - let query = - match views::log::Query::from_request(query.as_ref().expect("query parsed")) { - Ok(query) => query, - Err(()) => return views::bad_request("bad query\n"), - }; - views::log::serve(&self.views, route, query, request.method().clone()).await - } - router::Route::AtomFeed(route) => { - views::atom::serve( - &self.views, - route, - request.headers().get(axum::http::header::HOST), - request.method().clone(), - ) - .await - } - router::Route::GitLfs(route) => { - lfs::serve( - std::path::Path::new(REPOSITORIES), - &route.repo, - &route.params, - self.write_enabled, - request, - ) - .await - } - router::Route::GitInfoRefs(route) => { - self.git(request, route.repo, "info/refs", self.write_enabled) - .await - } - router::Route::GitUploadPack(route) => { - self.git(request, route.repo, "git-upload-pack", false) - .await - } - router::Route::GitReceivePack(route) if self.write_enabled => { - self.git(request, route.repo, "git-receive-pack", true) - .await - } - router::Route::GitReceivePack(_) => { - plain_response(axum::http::StatusCode::FORBIDDEN, "HTTP push is disabled\n") - } - router::Route::GitHead(route) => self.git(request, route.repo, "HEAD", false).await, - router::Route::GitObjects(route) => { - self.git( - request, - route.repo, - &format!("objects/{}", route.params), - false, - ) - .await - } - } +async fn proxy_to_cgit( + axum::extract::State(state): axum::extract::State<AppState>, + axum::extract::ConnectInfo(remote): axum::extract::ConnectInfo<std::net::SocketAddr>, + mut request: axum::extract::Request, +) -> axum::response::Response { + if request.method() != axum::http::Method::GET && request.method() != axum::http::Method::HEAD { + return plain_response(axum::http::StatusCode::FORBIDDEN, "forbidden\n"); } + request.extensions_mut().insert(cgi::RemoteAddr(remote)); - async fn git( - &self, - mut request: axum::extract::Request, - repo: String, - endpoint: &str, - authenticated: bool, - ) -> axum::response::Response { - if !safe_repository(&repo) { - return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); - } - let mut environment = vec![ - ("PATH_INFO".into(), format!("/{repo}.git/{endpoint}").into()), - ("GIT_PROJECT_ROOT".into(), REPOSITORIES.into()), - ]; - if authenticated { - environment.push(("REMOTE_USER".into(), "gilti".into())); - } - request - .extensions_mut() - .insert(cgi::Environment(environment)); - match tower::ServiceExt::oneshot(self.git.clone(), request).await { - Ok(response) => response, - Err(error) => internal_error("git-http-backend", error), + let method = request.method().clone(); + match tower::ServiceExt::oneshot(state.cgit.clone(), request).await { + Ok(response) => render_private_page(method, response).await, + Err(error) => { + eprintln!("gilti: cgit request failed: {error}"); + plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n") } } } -#[derive(Default)] -struct RequestQuery { - format: Option<String>, - 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()) +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; } -} -fn request_query(query: Option<&str>) -> Result<RequestQuery, ()> { - let mut result = RequestQuery::default(); - for pair in query - .unwrap_or("") - .split('&') - .filter(|pair| !pair.is_empty()) - { - let (name, value) = pair.split_once('=').unwrap_or((pair, "")); - let name = decode_query(name)?; - let value = decode_query(value)?; - if name == "format" { - if result.format.replace(value).is_some() { - return Err(()); - } - continue; - } - let environment = match name.as_str() { - "q" => "GILTI_QUERY_SEARCH", - "qt" => "GILTI_QUERY_GREP", - "ofs" => "GILTI_QUERY_OFFSET", - "s" => "GILTI_QUERY_SORT", - "showmsg" => "GILTI_QUERY_SHOWMSG", - "period" => "GILTI_QUERY_PERIOD", - "dt" => "GILTI_QUERY_DIFFTYPE", - "context" => "GILTI_QUERY_CONTEXT", - "ignorews" => "GILTI_QUERY_IGNOREWS", - "follow" => "GILTI_QUERY_FOLLOW", - "view" => "GILTI_QUERY_VIEW", - _ => return Err(()), - }; - if result - .environment - .iter() - .any(|(existing, _)| existing == std::ffi::OsStr::new(environment)) - { - return Err(()); - } - result.environment.push((environment.into(), value.into())); + 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()); } - Ok(result) -} - -fn decode_query(value: &str) -> Result<String, ()> { - let value = value.replace('+', " "); - let value = percent_encoding::percent_decode_str(&value) - .decode_utf8() - .map_err(|_| ())? - .into_owned(); - (!value.contains('\0')).then_some(value).ok_or(()) -} - -fn valid_format(route: &router::Route, format: Option<&str>) -> bool { - let Some(format) = format else { - return true; + let body = match axum::body::to_bytes(body, usize::MAX).await { + Ok(body) => body, + Err(error) => { + eprintln!("gilti: cannot read private page response: {error}"); + return plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n"); + } }; - match route { - router::Route::Tree(_) | router::Route::Diff(_) => matches!(format, "html" | "raw"), - router::Route::Object(_) => format == "raw", - router::Route::Patch(_) => matches!(format, "patch" | "raw"), - router::Route::Archive(_) | router::Route::ArchiveSignature(_) => matches!( - format, - "tar" | "tar.gz" | "tar.bz2" | "tar.lz" | "tar.xz" | "tar.zst" | "zip" + match ui::render(&body) { + Ok(markup) => axum::http::response::Response::from_parts( + parts, + axum::body::Body::from(markup.into_string()), ), - router::Route::AtomFeed(_) => format == "atom", - _ => format == "html", - } -} - -fn safe_repository(repo: &str) -> bool { - !repo.is_empty() - && !repo.starts_with('/') - && !repo.chars().any(char::is_control) - && !repo.contains('\\') - && repo - .split('/') - .all(|component| !component.is_empty() && !matches!(component, "." | "..")) -} - -fn redirect(repo: &str) -> axum::response::Response { - axum::http::Response::builder() - .status(axum::http::StatusCode::PERMANENT_REDIRECT) - .header( - axum::http::header::LOCATION, - format!("/{}", encode_path(repo)), - ) - .body(axum::body::Body::empty()) - .expect("valid redirect") -} - -fn encode_path(value: &str) -> String { - let mut encoded = String::new(); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || byte == b'/' || byte == b'_' { - encoded.push(char::from(byte)); - } else { - use std::fmt::Write; - write!(encoded, "%{byte:02X}").expect("writing to String cannot fail"); + Err(error) => { + eprintln!("gilti: invalid private page response: {error}"); + plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n") } } - encoded -} - -fn internal_error(context: &str, error: std::io::Error) -> axum::response::Response { - eprintln!("gilti: {context} request failed: {error}"); - plain_response( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "internal server error\n", - ) } fn response( @@ -616,9 +264,93 @@ fn plain_response( #[cfg(test)] mod tests { #[test] - fn structural_query_parameters_are_rejected() { - assert!(super::request_query(Some("id=HEAD")).is_err()); - assert!(super::request_query(Some("path=README.md")).is_err()); - assert!(super::request_query(Some("format=raw&format=html")).is_err()); + fn extracts_cache_from_cgit_config() { + let (contents, cache) = + super::prepare_cgit_config("# comment\r\ncache=5\r\nroot-title=Gilti\r\n").unwrap(); + assert_eq!(contents, "# comment\r\nroot-title=Gilti\r\n"); + assert_eq!(cache, std::time::Duration::from_secs(5)); + } + + #[test] + fn cache_is_optional_and_bounded() { + let (_, cache) = super::prepare_cgit_config("root-title=Gilti\n").unwrap(); + assert!(cache.is_zero()); + assert!(super::prepare_cgit_config("cache=1\ncache=2\n").is_err()); + assert!(super::prepare_cgit_config("cache=3601\n").is_err()); + assert!(super::prepare_cgit_config("cache=forever\n").is_err()); + } + + #[tokio::test] + async fn private_pages_become_html_and_head_keeps_headers() { + let model = br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[]}"#; + let response = axum::http::Response::builder() + .status(axum::http::StatusCode::OK) + .header( + axum::http::header::CONTENT_TYPE, + super::ui::PRIVATE_CONTENT_TYPE, + ) + .header(axum::http::header::CACHE_CONTROL, "max-age=60") + .header(axum::http::header::CONTENT_LENGTH, model.len()) + .body(axum::body::Body::from(model.as_slice())) + .unwrap(); + let response = super::render_private_page(axum::http::Method::GET, response).await; + assert_eq!( + response.headers()[axum::http::header::CONTENT_TYPE], + "text/html; charset=UTF-8" + ); + assert_eq!( + response.headers()[axum::http::header::CACHE_CONTROL], + "max-age=60" + ); + assert!( + !response + .headers() + .contains_key(axum::http::header::CONTENT_LENGTH) + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + std::str::from_utf8(&body) + .unwrap() + .contains("repository list") + ); + + let response = axum::http::Response::builder() + .header( + axum::http::header::CONTENT_TYPE, + super::ui::PRIVATE_CONTENT_TYPE, + ) + .header(axum::http::header::CACHE_CONTROL, "max-age=60") + .body(axum::body::Body::empty()) + .unwrap(); + let response = super::render_private_page(axum::http::Method::HEAD, response).await; + assert_eq!( + response.headers()[axum::http::header::CONTENT_TYPE], + "text/html; charset=UTF-8" + ); + assert!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn malformed_private_page_fails_closed() { + let response = axum::http::Response::builder() + .header( + axum::http::header::CONTENT_TYPE, + super::ui::PRIVATE_CONTENT_TYPE, + ) + .body(axum::body::Body::from("not json")) + .unwrap(); + assert_eq!( + super::render_private_page(axum::http::Method::GET, response) + .await + .status(), + axum::http::StatusCode::BAD_GATEWAY + ); } } diff --git a/crates/gilti/src/models/about.rs b/crates/gilti/src/models/about.rs deleted file mode 100644 --- a/crates/gilti/src/models/about.rs +++ /dev/null @@ -1,31 +0,0 @@ -// 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<u8>, -} - -impl About { - pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> { - 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.rs b/crates/gilti/src/models/archive.rs deleted file mode 100644 --- a/crates/gilti/src/models/archive.rs +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::{Path, PathBuf}; - -pub struct Archive { - pub repository_path: PathBuf, - pub oid: String, - pub prefix: String, -} - -impl Archive { - pub fn load( - root: &Path, - name: &str, - revision: &crate::router::Revision, - path: Option<&str>, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let commit = super::revision::commit(&repository, revision)?; - if let Some(path) = path { - commit - .tree() - .and_then(|tree| tree.get_path(Path::new(path))) - .map_err(|_| super::Error::NotFound)?; - } - let repository_path = repository.path().to_owned(); - let prefix = name - .rsplit('/') - .next() - .unwrap_or(name) - .strip_suffix(".git") - .unwrap_or_else(|| name.rsplit('/').next().unwrap_or(name)) - .to_owned(); - Ok(Self { - repository_path, - oid: commit.id().to_string(), - prefix, - }) - } -} diff --git a/crates/gilti/src/models/archive_signature.rs b/crates/gilti/src/models/archive_signature.rs deleted file mode 100644 --- a/crates/gilti/src/models/archive_signature.rs +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct ArchiveSignature { - pub bytes: Vec<u8>, - pub oid: String, - pub filename: String, -} - -impl ArchiveSignature { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - format: &str, - ) -> Result<Self, super::Error> { - 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 deleted file mode 100644 --- a/crates/gilti/src/models/blame.rs +++ /dev/null @@ -1,114 +0,0 @@ -// 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<u8>, - pub binary: bool, - pub hunks: Vec<Hunk>, -} - -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<String>, -} - -impl Blame { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - path: String, - ) -> Result<Self, super::Error> { - 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/commit.rs b/crates/gilti/src/models/commit.rs deleted file mode 100644 --- a/crates/gilti/src/models/commit.rs +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct Commit { - pub repository: super::repository::Info, - pub revision: String, - pub oid: String, - pub tree: String, - pub author: Identity, - pub committer: Identity, - pub subject: String, - pub message: String, - pub parents: Vec<String>, - pub decorations: Vec<Decoration>, - pub notes: Option<String>, - pub diff: Option<super::diff::Diff>, -} - -pub struct Identity { - pub name: String, - pub email: String, - pub timestamp: i64, - pub offset_minutes: i32, -} - -pub struct Decoration { - pub label: String, - pub reference: Option<String>, -} - -impl Commit { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - diff_options: super::diff::Options, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let commit = super::revision::commit(&repository, &revision)?; - let oid = commit.id(); - let parents = commit - .parent_ids() - .map(|id| id.to_string()) - .collect::<Vec<_>>(); - let author = identity(commit.author()); - let committer = identity(commit.committer()); - let message = String::from_utf8_lossy(commit.message_bytes()); - let subject = - String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default()).into_owned(); - let body = message - .split_once('\n') - .map_or("", |(_, body)| body.trim_start_matches('\n')) - .to_owned(); - let notes = repository - .find_note(None, oid) - .ok() - .map(|note| String::from_utf8_lossy(note.message_bytes()).into_owned()); - let refs = super::refs::Refs::load(root, name)?; - let mut decorations = Vec::new(); - if repository - .head() - .ok() - .and_then(|head| head.peel_to_commit().ok()) - .is_some_and(|head| head.id() == oid) - { - decorations.push(Decoration { - label: "HEAD".to_owned(), - reference: None, - }); - } - for branch in refs.branches { - if repository - .find_reference(&branch.reference) - .ok() - .and_then(|reference| reference.peel_to_commit().ok()) - .is_some_and(|target| target.id() == oid) - { - decorations.push(Decoration { - label: branch.name, - reference: Some(branch.reference), - }); - } - } - for tag in refs.tags { - if repository - .find_reference(&tag.reference) - .ok() - .and_then(|reference| reference.peel_to_commit().ok()) - .is_some_and(|target| target.id() == oid) - { - decorations.push(Decoration { - label: tag.name, - reference: Some(tag.reference), - }); - } - } - let old_revision = (parents.len() < 3) - .then(|| parents.first().cloned()) - .flatten() - .map(crate::router::Revision::Commit); - let show_diff = parents.len() < 3; - let revision_name = super::revision::selector(&revision); - let tree = commit.tree_id().to_string(); - drop(commit); - drop(repository); - let diff = show_diff - .then(|| { - super::diff::Diff::load( - root, - name, - old_revision, - revision.clone(), - None, - diff_options, - ) - }) - .transpose()?; - Ok(Self { - repository: info, - revision: revision_name, - oid: oid.to_string(), - tree, - author, - committer, - subject, - message: body, - parents, - decorations, - notes, - diff, - }) - } -} - -fn identity(signature: git2::Signature<'_>) -> Identity { - Identity { - name: signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()), - email: signature - .email() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()), - timestamp: signature.when().seconds(), - offset_minutes: signature.when().offset_minutes(), - } -} diff --git a/crates/gilti/src/models/diff.rs b/crates/gilti/src/models/diff.rs deleted file mode 100644 --- a/crates/gilti/src/models/diff.rs +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -#[derive(Clone, Copy)] -pub struct Options { - pub context: u32, - pub ignore_whitespace: bool, -} - -pub struct Diff { - pub repository: super::repository::Info, - pub old_revision: Option<String>, - pub new_revision: String, - pub old_oid: Option<String>, - pub new_oid: String, - pub files: Vec<File>, - pub additions: usize, - pub deletions: usize, -} - -pub struct File { - pub status: git2::Delta, - pub old_path: String, - pub new_path: String, - pub old_oid: Option<String>, - pub new_oid: Option<String>, - pub old_mode: u32, - pub new_mode: u32, - pub old_size: u64, - pub new_size: u64, - pub binary: bool, - pub additions: usize, - pub deletions: usize, - pub hunks: Vec<Hunk>, -} - -pub struct Hunk { - pub header: String, - pub lines: Vec<Line>, -} - -pub struct Line { - pub origin: char, - pub content: String, - pub old_line: Option<u32>, - pub new_line: Option<u32>, -} - -impl Diff { - pub fn load( - root: &Path, - name: &str, - old_revision: Option<crate::router::Revision>, - new_revision: crate::router::Revision, - path: Option<String>, - options: Options, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let new_commit = super::revision::commit(&repository, &new_revision)?; - let old_commit = old_revision - .as_ref() - .map(|revision| super::revision::commit(&repository, revision)) - .transpose()?; - let old_tree = old_commit - .as_ref() - .map(git2::Commit::tree) - .transpose() - .map_err(super::Error::from_git)?; - let new_tree = new_commit.tree().map_err(super::Error::from_git)?; - let mut diff_options = git2::DiffOptions::new(); - diff_options.context_lines(options.context); - if options.ignore_whitespace { - diff_options.ignore_whitespace(true); - } - if let Some(path) = &path { - diff_options.pathspec(path).disable_pathspec_match(true); - } - let mut diff = repository - .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut diff_options)) - .map_err(super::Error::from_git)?; - diff.find_similar(None).map_err(super::Error::from_git)?; - let stats = diff.stats().map_err(super::Error::from_git)?; - let mut files = Vec::with_capacity(diff.deltas().len()); - for (index, delta) in diff.deltas().enumerate() { - let old_file = delta.old_file(); - let new_file = delta.new_file(); - let patch = git2::Patch::from_diff(&diff, index).map_err(super::Error::from_git)?; - let (additions, deletions, hunks) = if let Some(patch) = patch { - let (_, additions, deletions) = - patch.line_stats().map_err(super::Error::from_git)?; - let mut hunks = Vec::with_capacity(patch.num_hunks()); - for hunk_index in 0..patch.num_hunks() { - let (hunk, lines) = patch.hunk(hunk_index).map_err(super::Error::from_git)?; - let mut result = Hunk { - header: String::from_utf8_lossy(hunk.header()) - .trim_end_matches('\n') - .to_owned(), - lines: Vec::with_capacity(lines), - }; - for line_index in 0..lines { - let line = patch - .line_in_hunk(hunk_index, line_index) - .map_err(super::Error::from_git)?; - result.lines.push(Line { - origin: line.origin(), - content: String::from_utf8_lossy(line.content()) - .trim_end_matches('\n') - .to_owned(), - old_line: line.old_lineno(), - new_line: line.new_lineno(), - }); - } - hunks.push(result); - } - (additions, deletions, hunks) - } else { - (0, 0, Vec::new()) - }; - files.push(File { - status: delta.status(), - old_path: path_string(old_file.path_bytes()), - new_path: path_string(new_file.path_bytes()), - old_oid: oid(old_file.id()), - new_oid: oid(new_file.id()), - old_mode: old_file.mode().into(), - new_mode: new_file.mode().into(), - old_size: old_file.size(), - new_size: new_file.size(), - binary: old_file.is_binary() || new_file.is_binary(), - additions, - deletions, - hunks, - }); - } - Ok(Self { - repository: info, - old_revision: old_revision.as_ref().map(super::revision::selector), - new_revision: super::revision::selector(&new_revision), - old_oid: old_commit.map(|commit| commit.id().to_string()), - new_oid: new_commit.id().to_string(), - files, - additions: stats.insertions(), - deletions: stats.deletions(), - }) - } -} - -fn path_string(path: Option<&[u8]>) -> String { - path.map_or_else(String::new, |path| { - String::from_utf8_lossy(path).into_owned() - }) -} - -fn oid(oid: git2::Oid) -> Option<String> { - (!oid.is_zero()).then(|| oid.to_string()) -} diff --git a/crates/gilti/src/models/history.rs b/crates/gilti/src/models/history.rs deleted file mode 100644 --- a/crates/gilti/src/models/history.rs +++ /dev/null @@ -1,556 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::{collections::BTreeMap, path::Path, process::Command}; - -pub const LOG_PAGE_SIZE: usize = 50; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Search { - None, - Grep(String), - Author(String), - Committer(String), - Range(String), -} - -pub struct Options { - pub path: Option<String>, - pub follow: bool, - pub search: Search, - pub offset: usize, - pub limit: usize, - pub graph: bool, - pub ignore_whitespace: bool, - pub include_statistics: bool, -} - -pub struct History { - pub repository: super::repository::Info, - pub revision: String, - pub entries: Vec<Entry>, - pub has_next: bool, - pub graph: bool, -} - -pub struct Entry { - pub oid: String, - pub subject: String, - pub body: String, - pub author: super::commit::Identity, - pub committer: super::commit::Identity, - pub decorations: Vec<super::commit::Decoration>, - pub notes: Option<String>, - pub files: usize, - pub additions: usize, - pub deletions: usize, - pub graph: String, - pub graph_continuations: Vec<String>, - pub path: Option<String>, -} - -impl History { - pub fn load( - root: &Path, - name: &str, - revision: crate::router::Revision, - options: Options, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let resolved = super::revision::commit(&repository, &revision)?; - let revision_name = super::revision::selector(&revision); - if let Search::Range(range) = &options.search { - for selector in range - .split_ascii_whitespace() - .flat_map(|selector| selector.split("..")) - { - let selector = if selector == "HEAD" { - crate::router::Revision::Head - } else if selector.starts_with("refs/") { - crate::router::Revision::Ref(selector.to_owned()) - } else { - crate::router::Revision::Commit(selector.to_owned()) - }; - super::revision::commit(&repository, &selector)?; - } - } - let repository_path = super::repository::path(root, name)?; - let follow = options.follow && options.path.is_some(); - let oids = select( - &repository_path, - resolved.id().to_string(), - &options, - follow, - )?; - let has_next = oids.len() > options.limit; - let selected = &oids[..oids.len().min(options.limit)]; - let git_statistics = if !options.include_statistics { - Some(BTreeMap::new()) - } else if follow { - None - } else { - Some(statistics_from_git( - &repository_path, - selected, - options.path.as_deref(), - options.ignore_whitespace, - )?) - }; - let mut decorations = decorations(&repository)?; - let mut entries = Vec::with_capacity(selected.len()); - let mut path = options.path.clone(); - for selected in selected { - let oid = git2::Oid::from_str_ext(&selected.oid, repository.object_format()) - .map_err(|_| super::Error::NotFound)?; - let commit = repository - .find_commit(oid) - .map_err(super::Error::from_git)?; - let (files, additions, deletions) = match &git_statistics { - Some(statistics) => statistics.get(&selected.oid).copied().unwrap_or_default(), - None => statistics_from_git2( - &repository, - &commit, - path.as_deref(), - options.ignore_whitespace, - )?, - }; - let message = String::from_utf8_lossy(commit.message_bytes()); - let subject = - String::from_utf8_lossy(commit.summary_bytes().unwrap_or_default()).into_owned(); - let body = message - .split_once('\n') - .map_or("", |(_, body)| body.trim_start_matches('\n')) - .to_owned(); - let notes = repository - .find_note(None, oid) - .ok() - .map(|note| String::from_utf8_lossy(note.message_bytes()).into_owned()); - entries.push(Entry { - oid: selected.oid.clone(), - subject, - body, - author: identity(commit.author()), - committer: identity(commit.committer()), - decorations: decorations.remove(&oid).unwrap_or_default(), - notes, - files, - additions, - deletions, - graph: selected.graph.clone(), - graph_continuations: selected.graph_continuations.clone(), - path: path.clone(), - }); - if follow { - path = previous_path(&repository, &commit, path)?; - } - } - Ok(Self { - repository: info, - revision: revision_name, - entries, - has_next, - graph: options.graph && !follow, - }) - } -} - -struct Selected { - oid: String, - graph: String, - graph_continuations: Vec<String>, -} - -fn git(path: &Path) -> Command { - let mut command = Command::new(crate::GIT); - command - .env_clear() - .env("HOME", "/var/empty") - .env("PATH", "/usr/bin:/bin") - .env("LC_ALL", "C") - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_ATTR_NOSYSTEM", "1") - .arg("--no-replace-objects") - .arg("--git-dir") - .arg(path) - .arg("-c") - .arg("core.hooksPath=/dev/null") - .arg("-c") - .arg("color.ui=false") - .arg("--no-pager"); - command -} - -fn select( - path: &Path, - revision: String, - options: &Options, - follow: bool, -) -> Result<Vec<Selected>, super::Error> { - let mut command = git(path); - command - .arg("log") - .arg("--no-ext-diff") - .arg("--no-textconv") - .arg("--no-color") - .arg("--format=format:%x1e%H%x1f") - .arg(format!("--max-count={}", options.limit.saturating_add(1))) - .arg(format!("--skip={}", options.offset)); - if options.graph && !follow { - command.arg("--graph"); - } - if options.ignore_whitespace { - command.arg("--ignore-all-space"); - } - if follow { - command.arg("--follow"); - } - match &options.search { - Search::None => { - command.arg(revision); - } - Search::Grep(value) => { - command - .arg("--regexp-ignore-case") - .arg(format!("--grep={value}")) - .arg(revision); - } - Search::Author(value) => { - command - .arg("--regexp-ignore-case") - .arg(format!("--author={value}")) - .arg(revision); - } - Search::Committer(value) => { - command - .arg("--regexp-ignore-case") - .arg(format!("--committer={value}")) - .arg(revision); - } - Search::Range(value) => { - for selector in value.split_ascii_whitespace() { - command.arg(selector); - } - } - } - if let Some(path) = &options.path { - command.arg("--").arg(path); - } - let output = command - .output() - .map_err(|error| super::Error::Internal(error.to_string()))?; - if !output.status.success() { - let error = String::from_utf8_lossy(&output.stderr); - return Err(super::Error::Internal(format!("git log failed: {error}"))); - } - parse(&output.stdout) -} - -fn parse(bytes: &[u8]) -> Result<Vec<Selected>, super::Error> { - let text = std::str::from_utf8(bytes) - .map_err(|_| super::Error::Internal("git log emitted invalid UTF-8".to_owned()))?; - let mut result = Vec::new(); - let mut prefix_start = 0; - while let Some(relative) = text[prefix_start..].find('\x1e') { - let marker = prefix_start + relative; - let graph_lines = text[prefix_start..marker] - .strip_prefix('\n') - .unwrap_or(&text[prefix_start..marker]) - .split_terminator('\n') - .collect::<Vec<_>>(); - let (graph, graph_continuations) = - graph_lines - .split_last() - .map_or(("", Vec::new()), |(graph, continuations)| { - ( - *graph, - continuations - .iter() - .map(|line| (*line).to_owned()) - .collect(), - ) - }); - let remainder = &text[marker + 1..]; - let (oid, _) = remainder.split_once('\x1f').ok_or_else(|| { - super::Error::Internal("git log emitted an invalid record".to_owned()) - })?; - let oid = oid.trim(); - if !(matches!(oid.len(), 40 | 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())) { - return Err(super::Error::Internal( - "git log emitted an invalid object id".to_owned(), - )); - } - result.push(Selected { - oid: oid.to_owned(), - graph: graph.to_owned(), - graph_continuations, - }); - prefix_start = marker + 1 + oid.len() + 1; - } - Ok(result) -} - -fn decorations( - repository: &git2::Repository, -) -> Result<BTreeMap<git2::Oid, Vec<super::commit::Decoration>>, super::Error> { - let mut result = BTreeMap::new(); - if let Ok(head) = repository.head() - && let Ok(commit) = head.peel_to_commit() - { - result - .entry(commit.id()) - .or_insert_with(Vec::new) - .push(super::commit::Decoration { - label: "HEAD".to_owned(), - reference: None, - }); - } - let references = repository.references().map_err(super::Error::from_git)?; - for reference in references.flatten() { - let Ok(name) = reference.name() else { - continue; - }; - if !name.starts_with("refs/heads/") && !name.starts_with("refs/tags/") { - continue; - } - let Ok(commit) = reference.peel_to_commit() else { - continue; - }; - let label = name - .strip_prefix("refs/heads/") - .or_else(|| name.strip_prefix("refs/tags/")) - .unwrap_or(name); - result - .entry(commit.id()) - .or_insert_with(Vec::new) - .push(super::commit::Decoration { - label: label.to_owned(), - reference: Some(name.to_owned()), - }); - } - Ok(result) -} - -fn statistics_from_git( - repository: &Path, - selected: &[Selected], - path: Option<&str>, - ignore_whitespace: bool, -) -> Result<BTreeMap<String, (usize, usize, usize)>, super::Error> { - if selected.is_empty() { - return Ok(BTreeMap::new()); - } - let mut command = git(repository); - command - .arg("log") - .arg("--no-walk=unsorted") - .arg("--root") - .arg("--diff-merges=first-parent") - .arg("--no-ext-diff") - .arg("--no-textconv") - .arg("--find-renames") - .arg("--numstat") - .arg("-z") - .arg("--format=format:GILTI-STATS%x00%H%x00"); - if ignore_whitespace { - command.arg("--ignore-all-space"); - } - for entry in selected { - command.arg(&entry.oid); - } - if let Some(path) = path { - command.arg("--").arg(path); - } - let output = command - .output() - .map_err(|error| super::Error::Internal(error.to_string()))?; - if !output.status.success() { - return Err(super::Error::Internal(format!( - "git log statistics failed: {}", - String::from_utf8_lossy(&output.stderr) - ))); - } - parse_statistics(&output.stdout) -} - -fn parse_statistics( - output: &[u8], -) -> Result<BTreeMap<String, (usize, usize, usize)>, super::Error> { - const MARKER: &[u8] = b"GILTI-STATS\0"; - let mut result = BTreeMap::new(); - let mut cursor = 0; - while let Some(relative) = output[cursor..] - .windows(MARKER.len()) - .position(|value| value == MARKER) - { - let start = cursor + relative + MARKER.len(); - let oid_end = output[start..] - .iter() - .position(|byte| *byte == 0) - .map(|end| start + end) - .ok_or_else(|| super::Error::Internal("git statistics omitted an OID".to_owned()))?; - let oid = std::str::from_utf8(&output[start..oid_end]).map_err(|_| { - super::Error::Internal("git statistics emitted invalid UTF-8".to_owned()) - })?; - if !(matches!(oid.len(), 40 | 64) && oid.bytes().all(|byte| byte.is_ascii_hexdigit())) { - return Err(super::Error::Internal( - "git statistics emitted an invalid object ID".to_owned(), - )); - } - let next = output[oid_end + 1..] - .windows(MARKER.len()) - .position(|value| value == MARKER) - .map_or(output.len(), |offset| oid_end + 1 + offset); - let mut files = 0; - let mut additions = 0; - let mut deletions = 0; - let mut fields = output[oid_end + 1..next].split(|byte| *byte == 0); - while let Some(field) = fields.next() { - let field = field.strip_prefix(b"\n").unwrap_or(field); - let mut values = field.splitn(3, |byte| *byte == b'\t'); - let (Some(added), Some(deleted), Some(path)) = - (values.next(), values.next(), values.next()) - else { - continue; - }; - let count = |value: &[u8]| { - (value != b"-") - .then(|| std::str::from_utf8(value).ok()?.parse::<usize>().ok()) - .flatten() - .unwrap_or(0) - }; - if (added == b"-" || added.iter().all(u8::is_ascii_digit)) - && (deleted == b"-" || deleted.iter().all(u8::is_ascii_digit)) - { - files += 1; - additions += count(added); - deletions += count(deleted); - if path.is_empty() { - fields.next(); - fields.next(); - } - } - } - result.insert(oid.to_owned(), (files, additions, deletions)); - cursor = next; - } - Ok(result) -} - -fn statistics_from_git2( - repository: &git2::Repository, - commit: &git2::Commit<'_>, - path: Option<&str>, - ignore_whitespace: bool, -) -> Result<(usize, usize, usize), super::Error> { - let old_tree = if commit.parent_count() > 0 { - Some( - commit - .parent(0) - .map_err(super::Error::from_git)? - .tree() - .map_err(super::Error::from_git)?, - ) - } else { - None - }; - let new_tree = commit.tree().map_err(super::Error::from_git)?; - let mut options = git2::DiffOptions::new(); - if ignore_whitespace { - options.ignore_whitespace(true); - } - let mut diff = repository - .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut options)) - .map_err(super::Error::from_git)?; - diff.find_similar(None).map_err(super::Error::from_git)?; - let Some(path) = path else { - let stats = diff.stats().map_err(super::Error::from_git)?; - return Ok((stats.files_changed(), stats.insertions(), stats.deletions())); - }; - let mut files = 0; - let mut additions = 0; - let mut deletions = 0; - for (index, delta) in diff.deltas().enumerate() { - if delta.old_file().path_bytes() != Some(path.as_bytes()) - && delta.new_file().path_bytes() != Some(path.as_bytes()) - { - continue; - } - files += 1; - if let Some(patch) = git2::Patch::from_diff(&diff, index).map_err(super::Error::from_git)? { - let (_, added, deleted) = patch.line_stats().map_err(super::Error::from_git)?; - additions += added; - deletions += deleted; - } - } - Ok((files, additions, deletions)) -} - -fn previous_path( - repository: &git2::Repository, - commit: &git2::Commit<'_>, - path: Option<String>, -) -> Result<Option<String>, super::Error> { - let Some(path) = path else { return Ok(None) }; - if commit.parent_count() == 0 { - return Ok(Some(path)); - } - let parent = commit.parent(0).map_err(super::Error::from_git)?; - let old_tree = parent.tree().map_err(super::Error::from_git)?; - let new_tree = commit.tree().map_err(super::Error::from_git)?; - let mut diff = repository - .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None) - .map_err(super::Error::from_git)?; - diff.find_similar(None).map_err(super::Error::from_git)?; - for delta in diff.deltas() { - if delta.status() == git2::Delta::Renamed - && delta.new_file().path_bytes() == Some(path.as_bytes()) - { - return Ok(delta - .old_file() - .path_bytes() - .map(|path| String::from_utf8_lossy(path).into_owned())); - } - } - Ok(Some(path)) -} - -fn identity(signature: git2::Signature<'_>) -> super::commit::Identity { - super::commit::Identity { - name: signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()), - email: signature - .email() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.email_bytes()).into_owned()), - timestamp: signature.when().seconds(), - offset_minutes: signature.when().offset_minutes(), - } -} - -#[cfg(test)] -mod tests { - #[test] - fn parses_control_delimited_graph_output() { - let rows = super::parse(b"* \x1e0123456789abcdef0123456789abcdef01234567\x1f\n|\\ \n| * \x1e89abcdef0123456789abcdef0123456789abcdef\x1f\n").unwrap(); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].graph, "* "); - assert_eq!(rows[1].graph, "| * "); - assert_eq!(rows[1].graph_continuations, ["|\\ "]); - } - - #[test] - fn parses_git_numstat_records_and_binary_files() { - let oid = "0123456789abcdef0123456789abcdef01234567"; - let other = "89abcdef0123456789abcdef0123456789abcdef"; - let output = format!( - "GILTI-STATS\0{oid}\0\0\n1\t2\tfile.txt\0-\t-\tbinary\0GILTI-STATS\0{other}\0\0\n0\t0\t\0old\0new\0" - ); - let statistics = super::parse_statistics(output.as_bytes()).unwrap(); - assert_eq!(statistics[oid], (2, 1, 2)); - assert_eq!(statistics[other], (1, 0, 0)); - } -} diff --git a/crates/gilti/src/models/mod.rs b/crates/gilti/src/models/mod.rs deleted file mode 100644 --- a/crates/gilti/src/models/mod.rs +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod about; -pub mod archive; -pub mod archive_signature; -pub mod blame; -pub mod commit; -pub mod diff; -pub mod history; -pub mod object; -pub mod overview; -pub mod patch; -pub mod refs; -pub mod repositories; -pub mod repository; -pub mod revision; -pub mod stats; -pub mod tag; -pub mod time; -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()) - } - } -} - -#[cfg(test)] -mod migration_tests { - #[test] - fn loads_diff_commit_stats_archive_and_patch_models() { - let root = - std::env::temp_dir().join(format!("gilti-five-model-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::now("Model Tester", "model@example.test").unwrap(); - let first_blob = repository.blob(b"first\n").unwrap(); - let mut builder = repository.treebuilder(None).unwrap(); - builder.insert("file.txt", first_blob, 0o100644).unwrap(); - let first_tree_oid = builder.write().unwrap(); - drop(builder); - let first_tree = repository.find_tree(first_tree_oid).unwrap(); - let first = repository - .commit( - Some("refs/heads/main"), - &signature, - &signature, - "first", - &first_tree, - &[], - ) - .unwrap(); - let first = repository.find_commit(first).unwrap(); - let first_oid = first.id(); - let second_blob = repository.blob(b"second\n").unwrap(); - let mut builder = repository.treebuilder(None).unwrap(); - builder.insert("file.txt", second_blob, 0o100644).unwrap(); - let second_tree_oid = builder.write().unwrap(); - drop(builder); - let second_tree = repository.find_tree(second_tree_oid).unwrap(); - let second = repository - .commit( - Some("refs/heads/main"), - &signature, - &signature, - "second\n\nbody", - &second_tree, - &[&first], - ) - .unwrap(); - repository.set_head("refs/heads/main").unwrap(); - drop(first); - drop(first_tree); - drop(second_tree); - drop(repository); - - let old = crate::router::Revision::Commit(first_oid.to_string()); - let new = crate::router::Revision::Commit(second.to_string()); - let options = super::diff::Options { - context: 3, - ignore_whitespace: false, - }; - let diff = super::diff::Diff::load( - &root, - "example", - Some(old.clone()), - new.clone(), - None, - options, - ) - .unwrap(); - assert_eq!(diff.files.len(), 1); - assert_eq!((diff.additions, diff.deletions), (1, 1)); - let commit = super::commit::Commit::load(&root, "example", new.clone(), options).unwrap(); - assert_eq!(commit.subject, "second"); - assert_eq!(commit.parents, [first_oid.to_string()]); - assert!(commit.diff.is_some()); - let stats = - super::stats::Stats::load(&root, "example", super::stats::Period::Week).unwrap(); - assert_eq!(stats.totals.iter().sum::<usize>(), 2); - let archive = - super::archive::Archive::load(&root, "example", &new, Some("file.txt")).unwrap(); - assert_eq!(archive.oid, second.to_string()); - let patch = super::patch::Patch::load(&root, "example", &old, &new).unwrap(); - assert_eq!(patch.old_oid, first_oid.to_string()); - assert_eq!(patch.new_oid, second.to_string()); - let history = super::history::History::load( - &root, - "example", - new, - super::history::Options { - path: Some("file.txt".to_owned()), - follow: true, - search: super::history::Search::Grep("second".to_owned()), - offset: 0, - limit: 1, - graph: true, - ignore_whitespace: false, - include_statistics: true, - }, - ) - .unwrap(); - assert_eq!(history.entries.len(), 1); - assert_eq!(history.entries[0].oid, second.to_string()); - assert!(!history.graph); - std::fs::remove_dir_all(root).unwrap(); - } -} diff --git a/crates/gilti/src/models/object.rs b/crates/gilti/src/models/object.rs deleted file mode 100644 --- a/crates/gilti/src/models/object.rs +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct RawObject { - pub bytes: Vec<u8>, - pub binary: bool, -} - -impl RawObject { - pub fn load(root: &Path, repository: &str, oid: &str) -> Result<Self, super::Error> { - 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_browser_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 deleted file mode 100644 --- a/crates/gilti/src/models/overview.rs +++ /dev/null @@ -1,122 +0,0 @@ -// 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<super::refs::Branch>, - pub tags: Vec<super::refs::Tag>, - pub commits: Vec<Commit>, -} - -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<Decoration>, -} - -pub struct Decoration { - pub label: String, - pub reference: Option<String>, - pub tag: bool, -} - -impl Overview { - pub fn load(root: &Path, name: &str) -> Result<Self, super::Error> { - 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::<git2::Oid, Vec<Decoration>>::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) - && 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/patch.rs b/crates/gilti/src/models/patch.rs deleted file mode 100644 --- a/crates/gilti/src/models/patch.rs +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::{Path, PathBuf}; - -pub struct Patch { - pub repository_path: PathBuf, - pub old_oid: String, - pub new_oid: String, -} - -impl Patch { - pub fn load( - root: &Path, - name: &str, - old: &crate::router::Revision, - new: &crate::router::Revision, - ) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let old = super::revision::commit(&repository, old)?; - let new = super::revision::commit(&repository, new)?; - Ok(Self { - repository_path: repository.path().to_owned(), - old_oid: old.id().to_string(), - new_oid: new.id().to_string(), - }) - } -} diff --git a/crates/gilti/src/models/refs.rs b/crates/gilti/src/models/refs.rs deleted file mode 100644 --- a/crates/gilti/src/models/refs.rs +++ /dev/null @@ -1,196 +0,0 @@ -// 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<Branch>, - pub tags: Vec<Tag>, -} - -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<Self, super::Error> { - 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::<Vec<_>>(), - ["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 deleted file mode 100644 --- a/crates/gilti/src/models/repositories.rs +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::path::Path; - -pub struct Repositories { - pub rows: Vec<Repository>, - pub offset: usize, - pub has_next: bool, -} - -pub struct Repository { - pub name: String, - pub description: String, - pub timestamp: Option<i64>, - pub populated: bool, -} - -#[derive(Clone, Copy)] -pub enum Sort { - Name, - Description, - Owner, - Idle, -} - -pub struct Filter { - pub search: Option<String>, - pub sort: Sort, - pub offset: usize, - pub limit: usize, -} - -impl Repositories { - pub fn load(root: &Path, filter: Filter) -> Result<Self, super::Error> { - 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<Repository>) -> 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<Repository> { - 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::<Vec<_>>(), - ["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 deleted file mode 100644 --- a/crates/gilti/src/models/repository.rs +++ /dev/null @@ -1,93 +0,0 @@ -// 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 has_readme: bool, -} - -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()); - let has_readme = repository - .head() - .and_then(|head| head.peel_to_tree()) - .ok() - .is_some_and(|tree| { - ["README.md", "README"].iter().any(|path| { - tree.get_path(Path::new(path)) - .is_ok_and(|entry| entry.kind() == Some(git2::ObjectType::Blob)) - }) - }); - Info { - name: name.to_owned(), - description, - has_readme, - } -} - -pub fn open(root: &Path, name: &str) -> Result<git2::Repository, super::Error> { - git2::Repository::open_bare(path(root, name)?).map_err(super::Error::from_git) -} - -pub fn path(root: &Path, name: &str) -> Result<PathBuf, super::Error> { - 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 deleted file mode 100644 --- a/crates/gilti/src/models/revision.rs +++ /dev/null @@ -1,34 +0,0 @@ -// 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<git2::Commit<'repo>, 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/stats.rs b/crates/gilti/src/models/stats.rs deleted file mode 100644 --- a/crates/gilti/src/models/stats.rs +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use std::collections::BTreeMap; -use std::path::Path; - -#[derive(Clone, Copy, Eq, PartialEq)] -pub enum Period { - Week, - Month, - Quarter, - Year, -} - -impl Period { - pub fn name(self) -> &'static str { - match self { - Self::Week => "week", - Self::Month => "month", - Self::Quarter => "quarter", - Self::Year => "year", - } - } -} - -pub struct Stats { - pub repository: super::repository::Info, - pub labels: Vec<String>, - pub authors: Vec<Author>, - pub totals: Vec<usize>, -} - -pub struct Author { - pub name: String, - pub counts: Vec<usize>, - pub total: usize, -} - -impl Stats { - pub fn load(root: &Path, name: &str, period: Period) -> Result<Self, super::Error> { - let repository = super::repository::open(root, name)?; - let info = super::repository::info(&repository, name); - let head = super::revision::commit(&repository, &crate::router::Revision::Head)?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs() as i64); - let labels = labels(period, now); - let positions = labels - .iter() - .enumerate() - .map(|(index, label)| (label.clone(), index)) - .collect::<BTreeMap<_, _>>(); - let mut authors = BTreeMap::<String, Vec<usize>>::new(); - 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)?; - for oid in walk { - let commit = repository - .find_commit(oid.map_err(super::Error::from_git)?) - .map_err(super::Error::from_git)?; - if commit.parent_count() > 1 { - continue; - } - let label = label(period, commit.time().seconds()); - let Some(index) = positions.get(&label).copied() else { - continue; - }; - let signature = commit.author(); - let author = signature - .name() - .map(str::to_owned) - .unwrap_or_else(|_| String::from_utf8_lossy(signature.name_bytes()).into_owned()); - authors.entry(author).or_insert_with(|| vec![0; 4])[index] += 1; - } - let mut authors = authors - .into_iter() - .map(|(name, counts)| Author { - total: counts.iter().sum(), - name, - counts, - }) - .collect::<Vec<_>>(); - authors.sort_by(|left, right| { - right - .total - .cmp(&left.total) - .then_with(|| left.name.cmp(&right.name)) - }); - let totals = (0..4) - .map(|index| authors.iter().map(|author| author.counts[index]).sum()) - .collect(); - Ok(Self { - repository: info, - labels, - authors, - totals, - }) - } -} - -fn labels(period: Period, now: i64) -> Vec<String> { - let mut time = tm(now); - truncate(period, &mut time); - let mut labels = Vec::with_capacity(4); - for _ in 0..4 { - labels.push(pretty(period, &time)); - decrement(period, &mut time); - } - labels.reverse(); - labels -} - -fn label(period: Period, timestamp: i64) -> String { - let mut time = tm(timestamp); - truncate(period, &mut time); - pretty(period, &time) -} - -fn tm(timestamp: i64) -> libc::tm { - super::time::utc(timestamp) - .unwrap_or_else(|| super::time::utc(0).expect("the Unix epoch is representable")) -} - -fn normalize(time: &mut libc::tm) { - // SAFETY: time points to an initialized libc::tm and timegm normalizes it in place. - unsafe { libc::timegm(time) }; -} - -fn truncate(period: Period, time: &mut libc::tm) { - match period { - Period::Week => { - time.tm_mday -= (time.tm_wday + 6) % 7; - } - Period::Month => time.tm_mday = 1, - Period::Quarter => { - time.tm_mday = 1; - time.tm_mon -= time.tm_mon % 3; - } - Period::Year => { - time.tm_mday = 1; - time.tm_mon = 0; - } - } - normalize(time); -} - -fn decrement(period: Period, time: &mut libc::tm) { - match period { - Period::Week => time.tm_mday -= 7, - Period::Month => time.tm_mon -= 1, - Period::Quarter => time.tm_mon -= 3, - Period::Year => time.tm_year -= 1, - } - normalize(time); -} - -fn pretty(period: Period, time: &libc::tm) -> String { - match period { - Period::Week => strftime("W%V %G", time), - Period::Month => { - const MONTHS: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - format!("{} {}", MONTHS[time.tm_mon as usize], time.tm_year + 1900) - } - Period::Quarter => format!("Q{} {}", time.tm_mon / 3 + 1, time.tm_year + 1900), - Period::Year => (time.tm_year + 1900).to_string(), - } -} - -fn strftime(format: &str, time: &libc::tm) -> String { - let format = std::ffi::CString::new(format).expect("static format has no NUL"); - let mut buffer = [0_u8; 32]; - // SAFETY: the output buffer, format string and tm pointers are valid. - let length = unsafe { - libc::strftime( - buffer.as_mut_ptr().cast::<libc::c_char>(), - buffer.len(), - format.as_ptr(), - time, - ) - }; - std::str::from_utf8(&buffer[..length]) - .expect("strftime emits ASCII for this format") - .to_owned() -} diff --git a/crates/gilti/src/models/tag.rs b/crates/gilti/src/models/tag.rs deleted file mode 100644 --- a/crates/gilti/src/models/tag.rs +++ /dev/null @@ -1,112 +0,0 @@ -// 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<Target>, - pub annotated: bool, - pub tagger: String, - pub tagger_email: String, - pub timestamp: Option<i64>, - 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<Self, super::Error> { - 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<Vec<Target>, 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/time.rs b/crates/gilti/src/models/time.rs deleted file mode 100644 --- a/crates/gilti/src/models/time.rs +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub fn utc(timestamp: i64) -> Option<libc::tm> { - let mut value = std::mem::MaybeUninit::<libc::tm>::uninit(); - // SAFETY: both pointers are valid for the call; the result is read only when - // gmtime_r reports success and has initialized it. - let result = unsafe { libc::gmtime_r(×tamp, value.as_mut_ptr()) }; - (!result.is_null()).then(|| unsafe { value.assume_init() }) -} diff --git a/crates/gilti/src/models/tree.rs b/crates/gilti/src/models/tree.rs deleted file mode 100644 --- a/crates/gilti/src/models/tree.rs +++ /dev/null @@ -1,250 +0,0 @@ -// 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<String>, - pub content: Content, -} - -pub enum Content { - Directory { - oid: String, - entries: Vec<Entry>, - }, - Blob { - oid: String, - bytes: Vec<u8>, - 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<String>, -} - -#[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<String>, - ) -> Result<Self, super::Error> { - 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<Content, super::Error> { - 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/router.rs b/crates/gilti/src/router.rs deleted file mode 100644 --- a/crates/gilti/src/router.rs +++ /dev/null @@ -1,867 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -/* -URL scheme - -The URL space is divided into disjoint Gilti-specific and repository-specific -namespaces. The reserved top-level segment "-" belongs to Gilti and cannot be -used as the first segment of a repository name. - -Git objects are content-addressed and immutable: a full object ID always -identifies the same object contents. Refs and repository metadata are mutable, -and unreachable objects may eventually be removed. - -Routes marked I use only immutable selectors. Routes marked M depend on the -current repository state. A composite selector inherits mutability from its -components: a tree path under a full commit OID is immutable, while the same -path under a branch or tag ref is mutable. A comparison is immutable only when -both of its revision selectors are immutable. - -Repository names, ref names, and tree paths may all contain "/". Their -boundaries are expressed using literal "/+/" segments and are never inferred -from repository state. Structural delimiters are recognized before percent -decoding. A literal data segment equal to "+" must be percent-encoded as -"%2B". - -Route parameters whose names end in "*" may contain "/". They are terminated -by the next structural "/+/" segment, a route-specific terminal marker, or the -end of the route. The terminal ".git" suffix separates a repository name from -Git HTTP transport paths. - - {repo*} canonical repository name - {ref*} ref name relative to "refs/", for example "heads/main" - {path*} path within a Git tree - {commit_id} full OID of a commit; abbreviated IDs are not accepted - {object_id} full OID of any Git object; abbreviated IDs are not accepted - {rev*} "{commit_id}", "refs/{ref*}", or "HEAD" - {asset_path*} path below the Gilti static asset root - {old_rev*} revision selector - {new_rev*} revision selector - {dumb_http_path*} path below the Git dumb HTTP "objects/" endpoint - {lfs_path*} path below the Git LFS endpoint - -Archive, diff, blob, and object formats are representation options, not route -parameters. They are selected by the "format" query parameter, then by the -Accept header, and finally by the server default. The query parameter takes -precedence when both are present. Archive signature routes use the same query -parameter to identify the signed archive format. Raw blob contents, raw Git -object contents, and raw unified diffs are representations of their respective -views rather than separate views. - -Rendered documents are an HTML view mode selected with the "view" query -parameter. Supported values are "source" and "rendered". Renderable documents -may default to "rendered"; other blobs default to "source". - -Gilti views and operational endpoints: - -/-/about <- Gilti about page -/-/terms <- Gilti terms of use -/-/assets/{asset_path*} <- Static asset -/-/health <- Health check returning {"status":"ok"} -*/ - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Route { - // Repository views: - /// M: Repository list - /// / - Repositories, - - /// M: Repository overview - /// /{repo*} - Overview(RepoRoute<()>), - - /// M: Repository documentation - /// /{repo*}/+/about - About(RepoRoute<()>), - - /// M: Repository activity statistics - /// /{repo*}/+/stats - Stats(RepoRoute<()>), - - /// M: Recent tags, commits, and summary data - /// /{repo*}/+/summary - Summary(RepoRoute<()>), - - // Immutable object views: - /// I: Git object - /// /{repo*}/+/object/{object_id} - Object(RepoRoute<String>), - - // Refs and revisions: - /// M: Ref list - /// /{repo*}/+/refs - Refs(RepoRoute<()>), - - /// I/M: Revision overview and resolved target - /// /{repo*}/+/{rev*} - Revision(RepoRoute<Revision>), - - /// I/M: Commit log, optionally restricted to a path - /// /{repo*}/+/{rev*}/+/log - /// /{repo*}/+/{rev*}/+/log/{path*} - Log(RepoRoute<RevisionPath>), - - /// I/M: Tree, blob, document, or submodule - /// /{repo*}/+/{rev*}/+/tree - /// /{repo*}/+/{rev*}/+/tree/{path*} - Tree(RepoRoute<RevisionPath>), - - /// I/M: File blame - /// /{repo*}/+/{rev*}/+/blame/{path*} - Blame(RepoRoute<RevisionFile>), - - /// I/M: Archive of the root tree or a subtree - /// /{repo*}/+/{rev*}/+/archive - /// /{repo*}/+/{rev*}/+/archive/{path*} - Archive(RepoRoute<RevisionPath>), - - /// I/M: Detached archive signature - /// /{repo*}/+/{rev*}/+/archive-signature - ArchiveSignature(RepoRoute<Revision>), - - // Feed & updates: - /// M: Atom commit feed, optionally restricted to a path - /// /{repo*}/+/refs/{ref*}/+/feed/atom - /// /{repo*}/+/refs/{ref*}/+/feed/atom/{path*} - AtomFeed(RepoRoute<RefPath>), - - // Comparisons and patches: - /// I/M: Diff between two revisions, optionally restricted to a path - /// /{repo*}/+/diff/{old_rev*}..{new_rev*} - /// /{repo*}/+/diff/{old_rev*}..{new_rev*}/+/{path*} - Diff(RepoRoute<Comparison>), - - /// I/M: Mail-formatted patch or patch series, optionally restricted to a path - /// /{repo*}/+/patch/{old_rev*}..{new_rev*} - /// /{repo*}/+/patch/{old_rev*}..{new_rev*}/+/{path*} - Patch(RepoRoute<Comparison>), - - // Git HTTP transport endpoints: - /// M: Public clone URL - /// /{repo*}.git - GitClone(RepoRoute<()>), - - /// M: Service discovery and ref advertisement - /// /{repo*}.git/info/refs - GitInfoRefs(RepoRoute<()>), - - /// M: Fetch protocol endpoint - /// /{repo*}.git/git-upload-pack - GitUploadPack(RepoRoute<()>), - - /// M: Push protocol endpoint, when enabled - /// /{repo*}.git/git-receive-pack - GitReceivePack(RepoRoute<()>), - - /// M: Dumb HTTP HEAD advertisement, when enabled - /// /{repo*}.git/HEAD - GitHead(RepoRoute<()>), - - /// Git dumb HTTP object endpoint, when enabled - /// /{repo*}.git/objects/{dumb_http_path*} - GitObjects(RepoRoute<String>), - - /// Git LFS endpoint, when enabled - /// /{repo*}.git/info/lfs/{lfs_path*} - GitLfs(RepoRoute<String>), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RepoRoute<T> { - pub repo: String, - pub params: T, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RevisionPath { - pub rev: Revision, - pub path: Option<String>, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RevisionFile { - pub rev: Revision, - pub path: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RefPath { - pub reference: String, - pub path: Option<String>, -} - -/// A revision range separated by `..` and an optional tree path separated by `/+/`. -/// It is immutable only when both revision selectors are full commit OIDs. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Comparison { - pub old_rev: Revision, - pub new_rev: Revision, - pub path: Option<String>, -} - -/// A revision route is immutable for a full commit OID and mutable for a ref or -/// `HEAD`. An annotated tag displays its object chain and peeled target. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Revision { - Head, - Ref(String), - Commit(String), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ParseError; - -impl std::fmt::Display for ParseError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("invalid repository route") - } -} - -impl std::error::Error for ParseError {} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TokenKind { - Text, - Slash, - Boundary, - Range, - GitSuffix, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct Token<'a> { - kind: TokenKind, - raw: &'a str, -} - -/// Parses a raw, percent-encoded URI path. Query parameters are handled separately. -pub fn parse(path: &str) -> Result<Route, ParseError> { - let tokens = lex(path)?; - Parser { - tokens: &tokens, - cursor: 0, - } - .parse() -} - -fn lex(path: &str) -> Result<Vec<Token<'_>>, ParseError> { - let bytes = path.as_bytes(); - let mut tokens = Vec::new(); - let (mut cursor, mut start) = (0, 0); - while cursor < bytes.len() { - if bytes[cursor] == b'%' { - if cursor + 2 >= bytes.len() - || !bytes[cursor + 1].is_ascii_hexdigit() - || !bytes[cursor + 2].is_ascii_hexdigit() - { - return Err(ParseError); - } - cursor += 3; - continue; - } - if matches!(bytes[cursor], b'?' | b'#') { - return Err(ParseError); - } - let delimiter = if bytes[cursor..].starts_with(b"/+/") { - Some((TokenKind::Boundary, 3)) - } else if bytes[cursor] == b'/' { - Some((TokenKind::Slash, 1)) - } else if bytes[cursor..].starts_with(b"..") { - Some((TokenKind::Range, 2)) - } else if bytes[cursor..].starts_with(b".git") - && (cursor + 4 == bytes.len() || bytes[cursor + 4] == b'/') - { - Some((TokenKind::GitSuffix, 4)) - } else { - None - }; - let Some((kind, width)) = delimiter else { - cursor += 1; - continue; - }; - if start < cursor { - tokens.push(Token { - kind: TokenKind::Text, - raw: &path[start..cursor], - }); - } - tokens.push(Token { - kind, - raw: &path[cursor..cursor + width], - }); - cursor += width; - start = cursor; - } - if start < cursor { - tokens.push(Token { - kind: TokenKind::Text, - raw: &path[start..cursor], - }); - } - Ok(tokens) -} - -struct Parser<'tokens, 'input> { - tokens: &'tokens [Token<'input>], - cursor: usize, -} - -impl<'tokens, 'input> Parser<'tokens, 'input> { - fn parse(mut self) -> Result<Route, ParseError> { - self.expect(TokenKind::Slash)?; - if self.end() { - return Ok(Route::Repositories); - } - let repo = decode_repo(self.take_until(&[TokenKind::Boundary, TokenKind::GitSuffix]))?; - match self.peek() { - None => Ok(Route::Overview(route(repo, ()))), - Some(TokenKind::Boundary) => { - self.cursor += 1; - self.view(repo) - } - Some(TokenKind::GitSuffix) => { - self.cursor += 1; - self.git(repo) - } - _ => Err(ParseError), - } - } - - fn view(&mut self, repo: String) -> Result<Route, ParseError> { - Ok(match self.peek_text() { - Some("about") => { - self.cursor += 1; - self.finish()?; - Route::About(route(repo, ())) - } - Some("stats") => { - self.cursor += 1; - self.finish()?; - Route::Stats(route(repo, ())) - } - Some("summary") => { - self.cursor += 1; - self.finish()?; - Route::Summary(route(repo, ())) - } - Some("refs") if self.cursor + 1 == self.tokens.len() => { - self.cursor += 1; - Route::Refs(route(repo, ())) - } - Some("object") => { - self.cursor += 1; - self.expect(TokenKind::Slash)?; - let object_id = parse_oid(self.take_text()?)?; - self.finish()?; - Route::Object(route(repo, object_id)) - } - Some("diff") | Some("patch") => { - let patch = self.take_text()? == "patch"; - self.expect(TokenKind::Slash)?; - return self.comparison(repo, patch); - } - _ => { - let rev = self.revision()?; - if self.end() { - Route::Revision(route(repo, rev)) - } else { - self.expect(TokenKind::Boundary)?; - return self.revision_view(repo, rev); - } - } - }) - } - - fn revision_view(&mut self, repo: String, rev: Revision) -> Result<Route, ParseError> { - Ok(match self.take_text()? { - "log" => Route::Log(route( - repo, - RevisionPath { - rev, - path: self.optional_path(TokenKind::Slash)?, - }, - )), - "tree" => Route::Tree(route( - repo, - RevisionPath { - rev, - path: self.optional_path(TokenKind::Slash)?, - }, - )), - "blame" => { - self.expect(TokenKind::Slash)?; - Route::Blame(route( - repo, - RevisionFile { - rev, - path: decode_path(self.rest())?, - }, - )) - } - "archive" => Route::Archive(route( - repo, - RevisionPath { - rev, - path: self.optional_path(TokenKind::Slash)?, - }, - )), - "archive-signature" => { - self.finish()?; - Route::ArchiveSignature(route(repo, rev)) - } - "feed" => { - self.expect(TokenKind::Slash)?; - self.expect_text("atom")?; - Route::AtomFeed(route( - repo, - RefPath { - reference: reference(rev)?, - path: self.optional_path(TokenKind::Slash)?, - }, - )) - } - _ => return Err(ParseError), - }) - } - - fn comparison(&mut self, repo: String, patch: bool) -> Result<Route, ParseError> { - let old_rev = self.revision()?; - self.expect(TokenKind::Range)?; - let new_rev = self.revision()?; - let comparison = route( - repo, - Comparison { - old_rev, - new_rev, - path: self.optional_path(TokenKind::Boundary)?, - }, - ); - Ok(if patch { - Route::Patch(comparison) - } else { - Route::Diff(comparison) - }) - } - - fn git(&mut self, repo: String) -> Result<Route, ParseError> { - if self.end() { - return Ok(Route::GitClone(route(repo, ()))); - } - self.expect(TokenKind::Slash)?; - Ok(match self.take_text()? { - "info" => { - self.expect(TokenKind::Slash)?; - match self.take_text()? { - "refs" => { - self.finish()?; - Route::GitInfoRefs(route(repo, ())) - } - "lfs" => { - self.expect(TokenKind::Slash)?; - Route::GitLfs(route(repo, decode_path(self.rest())?)) - } - _ => return Err(ParseError), - } - } - "git-upload-pack" => { - self.finish()?; - Route::GitUploadPack(route(repo, ())) - } - "git-receive-pack" => { - self.finish()?; - Route::GitReceivePack(route(repo, ())) - } - "HEAD" => { - self.finish()?; - Route::GitHead(route(repo, ())) - } - "objects" => { - self.expect(TokenKind::Slash)?; - Route::GitObjects(route(repo, decode_path(self.rest())?)) - } - _ => return Err(ParseError), - }) - } - - fn revision(&mut self) -> Result<Revision, ParseError> { - parse_revision(self.take_until(&[TokenKind::Boundary, TokenKind::Range])) - } - - fn optional_path(&mut self, separator: TokenKind) -> Result<Option<String>, ParseError> { - if self.end() { - Ok(None) - } else { - self.expect(separator)?; - decode_path(self.rest()).map(Some) - } - } - - fn take_until(&mut self, delimiters: &[TokenKind]) -> &'tokens [Token<'input>] { - let start = self.cursor; - while self.peek().is_some_and(|kind| !delimiters.contains(&kind)) { - self.cursor += 1; - } - &self.tokens[start..self.cursor] - } - - fn rest(&mut self) -> &'tokens [Token<'input>] { - let rest = &self.tokens[self.cursor..]; - self.cursor = self.tokens.len(); - rest - } - - fn peek(&self) -> Option<TokenKind> { - self.tokens.get(self.cursor).map(|token| token.kind) - } - - fn peek_text(&self) -> Option<&'input str> { - self.tokens - .get(self.cursor) - .filter(|token| token.kind == TokenKind::Text) - .map(|token| token.raw) - } - - fn take_text(&mut self) -> Result<&'input str, ParseError> { - let text = self.peek_text().ok_or(ParseError)?; - self.cursor += 1; - Ok(text) - } - - fn expect_text(&mut self, expected: &str) -> Result<(), ParseError> { - (self.take_text()? == expected) - .then_some(()) - .ok_or(ParseError) - } - - fn expect(&mut self, expected: TokenKind) -> Result<(), ParseError> { - (self.peek() == Some(expected)) - .then(|| self.cursor += 1) - .ok_or(ParseError) - } - - fn finish(&self) -> Result<(), ParseError> { - self.end().then_some(()).ok_or(ParseError) - } - - fn end(&self) -> bool { - self.cursor == self.tokens.len() - } -} - -fn parse_revision(value: &[Token<'_>]) -> Result<Revision, ParseError> { - if value.len() == 1 && value[0].kind == TokenKind::Text { - return match value[0].raw { - "HEAD" => Ok(Revision::Head), - oid => parse_oid(oid).map(Revision::Commit), - }; - } - if value.len() >= 2 && value[0].raw == "refs" && value[1].kind == TokenKind::Slash { - let reference = format!("refs/{}", decode_path(&value[2..])?); - return valid_ref(&reference) - .then_some(Revision::Ref(reference)) - .ok_or(ParseError); - } - Err(ParseError) -} - -fn parse_oid(value: &str) -> Result<String, ParseError> { - (matches!(value.len(), 40 | 64) - && value - .as_bytes() - .iter() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))) - .then(|| value.to_owned()) - .ok_or(ParseError) -} - -fn valid_ref(value: &str) -> bool { - !value.ends_with('.') - && !value.contains("..") - && !value.contains("@{") - && value.split('/').all(|part| { - !part.is_empty() - && !part.starts_with('.') - && !part.ends_with(".lock") - && !part - .bytes() - .any(|byte| byte < b' ' || byte == 0x7f || b" ~^:?*[\\".contains(&byte)) - }) -} - -fn decode_repo(value: &[Token<'_>]) -> Result<String, ParseError> { - decode_name(value, true) -} - -fn decode_path(value: &[Token<'_>]) -> Result<String, ParseError> { - decode_name(value, false) -} - -fn decode_name(value: &[Token<'_>], repo: bool) -> Result<String, ParseError> { - if value.is_empty() { - return Err(ParseError); - } - for (index, component) in value - .split(|token| token.kind == TokenKind::Slash) - .enumerate() - { - let literal = |expected| component.len() == 1 && component[0].raw == expected; - if component.is_empty() - || component.iter().any(|token| { - !matches!( - token.kind, - TokenKind::Text | TokenKind::Range | TokenKind::GitSuffix - ) - }) - || literal(".") - || literal("..") - || literal("+") - || repo - && (index == 0 && literal("-") - || component - .last() - .is_some_and(|token| token.kind == TokenKind::GitSuffix)) - { - return Err(ParseError); - } - } - let mut decoded = String::new(); - for token in value { - decoded.push_str( - &percent_encoding::percent_decode_str(token.raw) - .decode_utf8() - .map_err(|_| ParseError)?, - ); - } - (!decoded.contains('\0')) - .then_some(decoded) - .ok_or(ParseError) -} - -fn reference(rev: Revision) -> Result<String, ParseError> { - match rev { - Revision::Ref(reference) => Ok(reference), - _ => Err(ParseError), - } -} - -fn route<T>(repo: String, params: T) -> RepoRoute<T> { - RepoRoute { repo, params } -} - -#[cfg(test)] -mod tests { - use super::{ - Comparison, RefPath, RepoRoute, Revision, RevisionFile, RevisionPath, Route, TokenKind, - lex, parse, - }; - - const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; - const OTHER: &str = "89abcdef0123456789abcdef0123456789abcdef"; - - fn repository<T>(params: T) -> RepoRoute<T> { - repo("group/проект", params) - } - - fn repo<T>(name: &str, params: T) -> RepoRoute<T> { - RepoRoute { - repo: name.to_owned(), - params, - } - } - - #[test] - fn lexer_preserves_raw_tokens() { - use TokenKind::{Boundary, GitSuffix, Range, Slash, Text}; - - let tokens = lex("/repo%2Egit.git/diff/HEAD..refs/heads/main/+/src").unwrap(); - assert_eq!( - tokens - .iter() - .map(|token| (token.kind, token.raw)) - .collect::<Vec<_>>(), - [ - (Slash, "/"), - (Text, "repo%2Egit"), - (GitSuffix, ".git"), - (Slash, "/"), - (Text, "diff"), - (Slash, "/"), - (Text, "HEAD"), - (Range, ".."), - (Text, "refs"), - (Slash, "/"), - (Text, "heads"), - (Slash, "/"), - (Text, "main"), - (Boundary, "/+/"), - (Text, "src"), - ] - ); - } - - #[test] - fn parses_repository_views() { - assert_eq!(parse("/").unwrap(), Route::Repositories); - assert_eq!( - parse("/group/%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82").unwrap(), - Route::Overview(repository(())) - ); - assert_eq!( - parse("/foo%2Egit/+/summary").unwrap(), - Route::Summary(repo("foo.git", ())) - ); - assert_eq!( - parse("/odd..repo").unwrap(), - Route::Overview(repo("odd..repo", ())) - ); - for (path, expected) in [ - ("about", Route::About(repository(()))), - ("stats", Route::Stats(repository(()))), - ("summary", Route::Summary(repository(()))), - ("refs", Route::Refs(repository(()))), - ] { - assert_eq!(parse(&format!("/group/проект/+/{path}")).unwrap(), expected); - } - assert_eq!( - parse(&format!("/group/проект/+/object/{COMMIT}")).unwrap(), - Route::Object(repository(COMMIT.to_owned())) - ); - } - - #[test] - fn parses_revision_views_and_escaped_paths() { - let reference = Revision::Ref("refs/heads/feature/+".to_owned()); - assert_eq!( - parse("/group/проект/+/refs/heads/feature/%2B").unwrap(), - Route::Revision(repository(reference.clone())) - ); - assert_eq!( - parse("/group/проект/+/refs/heads/feature/%2B/+/tree").unwrap(), - Route::Tree(repository(RevisionPath { - rev: reference.clone(), - path: None, - })) - ); - assert_eq!( - parse("/group/проект/+/refs/heads/feature/%2B/+/tree/src/%2B/lib.rs").unwrap(), - Route::Tree(repository(RevisionPath { - rev: reference.clone(), - path: Some("src/+/lib.rs".to_owned()), - })) - ); - assert_eq!( - parse("/group/проект/+/HEAD/+/tree/src/a..b/foo.git").unwrap(), - Route::Tree(repository(RevisionPath { - rev: Revision::Head, - path: Some("src/a..b/foo.git".to_owned()), - })) - ); - assert_eq!( - parse(&format!("/group/проект/+/{COMMIT}/+/log/src/lib.rs")).unwrap(), - Route::Log(repository(RevisionPath { - rev: Revision::Commit(COMMIT.to_owned()), - path: Some("src/lib.rs".to_owned()), - })) - ); - assert_eq!( - parse("/group/проект/+/HEAD/+/archive").unwrap(), - Route::Archive(repository(RevisionPath { - rev: Revision::Head, - path: None, - })) - ); - assert_eq!( - parse("/group/проект/+/refs/heads/main/+/blame/src/lib.rs").unwrap(), - Route::Blame(repository(RevisionFile { - rev: Revision::Ref("refs/heads/main".to_owned()), - path: "src/lib.rs".to_owned(), - })) - ); - } - - #[test] - fn parses_feeds_comparisons_and_patches() { - assert_eq!( - parse("/group/проект/+/refs/heads/main/+/feed/atom/src/lib.rs").unwrap(), - Route::AtomFeed(repository(RefPath { - reference: "refs/heads/main".to_owned(), - path: Some("src/lib.rs".to_owned()), - })) - ); - assert_eq!( - parse(&format!( - "/group/проект/+/diff/refs/heads/main..{COMMIT}/+/src/lib.rs" - )) - .unwrap(), - Route::Diff(repository(Comparison { - old_rev: Revision::Ref("refs/heads/main".to_owned()), - new_rev: Revision::Commit(COMMIT.to_owned()), - path: Some("src/lib.rs".to_owned()), - })) - ); - assert_eq!( - parse(&format!("/group/проект/+/patch/{COMMIT}..{OTHER}")).unwrap(), - Route::Patch(repository(Comparison { - old_rev: Revision::Commit(COMMIT.to_owned()), - new_rev: Revision::Commit(OTHER.to_owned()), - path: None, - })) - ); - } - - #[test] - fn parses_git_transport_routes() { - for (path, expected) in [ - ("/group/проект.git", Route::GitClone(repository(()))), - ("/foo%2Egit.git", Route::GitClone(repo("foo.git", ()))), - ( - "/group/проект.git/info/refs", - Route::GitInfoRefs(repository(())), - ), - ( - "/group/проект.git/git-upload-pack", - Route::GitUploadPack(repository(())), - ), - ( - "/group/проект.git/git-receive-pack", - Route::GitReceivePack(repository(())), - ), - ("/group/проект.git/HEAD", Route::GitHead(repository(()))), - ( - "/group/проект.git/objects/ab/cdef", - Route::GitObjects(repository("ab/cdef".to_owned())), - ), - ( - "/group/проект.git/info/lfs/objects/batch", - Route::GitLfs(repository("objects/batch".to_owned())), - ), - ] { - assert_eq!(parse(path).unwrap(), expected); - } - } - - #[test] - fn rejects_ambiguous_or_noncanonical_routes() { - for path in [ - "", - "/-/about", - "/repo/", - "/repo/+/", - "/repo/+/main", - "/repo/+/deadbeef", - "/repo/+/refs/heads/bad..ref", - "/repo/+/HEAD/+/blame", - "/repo/+/HEAD/+/feed/atom", - "/repo/+/diff/HEAD...HEAD", - "/repo/+/diff/HEAD..", - "/repo/+/tree", - "/repo.git/unknown", - "/repo/%zz", - "/repo?format=raw", - ] { - assert!(parse(path).is_err(), "accepted {path}"); - } - } -} diff --git a/crates/gilti/src/ui.rs b/crates/gilti/src/ui.rs new file mode 100644 --- /dev/null +++ b/crates/gilti/src/ui.rs @@ -0,0 +1,296 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{Markup, PreEscaped, html}; +use serde::Deserialize; + +pub const PRIVATE_CONTENT_TYPE: &str = "application/vnd.gilti.repolist+json"; + +#[derive(Deserialize)] +struct RepoList { + page: String, + title: String, + root_desc: String, + root_url: String, + about_url: String, + noheader: bool, + search: String, + current_url: String, + root_readme: bool, + owner_enabled: bool, + links_enabled: bool, + section_grouping: bool, + shell: Shell, + sort_urls: SortUrls, + rows: Vec<Row>, + pager: Vec<Pager>, +} + +#[derive(Deserialize)] +struct Shell { + embedded: bool, + robots: String, + css: Vec<String>, + js: Vec<String>, + favicon: String, + head_include: Option<String>, + header: Option<String>, + footer_configured: bool, + footer: Option<String>, + logo: String, + logo_link: String, + cgit_version: String, + git_version: String, + generated_at: String, +} + +#[derive(Deserialize)] +struct SortUrls { + name: String, + desc: String, + owner: String, + idle: String, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum Row { + Section(Section), + Repo(Box<Repo>), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Section { + section: Option<String>, +} + +#[derive(Deserialize)] +struct Repo { + name: String, + section: Option<String>, + url: String, + description: Description, + owner: String, + owner_url: String, + idle: Option<Age>, + log_url: String, + tree_url: String, +} + +#[derive(Deserialize)] +struct Description { + text: String, + truncated: bool, +} + +#[derive(Deserialize)] +struct Age { + timestamp: i64, + title: String, + unit: String, + amount: f64, +} + +#[derive(Deserialize)] +struct Pager { + url: String, + current: bool, +} + +pub fn render(body: &[u8]) -> Result<Markup, serde_json::Error> { + let page: RepoList = serde_json::from_slice(body)?; + if page.page != "repolist" { + return Err(serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "unknown private page", + ))); + } + Ok(render_repolist(page)) +} + +fn include(content: &Option<String>) -> Markup { + PreEscaped(content.clone().unwrap_or_default()) +} + +fn render_repolist(page: RepoList) -> Markup { + let shell = &page.shell; + html! { + @if shell.embedded { + (include(&shell.header)) + } @else { + (maud::DOCTYPE) + html lang="en" { + head { + title { (&page.title) } + meta name="generator" content=(format!("cgit {}", shell.cgit_version)); + + @if !shell.robots.is_empty() { + meta name="robots" content=(&shell.robots); + } + + @if shell.css.is_empty() { + link rel="stylesheet" type="text/css" href="/cgit.css"; + } + + @for css in &shell.css { + @if !css.is_empty() { + link rel="stylesheet" type="text/css" href=(css); + } + } + + @if shell.js.is_empty() { + script type="text/javascript" src="/cgit.js" {} + } + + @for js in &shell.js { + @if !js.is_empty() { + script type="text/javascript" src=(js) {} + } + } + + @if !shell.favicon.is_empty() { + link rel="shortcut icon" href=(&shell.favicon); + } + + (include(&shell.head_include)) + } + body { + (include(&shell.header)) + (page_content(&page, true)) + } + } + } + @if shell.embedded { + (page_content(&page, false)) + (footer(shell, true)) + } + } +} + +fn page_content(page: &RepoList, footer_inside: bool) -> Markup { + let shell = &page.shell; + let has_repos = page.rows.iter().any(|row| matches!(row, Row::Repo(_))); + html! { + div id="cgit" { + @if !page.noheader { + table id="header" { + tr { + @if !shell.logo.is_empty() { + td class="logo" rowspan="2" { + a href=(if shell.logo_link.is_empty() { &page.root_url } else { &shell.logo_link }) { + img src=(&shell.logo) alt="cgit logo"; + } + } + } + td class="main" { (&page.title) } + } + tr { td class="sub" { (&page.root_desc) } } + } + } + table class="tabs" { tr { + td { + a href=(&page.root_url) class="active" { "index" } + @if page.root_readme { a href=(&page.about_url) { "about" } } + } + td class="form" { + form method="get" action=(&page.current_url) { + input type="search" name="q" size="10" value=(&page.search); + input type="submit" value="search"; + } + } + }} + div class="content" { + table summary="repository list" class="list nowrap" { + @if has_repos { tr class="nohover" { + th class="left" { a href=(&page.sort_urls.name) { "Name" } } + th class="left" { a href=(&page.sort_urls.desc) { "Description" } } + @if page.owner_enabled { th class="left" { a href=(&page.sort_urls.owner) { "Owner" } } } + th class="left" { a href=(&page.sort_urls.idle) { "Idle" } } + @if page.links_enabled { th class="left" { "Links" } } + }} + @for row in &page.rows { + @match row { + Row::Section(section) => tr class="nohover-highlight" { + td colspan=(3 + usize::from(page.owner_enabled) + usize::from(page.links_enabled)) class="reposection" { (section.section.as_deref().unwrap_or("")) } + }, + Row::Repo(repo) => tr { + td class=(if page.section_grouping && repo.section.is_some() { "sublevel-repo" } else { "toplevel-repo" }) { a href=(&repo.url) { (&repo.name) } } + td { a href=(&repo.url) { (&repo.description.text) @if repo.description.truncated { "..." } } } + @if page.owner_enabled { td { a href=(&repo.owner_url) { (&repo.owner) } } } + td { @if let Some(age) = &repo.idle { span class=(if age.unit == "minutes" { "age-mins".to_owned() } else { format!("age-{}", age.unit) }) data-ut=(age.timestamp) title=(&age.title) { (format!("{:.0}", age.amount)) " " (if age.unit == "minutes" { "min." } else { &age.unit }) } } } + @if page.links_enabled { td { a class="button" href=(&repo.url) { "summary" } a class="button" href=(&repo.log_url) { "log" } a class="button" href=(&repo.tree_url) { "tree" } } } + }, + } + } + } + @if !page.pager.is_empty() { ul class="pager" { @for (index, item) in page.pager.iter().enumerate() { li { a href=(&item.url) class=[item.current.then_some("current")] title=(format!("Page {}", index + 1)) { "[" (index + 1) "]" } } } } } + } + @if footer_inside { (footer(&page.shell, false)) } + } + } +} + +fn footer(shell: &Shell, embedded: bool) -> Markup { + if let Some(footer) = &shell.footer { + return PreEscaped(footer.clone()); + } + if embedded || shell.footer_configured { + return Markup::default(); + } + html! { div class="footer" { "generated by " a href="https://git.zx2c4.com/cgit/about/" { "cgit " (&shell.cgit_version) } " (" a href="https://git-scm.com/" { "git " (&shell.git_version) } ") at " (&shell.generated_at) } } +} + +#[cfg(test)] +mod tests { + #[test] + fn renders_shell_and_escapes_data() { + let markup = super::render(br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":false,"search":"<x>","current_url":"/","root_readme":true,"owner_enabled":true,"links_enabled":true,"section_grouping":true,"shell":{"embedded":false,"robots":"noindex","css":["/custom.css"],"js":["/custom.js"],"favicon":"/favicon.ico","head_include":"<meta name='x'>","header":"ignored","footer_configured":false,"footer":null,"logo":"/cgit.png","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[{"name":"<repo>","section":"group","url":"/?url=repo","description":{"text":"x&y","truncated":true},"owner":"o","owner_url":"/?q=o","idle":{"timestamp":1,"title":"date","unit":"years","amount":1},"log_url":"/?url=repo/log/&showmsg=1","tree_url":"/?url=repo/tree/"}],"pager":[]}"#).unwrap().into_string(); + assert!(markup.contains("<repo>")); + assert!(markup.contains("x&y...")); + assert!(markup.contains("href=\"/?url=repo/log/&showmsg=1\"")); + assert!(markup.contains("/custom.css")); + assert!(markup.contains("cgit.png")); + assert!(markup.contains("name='x'")); + } + + #[test] + fn empty_rows_have_no_column_header() { + let markup = super::render(br#"{"page":"repolist","title":"Gilti","root_desc":"","root_url":"/","about_url":"/?p=about","noheader":true,"search":"","current_url":"/","root_readme":false,"owner_enabled":false,"links_enabled":false,"section_grouping":false,"shell":{"embedded":false,"robots":"","css":[],"js":[],"favicon":"","head_include":null,"header":null,"footer_configured":false,"footer":null,"logo":"","logo_link":"","cgit_version":"v1","git_version":"2","generated_at":"now"},"sort_urls":{"name":"/?s=name","desc":"/?s=desc","owner":"/?s=owner","idle":"/?s=idle"},"rows":[],"pager":[{"url":"/?ofs=0","current":false}]}"#).unwrap().into_string(); + assert!(!markup.contains("<tr class=\"nohover\">")); + } + + #[test] + fn footer_respects_embedded_and_unreadable_include_modes() { + let mut shell = super::Shell { + embedded: false, + robots: String::new(), + css: Vec::new(), + js: Vec::new(), + favicon: String::new(), + head_include: None, + header: None, + footer_configured: false, + footer: None, + logo: String::new(), + logo_link: String::new(), + cgit_version: "v1".into(), + git_version: "2".into(), + generated_at: "now".into(), + }; + assert!(super::footer(&shell, true).into_string().is_empty()); + shell.footer_configured = true; + assert!(super::footer(&shell, false).into_string().is_empty()); + shell.footer = Some("<footer>configured</footer>".into()); + assert!( + super::footer(&shell, true) + .into_string() + .contains("<footer>configured</footer>") + ); + } + + #[test] + fn rejects_invalid_model() { + assert!(super::render(br#"{"page":"repolist"}"#).is_err()); + } +} diff --git a/crates/gilti/src/views/about.rs b/crates/gilti/src/views/about.rs deleted file mode 100644 --- a/crates/gilti/src/views/about.rs +++ /dev/null @@ -1,36 +0,0 @@ -// 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.rs b/crates/gilti/src/views/archive.rs deleted file mode 100644 --- a/crates/gilti/src/views/archive.rs +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use tokio::io::AsyncWriteExt; - -#[derive(Clone, Copy)] -pub enum Format { - Tar, - TarGzip, - TarBzip2, - TarLzip, - TarXz, - TarZstd, - Zip, -} - -impl Format { - pub fn parse(value: Option<&str>) -> Option<Self> { - match value.unwrap_or("tar.gz") { - "tar" => Some(Self::Tar), - "tar.gz" => Some(Self::TarGzip), - "tar.bz2" => Some(Self::TarBzip2), - "tar.lz" => Some(Self::TarLzip), - "tar.xz" => Some(Self::TarXz), - "tar.zst" => Some(Self::TarZstd), - "zip" => Some(Self::Zip), - _ => None, - } - } - - fn extension(self) -> &'static str { - match self { - Self::Tar => "tar", - Self::TarGzip => "tar.gz", - Self::TarBzip2 => "tar.bz2", - Self::TarLzip => "tar.lz", - Self::TarXz => "tar.xz", - Self::TarZstd => "tar.zst", - Self::Zip => "zip", - } - } - - fn content_type(self) -> &'static str { - match self { - Self::Tar => "application/x-tar; charset=UTF-8", - Self::TarGzip => "application/x-gzip; charset=UTF-8", - Self::TarBzip2 => "application/x-bzip2; charset=UTF-8", - Self::TarLzip => "application/x-lzip; charset=UTF-8", - Self::TarXz => "application/x-xz; charset=UTF-8", - Self::TarZstd => "application/x-zstd; charset=UTF-8", - Self::Zip => "application/x-zip; charset=UTF-8", - } - } - - fn git_format(self) -> &'static str { - match self { - Self::TarGzip => "tar.gz", - Self::Zip => "zip", - _ => "tar", - } - } - - fn compressor(self) -> Option<(&'static str, &'static [&'static str])> { - match self { - Self::TarBzip2 => Some(("/usr/bin/bzip2", &[])), - Self::TarLzip => Some(("/usr/bin/lzip", &[])), - Self::TarXz => Some(("/usr/bin/xz", &[])), - Self::TarZstd => Some(("/usr/bin/zstd", &["-T0"])), - _ => None, - } - } -} - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::RevisionPath>, - format: Format, - 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 name = route.repo.clone(); - let revision = route.params.rev.clone(); - let path = route.params.path.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::archive::Archive::load( - std::path::Path::new(repositories), - &name, - &revision, - path.as_deref(), - ) - }) - .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 bytes = match generate(&model, route.params.path.as_deref(), format).await { - Ok(bytes) => bytes, - Err(error) => return super::error(crate::models::Error::Internal(error)), - }; - let filename = format!("{}.{}", model.prefix, format.extension()); - super::bytes_response( - format.content_type(), - Some(content_disposition(&filename)), - Some(format!("\"{}\"", model.oid)), - bytes, - &method, - ) -} - -async fn generate( - model: &crate::models::archive::Archive, - path: Option<&str>, - format: Format, -) -> Result<Vec<u8>, String> { - let mut command = tokio::process::Command::new(crate::GIT); - command - .arg("--git-dir") - .arg(&model.repository_path) - .arg("archive") - .arg(format!("--format={}", format.git_format())) - .arg(format!("--prefix={}/", model.prefix)) - .arg(&model.oid) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1"); - if let Some(path) = path { - command.arg("--").arg(format!(":(literal){path}")); - } - let output = command.output().await.map_err(|error| error.to_string())?; - if !output.status.success() { - return Err(format!( - "git archive failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - let Some((program, arguments)) = format.compressor() else { - return Ok(output.stdout); - }; - let mut child = tokio::process::Command::new(program) - .args(arguments) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|error| error.to_string())?; - let mut input = child.stdin.take().expect("compressor stdin is piped"); - let bytes = output.stdout; - let writer = tokio::spawn(async move { - input.write_all(&bytes).await?; - input.shutdown().await - }); - let output = child - .wait_with_output() - .await - .map_err(|error| error.to_string())?; - writer - .await - .map_err(|error| error.to_string())? - .map_err(|error| error.to_string())?; - if output.status.success() { - Ok(output.stdout) - } else { - Err(format!( - "archive compressor failed: {}", - String::from_utf8_lossy(&output.stderr) - )) - } -} - -fn content_disposition(filename: &str) -> String { - let fallback = filename - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { - character - } else { - '_' - } - }) - .collect::<String>(); - let encoded = - percent_encoding::utf8_percent_encode(filename, percent_encoding::NON_ALPHANUMERIC); - format!("inline; filename=\"{fallback}\"; filename*=UTF-8''{encoded}") -} diff --git a/crates/gilti/src/views/archive_signature.rs b/crates/gilti/src/views/archive_signature.rs deleted file mode 100644 --- a/crates/gilti/src/views/archive_signature.rs +++ /dev/null @@ -1,53 +0,0 @@ -// 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<crate::router::Revision>, - 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/atom.rs b/crates/gilti/src/views/atom.rs deleted file mode 100644 --- a/crates/gilti/src/views/atom.rs +++ /dev/null @@ -1,247 +0,0 @@ -// 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<crate::router::RefPath>, - host: Option<&axum::http::HeaderValue>, - 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 name = route.repo; - let reference = route.params.reference; - let path = route.params.path; - let selection_path = path.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::history::History::load( - std::path::Path::new(repositories), - &name, - crate::router::Revision::Ref(reference), - crate::models::history::Options { - path: selection_path, - follow: false, - search: crate::models::history::Search::None, - offset: 0, - limit: 10, - graph: false, - ignore_whitespace: false, - include_statistics: false, - }, - ) - }) - .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 host = host - .and_then(|host| host.to_str().ok()) - .filter(|host| !host.is_empty()); - let body = document(&model, path.as_deref(), host); - super::bytes_response( - "text/xml; charset=UTF-8", - None, - None, - body.into_bytes(), - &method, - ) -} - -fn document( - model: &crate::models::history::History, - path: Option<&str>, - host: Option<&str>, -) -> String { - let repository = super::shared::repository_url(&model.repository.name); - let reference = &model.revision; - let mut feed = String::from("<feed xmlns='http://www.w3.org/2005/Atom'>\n<title>"); - text(&mut feed, &model.repository.name); - if let Some(path) = path { - feed.push('/'); - text(&mut feed, path); - } - feed.push_str(", branch "); - text(&mut feed, reference); - feed.push_str("</title>\n<subtitle>"); - text(&mut feed, &model.repository.description); - feed.push_str("</subtitle>\n"); - let self_path = path.map_or_else( - || { - format!( - "{repository}/+/{}/+/feed/atom", - super::shared::encode_path(&model.revision) - ) - }, - |path| { - format!( - "{repository}/+/{}/+/feed/atom/{}", - super::shared::encode_path(&model.revision), - super::shared::encode_path(path) - ) - }, - ); - if let Some(host) = host { - let absolute = |path: &str| format!("http://{host}{path}"); - element(&mut feed, "id", &absolute(&self_path)); - attribute( - &mut feed, - "link", - &[('r', "self"), ('h', &absolute(&self_path))], - ); - attribute( - &mut feed, - "link", - &[ - ('r', "alternate"), - ('t', "text/html"), - ('h', &absolute(&repository)), - ], - ); - } - if let Some(entry) = model.entries.first() { - element(&mut feed, "updated", ×tamp(&entry.committer)); - } - for entry in &model.entries { - feed.push_str("<entry>\n<title>"); - text(&mut feed, &entry.subject); - feed.push_str("</title>\n"); - element(&mut feed, "updated", ×tamp(&entry.committer)); - feed.push_str("<author>\n"); - if !entry.author.name.is_empty() { - element(&mut feed, "name", &entry.author.name); - } - if !entry.author.email.is_empty() { - element(&mut feed, "email", &entry.author.email); - } - feed.push_str("</author>\n"); - element(&mut feed, "published", ×tamp(&entry.author)); - if let Some(host) = host { - attribute( - &mut feed, - "link", - &[ - ('r', "alternate"), - ('t', "text/html"), - ('h', &format!("http://{host}{repository}/+/{}", entry.oid)), - ], - ); - } - element(&mut feed, "id", &urn(&entry.oid)); - feed.push_str("<content type='text'>\n"); - text(&mut feed, &entry.body); - feed.push_str("</content>\n</entry>\n"); - } - feed.push_str("</feed>\n"); - feed -} - -fn element(output: &mut String, name: &str, value: &str) { - output.push('<'); - output.push_str(name); - output.push('>'); - text(output, value); - output.push_str("</"); - output.push_str(name); - output.push_str(">\n"); -} - -fn attribute(output: &mut String, name: &str, attributes: &[(char, &str)]) { - output.push('<'); - output.push_str(name); - for (key, value) in attributes { - let key = match key { - 'r' => "rel", - 't' => "type", - 'h' => "href", - _ => unreachable!(), - }; - output.push(' '); - output.push_str(key); - output.push_str("='"); - attribute_value(output, value); - output.push('\''); - } - output.push_str("/>\n"); -} - -fn text(output: &mut String, value: &str) { - for character in value.chars() { - match character { - '&' => output.push_str("&"), - '<' => output.push_str("<"), - '>' => output.push_str(">"), - character if xml_character(character) => output.push(character), - _ => output.push('\u{fffd}'), - } - } -} -fn attribute_value(output: &mut String, value: &str) { - for character in value.chars() { - match character { - '&' => output.push_str("&"), - '<' => output.push_str("<"), - '\'' => output.push_str("'"), - '"' => output.push_str("""), - character if xml_character(character) => output.push(character), - _ => output.push('\u{fffd}'), - } - } -} - -fn xml_character(character: char) -> bool { - matches!(character, '\u{9}' | '\u{a}' | '\u{d}') - || ('\u{20}'..='\u{d7ff}').contains(&character) - || ('\u{e000}'..='\u{fffd}').contains(&character) - || character >= '\u{10000}' -} - -fn urn(oid: &str) -> String { - let algorithm = if oid.len() == 64 { "sha256" } else { "sha1" }; - format!("urn:{algorithm}:{oid}") -} - -fn timestamp(identity: &crate::models::commit::Identity) -> String { - let Some(value) = crate::models::time::utc(identity.timestamp) else { - return "1970-01-01T00:00:00Z".to_owned(); - }; - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", - value.tm_year + 1900, - value.tm_mon + 1, - value.tm_mday, - value.tm_hour, - value.tm_min, - value.tm_sec - ) -} - -#[cfg(test)] -mod tests { - #[test] - fn escapes_xml_and_identifies_sha256() { - let mut output = String::new(); - super::text(&mut output, "<&>\0\u{1}\t"); - assert_eq!(output, "<&>��\t"); - assert_eq!( - super::urn(&"a".repeat(40)), - format!("urn:sha1:{}", "a".repeat(40)) - ); - assert_eq!( - super::urn(&"b".repeat(64)), - format!("urn:sha256:{}", "b".repeat(64)) - ); - assert_eq!( - super::timestamp(&crate::models::commit::Identity { - name: String::new(), - email: String::new(), - timestamp: 0, - offset_minutes: 330 - }), - "1970-01-01T00:00:00Z" - ); - } -} diff --git a/crates/gilti/src/views/blame.rs b/crates/gilti/src/views/blame.rs deleted file mode 100644 --- a/crates/gilti/src/views/blame.rs +++ /dev/null @@ -1,112 +0,0 @@ -// 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<crate::router::RevisionFile>, - 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::<Vec<_>>(); - 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::<Vec<_>>(); - 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/diff.rs b/crates/gilti/src/views/diff.rs deleted file mode 100644 --- a/crates/gilti/src/views/diff.rs +++ /dev/null @@ -1,456 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -#[derive(Clone, Copy, Eq, PartialEq)] -pub enum Mode { - Unified, - SideBySide, - StatOnly, -} - -#[derive(Clone, Copy)] -pub struct Query { - pub context: u32, - pub ignore_whitespace: bool, - pub mode: Mode, -} - -impl Query { - pub fn from_request(query: &crate::RequestQuery) -> Result<Self, ()> { - let context = query - .value("GILTI_QUERY_CONTEXT") - .map(str::parse) - .transpose() - .map_err(|_| ())? - .unwrap_or(3); - let context = if context == 0 { 3 } else { context }; - if context > 40 { - return Err(()); - } - let ignore_whitespace = match query.value("GILTI_QUERY_IGNOREWS") { - None | Some("0") => false, - Some("1") => true, - _ => return Err(()), - }; - let mode = match query.value("GILTI_QUERY_DIFFTYPE") { - None | Some("0") => Mode::Unified, - Some("1") => Mode::SideBySide, - Some("2") => Mode::StatOnly, - _ => return Err(()), - }; - Ok(Self { - context, - ignore_whitespace, - mode, - }) - } - - pub fn options(self) -> crate::models::diff::Options { - crate::models::diff::Options { - context: self.context, - ignore_whitespace: self.ignore_whitespace, - } - } -} - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::Comparison>, - query: Query, - raw: bool, - 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 name = route.repo.clone(); - let old = route.params.old_rev.clone(); - let new = route.params.new_rev.clone(); - let path = route.params.path.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::diff::Diff::load( - std::path::Path::new(repositories), - &name, - Some(old), - new, - path, - query.options(), - ) - }) - .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 raw { - return raw_response(context, &route, &model, query, &method).await; - } - let revision = model.new_revision.clone(); - let content = content(&model, query, route.params.path.as_deref(), true); - super::shared::render( - context, - &model.repository, - &revision, - super::shared::Page::Diff, - content, - &method, - ) -} - -pub fn content( - model: &crate::models::diff::Diff, - query: Query, - path: Option<&str>, - controls: bool, -) -> Markup { - html! { - @if controls { (content_controls(query)) } - div class="diffstat-header" { - "Diffstat" - @if let Some(path) = path { " (limited to '" (path) "')" } - } - table summary="diffstat" class="diffstat" { - @for file in &model.files { (file_stat(model, file)) } - } - div class="diffstat-summary" { - (model.files.len()) " files changed, " (model.additions) " insertions, " (model.deletions) " deletions" - } - @if query.mode != Mode::StatOnly { - @if query.mode == Mode::SideBySide { - table summary="ssdiff" class="ssdiff" { - @for file in &model.files { (side_file(model, file)) } - } - } @else { - table summary="diff" class="diff" { tr { td { - @for file in &model.files { (unified_file(model, file)) } - } } } - } - } - } -} - -pub fn content_controls(query: Query) -> Markup { - html! { div class="gilti-panel" { - b { "diff options" } - form method="get" { table { - tr { td colspan="2" {} } - tr { td class="label" { "context:" } td class="ctrl" { - select name="context" onchange="this.form.submit();" { - @for value in [1_u32,2,3,4,5,6,7,8,9,10,15,20,25,30,35,40] { - option value=(value) selected[query.context == value] { (value) } - } - } - } } - tr { td class="label" { "space:" } td class="ctrl" { - select name="ignorews" onchange="this.form.submit();" { - option value="0" selected[!query.ignore_whitespace] { "include" } - option value="1" selected[query.ignore_whitespace] { "ignore" } - } - } } - tr { td class="label" { "mode:" } td class="ctrl" { - select name="dt" onchange="this.form.submit();" { - option value="0" selected[query.mode == Mode::Unified] { "unified" } - option value="1" selected[query.mode == Mode::SideBySide] { "ssdiff" } - option value="2" selected[query.mode == Mode::StatOnly] { "stat only" } - } - } } - tr { td {} td class="ctrl" { noscript { input type="submit" value="reload"; } } } - } } - } } -} - -fn file_stat(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup { - let path = if file.new_path.is_empty() { - &file.old_path - } else { - &file.new_path - }; - let repo = super::shared::repository_url(&model.repository.name); - let old = super::shared::encode_path(model.old_revision.as_deref().unwrap_or("HEAD")); - let new = super::shared::encode_path(&model.new_revision); - let path_url = super::shared::encode_path(path); - let class = status_class(file.status); - html! { tr { - td class="mode" { - (super::tree::filemode(if file.new_mode == 0 { file.old_mode } else { file.new_mode })) - @if file.old_mode != 0 && file.new_mode != 0 && file.old_mode != file.new_mode { - span class="modechange" { "[" (super::tree::filemode(file.old_mode)) "]" } - } - } - td class=(class) { - a href=(format!("{repo}/+/diff/{old}..{new}/+/{path_url}")) { (path) } - @if matches!(file.status, git2::Delta::Renamed | git2::Delta::Copied) { - " (" @if file.status == git2::Delta::Copied { "copied" } @else { "renamed" } - " from " (&file.old_path) ")" - } - } - td class="right" { - @if file.binary { "bin" } @else { (file.additions + file.deletions) } - } - td class="graph" { - @if file.binary { (file.old_size) " -> " (file.new_size) " bytes" } - @else { "+" (file.additions) " −" (file.deletions) } - } - } } -} - -fn unified_file(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup { - html! { - (file_header(model, file)) - @if file.binary { div class="ctx" { "Binary files differ" } } - @for hunk in &file.hunks { - div class="hunk" { (&hunk.header) } - @for line in &hunk.lines { - div class=(line_class(line.origin)) { - @if matches!(line.origin, '+' | '-' | ' ') { (line.origin) } - (&line.content) - } - } - } - } -} - -fn side_file(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup { - html! { - tr { td colspan="4" { (file_header(model, file)) } } - @if file.binary { tr { td colspan="4" { "Binary files differ" } } } - @for hunk in &file.hunks { - tr { td colspan="4" class="hunk" { (&hunk.header) } } - @for (old, new) in side_rows(hunk) { tr { - td class="lineno" { @if let Some(line) = old { (line.old_line.unwrap_or_default()) } } - td class=[old.map(|line| line_class(line.origin))] { (side_content(old, new, true)) } - td class="lineno" { @if let Some(line) = new { (line.new_line.unwrap_or_default()) } } - td class=[new.map(|line| line_class(line.origin))] { (side_content(new, old, false)) } - } } - } - } -} - -fn side_rows( - hunk: &crate::models::diff::Hunk, -) -> Vec<( - Option<&crate::models::diff::Line>, - Option<&crate::models::diff::Line>, -)> { - let mut rows = Vec::new(); - let mut removed = Vec::new(); - let mut added = Vec::new(); - let flush = |rows: &mut Vec<_>, removed: &mut Vec<_>, added: &mut Vec<_>| { - let count = removed.len().max(added.len()); - for index in 0..count { - rows.push((removed.get(index).copied(), added.get(index).copied())); - } - removed.clear(); - added.clear(); - }; - for line in &hunk.lines { - match line.origin { - '-' => removed.push(line), - '+' => added.push(line), - _ => { - flush(&mut rows, &mut removed, &mut added); - rows.push((Some(line), Some(line))); - } - } - } - flush(&mut rows, &mut removed, &mut added); - rows -} - -fn side_content( - line: Option<&crate::models::diff::Line>, - other: Option<&crate::models::diff::Line>, - old: bool, -) -> Markup { - let Some(line) = line else { - return html! {}; - }; - let paired_change = - other.is_some_and(|other| matches!((line.origin, other.origin), ('-', '+') | ('+', '-'))); - if !paired_change { - return html! { (&line.content) }; - } - let other = &other.expect("paired line exists").content; - let common = if old { - common_subsequence(&line.content, other) - } else { - common_subsequence(other, &line.content) - }; - let segments = highlighted_segments(&line.content, &common); - let class = if old { "del" } else { "add" }; - html! { - @for (changed, text) in segments { - @if changed { span class=(class) { (text) } } @else { (text) } - } - } -} - -fn common_subsequence(old: &str, new: &str) -> Vec<char> { - let old = old.chars().collect::<Vec<_>>(); - let new = new.chars().collect::<Vec<_>>(); - if old.len() >= 200 || new.len() >= 200 { - return Vec::new(); - } - let mut lengths = vec![vec![0_usize; new.len() + 1]; old.len() + 1]; - for left in (0..old.len()).rev() { - for right in (0..new.len()).rev() { - lengths[left][right] = if old[left] == new[right] { - lengths[left + 1][right + 1] + 1 - } else { - lengths[left + 1][right].max(lengths[left][right + 1]) - }; - } - } - let (mut left, mut right) = (0, 0); - let mut common = Vec::with_capacity(lengths[0][0]); - while left < old.len() && right < new.len() { - if old[left] == new[right] { - common.push(old[left]); - left += 1; - right += 1; - } else if lengths[left + 1][right] >= lengths[left][right + 1] { - left += 1; - } else { - right += 1; - } - } - common -} - -fn highlighted_segments(value: &str, common: &[char]) -> Vec<(bool, String)> { - let mut common = common.iter().copied().peekable(); - let mut segments = Vec::<(bool, String)>::new(); - for character in value.chars() { - let changed = common.peek().copied() != Some(character); - if !changed { - common.next(); - } - if let Some((last_changed, text)) = segments.last_mut() - && *last_changed == changed - { - text.push(character); - continue; - } - segments.push((changed, character.to_string())); - } - segments -} - -fn file_header(model: &crate::models::diff::Diff, file: &crate::models::diff::File) -> Markup { - let old = if file.old_path.is_empty() { - "/dev/null" - } else { - &file.old_path - }; - let new = if file.new_path.is_empty() { - "/dev/null" - } else { - &file.new_path - }; - html! { div class="head" { - "diff --git a/" (old) " b/" (new) - @if file.old_mode == 0 { br; "new file mode " (format!("{:06o}", file.new_mode)) } - @if file.new_mode == 0 { br; "deleted file mode " (format!("{:06o}", file.old_mode)) } - @if let (Some(old_oid), Some(new_oid)) = (&file.old_oid, &file.new_oid) { - br; "index " (&old_oid[..old_oid.len().min(7)]) ".." (&new_oid[..new_oid.len().min(7)]) - @if file.old_mode != 0 && file.new_mode != 0 { - " " (format!("{:06o}", file.old_mode)) - @if file.old_mode != file.new_mode { ".." (format!("{:06o}", file.new_mode)) } - } - } - br; "--- " @if file.old_mode == 0 { "/dev/null" } @else { "a/" (old) } - br; "+++ " @if file.new_mode == 0 { "/dev/null" } @else { "b/" (new) } - @if model.old_oid.is_none() { "" } - } } -} - -fn line_class(origin: char) -> &'static str { - match origin { - '+' => "add", - '-' => "del", - '@' => "hunk", - _ => "ctx", - } -} - -fn status_class(status: git2::Delta) -> &'static str { - match status { - git2::Delta::Added => "add", - git2::Delta::Copied => "cpy", - git2::Delta::Deleted => "del", - git2::Delta::Modified => "upd", - git2::Delta::Renamed => "mov", - git2::Delta::Typechange => "typ", - git2::Delta::Conflicted => "stg", - _ => "unk", - } -} - -async fn raw_response( - context: &super::shared::Context, - route: &crate::router::RepoRoute<crate::router::Comparison>, - model: &crate::models::diff::Diff, - query: Query, - method: &axum::http::Method, -) -> axum::response::Response { - let repository = match crate::models::repository::path( - std::path::Path::new(context.repositories), - &route.repo, - ) { - Ok(repository) => repository, - Err(error) => return super::error(error), - }; - let mut command = tokio::process::Command::new(crate::GIT); - command - .arg("--git-dir") - .arg(repository) - .arg("-c") - .arg("color.ui=false") - .arg("diff") - .arg("--no-ext-diff") - .arg("--no-textconv") - .arg("--no-renames") - .arg(format!("--unified={}", query.context)); - if query.ignore_whitespace { - command.arg("--ignore-all-space"); - } - command - .arg(model.old_oid.as_deref().unwrap_or("")) - .arg(&model.new_oid); - if let Some(path) = &route.params.path { - command.arg("--").arg(path); - } - let output = match command.output().await { - Ok(output) if output.status.success() => output.stdout, - Ok(output) => { - eprintln!( - "gilti: git diff failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - return super::error(crate::models::Error::Internal("git diff failed".to_owned())); - } - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - super::bytes_response("text/plain; charset=UTF-8", None, None, output, method) -} - -#[cfg(test)] -mod tests { - #[test] - fn highlights_side_by_side_character_changes() { - let common = super::common_subsequence("alpha", "aloha"); - assert_eq!(common, ['a', 'l', 'h', 'a']); - assert_eq!( - super::highlighted_segments("alpha", &common), - [ - (false, "al".to_owned()), - (true, "p".to_owned()), - (false, "ha".to_owned()), - ] - ); - } -} diff --git a/crates/gilti/src/views/log.rs b/crates/gilti/src/views/log.rs deleted file mode 100644 --- a/crates/gilti/src/views/log.rs +++ /dev/null @@ -1,298 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub struct Query { - pub offset: usize, - pub show_message: bool, - pub follow: bool, - pub ignore_whitespace: bool, - pub search: Search, -} - -pub enum Search { - None, - Grep(String), - Author(String), - Committer(String), - Range(String), -} - -impl Query { - pub fn from_request(query: &crate::RequestQuery) -> Result<Self, ()> { - let offset = match query.value("GILTI_QUERY_OFFSET") { - None => 0, - Some(value) => value.parse().map_err(|_| ())?, - }; - let boolean = |name| match query.value(name) { - None | Some("0") => Ok(false), - Some("1") => Ok(true), - Some(_) => Err(()), - }; - let search = match ( - query.value("GILTI_QUERY_GREP"), - query.value("GILTI_QUERY_SEARCH"), - ) { - (None, None) => Search::None, - (Some(_), None) | (None, Some(_)) => return Err(()), - (Some("grep"), Some(value)) => Search::Grep(value.to_owned()), - (Some("author"), Some(value)) => Search::Author(value.to_owned()), - (Some("committer"), Some(value)) => Search::Committer(value.to_owned()), - (Some("range"), Some(value)) if valid_range(value) => Search::Range(value.to_owned()), - _ => return Err(()), - }; - Ok(Self { - offset, - show_message: boolean("GILTI_QUERY_SHOWMSG")?, - follow: boolean("GILTI_QUERY_FOLLOW")?, - ignore_whitespace: boolean("GILTI_QUERY_IGNOREWS")?, - search, - }) - } - - fn history(&self) -> crate::models::history::Search { - match &self.search { - Search::None => crate::models::history::Search::None, - Search::Grep(value) => crate::models::history::Search::Grep(value.clone()), - Search::Author(value) => crate::models::history::Search::Author(value.clone()), - Search::Committer(value) => crate::models::history::Search::Committer(value.clone()), - Search::Range(value) => crate::models::history::Search::Range(value.clone()), - } - } - - fn suffix(&self, offset: usize, show_message: bool, follow: bool) -> String { - let mut values = Vec::new(); - if offset != 0 { - values.push(format!("ofs={offset}")); - } - if show_message { - values.push("showmsg=1".to_owned()); - } - if follow { - values.push("follow=1".to_owned()); - } - if self.ignore_whitespace { - values.push("ignorews=1".to_owned()); - } - match &self.search { - Search::None => {} - Search::Grep(value) => { - values.push("qt=grep".to_owned()); - values.push(format!("q={}", query(value))); - } - Search::Author(value) => { - values.push("qt=author".to_owned()); - values.push(format!("q={}", query(value))); - } - Search::Committer(value) => { - values.push("qt=committer".to_owned()); - values.push(format!("q={}", query(value))); - } - Search::Range(value) => { - values.push("qt=range".to_owned()); - values.push(format!("q={}", query(value))); - } - } - if values.is_empty() { - String::new() - } else { - format!("?{}", values.join("&")) - } - } -} - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<crate::router::RevisionPath>, - 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 name = route.repo; - let revision = route.params.rev; - let path = route.params.path; - let options = crate::models::history::Options { - path: path.clone(), - follow: query.follow, - search: query.history(), - offset: query.offset, - limit: crate::models::history::LOG_PAGE_SIZE, - graph: true, - ignore_whitespace: query.ignore_whitespace, - include_statistics: true, - }; - let model = tokio::task::spawn_blocking(move || { - crate::models::history::History::load( - std::path::Path::new(repositories), - &name, - revision, - options, - ) - }) - .await; - let model = match model { - Ok(Ok(value)) => value, - Ok(Err(error)) => return super::error(error), - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - super::shared::render( - context, - &model.repository, - &model.revision, - super::shared::Page::Log, - content(&model, &query, path.as_deref()), - &method, - ) -} - -fn content(model: &crate::models::history::History, query: &Query, path: Option<&str>) -> Markup { - let base = format!( - "{}/+/{}/+/log", - super::shared::repository_url(&model.repository.name), - super::shared::encode_path(&model.revision) - ); - let base = path.map_or(base.clone(), |path| { - format!("{base}/{}", super::shared::encode_path(path)) - }); - let columns = if model.graph { 6 } else { 5 }; - html! { - form class="right" method="get" action=(&base) { - @if query.show_message { input type="hidden" name="showmsg" value="1"; } - @if query.follow { input type="hidden" name="follow" value="1"; } - @if query.ignore_whitespace { input type="hidden" name="ignorews" value="1"; } - select name="qt" { - option value="grep" selected[matches!(query.search, Search::Grep(_))] { "log msg" } - option value="author" selected[matches!(query.search, Search::Author(_))] { "author" } - option value="committer" selected[matches!(query.search, Search::Committer(_))] { "committer" } - option value="range" selected[matches!(query.search, Search::Range(_))] { "range" } - } - input class="txt" type="search" size="10" name="q" value=(search_value(&query.search)); - input type="submit" value="search"; - } - @if let Some(path) = path { - div class="path" { - "path: " (path) " (" - a href=(format!("{}{}", base, query.suffix(query.offset, query.show_message, !query.follow))) { (if query.follow { "unfollow" } else { "follow" }) } - ")" - } - } - table class="list nowrap" { - tr class="nohover" { - @if model.graph { th {} } @else { th class="left" { "Age" } } - th class="left" { "Commit message (" a href=(format!("{}{}", base, query.suffix(query.offset, !query.show_message, query.follow))) { (if query.show_message { "Collapse" } else { "Expand" }) } ")" } - th class="left" { "Author" } - @if model.graph { th class="left" { "Age" } } - th class="left" { "Files" } - th class="left" { "Lines" } - } - @for entry in &model.entries { - @if model.graph { - @for continuation in &entry.graph_continuations { - tr class="nohover" { td class="commitgraph" { (graph(continuation)) } td colspan=(columns - 1) {} } - } - } - tr class=[query.show_message.then_some("logheader")] { - @if model.graph { td class="commitgraph" { (graph(&entry.graph)) } } @else { td { (age(&entry.committer)) } } - td class=[query.show_message.then_some("logsubject")] { - a href=(format!("{}/+/{}", super::shared::repository_url(&model.repository.name), entry.oid)) { (&entry.subject) } - @for decoration in &entry.decorations { - @if let Some(reference) = &decoration.reference { - @if reference.starts_with("refs/tags/") { - span class="decoration" { " " a class="tag-deco" href=(format!("{}/+/{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference))) { (&decoration.label) } } - } @else { - @let link = entry.path.as_ref().map_or_else(|| format!("{}/+/{}/+/log{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference), query.suffix(0, query.show_message, query.follow)), |path| format!("{}/+/{}/+/log/{}{}", super::shared::repository_url(&model.repository.name), super::shared::encode_path(reference), super::shared::encode_path(path), query.suffix(0, query.show_message, query.follow))); - span class="decoration" { " " a class="branch-deco" href=(link) { (&decoration.label) } } - } - } @else { span class="decoration" { " " (&decoration.label) } } - } - } - td { (&entry.author.name) } - @if model.graph { td { (age(&entry.committer)) } } - td { (entry.files) } - td { span class="deletions" { "-" (entry.deletions) } "/" span class="insertions" { "+" (entry.additions) } } - } - @if query.show_message { tr class="nohover-highlight" { td colspan=(columns) class="logmsg" { (&entry.body) @if let Some(notes) = &entry.notes { "\n" (notes) } } } } - } - } - ul class="pager" { - @if query.offset > 0 { li { a href=(format!("{}{}", base, query.suffix(query.offset.saturating_sub(crate::models::history::LOG_PAGE_SIZE), query.show_message, query.follow))) { "[prev]" } } } - @if model.has_next { li { a href=(format!("{}{}", base, query.suffix(query.offset + crate::models::history::LOG_PAGE_SIZE, query.show_message, query.follow))) { "[next]" } } } - } - } -} - -fn graph(line: &str) -> Markup { - html! { - @for (index, character) in line.chars().enumerate() { - span class=(format!("column{}", index / 2 % 6 + 1)) { (character) } - } - } -} - -fn search_value(search: &Search) -> &str { - match search { - Search::None => "", - Search::Grep(value) - | Search::Author(value) - | Search::Committer(value) - | Search::Range(value) => value, - } -} - -fn age(identity: &crate::models::commit::Identity) -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs() as i64); - if now.saturating_sub(identity.timestamp) < 14 * 24 * 60 * 60 { - return super::shared::age(identity.timestamp); - } - let local = identity.timestamp + i64::from(identity.offset_minutes) * 60; - let Some(value) = crate::models::time::utc(local) else { - return super::shared::age(identity.timestamp); - }; - format!( - "{:04}-{:02}-{:02}", - value.tm_year + 1900, - value.tm_mon + 1, - value.tm_mday - ) -} - -fn valid_range(value: &str) -> bool { - let mut any = false; - for selector in value.split_ascii_whitespace() { - any = true; - let valid = |selector: &str| { - selector == "HEAD" - || selector.starts_with("refs/") - || (matches!(selector.len(), 40 | 64) - && selector.bytes().all(|byte| byte.is_ascii_hexdigit())) - }; - if selector.starts_with('-') || !selector.split("..").all(valid) { - return false; - } - } - any -} - -fn query(value: &str) -> String { - percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string() -} - -#[cfg(test)] -mod tests { - #[test] - fn query_is_strict() { - assert!(super::Query::from_request(&crate::RequestQuery::default()).is_ok()); - let query = crate::request_query(Some("follow=2")).unwrap(); - assert!(super::Query::from_request(&query).is_err()); - let query = crate::request_query(Some("qt=range&q=-bad")).unwrap(); - assert!(super::Query::from_request(&query).is_err()); - let query = crate::request_query(Some("qt=range&q=HEAD..refs%2Fheads%2Fmain")).unwrap(); - assert!(super::Query::from_request(&query).is_ok()); - } -} diff --git a/crates/gilti/src/views/mod.rs b/crates/gilti/src/views/mod.rs deleted file mode 100644 --- a/crates/gilti/src/views/mod.rs +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub mod about; -pub mod archive; -pub mod archive_signature; -pub mod atom; -pub mod blame; -pub mod diff; -pub mod log; -pub mod object; -pub mod overview; -pub mod patch; -pub mod refs; -pub mod repositories; -pub mod revision; -pub mod shared; -pub mod stats; -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") -} - -pub(crate) fn bad_request(message: &'static str) -> axum::response::Response { - axum::response::Response::builder() - .status(axum::http::StatusCode::BAD_REQUEST) - .header(axum::http::header::CONTENT_TYPE, "text/plain") - .body(axum::body::Body::from(message)) - .expect("static response is valid") -} - -pub(crate) fn bytes_response( - content_type: &'static str, - content_disposition: Option<String>, - etag: Option<String>, - bytes: Vec<u8>, - method: &axum::http::Method, -) -> axum::response::Response { - let length = bytes.len(); - let body = if method == axum::http::Method::HEAD { - axum::body::Body::empty() - } else { - axum::body::Body::from(bytes) - }; - let mut response = axum::response::Response::builder() - .status(axum::http::StatusCode::OK) - .header(axum::http::header::CONTENT_TYPE, content_type) - .header(axum::http::header::CONTENT_LENGTH, length); - if let Some(value) = content_disposition { - response = response.header(axum::http::header::CONTENT_DISPOSITION, value); - } - if let Some(value) = etag { - response = response.header(axum::http::header::ETAG, value); - } - response.body(body).expect("byte 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 deleted file mode 100644 --- a/crates/gilti/src/views/object.rs +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -pub async fn serve( - repositories: &'static str, - route: crate::router::RepoRoute<String>, - 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 deleted file mode 100644 --- a/crates/gilti/src/views/overview.rs +++ /dev/null @@ -1,155 +0,0 @@ -// 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/patch.rs b/crates/gilti/src/views/patch.rs deleted file mode 100644 --- a/crates/gilti/src/views/patch.rs +++ /dev/null @@ -1,63 +0,0 @@ -// 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<crate::router::Comparison>, - 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 name = route.repo.clone(); - let old = route.params.old_rev.clone(); - let new = route.params.new_rev.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::patch::Patch::load(std::path::Path::new(repositories), &name, &old, &new) - }) - .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 mut command = tokio::process::Command::new(crate::GIT); - command - .arg("--git-dir") - .arg(&model.repository_path) - .arg("-c") - .arg("color.ui=false") - .arg("format-patch") - .arg("--stdout") - .arg("--keep-subject") - .arg("--no-renames") - .arg("--signature=Gilti") - .arg(format!("{}..{}", model.old_oid, model.new_oid)) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1"); - if let Some(path) = &route.params.path { - command.arg("--").arg(format!(":(literal){path}")); - } - let output = match command.output().await { - Ok(output) if output.status.success() => output.stdout, - Ok(output) => { - eprintln!( - "gilti: git format-patch failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - return super::error(crate::models::Error::Internal( - "git format-patch failed".to_owned(), - )); - } - Err(error) => return super::error(crate::models::Error::Internal(error.to_string())), - }; - let filename = format!("{}..{}.patch", model.old_oid, model.new_oid); - super::bytes_response( - "text/plain; charset=UTF-8", - Some(format!("inline; filename=\"{filename}\"")), - None, - output, - &method, - ) -} diff --git a/crates/gilti/src/views/refs.rs b/crates/gilti/src/views/refs.rs deleted file mode 100644 --- a/crates/gilti/src/views/refs.rs +++ /dev/null @@ -1,87 +0,0 @@ -// 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 deleted file mode 100644 --- a/crates/gilti/src/views/repositories.rs +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{DOCTYPE, html}; - -pub struct Query { - search: Option<String>, - 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/gilti.css"; - script type="text/javascript" src="/-/assets/gilti.js" {} - link rel="shortcut icon" href="/-/assets/favicon.ico"; - } - body { div id="gilti" { - table id="header" { - tr { - td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti 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::<String>(); - 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/revision.rs b/crates/gilti/src/views/revision.rs deleted file mode 100644 --- a/crates/gilti/src/views/revision.rs +++ /dev/null @@ -1,118 +0,0 @@ -// 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<crate::router::Revision>, - query: super::diff::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 name = route.repo.clone(); - let revision = route.params.clone(); - let model = tokio::task::spawn_blocking(move || { - crate::models::commit::Commit::load( - std::path::Path::new(repositories), - &name, - revision, - query.options(), - ) - }) - .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, query); - super::shared::render_titled( - context, - &model.repository, - &model.revision, - super::shared::Page::Revision, - Some(&model.subject), - content, - &method, - ) -} - -fn content(model: &crate::models::commit::Commit, query: super::diff::Query) -> Markup { - let repo = super::shared::repository_url(&model.repository.name); - let revision = super::shared::encode_path(&model.revision); - html! { - (super::diff::content_controls(query)) - table summary="commit info" class="commit-info" { - tr { th { "author" } td { (&model.author.name) " <" (&model.author.email) ">" } - td class="right" { (timestamp(&model.author)) } } - tr { th { "committer" } td { (&model.committer.name) " <" (&model.committer.email) ">" } - td class="right" { (timestamp(&model.committer)) } } - tr { th { "commit" } td colspan="2" class="oid" { - a href=(format!("{repo}/+/{}", model.oid)) { (&model.oid) } - @let patch_old = model.parents.first().map_or("HEAD", String::as_str); - " (" a href=(format!("{repo}/+/patch/{patch_old}..{}", model.oid)) { "patch" } ")" - } } - tr { th { "tree" } td colspan="2" class="oid" { - a href=(format!("{repo}/+/{revision}/+/tree")) { (&model.tree) } - } } - @for parent in &model.parents { - tr { th { "parent" } td colspan="2" class="oid" { - a href=(format!("{repo}/+/{parent}")) { (parent) } - " (" a href=(format!("{repo}/+/diff/{parent}..{}", model.oid)) { "diff" } ")" - } } - } - tr { th { "download" } td colspan="2" 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; - } - } } - } - div class="commit-subject" { - (&model.subject) - @for decoration in &model.decorations { - " (" - @if let Some(reference) = &decoration.reference { - @let reference = super::shared::encode_path(reference); - a href=(format!("{repo}/+/{reference}")) { (&decoration.label) } - } @else { (&decoration.label) } - ")" - } - } - div class="commit-msg" { (&model.message) } - @if let Some(notes) = &model.notes { - div class="notes-header" { "Notes" } - div class="notes" { (notes) } - div class="notes-footer" {} - } - @if let Some(diff) = &model.diff { - (super::diff::content(diff, query, None, false)) - } - } -} - -fn timestamp(identity: &crate::models::commit::Identity) -> String { - let adjusted = identity.timestamp + i64::from(identity.offset_minutes) * 60; - let value = crate::models::time::utc(adjusted) - .unwrap_or_else(|| crate::models::time::utc(0).expect("the Unix epoch is representable")); - let sign = if identity.offset_minutes < 0 { - '-' - } else { - '+' - }; - let offset = identity.offset_minutes.unsigned_abs(); - format!( - "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{:02}{:02}", - value.tm_year + 1900, - value.tm_mon + 1, - value.tm_mday, - value.tm_hour, - value.tm_min, - value.tm_sec, - offset / 60, - offset % 60 - ) -} diff --git a/crates/gilti/src/views/shared.rs b/crates/gilti/src/views/shared.rs deleted file mode 100644 --- a/crates/gilti/src/views/shared.rs +++ /dev/null @@ -1,148 +0,0 @@ -// 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<str>, - pub root_description: std::sync::Arc<str>, - pub clone_prefix: std::sync::Arc<str>, -} - -#[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 { - render_titled(context, repository, revision, active, None, content, method) -} - -pub fn render_titled( - context: &Context, - repository: &crate::models::repository::Info, - revision: &str, - active: Page, - page_title: Option<&str>, - content: Markup, - method: &axum::http::Method, -) -> axum::response::Response { - let repo = repository_url(&repository.name); - let rev = encode_path(revision); - let title = page_title.map_or_else( - || format!("{} - {}", repository.name, repository.description), - |page_title| { - format!( - "{page_title} - {} - {}", - 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/gilti.css"; - script type="text/javascript" src="/-/assets/gilti.js" {} - link rel="shortcut icon" href="/-/assets/favicon.ico"; - } - body { div id="gilti" { - table id="header" { - tr { - td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } } - td class="main" { (&*context.root_title) " : " a href=(&repo) { (&repository.name) } } - } - tr { td class="sub" { (&repository.description) } } - } - table class="tabs" { tr { td { - @if repository.has_readme { - (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/stats.rs b/crates/gilti/src/views/stats.rs deleted file mode 100644 --- a/crates/gilti/src/views/stats.rs +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov -// SPDX-License-Identifier: AGPL-3.0-or-later - -use maud::{Markup, html}; - -pub struct Query { - pub period: crate::models::stats::Period, - pub code: &'static str, - pub top: Option<usize>, -} - -pub enum QueryError { - BadRequest, - NotFound, -} - -impl Query { - pub fn from_request(query: &crate::RequestQuery) -> Result<Self, QueryError> { - let (period, code) = match query.value("GILTI_QUERY_PERIOD") { - None | Some("w" | "week") => (crate::models::stats::Period::Week, "w"), - Some("m" | "month") => (crate::models::stats::Period::Month, "m"), - Some("q" | "quarter") => (crate::models::stats::Period::Quarter, "q"), - Some("y" | "year") => (crate::models::stats::Period::Year, "y"), - Some(_) => return Err(QueryError::NotFound), - }; - let top = match query.value("GILTI_QUERY_OFFSET") { - None | Some("0") => Some(10), - Some("-1") => None, - Some(value) => Some( - value - .parse::<usize>() - .ok() - .filter(|value| *value > 0) - .ok_or(QueryError::BadRequest)?, - ), - }; - Ok(Self { period, code, top }) - } -} - -pub async fn serve( - context: &super::shared::Context, - route: crate::router::RepoRoute<()>, - 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 name = route.repo; - let model = tokio::task::spawn_blocking(move || { - crate::models::stats::Stats::load(std::path::Path::new(repositories), &name, query.period) - }) - .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, &query); - super::shared::render( - context, - &model.repository, - "HEAD", - super::shared::Page::Stats, - content, - &method, - ) -} - -fn content(model: &crate::models::stats::Stats, query: &Query) -> Markup { - let shown = query - .top - .unwrap_or(model.authors.len()) - .min(model.authors.len()); - html! { - div class="gilti-panel" { - b { "stat options" } - form method="get" { table { - tr { td colspan="2" {} } - tr { td class="label" { "Period:" } td class="ctrl" { - select name="period" onchange="this.form.submit();" { - @for (code, name) in [("w","week"),("m","month"),("q","quarter"),("y","year")] { - option value=(code) selected[query.code == code] { (name) } - } - } - } } - tr { td class="label" { "Authors:" } td class="ctrl" { - select name="ofs" onchange="this.form.submit();" { - @for value in [10_usize,25,50,100] { - option value=(value) selected[query.top == Some(value)] { (value) } - } - option value="-1" selected[query.top.is_none()] { "all" } - } - } } - tr { td {} td class="ctrl" { noscript { input type="submit" value="Reload"; } } } - } } - } - h2 { "Commits per author per " (query.period.name()) } - table class="stats" { - tr { th { "Author" } @for label in &model.labels { th { (label) } } th { "Total" } } - @for author in &model.authors[..shown] { - tr { td class="left" { (&author.name) } - @for count in &author.counts { td { (count) } } - td class="sum" { (author.total) } - } - } - @if shown < model.authors.len() { - @let others = &model.authors[shown..]; - tr { td class="left" { "Others (" (others.len()) ")" } - @for index in 0..4 { td { (others.iter().map(|author| author.counts[index]).sum::<usize>()) } } - td class="sum" { (others.iter().map(|author| author.total).sum::<usize>()) } - } - } - tr { td class="total" { "Total" } - @for total in &model.totals { td class="sum" { (total) } } - td class="sum" { (model.totals.iter().sum::<usize>()) } - } - } - } -} diff --git a/crates/gilti/src/views/tag.rs b/crates/gilti/src/views/tag.rs deleted file mode 100644 --- a/crates/gilti/src/views/tag.rs +++ /dev/null @@ -1,76 +0,0 @@ -// 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<crate::router::Revision>, - 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 deleted file mode 100644 --- a/crates/gilti/src/views/tree.rs +++ /dev/null @@ -1,293 +0,0 @@ -// 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<crate::router::RevisionPath>, - 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)) } - } - } - } -} - -pub(super) 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::<Vec<_>>() - }); - 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::<Vec<_>>(); - 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::<Vec<_>>().join(" ")) } - td class="hex" { (chunk.iter().map(|byte| if byte.is_ascii_graphic() { char::from(*byte) } else { '.' }).collect::<String>()) } - } - } - } } -} - -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::<String>(); - format!( - "inline; filename=\"{fallback}\"; filename*=UTF-8''{}", - super::shared::encode_path(name) - ) -} diff --git a/git b/git new file mode 160000 --- /dev/null +++ b/git @@ -0,0 +1 @@ +Subproject commit 94f057755b7941b321fd11fec1b2e3ca5313a4e0 diff --git a/mise.toml b/mise.toml index 37ffe1c..6101ba9 100644 --- a/mise.toml +++ b/mise.toml @@ -8,11 +8,15 @@ experimental = true [tools] helm = "4.1.1" -rust = { version = "1.97.1", profile = "minimal", components = ["clippy", "llvm-tools-preview", "rustfmt"] } +rust = { version = "1.97.1", profile = "minimal", components = [ + "clippy", + "llvm-tools-preview", + "rustfmt", +] } shellcheck = "0.11.0" [task_config] dir = "{{cwd}}" includes = [ - "git::https://git.dimidiumlabs.io/platform.git//tasks?ref=8bc35fe8be889c50db2d1fb4425cc1b4097dc6b8", + "git::https://github.com/dimidiumlabs/platform.git//tasks?ref=8bc35fe8be889c50db2d1fb4425cc1b4097dc6b8", ] diff --git a/scripts/build-artifacts.sh b/scripts/build-artifacts.sh index 32016ff..741b40e 100755 --- a/scripts/build-artifacts.sh +++ b/scripts/build-artifacts.sh @@ -19,21 +19,39 @@ ldd --version 2>&1 | grep -q musl || { echo 'container artifacts must be built in a musl environment' >&2 exit 1 } +[ -f git/Makefile ] || { + echo 'Git submodule is not initialized; run git submodule update --init' >&2 + exit 1 +} workspace=$PWD build=$(mktemp -d) trap 'rm -rf "$build"' EXIT INT TERM +cp -R cgit "$build/cgit" +cp -R git "$build/cgit/git" +make -C "$build/cgit/git" -f ../Makefile -j"$(nproc)" ../cgit \ + NO_CURL=1 \ + NO_GETTEXT=1 \ + NO_OPENSSL=1 \ + NO_REGEX=NeedsStartEnd + CARGO_TARGET_DIR="$build/target" cargo build \ --manifest-path "$workspace/Cargo.toml" \ --locked \ --release \ --workspace + output="$workspace/.container/binary-$arch" rm -rf "$output" install -Dm0755 "$build/target/release/gilti" "$output/gilti" install -Dm0755 "$build/target/release/gilti-ssh" "$output/gilti-ssh" -install -Dm0644 crates/gilti/assets/gilti.css "$output/gilti.css" -install -Dm0644 crates/gilti/assets/gilti.js "$output/gilti.js" -install -Dm0644 crates/gilti/assets/gilti.png "$output/gilti.png" -install -Dm0644 crates/gilti/assets/favicon.ico "$output/favicon.ico" +install -Dm0755 "$build/cgit/cgit" "$output/gilti-cgit" +strip "$output/gilti-cgit" + +install -Dm0644 cgit/cgit.css "$output/cgit.css" +install -Dm0644 cgit/cgit.js "$output/cgit.js" +install -Dm0644 cgit/cgit.png "$output/cgit.png" +install -Dm0644 cgit/favicon.ico "$output/favicon.ico" +install -Dm0644 cgit/COPYING "$output/COPYING.cgit.txt" +install -Dm0644 git/COPYING "$output/COPYING.git.txt" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 6da7570..3eaa8f9 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -8,6 +8,7 @@ umask 077 state=/var/lib/gilti run_dir=/run/gilti +http_run_dir=$run_dir/http ssh_run_dir=$run_dir/ssh git_home=$state/git @@ -29,8 +30,10 @@ prepare_runtime() { install -d -m 0750 -o git -g git "$git_home" "$repositories" install -d -m 0700 -o root -g root "$host_key_dir" install -d -m 0755 -o root -g root "$run_dir" + install -d -m 0750 -o git -g git "$http_run_dir" install -d -m 0750 -o root -g git "$ssh_run_dir" - rm -f "$ssh_run_dir/sshd.pid" "$authorized_keys" "$authorized_keys".* + rm -f "$ssh_run_dir/sshd.pid" "$http_run_dir"/cgitrc.* \ + "$authorized_keys" "$authorized_keys".* } prepare_authorized_keys() { diff --git a/tests/chart.sh b/tests/chart.sh index 42f6910..f0471d4 100755 --- a/tests/chart.sh +++ b/tests/chart.sh @@ -28,15 +28,20 @@ grep -q '^apiVersion: gateway.networking.k8s.io/v1$' "$rendered" grep -q 'helm.sh/resource-policy: keep' "$rendered" grep -q 'ssh-ed25519 AAAAcharttest gilti' "$rendered" grep -q 'mountPath: /etc/gilti/authorized_keys' "$rendered" +grep -q '^ cache=5$' "$rendered" +if grep -q '/var/cache/cgit' "$rendered"; then + echo 'chart still provisions the removed cgit disk cache' >&2 + exit 1 +fi cat >"$injected" <<'EOF' -web: +cgit: rootTitle: |- Gilti scan-path=/var/lib/gilti/git/repositories EOF if helm template gilti "$chart" -f "$injected" >"$invalid" 2>&1; then - echo 'chart accepted multiline web configuration' >&2 + echo 'chart accepted multiline cgit configuration' >&2 exit 1 fi @@ -44,3 +49,8 @@ if helm template gilti "$chart" --set replicaCount=2 >"$invalid" 2>&1; then echo 'chart accepted unsupported replicaCount=2' >&2 exit 1 fi + +if helm template gilti "$chart" --set cgit.cache=3601 >"$invalid" 2>&1; then + echo 'chart accepted an excessive CGI cache lifetime' >&2 + exit 1 +fi diff --git a/tests/smoke.sh b/tests/smoke.sh index 25f6304..f9ed0c7 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -29,10 +29,6 @@ ssh-keygen -q -t ed25519 -N '' -f "$work/admin-2" ssh-keygen -q -t ed25519 -N '' -f "$work/stranger" cat "$work/admin.pub" "$work/admin-2.pub" >"$authorized_keys" "$engine" volume create "$volume" >/dev/null -if "$engine" run --rm --entrypoint /bin/sh "$image" -c 'test -e /usr/local/bin/gilti-cgit' >/dev/null 2>&1; then - echo 'removed legacy browser binary is installed' >&2 - exit 1 -fi printf '%s\n' 'not an SSH key' >"$work/bad-authorized-keys" if "$engine" run --rm \ @@ -60,7 +56,7 @@ start() { "$image" >/dev/null i=0 - until curl -fsS "http://127.0.0.1:$http_port/-/health" >/dev/null 2>&1; do + until curl -fsS "http://127.0.0.1:$http_port/healthz" >/dev/null 2>&1; do i=$((i + 1)) if [ "$i" -ge 60 ]; then "$engine" logs "$name" >&2 @@ -77,6 +73,23 @@ start() { } start +cgit_path=$("$engine" exec "$name" sh -c 'command -v gilti-cgit') +[ "$cgit_path" = /usr/local/bin/gilti-cgit ] || { + echo "unexpected cgit binary path: $cgit_path" >&2 + exit 1 +} +if "$engine" exec "$name" test -e /usr/share/webapps/cgit/cgit.cgi; then + echo 'legacy cgit.cgi is installed' >&2 + exit 1 +fi +if "$engine" exec "$name" test -e /var/cache/cgit; then + echo 'legacy cgit disk-cache directory exists' >&2 + exit 1 +fi +if "$engine" exec "$name" sh -c 'grep -q "^cache=" /run/gilti/http/cgitrc.*'; then + echo 'Gilti cache parameter leaked into the cgit configuration' >&2 + exit 1 +fi sshd_config=$("$engine" exec "$name" /usr/sbin/sshd -T -f /etc/ssh/sshd_config \ -C user=git,host=localhost,addr=127.0.0.1) for expected in \ @@ -96,30 +109,30 @@ for expected in \ exit 1 } done -[ "$(curl -fsS "http://127.0.0.1:$http_port/-/health")" = '{"status":"ok"}' ] || { +[ "$(curl -fsS "http://127.0.0.1:$http_port/healthz")" = ok ] || { echo 'unexpected health response' >&2 exit 1 } status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$http_port/") -[ "$status" = 405 ] || { - echo "POST to repository browser returned HTTP $status instead of 405" >&2 +[ "$status" = 403 ] || { + echo "POST to cgit returned HTTP $status instead of 403" >&2 exit 1 } -curl -fsS "http://127.0.0.1:$http_port/-/assets/gilti.css" | grep -q 'div#gilti' -content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/gilti.css" | +curl -fsS "http://127.0.0.1:$http_port/cgit.css" | grep -q 'cgit' +content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.css" | awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') [ "$content_type" = text/css ] || { - echo "unexpected gilti.css content type: $content_type" >&2 + echo "unexpected cgit.css content type: $content_type" >&2 exit 1 } -curl -fsS "http://127.0.0.1:$http_port/-/assets/gilti.js" | grep -q 'function' -content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/gilti.js" | +curl -fsS "http://127.0.0.1:$http_port/cgit.js" | grep -q 'function' +content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.js" | awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') [ "$content_type" = text/javascript ] || { - echo "unexpected gilti.js content type: $content_type" >&2 + echo "unexpected cgit.js content type: $content_type" >&2 exit 1 } -curl -fsSI "http://127.0.0.1:$http_port/-/health" >/dev/null +curl -fsSI "http://127.0.0.1:$http_port/healthz" >/dev/null # shellcheck disable=SC2016 # Expanded by the shell inside the container. httpd_uid=$("$engine" exec "$name" sh -c ' @@ -147,16 +160,16 @@ keys_mode=$("$engine" exec "$name" stat -c '%u:%g:%a' /run/gilti/ssh/authorized_ } if "$engine" exec --user 10000:10000 "$name" sh -c \ 'printf x >>/run/gilti/ssh/authorized_keys' 2>/dev/null; then - echo 'Git service user can modify authorized_keys' >&2 + echo 'Git/cgit user can modify authorized_keys' >&2 exit 1 fi if "$engine" exec --user 10000:10000 "$name" rm /run/gilti/ssh/authorized_keys \ 2>/dev/null; then - echo 'Git service user can remove authorized_keys' >&2 + echo 'Git/cgit user can remove authorized_keys' >&2 exit 1 fi if "$engine" exec --user 10000:10000 "$name" test -r /var/lib/gilti/ssh/ssh_host_ed25519_key; then - echo 'Git service user can read the SSH host private key' >&2 + echo 'Git/cgit user can read the SSH host private key' >&2 exit 1 fi @@ -232,64 +245,17 @@ until curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'testing'; do [ "$i" -lt 30 ] || { "$engine" logs "$name" >&2; exit 1; } sleep 1 done -curl -fsS "http://127.0.0.1:$http_port/testing" | grep -q 'Initial commit' -curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/tree/README%2emd" | grep -q 'Testing' -old_commit=$(git -C "$work/testing-clone" rev-parse HEAD^) -new_commit=$(git -C "$work/testing-clone" rev-parse HEAD) -curl -fsS "http://127.0.0.1:$http_port/testing/+/$new_commit" | grep -q 'Push with second key' -log_url="http://127.0.0.1:$http_port/testing/+/refs/heads/main/+/log" -curl -fsS "$log_url" -o "$work/log.html" -grep -q 'id="gilti"' "$work/log.html" -grep -q 'Push with second key' "$work/log.html" -log_length=$(wc -c <"$work/log.html" | tr -d ' ') -head_length=$(curl -fsSI "$log_url" | - awk -F ': ' 'tolower($1) == "content-length" { gsub("\\r", "", $2); print $2 }') -[ "$head_length" = "$log_length" ] || { - echo "Log HEAD length $head_length differs from GET length $log_length" >&2 - exit 1 -} -feed_url="http://127.0.0.1:$http_port/testing/+/refs/heads/main/+/feed/atom" -curl -fsS "$feed_url" -o "$work/feed.xml" -grep -q '<feed xmlns=' "$work/feed.xml" -grep -q '<title>Push with second key</title>' "$work/feed.xml" -grep -q "<id>urn:sha1:$new_commit</id>" "$work/feed.xml" -feed_length=$(wc -c <"$work/feed.xml" | tr -d ' ') -head_length=$(curl -fsSI "$feed_url" | - awk -F ': ' 'tolower($1) == "content-length" { gsub("\\r", "", $2); print $2 }') -[ "$head_length" = "$feed_length" ] || { - echo "Atom HEAD length $head_length differs from GET length $feed_length" >&2 - exit 1 -} -curl -fsS "http://127.0.0.1:$http_port/testing/+/stats" | grep -q 'Gilti smoke test' -curl -fsS "http://127.0.0.1:$http_port/testing/+/diff/$old_commit..$new_commit?format=raw" | - grep -q '^+Second key can push\.' -curl -fsS "http://127.0.0.1:$http_port/testing/+/patch/$old_commit..$new_commit/+/README%2emd" | - grep -q '^Subject: Push with second key' -for format in tar tar.gz tar.bz2 tar.lz tar.xz tar.zst zip; do - curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/archive?format=$format" \ - -o "$work/testing.$format" - [ -s "$work/testing.$format" ] || { - echo "empty $format archive" >&2 - exit 1 - } -done -summary_status=$(curl -sS -o /dev/null -w '%{http_code}:%{redirect_url}' \ - "http://127.0.0.1:$http_port/testing/+/summary") -[ "$summary_status" = "308:http://127.0.0.1:$http_port/testing" ] || { - echo "unexpected summary redirect: $summary_status" >&2 - exit 1 -} -GIT_CONFIG_GLOBAL=/dev/null git clone -q \ - "http://127.0.0.1:$http_port/testing.git" "$work/testing-http-clone" -[ -f "$work/testing-http-clone/README.md" ] -lfs_oid=2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881 -lfs_response=$(printf '{"operation":"download","objects":[{"oid":"%s","size":1}]}' "$lfs_oid" | - curl -fsS -H 'Content-Type: application/vnd.git-lfs+json' --data-binary @- \ - "http://127.0.0.1:$http_port/testing.git/info/lfs/objects/batch") -printf '%s' "$lfs_response" | grep -q '"code":404' || { - echo 'unexpected LFS batch response' >&2 +cache_url="http://127.0.0.1:$http_port/testing/" +curl -fsS -D "$work/cache-1.headers" -o /dev/null "$cache_url" +sleep 2 +curl -fsS -D "$work/cache-2.headers" -o /dev/null "$cache_url" +cache_modified_1=$(awk -F ': ' 'tolower($1) == "last-modified" { gsub("\\r", "", $2); print $2 }' "$work/cache-1.headers") +cache_modified_2=$(awk -F ': ' 'tolower($1) == "last-modified" { gsub("\\r", "", $2); print $2 }' "$work/cache-2.headers") +[ -n "$cache_modified_1" ] && [ "$cache_modified_1" = "$cache_modified_2" ] || { + echo 'CGI response was not served from the in-memory cache' >&2 exit 1 } + # The running sshd uses its startup snapshot, not the mounted source file. cat "$work/admin.pub" >"$authorized_keys" # shellcheck disable=SC2086 |
