diff options
Diffstat
| -rw-r--r-- | .dockerignore | 2 | +2 −0 |
| -rw-r--r-- | .editorconfig | 26 | +0 −26 |
| -rw-r--r-- | .github/workflows/build.yml | 18 | +8 −10 |
| -rw-r--r-- | .gitignore | 5 | +2 −3 |
| -rw-r--r-- | .gitmodules | 6 | +6 −0 |
| -rw-r--r-- | CODEOWNERS | 2 | +1 −1 |
| -rw-r--r-- | Cargo.toml | 4 | +2 −2 |
| -rw-r--r-- | Dockerfile | 17 | +11 −6 |
| -rw-r--r-- | README.md | 14 | +8 −6 |
| -rw-r--r-- | REUSE.toml | 4 | +2 −2 |
| -rw-r--r-- | cgit/COPYING | 339 | +339 −0 |
| -rw-r--r-- | cgit/Makefile | 25 | +25 −0 |
| -rw-r--r-- | cgit/cgit.c | 400 | +400 −0 |
| -rw-r--r-- | cgit/cgit.css (renamed from crates/gilti/assets/gilti.css) | 374 | +187 −187 |
| -rw-r--r-- | cgit/cgit.h | 275 | +275 −0 |
| -rw-r--r-- | cgit/cgit.js (renamed from crates/gilti/assets/gilti.js) | 6 | +3 −3 |
| -rw-r--r-- | cgit/cgit.png (renamed from crates/gilti/assets/gilti.png) | bin | 1366 -> 1366 bytes |
| -rw-r--r-- | cgit/cmd.c | 47 | +47 −0 |
| -rw-r--r-- | cgit/cmd.h | 19 | +19 −0 |
| -rw-r--r-- | cgit/favicon.ico (renamed from crates/gilti/assets/favicon.ico) | bin | 1078 -> 1078 bytes |
| -rw-r--r-- | cgit/html.c | 350 | +350 −0 |
| -rw-r--r-- | cgit/html.h | 41 | +41 −0 |
| -rw-r--r-- | cgit/parsing.c | 181 | +181 −0 |
| -rw-r--r-- | cgit/shared.c | 482 | +482 −0 |
| -rw-r--r-- | cgit/ui-atom.c | 159 | +159 −0 |
| -rw-r--r-- | cgit/ui-atom.h | 10 | +10 −0 |
| -rw-r--r-- | cgit/ui-log.c | 555 | +555 −0 |
| -rw-r--r-- | cgit/ui-log.h | 13 | +13 −0 |
| -rw-r--r-- | cgit/ui-shared.c | 986 | +986 −0 |
| -rw-r--r-- | cgit/ui-shared.h | 69 | +69 −0 |
| -rw-r--r-- | charts/gilti/Chart.yaml | 4 | +2 −2 |
| -rw-r--r-- | charts/gilti/README.md | 12 | +7 −5 |
| -rw-r--r-- | charts/gilti/templates/configmap.yaml | 2 | +1 −1 |
| -rw-r--r-- | charts/gilti/templates/deployment.yaml | 14 | +8 −6 |
| -rw-r--r-- | charts/gilti/values.schema.json | 5 | +3 −2 |
| -rw-r--r-- | charts/gilti/values.yaml | 4 | +3 −1 |
| -rw-r--r-- | crates/gilti/Cargo.toml | 2 | +1 −1 |
| -rw-r--r-- | crates/gilti/src/cgi.rs | 284 | +278 −6 |
| -rw-r--r-- | crates/gilti/src/main.rs | 257 | +220 −37 |
| -rw-r--r-- | crates/gilti/src/models/history.rs | 556 | +0 −556 |
| -rw-r--r-- | crates/gilti/src/models/mod.rs | 21 | +0 −21 |
| -rw-r--r-- | crates/gilti/src/models/object.rs | 2 | +1 −1 |
| -rw-r--r-- | crates/gilti/src/models/stats.rs | 8 | +6 −2 |
| -rw-r--r-- | crates/gilti/src/models/time.rs | 10 | +0 −10 |
| -rw-r--r-- | crates/gilti/src/views/atom.rs | 247 | +0 −247 |
| -rw-r--r-- | crates/gilti/src/views/diff.rs | 2 | +1 −1 |
| -rw-r--r-- | crates/gilti/src/views/log.rs | 298 | +0 −298 |
| -rw-r--r-- | crates/gilti/src/views/mod.rs | 4 | +1 −3 |
| -rw-r--r-- | crates/gilti/src/views/repositories.rs | 8 | +4 −4 |
| -rw-r--r-- | crates/gilti/src/views/revision.rs | 8 | +6 −2 |
| -rw-r--r-- | crates/gilti/src/views/shared.rs | 8 | +4 −4 |
| -rw-r--r-- | crates/gilti/src/views/stats.rs | 2 | +1 −1 |
| m--------- | git | 1 | +1 −0 |
| -rw-r--r-- | mise.toml | 8 | +6 −2 |
| -rwxr-xr-x | scripts/build-artifacts.sh | 26 | +22 −4 |
| -rwxr-xr-x | tests/chart.sh | 19 | +17 −2 |
| -rwxr-xr-x | tests/smoke.sh | 73 | +37 −36 |
57 files changed, 4813 insertions, 1501 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..31fe9c8 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,7 +64,7 @@ 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 @@ -86,6 +83,7 @@ jobs: apk add --no-cache \ build-base=0.5-r3 \ cmake \ + git=2.49.1-r0 \ python3 \ zlib-dev=1.3.2-r0 \ zlib-static=1.3.2-r0 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.toml b/Cargo.toml index fc8833e..2f300b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,8 @@ 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"] } diff --git a/Dockerfile b/Dockerfile index a625eb1..7dd66ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ 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" @@ -44,17 +44,22 @@ RUN apk add --no-cache \ 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/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..5e09b9b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ platforms but aren't ready to host complex services like Forgejo. 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 +- Password authentication, shells, forwarding, and tunnels are disabled; cgit filters are not supported. Optional unauthenticated HTTP writes must be enabled explicitly. - Gilti is a single-replica service backed by one POSIX persistent volume. It is @@ -44,9 +44,10 @@ docker run --rm \ 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_CGIT_ROOT_TITLE` (default: `Gilti`); +- `GILTI_CGIT_ROOT_DESCRIPTION` (default: `A tiny Git server`); +- `GILTI_CGIT_CLONE_PREFIX` (empty by default); +- `GILTI_CGIT_CACHE` (default: `5`, maximum: `3600` seconds); - `GILTI_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 @@ -67,7 +68,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 +76,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,25 @@ +# 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_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,400 @@ +/* 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 "html.h" +#include "ui-shared.h" + +const char *cgit_version = CGIT_VERSION; + +static const char *config_value(const char *name) +{ + const char *value = getenv(name); + + if (!value) { + fprintf(stderr, "gilti-cgit: missing required environment variable %s\n", + name); + exit(1); + } + return value; +} + +static char *config_string(const char *name) +{ + return xstrdup(config_value(name)); +} + +static char *config_optional_string(const char *name) +{ + const char *value = config_value(name); + + return *value ? xstrdup(value) : NULL; +} + +static int config_integer(const char *name) +{ + const char *value = config_value(name); + char *end; + long result; + + errno = 0; + result = strtol(value, &end, 10); + if (errno || end == value || *end || result < INT_MIN || result > INT_MAX) { + fprintf(stderr, "gilti-cgit: environment variable %s must be an integer\n", + name); + exit(1); + } + return result; +} + +static char *request_optional_string(const char *name) +{ + const char *value = getenv(name); + + return value && *value ? xstrdup(value) : NULL; +} + +static int request_optional_integer(const char *name) +{ + const char *value = getenv(name); + char *end; + long result; + + if (!value || !*value) + return 0; + errno = 0; + result = strtol(value, &end, 10); + if (errno || end == value || *end || result < INT_MIN || result > INT_MAX) { + fprintf(stderr, "gilti-cgit: environment variable %s must be an integer\n", + name); + exit(1); + } + return result; +} + +static void prepare_context(void) +{ + const char *value; + + memset(&ctx, 0, sizeof(ctx)); + ctx.cfg.clone_prefix = config_optional_string("CGIT_CLONE_PREFIX"); + ctx.cfg.clone_url = config_optional_string("CGIT_CLONE_URL"); + ctx.cfg.commit_sort = config_integer("CGIT_COMMIT_SORT"); + ctx.cfg.difftype = config_integer("CGIT_DIFFTYPE"); + ctx.cfg.embedded = config_integer("CGIT_EMBEDDED"); + ctx.cfg.enable_commit_graph = config_integer("CGIT_ENABLE_COMMIT_GRAPH"); + ctx.cfg.enable_follow_links = config_integer("CGIT_ENABLE_FOLLOW_LINKS"); + ctx.cfg.enable_log_filecount = config_integer("CGIT_ENABLE_LOG_FILECOUNT"); + ctx.cfg.enable_log_linecount = config_integer("CGIT_ENABLE_LOG_LINECOUNT"); + ctx.cfg.enable_remote_branches = config_integer("CGIT_ENABLE_REMOTE_BRANCHES"); + ctx.cfg.favicon = config_string("CGIT_FAVICON"); + ctx.cfg.footer = config_optional_string("CGIT_FOOTER"); + ctx.cfg.head_include = config_optional_string("CGIT_HEAD_INCLUDE"); + ctx.cfg.header = config_optional_string("CGIT_HEADER"); + ctx.cfg.local_time = config_integer("CGIT_LOCAL_TIME"); + ctx.cfg.logo = config_string("CGIT_LOGO"); + ctx.cfg.logo_link = config_optional_string("CGIT_LOGO_LINK"); + ctx.cfg.max_atom_items = config_integer("CGIT_MAX_ATOM_ITEMS"); + ctx.cfg.max_commit_count = config_integer("CGIT_MAX_COMMIT_COUNT"); + ctx.cfg.max_msg_len = config_integer("CGIT_MAX_MESSAGE_LENGTH"); + ctx.cfg.noheader = config_integer("CGIT_NOHEADER"); + ctx.cfg.noplainemail = config_integer("CGIT_NOPLAINEMAIL"); + cgit_default_repo_desc = config_string("CGIT_REPO_DEFAULT_DESC"); + ctx.cfg.renamelimit = config_integer("CGIT_RENAMELIMIT"); + ctx.cfg.robots = config_string("CGIT_ROBOTS"); + ctx.cfg.root_desc = config_string("CGIT_ROOT_DESC"); + ctx.cfg.root_title = config_string("CGIT_ROOT_TITLE"); + ctx.cfg.script_name = config_string("SCRIPT_NAME"); + ctx.cfg.section = config_string("CGIT_SECTION"); + ctx.cfg.virtual_root = ensure_end(config_value("CGIT_VIRTUAL_ROOT"), '/'); + string_list_init_dup(&ctx.cfg.css); + string_list_init_dup(&ctx.cfg.js); + string_list_init_dup(&ctx.cfg.readme); + value = config_value("CGIT_CSS"); + if (*value) + string_list_append(&ctx.cfg.css, value); + value = config_value("CGIT_JS"); + if (*value) + string_list_append(&ctx.cfg.js, value); + value = config_value("CGIT_README_0"); + if (*value) + string_list_append(&ctx.cfg.readme, value); + value = config_value("CGIT_README_1"); + if (*value) + string_list_append(&ctx.cfg.readme, value); + + ctx.env.http_host = getenv("HTTP_HOST"); + ctx.env.https = getenv("HTTPS"); + ctx.env.path_info = getenv("PATH_INFO"); + ctx.env.query_string = getenv("QUERY_STRING"); + ctx.env.request_method = getenv("REQUEST_METHOD"); + 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.modified = time(NULL); +} + +static void prepare_request(void) +{ + ctx.qry.repo = request_optional_string("GILTI_REPOSITORY"); + ctx.qry.page = config_string("GILTI_PAGE"); + ctx.qry.url = request_optional_string("GILTI_CURRENT_URL"); + ctx.qry.head = request_optional_string("GILTI_REVISION"); + if (ctx.qry.head) { + ctx.qry.oid = xstrdup(ctx.qry.head); + ctx.qry.has_oid = 1; + } + ctx.qry.path = request_optional_string("GILTI_PATH"); + ctx.qry.search = request_optional_string("GILTI_QUERY_SEARCH"); + ctx.qry.grep = request_optional_string("GILTI_QUERY_GREP"); + ctx.qry.ofs = request_optional_integer("GILTI_QUERY_OFFSET"); + ctx.qry.showmsg = request_optional_integer("GILTI_QUERY_SHOWMSG"); + ctx.qry.context = request_optional_integer("GILTI_QUERY_CONTEXT"); + ctx.qry.ignorews = request_optional_integer("GILTI_QUERY_IGNOREWS"); + ctx.qry.follow = request_optional_integer("GILTI_QUERY_FOLLOW"); + ctx.qry.difftype = request_optional_integer("GILTI_QUERY_DIFFTYPE"); +} + +static void prepare_repository(void) +{ + char *description = NULL; + char *description_path; + char *separator; + struct passwd *owner; + struct stat stat; + size_t size; + + ctx.repo = cgit_add_repo(ctx.qry.repo); + ctx.repo->path = config_string("GILTI_REPOSITORY_PATH"); + description_path = fmtalloc("%s/description", ctx.repo->path); + if (!read_first_line(description_path, &description, &size)) + ctx.repo->desc = description; + free(description_path); + if (!lstat(ctx.repo->path, &stat) && (owner = getpwuid(stat.st_uid))) { + ctx.repo->owner = strdup_first_line(owner->pw_gecos && *owner->pw_gecos ? + owner->pw_gecos : owner->pw_name); + separator = strchr(ctx.repo->owner, ','); + if (separator) + *separator = '\0'; + } +} + +static char *guess_defbranch(void) +{ + const char *ref; + struct object_id oid; + + ref = refs_resolve_ref_unsafe(get_main_ref_store(the_repository), + "HEAD", 0, &oid, NULL); + if (!ref) + return xstrdup("HEAD"); + return xstrdup(ref); +} + +struct walk_tree_context { + const char *match_path; + unsigned int found_path:1; +}; + +static int find_path(const struct object_id *oid UNUSED, struct strbuf *base, + const char *pathname, unsigned mode, void *cbdata) +{ + struct walk_tree_context *walk_tree_ctx = cbdata; + + if (!S_ISREG(mode)) + return READ_TREE_RECURSIVE; + if (strncmp(base->buf, walk_tree_ctx->match_path, base->len) || + strcmp(walk_tree_ctx->match_path + base->len, pathname)) + return READ_TREE_RECURSIVE; + walk_tree_ctx->found_path = 1; + return 0; +} + +static int ref_path_exists(const char *path, const char *ref) +{ + struct object_id oid; + unsigned long size; + struct pathspec_item path_item = { + .match = xstrdup(path), + .len = strlen(path) + }; + struct pathspec paths = { + .nr = 1, + .items = &path_item + }; + struct walk_tree_context walk_tree_ctx = { + .match_path = path, + .found_path = 0 + }; + + if (!repo_get_oid(the_repository, ref, &oid) && + odb_read_object_info(the_repository->objects, &oid, &size) == OBJ_COMMIT) + read_tree(the_repository, + repo_get_commit_tree(the_repository, + lookup_commit_reference(the_repository, &oid)), + &paths, find_path, &walk_tree_ctx); + free(path_item.match); + return walk_tree_ctx.found_path; +} + +static void parse_readme(const char *readme, char **filename, char **ref) +{ + const char *colon; + + *filename = NULL; + *ref = NULL; + if (!readme || !*readme) + return; + colon = strchr(readme, ':'); + if (colon && colon[1]) { + if (colon == readme) + *ref = xstrdup(ctx.qry.head ? ctx.qry.head : ctx.repo->defbranch); + else + *ref = xstrndup(readme, colon - readme); + readme = colon + 1; + } + *filename = *ref || readme[0] == '/' ? xstrdup(readme) : + fmtalloc("%s/%s", ctx.repo->path, readme); +} + +static void choose_readme(void) +{ + char *filename, *ref; + struct string_list_item *entry; + + for_each_string_list_item(entry, &ctx.repo->readme) { + parse_readme(entry->string, &filename, &ref); + if ((ref && ref_path_exists(filename, ref)) || + (!ref && !access(filename, R_OK))) { + ctx.repo->readme.strdup_strings = 1; + string_list_clear(&ctx.repo->readme, 0); + ctx.repo->readme.strdup_strings = 0; + string_list_append(&ctx.repo->readme, filename)->util = ref; + return; + } + free(filename); + free(ref); + } + ctx.repo->readme.strdup_strings = 1; + string_list_clear(&ctx.repo->readme, 0); + ctx.repo->readme.strdup_strings = 0; +} + +static void prepare_repo_env(int *nongit) +{ + /* 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.head = xstrdup("HEAD"); + + if (repo_get_oid(the_repository, ctx.qry.head, &oid)) { + cgit_print_error_page(404, "Not found", + "Invalid revision: %s", ctx.qry.head); + return 1; + } + cgit_prepare_repo_env(ctx.repo); + choose_readme(); + return 0; +} + +static void process_request(void) +{ + struct cgit_cmd *cmd; + int nongit = 0; + + if (ctx.qry.repo && !ctx.repo) { + cgit_print_error_page(404, "Not found", "Repository not found"); + return; + } + + if (ctx.repo) + prepare_repo_env(&nongit); + + cmd = cgit_get_cmd(); + if (!cmd) { + 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 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 UNUSED, const char **argv UNUSED) +{ + set_die_routine(cgit_die_routine); + + prepare_context(); + prepare_request(); + if (ctx.qry.repo) + prepare_repository(); + 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,275 @@ +/* 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 (*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_repo { + char *url; + char *name; + char *path; + char *desc; + char *extra_head_content; + char *owner; + char *homepage; + char *defbranch; + struct string_list readme; + char *section; + char *clone_url; + char *logo; + char *logo_link; + int enable_commit_graph; + int enable_follow_links; + int enable_log_filecount; + int enable_log_linecount; + int enable_remote_branches; + int commit_sort; + time_t mtime; + int hide; + int ignore; +}; + +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; + char *repo; + char *page; + char *search; + char *grep; + char *head; + char *oid; + char *path; + char *url; + int ofs; + int showmsg; + diff_type difftype; + int show_all; + int context; + int ignorews; + int follow; + char *vpath; +}; + +struct cgit_config { + char *clone_prefix; + char *clone_url; + char *favicon; + char *footer; + char *head_include; + char *header; + char *logo; + char *logo_link; + 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 *virtual_root; /* Always ends with '/'. */ + int embedded; + int enable_follow_links; + int enable_commit_graph; + int enable_log_filecount; + int enable_log_linecount; + int enable_remote_branches; + int local_time; + int max_atom_items; + int max_commit_count; + int max_msg_len; + int noplainemail; + int noheader; + int renamelimit; + diff_type difftype; + int commit_sort; + 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 *http_host; + const char *https; + const char *path_info; + const char *query_string; + const char *request_method; + 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; +}; + +extern const char *cgit_version; + +extern struct cgit_context ctx; + +extern char *cgit_default_repo_desc; +extern struct cgit_repo *cgit_add_repo(const char *url); + +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 const char *cgit_repobasename(const char *reponame); + +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); + +#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,47 @@ +/* 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-atom.h" +#include "ui-log.h" + +static void atom_fn(void) +{ + cgit_print_atom(ctx.qry.head, ctx.qry.path, ctx.cfg.max_atom_items); +} + +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); +} + +#define def_cmd(name, want_repo, want_vpath) \ + {#name, name##_fn, want_repo, want_vpath} + +struct cgit_cmd *cgit_get_cmd(void) +{ + static struct cgit_cmd cmds[] = { + def_cmd(atom, 1, 0), + def_cmd(log, 1, 1), + }; + int i; + + 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,19 @@ +/* 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; +}; + +extern struct cgit_cmd *cgit_get_cmd(void); + +#endif /* CMD_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/html.c b/cgit/html.c new file mode 100644 --- /dev/null +++ b/cgit/html.c @@ -0,0 +1,350 @@ +/* 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; +} diff --git a/cgit/html.h b/cgit/html.h new file mode 100644 --- /dev/null +++ b/cgit/html.h @@ -0,0 +1,41 @@ +/* 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); + + +#endif /* HTML_H */ diff --git a/cgit/parsing.c b/cgit/parsing.c new file mode 100644 --- /dev/null +++ b/cgit/parsing.c @@ -0,0 +1,181 @@ +/* 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" + +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/shared.c b/cgit/shared.c new file mode 100644 --- /dev/null +++ b/cgit/shared.c @@ -0,0 +1,482 @@ +/* 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_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; +struct cgit_repo *cgit_add_repo(const char *url) +{ + struct cgit_repo *ret = xcalloc(1, sizeof(*ret)); + + 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->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->commit_sort = ctx.cfg.commit_sort; + ret->readme = ctx.cfg.readme; + ret->mtime = -1; + ret->clone_url = ctx.cfg.clone_url; + ret->hide = ret->ignore = 0; + return ret; +} + +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); +} + +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; +} 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,159 @@ +/* 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 *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_revurl(ctx.repo->url, hex); + html_attr(pageurl); + 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-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-shared.c b/cgit/ui-shared.c new file mode 100644 --- /dev/null +++ b/cgit/ui-shared.c @@ -0,0 +1,986 @@ +/* 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) +{ + return xstrdup(ctx.qry.url ? ctx.qry.url : cgit_rooturl()); +} + +char *cgit_currentfullurl(void) +{ + const char *url = ctx.qry.url ? ctx.qry.url : cgit_rooturl(); + const char *query = ctx.env.query_string; + + return query && *query ? fmtalloc("%s?%s", url, query) : xstrdup(url); +} + +const char *cgit_rooturl(void) +{ + if (ctx.cfg.virtual_root) + return ctx.cfg.virtual_root; + else + return ctx.cfg.script_name; +} + +static void add_url_path(struct strbuf *url, const char *value) +{ + const unsigned char *p = (const unsigned char *)(value ? value : ""); + + for (; *p; p++) { + if (isalnum(*p) || *p == '/' || *p == '_') + strbuf_addch(url, *p); + else + strbuf_addf(url, "%%%02x", *p); + } +} + +static void add_repo_url(struct strbuf *url, const char *reponame) +{ + strbuf_addch(url, '/'); + add_url_path(url, reponame); +} + +static const char *link_revision(const char *head, const char *rev) +{ + return rev ? rev : head ? head : "HEAD"; +} + +static void add_revision(struct strbuf *url, const char *revision) +{ + struct object_id oid; + + if (!strcmp(revision, "HEAD") || starts_with(revision, "refs/") || + !get_oid_hex(revision, &oid)) + add_url_path(url, revision); + else { + strbuf_addstr(url, "refs/heads/"); + add_url_path(url, revision); + } +} + +static char *repo_view_url(const char *reponame, const char *page, + const char *revision, const char *path) +{ + struct strbuf url = STRBUF_INIT; + + add_repo_url(&url, reponame); + if (!page || !strcmp(page, "summary")) + return strbuf_detach(&url, NULL); + strbuf_addstr(&url, "/+/"); + if (!strcmp(page, "about") || !strcmp(page, "stats") || + !strcmp(page, "refs")) { + strbuf_addstr(&url, page); + return strbuf_detach(&url, NULL); + } + add_revision(&url, revision ? revision : "HEAD"); + if (strcmp(page, "commit")) { + strbuf_addstr(&url, "/+/"); + strbuf_addstr(&url, !strcmp(page, "plain") ? "tree" : page); + if (path) { + strbuf_addch(&url, '/'); + add_url_path(&url, path); + } + } + return strbuf_detach(&url, NULL); +} + +char *cgit_repourl(const char *reponame) +{ + return repo_view_url(reponame, NULL, NULL, NULL); +} + +char *cgit_fileurl(const char *reponame, const char *pagename, + const char *filename, const char *query) +{ + char *url = repo_view_url(reponame, + !strcmp(pagename, "atom") ? "feed/atom" : pagename, + ctx.qry.head, filename); + char *result; + + if (!query) + return url; + result = fmtalloc("%s?%s", url, query); + free(url); + return result; +} + +char *cgit_pageurl(const char *reponame, const char *pagename, + const char *query) +{ + return cgit_fileurl(reponame, pagename, NULL, query); +} + +char *cgit_revurl(const char *reponame, const char *revision) +{ + return repo_view_url(reponame, "commit", revision, NULL); +} + +char *cgit_treeurl(const char *reponame, const char *revision, + const char *path, const char *fragment) +{ + char *url = repo_view_url(reponame, "tree", revision, path); + char *result; + + if (!fragment) + return url; + result = fmtalloc("%s#%s", url, fragment); + free(url); + return result; +} + +const char *cgit_repobasename(const char *reponame) +{ + /* I assume we don't need to store more than one repo basename */ + 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; +} + +static void site_url(const char *page, const char *search, const char *sort, int ofs, int always_root) +{ + char *delim = "?"; + + if (page) + htmlf("/-/%s", page); + else if (always_root) + html_attr(cgit_rooturl()); + else { + char *currenturl = cgit_currenturl(); + html_attr(currenturl); + free(currenturl); + } + + 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 void link_start(const char *url, const char *title, const char *class) +{ + html("<a"); + if (title) { + html(" title='"); + html_attr(title); + html("'"); + } + if (class) { + html(" class='"); + html_attr(class); + html("'"); + } + html(" href='"); + html_attr(url); +} + +static char *repolink(const char *title, const char *class, const char *page, + const char *head, const char *path) +{ + char *url = repo_view_url(ctx.repo->url, page, head, path); + + link_start(url, title, class); + free(url); + return "?"; +} + +static void reporevlink(const char *page, const char *name, const char *title, + const char *class, const char *head, const char *rev, + const char *path) +{ + char *url = repo_view_url(ctx.repo->url, page, + link_revision(head, rev), path); + + if (!strcmp(page ? page : "", "plain")) { + char *raw = fmtalloc("%s?format=raw", url); + free(url); + url = raw; + } + link_start(url, title, class); + free(url); + html("'>"); + html_txt(name); + html("</a>"); +} + +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) +{ + char *ref = starts_with(tag, "refs/tags/") ? xstrdup(tag) : + fmtalloc("refs/tags/%s", tag); + + reporevlink("commit", name, title, class, ref, NULL, NULL); + free(ref); +} + +void cgit_tree_link(const char *name, const char *title, const char *class, + const char *head, const char *rev, const char *path) +{ + reporevlink("tree", 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", link_revision(head, rev), path); + 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", link_revision(head, rev), NULL); + 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_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) +{ + struct strbuf url = STRBUF_INIT; + char *delim = "?"; + + add_repo_url(&url, ctx.repo->url); + strbuf_addstr(&url, "/+/diff/"); + add_revision(&url, old_rev ? old_rev : "HEAD"); + strbuf_addstr(&url, ".."); + add_revision(&url, link_revision(head, new_rev)); + if (path) { + strbuf_addstr(&url, "/+/"); + add_url_path(&url, path); + } + link_start(url.buf, title, class); + strbuf_release(&url); + if (ctx.qry.difftype) { + html(delim); + htmlf("dt=%d", ctx.qry.difftype); + 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_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, "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 { + repolink(title, class, ctx.qry.page, ctx.qry.head, ctx.qry.path); + html("'>"); + html_txt(name); + html("</a>"); + } +} + +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.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); +} + +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, "/-/assets/cgit.css"); + + if (ctx.cfg.js.items) + for_each_string_list(&ctx.cfg.js, emit_js_link, NULL); + else + emit_js_link(NULL, "/-/assets/cgit.js"); + + if (ctx.cfg.favicon && *ctx.cfg.favicon) { + html("<link rel='shortcut icon' href='"); + html_attr(ctx.cfg.favicon); + html("'/>\n"); + } + if (host && ctx.repo && ctx.qry.head && starts_with(ctx.qry.head, "refs/")) { + char *fileurl; + + html("<link rel='alternate' title='Atom feed' href='"); + html(cgit_httpscheme()); + html_attr(host); + fileurl = cgit_fileurl(ctx.repo->url, "atom", ctx.qry.vpath, NULL); + html_attr(fileurl); + html("' type='application/atom+xml'/>\n"); + 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) { + char *suffix = fmtalloc("%s.git", ctx.repo->url); + add_clone_urls(fn, ctx.cfg.clone_prefix, suffix); + free(suffix); + } else { + char *host = cgit_hosturl(); + struct strbuf url = STRBUF_INIT; + + if (!host) + return; + strbuf_addf(&url, "%s%s", cgit_httpscheme(), host); + add_repo_url(&url, ctx.repo->url); + strbuf_addstr(&url, ".git"); + fn(url.buf); + strbuf_release(&url); + free(host); + } +} + +static int print_branch_option(const struct reference *ref, void *cb_data UNUSED) +{ + char *url = repo_view_url(ctx.repo->url, "tree", ref->name, + ctx.qry.vpath); + + html_option(url, ref->name, + ctx.qry.head && !strcmp(ref->name, ctx.qry.head) ? url : NULL); + free(url); + return 0; +} + +void cgit_add_hidden_formfields(int incl_head UNUSED, int incl_search, + const char *page UNUSED) +{ + 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 blame = !strcmp(ctx.qry.page, "blame"); + int levels = 0; + + ctx.qry.path = NULL; + if (blame) + cgit_tree_link("root", NULL, NULL, ctx.qry.head, + ctx.qry.oid, NULL); + else + cgit_self_link("root", NULL, NULL); + ctx.qry.path = p = path; + while (p < end) { + if (!(q = strchr(p, '/')) || levels > 15) + q = end; + *q = '\0'; + html_txt("/"); + if (blame && q < end) + cgit_tree_link(p, NULL, NULL, ctx.qry.head, + ctx.qry.oid, ctx.qry.path); + else + cgit_self_link(p, NULL, NULL); + if (q < end) + *q = '/'; + p = q + 1; + ++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("<select onchange='window.location.href=this.value'>\n"); + refs_for_each_branch_ref(get_main_ref_store(the_repository), + print_branch_option, ctx.qry.head); + if (ctx.repo->enable_remote_branches) + refs_for_each_remote_ref(get_main_ref_store(the_repository), + print_branch_option, ctx.qry.head); + html("</select>"); + } 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); + cgit_tree_link("tree", NULL, hc("tree"), ctx.qry.head, + ctx.qry.oid, ctx.qry.vpath); + cgit_commit_link("commit", NULL, + ctx.qry.page && !strcmp(ctx.qry.page, "revision") ? "active" : hc("commit"), + ctx.qry.head, ctx.qry.oid, ctx.qry.vpath); + cgit_diff_link("diff", NULL, hc("diff"), ctx.qry.head, + ctx.qry.oid, NULL, ctx.qry.vpath); + 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_attr(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'>"); +} 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,69 @@ +/* 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 char *cgit_revurl(const char *reponame, const char *revision); +extern char *cgit_treeurl(const char *reponame, const char *revision, + const char *path, const char *fragment); + + +extern void cgit_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_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_refs_link(const char *name, const char *title, + const char *class, const char *head, + const char *rev, const char *path); +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_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_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_add_clone_urls(void (*fn)(const char *)); +extern void cgit_add_hidden_formfields(int incl_head, int incl_search, + const char *page); + +#endif /* UI_SHARED_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..f8e5476 100644 --- a/charts/gilti/README.md +++ b/charts/gilti/README.md @@ -53,11 +53,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 +76,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..7cb6f72 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 }} diff --git a/charts/gilti/templates/deployment.yaml b/charts/gilti/templates/deployment.yaml index 08bd3c7..58bf5d0 100644 --- a/charts/gilti/templates/deployment.yaml +++ b/charts/gilti/templates/deployment.yaml @@ -48,12 +48,14 @@ spec: 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_CGIT_ROOT_TITLE + value: {{ .Values.cgit.rootTitle | quote }} + - name: GILTI_CGIT_ROOT_DESCRIPTION + value: {{ .Values.cgit.rootDescription | quote }} + - name: GILTI_CGIT_CLONE_PREFIX + value: {{ .Values.cgit.clonePrefix | quote }} + - name: GILTI_CGIT_CACHE + value: {{ .Values.cgit.cache | quote }} - name: GILTI_HTTP_WRITE value: {{ ternary "1" "0" .Values.http.write | quote }} ports: diff --git a/charts/gilti/values.schema.json b/charts/gilti/values.schema.json index 3fc6618..a22ed4e 100644 --- a/charts/gilti/values.schema.json +++ b/charts/gilti/values.schema.json @@ -22,12 +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]*$" } + "clonePrefix": { "type": "string", "pattern": "^[^\\r\\n]*$" }, + "cache": { "type": "integer", "minimum": 0, "maximum": 3600 } } }, "http": { diff --git a/charts/gilti/values.yaml b/charts/gilti/values.yaml index 2924782..60e492e 100644 --- a/charts/gilti/values.yaml +++ b/charts/gilti/values.yaml @@ -17,10 +17,12 @@ ssh: # Every listed key has read/write access to every repository. authorizedKeys: [] -web: +cgit: rootTitle: Gilti rootDescription: A tiny Git server clonePrefix: "" + # In-memory CGI response lifetime in seconds; zero disables the cache. + cache: 5 http: # Allow unauthenticated Git receive-pack and LFS uploads. diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml index 8296cd9..c98b0a2 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 diff --git a/crates/gilti/src/cgi.rs b/crates/gilti/src/cgi.rs index ce5989a..38d0693 100644 --- a/crates/gilti/src/cgi.rs +++ b/crates/gilti/src/cgi.rs @@ -3,10 +3,195 @@ //! HTTP-to-CGI Tower service. +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, Default)] pub struct Environment(pub Vec<(std::ffi::OsString, std::ffi::OsString)>); #[derive(Clone, Copy)] +pub struct NoCache; + +#[derive(Clone, Copy)] pub struct RemoteAddr(pub std::net::SocketAddr); #[derive(Clone)] @@ -15,6 +200,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,7 +214,15 @@ 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( @@ -52,6 +246,20 @@ impl Cgi { let body = axum::body::to_bytes(body, 1024 * 1024 * 1024) .await .map_err(std::io::Error::other)?; + let cache_key = parts + .extensions + .get::<NoCache>() + .is_none() + .then(|| CacheKey::from_request(&parts, &body)) + .flatten(); + 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")); @@ -140,12 +348,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 +430,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/main.rs b/crates/gilti/src/main.rs index 70a0b3d..1d42963 100644 --- a/crates/gilti/src/main.rs +++ b/crates/gilti/src/main.rs @@ -8,8 +8,12 @@ pub mod router; mod views; const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080"; +const DEFAULT_CACHE_SECONDS: &str = "5"; const DEFAULT_ROOT_TITLE: &str = "Gilti"; const DEFAULT_ROOT_DESCRIPTION: &str = "A tiny Git server"; +const MAX_CACHE_SECONDS: u64 = 3600; + +const CGIT: &str = "/usr/local/bin/gilti-cgit"; const GIT_HTTP_BACKENDS: &[&str] = &[ "/usr/libexec/git-core/git-http-backend", "/usr/lib/git-core/git-http-backend", @@ -24,13 +28,49 @@ const ARCHIVE_COMPRESSORS: &[&str] = &[ "/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 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"; + +const CGIT_ENVIRONMENT: &[(&str, &str)] = &[ + ("CGIT_CLONE_URL", ""), + ("CGIT_COMMIT_SORT", "0"), + ("CGIT_CSS", "/-/assets/cgit.css"), + ("CGIT_DIFFTYPE", "0"), + ("CGIT_EMBEDDED", "0"), + ("CGIT_ENABLE_COMMIT_GRAPH", "1"), + ("CGIT_ENABLE_FOLLOW_LINKS", "0"), + ("CGIT_ENABLE_LOG_FILECOUNT", "1"), + ("CGIT_ENABLE_LOG_LINECOUNT", "1"), + ("CGIT_ENABLE_REMOTE_BRANCHES", "0"), + ("CGIT_FAVICON", "/-/assets/favicon.ico"), + ("CGIT_FOOTER", ""), + ("CGIT_HEADER", ""), + ("CGIT_HEAD_INCLUDE", ""), + ("CGIT_JS", "/-/assets/cgit.js"), + ("CGIT_LOCAL_TIME", "0"), + ("CGIT_LOGO", "/-/assets/cgit.png"), + ("CGIT_LOGO_LINK", ""), + ("CGIT_MAX_ATOM_ITEMS", "10"), + ("CGIT_MAX_COMMIT_COUNT", "50"), + ("CGIT_MAX_MESSAGE_LENGTH", "80"), + ("CGIT_NOHEADER", "0"), + ("CGIT_NOPLAINEMAIL", "0"), + ("CGIT_README_0", ":README.md"), + ("CGIT_README_1", ":README"), + ("CGIT_REPO_DEFAULT_DESC", "[no description]"), + ("CGIT_RENAMELIMIT", "-1"), + ("CGIT_ROBOTS", "index, nofollow"), + ("CGIT_SECTION", ""), + ("CGIT_VIRTUAL_ROOT", "/"), + ("GIT_ATTR_NOSYSTEM", "1"), + ("GIT_CONFIG_NOSYSTEM", "1"), +]; #[derive(Clone)] struct RepositoryService { + cgit: cgi::Cgi, git: cgi::Cgi, views: views::shared::Context, write_enabled: bool, @@ -38,6 +78,7 @@ struct RepositoryService { struct Config { listen_addr: std::net::SocketAddr, + cache: std::time::Duration, root_title: String, root_description: String, clone_prefix: String, @@ -49,11 +90,13 @@ impl Config { let listen_addr = environment("GILTI_HTTP_ADDR", DEFAULT_LISTEN_ADDR)? .parse() .map_err(|_| invalid_config("GILTI_HTTP_ADDR must be a socket address"))?; + let cache = parse_cache(&environment("GILTI_CGIT_CACHE", DEFAULT_CACHE_SECONDS)?)?; 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", "")?, + cache, + root_title: environment("GILTI_CGIT_ROOT_TITLE", DEFAULT_ROOT_TITLE)?, + root_description: environment("GILTI_CGIT_ROOT_DESCRIPTION", DEFAULT_ROOT_DESCRIPTION)?, + clone_prefix: environment("GILTI_CGIT_CLONE_PREFIX", "")?, http_write: parse_bool("GILTI_HTTP_WRITE", &environment("GILTI_HTTP_WRITE", "0")?)?, }) } @@ -77,6 +120,18 @@ fn parse_bool(name: &str, value: &str) -> std::io::Result<bool> { } } +fn parse_cache(value: &str) -> std::io::Result<std::time::Duration> { + let seconds = value + .parse::<u64>() + .map_err(|_| invalid_config("GILTI_CGIT_CACHE must be an integer number of seconds"))?; + if seconds > MAX_CACHE_SECONDS { + return Err(invalid_config(format!( + "GILTI_CGIT_CACHE must not exceed {MAX_CACHE_SECONDS} seconds" + ))); + } + Ok(std::time::Duration::from_secs(seconds)) +} + fn invalid_config(message: impl Into<String>) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::InvalidData, message.into()) } @@ -98,6 +153,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { root_description: std::sync::Arc::from(config.root_description.clone()), clone_prefix: std::sync::Arc::from(config.clone_prefix.clone()), }; + let mut cgit = cgi::Cgi::new(CGIT, GIT_HOME, config.listen_addr) + .cache(config.cache) + .env("CGIT_ROOT_TITLE", config.root_title) + .env("CGIT_ROOT_DESC", config.root_description) + .env("CGIT_CLONE_PREFIX", config.clone_prefix) + .env("PATH", "/usr/bin:/bin"); + for (name, value) in CGIT_ENVIRONMENT { + cgit = cgit.env(*name, *value); + } let git = cgi::Cgi::new(git_http_backend, GIT_HOME, config.listen_addr) .env("GIT_PROJECT_ROOT", REPOSITORIES) .env("GIT_HTTP_EXPORT_ALL", "1") @@ -108,6 +172,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .env("GIT_CONFIG_NOSYSTEM", "1") .env("PATH", "/usr/bin:/bin"); let repositories = RepositoryService { + cgit, git, views, write_enabled: config.http_write, @@ -136,20 +201,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { }), ) .route( - "/-/assets/gilti.css", - axum::routing::get(async || static_file(ASSET_CSS, "text/css")), + "/-/assets/cgit.css", + axum::routing::get(async || static_file(CGIT_CSS, "text/css")), ) .route( - "/-/assets/gilti.js", - axum::routing::get(async || static_file(ASSET_JS, "text/javascript")), + "/-/assets/cgit.js", + axum::routing::get(async || static_file(CGIT_JS, "text/javascript")), ) .route( - "/-/assets/gilti.png", - axum::routing::get(async || static_file(ASSET_LOGO, "image/png")), + "/-/assets/cgit.png", + axum::routing::get(async || static_file(CGIT_LOGO, "image/png")), ) .route( "/-/assets/favicon.ico", - axum::routing::get(async || static_file(ASSET_FAVICON, "image/x-icon")), + axum::routing::get(async || static_file(CGIT_FAVICON, "image/x-icon")), ) .fallback_service(repositories); @@ -177,7 +242,8 @@ fn git_http_backend() -> std::io::Result<std::path::PathBuf> { } fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> { - for path in std::iter::once(git_http_backend) + for path in std::iter::once(std::path::Path::new(CGIT)) + .chain(std::iter::once(git_http_backend)) .chain(std::iter::once(std::path::Path::new(GIT))) .chain(ARCHIVE_COMPRESSORS.iter().map(std::path::Path::new)) { @@ -191,7 +257,7 @@ fn check_files(git_http_backend: &std::path::Path) -> std::io::Result<()> { ))); } } - for path in [ASSET_CSS, ASSET_JS, ASSET_LOGO, ASSET_FAVICON] { + for path in [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" @@ -255,7 +321,7 @@ impl RepositoryService { { request.extensions_mut().insert(cgi::RemoteAddr(remote)); } - let browser_view = matches!( + let migrated = matches!( &route, router::Route::Repositories | router::Route::Overview(_) @@ -270,10 +336,8 @@ impl RepositoryService { | router::Route::ArchiveSignature(_) | router::Route::Diff(_) | router::Route::Patch(_) - | router::Route::Log(_) - | router::Route::AtomFeed(_) ); - let query = if browser_view { + let query = if migrated { let query = match request_query(request.uri().query()) { Ok(query) => query, Err(()) => { @@ -387,23 +451,6 @@ impl RepositoryService { 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), @@ -439,6 +486,7 @@ impl RepositoryService { ) .await } + route => self.cgit(request, route).await, } } @@ -462,11 +510,73 @@ impl RepositoryService { request .extensions_mut() .insert(cgi::Environment(environment)); + request.extensions_mut().insert(cgi::NoCache); match tower::ServiceExt::oneshot(self.git.clone(), request).await { Ok(response) => response, Err(error) => internal_error("git-http-backend", error), } } + + async fn cgit( + &self, + mut request: axum::extract::Request, + route: router::Route, + ) -> axum::response::Response { + if request.method() != axum::http::Method::GET + && request.method() != axum::http::Method::HEAD + { + return plain_response( + axum::http::StatusCode::METHOD_NOT_ALLOWED, + "method not allowed\n", + ); + } + let query = match request_query(request.uri().query()) { + Ok(query) => query, + Err(()) => return plain_response(axum::http::StatusCode::BAD_REQUEST, "bad query\n"), + }; + if !valid_format(&route, query.format.as_deref()) { + return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); + } + let mut environment = match cgit_environment(route, query, request.uri().path()) { + Ok(environment) => environment, + Err(()) => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), + }; + let repository = environment + .iter() + .find(|(name, _)| name == std::ffi::OsStr::new("GILTI_REPOSITORY")) + .and_then(|(_, value)| value.to_str()); + let repository_path = match repository { + Some(repository) => crate::models::repository::path( + std::path::Path::new(self.views.repositories), + repository, + ), + None => return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"), + }; + let repository_path = match repository_path { + Ok(path) => path, + Err(crate::models::Error::NotFound) => { + return plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n"); + } + Err(crate::models::Error::Internal(error)) => { + eprintln!("gilti: repository lookup failed: {error}"); + return plain_response( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "internal server error\n", + ); + } + }; + environment.push(( + "GILTI_REPOSITORY_PATH".into(), + repository_path.into_os_string(), + )); + request + .extensions_mut() + .insert(cgi::Environment(environment)); + match tower::ServiceExt::oneshot(self.cgit.clone(), request).await { + Ok(response) => response, + Err(error) => internal_error("cgit", error), + } + } } #[derive(Default)] @@ -552,6 +662,44 @@ fn valid_format(route: &router::Route, format: Option<&str>) -> bool { } } +fn cgit_environment( + route: router::Route, + query: RequestQuery, + current_url: &str, +) -> Result<Vec<(std::ffi::OsString, std::ffi::OsString)>, ()> { + let mut environment = query.environment; + let mut set = |name: &str, value: String| environment.push((name.into(), value.into())); + set("GILTI_CURRENT_URL", current_url.to_owned()); + + match route { + router::Route::Log(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "log".to_owned()); + set("GILTI_REVISION", revision(route.params.rev)); + if let Some(path) = route.params.path { + set("GILTI_PATH", path); + } + } + router::Route::AtomFeed(route) => { + set("GILTI_REPOSITORY", route.repo); + set("GILTI_PAGE", "atom".to_owned()); + set("GILTI_REVISION", route.params.reference); + if let Some(path) = route.params.path { + set("GILTI_PATH", path); + } + } + _ => return Err(()), + } + Ok(environment) +} + +fn revision(revision: router::Revision) -> String { + match revision { + router::Revision::Head => "HEAD".to_owned(), + router::Revision::Ref(reference) | router::Revision::Commit(reference) => reference, + } +} + fn safe_repository(repo: &str) -> bool { !repo.is_empty() && !repo.starts_with('/') @@ -615,10 +763,45 @@ fn plain_response( #[cfg(test)] mod tests { + fn environment<'a>( + values: &'a [(std::ffi::OsString, std::ffi::OsString)], + name: &str, + ) -> &'a str { + values + .iter() + .find(|(key, _)| key == std::ffi::OsStr::new(name)) + .and_then(|(_, value)| value.to_str()) + .unwrap() + } + + #[test] + fn route_parameters_become_trusted_cgit_environment() { + let route = super::router::parse("/group/repo/+/refs/heads/main/+/log/src/lib.rs").unwrap(); + let query = super::request_query(Some("ofs=5&follow=1")).unwrap(); + let values = super::cgit_environment(route, query, "/group/repo").unwrap(); + assert_eq!(environment(&values, "GILTI_REPOSITORY"), "group/repo"); + assert_eq!(environment(&values, "GILTI_PAGE"), "log"); + assert_eq!(environment(&values, "GILTI_REVISION"), "refs/heads/main"); + assert_eq!(environment(&values, "GILTI_PATH"), "src/lib.rs"); + assert_eq!(environment(&values, "GILTI_QUERY_OFFSET"), "5"); + assert_eq!(environment(&values, "GILTI_QUERY_FOLLOW"), "1"); + } + #[test] fn structural_query_parameters_are_rejected() { assert!(super::request_query(Some("id=HEAD")).is_err()); assert!(super::request_query(Some("path=README.md")).is_err()); assert!(super::request_query(Some("format=raw&format=html")).is_err()); } + + #[test] + fn cache_is_bounded() { + assert_eq!( + super::parse_cache("5").unwrap(), + std::time::Duration::from_secs(5) + ); + assert!(super::parse_cache("0").unwrap().is_zero()); + assert!(super::parse_cache("3601").is_err()); + assert!(super::parse_cache("forever").is_err()); + } } 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 index b507017..f60f9f3 100644 --- a/crates/gilti/src/models/mod.rs +++ b/crates/gilti/src/models/mod.rs @@ -7,7 +7,6 @@ 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; @@ -17,7 +16,6 @@ pub mod repository; pub mod revision; pub mod stats; pub mod tag; -pub mod time; pub mod tree; #[derive(Debug)] @@ -119,25 +117,6 @@ mod migration_tests { 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 index 68afdc0..36c7d67 100644 --- a/crates/gilti/src/models/object.rs +++ b/crates/gilti/src/models/object.rs @@ -36,7 +36,7 @@ mod tests { } #[test] - fn reads_raw_text_and_binary_objects_like_browser_blob() { + fn reads_raw_text_and_binary_objects_like_cgit_blob() { let (root, repository) = fixture(); let odb = repository.odb().unwrap(); let text_oid = odb.write(git2::ObjectType::Blob, b"hello\n").unwrap(); diff --git a/crates/gilti/src/models/stats.rs b/crates/gilti/src/models/stats.rs index deaca92..c4b026e 100644 --- a/crates/gilti/src/models/stats.rs +++ b/crates/gilti/src/models/stats.rs @@ -118,8 +118,12 @@ fn label(period: Period, timestamp: i64) -> String { } fn tm(timestamp: i64) -> libc::tm { - super::time::utc(timestamp) - .unwrap_or_else(|| super::time::utc(0).expect("the Unix epoch is representable")) + let mut result = std::mem::MaybeUninit::<libc::tm>::uninit(); + // SAFETY: both pointers are valid for the duration of the call. + unsafe { + libc::gmtime_r(×tamp, result.as_mut_ptr()); + result.assume_init() + } } fn normalize(time: &mut libc::tm) { 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/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/diff.rs b/crates/gilti/src/views/diff.rs index aedaf9d..30be8af 100644 --- a/crates/gilti/src/views/diff.rs +++ b/crates/gilti/src/views/diff.rs @@ -134,7 +134,7 @@ pub fn content( } pub fn content_controls(query: Query) -> Markup { - html! { div class="gilti-panel" { + html! { div class="cgit-panel" { b { "diff options" } form method="get" { table { tr { td colspan="2" {} } 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 index 46c6d4a..3cb192d 100644 --- a/crates/gilti/src/views/mod.rs +++ b/crates/gilti/src/views/mod.rs @@ -4,10 +4,8 @@ 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; @@ -35,7 +33,7 @@ pub(crate) fn bad_request(message: &'static str) -> axum::response::Response { .expect("static response is valid") } -pub(crate) fn bytes_response( +fn bytes_response( content_type: &'static str, content_disposition: Option<String>, etag: Option<String>, diff --git a/crates/gilti/src/views/repositories.rs b/crates/gilti/src/views/repositories.rs index d692306..0722d5c 100644 --- a/crates/gilti/src/views/repositories.rs +++ b/crates/gilti/src/views/repositories.rs @@ -63,14 +63,14 @@ pub async fn serve( 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="stylesheet" type="text/css" href="/-/assets/cgit.css"; + script type="text/javascript" src="/-/assets/cgit.js" {} link rel="shortcut icon" href="/-/assets/favicon.ico"; } - body { div id="gilti" { + body { div id="cgit" { table id="header" { tr { - td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } } + td class="logo" rowspan="2" { a href="/" { img src="/-/assets/cgit.png" alt="cgit logo"; } } td class="main" { (&*context.root_title) } } tr { td class="sub" { (&*context.root_description) } } diff --git a/crates/gilti/src/views/revision.rs b/crates/gilti/src/views/revision.rs index f4466c5..7f086f0 100644 --- a/crates/gilti/src/views/revision.rs +++ b/crates/gilti/src/views/revision.rs @@ -96,8 +96,12 @@ fn content(model: &crate::models::commit::Commit, query: super::diff::Query) -> 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 mut value = std::mem::MaybeUninit::<libc::tm>::uninit(); + // SAFETY: both pointers are valid for the duration of the call. + let value = unsafe { + libc::gmtime_r(&adjusted, value.as_mut_ptr()); + value.assume_init() + }; let sign = if identity.offset_minutes < 0 { '-' } else { diff --git a/crates/gilti/src/views/shared.rs b/crates/gilti/src/views/shared.rs index d18d79c..198ecf7 100644 --- a/crates/gilti/src/views/shared.rs +++ b/crates/gilti/src/views/shared.rs @@ -61,14 +61,14 @@ pub fn render_titled( 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="stylesheet" type="text/css" href="/-/assets/cgit.css"; + script type="text/javascript" src="/-/assets/cgit.js" {} link rel="shortcut icon" href="/-/assets/favicon.ico"; } - body { div id="gilti" { + body { div id="cgit" { table id="header" { tr { - td class="logo" rowspan="2" { a href="/" { img src="/-/assets/gilti.png" alt="gilti logo"; } } + td class="logo" rowspan="2" { a href="/" { img src="/-/assets/cgit.png" alt="cgit logo"; } } td class="main" { (&*context.root_title) " : " a href=(&repo) { (&repository.name) } } } tr { td class="sub" { (&repository.description) } } diff --git a/crates/gilti/src/views/stats.rs b/crates/gilti/src/views/stats.rs index 4c6de35..a15c716 100644 --- a/crates/gilti/src/views/stats.rs +++ b/crates/gilti/src/views/stats.rs @@ -75,7 +75,7 @@ fn content(model: &crate::models::stats::Stats, query: &Query) -> Markup { .unwrap_or(model.authors.len()) .min(model.authors.len()); html! { - div class="gilti-panel" { + div class="cgit-panel" { b { "stat options" } form method="get" { table { tr { td colspan="2" {} } 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/tests/chart.sh b/tests/chart.sh index 42f6910..b5df06b 100755 --- a/tests/chart.sh +++ b/tests/chart.sh @@ -28,15 +28,25 @@ 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 'name: GILTI_CGIT_CACHE' "$rendered" +grep -q 'value: "5"' "$rendered" +if grep -q 'cgitrc' "$rendered"; then + echo 'chart still provisions a cgit configuration file' >&2 + exit 1 +fi +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 +54,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..e883e20 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 \ @@ -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" test -e /etc/cgitrc; then + echo 'legacy cgit configuration file is installed' >&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 \ @@ -105,18 +118,18 @@ status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$http echo "POST to repository browser returned HTTP $status instead of 405" >&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/-/assets/cgit.css" | grep -q 'cgit' +content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/cgit.css" | awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') [ "$content_type" = text/css ] || { - echo "unexpected 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/-/assets/cgit.js" | grep -q 'function' +content_type=$(curl -fsSI "http://127.0.0.1:$http_port/-/assets/cgit.js" | awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') [ "$content_type" = text/javascript ] || { - echo "unexpected 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 @@ -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 @@ -237,29 +250,6 @@ curl -fsS "http://127.0.0.1:$http_port/testing/+/HEAD/+/tree/README%2emd" | grep 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\.' @@ -290,6 +280,17 @@ printf '%s' "$lfs_response" | grep -q '"code":404' || { echo 'unexpected LFS batch response' >&2 exit 1 } +cache_url="http://127.0.0.1:$http_port/testing/+/HEAD/+/log" +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 |
