[mirror] Scalable static site server for Git forges (like GitHub Pages)

Compare changes

Choose any two refs to compare.

+28 -21
.forgejo/workflows/ci.yaml
··· 5 5 pull_request: 6 6 workflow_dispatch: 7 7 8 + env: 9 + FORGE: codeberg.org 10 + 8 11 jobs: 9 12 check: 10 - runs-on: codeberg-small-lazy 13 + runs-on: debian-trixie 11 14 container: 12 - image: docker.io/library/node:24-trixie-slim@sha256:45babd1b4ce0349fb12c4e24bf017b90b96d52806db32e001e3013f341bef0fe 15 + image: docker.io/library/node:24-trixie-slim@sha256:b05474903f463ce4064c09986525e6588c3e66c51b69be9c93a39fb359f883ce 13 16 steps: 14 17 - name: Check out source code 15 - uses: https://code.forgejo.org/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 18 + uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 16 19 - name: Set up toolchain 17 - uses: https://code.forgejo.org/actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6 20 + uses: https://code.forgejo.org/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 18 21 with: 19 22 go-version: '>=1.25.0' 20 23 - name: Install dependencies ··· 25 28 - name: Build service 26 29 run: | 27 30 go build 31 + - name: Run tests 32 + run: | 33 + go test ./... 28 34 - name: Run static analysis 29 35 run: | 30 - go vet 31 - staticcheck 36 + go vet ./... 37 + staticcheck ./... 32 38 33 39 release: 34 40 # IMPORTANT: This workflow step will not work without the Releases unit enabled! 35 41 if: ${{ forge.ref == 'refs/heads/main' || startsWith(forge.event.ref, 'refs/tags/v') }} 36 42 needs: [check] 37 - runs-on: codeberg-medium-lazy 43 + runs-on: debian-trixie 38 44 container: 39 - image: docker.io/library/node:24-trixie-slim@sha256:ef4ca6d078dd18322059a1f051225f7bbfc2bb60c16cbb5d8a1ba2cc8964fe8a 45 + image: docker.io/library/node:24-trixie-slim@sha256:b05474903f463ce4064c09986525e6588c3e66c51b69be9c93a39fb359f883ce 40 46 steps: 41 47 - name: Check out source code 42 - uses: https://code.forgejo.org/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 48 + uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 43 49 - name: Set up toolchain 44 - uses: https://code.forgejo.org/actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6 50 + uses: https://code.forgejo.org/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 45 51 with: 46 52 go-version: '>=1.25.0' 47 53 - name: Install dependencies ··· 58 64 build linux arm64 59 65 build darwin arm64 60 66 - name: Create release 61 - uses: https://code.forgejo.org/actions/forgejo-release@v2.7.3 67 + uses: https://code.forgejo.org/actions/forgejo-release@fc0488c944626f9265d87fbc4dd6c08f78014c63 # v2.7.3 62 68 with: 63 69 tag: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }} 64 70 release-dir: assets ··· 69 75 package: 70 76 if: ${{ forge.ref == 'refs/heads/main' || startsWith(forge.event.ref, 'refs/tags/v') }} 71 77 needs: [check] 72 - runs-on: codeberg-medium-lazy 78 + runs-on: debian-trixie 73 79 container: 74 - image: docker.io/library/node:24-trixie-slim@sha256:ef4ca6d078dd18322059a1f051225f7bbfc2bb60c16cbb5d8a1ba2cc8964fe8a 80 + image: docker.io/library/node:24-trixie-slim@sha256:b05474903f463ce4064c09986525e6588c3e66c51b69be9c93a39fb359f883ce 75 81 steps: 76 82 - name: Install dependencies 77 83 run: | 78 84 apt-get -y update 79 - apt-get -y install buildah ca-certificates 85 + apt-get -y install ca-certificates buildah qemu-user-binfmt 80 86 - name: Check out source code 81 - uses: https://code.forgejo.org/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 87 + uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 88 + - name: Authenticate with Docker 89 + run: | 90 + buildah login --authfile=/tmp/authfile-${FORGE}.json \ 91 + -u ${{ vars.PACKAGES_USER }} -p ${{ secrets.PACKAGES_TOKEN }} ${FORGE} 82 92 - name: Build container 83 93 run: | 84 94 printf '[storage]\ndriver="vfs"\nrunroot="/run/containers/storage"\ngraphroot="/var/lib/containers/storage"\n' | tee /etc/containers/storage.conf 85 - buildah build --arch=amd64 --tag=container:${VER}-amd64 . 86 - buildah build --arch=arm64 --tag=container:${VER}-arm64 . 95 + buildah build ${CACHE} --arch=amd64 --tag=container:${VER}-amd64 96 + buildah build ${CACHE} --arch=arm64 --tag=container:${VER}-arm64 87 97 buildah manifest create container:${VER} \ 88 98 container:${VER}-amd64 \ 89 99 container:${VER}-arm64 90 100 env: 91 101 BUILDAH_ISOLATION: chroot 92 102 VER: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }} 103 + CACHE: ${{ format('--authfile=/tmp/authfile-{0}.json --layers --cache-from {0}/{1}/cache --cache-to {0}/{1}/cache', env.FORGE, forge.repository) }} 93 104 - if: ${{ forge.repository == 'git-pages/git-pages' }} 94 105 name: Push container to Codeberg 95 106 run: | 96 - buildah login --authfile=/tmp/authfile-${FORGE}.json \ 97 - -u ${{ vars.PACKAGES_USER }} -p ${{ secrets.PACKAGES_TOKEN }} ${FORGE} 98 107 buildah manifest push --authfile=/tmp/authfile-${FORGE}.json \ 99 108 --all container:${VER} "docker://${FORGE}/${{ forge.repository }}:${VER/v/}" 100 109 env: 101 - BUILDAH_ISOLATION: chroot 102 - FORGE: codeberg.org 103 110 VER: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }}
+1
.gitignore
··· 4 4 /data 5 5 /config*.toml* 6 6 /git-pages 7 + /site
+11 -14
Dockerfile
··· 1 1 # Install CA certificates. 2 - FROM docker.io/library/alpine:latest@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 AS ca-certificates-builder 2 + FROM docker.io/library/alpine:3 AS ca-certificates-builder 3 3 RUN apk --no-cache add ca-certificates 4 4 5 5 # Build supervisor. 6 - FROM docker.io/library/golang:1.25-alpine@sha256:aee43c3ccbf24fdffb7295693b6e33b21e01baec1b2a55acc351fde345e9ec34 AS supervisor-builder 6 + FROM docker.io/library/golang:1.25-alpine@sha256:ac09a5f469f307e5da71e766b0bd59c9c49ea460a528cc3e6686513d64a6f1fb AS supervisor-builder 7 7 RUN apk --no-cache add git 8 8 WORKDIR /build 9 9 RUN git clone https://github.com/ochinchina/supervisord . && \ 10 10 git checkout 16cb640325b3a4962b2ba17d68fb5c2b1e1b6b3c 11 - RUN GOBIN=/usr/bin go install -ldflags "-s -w" && \ 12 - go clean -cache -modcache 11 + RUN GOBIN=/usr/bin go install -ldflags "-s -w" 13 12 14 13 # Build Caddy with S3 storage backend. 15 - FROM docker.io/library/caddy:2.10.2-builder@sha256:53f91ad7c5f1ab9a607953199b7c1e10920c570ae002aef913d68ed7464fb19f AS caddy-builder 14 + FROM docker.io/library/caddy:2.10.2-builder@sha256:6644af24bde2b4dbb07eb57637051abd2aa713e9787fa1eb544c3f31a0620898 AS caddy-builder 16 15 RUN xcaddy build ${CADDY_VERSION} \ 17 - --with=github.com/ss098/certmagic-s3@v0.0.0-20250922022452-8af482af5f39 && \ 18 - go clean -cache -modcache 16 + --with=github.com/ss098/certmagic-s3@v0.0.0-20250922022452-8af482af5f39 19 17 20 18 # Build git-pages. 21 - FROM docker.io/library/golang:1.25-alpine@sha256:aee43c3ccbf24fdffb7295693b6e33b21e01baec1b2a55acc351fde345e9ec34 AS git-pages-builder 19 + FROM docker.io/library/golang:1.25-alpine@sha256:ac09a5f469f307e5da71e766b0bd59c9c49ea460a528cc3e6686513d64a6f1fb AS git-pages-builder 22 20 RUN apk --no-cache add git 23 21 WORKDIR /build 24 22 COPY go.mod go.sum ./ 25 23 RUN go mod download 26 24 COPY *.go ./ 27 25 COPY src/ ./src/ 28 - RUN go build -ldflags "-s -w" -o git-pages . && \ 29 - go clean -cache -modcache 26 + RUN go build -ldflags "-s -w" -o git-pages . 30 27 31 28 # Compose git-pages and Caddy. 32 - FROM docker.io/library/busybox:1.37.0-musl@sha256:ef13e7482851632be3faf5bd1d28d4727c0810901d564b35416f309975a12a30 29 + FROM docker.io/library/busybox:1.37.0-musl@sha256:03db190ed4c1ceb1c55d179a0940e2d71d42130636a780272629735893292223 33 30 COPY --from=ca-certificates-builder /etc/ssl/cert.pem /etc/ssl/cert.pem 34 31 COPY --from=supervisor-builder /usr/bin/supervisord /bin/supervisord 35 32 COPY --from=caddy-builder /usr/bin/caddy /bin/caddy ··· 39 36 RUN mkdir /app/data 40 37 COPY conf/supervisord.conf /app/supervisord.conf 41 38 COPY conf/Caddyfile /app/Caddyfile 42 - COPY conf/config.example.toml /app/config.toml 39 + COPY conf/config.docker.toml /app/config.toml 43 40 44 41 # Caddy ports: 45 42 EXPOSE 80/tcp 443/tcp 443/udp ··· 49 46 # While the default command is to run git-pages standalone, the intended configuration 50 47 # is to use it with Caddy and store both site data and credentials to an S3-compatible 51 48 # object store. 52 - # * In a standalone configuration, the default, git-caddy listens on port 3000 (http). 53 - # * In a combined configuration, supervisord launches both git-caddy and Caddy, and 49 + # * In a standalone configuration, the default, git-pages listens on port 3000 (http). 50 + # * In a combined configuration, supervisord launches both git-pages and Caddy, and 54 51 # Caddy listens on ports 80 (http) and 443 (https). 55 52 CMD ["git-pages"] 56 53 # CMD ["supervisord"]
-14
LICENSE-0BSD.txt
··· 1 - Copyright (C) git-pages contributors 2 - Copyright (C) Catherine 'whitequark' 3 - 4 - Permission to use, copy, modify, and/or distribute this software for 5 - any purpose with or without fee is hereby granted. 6 - 7 - THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 - WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 - MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 - ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 - WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 12 - AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 13 - OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 -
+14
LICENSE.txt
··· 1 + Copyright (C) git-pages contributors 2 + Copyright (C) Catherine 'whitequark' 3 + 4 + Permission to use, copy, modify, and/or distribute this software for 5 + any purpose with or without fee is hereby granted. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 12 + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 13 + OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 +
+38 -11
README.md
··· 1 1 git-pages 2 2 ========= 3 3 4 - _git-pages_ is a static site server for use with Git forges (i.e. a GitHub Pages replacement). It is written with efficiency in mind, scaling horizontally to any number of deployed sites and concurrent requests and serving sites up to hundreds of megabytes in size, while being equally suitable for single-user deployments. 4 + _git-pages_ is a static site server for use with Git forges (i.e. a GitHub Pages replacement). It is written with efficiency in mind, scaling horizontally to any number of machines and serving sites up to multiple gigabytes in size, while being equally suitable for small single-user deployments. 5 + 6 + It is implemented in Go and has no other mandatory dependencies, although it is designed to be used together with the [Caddy server][caddy] for TLS termination. Site data may be stored on the filesystem or in an [Amazon S3](https://aws.amazon.com/s3/) compatible object store. 5 7 6 - It is implemented in Go and has no other mandatory dependencies, although it is designed to be used together with the [Caddy server][caddy] (for TLS termination) and an [Amazon S3](https://aws.amazon.com/s3/) compatible object store (for horizontal scalability of storage). 8 + The included Docker container provides everything needed to deploy a Pages service, including zero-configuration on-demand provisioning of TLS certificates from [Let's Encrypt](https://letsencrypt.org/), and runs on any commodity cloud infrastructure. 7 9 8 - The included Docker container provides everything needed to deploy a Pages service, including zero-configuration on-demand provisioning of TLS certificates from [Let's Encrypt](https://letsencrypt.org/), and runs on any commodity cloud infrastructure. There is also a first-party deployment of _git-pages_ at [grebedoc.dev](https://grebedoc.dev). 10 + > [!TIP] 11 + > If you want to publish a site using _git-pages_ to an existing service like Codeberg Pages or [Grebedoc][grebedoc], consider using the [CLI tool][git-pages-cli] or the [Forgejo Action][git-pages-action]. 9 12 10 13 [caddy]: https://caddyserver.com/ 14 + [git-pages-cli]: https://codeberg.org/git-pages/git-pages-cli 15 + [git-pages-action]: https://codeberg.org/git-pages/action 16 + [codeberg-pages]: https://codeberg.page 17 + [grebedoc]: https://grebedoc.dev 11 18 12 19 13 20 Quickstart ··· 32 39 33 40 The `pages` branch of the repository is now available at http://localhost:3000/! 34 41 35 - [git-pages-cli]: https://codeberg.org/git-pages/git-pages-cli 36 - 37 42 38 43 Deployment 39 44 ---------- ··· 63 68 - Site URLs that have a path starting with `.git-pages/...` are reserved for _git-pages_ itself. 64 69 - The `.git-pages/health` URL returns `ok` with the `Last-Modified:` header set to the manifest modification time. 65 70 - The `.git-pages/manifest.json` URL returns a [ProtoJSON](https://protobuf.dev/programming-guides/json/) representation of the deployed site manifest with the `Last-Modified:` header set to the manifest modification time. It enumerates site structure, redirect rules, and errors that were not severe enough to abort publishing. Note that **the manifest JSON format is not stable and will change without notice**. 66 - - **With feature `archive-site`:** The `.git-pages/archive.tar` URL returns a tar archive of all site contents, including `_redirects` and `_headers` files (reconstructed from the manifest), with the `Last-Modified:` header set to the manifest modification time. Compression can be enabled using the `Accept-Encoding:` HTTP header (only). 71 + - The `.git-pages/archive.tar` URL returns a tar archive of all site contents, including `_redirects` and `_headers` files (reconstructed from the manifest), with the `Last-Modified:` header set to the manifest modification time. Compression can be enabled using the `Accept-Encoding:` HTTP header (only). 67 72 * In response to a `PUT` or `POST` request, the server updates a site with new content. The URL of the request must be the root URL of the site that is being published. 68 73 - If the `PUT` method receives an `application/x-www-form-urlencoded` body, it contains a repository URL to be shallowly cloned. The `Branch` header contains the branch to be checked out; the `pages` branch is used if the header is absent. 69 74 - If the `PUT` method receives an `application/x-tar`, `application/x-tar+gzip`, `application/x-tar+zstd`, or `application/zip` body, it contains an archive to be extracted. 70 75 - The `POST` method requires an `application/json` body containing a Forgejo/Gitea/Gogs/GitHub webhook event payload. Requests where the `ref` key contains anything other than `refs/heads/pages` are ignored, and only the `pages` branch is used. The `repository.clone_url` key contains a repository URL to be shallowly cloned. 71 76 - If the received contents is empty, performs the same action as `DELETE`. 77 + * In response to a `PATCH` request, the server partially updates a site with new content. The URL of the request must be the root URL of the site that is being published. 78 + - The request must have a `application/x-tar`, `application/x-tar+gzip`, or `application/x-tar+zstd` body, whose contents is *merged* with the existing site contents as follows: 79 + - A character device entry with major 0 and minor 0 is treated as a "whiteout marker" (following [unionfs][whiteout]): it causes any existing file or directory with the same name to be deleted. 80 + - A directory entry replaces any existing file or directory with the same name (if any), recursively removing the old contents. 81 + - A file or symlink entry replaces any existing file or directory with the same name (if any). 82 + - If there is no `Create-Parents:` header or a `Create-Parents: no` header is present, the parent path of an entry must exist and refer to a directory. 83 + - If a `Create-Parents: yes` header is present, any missing segments in the parent path of an entry will be created (like `mkdir -p`). Any existing segments refer to directories. 84 + - The request must have a `Atomic: yes` or `Atomic: no` header. Not every backend configuration makes it possible to perform atomic compare-and-swap operations; on backends without atomic CAS support, `Atomic: yes` requests will fail, while `Atomic: no` requests will provide a best-effort approximation. 85 + - If a `PATCH` request loses a race against another content update request, it may return `409 Conflict`. This is true regardless of the `Atomic:` header value. Whenever this happens, resubmit the request as-is. 86 + - If the site has no contents after the update is applied, performs the same action as `DELETE`. 72 87 * In response to a `DELETE` request, the server unpublishes a site. The URL of the request must be the root URL of the site that is being unpublished. Site data remains stored for an indeterminate period of time, but becomes completely inaccessible. 88 + * If a `Dry-Run: yes` header is provided with a `PUT`, `PATCH`, `DELETE`, or `POST` request, only the authorization checks are run; no destructive updates are made. 73 89 * All updates to site content are atomic (subject to consistency guarantees of the storage backend). That is, there is an instantaneous moment during an update before which the server will return the old content and after which it will return the new content. 74 90 * Files with a certain name, when placed in the root of a site, have special functions: 75 91 - [Netlify `_redirects`][_redirects] file can be used to specify HTTP redirect and rewrite rules. The _git-pages_ implementation currently does not support placeholders, query parameters, or conditions, and may differ from Netlify in other minor ways. If you find that a supported `_redirects` file feature does not work the same as on Netlify, please file an issue. (Note that _git-pages_ does not perform URL normalization; `/foo` and `/foo/` are *not* the same, unlike with Netlify.) 76 92 - [Netlify `_headers`][_headers] file can be used to specify custom HTTP response headers (if allowlisted by configuration). In particular, this is useful to enable [CORS requests][cors]. The _git-pages_ implementation may differ from Netlify in minor ways; if you find that a `_headers` file feature does not work the same as on Netlify, please file an issue. 93 + * Incremental updates can be made using `PUT` or `PATCH` requests where the body contains an archive (both tar and zip are supported). 94 + - Any archive entry that is a symlink to `/git/pages/<git-sha256>` is replaced with an existing manifest entry for the same site whose git blob hash matches `<git-sha256>`. If there is no existing manifest entry with the specified git hash, the update fails with a `422 Unprocessable Entity`. 95 + - For this error response only, if the negotiated content type is `application/vnd.git-pages.unresolved`, the response will contain the `<git-sha256>` of each unresolved reference, one per line. 96 + * Support for SHA-256 Git hashes is [limited by go-git][go-git-sha256]; once go-git implements the required features, _git-pages_ will automatically gain support for SHA-256 Git hashes. Note that shallow clones (used by _git-pages_ to conserve bandwidth if available) aren't supported yet in the Git protocol as of 2025. 77 97 78 98 [_redirects]: https://docs.netlify.com/manage/routing/redirects/overview/ 79 99 [_headers]: https://docs.netlify.com/manage/routing/headers/ 80 100 [cors]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS 101 + [go-git-sha256]: https://github.com/go-git/go-git/issues/706 102 + [whiteout]: https://docs.kernel.org/filesystems/overlayfs.html#whiteouts-and-opaque-directories 81 103 82 104 83 105 Authorization ··· 85 107 86 108 DNS is the primary authorization method, using either TXT records or wildcard matching. In certain cases, git forge authorization is used in addition to DNS. 87 109 88 - The authorization flow for content updates (`PUT`, `DELETE`, `POST` requests) proceeds sequentially in the following order, with the first of multiple applicable rule taking precedence: 110 + The authorization flow for content updates (`PUT`, `PATCH`, `DELETE`, `POST` requests) proceeds sequentially in the following order, with the first of multiple applicable rule taking precedence: 89 111 90 112 1. **Development Mode:** If the environment variable `PAGES_INSECURE` is set to a truthful value like `1`, the request is authorized. 91 - 2. **DNS Challenge:** If the method is `PUT`, `DELETE`, `POST`, and a well-formed `Authorization:` header is provided containing a `<token>`, and a TXT record lookup at `_git-pages-challenge.<host>` returns a record whose concatenated value equals `SHA256("<host> <token>")`, the request is authorized. 113 + 2. **DNS Challenge:** If the method is `PUT`, `PATCH`, `DELETE`, `POST`, and a well-formed `Authorization:` header is provided containing a `<token>`, and a TXT record lookup at `_git-pages-challenge.<host>` returns a record whose concatenated value equals `SHA256("<host> <token>")`, the request is authorized. 92 114 - **`Pages` scheme:** Request includes an `Authorization: Pages <token>` header. 93 115 - **`Basic` scheme:** Request includes an `Authorization: Basic <basic>` header, where `<basic>` is equal to `Base64("Pages:<token>")`. (Useful for non-Forgejo forges.) 94 - 3. **DNS Allowlist:** If the method is `PUT` or `POST`, and a TXT record lookup at `_git-pages-repository.<host>` returns a set of well-formed absolute URLs, and (for `PUT` requests) the body contains a repository URL, and the requested clone URLs is contained in this set of URLs, the request is authorized. 116 + 3. **DNS Allowlist:** If the method is `PUT` or `POST`, and the request URL is `scheme://<user>.<host>/`, and a TXT record lookup at `_git-pages-repository.<host>` returns a set of well-formed absolute URLs, and (for `PUT` requests) the body contains a repository URL, and the requested clone URLs is contained in this set of URLs, the request is authorized. 95 117 4. **Wildcard Match (content):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized. 96 118 - **Index repository:** If the request URL is `scheme://<user>.<host>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, where `<project>` is computed by templating each element of `[[wildcard]].index-repos` with `<user>`, and `[[wildcard]]` is the section where the match occurred. 97 119 - **Project repository:** If the request URL is `scheme://<user>.<host>/<project>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, and `[[wildcard]]` is the section where the match occurred. 98 - 5. **Forge Authorization:** If the method is `PUT`, and the body contains an archive, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and `[[wildcard]].authorization` is non-empty, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) grants push permissions to a repository at the *matching* clone URL (as defined above) as determined by an API call to the forge, the request is authorized. (This enables publishing a site for a private repository.) 120 + 5. **Forge Authorization:** If the method is `PUT` or `PATCH`, and the body contains an archive, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and `[[wildcard]].authorization` is non-empty, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) grants push permissions to a repository at the *matching* clone URL (as defined above) as determined by an API call to the forge, the request is authorized. (This enables publishing a site for a private repository.) 99 121 5. **Default Deny:** Otherwise, the request is not authorized. 100 122 101 123 The authorization flow for metadata retrieval (`GET` requests with site paths starting with `.git-pages/`) in the following order, with the first of multiple applicable rule taking precedence: ··· 115 137 * If `SENTRY_DSN` environment variable is set, panics are reported to Sentry. 116 138 * If `SENTRY_DSN` and `SENTRY_LOGS=1` environment variables are set, logs are uploaded to Sentry. 117 139 * If `SENTRY_DSN` and `SENTRY_TRACING=1` environment variables are set, traces are uploaded to Sentry. 140 + * Optional syslog integration allows transmitting application logs to a syslog daemon. When present, the `SYSLOG_ADDR` environment variable enables the integration, and the value is used to configure the syslog destination. The value must follow the format `family/address` and is usually one of the following: 141 + * a Unix datagram socket: `unixgram//dev/log`; 142 + * TLS over TCP: `tcp+tls/host:port`; 143 + * plain TCP: `tcp/host:post`; 144 + * UDP: `udp/host:port`. 118 145 119 146 120 147 Architecture (v2) ··· 160 187 License 161 188 ------- 162 189 163 - [0-clause BSD](LICENSE-0BSD.txt) 190 + [0-clause BSD](LICENSE.txt)
-6
conf/Caddyfile
··· 25 25 on_demand 26 26 } 27 27 28 - # initial PUT/POST for a new domain has to happen over HTTP 29 - @upgrade `method('GET') && protocol('http')` 30 - redir @upgrade https://{host}{uri} 301 31 - 32 28 reverse_proxy http://localhost:3000 33 - header Alt-Svc `h3=":443"; persist=1, h2=":443"; persist=1` 34 - encode 35 29 }
+4
conf/config.docker.toml
··· 1 + [server] 2 + pages = "tcp/:3000" 3 + caddy = "tcp/:3001" 4 + metrics = "tcp/:3002"
+15 -6
conf/config.example.toml
··· 5 5 6 6 [server] 7 7 # Use "-" to disable the handler. 8 - pages = "tcp/:3000" 9 - caddy = "tcp/:3001" 10 - metrics = "tcp/:3002" 8 + pages = "tcp/localhost:3000" 9 + caddy = "tcp/localhost:3001" 10 + metrics = "tcp/localhost:3002" 11 11 12 12 [[wildcard]] # non-default section 13 13 domain = "codeberg.page" ··· 15 15 index-repos = ["<user>.codeberg.page", "pages"] 16 16 index-repo-branch = "main" 17 17 authorization = "forgejo" 18 - fallback-proxy-to = "https://codeberg.page" 18 + 19 + [fallback] # non-default section 20 + proxy-to = "https://codeberg.page" 21 + insecure = false 19 22 20 23 [storage] 21 24 type = "fs" ··· 23 26 [storage.fs] 24 27 root = "./data" 25 28 26 - [storage.s3] # non-default bucket configuration 29 + [storage.s3] # non-default section 27 30 endpoint = "play.min.io" 28 31 access-key-id = "Q3AM3UQ867SPQQA43P2F" 29 32 secret-access-key = "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG" ··· 47 50 update-timeout = "60s" 48 51 max-heap-size-ratio = 0.5 # * RAM_size 49 52 forbidden-domains = [] 50 - # allowed-repository-url-prefixes = <nil> 53 + allowed-repository-url-prefixes = [] 51 54 allowed-custom-headers = ["X-Clacks-Overhead"] 55 + 56 + [audit] 57 + node-id = 0 58 + collect = false 59 + include-ip = "" 60 + notify-url = "" 52 61 53 62 [observability] 54 63 slow-response-threshold = "500ms"
+24
flake.lock
··· 18 18 "type": "github" 19 19 } 20 20 }, 21 + "gomod2nix": { 22 + "inputs": { 23 + "flake-utils": [ 24 + "flake-utils" 25 + ], 26 + "nixpkgs": [ 27 + "nixpkgs" 28 + ] 29 + }, 30 + "locked": { 31 + "lastModified": 1763982521, 32 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 33 + "owner": "nix-community", 34 + "repo": "gomod2nix", 35 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 36 + "type": "github" 37 + }, 38 + "original": { 39 + "owner": "nix-community", 40 + "repo": "gomod2nix", 41 + "type": "github" 42 + } 43 + }, 21 44 "nix-filter": { 22 45 "locked": { 23 46 "lastModified": 1757882181, ··· 52 75 "root": { 53 76 "inputs": { 54 77 "flake-utils": "flake-utils", 78 + "gomod2nix": "gomod2nix", 55 79 "nix-filter": "nix-filter", 56 80 "nixpkgs": "nixpkgs" 57 81 }
+19 -4
flake.nix
··· 3 3 nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 4 flake-utils.url = "github:numtide/flake-utils"; 5 5 nix-filter.url = "github:numtide/nix-filter"; 6 + 7 + gomod2nix = { 8 + url = "github:nix-community/gomod2nix"; 9 + inputs.nixpkgs.follows = "nixpkgs"; 10 + inputs.flake-utils.follows = "flake-utils"; 11 + }; 6 12 }; 7 13 8 14 outputs = ··· 11 17 nixpkgs, 12 18 flake-utils, 13 19 nix-filter, 14 - }: 20 + ... 21 + }@inputs: 15 22 flake-utils.lib.eachDefaultSystem ( 16 23 system: 17 24 let 18 - pkgs = nixpkgs.legacyPackages.${system}; 25 + pkgs = import nixpkgs { 26 + inherit system; 27 + 28 + overlays = [ 29 + inputs.gomod2nix.overlays.default 30 + ]; 31 + }; 19 32 20 - git-pages = pkgs.buildGo125Module { 33 + git-pages = pkgs.buildGoApplication { 21 34 pname = "git-pages"; 22 35 version = "0"; 23 36 ··· 43 56 "-s -w" 44 57 ]; 45 58 46 - vendorHash = "sha256-oVXELOXbRTzzU8pUGNE4K552thlZXGAX7qpv6ETwz6o="; 59 + go = pkgs.go_1_25; 60 + modules = ./gomod2nix.toml; 47 61 }; 48 62 in 49 63 { ··· 56 70 57 71 packages = with pkgs; [ 58 72 caddy 73 + gomod2nix 59 74 ]; 60 75 }; 61 76
+30 -18
go.mod
··· 3 3 go 1.25.0 4 4 5 5 require ( 6 - codeberg.org/git-pages/go-headers v1.1.0 6 + codeberg.org/git-pages/go-headers v1.1.1 7 + codeberg.org/git-pages/go-slog-syslog v0.0.0-20251207093707-892f654e80b7 7 8 github.com/KimMachineGun/automemlimit v0.7.5 8 9 github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 9 10 github.com/creasty/defaults v1.8.0 10 - github.com/getsentry/sentry-go v0.36.2 11 - github.com/getsentry/sentry-go/slog v0.36.2 12 - github.com/go-git/go-billy/v6 v6.0.0-20251026101908-623011986e70 13 - github.com/go-git/go-git/v6 v6.0.0-20251029213217-0bbfc0875edd 14 - github.com/klauspost/compress v1.18.1 15 - github.com/maypok86/otter/v2 v2.2.1 16 - github.com/minio/minio-go/v7 v7.0.95 11 + github.com/dghubble/trie v0.1.0 12 + github.com/fatih/color v1.18.0 13 + github.com/getsentry/sentry-go v0.40.0 14 + github.com/getsentry/sentry-go/slog v0.40.0 15 + github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd 16 + github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19 17 + github.com/jpillora/backoff v1.0.0 18 + github.com/kankanreno/go-snowflake v1.2.0 19 + github.com/klauspost/compress v1.18.2 20 + github.com/maypok86/otter/v2 v2.3.0 21 + github.com/minio/minio-go/v7 v7.0.97 17 22 github.com/pelletier/go-toml/v2 v2.2.4 18 23 github.com/pquerna/cachecontrol v0.2.0 19 24 github.com/prometheus/client_golang v1.23.2 20 - github.com/samber/slog-multi v1.5.0 25 + github.com/samber/slog-multi v1.7.0 21 26 github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37 22 27 github.com/valyala/fasttemplate v1.2.2 23 - google.golang.org/protobuf v1.36.10 28 + golang.org/x/net v0.48.0 29 + google.golang.org/protobuf v1.36.11 24 30 ) 25 31 26 32 require ( ··· 29 35 github.com/beorn7/perks v1.0.1 // indirect 30 36 github.com/cespare/xxhash/v2 v2.3.0 // indirect 31 37 github.com/cloudflare/circl v1.6.1 // indirect 32 - github.com/cyphar/filepath-securejoin v0.5.0 // indirect 38 + github.com/cyphar/filepath-securejoin v0.6.1 // indirect 39 + github.com/davecgh/go-spew v1.1.1 // indirect 33 40 github.com/dustin/go-humanize v1.0.1 // indirect 34 41 github.com/emirpasic/gods v1.18.1 // indirect 35 42 github.com/go-git/gcfg/v2 v2.0.2 // indirect 36 43 github.com/go-ini/ini v1.67.0 // indirect 37 - github.com/goccy/go-json v0.10.5 // indirect 38 44 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 39 45 github.com/google/uuid v1.6.0 // indirect 40 46 github.com/kevinburke/ssh_config v1.4.0 // indirect 41 47 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 42 - github.com/minio/crc64nvme v1.0.2 // indirect 48 + github.com/klauspost/crc32 v1.3.0 // indirect 49 + github.com/leodido/go-syslog/v4 v4.3.0 // indirect 50 + github.com/mattn/go-colorable v0.1.13 // indirect 51 + github.com/mattn/go-isatty v0.0.20 // indirect 52 + github.com/minio/crc64nvme v1.1.0 // indirect 43 53 github.com/minio/md5-simd v1.1.2 // indirect 44 54 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 45 55 github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect 46 56 github.com/philhofer/fwd v1.2.0 // indirect 47 57 github.com/pjbgf/sha1cd v0.5.0 // indirect 48 58 github.com/pkg/errors v0.9.1 // indirect 59 + github.com/pmezard/go-difflib v1.0.0 // indirect 49 60 github.com/prometheus/client_model v0.6.2 // indirect 50 61 github.com/prometheus/common v0.66.1 // indirect 51 62 github.com/prometheus/procfs v0.16.1 // indirect 52 63 github.com/rs/xid v1.6.0 // indirect 53 - github.com/samber/lo v1.51.0 // indirect 64 + github.com/samber/lo v1.52.0 // indirect 54 65 github.com/samber/slog-common v0.19.0 // indirect 55 66 github.com/sergi/go-diff v1.4.0 // indirect 67 + github.com/stretchr/testify v1.11.1 // indirect 56 68 github.com/tinylib/msgp v1.3.0 // indirect 57 69 github.com/tj/assert v0.0.3 // indirect 58 70 github.com/valyala/bytebufferpool v1.0.0 // indirect 59 71 go.yaml.in/yaml/v2 v2.4.2 // indirect 60 - golang.org/x/crypto v0.43.0 // indirect 61 - golang.org/x/net v0.46.0 // indirect 62 - golang.org/x/sys v0.37.0 // indirect 63 - golang.org/x/text v0.30.0 // indirect 72 + golang.org/x/crypto v0.46.0 // indirect 73 + golang.org/x/sys v0.39.0 // indirect 74 + golang.org/x/text v0.32.0 // indirect 75 + gopkg.in/yaml.v3 v3.0.1 // indirect 64 76 )
+59 -44
go.sum
··· 1 - codeberg.org/git-pages/go-headers v1.0.0 h1:hvGU97hQdXaT5HwCpZJWQdg7akvtOBCSUNL4u2a5uTs= 2 - codeberg.org/git-pages/go-headers v1.0.0/go.mod h1:N4gwH0U3YPwmuyxqH7xBA8j44fTPX+vOEP7ejJVBPts= 3 - codeberg.org/git-pages/go-headers v1.1.0 h1:rk7/SOSsn+XuL7PUQZFYUaWKHEaj6K8mXmUV9rF2VxE= 4 - codeberg.org/git-pages/go-headers v1.1.0/go.mod h1:N4gwH0U3YPwmuyxqH7xBA8j44fTPX+vOEP7ejJVBPts= 1 + codeberg.org/git-pages/go-headers v1.1.1 h1:fpIBELKo66Z2k+gCeYl5mCEXVQ99Lmx1iup1nbo2shE= 2 + codeberg.org/git-pages/go-headers v1.1.1/go.mod h1:N4gwH0U3YPwmuyxqH7xBA8j44fTPX+vOEP7ejJVBPts= 3 + codeberg.org/git-pages/go-slog-syslog v0.0.0-20251207093707-892f654e80b7 h1:+rkrAxhNZo/eKEcKOqVOsF6ohAPv5amz0JLburOeRjs= 4 + codeberg.org/git-pages/go-slog-syslog v0.0.0-20251207093707-892f654e80b7/go.mod h1:8NPSXbYcVb71qqNM5cIgn1/uQgMisLbu2dVD1BNxsUw= 5 5 github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= 6 6 github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= 7 7 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= ··· 22 22 github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 23 23 github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= 24 24 github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= 25 - github.com/cyphar/filepath-securejoin v0.5.0 h1:hIAhkRBMQ8nIeuVwcAoymp7MY4oherZdAxD+m0u9zaw= 26 - github.com/cyphar/filepath-securejoin v0.5.0/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 25 + github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= 26 + github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= 27 27 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 28 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 29 29 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 + github.com/dghubble/trie v0.1.0 h1:kJnjBLFFElBwS60N4tkPvnLhnpcDxbBjIulgI8CpNGM= 31 + github.com/dghubble/trie v0.1.0/go.mod h1:sOmnzfBNH7H92ow2292dDFWNsVQuh/izuD7otCYb1ak= 30 32 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 31 33 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 32 - github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 33 - github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 34 34 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 35 35 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 36 - github.com/getsentry/sentry-go v0.36.2 h1:uhuxRPTrUy0dnSzTd0LrYXlBYygLkKY0hhlG5LXarzM= 37 - github.com/getsentry/sentry-go v0.36.2/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c= 38 - github.com/getsentry/sentry-go/slog v0.36.2 h1:PM27JHFE3lsE8fgI/cOueEOtjiktnC3Za2o5oL9PbJQ= 39 - github.com/getsentry/sentry-go/slog v0.36.2/go.mod h1:aVFAxnpA3FEtZeSBhBFAnWOlqhiLjaaoOZ0bmBN9IHo= 36 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 37 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 38 + github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo= 39 + github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= 40 + github.com/getsentry/sentry-go/slog v0.40.0 h1:uR2EPL9w6uHw3XB983IAqzqM9mP+fjJpNY9kfob3/Z8= 41 + github.com/getsentry/sentry-go/slog v0.40.0/go.mod h1:ArRaP+0rsbnJGyvZwYDo/vDQT/YBbOQeOlO+DGW+F9s= 40 42 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 41 43 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 42 44 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= 43 45 github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= 44 46 github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= 45 47 github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= 46 - github.com/go-git/go-billy/v6 v6.0.0-20251026101908-623011986e70 h1:TWpNrg9JPxp0q+KG0hoFGBulPIP/kMK1b0mDqjdEB/s= 47 - github.com/go-git/go-billy/v6 v6.0.0-20251026101908-623011986e70/go.mod h1:TpCYxdQ0tWZkrnAkd7yqK+z1C8RKcyjcaYAJNAcnUnM= 48 - github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w= 49 - github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU= 50 - github.com/go-git/go-git/v6 v6.0.0-20251029213217-0bbfc0875edd h1:pn6+tR4O8McyqEr2MbQwqcySovpG8jDd11F/jQ6aAfA= 51 - github.com/go-git/go-git/v6 v6.0.0-20251029213217-0bbfc0875edd/go.mod h1:z9pQiXCfyOZIs/8qa5zmozzbcsDPtGN91UD7+qeX3hk= 48 + github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd h1:Gd/f9cGi/3h1JOPaa6er+CkKUGyGX2DBJdFbDKVO+R0= 49 + github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI= 50 + github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs= 51 + github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146/go.mod h1:QE/75B8tBSLNGyUUbA9tw3EGHoFtYOtypa2h8YJxsWI= 52 + github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19 h1:0lz2eJScP8v5YZQsrEw+ggWC5jNySjg4bIZo5BIh6iI= 53 + github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19/go.mod h1:L+Evfcs7EdTqxwv854354cb6+++7TFL3hJn3Wy4g+3w= 52 54 github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= 53 55 github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 54 - github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 55 - github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 56 56 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 57 57 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 58 58 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 59 59 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 60 60 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 61 61 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 62 + github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 63 + github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 64 + github.com/kankanreno/go-snowflake v1.2.0 h1:Zx2SctsH5pivIj9vyhwyDyQS23jcDJx4iT49Bjv81kk= 65 + github.com/kankanreno/go-snowflake v1.2.0/go.mod h1:6CZ+10PeVsFXKZUTYyJzPiRIjn1IXbInaWLCX/LDJ0g= 62 66 github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= 63 67 github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= 64 - github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= 65 - github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= 68 + github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= 69 + github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 66 70 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 67 71 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 68 72 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 73 + github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= 74 + github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= 69 75 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 70 76 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 71 77 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= ··· 75 81 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 76 82 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 77 83 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 78 - github.com/maypok86/otter/v2 v2.2.1 h1:hnGssisMFkdisYcvQ8L019zpYQcdtPse+g0ps2i7cfI= 79 - github.com/maypok86/otter/v2 v2.2.1/go.mod h1:1NKY9bY+kB5jwCXBJfE59u+zAwOt6C7ni1FTlFFMqVs= 80 - github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= 81 - github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= 84 + github.com/leodido/go-syslog/v4 v4.3.0 h1:bbSpI/41bYK9iSdlYzcwvlxuLOE8yi4VTFmedtnghdA= 85 + github.com/leodido/go-syslog/v4 v4.3.0/go.mod h1:eJ8rUfDN5OS6dOkCOBYlg2a+hbAg6pJa99QXXgMrd98= 86 + github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 87 + github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 88 + github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 89 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 90 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 91 + github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= 92 + github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= 93 + github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= 94 + github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= 82 95 github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= 83 96 github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= 84 - github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= 85 - github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= 97 + github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= 98 + github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= 86 99 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 87 100 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 88 101 github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= ··· 113 126 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 114 127 github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 115 128 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 116 - github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= 117 - github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= 129 + github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= 130 + github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= 118 131 github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI= 119 132 github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M= 120 - github.com/samber/slog-multi v1.5.0 h1:UDRJdsdb0R5vFQFy3l26rpX3rL3FEPJTJ2yKVjoiT1I= 121 - github.com/samber/slog-multi v1.5.0/go.mod h1:im2Zi3mH/ivSY5XDj6LFcKToRIWPw1OcjSVSdXt+2d0= 133 + github.com/samber/slog-multi v1.7.0 h1:GKhbkxU3ujkyMsefkuz4qvE6EcgtSuqjFisPnfdzVLI= 134 + github.com/samber/slog-multi v1.7.0/go.mod h1:qTqzmKdPpT0h4PFsTN5rYRgLwom1v+fNGuIrl1Xnnts= 122 135 github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= 123 136 github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 124 137 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= ··· 140 153 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 141 154 go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 142 155 go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 143 - golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 144 - golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 145 - golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 146 - golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 147 - golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 148 - golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 149 - golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 150 - golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 151 - golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 152 - golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 153 - google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= 154 - google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 156 + golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= 157 + golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 158 + golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 159 + golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 160 + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 161 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 162 + golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 163 + golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 164 + golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= 165 + golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 166 + golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 167 + golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 168 + google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 169 + google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 155 170 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 156 171 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 157 172 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+204
gomod2nix.toml
··· 1 + schema = 3 2 + 3 + [mod] 4 + [mod."codeberg.org/git-pages/go-headers"] 5 + version = "v1.1.1" 6 + hash = "sha256-qgL7l1FHXxcBWhBnBLEI0yENd6P+frvwlKxEAXLA3VY=" 7 + [mod."codeberg.org/git-pages/go-slog-syslog"] 8 + version = "v0.0.0-20251207093707-892f654e80b7" 9 + hash = "sha256-ye+DBIyxqTEOViYRrQPWyGJCaLmyKSDwH5btlqDPizM=" 10 + [mod."github.com/KimMachineGun/automemlimit"] 11 + version = "v0.7.5" 12 + hash = "sha256-lH/ip9j2hbYUc2W/XIYve/5TScQPZtEZe3hu76CY//k=" 13 + [mod."github.com/Microsoft/go-winio"] 14 + version = "v0.6.2" 15 + hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 16 + [mod."github.com/ProtonMail/go-crypto"] 17 + version = "v1.3.0" 18 + hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 19 + [mod."github.com/beorn7/perks"] 20 + version = "v1.0.1" 21 + hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" 22 + [mod."github.com/c2h5oh/datasize"] 23 + version = "v0.0.0-20231215233829-aa82cc1e6500" 24 + hash = "sha256-8MqL7xCvE6fIjanz2jwkaLP1OE5kLu62TOcQx452DHQ=" 25 + [mod."github.com/cespare/xxhash/v2"] 26 + version = "v2.3.0" 27 + hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 28 + [mod."github.com/cloudflare/circl"] 29 + version = "v1.6.1" 30 + hash = "sha256-Dc69V12eIFnJoUNmwg6VKXHfAMijbAeEVSDe8AiOaLo=" 31 + [mod."github.com/creasty/defaults"] 32 + version = "v1.8.0" 33 + hash = "sha256-I1LE1cfOhMS5JxB7+fWTKieefw2Gge1UhIZh+A6pa6s=" 34 + [mod."github.com/cyphar/filepath-securejoin"] 35 + version = "v0.6.1" 36 + hash = "sha256-obqip8c1c9mjXFznyXF8aDnpcMw7ttzv+e28anCa/v0=" 37 + [mod."github.com/davecgh/go-spew"] 38 + version = "v1.1.1" 39 + hash = "sha256-nhzSUrE1fCkN0+RL04N4h8jWmRFPPPWbCuDc7Ss0akI=" 40 + [mod."github.com/dghubble/trie"] 41 + version = "v0.1.0" 42 + hash = "sha256-hVh7uYylpMCCSPcxl70hJTmzSwaA1MxBmJFBO5Xdncc=" 43 + [mod."github.com/dustin/go-humanize"] 44 + version = "v1.0.1" 45 + hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" 46 + [mod."github.com/emirpasic/gods"] 47 + version = "v1.18.1" 48 + hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" 49 + [mod."github.com/fatih/color"] 50 + version = "v1.18.0" 51 + hash = "sha256-pP5y72FSbi4j/BjyVq/XbAOFjzNjMxZt2R/lFFxGWvY=" 52 + [mod."github.com/getsentry/sentry-go"] 53 + version = "v0.40.0" 54 + hash = "sha256-mJ+EzM8WRzJ2Yp7ithDJNceU4+GbzQyi46yc8J8d13Y=" 55 + [mod."github.com/getsentry/sentry-go/slog"] 56 + version = "v0.40.0" 57 + hash = "sha256-uc9TpKiWMEpRbxwV2uGQeq1DDdZi+APOgu2StVzzEkw=" 58 + [mod."github.com/go-git/gcfg/v2"] 59 + version = "v2.0.2" 60 + hash = "sha256-icqMDeC/tEg/3979EuEN67Ml5KjdDA0R3QvR6iLLrSI=" 61 + [mod."github.com/go-git/go-billy/v6"] 62 + version = "v6.0.0-20251217170237-e9738f50a3cd" 63 + hash = "sha256-b2yunYcPUiLTU+Rr8qTBdsDEfsIhZDYmyqKW5udmpFY=" 64 + [mod."github.com/go-git/go-git/v6"] 65 + version = "v6.0.0-20251224103503-78aff6aa5ea9" 66 + hash = "sha256-kYjDqH0NZ+sxQnj5K8xKfO2WOVKtQ/7tWcqY6KYqAZE=" 67 + [mod."github.com/go-ini/ini"] 68 + version = "v1.67.0" 69 + hash = "sha256-V10ahGNGT+NLRdKUyRg1dos5RxLBXBk1xutcnquc/+4=" 70 + [mod."github.com/golang/groupcache"] 71 + version = "v0.0.0-20241129210726-2c02b8208cf8" 72 + hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 73 + [mod."github.com/google/uuid"] 74 + version = "v1.6.0" 75 + hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 76 + [mod."github.com/jpillora/backoff"] 77 + version = "v1.0.0" 78 + hash = "sha256-uxHg68NN8hrwPCrPfLYYprZHf7dMyEoPoF46JFx0IHU=" 79 + [mod."github.com/kankanreno/go-snowflake"] 80 + version = "v1.2.0" 81 + hash = "sha256-713xGEqjwaUGIu2EHII5sldWmcquFpxZmte/7R/O6LA=" 82 + [mod."github.com/kevinburke/ssh_config"] 83 + version = "v1.4.0" 84 + hash = "sha256-UclxB7Ll1FZCgU2SrGkiGdr4CoSRJ127MNnZtxKTsvg=" 85 + [mod."github.com/klauspost/compress"] 86 + version = "v1.18.2" 87 + hash = "sha256-mRa+6qEi5joqQao13ZFogmq67rOQzHCVbCCjKA+HKEc=" 88 + [mod."github.com/klauspost/cpuid/v2"] 89 + version = "v2.3.0" 90 + hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 91 + [mod."github.com/klauspost/crc32"] 92 + version = "v1.3.0" 93 + hash = "sha256-RsS/MDJbVzVB+i74whqABgwZJWMw+AutF6HhJBVgbag=" 94 + [mod."github.com/leodido/go-syslog/v4"] 95 + version = "v4.3.0" 96 + hash = "sha256-fCJ2rgrrPR/Ey/PoAsJhd8Sl8mblAnnMAmBuoWFBTgg=" 97 + [mod."github.com/mattn/go-colorable"] 98 + version = "v0.1.13" 99 + hash = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8=" 100 + [mod."github.com/mattn/go-isatty"] 101 + version = "v0.0.20" 102 + hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 103 + [mod."github.com/maypok86/otter/v2"] 104 + version = "v2.3.0" 105 + hash = "sha256-ELzmi/s2WqDeUmzSGnfx+ys2Hs28XHqF7vlEzyRotIA=" 106 + [mod."github.com/minio/crc64nvme"] 107 + version = "v1.1.0" 108 + hash = "sha256-OwlE70X91WO4HdbpGsOaB4w12Qrk0duCpfLeAskiqY8=" 109 + [mod."github.com/minio/md5-simd"] 110 + version = "v1.1.2" 111 + hash = "sha256-vykcXvy2VBBAXnJott/XsGTT0gk2UL36JzZKfJ1KAUY=" 112 + [mod."github.com/minio/minio-go/v7"] 113 + version = "v7.0.97" 114 + hash = "sha256-IwF14tWVYjBi28jUG9iFYd4Lpbc7Fvyy0zRzEZ82UEE=" 115 + [mod."github.com/munnerz/goautoneg"] 116 + version = "v0.0.0-20191010083416-a7dc8b61c822" 117 + hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 118 + [mod."github.com/pbnjay/memory"] 119 + version = "v0.0.0-20210728143218-7b4eea64cf58" 120 + hash = "sha256-QI+F1oPLOOtwNp8+m45OOoSfYFs3QVjGzE0rFdpF/IA=" 121 + [mod."github.com/pelletier/go-toml/v2"] 122 + version = "v2.2.4" 123 + hash = "sha256-8qQIPldbsS5RO8v/FW/se3ZsAyvLzexiivzJCbGRg2Q=" 124 + [mod."github.com/philhofer/fwd"] 125 + version = "v1.2.0" 126 + hash = "sha256-cGx2/0QQay46MYGZuamFmU0TzNaFyaO+J7Ddzlr/3dI=" 127 + [mod."github.com/pjbgf/sha1cd"] 128 + version = "v0.5.0" 129 + hash = "sha256-11XBkhdciQGsQ7jEMZ6PgphRKjruTSc7ZxfOwDuPCr8=" 130 + [mod."github.com/pkg/errors"] 131 + version = "v0.9.1" 132 + hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw=" 133 + [mod."github.com/pmezard/go-difflib"] 134 + version = "v1.0.0" 135 + hash = "sha256-/FtmHnaGjdvEIKAJtrUfEhV7EVo5A/eYrtdnUkuxLDA=" 136 + [mod."github.com/pquerna/cachecontrol"] 137 + version = "v0.2.0" 138 + hash = "sha256-tuTERCFfwmqPepw/rs5cyv9fArCD30BqgjZqwMV+vzQ=" 139 + [mod."github.com/prometheus/client_golang"] 140 + version = "v1.23.2" 141 + hash = "sha256-3GD4fBFa1tJu8MS4TNP6r2re2eViUE+kWUaieIOQXCg=" 142 + [mod."github.com/prometheus/client_model"] 143 + version = "v0.6.2" 144 + hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 145 + [mod."github.com/prometheus/common"] 146 + version = "v0.66.1" 147 + hash = "sha256-bqHPaV9IV70itx63wqwgy2PtxMN0sn5ThVxDmiD7+Tk=" 148 + [mod."github.com/prometheus/procfs"] 149 + version = "v0.16.1" 150 + hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 151 + [mod."github.com/rs/xid"] 152 + version = "v1.6.0" 153 + hash = "sha256-rJB7h3KuH1DPp5n4dY3MiGnV1Y96A10lf5OUl+MLkzU=" 154 + [mod."github.com/samber/lo"] 155 + version = "v1.52.0" 156 + hash = "sha256-xgMsPJv3rydHH10NZU8wz/DhK2VbbR8ymivOg1ChTp0=" 157 + [mod."github.com/samber/slog-common"] 158 + version = "v0.19.0" 159 + hash = "sha256-OYXVbZML7c3mFClVy8GEnNoWW+4OfcBsxWDtKh1u7B8=" 160 + [mod."github.com/samber/slog-multi"] 161 + version = "v1.6.0" 162 + hash = "sha256-uebbTcvsBP2LdOUIjDptES+HZOXxThnIt3+FKL0qJy4=" 163 + [mod."github.com/sergi/go-diff"] 164 + version = "v1.4.0" 165 + hash = "sha256-rs9NKpv/qcQEMRg7CmxGdP4HGuFdBxlpWf9LbA9wS4k=" 166 + [mod."github.com/stretchr/testify"] 167 + version = "v1.11.1" 168 + hash = "sha256-sWfjkuKJyDllDEtnM8sb/pdLzPQmUYWYtmeWz/5suUc=" 169 + [mod."github.com/tinylib/msgp"] 170 + version = "v1.3.0" 171 + hash = "sha256-PnpndO7k5Yl036vhWJGDsrcz0jsTX8sUiTqm/D3rAVw=" 172 + [mod."github.com/tj/assert"] 173 + version = "v0.0.3" 174 + hash = "sha256-4xhmZcHpUafabaXejE9ucVnGxG/txomvKzBg6cbkusg=" 175 + [mod."github.com/tj/go-redirects"] 176 + version = "v0.0.0-20200911105812-fd1ba1020b37" 177 + hash = "sha256-GpYpxdT4F7PkwGXLo7cYVcIRJrzd1sKHtFDH+bRb6Tk=" 178 + [mod."github.com/valyala/bytebufferpool"] 179 + version = "v1.0.0" 180 + hash = "sha256-I9FPZ3kCNRB+o0dpMwBnwZ35Fj9+ThvITn8a3Jr8mAY=" 181 + [mod."github.com/valyala/fasttemplate"] 182 + version = "v1.2.2" 183 + hash = "sha256-gp+lNXE8zjO+qJDM/YbS6V43HFsYP6PKn4ux1qa5lZ0=" 184 + [mod."go.yaml.in/yaml/v2"] 185 + version = "v2.4.2" 186 + hash = "sha256-oC8RWdf1zbMYCtmR0ATy/kCkhIwPR9UqFZSMOKLVF/A=" 187 + [mod."golang.org/x/crypto"] 188 + version = "v0.46.0" 189 + hash = "sha256-I8N/spcw3/h0DFA+V1WK38HctckWIB9ep93DEVCALxU=" 190 + [mod."golang.org/x/net"] 191 + version = "v0.48.0" 192 + hash = "sha256-oZpddsiJwWCH3Aipa+XXpy7G/xHY5fEagUSok7T0bXE=" 193 + [mod."golang.org/x/sys"] 194 + version = "v0.39.0" 195 + hash = "sha256-dxTBu/JAWUkPbjFIXXRFdhQWyn+YyEpIC+tWqGo0Y6U=" 196 + [mod."golang.org/x/text"] 197 + version = "v0.32.0" 198 + hash = "sha256-9PXtWBKKY9rG4AgjSP4N+I1DhepXhy8SF/vWSIDIoWs=" 199 + [mod."google.golang.org/protobuf"] 200 + version = "v1.36.11" 201 + hash = "sha256-7W+6jntfI/awWL3JP6yQedxqP5S9o3XvPgJ2XxxsIeE=" 202 + [mod."gopkg.in/yaml.v3"] 203 + version = "v3.0.1" 204 + hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU="
+3 -1
renovate.json
··· 14 14 "lockFileMaintenance": { 15 15 "enabled": true, 16 16 "automerge": false 17 - } 17 + }, 18 + "semanticCommits": "disabled", 19 + "commitMessagePrefix": "[Renovate]" 18 20 }
+393
src/audit.go
··· 1 + package git_pages 2 + 3 + import ( 4 + "cmp" 5 + "context" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "os" 10 + "os/exec" 11 + "path" 12 + "path/filepath" 13 + "strconv" 14 + "strings" 15 + "time" 16 + 17 + exponential "github.com/jpillora/backoff" 18 + "github.com/kankanreno/go-snowflake" 19 + "github.com/prometheus/client_golang/prometheus" 20 + "github.com/prometheus/client_golang/prometheus/promauto" 21 + "google.golang.org/protobuf/encoding/protojson" 22 + "google.golang.org/protobuf/proto" 23 + timestamppb "google.golang.org/protobuf/types/known/timestamppb" 24 + ) 25 + 26 + var ( 27 + auditNotifyOkCount = promauto.NewCounter(prometheus.CounterOpts{ 28 + Name: "git_pages_audit_notify_ok", 29 + Help: "Count of successful audit notifications", 30 + }) 31 + auditNotifyErrorCount = promauto.NewCounter(prometheus.CounterOpts{ 32 + Name: "git_pages_audit_notify_error", 33 + Help: "Count of failed audit notifications", 34 + }) 35 + ) 36 + 37 + type principalKey struct{} 38 + 39 + var PrincipalKey = principalKey{} 40 + 41 + func WithPrincipal(ctx context.Context) context.Context { 42 + principal := &Principal{} 43 + return context.WithValue(ctx, PrincipalKey, principal) 44 + } 45 + 46 + func GetPrincipal(ctx context.Context) *Principal { 47 + if principal, ok := ctx.Value(PrincipalKey).(*Principal); ok { 48 + return principal 49 + } 50 + return nil 51 + } 52 + 53 + type AuditID int64 54 + 55 + func GenerateAuditID() AuditID { 56 + inner, err := snowflake.NextID() 57 + if err != nil { 58 + panic(err) 59 + } 60 + return AuditID(inner) 61 + } 62 + 63 + func ParseAuditID(repr string) (AuditID, error) { 64 + inner, err := strconv.ParseInt(repr, 16, 64) 65 + if err != nil { 66 + return AuditID(0), err 67 + } 68 + return AuditID(inner), nil 69 + } 70 + 71 + func (id AuditID) String() string { 72 + return fmt.Sprintf("%016x", int64(id)) 73 + } 74 + 75 + func (id AuditID) CompareTime(when time.Time) int { 76 + idMillis := int64(id) >> (snowflake.MachineIDLength + snowflake.SequenceLength) 77 + whenMillis := when.UTC().UnixNano() / 1e6 78 + return cmp.Compare(idMillis, whenMillis) 79 + } 80 + 81 + func EncodeAuditRecord(record *AuditRecord) (data []byte) { 82 + data, err := proto.MarshalOptions{Deterministic: true}.Marshal(record) 83 + if err != nil { 84 + panic(err) 85 + } 86 + return 87 + } 88 + 89 + func DecodeAuditRecord(data []byte) (record *AuditRecord, err error) { 90 + record = &AuditRecord{} 91 + err = proto.Unmarshal(data, record) 92 + return 93 + } 94 + 95 + func (record *AuditRecord) GetAuditID() AuditID { 96 + return AuditID(record.GetId()) 97 + } 98 + 99 + func (record *AuditRecord) DescribePrincipal() string { 100 + var items []string 101 + if record.Principal != nil { 102 + if record.Principal.GetIpAddress() != "" { 103 + items = append(items, record.Principal.GetIpAddress()) 104 + } 105 + if record.Principal.GetCliAdmin() { 106 + items = append(items, "<cli-admin>") 107 + } 108 + } 109 + if len(items) > 0 { 110 + return strings.Join(items, ";") 111 + } else { 112 + return "<unknown>" 113 + } 114 + } 115 + 116 + func (record *AuditRecord) DescribeResource() string { 117 + desc := "<unknown>" 118 + if record.Domain != nil && record.Project != nil { 119 + desc = path.Join(*record.Domain, *record.Project) 120 + } else if record.Domain != nil { 121 + desc = *record.Domain 122 + } 123 + return desc 124 + } 125 + 126 + type AuditRecordScope int 127 + 128 + const ( 129 + AuditRecordComplete AuditRecordScope = iota 130 + AuditRecordNoManifest 131 + ) 132 + 133 + func AuditRecordJSON(record *AuditRecord, scope AuditRecordScope) []byte { 134 + switch scope { 135 + case AuditRecordComplete: 136 + // as-is 137 + case AuditRecordNoManifest: 138 + // trim the manifest 139 + newRecord := &AuditRecord{} 140 + proto.Merge(newRecord, record) 141 + newRecord.Manifest = nil 142 + record = newRecord 143 + } 144 + 145 + json, err := protojson.MarshalOptions{ 146 + Multiline: true, 147 + EmitDefaultValues: true, 148 + }.Marshal(record) 149 + if err != nil { 150 + panic(err) 151 + } 152 + return json 153 + } 154 + 155 + // This function receives `id` and `record` separately because the record itself may have its 156 + // ID missing or mismatched. While this is very unlikely, using the actual primary key as 157 + // the filename is more robust. 158 + func ExtractAuditRecord(ctx context.Context, id AuditID, record *AuditRecord, dest string) error { 159 + const mode = 0o400 // readable by current user, not writable 160 + 161 + err := os.WriteFile(filepath.Join(dest, fmt.Sprintf("%s-event.json", id)), 162 + AuditRecordJSON(record, AuditRecordNoManifest), mode) 163 + if err != nil { 164 + return err 165 + } 166 + 167 + if record.Manifest != nil { 168 + err = os.WriteFile(filepath.Join(dest, fmt.Sprintf("%s-manifest.json", id)), 169 + ManifestJSON(record.Manifest), mode) 170 + if err != nil { 171 + return err 172 + } 173 + 174 + archive, err := os.OpenFile(filepath.Join(dest, fmt.Sprintf("%s-archive.tar", id)), 175 + os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) 176 + if err != nil { 177 + return err 178 + } 179 + defer archive.Close() 180 + 181 + err = CollectTar(ctx, archive, record.Manifest, ManifestMetadata{}) 182 + if err != nil { 183 + return err 184 + } 185 + } 186 + 187 + return nil 188 + } 189 + 190 + func AuditEventProcessor(command string, args []string) (http.Handler, error) { 191 + var err error 192 + 193 + // Resolve the command to an absolute path, as it will be run from a different current 194 + // directory, which would break e.g. `git-pages -audit-server tcp/:3004 ./handler.sh`. 195 + if command, err = exec.LookPath(command); err != nil { 196 + return nil, err 197 + } 198 + if command, err = filepath.Abs(command); err != nil { 199 + return nil, err 200 + } 201 + 202 + router := http.NewServeMux() 203 + router.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 204 + // Go will cancel the request context if the client drops the connection. We don't want 205 + // that to interrupt processing. However, we also want the client (not the server) to 206 + // handle retries, so instead of spawning a goroutine to process the event, we do this 207 + // within the HTTP handler. If an error is returned, the notify goroutine in the worker 208 + // will retry the HTTP request (with backoff) until it succeeds. 209 + // 210 + // This is a somewhat idiosyncratic design and it's not clear that this is the best 211 + // possible approach (e.g. if the worker gets restarted and the event processing fails, 212 + // it will not be retried), but it should do the job for now. It is expected that 213 + // some form of observability is used to highlight event processor errors. 214 + ctx := context.WithoutCancel(r.Context()) 215 + 216 + id, err := ParseAuditID(r.URL.RawQuery) 217 + if err != nil { 218 + logc.Printf(ctx, "audit process err: malformed query\n") 219 + http.Error(w, "malformed query", http.StatusBadRequest) 220 + return 221 + } else { 222 + logc.Printf(ctx, "audit process %s", id) 223 + } 224 + 225 + record, err := backend.QueryAuditLog(ctx, id) 226 + if err != nil { 227 + logc.Printf(ctx, "audit process err: missing record\n") 228 + http.Error(w, "missing record", http.StatusNotFound) 229 + return 230 + } 231 + 232 + args := append(args, id.String(), record.GetEvent().String()) 233 + cmd := exec.CommandContext(ctx, command, args...) 234 + if cmd.Dir, err = os.MkdirTemp("", "auditRecord"); err != nil { 235 + panic(fmt.Errorf("mkdtemp: %w", err)) 236 + } 237 + defer os.RemoveAll(cmd.Dir) 238 + 239 + if err = ExtractAuditRecord(ctx, id, record, cmd.Dir); err != nil { 240 + logc.Printf(ctx, "audit process %s err: %s\n", id, err) 241 + http.Error(w, err.Error(), http.StatusInternalServerError) 242 + return 243 + } 244 + 245 + output, err := cmd.CombinedOutput() 246 + if err != nil { 247 + logc.Printf(ctx, "audit process %s err: %s; %s\n", id, err, string(output)) 248 + w.WriteHeader(http.StatusServiceUnavailable) 249 + if len(output) == 0 { 250 + fmt.Fprintln(w, err.Error()) 251 + } 252 + } else { 253 + logc.Printf(ctx, "audit process %s ok: %s\n", id, string(output)) 254 + w.WriteHeader(http.StatusOK) 255 + } 256 + w.Write(output) 257 + })) 258 + return router, nil 259 + } 260 + 261 + type auditedBackend struct { 262 + Backend 263 + } 264 + 265 + var _ Backend = (*auditedBackend)(nil) 266 + 267 + func NewAuditedBackend(backend Backend) Backend { 268 + return &auditedBackend{backend} 269 + } 270 + 271 + // This function does not retry appending audit records; as such, if it returns an error, 272 + // this error must interrupt whatever operation it was auditing. A corollary is that it is 273 + // possible that appending an audit record succeeds but the audited operation fails. 274 + // This is considered fine since the purpose of auditing is to record end user intent, not 275 + // to be a 100% accurate reflection of performed actions. When in doubt, the audit records 276 + // should be examined together with the application logs. 277 + func (audited *auditedBackend) appendNewAuditRecord(ctx context.Context, record *AuditRecord) (err error) { 278 + if config.Audit.Collect { 279 + id := GenerateAuditID() 280 + record.Id = proto.Int64(int64(id)) 281 + record.Timestamp = timestamppb.Now() 282 + record.Principal = GetPrincipal(ctx) 283 + 284 + err = audited.Backend.AppendAuditLog(ctx, id, record) 285 + if err != nil { 286 + err = fmt.Errorf("audit: %w", err) 287 + } else { 288 + var subject string 289 + if record.Project == nil { 290 + subject = *record.Domain 291 + } else { 292 + subject = path.Join(*record.Domain, *record.Project) 293 + } 294 + logc.Printf(ctx, "audit %s ok: %s %s\n", subject, id, record.Event.String()) 295 + 296 + // Send a notification to the audit server, if configured, and try to make sure 297 + // it is delivered by retrying with exponential backoff on errors. 298 + notifyAudit(context.WithoutCancel(ctx), id) 299 + } 300 + } 301 + return 302 + } 303 + 304 + func notifyAudit(ctx context.Context, id AuditID) { 305 + if config.Audit.NotifyURL != nil { 306 + notifyURL := config.Audit.NotifyURL.URL 307 + notifyURL.RawQuery = id.String() 308 + 309 + // See also the explanation in `AuditEventProcessor` above. 310 + go func() { 311 + backoff := exponential.Backoff{ 312 + Jitter: true, 313 + Min: time.Second * 1, 314 + Max: time.Second * 60, 315 + } 316 + for { 317 + resp, err := http.Get(notifyURL.String()) 318 + var body []byte 319 + if err == nil { 320 + defer resp.Body.Close() 321 + body, _ = io.ReadAll(resp.Body) 322 + } 323 + if err == nil && resp.StatusCode == http.StatusOK { 324 + logc.Printf(ctx, "audit notify %s ok: %s\n", id, string(body)) 325 + auditNotifyOkCount.Inc() 326 + break 327 + } else { 328 + sleepFor := backoff.Duration() 329 + if err != nil { 330 + logc.Printf(ctx, "audit notify %s err: %s (retry in %s)", 331 + id, err, sleepFor) 332 + } else { 333 + logc.Printf(ctx, "audit notify %s fail: %s (retry in %s); %s", 334 + id, resp.Status, sleepFor, string(body)) 335 + } 336 + auditNotifyErrorCount.Inc() 337 + time.Sleep(sleepFor) 338 + } 339 + } 340 + }() 341 + } 342 + } 343 + 344 + func (audited *auditedBackend) CommitManifest( 345 + ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions, 346 + ) (err error) { 347 + domain, project, ok := strings.Cut(name, "/") 348 + if !ok { 349 + panic("malformed manifest name") 350 + } 351 + audited.appendNewAuditRecord(ctx, &AuditRecord{ 352 + Event: AuditEvent_CommitManifest.Enum(), 353 + Domain: proto.String(domain), 354 + Project: proto.String(project), 355 + Manifest: manifest, 356 + }) 357 + 358 + return audited.Backend.CommitManifest(ctx, name, manifest, opts) 359 + } 360 + 361 + func (audited *auditedBackend) DeleteManifest( 362 + ctx context.Context, name string, opts ModifyManifestOptions, 363 + ) (err error) { 364 + domain, project, ok := strings.Cut(name, "/") 365 + if !ok { 366 + panic("malformed manifest name") 367 + } 368 + audited.appendNewAuditRecord(ctx, &AuditRecord{ 369 + Event: AuditEvent_DeleteManifest.Enum(), 370 + Domain: proto.String(domain), 371 + Project: proto.String(project), 372 + }) 373 + 374 + return audited.Backend.DeleteManifest(ctx, name, opts) 375 + } 376 + 377 + func (audited *auditedBackend) FreezeDomain(ctx context.Context, domain string) (err error) { 378 + audited.appendNewAuditRecord(ctx, &AuditRecord{ 379 + Event: AuditEvent_FreezeDomain.Enum(), 380 + Domain: proto.String(domain), 381 + }) 382 + 383 + return audited.Backend.FreezeDomain(ctx, domain) 384 + } 385 + 386 + func (audited *auditedBackend) UnfreezeDomain(ctx context.Context, domain string) (err error) { 387 + audited.appendNewAuditRecord(ctx, &AuditRecord{ 388 + Event: AuditEvent_UnfreezeDomain.Enum(), 389 + Domain: proto.String(domain), 390 + }) 391 + 392 + return audited.Backend.UnfreezeDomain(ctx, domain) 393 + }
+57 -23
src/auth.go
··· 6 6 "encoding/json" 7 7 "errors" 8 8 "fmt" 9 - "log" 10 9 "net" 11 10 "net/http" 12 11 "net/url" 13 12 "slices" 14 13 "strings" 15 14 "time" 15 + 16 + "golang.org/x/net/idna" 16 17 ) 17 18 18 19 type AuthError struct { ··· 32 33 return false 33 34 } 34 35 35 - func authorizeInsecure() *Authorization { 36 + func authorizeInsecure(r *http.Request) *Authorization { 36 37 if config.Insecure { // for testing only 37 - log.Println("auth: INSECURE mode") 38 + logc.Println(r.Context(), "auth: INSECURE mode") 38 39 return &Authorization{ 39 40 repoURLs: nil, 40 41 branch: "pages", ··· 43 44 return nil 44 45 } 45 46 47 + var idnaProfile = idna.New(idna.MapForLookup(), idna.BidiRule()) 48 + 46 49 func GetHost(r *http.Request) (string, error) { 47 - // FIXME: handle IDNA 48 50 host, _, err := net.SplitHostPort(r.Host) 49 51 if err != nil { 50 - // dirty but the go stdlib doesn't have a "split port if present" function 51 52 host = r.Host 52 53 } 53 - if strings.HasPrefix(host, ".") { 54 + // this also rejects invalid characters and labels 55 + host, err = idnaProfile.ToASCII(host) 56 + if err != nil { 57 + if config.Feature("relaxed-idna") { 58 + // unfortunately, the go IDNA library has some significant issues around its 59 + // Unicode TR46 implementation: https://github.com/golang/go/issues/76804 60 + // we would like to allow *just* the _ here, but adding `idna.StrictDomainName(false)` 61 + // would also accept domains like `*.foo.bar` which should clearly be disallowed. 62 + // as a workaround, accept a domain name if it is valid with all `_` characters 63 + // replaced with an alphanumeric character (we use `a`); this allows e.g. `foo_bar.xxx` 64 + // and `foo__bar.xxx`, as well as `_foo.xxx` and `foo_.xxx`. labels starting with 65 + // an underscore are explicitly rejected below. 66 + _, err = idnaProfile.ToASCII(strings.ReplaceAll(host, "_", "a")) 67 + } 68 + if err != nil { 69 + return "", AuthError{http.StatusBadRequest, 70 + fmt.Sprintf("malformed host name %q", host)} 71 + } 72 + } 73 + if strings.HasPrefix(host, ".") || strings.HasPrefix(host, "_") { 54 74 return "", AuthError{http.StatusBadRequest, 55 - fmt.Sprintf("host name %q is reserved", host)} 75 + fmt.Sprintf("reserved host name %q", host)} 56 76 } 57 77 host = strings.TrimSuffix(host, ".") 58 78 return host, nil 59 79 } 60 80 81 + func IsValidProjectName(name string) bool { 82 + return !strings.HasPrefix(name, ".") && !strings.Contains(name, "%") 83 + } 84 + 61 85 func GetProjectName(r *http.Request) (string, error) { 62 86 // path must be either `/` or `/foo/` (`/foo` is accepted as an alias) 63 87 path := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/") 64 - if path == ".index" || strings.HasPrefix(path, ".index/") { 88 + if !IsValidProjectName(path) { 65 89 return "", AuthError{http.StatusBadRequest, 66 90 fmt.Sprintf("directory name %q is reserved", ".index")} 67 91 } else if strings.Contains(path, "/") { ··· 159 183 return nil, err 160 184 } 161 185 186 + projectName, err := GetProjectName(r) 187 + if err != nil { 188 + return nil, err 189 + } 190 + 162 191 allowlistHostname := fmt.Sprintf("_git-pages-repository.%s", host) 163 192 records, err := net.LookupTXT(allowlistHostname) 164 193 if err != nil { 165 194 return nil, AuthError{http.StatusUnauthorized, 166 195 fmt.Sprintf("failed to look up DNS repository allowlist: %s TXT", allowlistHostname)} 196 + } 197 + 198 + if projectName != ".index" { 199 + return nil, AuthError{http.StatusUnauthorized, 200 + "DNS repository allowlist only authorizes index site"} 167 201 } 168 202 169 203 var ( ··· 266 300 } 267 301 268 302 if len(dnsRecords) > 0 { 269 - log.Printf("auth: %s TXT/CNAME: %q\n", host, dnsRecords) 303 + logc.Printf(r.Context(), "auth: %s TXT/CNAME: %q\n", host, dnsRecords) 270 304 } 271 305 272 306 for _, dnsRecord := range dnsRecords { ··· 314 348 func AuthorizeMetadataRetrieval(r *http.Request) (*Authorization, error) { 315 349 causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} 316 350 317 - auth := authorizeInsecure() 351 + auth := authorizeInsecure(r) 318 352 if auth != nil { 319 353 return auth, nil 320 354 } ··· 325 359 } else if err != nil { // bad request 326 360 return nil, err 327 361 } else { 328 - log.Println("auth: DNS challenge") 362 + logc.Println(r.Context(), "auth: DNS challenge") 329 363 return auth, nil 330 364 } 331 365 ··· 336 370 } else if err != nil { // bad request 337 371 return nil, err 338 372 } else { 339 - log.Printf("auth: wildcard %s\n", pattern.GetHost()) 373 + logc.Printf(r.Context(), "auth: wildcard %s\n", pattern.GetHost()) 340 374 return auth, nil 341 375 } 342 376 } ··· 348 382 } else if err != nil { // bad request 349 383 return nil, err 350 384 } else { 351 - log.Printf("auth: codeberg %s\n", r.Host) 385 + logc.Printf(r.Context(), "auth: codeberg %s\n", r.Host) 352 386 return auth, nil 353 387 } 354 388 } ··· 366 400 return nil, err 367 401 } 368 402 369 - auth := authorizeInsecure() 403 + auth := authorizeInsecure(r) 370 404 if auth != nil { 371 405 return auth, nil 372 406 } ··· 378 412 } else if err != nil { // bad request 379 413 return nil, err 380 414 } else { 381 - log.Println("auth: DNS challenge: allow *") 415 + logc.Println(r.Context(), "auth: DNS challenge: allow *") 382 416 return auth, nil 383 417 } 384 418 ··· 390 424 } else if err != nil { // bad request 391 425 return nil, err 392 426 } else { 393 - log.Printf("auth: DNS allowlist: allow %v\n", auth.repoURLs) 427 + logc.Printf(r.Context(), "auth: DNS allowlist: allow %v\n", auth.repoURLs) 394 428 return auth, nil 395 429 } 396 430 } ··· 404 438 } else if err != nil { // bad request 405 439 return nil, err 406 440 } else { 407 - log.Printf("auth: wildcard %s: allow %v\n", pattern.GetHost(), auth.repoURLs) 441 + logc.Printf(r.Context(), "auth: wildcard %s: allow %v\n", pattern.GetHost(), auth.repoURLs) 408 442 return auth, nil 409 443 } 410 444 } ··· 416 450 } else if err != nil { // bad request 417 451 return nil, err 418 452 } else { 419 - log.Printf("auth: codeberg %s: allow %v branch %s\n", 453 + logc.Printf(r.Context(), "auth: codeberg %s: allow %v branch %s\n", 420 454 r.Host, auth.repoURLs, auth.branch) 421 455 return auth, nil 422 456 } ··· 427 461 } 428 462 429 463 func checkAllowedURLPrefix(repoURL string) error { 430 - if config.Limits.AllowedRepositoryURLPrefixes != nil { 464 + if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 { 431 465 allowedPrefix := false 432 466 repoURL = strings.ToLower(repoURL) 433 467 for _, allowedRepoURLPrefix := range config.Limits.AllowedRepositoryURLPrefixes { ··· 633 667 return nil, err 634 668 } 635 669 636 - auth := authorizeInsecure() 670 + auth := authorizeInsecure(r) 637 671 if auth != nil { 638 672 return auth, nil 639 673 } ··· 645 679 } else if err != nil { // bad request 646 680 return nil, err 647 681 } else { 648 - log.Printf("auth: forge token: allow\n") 682 + logc.Printf(r.Context(), "auth: forge token: allow\n") 649 683 return auth, nil 650 684 } 651 685 652 - if config.Limits.AllowedRepositoryURLPrefixes != nil { 686 + if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 { 653 687 causes = append(causes, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"}) 654 688 } else { 655 689 // DNS challenge gives absolute authority. ··· 659 693 } else if err != nil { // bad request 660 694 return nil, err 661 695 } else { 662 - log.Println("auth: DNS challenge") 696 + logc.Println(r.Context(), "auth: DNS challenge") 663 697 return auth, nil 664 698 } 665 699 }
+89 -15
src/backend.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "io" 8 - "slices" 8 + "iter" 9 9 "strings" 10 10 "time" 11 11 ) 12 12 13 13 var ErrObjectNotFound = errors.New("not found") 14 + var ErrPreconditionFailed = errors.New("precondition failed") 15 + var ErrWriteConflict = errors.New("write conflict") 16 + var ErrDomainFrozen = errors.New("domain administratively frozen") 14 17 15 18 func splitBlobName(name string) []string { 16 - algo, hash, found := strings.Cut(name, "-") 17 - if found { 18 - return slices.Concat([]string{algo}, splitBlobName(hash)) 19 + if algo, hash, found := strings.Cut(name, "-"); found { 20 + return []string{algo, hash[0:2], hash[2:4], hash[4:]} 19 21 } else { 20 - return []string{name[0:2], name[2:4], name[4:]} 22 + panic("malformed blob name") 21 23 } 22 24 } 23 25 26 + func joinBlobName(parts []string) string { 27 + return fmt.Sprintf("%s-%s", parts[0], strings.Join(parts[1:], "")) 28 + } 29 + 24 30 type BackendFeature string 25 31 26 32 const ( 27 33 FeatureCheckDomainMarker BackendFeature = "check-domain-marker" 28 34 ) 29 35 36 + type BlobMetadata struct { 37 + Name string 38 + Size int64 39 + LastModified time.Time 40 + } 41 + 30 42 type GetManifestOptions struct { 43 + // If true and the manifest is past the cache `MaxAge`, `GetManifest` blocks and returns 44 + // a fresh object instead of revalidating in background and returning a stale object. 31 45 BypassCache bool 32 46 } 33 47 48 + type ManifestMetadata struct { 49 + Name string 50 + Size int64 51 + LastModified time.Time 52 + ETag string 53 + } 54 + 55 + type ModifyManifestOptions struct { 56 + // If non-zero, the request will only succeed if the manifest hasn't been changed since 57 + // the given time. Whether this is racy or not is can be determined via `HasAtomicCAS()`. 58 + IfUnmodifiedSince time.Time 59 + // If non-empty, the request will only succeed if the manifest hasn't changed from 60 + // the state corresponding to the ETag. Whether this is racy or not is can be determined 61 + // via `HasAtomicCAS()`. 62 + IfMatch string 63 + } 64 + 65 + type SearchAuditLogOptions struct { 66 + // Inclusive lower bound on returned audit records, per their Snowflake ID (which may differ 67 + // slightly from the embedded timestamp). If zero, audit records are returned since beginning 68 + // of time. 69 + Since time.Time 70 + // Inclusive upper bound on returned audit records, per their Snowflake ID (which may differ 71 + // slightly from the embedded timestamp). If zero, audit records are returned until the end 72 + // of time. 73 + Until time.Time 74 + } 75 + 76 + type SearchAuditLogResult struct { 77 + ID AuditID 78 + Err error 79 + } 80 + 34 81 type Backend interface { 35 82 // Returns true if the feature has been enabled for this store, false otherwise. 36 83 HasFeature(ctx context.Context, feature BackendFeature) bool ··· 40 87 41 88 // Retrieve a blob. Returns `reader, size, mtime, err`. 42 89 GetBlob(ctx context.Context, name string) ( 43 - reader io.ReadSeeker, size uint64, mtime time.Time, err error, 90 + reader io.ReadSeeker, metadata BlobMetadata, err error, 44 91 ) 45 92 46 93 // Store a blob. If a blob called `name` already exists, this function returns `nil` without ··· 51 98 // Delete a blob. This is an unconditional operation that can break integrity of manifests. 52 99 DeleteBlob(ctx context.Context, name string) error 53 100 101 + // Iterate through all blobs. Whether blobs that are newly added during iteration will appear 102 + // in the results is unspecified. 103 + EnumerateBlobs(ctx context.Context) iter.Seq2[BlobMetadata, error] 104 + 54 105 // Retrieve a manifest. 55 106 GetManifest(ctx context.Context, name string, opts GetManifestOptions) ( 56 - manifest *Manifest, mtime time.Time, err error, 107 + manifest *Manifest, metadata ManifestMetadata, err error, 57 108 ) 58 109 59 110 // Stage a manifest. This operation stores a new version of a manifest, locking any blobs ··· 61 112 // effects. 62 113 StageManifest(ctx context.Context, manifest *Manifest) error 63 114 115 + // Whether a compare-and-swap operation on a manifest is truly race-free, or only best-effort 116 + // atomic with a small but non-zero window where two requests may race where the one committing 117 + // first will have its update lost. (Plain swap operations are always guaranteed to be atomic.) 118 + HasAtomicCAS(ctx context.Context) bool 119 + 64 120 // Commit a manifest. This is an atomic operation; `GetManifest` calls will return either 65 121 // the old version or the new version of the manifest, never anything else. 66 - CommitManifest(ctx context.Context, name string, manifest *Manifest) error 122 + CommitManifest(ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions) error 67 123 68 124 // Delete a manifest. 69 - DeleteManifest(ctx context.Context, name string) error 125 + DeleteManifest(ctx context.Context, name string, opts ModifyManifestOptions) error 70 126 71 - // List all manifests. 72 - ListManifests(ctx context.Context) (manifests []string, err error) 127 + // Iterate through all manifests. Whether manifests that are newly added during iteration 128 + // will appear in the results is unspecified. 129 + EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] 73 130 74 131 // Check whether a domain has any deployments. 75 132 CheckDomain(ctx context.Context, domain string) (found bool, err error) 76 133 77 - // Creates a domain. This allows us to start serving content for the domain. 134 + // Create a domain. This allows us to start serving content for the domain. 78 135 CreateDomain(ctx context.Context, domain string) error 136 + 137 + // Freeze a domain. This allows a site to be administratively locked, e.g. if it 138 + // is discovered serving abusive content. 139 + FreezeDomain(ctx context.Context, domain string) error 140 + 141 + // Thaw a domain. This removes the previously placed administrative lock (if any). 142 + UnfreezeDomain(ctx context.Context, domain string) error 143 + 144 + // Append a record to the audit log. 145 + AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) error 146 + 147 + // Retrieve a single record from the audit log. 148 + QueryAuditLog(ctx context.Context, id AuditID) (record *AuditRecord, err error) 149 + 150 + // Retrieve records from the audit log by time range. 151 + SearchAuditLog(ctx context.Context, opts SearchAuditLogOptions) iter.Seq2[AuditID, error] 79 152 } 80 153 81 - func CreateBackend(config *StorageConfig) (backend Backend, err error) { 154 + func CreateBackend(ctx context.Context, config *StorageConfig) (backend Backend, err error) { 82 155 switch config.Type { 83 156 case "fs": 84 - if backend, err = NewFSBackend(&config.FS); err != nil { 157 + if backend, err = NewFSBackend(ctx, &config.FS); err != nil { 85 158 err = fmt.Errorf("fs backend: %w", err) 86 159 } 87 160 case "s3": 88 - if backend, err = NewS3Backend(context.Background(), &config.S3); err != nil { 161 + if backend, err = NewS3Backend(ctx, &config.S3); err != nil { 89 162 err = fmt.Errorf("s3 backend: %w", err) 90 163 } 91 164 default: 92 165 err = fmt.Errorf("unknown backend: %s", config.Type) 93 166 } 167 + backend = NewAuditedBackend(backend) 94 168 return 95 169 }
+276 -26
src/backend_fs.go
··· 6 6 "errors" 7 7 "fmt" 8 8 "io" 9 - "io/fs" 9 + iofs "io/fs" 10 + "iter" 10 11 "os" 11 12 "path/filepath" 12 13 "strings" 13 - "time" 14 14 ) 15 15 16 16 type FSBackend struct { 17 - blobRoot *os.Root 18 - siteRoot *os.Root 17 + blobRoot *os.Root 18 + siteRoot *os.Root 19 + auditRoot *os.Root 20 + hasAtomicCAS bool 19 21 } 20 22 21 23 var _ Backend = (*FSBackend)(nil) ··· 54 56 return tempPath, nil 55 57 } 56 58 57 - func NewFSBackend(config *FSConfig) (*FSBackend, error) { 59 + func checkAtomicCAS(root *os.Root) bool { 60 + fileName := ".hasAtomicCAS" 61 + file, err := root.Create(fileName) 62 + if err != nil { 63 + panic(err) 64 + } 65 + root.Remove(fileName) 66 + defer file.Close() 67 + 68 + flockErr := FileLock(file) 69 + funlockErr := FileUnlock(file) 70 + return (flockErr == nil && funlockErr == nil) 71 + } 72 + 73 + func NewFSBackend(ctx context.Context, config *FSConfig) (*FSBackend, error) { 58 74 blobRoot, err := maybeCreateOpenRoot(config.Root, "blob") 59 75 if err != nil { 60 76 return nil, fmt.Errorf("blob: %w", err) ··· 63 79 if err != nil { 64 80 return nil, fmt.Errorf("site: %w", err) 65 81 } 66 - return &FSBackend{blobRoot, siteRoot}, nil 82 + auditRoot, err := maybeCreateOpenRoot(config.Root, "audit") 83 + if err != nil { 84 + return nil, fmt.Errorf("audit: %w", err) 85 + } 86 + hasAtomicCAS := checkAtomicCAS(siteRoot) 87 + if hasAtomicCAS { 88 + logc.Println(ctx, "fs: has atomic CAS") 89 + } else { 90 + logc.Println(ctx, "fs: has best-effort CAS") 91 + } 92 + return &FSBackend{blobRoot, siteRoot, auditRoot, hasAtomicCAS}, nil 67 93 } 68 94 69 95 func (fs *FSBackend) Backend() Backend { ··· 91 117 func (fs *FSBackend) GetBlob( 92 118 ctx context.Context, name string, 93 119 ) ( 94 - reader io.ReadSeeker, size uint64, mtime time.Time, err error, 120 + reader io.ReadSeeker, metadata BlobMetadata, err error, 95 121 ) { 96 122 blobPath := filepath.Join(splitBlobName(name)...) 97 123 stat, err := fs.blobRoot.Stat(blobPath) ··· 107 133 err = fmt.Errorf("open: %w", err) 108 134 return 109 135 } 110 - return file, uint64(stat.Size()), stat.ModTime(), nil 136 + return file, BlobMetadata{name, int64(stat.Size()), stat.ModTime()}, nil 111 137 } 112 138 113 139 func (fs *FSBackend) PutBlob(ctx context.Context, name string, data []byte) error { 114 140 blobPath := filepath.Join(splitBlobName(name)...) 115 141 blobDir := filepath.Dir(blobPath) 142 + 143 + if _, err := fs.blobRoot.Stat(blobPath); err == nil { 144 + // Blob already exists. While on Linux it would be benign to write and replace a blob 145 + // that already exists, on Windows this is liable to cause access errors. 146 + return nil 147 + } 116 148 117 149 tempPath, err := createTempInRoot(fs.blobRoot, name, data) 118 150 if err != nil { ··· 149 181 return fs.blobRoot.Remove(blobPath) 150 182 } 151 183 152 - func (b *FSBackend) ListManifests(ctx context.Context) (manifests []string, err error) { 153 - err = fs.WalkDir(b.siteRoot.FS(), ".", func(path string, d fs.DirEntry, err error) error { 154 - if strings.Count(path, "/") > 1 { 155 - return fs.SkipDir 156 - } 157 - _, project, _ := strings.Cut(path, "/") 158 - if project == "" || strings.HasPrefix(project, ".") && project != ".index" { 159 - return nil 160 - } 161 - manifests = append(manifests, path) 162 - return nil 163 - }) 164 - return 184 + func (fs *FSBackend) EnumerateBlobs(ctx context.Context) iter.Seq2[BlobMetadata, error] { 185 + return func(yield func(BlobMetadata, error) bool) { 186 + iofs.WalkDir(fs.blobRoot.FS(), ".", 187 + func(path string, entry iofs.DirEntry, err error) error { 188 + var metadata BlobMetadata 189 + if err != nil { 190 + // report error 191 + } else if entry.IsDir() { 192 + // skip directory 193 + return nil 194 + } else if info, err := entry.Info(); err != nil { 195 + // report error 196 + } else { 197 + // report blob 198 + metadata.Name = joinBlobName(strings.Split(path, "/")) 199 + metadata.Size = info.Size() 200 + metadata.LastModified = info.ModTime() 201 + } 202 + if !yield(metadata, err) { 203 + return iofs.SkipAll 204 + } 205 + return nil 206 + }) 207 + } 165 208 } 166 209 167 210 func (fs *FSBackend) GetManifest( 168 211 ctx context.Context, name string, opts GetManifestOptions, 169 212 ) ( 170 - manifest *Manifest, mtime time.Time, err error, 213 + manifest *Manifest, metadata ManifestMetadata, err error, 171 214 ) { 172 215 stat, err := fs.siteRoot.Stat(name) 173 216 if errors.Is(err, os.ErrNotExist) { ··· 186 229 if err != nil { 187 230 return 188 231 } 189 - return manifest, stat.ModTime(), nil 232 + return manifest, ManifestMetadata{ 233 + LastModified: stat.ModTime(), 234 + ETag: fmt.Sprintf("%x", sha256.Sum256(data)), 235 + }, nil 190 236 } 191 237 192 238 func stagedManifestName(manifestData []byte) string { ··· 208 254 return nil 209 255 } 210 256 211 - func (fs *FSBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { 257 + func domainFrozenMarkerName(domain string) string { 258 + return filepath.Join(domain, ".frozen") 259 + } 260 + 261 + func (fs *FSBackend) checkDomainFrozen(ctx context.Context, domain string) error { 262 + if _, err := fs.siteRoot.Stat(domainFrozenMarkerName(domain)); err == nil { 263 + return ErrDomainFrozen 264 + } else if !errors.Is(err, os.ErrNotExist) { 265 + return fmt.Errorf("stat: %w", err) 266 + } else { 267 + return nil 268 + } 269 + } 270 + 271 + func (fs *FSBackend) HasAtomicCAS(ctx context.Context) bool { 272 + // On a suitable filesystem, POSIX advisory locks can be used to implement atomic CAS. 273 + // An implementation consists of two parts: 274 + // - Intra-process mutex set (one per manifest), to prevent races between goroutines; 275 + // - Inter-process POSIX advisory locks (one per manifest), to prevent races between 276 + // different git-pages instances. 277 + return fs.hasAtomicCAS 278 + } 279 + 280 + type manifestLockGuard struct { 281 + file *os.File 282 + } 283 + 284 + func lockManifest(fs *os.Root, name string) (*manifestLockGuard, error) { 285 + file, err := fs.Open(name) 286 + if errors.Is(err, os.ErrNotExist) { 287 + return &manifestLockGuard{nil}, nil 288 + } else if err != nil { 289 + return nil, fmt.Errorf("open: %w", err) 290 + } 291 + if err := FileLock(file); err != nil { 292 + file.Close() 293 + return nil, fmt.Errorf("flock(LOCK_EX): %w", err) 294 + } 295 + return &manifestLockGuard{file}, nil 296 + } 297 + 298 + func (guard *manifestLockGuard) Unlock() { 299 + if guard.file != nil { 300 + FileUnlock(guard.file) 301 + guard.file.Close() 302 + } 303 + } 304 + 305 + func (fs *FSBackend) checkManifestPrecondition( 306 + ctx context.Context, name string, opts ModifyManifestOptions, 307 + ) error { 308 + if !opts.IfUnmodifiedSince.IsZero() { 309 + stat, err := fs.siteRoot.Stat(name) 310 + if err != nil { 311 + return fmt.Errorf("stat: %w", err) 312 + } 313 + 314 + if stat.ModTime().Compare(opts.IfUnmodifiedSince) > 0 { 315 + return fmt.Errorf("%w: If-Unmodified-Since", ErrPreconditionFailed) 316 + } 317 + } 318 + 319 + if opts.IfMatch != "" { 320 + data, err := fs.siteRoot.ReadFile(name) 321 + if err != nil { 322 + return fmt.Errorf("read: %w", err) 323 + } 324 + 325 + if fmt.Sprintf("%x", sha256.Sum256(data)) != opts.IfMatch { 326 + return fmt.Errorf("%w: If-Match", ErrPreconditionFailed) 327 + } 328 + } 329 + 330 + return nil 331 + } 332 + 333 + func (fs *FSBackend) CommitManifest( 334 + ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions, 335 + ) error { 336 + if fs.hasAtomicCAS { 337 + if guard, err := lockManifest(fs.siteRoot, name); err != nil { 338 + return err 339 + } else { 340 + defer guard.Unlock() 341 + } 342 + } 343 + 344 + domain := filepath.Dir(name) 345 + if err := fs.checkDomainFrozen(ctx, domain); err != nil { 346 + return err 347 + } 348 + 349 + if err := fs.checkManifestPrecondition(ctx, name, opts); err != nil { 350 + return err 351 + } 352 + 212 353 manifestData := EncodeManifest(manifest) 213 354 manifestHashName := stagedManifestName(manifestData) 214 355 ··· 216 357 return fmt.Errorf("manifest not staged") 217 358 } 218 359 219 - if err := fs.siteRoot.MkdirAll(filepath.Dir(name), 0o755); err != nil { 360 + if err := fs.siteRoot.MkdirAll(domain, 0o755); err != nil { 220 361 return fmt.Errorf("mkdir: %w", err) 221 362 } 222 363 ··· 227 368 return nil 228 369 } 229 370 230 - func (fs *FSBackend) DeleteManifest(ctx context.Context, name string) error { 371 + func (fs *FSBackend) DeleteManifest( 372 + ctx context.Context, name string, opts ModifyManifestOptions, 373 + ) error { 374 + if fs.hasAtomicCAS { 375 + if guard, err := lockManifest(fs.siteRoot, name); err != nil { 376 + return err 377 + } else { 378 + defer guard.Unlock() 379 + } 380 + } 381 + 382 + domain := filepath.Dir(name) 383 + if err := fs.checkDomainFrozen(ctx, domain); err != nil { 384 + return err 385 + } 386 + 387 + if err := fs.checkManifestPrecondition(ctx, name, opts); err != nil { 388 + return err 389 + } 390 + 231 391 err := fs.siteRoot.Remove(name) 232 392 if errors.Is(err, os.ErrNotExist) { 233 393 return nil ··· 236 396 } 237 397 } 238 398 399 + func (fs *FSBackend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] { 400 + return func(yield func(ManifestMetadata, error) bool) { 401 + iofs.WalkDir(fs.siteRoot.FS(), ".", 402 + func(path string, entry iofs.DirEntry, err error) error { 403 + _, project, _ := strings.Cut(path, "/") 404 + var metadata ManifestMetadata 405 + if err != nil { 406 + // report error 407 + } else if entry.IsDir() { 408 + // skip directory 409 + return nil 410 + } else if project == "" || strings.HasPrefix(project, ".") && project != ".index" { 411 + // skip internal 412 + return nil 413 + } else if info, err := entry.Info(); err != nil { 414 + // report error 415 + } else { 416 + // report blob 417 + metadata.Name = path 418 + metadata.Size = info.Size() 419 + metadata.LastModified = info.ModTime() 420 + // not setting metadata.ETag since it is too costly 421 + } 422 + if !yield(metadata, err) { 423 + return iofs.SkipAll 424 + } 425 + return nil 426 + }) 427 + } 428 + } 429 + 239 430 func (fs *FSBackend) CheckDomain(ctx context.Context, domain string) (bool, error) { 240 431 _, err := fs.siteRoot.Stat(domain) 241 432 if errors.Is(err, os.ErrNotExist) { ··· 250 441 func (fs *FSBackend) CreateDomain(ctx context.Context, domain string) error { 251 442 return nil // no-op 252 443 } 444 + 445 + func (fs *FSBackend) FreezeDomain(ctx context.Context, domain string) error { 446 + return fs.siteRoot.WriteFile(domainFrozenMarkerName(domain), []byte{}, 0o644) 447 + } 448 + 449 + func (fs *FSBackend) UnfreezeDomain(ctx context.Context, domain string) error { 450 + err := fs.siteRoot.Remove(domainFrozenMarkerName(domain)) 451 + if errors.Is(err, os.ErrNotExist) { 452 + return nil 453 + } else { 454 + return err 455 + } 456 + } 457 + 458 + func (fs *FSBackend) AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) error { 459 + if _, err := fs.auditRoot.Stat(id.String()); err == nil { 460 + panic(fmt.Errorf("audit ID collision: %s", id)) 461 + } 462 + 463 + return fs.auditRoot.WriteFile(id.String(), EncodeAuditRecord(record), 0o644) 464 + } 465 + 466 + func (fs *FSBackend) QueryAuditLog(ctx context.Context, id AuditID) (*AuditRecord, error) { 467 + if data, err := fs.auditRoot.ReadFile(id.String()); err != nil { 468 + return nil, fmt.Errorf("read: %w", err) 469 + } else if record, err := DecodeAuditRecord(data); err != nil { 470 + return nil, fmt.Errorf("decode: %w", err) 471 + } else { 472 + return record, nil 473 + } 474 + } 475 + 476 + func (fs *FSBackend) SearchAuditLog( 477 + ctx context.Context, opts SearchAuditLogOptions, 478 + ) iter.Seq2[AuditID, error] { 479 + return func(yield func(AuditID, error) bool) { 480 + iofs.WalkDir(fs.auditRoot.FS(), ".", 481 + func(path string, entry iofs.DirEntry, err error) error { 482 + if path == "." { 483 + return nil // skip 484 + } 485 + var id AuditID 486 + if err != nil { 487 + // report error 488 + } else if id, err = ParseAuditID(path); err != nil { 489 + // report error 490 + } else if !opts.Since.IsZero() && id.CompareTime(opts.Since) < 0 { 491 + return nil // skip 492 + } else if !opts.Until.IsZero() && id.CompareTime(opts.Until) > 0 { 493 + return nil // skip 494 + } 495 + if !yield(id, err) { 496 + return iofs.SkipAll // break 497 + } else { 498 + return nil // continue 499 + } 500 + }) 501 + } 502 + }
+330 -81
src/backend_s3.go
··· 6 6 "crypto/sha256" 7 7 "fmt" 8 8 "io" 9 - "log" 9 + "iter" 10 10 "net/http" 11 11 "path" 12 12 "strings" ··· 36 36 manifestCacheEvictionsCount prometheus.Counter 37 37 38 38 s3GetObjectDurationSeconds *prometheus.HistogramVec 39 - s3GetObjectErrorsCount *prometheus.CounterVec 39 + s3GetObjectResponseCount *prometheus.CounterVec 40 40 ) 41 41 42 42 func initS3BackendMetrics() { ··· 96 96 NativeHistogramMaxBucketNumber: 100, 97 97 NativeHistogramMinResetDuration: 10 * time.Minute, 98 98 }, []string{"kind"}) 99 - s3GetObjectErrorsCount = promauto.NewCounterVec(prometheus.CounterOpts{ 100 - Name: "git_pages_s3_get_object_errors_count", 101 - Help: "Count of s3:GetObject errors", 102 - }, []string{"object_kind"}) 99 + s3GetObjectResponseCount = promauto.NewCounterVec(prometheus.CounterOpts{ 100 + Name: "git_pages_s3_get_object_responses_count", 101 + Help: "Count of s3:GetObject responses", 102 + }, []string{"kind", "code"}) 103 103 } 104 104 105 105 // Blobs can be safely cached indefinitely. They only need to be evicted to preserve memory. ··· 117 117 type CachedManifest struct { 118 118 manifest *Manifest 119 119 weight uint32 120 - mtime time.Time 121 - etag string 120 + metadata ManifestMetadata 122 121 err error 123 122 } 124 123 ··· 144 143 options.Weigher = weigher 145 144 } 146 145 if config.MaxStale != 0 { 147 - options.RefreshCalculator = otter.RefreshWriting[K, V](time.Duration(config.MaxAge)) 146 + options.RefreshCalculator = otter.RefreshWriting[K, V]( 147 + time.Duration(config.MaxAge)) 148 148 } 149 149 if config.MaxAge != 0 || config.MaxStale != 0 { 150 - options.ExpiryCalculator = otter.ExpiryWriting[K, V](time.Duration(config.MaxAge + config.MaxStale)) 150 + options.ExpiryCalculator = otter.ExpiryWriting[K, V]( 151 + time.Duration(config.MaxAge + config.MaxStale)) 151 152 } 152 153 return options 153 154 } ··· 170 171 if err != nil { 171 172 return nil, err 172 173 } else if !exists { 173 - log.Printf("s3: create bucket %s\n", bucket) 174 + logc.Printf(ctx, "s3: create bucket %s\n", bucket) 174 175 175 176 err = client.MakeBucket(ctx, bucket, 176 177 minio.MakeBucketOptions{Region: config.Region}) ··· 236 237 minio.StatObjectOptions{}) 237 238 if err != nil { 238 239 if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { 239 - log.Printf("s3 feature %q: disabled", feature) 240 + logc.Printf(ctx, "s3 feature %q: disabled", feature) 240 241 return false, nil 241 242 } else { 242 243 return false, err 243 244 } 244 245 } 245 - log.Printf("s3 feature %q: enabled", feature) 246 + logc.Printf(ctx, "s3 feature %q: enabled", feature) 246 247 return true, nil 247 248 } 248 249 ··· 250 251 if err != nil { 251 252 err = fmt.Errorf("getting s3 backend feature %q: %w", feature, err) 252 253 ObserveError(err) 253 - log.Print(err) 254 + logc.Println(ctx, err) 254 255 return false 255 256 } 256 257 return isOn ··· 265 266 func (s3 *S3Backend) GetBlob( 266 267 ctx context.Context, name string, 267 268 ) ( 268 - reader io.ReadSeeker, size uint64, mtime time.Time, err error, 269 + reader io.ReadSeeker, metadata BlobMetadata, err error, 269 270 ) { 270 271 loader := func(ctx context.Context, name string) (*CachedBlob, error) { 271 - log.Printf("s3: get blob %s\n", name) 272 + logc.Printf(ctx, "s3: get blob %s\n", name) 272 273 273 274 startTime := time.Now() 274 275 ··· 297 298 return &CachedBlob{data, stat.LastModified}, nil 298 299 } 299 300 301 + observer := func(ctx context.Context, name string) (*CachedBlob, error) { 302 + cached, err := loader(ctx, name) 303 + var code = "OK" 304 + if resp, ok := err.(minio.ErrorResponse); ok { 305 + code = resp.Code 306 + } 307 + s3GetObjectResponseCount.With(prometheus.Labels{"kind": "blob", "code": code}).Inc() 308 + return cached, err 309 + } 310 + 300 311 var cached *CachedBlob 301 - cached, err = s3.blobCache.Get(ctx, name, otter.LoaderFunc[string, *CachedBlob](loader)) 312 + cached, err = s3.blobCache.Get(ctx, name, otter.LoaderFunc[string, *CachedBlob](observer)) 302 313 if err != nil { 303 314 if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { 304 - s3GetObjectErrorsCount.With(prometheus.Labels{"object_kind": "blob"}).Inc() 305 315 err = fmt.Errorf("%w: %s", ErrObjectNotFound, errResp.Key) 306 316 } 307 317 } else { 308 318 reader = bytes.NewReader(cached.blob) 309 - size = uint64(len(cached.blob)) 310 - mtime = cached.mtime 319 + metadata.Name = name 320 + metadata.Size = int64(len(cached.blob)) 321 + metadata.LastModified = cached.mtime 311 322 } 312 323 return 313 324 } 314 325 315 326 func (s3 *S3Backend) PutBlob(ctx context.Context, name string, data []byte) error { 316 - log.Printf("s3: put blob %s (%s)\n", name, datasize.ByteSize(len(data)).HumanReadable()) 327 + logc.Printf(ctx, "s3: put blob %s (%s)\n", name, datasize.ByteSize(len(data)).HumanReadable()) 317 328 318 329 _, err := s3.client.StatObject(ctx, s3.bucket, blobObjectName(name), 319 330 minio.GetObjectOptions{}) ··· 325 336 return err 326 337 } else { 327 338 ObserveData(ctx, "blob.status", "created") 328 - log.Printf("s3: put blob %s (created)\n", name) 339 + logc.Printf(ctx, "s3: put blob %s (created)\n", name) 329 340 return nil 330 341 } 331 342 } else { ··· 333 344 } 334 345 } else { 335 346 ObserveData(ctx, "blob.status", "exists") 336 - log.Printf("s3: put blob %s (exists)\n", name) 347 + logc.Printf(ctx, "s3: put blob %s (exists)\n", name) 337 348 blobsDedupedCount.Inc() 338 349 blobsDedupedBytes.Add(float64(len(data))) 339 350 return nil ··· 341 352 } 342 353 343 354 func (s3 *S3Backend) DeleteBlob(ctx context.Context, name string) error { 344 - log.Printf("s3: delete blob %s\n", name) 355 + logc.Printf(ctx, "s3: delete blob %s\n", name) 345 356 346 357 return s3.client.RemoveObject(ctx, s3.bucket, blobObjectName(name), 347 358 minio.RemoveObjectOptions{}) 348 359 } 349 360 361 + func (s3 *S3Backend) EnumerateBlobs(ctx context.Context) iter.Seq2[BlobMetadata, error] { 362 + return func(yield func(BlobMetadata, error) bool) { 363 + logc.Print(ctx, "s3: enumerate blobs") 364 + 365 + ctx, cancel := context.WithCancel(ctx) 366 + defer cancel() 367 + 368 + prefix := "blob/" 369 + for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{ 370 + Prefix: prefix, 371 + Recursive: true, 372 + }) { 373 + var metadata BlobMetadata 374 + var err error 375 + if err = object.Err; err == nil { 376 + key := strings.TrimPrefix(object.Key, prefix) 377 + if strings.HasSuffix(key, "/") { 378 + continue // directory; skip 379 + } else { 380 + metadata.Name = joinBlobName(strings.Split(key, "/")) 381 + metadata.Size = object.Size 382 + metadata.LastModified = object.LastModified 383 + } 384 + } 385 + if !yield(metadata, err) { 386 + break 387 + } 388 + } 389 + } 390 + } 391 + 350 392 func manifestObjectName(name string) string { 351 393 return fmt.Sprintf("site/%s", name) 352 394 } ··· 355 397 return fmt.Sprintf("dirty/%x", sha256.Sum256(manifestData)) 356 398 } 357 399 358 - func (s3 *S3Backend) ListManifests(ctx context.Context) (manifests []string, err error) { 359 - log.Print("s3: list manifests") 360 - 361 - ctx, cancel := context.WithCancel(ctx) 362 - defer cancel() 363 - 364 - prefix := manifestObjectName("") 365 - for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{ 366 - Prefix: prefix, 367 - Recursive: true, 368 - }) { 369 - if object.Err != nil { 370 - return nil, object.Err 371 - } 372 - key := strings.TrimRight(strings.TrimPrefix(object.Key, prefix), "/") 373 - if strings.Count(key, "/") > 1 { 374 - continue 375 - } 376 - _, project, _ := strings.Cut(key, "/") 377 - if project == "" || strings.HasPrefix(project, ".") && project != ".index" { 378 - continue 379 - } 380 - manifests = append(manifests, key) 381 - } 382 - 383 - return 384 - } 385 - 386 400 type s3ManifestLoader struct { 387 401 s3 *S3Backend 388 402 } 389 403 390 - func (l s3ManifestLoader) Load(ctx context.Context, key string) (*CachedManifest, error) { 404 + func (l s3ManifestLoader) Load( 405 + ctx context.Context, key string, 406 + ) ( 407 + *CachedManifest, error, 408 + ) { 391 409 return l.load(ctx, key, nil) 392 410 } 393 411 394 - func (l s3ManifestLoader) Reload(ctx context.Context, key string, oldValue *CachedManifest) (*CachedManifest, error) { 412 + func (l s3ManifestLoader) Reload( 413 + ctx context.Context, key string, oldValue *CachedManifest, 414 + ) ( 415 + *CachedManifest, error, 416 + ) { 395 417 return l.load(ctx, key, oldValue) 396 418 } 397 419 398 - func (l s3ManifestLoader) load(ctx context.Context, name string, oldManifest *CachedManifest) (*CachedManifest, error) { 420 + func (l s3ManifestLoader) load( 421 + ctx context.Context, name string, oldManifest *CachedManifest, 422 + ) ( 423 + *CachedManifest, error, 424 + ) { 425 + logc.Printf(ctx, "s3: get manifest %s\n", name) 426 + 399 427 loader := func() (*CachedManifest, error) { 400 - log.Printf("s3: get manifest %s\n", name) 401 - 402 - startTime := time.Now() 403 - 404 428 opts := minio.GetObjectOptions{} 405 - if oldManifest != nil && oldManifest.etag != "" { 406 - opts.SetMatchETagExcept(oldManifest.etag) 429 + if oldManifest != nil && oldManifest.metadata.ETag != "" { 430 + opts.SetMatchETagExcept(oldManifest.metadata.ETag) 407 431 } 408 432 object, err := l.s3.client.GetObject(ctx, l.s3.bucket, manifestObjectName(name), opts) 409 433 // Note that many errors (e.g. NoSuchKey) will be reported only after this point. ··· 427 451 return nil, err 428 452 } 429 453 430 - s3GetObjectDurationSeconds. 431 - With(prometheus.Labels{"kind": "manifest"}). 432 - Observe(time.Since(startTime).Seconds()) 454 + metadata := ManifestMetadata{ 455 + LastModified: stat.LastModified, 456 + ETag: stat.ETag, 457 + } 458 + return &CachedManifest{manifest, uint32(len(data)), metadata, nil}, nil 459 + } 433 460 434 - return &CachedManifest{manifest, uint32(len(data)), stat.LastModified, stat.ETag, nil}, nil 461 + observer := func() (*CachedManifest, error) { 462 + cached, err := loader() 463 + var code = "OK" 464 + if resp, ok := err.(minio.ErrorResponse); ok { 465 + code = resp.Code 466 + } 467 + s3GetObjectResponseCount.With(prometheus.Labels{"kind": "manifest", "code": code}).Inc() 468 + return cached, err 435 469 } 436 470 437 - var cached *CachedManifest 438 - cached, err := loader() 471 + startTime := time.Now() 472 + cached, err := observer() 473 + s3GetObjectDurationSeconds. 474 + With(prometheus.Labels{"kind": "manifest"}). 475 + Observe(time.Since(startTime).Seconds()) 476 + 439 477 if err != nil { 440 - if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { 441 - s3GetObjectErrorsCount.With(prometheus.Labels{"object_kind": "manifest"}).Inc() 478 + errResp := minio.ToErrorResponse(err) 479 + if errResp.Code == "NoSuchKey" { 442 480 err = fmt.Errorf("%w: %s", ErrObjectNotFound, errResp.Key) 443 - return &CachedManifest{nil, 1, time.Time{}, "", err}, nil 481 + return &CachedManifest{nil, 1, ManifestMetadata{}, err}, nil 444 482 } else if errResp.StatusCode == http.StatusNotModified && oldManifest != nil { 445 483 return oldManifest, nil 446 484 } else { ··· 454 492 func (s3 *S3Backend) GetManifest( 455 493 ctx context.Context, name string, opts GetManifestOptions, 456 494 ) ( 457 - manifest *Manifest, mtime time.Time, err error, 495 + manifest *Manifest, metadata ManifestMetadata, err error, 458 496 ) { 459 497 if opts.BypassCache { 460 498 entry, found := s3.siteCache.Cache.GetEntry(name) ··· 469 507 return 470 508 } else { 471 509 // This could be `manifest, mtime, nil` or `nil, time.Time{}, ErrObjectNotFound`. 472 - manifest, mtime, err = cached.manifest, cached.mtime, cached.err 510 + manifest, metadata, err = cached.manifest, cached.metadata, cached.err 473 511 return 474 512 } 475 513 } 476 514 477 515 func (s3 *S3Backend) StageManifest(ctx context.Context, manifest *Manifest) error { 478 516 data := EncodeManifest(manifest) 479 - log.Printf("s3: stage manifest %x\n", sha256.Sum256(data)) 517 + logc.Printf(ctx, "s3: stage manifest %x\n", sha256.Sum256(data)) 480 518 481 519 _, err := s3.client.PutObject(ctx, s3.bucket, stagedManifestObjectName(data), 482 520 bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{}) 483 521 return err 484 522 } 485 523 486 - func (s3 *S3Backend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { 524 + func domainFrozenObjectName(domain string) string { 525 + return manifestObjectName(fmt.Sprintf("%s/.frozen", domain)) 526 + } 527 + 528 + func (s3 *S3Backend) checkDomainFrozen(ctx context.Context, domain string) error { 529 + _, err := s3.client.StatObject(ctx, s3.bucket, domainFrozenObjectName(domain), 530 + minio.GetObjectOptions{}) 531 + if err == nil { 532 + return ErrDomainFrozen 533 + } else if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { 534 + return nil 535 + } else { 536 + return err 537 + } 538 + } 539 + 540 + func (s3 *S3Backend) HasAtomicCAS(ctx context.Context) bool { 541 + // Support for `If-Unmodified-Since:` or `If-Match:` for PutObject requests is very spotty: 542 + // - AWS supports only `If-Match:`: 543 + // https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html 544 + // - Minio supports `If-Match:`: 545 + // https://blog.min.io/leading-the-way-minios-conditional-write-feature-for-modern-data-workloads/ 546 + // - Tigris supports `If-Unmodified-Since:` and `If-Match:`, but only with `X-Tigris-Consistent: true`; 547 + // https://www.tigrisdata.com/docs/objects/conditionals/ 548 + // Note that the `X-Tigris-Consistent: true` header must be present on *every* transaction 549 + // touching the object, not just on the CAS transactions. 550 + // - Wasabi does not support either one and docs seem to suggest that the headers are ignored; 551 + // - Garage does not support either one and source code suggests the headers are ignored. 552 + // It seems that the only safe option is to not claim support for atomic CAS, and only do 553 + // best-effort CAS implementation using HeadObject and PutObject/DeleteObject. 554 + return false 555 + } 556 + 557 + func (s3 *S3Backend) checkManifestPrecondition( 558 + ctx context.Context, name string, opts ModifyManifestOptions, 559 + ) error { 560 + if opts.IfUnmodifiedSince.IsZero() && opts.IfMatch == "" { 561 + return nil 562 + } 563 + 564 + stat, err := s3.client.StatObject(ctx, s3.bucket, manifestObjectName(name), 565 + minio.GetObjectOptions{}) 566 + if err != nil { 567 + return err 568 + } 569 + 570 + if !opts.IfUnmodifiedSince.IsZero() && stat.LastModified.Compare(opts.IfUnmodifiedSince) > 0 { 571 + return fmt.Errorf("%w: If-Unmodified-Since", ErrPreconditionFailed) 572 + } 573 + if opts.IfMatch != "" && stat.ETag != opts.IfMatch { 574 + return fmt.Errorf("%w: If-Match", ErrPreconditionFailed) 575 + } 576 + 577 + return nil 578 + } 579 + 580 + func (s3 *S3Backend) CommitManifest( 581 + ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions, 582 + ) error { 487 583 data := EncodeManifest(manifest) 488 - log.Printf("s3: commit manifest %x -> %s", sha256.Sum256(data), name) 584 + logc.Printf(ctx, "s3: commit manifest %x -> %s", sha256.Sum256(data), name) 585 + 586 + _, domain, _ := strings.Cut(name, "/") 587 + if err := s3.checkDomainFrozen(ctx, domain); err != nil { 588 + return err 589 + } 590 + 591 + if err := s3.checkManifestPrecondition(ctx, name, opts); err != nil { 592 + return err 593 + } 489 594 490 595 // Remove staged object unconditionally (whether commit succeeded or failed), since 491 596 // the upper layer has to retry the complete operation anyway. 597 + putOptions := minio.PutObjectOptions{} 598 + putOptions.Header().Add("X-Tigris-Consistent", "true") 599 + if opts.IfMatch != "" { 600 + // Not guaranteed to do anything (see `HasAtomicCAS`), but let's try anyway; 601 + // this is a "belt and suspenders" approach, together with `checkManifestPrecondition`. 602 + // It does reliably work on MinIO at least. 603 + putOptions.SetMatchETag(opts.IfMatch) 604 + } 492 605 _, putErr := s3.client.PutObject(ctx, s3.bucket, manifestObjectName(name), 493 - bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{}) 606 + bytes.NewReader(data), int64(len(data)), putOptions) 494 607 removeErr := s3.client.RemoveObject(ctx, s3.bucket, stagedManifestObjectName(data), 495 608 minio.RemoveObjectOptions{}) 496 609 s3.siteCache.Cache.Invalidate(name) 497 610 if putErr != nil { 498 - return putErr 611 + if errResp := minio.ToErrorResponse(putErr); errResp.Code == "PreconditionFailed" { 612 + return ErrPreconditionFailed 613 + } else { 614 + return putErr 615 + } 499 616 } else if removeErr != nil { 500 617 return removeErr 501 618 } else { ··· 503 620 } 504 621 } 505 622 506 - func (s3 *S3Backend) DeleteManifest(ctx context.Context, name string) error { 507 - log.Printf("s3: delete manifest %s\n", name) 623 + func (s3 *S3Backend) DeleteManifest( 624 + ctx context.Context, name string, opts ModifyManifestOptions, 625 + ) error { 626 + logc.Printf(ctx, "s3: delete manifest %s\n", name) 627 + 628 + _, domain, _ := strings.Cut(name, "/") 629 + if err := s3.checkDomainFrozen(ctx, domain); err != nil { 630 + return err 631 + } 632 + 633 + if err := s3.checkManifestPrecondition(ctx, name, opts); err != nil { 634 + return err 635 + } 508 636 509 637 err := s3.client.RemoveObject(ctx, s3.bucket, manifestObjectName(name), 510 638 minio.RemoveObjectOptions{}) ··· 512 640 return err 513 641 } 514 642 643 + func (s3 *S3Backend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] { 644 + return func(yield func(ManifestMetadata, error) bool) { 645 + logc.Print(ctx, "s3: enumerate manifests") 646 + 647 + ctx, cancel := context.WithCancel(ctx) 648 + defer cancel() 649 + 650 + prefix := "site/" 651 + for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{ 652 + Prefix: prefix, 653 + Recursive: true, 654 + }) { 655 + var metadata ManifestMetadata 656 + var err error 657 + if err = object.Err; err == nil { 658 + key := strings.TrimPrefix(object.Key, prefix) 659 + _, project, _ := strings.Cut(key, "/") 660 + if strings.HasSuffix(key, "/") { 661 + continue // directory; skip 662 + } else if project == "" || strings.HasPrefix(project, ".") && project != ".index" { 663 + continue // internal; skip 664 + } else { 665 + metadata.Name = key 666 + metadata.Size = object.Size 667 + metadata.LastModified = object.LastModified 668 + metadata.ETag = object.ETag 669 + } 670 + } 671 + if !yield(metadata, err) { 672 + break 673 + } 674 + } 675 + } 676 + } 677 + 515 678 func domainCheckObjectName(domain string) string { 516 679 return manifestObjectName(fmt.Sprintf("%s/.exists", domain)) 517 680 } 518 681 519 682 func (s3 *S3Backend) CheckDomain(ctx context.Context, domain string) (exists bool, err error) { 520 - log.Printf("s3: check domain %s\n", domain) 683 + logc.Printf(ctx, "s3: check domain %s\n", domain) 521 684 522 685 _, err = s3.client.StatObject(ctx, s3.bucket, domainCheckObjectName(domain), 523 686 minio.StatObjectOptions{}) ··· 548 711 } 549 712 550 713 func (s3 *S3Backend) CreateDomain(ctx context.Context, domain string) error { 551 - log.Printf("s3: create domain %s\n", domain) 714 + logc.Printf(ctx, "s3: create domain %s\n", domain) 552 715 553 716 _, err := s3.client.PutObject(ctx, s3.bucket, domainCheckObjectName(domain), 554 717 &bytes.Reader{}, 0, minio.PutObjectOptions{}) 555 718 return err 556 719 } 720 + 721 + func (s3 *S3Backend) FreezeDomain(ctx context.Context, domain string) error { 722 + logc.Printf(ctx, "s3: freeze domain %s\n", domain) 723 + 724 + _, err := s3.client.PutObject(ctx, s3.bucket, domainFrozenObjectName(domain), 725 + &bytes.Reader{}, 0, minio.PutObjectOptions{}) 726 + return err 727 + 728 + } 729 + 730 + func (s3 *S3Backend) UnfreezeDomain(ctx context.Context, domain string) error { 731 + logc.Printf(ctx, "s3: unfreeze domain %s\n", domain) 732 + 733 + err := s3.client.RemoveObject(ctx, s3.bucket, domainFrozenObjectName(domain), 734 + minio.RemoveObjectOptions{}) 735 + if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { 736 + return nil 737 + } else { 738 + return err 739 + } 740 + } 741 + 742 + func auditObjectName(id AuditID) string { 743 + return fmt.Sprintf("audit/%s", id) 744 + } 745 + 746 + func (s3 *S3Backend) AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) error { 747 + logc.Printf(ctx, "s3: append audit %s\n", id) 748 + 749 + name := auditObjectName(id) 750 + data := EncodeAuditRecord(record) 751 + 752 + options := minio.PutObjectOptions{} 753 + options.SetMatchETagExcept("*") // may or may not be supported 754 + _, err := s3.client.PutObject(ctx, s3.bucket, name, 755 + bytes.NewReader(data), int64(len(data)), options) 756 + if errResp := minio.ToErrorResponse(err); errResp.StatusCode == 412 { 757 + panic(fmt.Errorf("audit ID collision: %s", name)) 758 + } 759 + return err 760 + } 761 + 762 + func (s3 *S3Backend) QueryAuditLog(ctx context.Context, id AuditID) (*AuditRecord, error) { 763 + logc.Printf(ctx, "s3: read audit %s\n", id) 764 + 765 + object, err := s3.client.GetObject(ctx, s3.bucket, auditObjectName(id), 766 + minio.GetObjectOptions{}) 767 + if err != nil { 768 + return nil, err 769 + } 770 + defer object.Close() 771 + 772 + data, err := io.ReadAll(object) 773 + if err != nil { 774 + return nil, err 775 + } 776 + 777 + return DecodeAuditRecord(data) 778 + } 779 + 780 + func (s3 *S3Backend) SearchAuditLog( 781 + ctx context.Context, opts SearchAuditLogOptions, 782 + ) iter.Seq2[AuditID, error] { 783 + return func(yield func(AuditID, error) bool) { 784 + logc.Printf(ctx, "s3: search audit\n") 785 + 786 + ctx, cancel := context.WithCancel(ctx) 787 + defer cancel() 788 + 789 + prefix := "audit/" 790 + for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{ 791 + Prefix: prefix, 792 + }) { 793 + var id AuditID 794 + var err error 795 + if object.Err != nil { 796 + err = object.Err 797 + } else { 798 + id, err = ParseAuditID(strings.TrimPrefix(object.Key, prefix)) 799 + } 800 + if !yield(id, err) { 801 + break 802 + } 803 + } 804 + } 805 + }
+34 -31
src/caddy.go
··· 1 1 package git_pages 2 2 3 3 import ( 4 + "context" 4 5 "crypto/tls" 5 6 "fmt" 6 - "log" 7 7 "net" 8 8 "net/http" 9 - "net/url" 10 9 "strings" 11 10 ) 12 11 ··· 22 21 // this isn't really what git-pages is designed for, and object store accesses can cost money. 23 22 // [^1]: https://letsencrypt.org/2025/07/01/issuing-our-first-ip-address-certificate 24 23 if ip := net.ParseIP(domain); ip != nil { 25 - log.Println("caddy:", domain, 404, "(bare IP)") 24 + logc.Println(r.Context(), "caddy:", domain, 404, "(bare IP)") 26 25 w.WriteHeader(http.StatusNotFound) 27 26 return 28 27 } ··· 35 34 // Pages v2, which would under some circumstances return certificates with subjectAltName 36 35 // not valid for the SNI. Go's TLS stack makes `tls.Dial` return an error for these, 37 36 // thankfully making it unnecessary to examine X.509 certificates manually here.) 38 - for _, wildcardConfig := range config.Wildcard { 39 - if wildcardConfig.FallbackProxyTo == "" { 40 - continue 41 - } 42 - fallbackURL, err := url.Parse(wildcardConfig.FallbackProxyTo) 43 - if err != nil { 44 - continue 45 - } 46 - if fallbackURL.Scheme != "https" { 47 - continue 48 - } 49 - connectHost := fallbackURL.Host 50 - if fallbackURL.Port() != "" { 51 - connectHost += ":" + fallbackURL.Port() 52 - } else { 53 - connectHost += ":443" 54 - } 55 - log.Printf("caddy: check TLS %s", fallbackURL) 56 - connection, err := tls.Dial("tcp", connectHost, &tls.Config{ServerName: domain}) 57 - if err != nil { 58 - continue 59 - } 60 - connection.Close() 61 - found = true 62 - break 37 + found, err = tryDialWithSNI(r.Context(), domain) 38 + if err != nil { 39 + logc.Printf(r.Context(), "caddy err: check SNI: %s\n", err) 63 40 } 64 41 } 65 42 66 43 if found { 67 - log.Println("caddy:", domain, 200) 44 + logc.Println(r.Context(), "caddy:", domain, 200) 68 45 w.WriteHeader(http.StatusOK) 69 46 } else if err == nil { 70 - log.Println("caddy:", domain, 404) 47 + logc.Println(r.Context(), "caddy:", domain, 404) 71 48 w.WriteHeader(http.StatusNotFound) 72 49 } else { 73 - log.Println("caddy:", domain, 500) 50 + logc.Println(r.Context(), "caddy:", domain, 500) 74 51 w.WriteHeader(http.StatusInternalServerError) 75 52 fmt.Fprintln(w, err) 76 53 } 77 54 } 55 + 56 + func tryDialWithSNI(ctx context.Context, domain string) (bool, error) { 57 + if config.Fallback.ProxyTo == nil { 58 + return false, nil 59 + } 60 + 61 + fallbackURL := config.Fallback.ProxyTo 62 + if fallbackURL.Scheme != "https" { 63 + return false, nil 64 + } 65 + 66 + connectHost := fallbackURL.Host 67 + if fallbackURL.Port() != "" { 68 + connectHost += ":" + fallbackURL.Port() 69 + } else { 70 + connectHost += ":443" 71 + } 72 + 73 + logc.Printf(ctx, "caddy: check TLS %s", fallbackURL) 74 + connection, err := tls.Dial("tcp", connectHost, &tls.Config{ServerName: domain}) 75 + if err != nil { 76 + return false, err 77 + } 78 + connection.Close() 79 + return true, nil 80 + }
+28 -22
src/collect.go
··· 5 5 "context" 6 6 "fmt" 7 7 "io" 8 - "time" 9 8 ) 10 9 11 10 type Flusher interface { ··· 14 13 15 14 // Inverse of `ExtractTar`. 16 15 func CollectTar( 17 - context context.Context, writer io.Writer, manifest *Manifest, manifestMtime time.Time, 16 + context context.Context, writer io.Writer, manifest *Manifest, metadata ManifestMetadata, 18 17 ) ( 19 18 err error, 20 19 ) { ··· 22 21 23 22 appendFile := func(header *tar.Header, data []byte, transform Transform) (err error) { 24 23 switch transform { 25 - case Transform_None: 26 - case Transform_Zstandard: 24 + case Transform_Identity: 25 + case Transform_Zstd: 27 26 data, err = zstdDecoder.DecodeAll(data, []byte{}) 28 27 if err != nil { 29 - return err 28 + return fmt.Errorf("zstd: %s: %w", header.Name, err) 30 29 } 31 30 default: 32 - return fmt.Errorf("unexpected transform") 31 + return fmt.Errorf("%s: unexpected transform", header.Name) 33 32 } 34 33 header.Size = int64(len(data)) 35 34 36 35 err = archive.WriteHeader(header) 37 36 if err != nil { 38 - return 37 + return fmt.Errorf("tar: %w", err) 39 38 } 40 39 _, err = archive.Write(data) 40 + if err != nil { 41 + return fmt.Errorf("tar: %w", err) 42 + } 41 43 return 42 44 } 43 45 ··· 52 54 case Type_Directory: 53 55 header.Typeflag = tar.TypeDir 54 56 header.Mode = 0755 55 - header.ModTime = manifestMtime 56 - err = appendFile(&header, nil, Transform_None) 57 + header.ModTime = metadata.LastModified 58 + err = appendFile(&header, nil, Transform_Identity) 57 59 58 60 case Type_InlineFile: 59 61 header.Typeflag = tar.TypeReg 60 62 header.Mode = 0644 61 - header.ModTime = manifestMtime 63 + header.ModTime = metadata.LastModified 62 64 err = appendFile(&header, entry.GetData(), entry.GetTransform()) 63 65 64 66 case Type_ExternalFile: 65 67 var blobReader io.Reader 66 - var blobMtime time.Time 68 + var blobMetadata BlobMetadata 67 69 var blobData []byte 68 - blobReader, _, blobMtime, err = backend.GetBlob(context, string(entry.Data)) 70 + blobReader, blobMetadata, err = backend.GetBlob(context, string(entry.Data)) 69 71 if err != nil { 70 72 return 71 73 } 72 - blobData, _ = io.ReadAll(blobReader) 74 + blobData, err = io.ReadAll(blobReader) 75 + if err != nil { 76 + return 77 + } 73 78 header.Typeflag = tar.TypeReg 74 79 header.Mode = 0644 75 - header.ModTime = blobMtime 80 + header.ModTime = blobMetadata.LastModified 76 81 err = appendFile(&header, blobData, entry.GetTransform()) 77 82 78 83 case Type_Symlink: 79 84 header.Typeflag = tar.TypeSymlink 80 85 header.Mode = 0644 81 - header.ModTime = manifestMtime 82 - err = appendFile(&header, entry.GetData(), Transform_None) 86 + header.ModTime = metadata.LastModified 87 + err = appendFile(&header, entry.GetData(), Transform_Identity) 83 88 84 89 default: 85 - return fmt.Errorf("unexpected entry type") 90 + panic(fmt.Errorf("CollectTar encountered invalid entry: %v, %v", 91 + entry.GetType(), entry.GetTransform())) 86 92 } 87 93 if err != nil { 88 94 return err ··· 94 100 Name: RedirectsFileName, 95 101 Typeflag: tar.TypeReg, 96 102 Mode: 0644, 97 - ModTime: manifestMtime, 98 - }, []byte(redirects), Transform_None) 103 + ModTime: metadata.LastModified, 104 + }, []byte(redirects), Transform_Identity) 99 105 if err != nil { 100 106 return err 101 107 } ··· 106 112 Name: HeadersFileName, 107 113 Typeflag: tar.TypeReg, 108 114 Mode: 0644, 109 - ModTime: manifestMtime, 110 - }, []byte(headers), Transform_None) 115 + ModTime: metadata.LastModified, 116 + }, []byte(headers), Transform_Identity) 111 117 if err != nil { 112 118 return err 113 119 } ··· 115 121 116 122 err = archive.Flush() 117 123 if err != nil { 118 - return err 124 + return fmt.Errorf("tar: %w", err) 119 125 } 120 126 121 127 flusher, ok := writer.(Flusher)
+73 -19
src/config.go
··· 4 4 "bytes" 5 5 "encoding/json" 6 6 "fmt" 7 + "net/url" 7 8 "os" 8 9 "reflect" 9 10 "slices" ··· 16 17 "github.com/pelletier/go-toml/v2" 17 18 ) 18 19 19 - // For some reason, the standard `time.Duration` type doesn't implement the standard 20 + // For an unknown reason, the standard `time.Duration` type doesn't implement the standard 20 21 // `encoding.{TextMarshaler,TextUnmarshaler}` interfaces. 21 22 type Duration time.Duration 22 23 ··· 26 27 27 28 func (t *Duration) UnmarshalText(data []byte) (err error) { 28 29 u, err := time.ParseDuration(string(data)) 29 - *t = Duration(u) 30 + if err == nil { 31 + *t = Duration(u) 32 + } 30 33 return 31 34 } 32 35 ··· 34 37 return []byte(t.String()), nil 35 38 } 36 39 40 + // For a known but upsetting reason, the standard `url.URL` type doesn't implement the standard 41 + // `encoding.{TextMarshaler,TextUnmarshaler}` interfaces. 42 + type URL struct { 43 + url.URL 44 + } 45 + 46 + func (t *URL) String() string { 47 + return fmt.Sprint(&t.URL) 48 + } 49 + 50 + func (t *URL) UnmarshalText(data []byte) (err error) { 51 + u, err := url.Parse(string(data)) 52 + if err == nil { 53 + *t = URL{*u} 54 + } 55 + return 56 + } 57 + 58 + func (t *URL) MarshalText() ([]byte, error) { 59 + return []byte(t.String()), nil 60 + } 61 + 37 62 type Config struct { 38 63 Insecure bool `toml:"-" env:"insecure"` 39 64 Features []string `toml:"features"` 40 65 LogFormat string `toml:"log-format" default:"text"` 41 66 Server ServerConfig `toml:"server"` 42 67 Wildcard []WildcardConfig `toml:"wildcard"` 68 + Fallback FallbackConfig `toml:"fallback"` 43 69 Storage StorageConfig `toml:"storage"` 44 70 Limits LimitsConfig `toml:"limits"` 71 + Audit AuditConfig `toml:"audit"` 45 72 Observability ObservabilityConfig `toml:"observability"` 46 73 } 47 74 48 75 type ServerConfig struct { 49 - Pages string `toml:"pages" default:"tcp/:3000"` 50 - Caddy string `toml:"caddy" default:"tcp/:3001"` 51 - Metrics string `toml:"metrics" default:"tcp/:3002"` 76 + Pages string `toml:"pages" default:"tcp/localhost:3000"` 77 + Caddy string `toml:"caddy" default:"tcp/localhost:3001"` 78 + Metrics string `toml:"metrics" default:"tcp/localhost:3002"` 52 79 } 53 80 54 81 type WildcardConfig struct { 55 - Domain string `toml:"domain"` 56 - CloneURL string `toml:"clone-url"` 57 - IndexRepos []string `toml:"index-repos" default:"[]"` 58 - IndexRepoBranch string `toml:"index-repo-branch" default:"pages"` 59 - Authorization string `toml:"authorization"` 60 - FallbackProxyTo string `toml:"fallback-proxy-to"` 61 - FallbackInsecure bool `toml:"fallback-insecure"` 82 + Domain string `toml:"domain"` 83 + CloneURL string `toml:"clone-url"` // URL template, not an exact URL 84 + IndexRepos []string `toml:"index-repos" default:"[]"` 85 + IndexRepoBranch string `toml:"index-repo-branch" default:"pages"` 86 + Authorization string `toml:"authorization"` 87 + } 88 + 89 + type FallbackConfig struct { 90 + ProxyTo *URL `toml:"proxy-to"` 91 + Insecure bool `toml:"insecure"` 62 92 } 63 93 64 94 type CacheConfig struct { ··· 109 139 // List of domains unconditionally forbidden for uploads. 110 140 ForbiddenDomains []string `toml:"forbidden-domains" default:"[]"` 111 141 // List of allowed repository URL prefixes. Setting this option prohibits uploading archives. 112 - AllowedRepositoryURLPrefixes []string `toml:"allowed-repository-url-prefixes"` 142 + AllowedRepositoryURLPrefixes []string `toml:"allowed-repository-url-prefixes" default:"[]"` 113 143 // List of allowed custom headers. Header name must be in the MIME canonical form, 114 144 // e.g. `Foo-Bar`. Setting this option permits including this custom header in `_headers`, 115 145 // unless it is fundamentally unsafe. 116 146 AllowedCustomHeaders []string `toml:"allowed-custom-headers" default:"[\"X-Clacks-Overhead\"]"` 117 147 } 118 148 149 + type AuditConfig struct { 150 + // Globally unique machine identifier (0 to 63 inclusive). 151 + NodeID int `toml:"node-id"` 152 + // Whether audit reports should be stored whenever an audit event occurs. 153 + Collect bool `toml:"collect"` 154 + // If not empty, includes the principal's IP address in audit reports, with the value specifying 155 + // the source of the IP address. If the value is "X-Forwarded-For", the last item of the 156 + // corresponding header field (assumed to be comma-separated) is used. If the value is 157 + // "RemoteAddr", the connecting host's address is used. Any other value is disallowed. 158 + IncludeIPs string `toml:"include-ip"` 159 + // Endpoint to notify with a `GET /<notify-url>?<id>` whenever an audit event occurs. 160 + NotifyURL *URL `toml:"notify-url"` 161 + } 162 + 119 163 type ObservabilityConfig struct { 120 164 // Minimum duration for an HTTP request transaction to be unconditionally sampled. 121 165 SlowResponseThreshold Duration `toml:"slow-response-threshold" default:"500ms"` 122 166 } 123 167 124 - func (config *Config) DebugJSON() string { 125 - result, err := json.MarshalIndent(config, "", " ") 168 + func (config *Config) TOML() string { 169 + result, err := toml.Marshal(config) 126 170 if err != nil { 127 171 panic(err) 128 172 } ··· 192 236 if valueCast, err = strconv.ParseBool(repr); err == nil { 193 237 reflValue.SetBool(valueCast) 194 238 } 239 + case int: 240 + var parsed int64 241 + if parsed, err = strconv.ParseInt(repr, 10, strconv.IntSize); err == nil { 242 + reflValue.SetInt(parsed) 243 + } 195 244 case uint: 196 245 var parsed uint64 197 246 if parsed, err = strconv.ParseUint(repr, 10, strconv.IntSize); err == nil { ··· 203 252 } 204 253 case datasize.ByteSize: 205 254 if valueCast, err = datasize.ParseString(repr); err == nil { 206 - reflValue.Set(reflect.ValueOf(valueCast)) 207 - } 208 - case time.Duration: 209 - if valueCast, err = time.ParseDuration(repr); err == nil { 210 255 reflValue.Set(reflect.ValueOf(valueCast)) 211 256 } 212 257 case Duration: 213 258 var parsed time.Duration 214 259 if parsed, err = time.ParseDuration(repr); err == nil { 215 260 reflValue.Set(reflect.ValueOf(Duration(parsed))) 261 + } 262 + case *URL: 263 + if repr == "" { 264 + reflValue.Set(reflect.ValueOf(nil)) 265 + } else { 266 + var parsed *url.URL 267 + if parsed, err = url.Parse(repr); err == nil { 268 + reflValue.Set(reflect.ValueOf(&URL{*parsed})) 269 + } 216 270 } 217 271 case []WildcardConfig: 218 272 var parsed []*WildcardConfig
+141 -59
src/extract.go
··· 5 5 "archive/zip" 6 6 "bytes" 7 7 "compress/gzip" 8 + "context" 8 9 "errors" 9 10 "fmt" 10 11 "io" 12 + "math" 11 13 "os" 12 14 "strings" 13 15 14 16 "github.com/c2h5oh/datasize" 17 + "github.com/go-git/go-git/v6/plumbing" 15 18 "github.com/klauspost/compress/zstd" 16 - "google.golang.org/protobuf/proto" 17 19 ) 18 20 19 21 var ErrArchiveTooLarge = errors.New("archive too large") 20 22 21 - func ExtractTar(reader io.Reader) (*Manifest, error) { 22 - // If the tar stream is itself compressed, both the outer and the inner bounds checks 23 - // are load-bearing. 24 - boundedReader := ReadAtMost(reader, int64(config.Limits.MaxSiteSize.Bytes()), 23 + func boundArchiveStream(reader io.Reader) io.Reader { 24 + return ReadAtMost(reader, int64(config.Limits.MaxSiteSize.Bytes()), 25 25 fmt.Errorf("%w: %s limit exceeded", ErrArchiveTooLarge, config.Limits.MaxSiteSize.HR())) 26 + } 26 27 27 - archive := tar.NewReader(boundedReader) 28 + func ExtractGzip( 29 + ctx context.Context, reader io.Reader, 30 + next func(context.Context, io.Reader) (*Manifest, error), 31 + ) (*Manifest, error) { 32 + stream, err := gzip.NewReader(reader) 33 + if err != nil { 34 + return nil, err 35 + } 36 + defer stream.Close() 28 37 29 - manifest := Manifest{ 30 - Contents: map[string]*Entry{ 31 - "": {Type: Type_Directory.Enum()}, 32 - }, 38 + return next(ctx, boundArchiveStream(stream)) 39 + } 40 + 41 + func ExtractZstd( 42 + ctx context.Context, reader io.Reader, 43 + next func(context.Context, io.Reader) (*Manifest, error), 44 + ) (*Manifest, error) { 45 + stream, err := zstd.NewReader(reader) 46 + if err != nil { 47 + return nil, err 33 48 } 49 + defer stream.Close() 50 + 51 + return next(ctx, boundArchiveStream(stream)) 52 + } 53 + 54 + const BlobReferencePrefix = "/git/blobs/" 55 + 56 + type UnresolvedRefError struct { 57 + missing []string 58 + } 59 + 60 + func (err UnresolvedRefError) Error() string { 61 + return fmt.Sprintf("%d unresolved blob references", len(err.missing)) 62 + } 63 + 64 + // Returns a map of git hash to entry. If `manifest` is nil, returns an empty map. 65 + func indexManifestByGitHash(manifest *Manifest) map[string]*Entry { 66 + index := map[string]*Entry{} 67 + for _, entry := range manifest.GetContents() { 68 + if hash := entry.GetGitHash(); hash != "" { 69 + if _, ok := plumbing.FromHex(hash); ok { 70 + index[hash] = entry 71 + } else { 72 + panic(fmt.Errorf("index: malformed hash: %s", hash)) 73 + } 74 + } 75 + } 76 + return index 77 + } 78 + 79 + func addSymlinkOrBlobReference( 80 + manifest *Manifest, fileName string, target string, 81 + index map[string]*Entry, missing *[]string, 82 + ) *Entry { 83 + if hash, found := strings.CutPrefix(target, BlobReferencePrefix); found { 84 + if entry, found := index[hash]; found { 85 + manifest.Contents[fileName] = entry 86 + return entry 87 + } else { 88 + *missing = append(*missing, hash) 89 + return nil 90 + } 91 + } else { 92 + return AddSymlink(manifest, fileName, target) 93 + } 94 + } 95 + 96 + func ExtractTar(ctx context.Context, reader io.Reader, oldManifest *Manifest) (*Manifest, error) { 97 + archive := tar.NewReader(reader) 98 + 99 + var dataBytesRecycled int64 100 + var dataBytesTransferred int64 101 + 102 + index := indexManifestByGitHash(oldManifest) 103 + missing := []string{} 104 + manifest := NewManifest() 34 105 for { 35 106 header, err := archive.Next() 36 107 if err == io.EOF { ··· 50 121 } 51 122 } 52 123 53 - manifestEntry := Entry{} 54 124 switch header.Typeflag { 55 125 case tar.TypeReg: 56 126 fileData, err := io.ReadAll(archive) 57 127 if err != nil { 58 128 return nil, fmt.Errorf("tar: %s: %w", fileName, err) 59 129 } 60 - 61 - manifestEntry.Type = Type_InlineFile.Enum() 62 - manifestEntry.Size = proto.Int64(header.Size) 63 - manifestEntry.Data = fileData 64 - 130 + AddFile(manifest, fileName, fileData) 131 + dataBytesTransferred += int64(len(fileData)) 65 132 case tar.TypeSymlink: 66 - manifestEntry.Type = Type_Symlink.Enum() 67 - manifestEntry.Size = proto.Int64(header.Size) 68 - manifestEntry.Data = []byte(header.Linkname) 69 - 133 + entry := addSymlinkOrBlobReference( 134 + manifest, fileName, header.Linkname, index, &missing) 135 + dataBytesRecycled += entry.GetOriginalSize() 70 136 case tar.TypeDir: 71 - manifestEntry.Type = Type_Directory.Enum() 72 - fileName = strings.TrimSuffix(fileName, "/") 73 - 137 + AddDirectory(manifest, fileName) 74 138 default: 75 - AddProblem(&manifest, fileName, "unsupported type '%c'", header.Typeflag) 139 + AddProblem(manifest, fileName, "tar: unsupported type '%c'", header.Typeflag) 76 140 continue 77 141 } 78 - manifest.Contents[fileName] = &manifestEntry 79 142 } 80 - return &manifest, nil 81 - } 82 143 83 - func ExtractTarGzip(reader io.Reader) (*Manifest, error) { 84 - stream, err := gzip.NewReader(reader) 85 - if err != nil { 86 - return nil, err 144 + if len(missing) > 0 { 145 + return nil, UnresolvedRefError{missing} 87 146 } 88 - defer stream.Close() 89 147 90 - // stream length is limited in `ExtractTar` 91 - return ExtractTar(stream) 92 - } 148 + // Ensure parent directories exist for all entries. 149 + EnsureLeadingDirectories(manifest) 93 150 94 - func ExtractTarZstd(reader io.Reader) (*Manifest, error) { 95 - stream, err := zstd.NewReader(reader) 96 - if err != nil { 97 - return nil, err 98 - } 99 - defer stream.Close() 151 + logc.Printf(ctx, 152 + "reuse: %s recycled, %s transferred\n", 153 + datasize.ByteSize(dataBytesRecycled).HR(), 154 + datasize.ByteSize(dataBytesTransferred).HR(), 155 + ) 100 156 101 - // stream length is limited in `ExtractTar` 102 - return ExtractTar(stream) 157 + return manifest, nil 103 158 } 104 159 105 - func ExtractZip(reader io.Reader) (*Manifest, error) { 160 + // Used for zstd decompression inside zip files, it is recommended to share this. 161 + var zstdDecomp = zstd.ZipDecompressor() 162 + 163 + func ExtractZip(ctx context.Context, reader io.Reader, oldManifest *Manifest) (*Manifest, error) { 106 164 data, err := io.ReadAll(reader) 107 165 if err != nil { 108 166 return nil, err ··· 112 170 if err != nil { 113 171 return nil, err 114 172 } 173 + 174 + // Support zstd compression inside zip files. 175 + archive.RegisterDecompressor(zstd.ZipMethodWinZip, zstdDecomp) 176 + archive.RegisterDecompressor(zstd.ZipMethodPKWare, zstdDecomp) 115 177 116 178 // Detect and defuse zipbombs. 117 179 var totalSize uint64 118 180 for _, file := range archive.File { 181 + if totalSize+file.UncompressedSize64 < totalSize { 182 + // Would overflow 183 + totalSize = math.MaxUint64 184 + break 185 + } 119 186 totalSize += file.UncompressedSize64 120 187 } 121 188 if totalSize > config.Limits.MaxSiteSize.Bytes() { ··· 126 193 ) 127 194 } 128 195 129 - manifest := Manifest{ 130 - Contents: map[string]*Entry{ 131 - "": {Type: Type_Directory.Enum()}, 132 - }, 133 - } 196 + var dataBytesRecycled int64 197 + var dataBytesTransferred int64 198 + 199 + index := indexManifestByGitHash(oldManifest) 200 + missing := []string{} 201 + manifest := NewManifest() 134 202 for _, file := range archive.File { 135 - manifestEntry := Entry{} 136 - if !strings.HasSuffix(file.Name, "/") { 203 + if strings.HasSuffix(file.Name, "/") { 204 + AddDirectory(manifest, file.Name) 205 + } else { 137 206 fileReader, err := file.Open() 138 207 if err != nil { 139 208 return nil, err ··· 146 215 } 147 216 148 217 if file.Mode()&os.ModeSymlink != 0 { 149 - manifestEntry.Type = Type_Symlink.Enum() 218 + entry := addSymlinkOrBlobReference( 219 + manifest, file.Name, string(fileData), index, &missing) 220 + dataBytesRecycled += entry.GetOriginalSize() 150 221 } else { 151 - manifestEntry.Type = Type_InlineFile.Enum() 222 + AddFile(manifest, file.Name, fileData) 223 + dataBytesTransferred += int64(len(fileData)) 152 224 } 153 - manifestEntry.Size = proto.Int64(int64(file.UncompressedSize64)) 154 - manifestEntry.Data = fileData 155 - } else { 156 - manifestEntry.Type = Type_Directory.Enum() 157 225 } 158 - manifest.Contents[strings.TrimSuffix(file.Name, "/")] = &manifestEntry 159 226 } 160 - return &manifest, nil 227 + 228 + if len(missing) > 0 { 229 + return nil, UnresolvedRefError{missing} 230 + } 231 + 232 + // Ensure parent directories exist for all entries. 233 + EnsureLeadingDirectories(manifest) 234 + 235 + logc.Printf(ctx, 236 + "reuse: %s recycled, %s transferred\n", 237 + datasize.ByteSize(dataBytesRecycled).HR(), 238 + datasize.ByteSize(dataBytesTransferred).HR(), 239 + ) 240 + 241 + return manifest, nil 161 242 } 243 +
+189 -49
src/fetch.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 6 7 "io" 8 + "maps" 9 + "net/url" 7 10 "os" 11 + "slices" 8 12 13 + "github.com/c2h5oh/datasize" 9 14 "github.com/go-git/go-billy/v6/osfs" 10 15 "github.com/go-git/go-git/v6" 11 16 "github.com/go-git/go-git/v6/plumbing" 12 17 "github.com/go-git/go-git/v6/plumbing/cache" 13 18 "github.com/go-git/go-git/v6/plumbing/filemode" 14 19 "github.com/go-git/go-git/v6/plumbing/object" 20 + "github.com/go-git/go-git/v6/plumbing/protocol/packp" 21 + "github.com/go-git/go-git/v6/plumbing/transport" 15 22 "github.com/go-git/go-git/v6/storage/filesystem" 16 23 "google.golang.org/protobuf/proto" 17 24 ) 18 25 19 - func FetchRepository(ctx context.Context, repoURL string, branch string) (*Manifest, error) { 26 + var ErrRepositoryTooLarge = errors.New("repository too large") 27 + 28 + func FetchRepository( 29 + ctx context.Context, repoURL string, branch string, oldManifest *Manifest, 30 + ) ( 31 + *Manifest, error, 32 + ) { 20 33 span, ctx := ObserveFunction(ctx, "FetchRepository", 21 34 "git.repository", repoURL, "git.branch", branch) 22 35 defer span.Finish() 23 36 24 - baseDir, err := os.MkdirTemp("", "fetchRepo") 37 + parsedRepoURL, err := url.Parse(repoURL) 25 38 if err != nil { 26 - return nil, fmt.Errorf("mkdtemp: %w", err) 39 + return nil, fmt.Errorf("URL parse: %w", err) 27 40 } 28 - defer os.RemoveAll(baseDir) 29 41 30 - fs := osfs.New(baseDir, osfs.WithBoundOS()) 31 - cache := cache.NewObjectLRUDefault() 32 - storer := filesystem.NewStorageWithOptions(fs, cache, filesystem.Options{ 33 - ExclusiveAccess: true, 34 - LargeObjectThreshold: int64(config.Limits.GitLargeObjectThreshold.Bytes()), 35 - }) 36 - repo, err := git.CloneContext(ctx, storer, nil, &git.CloneOptions{ 37 - Bare: true, 38 - URL: repoURL, 39 - ReferenceName: plumbing.ReferenceName(branch), 40 - SingleBranch: true, 41 - Depth: 1, 42 - Tags: git.NoTags, 43 - }) 42 + var repo *git.Repository 43 + var storer *filesystem.Storage 44 + for _, filter := range []packp.Filter{packp.FilterBlobNone(), packp.Filter("")} { 45 + var tempDir string 46 + if tempDir, err = os.MkdirTemp("", "fetchRepo"); err != nil { 47 + return nil, fmt.Errorf("mkdtemp: %w", err) 48 + } 49 + defer os.RemoveAll(tempDir) 50 + 51 + storer = filesystem.NewStorageWithOptions( 52 + osfs.New(tempDir, osfs.WithBoundOS()), 53 + cache.NewObjectLRUDefault(), 54 + filesystem.Options{ 55 + ExclusiveAccess: true, 56 + LargeObjectThreshold: int64(config.Limits.GitLargeObjectThreshold.Bytes()), 57 + }, 58 + ) 59 + repo, err = git.CloneContext(ctx, storer, nil, &git.CloneOptions{ 60 + Bare: true, 61 + URL: repoURL, 62 + ReferenceName: plumbing.NewBranchReferenceName(branch), 63 + SingleBranch: true, 64 + Depth: 1, 65 + Tags: git.NoTags, 66 + Filter: filter, 67 + }) 68 + if err != nil { 69 + logc.Printf(ctx, "clone err: %s %s filter=%q\n", repoURL, branch, filter) 70 + continue 71 + } else { 72 + logc.Printf(ctx, "clone ok: %s %s filter=%q\n", repoURL, branch, filter) 73 + break 74 + } 75 + } 44 76 if err != nil { 45 77 return nil, fmt.Errorf("git clone: %w", err) 46 78 } ··· 63 95 walker := object.NewTreeWalker(tree, true, make(map[plumbing.Hash]bool)) 64 96 defer walker.Close() 65 97 66 - manifest := Manifest{ 67 - RepoUrl: proto.String(repoURL), 68 - Branch: proto.String(branch), 69 - Commit: proto.String(ref.Hash().String()), 70 - Contents: map[string]*Entry{ 71 - "": {Type: Type_Directory.Enum()}, 72 - }, 73 - } 98 + // Create a manifest for the tree object corresponding to `branch`, but do not populate it 99 + // with data yet; instead, record all the blobs we'll need. 100 + manifest := NewManifest() 101 + manifest.RepoUrl = proto.String(repoURL) 102 + manifest.Branch = proto.String(branch) 103 + manifest.Commit = proto.String(ref.Hash().String()) 104 + blobsNeeded := map[plumbing.Hash]*Entry{} 74 105 for { 75 106 name, entry, err := walker.Next() 76 107 if err == io.EOF { ··· 78 109 } else if err != nil { 79 110 return nil, fmt.Errorf("git walker: %w", err) 80 111 } else { 81 - manifestEntry := Entry{} 82 - if entry.Mode.IsFile() { 83 - blob, err := repo.BlobObject(entry.Hash) 84 - if err != nil { 85 - return nil, fmt.Errorf("git blob %s: %w", name, err) 86 - } 87 - 88 - reader, err := blob.Reader() 89 - if err != nil { 90 - return nil, fmt.Errorf("git blob open: %w", err) 91 - } 92 - defer reader.Close() 93 - 94 - data, err := io.ReadAll(reader) 95 - if err != nil { 96 - return nil, fmt.Errorf("git blob read: %w", err) 97 - } 98 - 112 + manifestEntry := &Entry{} 113 + if existingManifestEntry, found := blobsNeeded[entry.Hash]; found { 114 + // If the same blob is present twice, we only need to fetch it once (and both 115 + // instances will alias the same `Entry` structure in the manifest). 116 + manifestEntry = existingManifestEntry 117 + } else if entry.Mode.IsFile() { 118 + blobsNeeded[entry.Hash] = manifestEntry 99 119 if entry.Mode == filemode.Symlink { 100 120 manifestEntry.Type = Type_Symlink.Enum() 101 121 } else { 102 122 manifestEntry.Type = Type_InlineFile.Enum() 103 123 } 104 - manifestEntry.Size = proto.Int64(blob.Size) 105 - manifestEntry.Data = data 124 + manifestEntry.GitHash = proto.String(entry.Hash.String()) 106 125 } else if entry.Mode == filemode.Dir { 107 126 manifestEntry.Type = Type_Directory.Enum() 108 127 } else { 109 - AddProblem(&manifest, name, "unsupported mode %#o", entry.Mode) 128 + AddProblem(manifest, name, "unsupported mode %#o", entry.Mode) 110 129 continue 111 130 } 112 - manifest.Contents[name] = &manifestEntry 131 + manifest.Contents[name] = manifestEntry 113 132 } 114 133 } 115 - return &manifest, nil 134 + 135 + // Collect checkout statistics. 136 + var dataBytesRecycled int64 137 + var dataBytesTransferred int64 138 + 139 + // First, see if we can extract the blobs from the old manifest. This is the preferred option 140 + // because it avoids both network transfers and recompression. Note that we do not request 141 + // blobs from the backend under any circumstances to avoid creating a blob existence oracle. 142 + for _, oldManifestEntry := range oldManifest.GetContents() { 143 + if hash, ok := plumbing.FromHex(oldManifestEntry.GetGitHash()); ok { 144 + if manifestEntry, found := blobsNeeded[hash]; found { 145 + manifestEntry.Reset() 146 + proto.Merge(manifestEntry, oldManifestEntry) 147 + dataBytesRecycled += oldManifestEntry.GetOriginalSize() 148 + delete(blobsNeeded, hash) 149 + } 150 + } 151 + } 152 + 153 + // Second, fill the manifest entries with data from the git checkout we just made. 154 + // This will only succeed if a `blob:none` filter isn't supported and we got a full 155 + // clone despite asking for a partial clone. 156 + for hash, manifestEntry := range blobsNeeded { 157 + if err := readGitBlob(repo, hash, manifestEntry, &dataBytesTransferred); err == nil { 158 + delete(blobsNeeded, hash) 159 + } else if errors.Is(err, ErrRepositoryTooLarge) { 160 + return nil, err 161 + } 162 + } 163 + 164 + // Third, if we still don't have data for some manifest entries, re-establish a git transport 165 + // and request the missing blobs (only) from the server. 166 + if len(blobsNeeded) > 0 { 167 + client, err := transport.Get(parsedRepoURL.Scheme) 168 + if err != nil { 169 + return nil, fmt.Errorf("git transport: %w", err) 170 + } 171 + 172 + endpoint, err := transport.NewEndpoint(repoURL) 173 + if err != nil { 174 + return nil, fmt.Errorf("git endpoint: %w", err) 175 + } 176 + 177 + session, err := client.NewSession(storer, endpoint, nil) 178 + if err != nil { 179 + return nil, fmt.Errorf("git session: %w", err) 180 + } 181 + 182 + connection, err := session.Handshake(ctx, transport.UploadPackService) 183 + if err != nil { 184 + return nil, fmt.Errorf("git connection: %w", err) 185 + } 186 + defer connection.Close() 187 + 188 + if err := connection.Fetch(ctx, &transport.FetchRequest{ 189 + Wants: slices.Collect(maps.Keys(blobsNeeded)), 190 + Depth: 1, 191 + // Git CLI behaves like this, even if the wants above are references to blobs. 192 + Filter: "blob:none", 193 + }); err != nil && !errors.Is(err, transport.ErrNoChange) { 194 + return nil, fmt.Errorf("git blob fetch request: %w", err) 195 + } 196 + 197 + // All remaining blobs should now be available. 198 + for hash, manifestEntry := range blobsNeeded { 199 + if err := readGitBlob(repo, hash, manifestEntry, &dataBytesTransferred); err != nil { 200 + return nil, err 201 + } 202 + delete(blobsNeeded, hash) 203 + } 204 + } 205 + 206 + logc.Printf(ctx, 207 + "reuse: %s recycled, %s transferred\n", 208 + datasize.ByteSize(dataBytesRecycled).HR(), 209 + datasize.ByteSize(dataBytesTransferred).HR(), 210 + ) 211 + 212 + return manifest, nil 213 + } 214 + 215 + func readGitBlob( 216 + repo *git.Repository, hash plumbing.Hash, entry *Entry, bytesTransferred *int64, 217 + ) error { 218 + blob, err := repo.BlobObject(hash) 219 + if err != nil { 220 + return fmt.Errorf("git blob %s: %w", hash, err) 221 + } 222 + 223 + reader, err := blob.Reader() 224 + if err != nil { 225 + return fmt.Errorf("git blob open: %w", err) 226 + } 227 + defer reader.Close() 228 + 229 + data, err := io.ReadAll(reader) 230 + if err != nil { 231 + return fmt.Errorf("git blob read: %w", err) 232 + } 233 + 234 + switch entry.GetType() { 235 + case Type_InlineFile, Type_Symlink: 236 + // okay 237 + default: 238 + panic(fmt.Errorf("readGitBlob encountered invalid entry: %v, %v", 239 + entry.GetType(), entry.GetTransform())) 240 + } 241 + 242 + entry.Data = data 243 + entry.Transform = Transform_Identity.Enum() 244 + entry.OriginalSize = proto.Int64(blob.Size) 245 + entry.CompressedSize = proto.Int64(blob.Size) 246 + 247 + *bytesTransferred += blob.Size 248 + if uint64(*bytesTransferred) > config.Limits.MaxSiteSize.Bytes() { 249 + return fmt.Errorf("%w: fetch exceeds %s limit", 250 + ErrRepositoryTooLarge, 251 + config.Limits.MaxSiteSize.HR(), 252 + ) 253 + } 254 + 255 + return nil 116 256 }
+16
src/flock_other.go
··· 1 + //go:build !unix 2 + 3 + package git_pages 4 + 5 + import ( 6 + "fmt" 7 + "os" 8 + ) 9 + 10 + func FileLock(file *os.File) error { 11 + return fmt.Errorf("unimplemented") 12 + } 13 + 14 + func FileUnlock(file *os.File) error { 15 + return fmt.Errorf("unimplemented") 16 + }
+16
src/flock_posix.go
··· 1 + //go:build unix 2 + 3 + package git_pages 4 + 5 + import ( 6 + "os" 7 + "syscall" 8 + ) 9 + 10 + func FileLock(file *os.File) error { 11 + return syscall.Flock(int(file.Fd()), syscall.LOCK_EX) 12 + } 13 + 14 + func FileUnlock(file *os.File) error { 15 + return syscall.Flock(int(file.Fd()), syscall.LOCK_UN) 16 + }
+88
src/garbage.go
··· 1 + package git_pages 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/c2h5oh/datasize" 8 + "github.com/dghubble/trie" 9 + ) 10 + 11 + func trieReduce(data trie.Trier) (items, total int64) { 12 + data.Walk(func(key string, value any) error { 13 + items += 1 14 + total += *value.(*int64) 15 + return nil 16 + }) 17 + return 18 + } 19 + 20 + func TraceGarbage(ctx context.Context) error { 21 + allBlobs := trie.NewRuneTrie() 22 + liveBlobs := trie.NewRuneTrie() 23 + 24 + traceManifest := func(manifestName string, manifest *Manifest) error { 25 + for _, entry := range manifest.GetContents() { 26 + if entry.GetType() == Type_ExternalFile { 27 + blobName := string(entry.Data) 28 + if size := allBlobs.Get(blobName); size == nil { 29 + return fmt.Errorf("%s: dangling reference %s", manifestName, blobName) 30 + } else { 31 + liveBlobs.Put(blobName, size) 32 + } 33 + } 34 + } 35 + return nil 36 + } 37 + 38 + // Enumerate all blobs. 39 + for metadata, err := range backend.EnumerateBlobs(ctx) { 40 + if err != nil { 41 + return fmt.Errorf("trace blobs err: %w", err) 42 + } 43 + allBlobs.Put(metadata.Name, &metadata.Size) 44 + } 45 + 46 + // Enumerate blobs live via site manifests. 47 + for metadata, err := range backend.EnumerateManifests(ctx) { 48 + if err != nil { 49 + return fmt.Errorf("trace sites err: %w", err) 50 + } 51 + manifest, _, err := backend.GetManifest(ctx, metadata.Name, GetManifestOptions{}) 52 + if err != nil { 53 + return fmt.Errorf("trace sites err: %w", err) 54 + } 55 + err = traceManifest(metadata.Name, manifest) 56 + if err != nil { 57 + return fmt.Errorf("trace sites err: %w", err) 58 + } 59 + } 60 + 61 + // Enumerate blobs live via audit records. 62 + for auditID, err := range backend.SearchAuditLog(ctx, SearchAuditLogOptions{}) { 63 + if err != nil { 64 + return fmt.Errorf("trace audit err: %w", err) 65 + } 66 + auditRecord, err := backend.QueryAuditLog(ctx, auditID) 67 + if err != nil { 68 + return fmt.Errorf("trace audit err: %w", err) 69 + } 70 + if auditRecord.Manifest != nil { 71 + err = traceManifest(auditID.String(), auditRecord.Manifest) 72 + if err != nil { 73 + return fmt.Errorf("trace audit err: %w", err) 74 + } 75 + } 76 + } 77 + 78 + allBlobsCount, allBlobsSize := trieReduce(allBlobs) 79 + liveBlobsCount, liveBlobsSize := trieReduce(liveBlobs) 80 + logc.Printf(ctx, "trace all: %d blobs, %s", 81 + allBlobsCount, datasize.ByteSize(allBlobsSize).HR()) 82 + logc.Printf(ctx, "trace live: %d blobs, %s", 83 + liveBlobsCount, datasize.ByteSize(liveBlobsSize).HR()) 84 + logc.Printf(ctx, "trace dead: %d blobs, %s", 85 + allBlobsCount-liveBlobsCount, datasize.ByteSize(allBlobsSize-liveBlobsSize).HR()) 86 + 87 + return nil 88 + }
+114 -26
src/http.go
··· 2 2 3 3 import ( 4 4 "cmp" 5 + "fmt" 6 + "net" 7 + "net/http" 5 8 "regexp" 6 9 "slices" 7 10 "strconv" 8 11 "strings" 9 12 ) 10 13 11 - var httpAcceptEncodingRegexp = regexp.MustCompile(`` + 14 + var httpAcceptRegexp = regexp.MustCompile(`` + 12 15 // token optionally prefixed by whitespace 13 - `^[ \t]*([a-zA-Z0-9$!#$%&'*+.^_\x60|~-]+)` + 16 + `^[ \t]*([a-zA-Z0-9$!#$%&'*+./^_\x60|~-]+)` + 14 17 // quality value prefixed by a semicolon optionally surrounded by whitespace 15 18 `(?:[ \t]*;[ \t]*q=(0(?:\.[0-9]{1,3})?|1(?:\.0{1,3})?))?` + 16 19 // optional whitespace followed by comma or end of line 17 20 `[ \t]*(?:,|$)`, 18 21 ) 19 22 20 - type httpEncoding struct { 23 + type httpAcceptOffer struct { 21 24 code string 22 25 qval float64 23 26 } 24 27 25 - type httpEncodings struct { 26 - encodings []httpEncoding 27 - } 28 - 29 - func parseHTTPEncodings(headerValue string) (result httpEncodings) { 28 + func parseGenericAcceptHeader(headerValue string) (result []httpAcceptOffer) { 30 29 for headerValue != "" { 31 - matches := httpAcceptEncodingRegexp.FindStringSubmatch(headerValue) 30 + matches := httpAcceptRegexp.FindStringSubmatch(headerValue) 32 31 if matches == nil { 33 - return httpEncodings{} 32 + return 34 33 } 35 - enc := httpEncoding{strings.ToLower(matches[1]), 1.0} 34 + offer := httpAcceptOffer{strings.ToLower(matches[1]), 1.0} 36 35 if matches[2] != "" { 37 - enc.qval, _ = strconv.ParseFloat(matches[2], 64) 36 + offer.qval, _ = strconv.ParseFloat(matches[2], 64) 38 37 } 39 - result.encodings = append(result.encodings, enc) 38 + result = append(result, offer) 40 39 headerValue = headerValue[len(matches[0]):] 41 40 } 41 + return 42 + } 43 + 44 + func preferredAcceptOffer(offers []httpAcceptOffer) string { 45 + slices.SortStableFunc(offers, func(a, b httpAcceptOffer) int { 46 + return -cmp.Compare(a.qval, b.qval) 47 + }) 48 + for _, offer := range offers { 49 + if offer.qval != 0 { 50 + return offer.code 51 + } 52 + } 53 + return "" 54 + } 55 + 56 + type HTTPContentTypes struct { 57 + contentTypes []httpAcceptOffer 58 + } 59 + 60 + func ParseAcceptHeader(headerValue string) (result HTTPContentTypes) { 61 + if headerValue == "" { 62 + headerValue = "*/*" 63 + } 64 + result = HTTPContentTypes{parseGenericAcceptHeader(headerValue)} 65 + return 66 + } 67 + 68 + func (e *HTTPContentTypes) Negotiate(offers ...string) string { 69 + prefs := make(map[string]float64, len(offers)) 70 + for _, code := range offers { 71 + prefs[code] = 0 72 + } 73 + for _, ctyp := range e.contentTypes { 74 + if ctyp.code == "*/*" { 75 + for code := range prefs { 76 + prefs[code] = ctyp.qval 77 + } 78 + } else if _, ok := prefs[ctyp.code]; ok { 79 + prefs[ctyp.code] = ctyp.qval 80 + } 81 + } 82 + ctyps := make([]httpAcceptOffer, len(offers)) 83 + for idx, code := range offers { 84 + ctyps[idx] = httpAcceptOffer{code, prefs[code]} 85 + } 86 + return preferredAcceptOffer(ctyps) 87 + } 88 + 89 + type HTTPEncodings struct { 90 + encodings []httpAcceptOffer 91 + } 92 + 93 + func ParseAcceptEncodingHeader(headerValue string) (result HTTPEncodings) { 94 + result = HTTPEncodings{parseGenericAcceptHeader(headerValue)} 42 95 if len(result.encodings) == 0 { 43 96 // RFC 9110 says (https://httpwg.org/specs/rfc9110.html#field.accept-encoding): 44 97 // "If no Accept-Encoding header field is in the request, any content ··· 51 104 52 105 // Negotiate returns the most preferred encoding that is acceptable by the 53 106 // client, or an empty string if no encodings are acceptable. 54 - func (e *httpEncodings) Negotiate(codes ...string) string { 55 - prefs := make(map[string]float64, len(codes)) 56 - for _, code := range codes { 107 + func (e *HTTPEncodings) Negotiate(offers ...string) string { 108 + prefs := make(map[string]float64, len(offers)) 109 + for _, code := range offers { 57 110 prefs[code] = 0 58 111 } 59 112 implicitIdentity := true ··· 73 126 if _, ok := prefs["identity"]; ok && implicitIdentity { 74 127 prefs["identity"] = -1 // sort last 75 128 } 76 - encs := make([]httpEncoding, len(codes)) 77 - for idx, code := range codes { 78 - encs[idx] = httpEncoding{code, prefs[code]} 129 + encs := make([]httpAcceptOffer, len(offers)) 130 + for idx, code := range offers { 131 + encs[idx] = httpAcceptOffer{code, prefs[code]} 79 132 } 80 - slices.SortStableFunc(encs, func(a, b httpEncoding) int { 81 - return -cmp.Compare(a.qval, b.qval) 82 - }) 83 - for _, enc := range encs { 84 - if enc.qval != 0 { 85 - return enc.code 133 + return preferredAcceptOffer(encs) 134 + } 135 + 136 + func chainHTTPMiddleware(middleware ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { 137 + return func(handler http.Handler) http.Handler { 138 + for idx := len(middleware) - 1; idx >= 0; idx-- { 139 + handler = middleware[idx](handler) 86 140 } 141 + return handler 87 142 } 88 - return "" 143 + } 144 + 145 + func remoteAddrMiddleware(handler http.Handler) http.Handler { 146 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 + var readXForwardedFor bool 148 + switch config.Audit.IncludeIPs { 149 + case "X-Forwarded-For": 150 + readXForwardedFor = true 151 + case "RemoteAddr", "": 152 + readXForwardedFor = false 153 + default: 154 + panic(fmt.Errorf("config.Audit.IncludeIPs is set to an unknown value (%q)", 155 + config.Audit.IncludeIPs)) 156 + } 157 + 158 + usingOriginalRemoteAddr := true 159 + if readXForwardedFor { 160 + forwardedFor := strings.Split(r.Header.Get("X-Forwarded-For"), ",") 161 + if len(forwardedFor) > 0 { 162 + remoteAddr := strings.TrimSpace(forwardedFor[len(forwardedFor)-1]) 163 + if remoteAddr != "" { 164 + r.RemoteAddr = remoteAddr 165 + usingOriginalRemoteAddr = false 166 + } 167 + } 168 + } 169 + if usingOriginalRemoteAddr { 170 + if ipAddress, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { 171 + r.RemoteAddr = ipAddress 172 + } 173 + } 174 + 175 + handler.ServeHTTP(w, r) 176 + }) 89 177 }
+54
src/log.go
··· 1 + package git_pages 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "os" 8 + "runtime" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + var logc slogWithCtx 14 + 15 + type slogWithCtx struct{} 16 + 17 + func (l slogWithCtx) log(ctx context.Context, level slog.Level, msg string) { 18 + if ctx == nil { 19 + ctx = context.Background() 20 + } 21 + logger := slog.Default() 22 + if !logger.Enabled(ctx, level) { 23 + return 24 + } 25 + 26 + var pcs [1]uintptr 27 + // skip [runtime.Callers, this method, method calling this method] 28 + runtime.Callers(3, pcs[:]) 29 + 30 + record := slog.NewRecord(time.Now(), level, strings.TrimRight(msg, "\n"), pcs[0]) 31 + logger.Handler().Handle(ctx, record) 32 + } 33 + 34 + func (l slogWithCtx) Print(ctx context.Context, v ...any) { 35 + l.log(ctx, slog.LevelInfo, fmt.Sprint(v...)) 36 + } 37 + 38 + func (l slogWithCtx) Printf(ctx context.Context, format string, v ...any) { 39 + l.log(ctx, slog.LevelInfo, fmt.Sprintf(format, v...)) 40 + } 41 + 42 + func (l slogWithCtx) Println(ctx context.Context, v ...any) { 43 + l.log(ctx, slog.LevelInfo, fmt.Sprintln(v...)) 44 + } 45 + 46 + func (l slogWithCtx) Fatalf(ctx context.Context, format string, v ...any) { 47 + l.log(ctx, slog.LevelError, fmt.Sprintf(format, v...)) 48 + os.Exit(1) 49 + } 50 + 51 + func (l slogWithCtx) Fatalln(ctx context.Context, v ...any) { 52 + l.log(ctx, slog.LevelError, fmt.Sprintln(v...)) 53 + os.Exit(1) 54 + }
+318 -101
src/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "crypto/tls" 5 6 "errors" 6 7 "flag" 7 8 "fmt" ··· 10 11 "log/slog" 11 12 "net" 12 13 "net/http" 14 + "net/http/httputil" 13 15 "net/url" 14 16 "os" 17 + "path" 15 18 "runtime/debug" 16 19 "strings" 20 + "time" 17 21 18 22 automemlimit "github.com/KimMachineGun/automemlimit/memlimit" 19 23 "github.com/c2h5oh/datasize" 24 + "github.com/fatih/color" 25 + "github.com/kankanreno/go-snowflake" 20 26 "github.com/prometheus/client_golang/prometheus/promhttp" 27 + "google.golang.org/protobuf/proto" 21 28 ) 22 29 23 30 var config *Config 24 31 var wildcards []*WildcardPattern 32 + var fallback http.Handler 25 33 var backend Backend 26 34 27 - func configureFeatures() (err error) { 35 + func configureFeatures(ctx context.Context) (err error) { 28 36 if len(config.Features) > 0 { 29 - log.Println("features:", strings.Join(config.Features, ", ")) 37 + logc.Println(ctx, "features:", strings.Join(config.Features, ", ")) 30 38 } 31 39 return 32 40 } 33 41 34 - func configureMemLimit() (err error) { 42 + func configureMemLimit(ctx context.Context) (err error) { 35 43 // Avoid being OOM killed by not garbage collecting early enough. 36 44 memlimitBefore := datasize.ByteSize(debug.SetMemoryLimit(-1)) 37 45 automemlimit.SetGoMemLimitWithOpts( ··· 46 54 ) 47 55 memlimitAfter := datasize.ByteSize(debug.SetMemoryLimit(-1)) 48 56 if memlimitBefore == memlimitAfter { 49 - log.Println("memlimit: now", memlimitBefore.HR()) 57 + logc.Println(ctx, "memlimit: now", memlimitBefore.HR()) 50 58 } else { 51 - log.Println("memlimit: was", memlimitBefore.HR(), "now", memlimitAfter.HR()) 59 + logc.Println(ctx, "memlimit: was", memlimitBefore.HR(), "now", memlimitAfter.HR()) 52 60 } 53 61 return 54 62 } 55 63 56 - func configureWildcards() (err error) { 64 + func configureWildcards(_ context.Context) (err error) { 57 65 newWildcards, err := TranslateWildcards(config.Wildcard) 58 66 if err != nil { 59 67 return err ··· 63 71 } 64 72 } 65 73 66 - func listen(name string, listen string) net.Listener { 74 + func configureFallback(_ context.Context) (err error) { 75 + if config.Fallback.ProxyTo != nil { 76 + fallbackURL := &config.Fallback.ProxyTo.URL 77 + fallback = &httputil.ReverseProxy{ 78 + Rewrite: func(r *httputil.ProxyRequest) { 79 + r.SetURL(fallbackURL) 80 + r.Out.Host = r.In.Host 81 + r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] 82 + }, 83 + Transport: &http.Transport{ 84 + TLSClientConfig: &tls.Config{ 85 + InsecureSkipVerify: config.Fallback.Insecure, 86 + }, 87 + }, 88 + } 89 + } 90 + return 91 + } 92 + 93 + // Thread-unsafe, must be called only during initial configuration. 94 + func configureAudit(_ context.Context) (err error) { 95 + snowflake.SetStartTime(time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)) 96 + snowflake.SetMachineID(config.Audit.NodeID) 97 + return 98 + } 99 + 100 + func listen(ctx context.Context, name string, listen string) net.Listener { 67 101 if listen == "-" { 68 102 return nil 69 103 } 70 104 71 105 protocol, address, ok := strings.Cut(listen, "/") 72 106 if !ok { 73 - log.Fatalf("%s: %s: malformed endpoint", name, listen) 107 + logc.Fatalf(ctx, "%s: %s: malformed endpoint", name, listen) 74 108 } 75 109 76 110 listener, err := net.Listen(protocol, address) 77 111 if err != nil { 78 - log.Fatalf("%s: %s\n", name, err) 112 + logc.Fatalf(ctx, "%s: %s\n", name, err) 79 113 } 80 114 81 115 return listener ··· 85 119 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 86 120 defer func() { 87 121 if err := recover(); err != nil { 88 - log.Printf("panic: %s %s %s: %s\n%s", 122 + logc.Printf(r.Context(), "panic: %s %s %s: %s\n%s", 89 123 r.Method, r.Host, r.URL.Path, err, string(debug.Stack())) 90 124 http.Error(w, 91 125 fmt.Sprintf("internal server error: %s", err), ··· 97 131 }) 98 132 } 99 133 100 - func serve(listener net.Listener, handler http.Handler) { 134 + func serve(ctx context.Context, listener net.Listener, handler http.Handler) { 101 135 if listener != nil { 102 - handler = panicHandler(handler) 103 - 104 136 server := http.Server{Handler: handler} 105 137 server.Protocols = new(http.Protocols) 106 138 server.Protocols.SetHTTP1(true) 107 - if config.Feature("serve-h2c") { 108 - server.Protocols.SetUnencryptedHTTP2(true) 109 - } 110 - log.Fatalln(server.Serve(listener)) 139 + server.Protocols.SetUnencryptedHTTP2(true) 140 + logc.Fatalln(ctx, server.Serve(listener)) 111 141 } 112 142 } 113 143 ··· 118 148 case 1: 119 149 return arg 120 150 default: 121 - log.Fatalf("webroot argument must be either 'domain.tld' or 'domain.tld/dir") 151 + logc.Fatalln(context.Background(), 152 + "webroot argument must be either 'domain.tld' or 'domain.tld/dir") 122 153 return "" 123 154 } 124 155 } ··· 130 161 } else { 131 162 writer, err = os.Create(flag.Arg(0)) 132 163 if err != nil { 133 - log.Fatalln(err) 164 + logc.Fatalln(context.Background(), err) 134 165 } 135 166 } 136 167 return ··· 140 171 fmt.Fprintf(os.Stderr, "Usage:\n") 141 172 fmt.Fprintf(os.Stderr, "(server) "+ 142 173 "git-pages [-config <file>|-no-config]\n") 143 - fmt.Fprintf(os.Stderr, "(admin) "+ 144 - "git-pages {-run-migration <name>}\n") 145 174 fmt.Fprintf(os.Stderr, "(info) "+ 146 175 "git-pages {-print-config-env-vars|-print-config}\n") 147 - fmt.Fprintf(os.Stderr, "(cli) "+ 176 + fmt.Fprintf(os.Stderr, "(debug) "+ 177 + "git-pages {-list-blobs|-list-manifests}\n") 178 + fmt.Fprintf(os.Stderr, "(debug) "+ 148 179 "git-pages {-get-blob|-get-manifest|-get-archive|-update-site} <ref> [file]\n") 180 + fmt.Fprintf(os.Stderr, "(admin) "+ 181 + "git-pages {-freeze-domain <domain>|-unfreeze-domain <domain>}\n") 182 + fmt.Fprintf(os.Stderr, "(audit) "+ 183 + "git-pages {-audit-log|-audit-read <id>|-audit-server <endpoint> <program> [args...]}\n") 184 + fmt.Fprintf(os.Stderr, "(maint) "+ 185 + "git-pages {-run-migration <name>|-trace-garbage}\n") 149 186 flag.PrintDefaults() 150 187 } 151 188 152 189 func Main() { 190 + ctx := context.Background() 191 + 153 192 flag.Usage = usage 154 - printConfigEnvVars := flag.Bool("print-config-env-vars", false, 155 - "print every recognized configuration environment variable and exit") 156 - printConfig := flag.Bool("print-config", false, 157 - "print configuration as JSON and exit") 158 193 configTomlPath := flag.String("config", "", 159 194 "load configuration from `filename` (default: 'config.toml')") 160 195 noConfig := flag.Bool("no-config", false, 161 196 "run without configuration file (configure via environment variables)") 162 - runMigration := flag.String("run-migration", "", 163 - "run a store `migration` (one of: create-domain-markers)") 197 + printConfigEnvVars := flag.Bool("print-config-env-vars", false, 198 + "print every recognized configuration environment variable and exit") 199 + printConfig := flag.Bool("print-config", false, 200 + "print configuration as JSON and exit") 201 + listBlobs := flag.Bool("list-blobs", false, 202 + "enumerate every blob with its metadata") 203 + listManifests := flag.Bool("list-manifests", false, 204 + "enumerate every manifest with its metadata") 164 205 getBlob := flag.String("get-blob", "", 165 206 "write contents of `blob` ('sha256-xxxxxxx...xxx')") 166 207 getManifest := flag.String("get-manifest", "", ··· 169 210 "write archive for `site` (either 'domain.tld' or 'domain.tld/dir') in tar format") 170 211 updateSite := flag.String("update-site", "", 171 212 "update `site` (either 'domain.tld' or 'domain.tld/dir') from archive or repository URL") 213 + freezeDomain := flag.String("freeze-domain", "", 214 + "prevent any site uploads to a given `domain`") 215 + unfreezeDomain := flag.String("unfreeze-domain", "", 216 + "allow site uploads to a `domain` again after it has been frozen") 217 + auditLog := flag.Bool("audit-log", false, 218 + "display audit log") 219 + auditRead := flag.String("audit-read", "", 220 + "extract contents of audit record `id` to files '<id>-*'") 221 + auditRollback := flag.String("audit-rollback", "", 222 + "restore site from contents of audit record `id`") 223 + auditServer := flag.String("audit-server", "", 224 + "listen for notifications on `endpoint` and spawn a process for each audit event") 225 + runMigration := flag.String("run-migration", "", 226 + "run a store `migration` (one of: create-domain-markers)") 227 + traceGarbage := flag.Bool("trace-garbage", false, 228 + "estimate total size of unreachable blobs") 172 229 flag.Parse() 173 230 174 231 var cliOperations int 175 - if *getBlob != "" { 176 - cliOperations += 1 177 - } 178 - if *getManifest != "" { 179 - cliOperations += 1 180 - } 181 - if *getArchive != "" { 182 - cliOperations += 1 232 + for _, selected := range []bool{ 233 + *listBlobs, 234 + *listManifests, 235 + *getBlob != "", 236 + *getManifest != "", 237 + *getArchive != "", 238 + *updateSite != "", 239 + *freezeDomain != "", 240 + *unfreezeDomain != "", 241 + *auditLog, 242 + *auditRead != "", 243 + *auditRollback != "", 244 + *auditServer != "", 245 + *runMigration != "", 246 + *traceGarbage, 247 + } { 248 + if selected { 249 + cliOperations++ 250 + } 183 251 } 184 252 if cliOperations > 1 { 185 - log.Fatalln("-get-blob, -get-manifest, and -get-archive are mutually exclusive") 253 + logc.Fatalln(ctx, "-list-blobs, -list-manifests, -get-blob, -get-manifest, -get-archive, "+ 254 + "-update-site, -freeze-domain, -unfreeze-domain, -audit-log, -audit-read, "+ 255 + "-audit-rollback, -audit-server, -run-migration, and -trace-garbage are "+ 256 + "mutually exclusive") 186 257 } 187 258 188 259 if *configTomlPath != "" && *noConfig { 189 - log.Fatalln("-no-config and -config are mutually exclusive") 260 + logc.Fatalln(ctx, "-no-config and -config are mutually exclusive") 190 261 } 191 262 192 263 if *printConfigEnvVars { ··· 199 270 *configTomlPath = "config.toml" 200 271 } 201 272 if config, err = Configure(*configTomlPath); err != nil { 202 - log.Fatalln("config:", err) 273 + logc.Fatalln(ctx, "config:", err) 203 274 } 204 275 205 276 if *printConfig { 206 - fmt.Println(config.DebugJSON()) 277 + fmt.Println(config.TOML()) 207 278 return 208 279 } 209 280 ··· 211 282 defer FiniObservability() 212 283 213 284 if err = errors.Join( 214 - configureFeatures(), 215 - configureMemLimit(), 216 - configureWildcards(), 285 + configureFeatures(ctx), 286 + configureMemLimit(ctx), 287 + configureWildcards(ctx), 288 + configureFallback(ctx), 289 + configureAudit(ctx), 217 290 ); err != nil { 218 - log.Fatalln(err) 291 + logc.Fatalln(ctx, err) 219 292 } 220 293 221 - switch { 222 - case *runMigration != "": 223 - if backend, err = CreateBackend(&config.Storage); err != nil { 224 - log.Fatalln(err) 294 + // The server has its own logic for creating the backend. 295 + if cliOperations > 0 { 296 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 297 + logc.Fatalln(ctx, err) 225 298 } 299 + } 226 300 227 - if err := RunMigration(context.Background(), *runMigration); err != nil { 228 - log.Fatalln(err) 301 + switch { 302 + case *listBlobs: 303 + for metadata, err := range backend.EnumerateBlobs(ctx) { 304 + if err != nil { 305 + logc.Fatalln(ctx, err) 306 + } 307 + fmt.Fprintf(color.Output, "%s %s %s\n", 308 + metadata.Name, 309 + color.HiWhiteString(metadata.LastModified.UTC().Format(time.RFC3339)), 310 + color.HiGreenString(fmt.Sprint(metadata.Size)), 311 + ) 229 312 } 230 313 231 - case *getBlob != "": 232 - if backend, err = CreateBackend(&config.Storage); err != nil { 233 - log.Fatalln(err) 314 + case *listManifests: 315 + for metadata, err := range backend.EnumerateManifests(ctx) { 316 + if err != nil { 317 + logc.Fatalln(ctx, err) 318 + } 319 + fmt.Fprintf(color.Output, "%s %s %s\n", 320 + metadata.Name, 321 + color.HiWhiteString(metadata.LastModified.UTC().Format(time.RFC3339)), 322 + color.HiGreenString(fmt.Sprint(metadata.Size)), 323 + ) 234 324 } 235 325 236 - reader, _, _, err := backend.GetBlob(context.Background(), *getBlob) 326 + case *getBlob != "": 327 + reader, _, err := backend.GetBlob(ctx, *getBlob) 237 328 if err != nil { 238 - log.Fatalln(err) 329 + logc.Fatalln(ctx, err) 239 330 } 240 331 io.Copy(fileOutputArg(), reader) 241 332 242 333 case *getManifest != "": 243 - if backend, err = CreateBackend(&config.Storage); err != nil { 244 - log.Fatalln(err) 245 - } 246 - 247 334 webRoot := webRootArg(*getManifest) 248 - manifest, _, err := backend.GetManifest(context.Background(), webRoot, GetManifestOptions{}) 335 + manifest, _, err := backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 249 336 if err != nil { 250 - log.Fatalln(err) 337 + logc.Fatalln(ctx, err) 251 338 } 252 - fmt.Fprintln(fileOutputArg(), ManifestDebugJSON(manifest)) 339 + fmt.Fprintln(fileOutputArg(), string(ManifestJSON(manifest))) 253 340 254 341 case *getArchive != "": 255 - if backend, err = CreateBackend(&config.Storage); err != nil { 256 - log.Fatalln(err) 257 - } 258 - 259 342 webRoot := webRootArg(*getArchive) 260 - manifest, manifestMtime, err := 261 - backend.GetManifest(context.Background(), webRoot, GetManifestOptions{}) 343 + manifest, metadata, err := 344 + backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 262 345 if err != nil { 263 - log.Fatalln(err) 346 + logc.Fatalln(ctx, err) 347 + } 348 + if err = CollectTar(ctx, fileOutputArg(), manifest, metadata); err != nil { 349 + logc.Fatalln(ctx, err) 264 350 } 265 - CollectTar(context.Background(), fileOutputArg(), manifest, manifestMtime) 266 351 267 352 case *updateSite != "": 268 - if backend, err = CreateBackend(&config.Storage); err != nil { 269 - log.Fatalln(err) 270 - } 353 + ctx = WithPrincipal(ctx) 354 + GetPrincipal(ctx).CliAdmin = proto.Bool(true) 271 355 272 356 if flag.NArg() != 1 { 273 - log.Fatalln("update source must be provided as the argument") 357 + logc.Fatalln(ctx, "update source must be provided as the argument") 274 358 } 275 359 276 360 sourceURL, err := url.Parse(flag.Arg(0)) 277 361 if err != nil { 278 - log.Fatalln(err) 362 + logc.Fatalln(ctx, err) 279 363 } 280 364 281 365 var result UpdateResult 282 366 if sourceURL.Scheme == "" { 283 367 file, err := os.Open(sourceURL.Path) 284 368 if err != nil { 285 - log.Fatalln(err) 369 + logc.Fatalln(ctx, err) 286 370 } 287 371 defer file.Close() 288 372 ··· 301 385 } 302 386 303 387 webRoot := webRootArg(*updateSite) 304 - result = UpdateFromArchive(context.Background(), webRoot, contentType, file) 388 + result = UpdateFromArchive(ctx, webRoot, contentType, file) 305 389 } else { 306 390 branch := "pages" 307 391 if sourceURL.Fragment != "" { ··· 309 393 } 310 394 311 395 webRoot := webRootArg(*updateSite) 312 - result = UpdateFromRepository(context.Background(), webRoot, sourceURL.String(), branch) 396 + result = UpdateFromRepository(ctx, webRoot, sourceURL.String(), branch) 313 397 } 314 398 315 399 switch result.outcome { 316 400 case UpdateError: 317 - log.Printf("error: %s\n", result.err) 401 + logc.Printf(ctx, "error: %s\n", result.err) 318 402 os.Exit(2) 319 403 case UpdateTimeout: 320 - log.Println("timeout") 404 + logc.Println(ctx, "timeout") 321 405 os.Exit(1) 322 406 case UpdateCreated: 323 - log.Println("created") 407 + logc.Println(ctx, "created") 324 408 case UpdateReplaced: 325 - log.Println("replaced") 409 + logc.Println(ctx, "replaced") 326 410 case UpdateDeleted: 327 - log.Println("deleted") 411 + logc.Println(ctx, "deleted") 328 412 case UpdateNoChange: 329 - log.Println("no-change") 413 + logc.Println(ctx, "no-change") 414 + } 415 + 416 + case *freezeDomain != "" || *unfreezeDomain != "": 417 + ctx = WithPrincipal(ctx) 418 + GetPrincipal(ctx).CliAdmin = proto.Bool(true) 419 + 420 + var domain string 421 + var freeze bool 422 + if *freezeDomain != "" { 423 + domain = *freezeDomain 424 + freeze = true 425 + } else { 426 + domain = *unfreezeDomain 427 + freeze = false 428 + } 429 + 430 + if freeze { 431 + if err = backend.FreezeDomain(ctx, domain); err != nil { 432 + logc.Fatalln(ctx, err) 433 + } 434 + logc.Println(ctx, "frozen") 435 + } else { 436 + if err = backend.UnfreezeDomain(ctx, domain); err != nil { 437 + logc.Fatalln(ctx, err) 438 + } 439 + logc.Println(ctx, "thawed") 440 + } 441 + 442 + case *auditLog: 443 + ch := make(chan *AuditRecord) 444 + ids := []AuditID{} 445 + for id, err := range backend.SearchAuditLog(ctx, SearchAuditLogOptions{}) { 446 + if err != nil { 447 + logc.Fatalln(ctx, err) 448 + } 449 + go func() { 450 + if record, err := backend.QueryAuditLog(ctx, id); err != nil { 451 + logc.Fatalln(ctx, err) 452 + } else { 453 + ch <- record 454 + } 455 + }() 456 + ids = append(ids, id) 457 + } 458 + 459 + records := map[AuditID]*AuditRecord{} 460 + for len(records) < len(ids) { 461 + record := <-ch 462 + records[record.GetAuditID()] = record 463 + } 464 + 465 + for _, id := range ids { 466 + record := records[id] 467 + fmt.Fprintf(color.Output, "%s %s %s %s %s\n", 468 + record.GetAuditID().String(), 469 + color.HiWhiteString(record.GetTimestamp().AsTime().UTC().Format(time.RFC3339)), 470 + color.HiMagentaString(record.DescribePrincipal()), 471 + color.HiGreenString(record.DescribeResource()), 472 + record.GetEvent(), 473 + ) 474 + } 475 + 476 + case *auditRead != "": 477 + id, err := ParseAuditID(*auditRead) 478 + if err != nil { 479 + logc.Fatalln(ctx, err) 480 + } 481 + 482 + record, err := backend.QueryAuditLog(ctx, id) 483 + if err != nil { 484 + logc.Fatalln(ctx, err) 485 + } 486 + 487 + if err = ExtractAuditRecord(ctx, id, record, "."); err != nil { 488 + logc.Fatalln(ctx, err) 489 + } 490 + 491 + case *auditRollback != "": 492 + ctx = WithPrincipal(ctx) 493 + GetPrincipal(ctx).CliAdmin = proto.Bool(true) 494 + 495 + id, err := ParseAuditID(*auditRollback) 496 + if err != nil { 497 + logc.Fatalln(ctx, err) 498 + } 499 + 500 + record, err := backend.QueryAuditLog(ctx, id) 501 + if err != nil { 502 + logc.Fatalln(ctx, err) 503 + } 504 + 505 + if record.GetManifest() == nil || record.GetDomain() == "" || record.GetProject() == "" { 506 + logc.Fatalln(ctx, "no manifest in audit record") 507 + } 508 + 509 + webRoot := path.Join(record.GetDomain(), record.GetProject()) 510 + err = backend.StageManifest(ctx, record.GetManifest()) 511 + if err != nil { 512 + logc.Fatalln(ctx, err) 513 + } 514 + err = backend.CommitManifest(ctx, webRoot, record.GetManifest(), ModifyManifestOptions{}) 515 + if err != nil { 516 + logc.Fatalln(ctx, err) 517 + } 518 + 519 + case *auditServer != "": 520 + if flag.NArg() < 1 { 521 + logc.Fatalln(ctx, "handler path not provided") 522 + } 523 + 524 + processor, err := AuditEventProcessor(flag.Arg(0), flag.Args()[1:]) 525 + if err != nil { 526 + logc.Fatalln(ctx, err) 527 + } 528 + 529 + serve(ctx, listen(ctx, "audit", *auditServer), ObserveHTTPHandler(processor)) 530 + 531 + case *runMigration != "": 532 + if err = RunMigration(ctx, *runMigration); err != nil { 533 + logc.Fatalln(ctx, err) 534 + } 535 + 536 + case *traceGarbage: 537 + if err = TraceGarbage(ctx); err != nil { 538 + logc.Fatalln(ctx, err) 330 539 } 331 540 332 541 default: ··· 339 548 // The backend is not recreated (this is intentional as it allows preserving the cache). 340 549 OnReload(func() { 341 550 if newConfig, err := Configure(*configTomlPath); err != nil { 342 - log.Println("config: reload err:", err) 551 + logc.Println(ctx, "config: reload err:", err) 343 552 } else { 344 553 // From https://go.dev/ref/mem: 345 554 // > A read r of a memory location x holding a value that is not larger than ··· 349 558 // > concurrent write. 350 559 config = newConfig 351 560 if err = errors.Join( 352 - configureFeatures(), 353 - configureMemLimit(), 354 - configureWildcards(), 561 + configureFeatures(ctx), 562 + configureMemLimit(ctx), 563 + configureWildcards(ctx), 564 + configureFallback(ctx), 355 565 ); err != nil { 356 566 // At this point the configuration is in an in-between, corrupted state, so 357 567 // the only reasonable choice is to crash. 358 - log.Fatalln("config: reload fail:", err) 568 + logc.Fatalln(ctx, "config: reload fail:", err) 359 569 } else { 360 - log.Println("config: reload ok") 570 + logc.Println(ctx, "config: reload ok") 361 571 } 362 572 } 363 573 }) ··· 366 576 // spends some time initializing (which the S3 backend does) a proxy like Caddy can race 367 577 // with git-pages on startup and return errors for requests that would have been served 368 578 // just 0.5s later. 369 - pagesListener := listen("pages", config.Server.Pages) 370 - caddyListener := listen("caddy", config.Server.Caddy) 371 - metricsListener := listen("metrics", config.Server.Metrics) 579 + pagesListener := listen(ctx, "pages", config.Server.Pages) 580 + caddyListener := listen(ctx, "caddy", config.Server.Caddy) 581 + metricsListener := listen(ctx, "metrics", config.Server.Metrics) 372 582 373 - if backend, err = CreateBackend(&config.Storage); err != nil { 374 - log.Fatalln(err) 583 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 584 + logc.Fatalln(ctx, err) 375 585 } 376 586 backend = NewObservedBackend(backend) 377 587 378 - go serve(pagesListener, ObserveHTTPHandler(http.HandlerFunc(ServePages))) 379 - go serve(caddyListener, ObserveHTTPHandler(http.HandlerFunc(ServeCaddy))) 380 - go serve(metricsListener, promhttp.Handler()) 588 + middleware := chainHTTPMiddleware( 589 + panicHandler, 590 + remoteAddrMiddleware, 591 + ObserveHTTPHandler, 592 + ) 593 + go serve(ctx, pagesListener, middleware(http.HandlerFunc(ServePages))) 594 + go serve(ctx, caddyListener, middleware(http.HandlerFunc(ServeCaddy))) 595 + go serve(ctx, metricsListener, promhttp.Handler()) 381 596 382 597 if config.Insecure { 383 - log.Println("serve: ready (INSECURE)") 598 + logc.Println(ctx, "serve: ready (INSECURE)") 384 599 } else { 385 - log.Println("serve: ready") 600 + logc.Println(ctx, "serve: ready") 386 601 } 387 - select {} 602 + 603 + WaitForInterrupt() 604 + logc.Println(ctx, "serve: exiting") 388 605 } 389 606 }
+150 -53
src/manifest.go
··· 8 8 "crypto/sha256" 9 9 "errors" 10 10 "fmt" 11 - "log" 12 11 "mime" 13 12 "net/http" 14 13 "path" ··· 18 17 "time" 19 18 20 19 "github.com/c2h5oh/datasize" 20 + "github.com/go-git/go-git/v6/plumbing" 21 + format "github.com/go-git/go-git/v6/plumbing/format/config" 21 22 "github.com/klauspost/compress/zstd" 22 23 "github.com/prometheus/client_golang/prometheus" 23 24 "github.com/prometheus/client_golang/prometheus/promauto" ··· 37 38 }) 38 39 ) 39 40 41 + func NewManifest() *Manifest { 42 + return &Manifest{ 43 + Contents: map[string]*Entry{ 44 + "": {Type: Type_Directory.Enum()}, 45 + }, 46 + } 47 + } 48 + 40 49 func IsManifestEmpty(manifest *Manifest) bool { 41 50 if len(manifest.Contents) > 1 { 42 51 return false ··· 69 78 return true 70 79 } 71 80 72 - func EncodeManifest(manifest *Manifest) []byte { 73 - result, err := proto.MarshalOptions{Deterministic: true}.Marshal(manifest) 81 + func EncodeManifest(manifest *Manifest) (data []byte) { 82 + data, err := proto.MarshalOptions{Deterministic: true}.Marshal(manifest) 74 83 if err != nil { 75 84 panic(err) 76 85 } 77 - return result 86 + return 87 + } 88 + 89 + func DecodeManifest(data []byte) (manifest *Manifest, err error) { 90 + manifest = &Manifest{} 91 + err = proto.Unmarshal(data, manifest) 92 + return 93 + } 94 + 95 + func NewManifestEntry(type_ Type, data []byte) *Entry { 96 + entry := &Entry{} 97 + entry.Type = type_.Enum() 98 + if data != nil { 99 + entry.Data = data 100 + entry.Transform = Transform_Identity.Enum() 101 + entry.OriginalSize = proto.Int64(int64(len(data))) 102 + entry.CompressedSize = proto.Int64(int64(len(data))) 103 + } 104 + return entry 78 105 } 79 106 80 - func DecodeManifest(data []byte) (*Manifest, error) { 81 - manifest := Manifest{} 82 - err := proto.Unmarshal(data, &manifest) 83 - return &manifest, err 107 + func AddFile(manifest *Manifest, fileName string, data []byte) *Entry { 108 + // Fill in `git_hash` even for files not originating from git using the SHA256 algorithm; 109 + // we use this primarily for incremental archive uploads, but when support for git SHA256 110 + // repositories is complete, archive uploads and git checkouts will have cross-support for 111 + // incremental updates. 112 + hasher := plumbing.NewHasher(format.SHA256, plumbing.BlobObject, int64(len(data))) 113 + hasher.Write(data) 114 + entry := NewManifestEntry(Type_InlineFile, data) 115 + entry.GitHash = proto.String(hasher.Sum().String()) 116 + manifest.Contents[fileName] = entry 117 + return entry 84 118 } 85 119 86 - func AddProblem(manifest *Manifest, path, format string, args ...any) error { 120 + func AddSymlink(manifest *Manifest, fileName string, target string) *Entry { 121 + if path.IsAbs(target) { 122 + AddProblem(manifest, fileName, "absolute symlink: %s", target) 123 + return nil 124 + } else { 125 + entry := NewManifestEntry(Type_Symlink, []byte(target)) 126 + manifest.Contents[fileName] = entry 127 + return entry 128 + } 129 + } 130 + 131 + func AddDirectory(manifest *Manifest, dirName string) *Entry { 132 + dirName = strings.TrimSuffix(dirName, "/") 133 + entry := NewManifestEntry(Type_Directory, nil) 134 + manifest.Contents[dirName] = entry 135 + return entry 136 + } 137 + 138 + func AddProblem(manifest *Manifest, pathName, format string, args ...any) error { 87 139 cause := fmt.Sprintf(format, args...) 88 140 manifest.Problems = append(manifest.Problems, &Problem{ 89 - Path: proto.String(path), 141 + Path: proto.String(pathName), 90 142 Cause: proto.String(cause), 91 143 }) 92 - return fmt.Errorf("%s: %s", path, cause) 144 + return fmt.Errorf("%s: %s", pathName, cause) 145 + } 146 + 147 + // EnsureLeadingDirectories adds directory entries for any parent directories 148 + // that are implicitly referenced by files in the manifest but don't have 149 + // explicit directory entries. (This can be the case if an archive is created 150 + // via globs rather than including a whole directory.) 151 + func EnsureLeadingDirectories(manifest *Manifest) { 152 + for name := range manifest.Contents { 153 + for dir := path.Dir(name); dir != "." && dir != ""; dir = path.Dir(dir) { 154 + if _, exists := manifest.Contents[dir]; !exists { 155 + AddDirectory(manifest, dir) 156 + } 157 + } 158 + } 93 159 } 94 160 95 161 func GetProblemReport(manifest *Manifest) []string { 96 162 var report []string 97 163 for _, problem := range manifest.Problems { 98 164 report = append(report, 99 - fmt.Sprintf("%s: %s", problem.GetPath(), problem.GetCause())) 165 + fmt.Sprintf("/%s: %s", problem.GetPath(), problem.GetCause())) 100 166 } 101 167 return report 102 168 } 103 169 104 - func ManifestDebugJSON(manifest *Manifest) string { 105 - result, err := protojson.MarshalOptions{ 170 + func ManifestJSON(manifest *Manifest) []byte { 171 + json, err := protojson.MarshalOptions{ 106 172 Multiline: true, 107 173 EmitDefaultValues: true, 108 174 }.Marshal(manifest) 109 175 if err != nil { 110 176 panic(err) 111 177 } 112 - return string(result) 178 + return json 113 179 } 114 180 115 181 var ErrSymlinkLoop = errors.New("symbolic link loop") ··· 145 211 for path, entry := range manifest.Contents { 146 212 if entry.GetType() == Type_Directory || entry.GetType() == Type_Symlink { 147 213 // no Content-Type 148 - } else if entry.GetType() == Type_InlineFile && entry.GetTransform() == Transform_None { 214 + } else if entry.GetType() == Type_InlineFile && entry.GetTransform() == Transform_Identity { 149 215 contentType := mime.TypeByExtension(filepath.Ext(path)) 150 216 if contentType == "" { 151 - contentType = http.DetectContentType(entry.Data[:512]) 217 + contentType = http.DetectContentType(entry.Data[:min(512, len(entry.Data))]) 152 218 } 153 219 entry.ContentType = proto.String(contentType) 154 - } else { 220 + } else if entry.GetContentType() == "" { 155 221 panic(fmt.Errorf("DetectContentType encountered invalid entry: %v, %v", 156 222 entry.GetType(), entry.GetTransform())) 157 223 } 158 224 } 159 225 } 160 226 161 - // The `clauspost/compress/zstd` package recommends reusing a compressor to avoid repeated 227 + // The `klauspost/compress/zstd` package recommends reusing a compressor to avoid repeated 162 228 // allocations of internal buffers. 163 229 var zstdEncoder, _ = zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedBetterCompression)) 164 230 ··· 167 233 span, _ := ObserveFunction(ctx, "CompressFiles") 168 234 defer span.Finish() 169 235 170 - var originalSize, compressedSize int64 236 + var originalSize int64 237 + var compressedSize int64 171 238 for _, entry := range manifest.Contents { 172 - if entry.GetType() == Type_InlineFile && entry.GetTransform() == Transform_None { 173 - mtype := getMediaType(entry.GetContentType()) 174 - if strings.HasPrefix(mtype, "video/") || strings.HasPrefix(mtype, "audio/") { 239 + if entry.GetType() == Type_InlineFile && entry.GetTransform() == Transform_Identity { 240 + mediaType := getMediaType(entry.GetContentType()) 241 + if strings.HasPrefix(mediaType, "video/") || strings.HasPrefix(mediaType, "audio/") { 175 242 continue 176 243 } 177 - originalSize += entry.GetSize() 178 - compressedData := zstdEncoder.EncodeAll(entry.GetData(), make([]byte, 0, entry.GetSize())) 179 - if len(compressedData) < int(*entry.Size) { 244 + compressedData := zstdEncoder.EncodeAll(entry.GetData(), 245 + make([]byte, 0, entry.GetOriginalSize())) 246 + if int64(len(compressedData)) < entry.GetOriginalSize() { 180 247 entry.Data = compressedData 181 - entry.Size = proto.Int64(int64(len(entry.Data))) 182 - entry.Transform = Transform_Zstandard.Enum() 248 + entry.Transform = Transform_Zstd.Enum() 249 + entry.CompressedSize = proto.Int64(int64(len(entry.Data))) 183 250 } 184 - compressedSize += entry.GetSize() 185 251 } 252 + originalSize += entry.GetOriginalSize() 253 + compressedSize += entry.GetCompressedSize() 186 254 } 187 255 manifest.OriginalSize = proto.Int64(originalSize) 188 256 manifest.CompressedSize = proto.Int64(compressedSize) 189 257 190 258 if originalSize != 0 { 191 259 spaceSaving := (float64(originalSize) - float64(compressedSize)) / float64(originalSize) 192 - log.Printf("compress: saved %.2f percent (%s to %s)", 260 + logc.Printf(ctx, "compress: saved %.2f percent (%s to %s)", 193 261 spaceSaving*100.0, 194 262 datasize.ByteSize(originalSize).HR(), 195 263 datasize.ByteSize(compressedSize).HR(), ··· 203 271 // At the moment, there isn't a good way to report errors except to log them on the terminal. 204 272 // (Perhaps in the future they could be exposed at `.git-pages/status.txt`?) 205 273 func PrepareManifest(ctx context.Context, manifest *Manifest) error { 206 - // Parse Netlify-style `_redirects` 274 + // Parse Netlify-style `_redirects`. 207 275 if err := ProcessRedirectsFile(manifest); err != nil { 208 - log.Printf("redirects err: %s\n", err) 276 + logc.Printf(ctx, "redirects err: %s\n", err) 209 277 } else if len(manifest.Redirects) > 0 { 210 - log.Printf("redirects ok: %d rules\n", len(manifest.Redirects)) 278 + logc.Printf(ctx, "redirects ok: %d rules\n", len(manifest.Redirects)) 211 279 } 212 280 213 - // Parse Netlify-style `_headers` 281 + // Check if any redirects are unreachable. 282 + LintRedirects(manifest) 283 + 284 + // Parse Netlify-style `_headers`. 214 285 if err := ProcessHeadersFile(manifest); err != nil { 215 - log.Printf("headers err: %s\n", err) 286 + logc.Printf(ctx, "headers err: %s\n", err) 216 287 } else if len(manifest.Headers) > 0 { 217 - log.Printf("headers ok: %d rules\n", len(manifest.Headers)) 288 + logc.Printf(ctx, "headers ok: %d rules\n", len(manifest.Headers)) 218 289 } 219 290 220 - // Sniff content type like `http.ServeContent` 291 + // Sniff content type like `http.ServeContent`. 221 292 DetectContentType(manifest) 222 293 223 - // Opportunistically compress blobs (must be done last) 294 + // Opportunistically compress blobs (must be done last). 224 295 CompressFiles(ctx, manifest) 225 296 226 297 return nil 227 298 } 228 299 300 + var ErrSiteTooLarge = errors.New("site too large") 229 301 var ErrManifestTooLarge = errors.New("manifest too large") 230 302 231 303 // Uploads inline file data over certain size to the storage backend. Returns a copy of 232 304 // the manifest updated to refer to an external content-addressable store. 233 - func StoreManifest(ctx context.Context, name string, manifest *Manifest) (*Manifest, error) { 305 + func StoreManifest( 306 + ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions, 307 + ) (*Manifest, error) { 234 308 span, ctx := ObserveFunction(ctx, "StoreManifest", "manifest.name", name) 235 309 defer span.Finish() 236 310 ··· 247 321 CompressedSize: manifest.CompressedSize, 248 322 StoredSize: proto.Int64(0), 249 323 } 250 - extObjectSizes := make(map[string]int64) 251 324 for name, entry := range manifest.Contents { 252 325 cannotBeInlined := entry.GetType() == Type_InlineFile && 253 - entry.GetSize() > int64(config.Limits.MaxInlineFileSize.Bytes()) 326 + entry.GetCompressedSize() > int64(config.Limits.MaxInlineFileSize.Bytes()) 254 327 if cannotBeInlined { 255 328 dataHash := sha256.Sum256(entry.Data) 256 329 extManifest.Contents[name] = &Entry{ 257 - Type: Type_ExternalFile.Enum(), 258 - Size: entry.Size, 259 - Data: fmt.Appendf(nil, "sha256-%x", dataHash), 260 - Transform: entry.Transform, 261 - ContentType: entry.ContentType, 330 + Type: Type_ExternalFile.Enum(), 331 + OriginalSize: entry.OriginalSize, 332 + CompressedSize: entry.CompressedSize, 333 + Data: fmt.Appendf(nil, "sha256-%x", dataHash), 334 + Transform: entry.Transform, 335 + ContentType: entry.ContentType, 336 + GitHash: entry.GitHash, 262 337 } 263 - extObjectSizes[string(dataHash[:])] = entry.GetSize() 264 338 } else { 265 339 extManifest.Contents[name] = entry 266 340 } 267 341 } 268 - // `extObjectMap` stores size once per object, deduplicating it 269 - for _, storedSize := range extObjectSizes { 270 - *extManifest.StoredSize += storedSize 342 + 343 + // Compute the total and deduplicated storage size. 344 + totalSize := int64(0) 345 + blobSizes := map[string]int64{} 346 + for _, entry := range extManifest.Contents { 347 + totalSize += entry.GetOriginalSize() 348 + if entry.GetType() == Type_ExternalFile { 349 + blobSizes[string(entry.Data)] = entry.GetCompressedSize() 350 + } 351 + } 352 + if uint64(totalSize) > config.Limits.MaxSiteSize.Bytes() { 353 + return nil, fmt.Errorf("%w: contents size %s exceeds %s limit", 354 + ErrSiteTooLarge, 355 + datasize.ByteSize(totalSize).HR(), 356 + config.Limits.MaxSiteSize.HR(), 357 + ) 358 + } 359 + for _, blobSize := range blobSizes { 360 + *extManifest.StoredSize += blobSize 271 361 } 272 362 273 363 // Upload the resulting manifest and the blob it references. ··· 287 377 wg := sync.WaitGroup{} 288 378 ch := make(chan error, len(extManifest.Contents)) 289 379 for name, entry := range extManifest.Contents { 290 - if entry.GetType() == Type_ExternalFile { 380 + // Upload external entries (those that were decided as ineligible for being stored inline). 381 + // If the entry in the original manifest is already an external reference, there's no need 382 + // to externalize it (and no way for us to do so, since the entry only contains the blob name). 383 + if entry.GetType() == Type_ExternalFile && manifest.Contents[name].GetType() == Type_InlineFile { 291 384 wg.Go(func() { 292 385 err := backend.PutBlob(ctx, string(entry.Data), manifest.Contents[name].Data) 293 386 if err != nil { ··· 302 395 return nil, err // currently ignores all but 1st error 303 396 } 304 397 305 - if err := backend.CommitManifest(ctx, name, &extManifest); err != nil { 306 - return nil, fmt.Errorf("commit manifest: %w", err) 398 + if err := backend.CommitManifest(ctx, name, &extManifest, opts); err != nil { 399 + if errors.Is(err, ErrDomainFrozen) { 400 + return nil, err 401 + } else { 402 + return nil, fmt.Errorf("commit manifest: %w", err) 403 + } 307 404 } 308 405 309 406 return &extManifest, nil
+10 -8
src/migrate.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "log" 7 6 "slices" 8 7 "strings" 9 8 ) ··· 19 18 20 19 func createDomainMarkers(ctx context.Context) error { 21 20 if backend.HasFeature(ctx, FeatureCheckDomainMarker) { 22 - log.Print("store already has domain markers") 21 + logc.Print(ctx, "store already has domain markers") 23 22 return nil 24 23 } 25 24 26 - var manifests, domains []string 27 - manifests, err := backend.ListManifests(ctx) 28 - if err != nil { 29 - return fmt.Errorf("list manifests: %w", err) 25 + var manifests []string 26 + for metadata, err := range backend.EnumerateManifests(ctx) { 27 + if err != nil { 28 + return fmt.Errorf("enum manifests: %w", err) 29 + } 30 + manifests = append(manifests, metadata.Name) 30 31 } 31 32 slices.Sort(manifests) 33 + var domains []string 32 34 for _, manifest := range manifests { 33 35 domain, _, _ := strings.Cut(manifest, "/") 34 36 if len(domains) == 0 || domains[len(domains)-1] != domain { ··· 36 38 } 37 39 } 38 40 for idx, domain := range domains { 39 - log.Printf("(%d / %d) creating domain %s", idx+1, len(domains), domain) 41 + logc.Printf(ctx, "(%d / %d) creating domain %s", idx+1, len(domains), domain) 40 42 if err := backend.CreateDomain(ctx, domain); err != nil { 41 43 return fmt.Errorf("creating domain %s: %w", domain, err) 42 44 } ··· 44 46 if err := backend.EnableFeature(ctx, FeatureCheckDomainMarker); err != nil { 45 47 return err 46 48 } 47 - log.Printf("created markers for %d domains", len(domains)) 49 + logc.Printf(ctx, "created markers for %d domains", len(domains)) 48 50 return nil 49 51 }
+176 -46
src/observe.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "io" 8 + "iter" 8 9 "log" 9 10 "log/slog" 10 11 "math/rand/v2" ··· 12 13 "os" 13 14 "runtime/debug" 14 15 "strconv" 16 + "sync" 15 17 "time" 16 18 17 19 slogmulti "github.com/samber/slog-multi" 20 + 21 + syslog "codeberg.org/git-pages/go-slog-syslog" 18 22 19 23 "github.com/prometheus/client_golang/prometheus" 20 24 "github.com/prometheus/client_golang/prometheus/promauto" ··· 41 45 }, []string{"method"}) 42 46 ) 43 47 48 + var syslogHandler syslog.Handler 49 + 44 50 func hasSentry() bool { 45 51 return os.Getenv("SENTRY_DSN") != "" 46 52 } 47 53 54 + func chainSentryMiddleware( 55 + middleware ...func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event, 56 + ) func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { 57 + return func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { 58 + for idx := 0; idx < len(middleware) && event != nil; idx++ { 59 + event = middleware[idx](event, hint) 60 + } 61 + return event 62 + } 63 + } 64 + 65 + // sensitiveHTTPHeaders extends the list of sensitive headers defined in the Sentry Go SDK with our 66 + // own application-specific header field names. 67 + var sensitiveHTTPHeaders = map[string]struct{}{ 68 + "Forge-Authorization": {}, 69 + } 70 + 71 + // scrubSentryEvent removes sensitive HTTP header fields from the Sentry event. 72 + func scrubSentryEvent(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { 73 + if event.Request != nil && event.Request.Headers != nil { 74 + for key := range event.Request.Headers { 75 + if _, ok := sensitiveHTTPHeaders[key]; ok { 76 + delete(event.Request.Headers, key) 77 + } 78 + } 79 + } 80 + return event 81 + } 82 + 83 + // sampleSentryEvent returns a function that discards a Sentry event according to the sample rate, 84 + // unless the associated HTTP request triggers a mutation or it took too long to produce a response, 85 + // in which case the event is never discarded. 86 + func sampleSentryEvent(sampleRate float64) func(*sentry.Event, *sentry.EventHint) *sentry.Event { 87 + return func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { 88 + newSampleRate := sampleRate 89 + if event.Request != nil { 90 + switch event.Request.Method { 91 + case "PUT", "POST", "DELETE": 92 + newSampleRate = 1 93 + } 94 + } 95 + duration := event.Timestamp.Sub(event.StartTime) 96 + threshold := time.Duration(config.Observability.SlowResponseThreshold) 97 + if duration >= threshold { 98 + newSampleRate = 1 99 + } 100 + if rand.Float64() < newSampleRate { 101 + return event 102 + } 103 + return nil 104 + } 105 + } 106 + 48 107 func InitObservability() { 49 108 debug.SetPanicOnFault(true) 50 109 ··· 68 127 log.Println("unknown log format", config.LogFormat) 69 128 } 70 129 130 + if syslogAddr := os.Getenv("SYSLOG_ADDR"); syslogAddr != "" { 131 + var err error 132 + syslogHandler, err = syslog.NewHandler(&syslog.HandlerOptions{ 133 + Address: syslogAddr, 134 + AppName: "git-pages", 135 + StructuredDataID: "git-pages", 136 + }) 137 + if err != nil { 138 + log.Fatalf("syslog: %v", err) 139 + } 140 + logHandlers = append(logHandlers, syslogHandler) 141 + } 142 + 71 143 if hasSentry() { 72 144 enableLogs := false 73 145 if value, err := strconv.ParseBool(os.Getenv("SENTRY_LOGS")); err == nil { ··· 79 151 enableTracing = value 80 152 } 81 153 154 + tracesSampleRate := 1.00 155 + switch environment { 156 + case "development", "staging": 157 + default: 158 + tracesSampleRate = 0.05 159 + } 160 + 82 161 options := sentry.ClientOptions{} 162 + options.DisableTelemetryBuffer = !config.Feature("sentry-telemetry-buffer") 83 163 options.Environment = environment 84 164 options.EnableLogs = enableLogs 85 165 options.EnableTracing = enableTracing 86 - options.TracesSampleRate = 1 87 - switch environment { 88 - case "development", "staging": 89 - default: 90 - options.BeforeSendTransaction = func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { 91 - sampleRate := 0.05 92 - if trace, ok := event.Contexts["trace"]; ok { 93 - if data, ok := trace["data"].(map[string]any); ok { 94 - if method, ok := data["http.request.method"].(string); ok { 95 - switch method { 96 - case "PUT", "DELETE", "POST": 97 - sampleRate = 1 98 - default: 99 - duration := event.Timestamp.Sub(event.StartTime) 100 - threshold := time.Duration(config.Observability.SlowResponseThreshold) 101 - if duration >= threshold { 102 - sampleRate = 1 103 - } 104 - } 105 - } 106 - } 107 - } 108 - if rand.Float64() < sampleRate { 109 - return event 110 - } 111 - return nil 112 - } 113 - } 166 + options.TracesSampleRate = 1 // use our own custom sampling logic 167 + options.BeforeSend = scrubSentryEvent 168 + options.BeforeSendTransaction = chainSentryMiddleware( 169 + sampleSentryEvent(tracesSampleRate), 170 + scrubSentryEvent, 171 + ) 114 172 if err := sentry.Init(options); err != nil { 115 173 log.Fatalf("sentry: %s\n", err) 116 174 } ··· 126 184 } 127 185 128 186 func FiniObservability() { 187 + var wg sync.WaitGroup 188 + timeout := 2 * time.Second 189 + if syslogHandler != nil { 190 + wg.Go(func() { syslogHandler.Flush(timeout) }) 191 + } 129 192 if hasSentry() { 130 - sentry.Flush(2 * time.Second) 193 + wg.Go(func() { sentry.Flush(timeout) }) 131 194 } 195 + wg.Wait() 132 196 } 133 197 134 198 func ObserveError(err error) { ··· 290 354 func (backend *observedBackend) GetBlob( 291 355 ctx context.Context, name string, 292 356 ) ( 293 - reader io.ReadSeeker, size uint64, mtime time.Time, err error, 357 + reader io.ReadSeeker, metadata BlobMetadata, err error, 294 358 ) { 295 359 span, ctx := ObserveFunction(ctx, "GetBlob", "blob.name", name) 296 - if reader, size, mtime, err = backend.inner.GetBlob(ctx, name); err == nil { 297 - ObserveData(ctx, "blob.size", size) 360 + if reader, metadata, err = backend.inner.GetBlob(ctx, name); err == nil { 361 + ObserveData(ctx, "blob.size", metadata.Size) 298 362 blobsRetrievedCount.Inc() 299 - blobsRetrievedBytes.Add(float64(size)) 363 + blobsRetrievedBytes.Add(float64(metadata.Size)) 300 364 } 301 365 span.Finish() 302 366 return ··· 319 383 return 320 384 } 321 385 322 - func (backend *observedBackend) ListManifests(ctx context.Context) (manifests []string, err error) { 323 - span, ctx := ObserveFunction(ctx, "ListManifests") 324 - manifests, err = backend.inner.ListManifests(ctx) 325 - span.Finish() 326 - return 386 + func (backend *observedBackend) EnumerateBlobs(ctx context.Context) iter.Seq2[BlobMetadata, error] { 387 + return func(yield func(BlobMetadata, error) bool) { 388 + span, ctx := ObserveFunction(ctx, "EnumerateBlobs") 389 + for metadata, err := range backend.inner.EnumerateBlobs(ctx) { 390 + if !yield(metadata, err) { 391 + break 392 + } 393 + } 394 + span.Finish() 395 + } 327 396 } 328 397 329 398 func (backend *observedBackend) GetManifest( 330 399 ctx context.Context, name string, opts GetManifestOptions, 331 400 ) ( 332 - manifest *Manifest, mtime time.Time, err error, 401 + manifest *Manifest, metadata ManifestMetadata, err error, 333 402 ) { 334 403 span, ctx := ObserveFunction(ctx, "GetManifest", 335 404 "manifest.name", name, 336 405 "manifest.bypass_cache", opts.BypassCache, 337 406 ) 338 - if manifest, mtime, err = backend.inner.GetManifest(ctx, name, opts); err == nil { 407 + if manifest, metadata, err = backend.inner.GetManifest(ctx, name, opts); err == nil { 339 408 manifestsRetrievedCount.Inc() 340 409 } 341 410 span.Finish() ··· 349 418 return 350 419 } 351 420 352 - func (backend *observedBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest) (err error) { 421 + func (backend *observedBackend) HasAtomicCAS(ctx context.Context) bool { 422 + return backend.inner.HasAtomicCAS(ctx) 423 + } 424 + 425 + func (backend *observedBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions) (err error) { 353 426 span, ctx := ObserveFunction(ctx, "CommitManifest", "manifest.name", name) 354 - err = backend.inner.CommitManifest(ctx, name, manifest) 427 + err = backend.inner.CommitManifest(ctx, name, manifest, opts) 355 428 span.Finish() 356 429 return 357 430 } 358 431 359 - func (backend *observedBackend) DeleteManifest(ctx context.Context, name string) (err error) { 432 + func (backend *observedBackend) DeleteManifest(ctx context.Context, name string, opts ModifyManifestOptions) (err error) { 360 433 span, ctx := ObserveFunction(ctx, "DeleteManifest", "manifest.name", name) 361 - err = backend.inner.DeleteManifest(ctx, name) 434 + err = backend.inner.DeleteManifest(ctx, name, opts) 362 435 span.Finish() 363 436 return 364 437 } 365 438 439 + func (backend *observedBackend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] { 440 + return func(yield func(ManifestMetadata, error) bool) { 441 + span, ctx := ObserveFunction(ctx, "EnumerateManifests") 442 + for metadata, err := range backend.inner.EnumerateManifests(ctx) { 443 + if !yield(metadata, err) { 444 + break 445 + } 446 + } 447 + span.Finish() 448 + } 449 + } 450 + 366 451 func (backend *observedBackend) CheckDomain(ctx context.Context, domain string) (found bool, err error) { 367 - span, ctx := ObserveFunction(ctx, "CheckDomain", "manifest.domain", domain) 452 + span, ctx := ObserveFunction(ctx, "CheckDomain", "domain.name", domain) 368 453 found, err = backend.inner.CheckDomain(ctx, domain) 369 454 span.Finish() 370 455 return 371 456 } 372 457 373 458 func (backend *observedBackend) CreateDomain(ctx context.Context, domain string) (err error) { 374 - span, ctx := ObserveFunction(ctx, "CreateDomain", "manifest.domain", domain) 459 + span, ctx := ObserveFunction(ctx, "CreateDomain", "domain.name", domain) 375 460 err = backend.inner.CreateDomain(ctx, domain) 376 461 span.Finish() 377 462 return 378 463 } 464 + 465 + func (backend *observedBackend) FreezeDomain(ctx context.Context, domain string) (err error) { 466 + span, ctx := ObserveFunction(ctx, "FreezeDomain", "domain.name", domain) 467 + err = backend.inner.FreezeDomain(ctx, domain) 468 + span.Finish() 469 + return 470 + } 471 + 472 + func (backend *observedBackend) UnfreezeDomain(ctx context.Context, domain string) (err error) { 473 + span, ctx := ObserveFunction(ctx, "UnfreezeDomain", "domain.name", domain) 474 + err = backend.inner.UnfreezeDomain(ctx, domain) 475 + span.Finish() 476 + return 477 + } 478 + 479 + func (backend *observedBackend) AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) (err error) { 480 + span, ctx := ObserveFunction(ctx, "AppendAuditLog", "audit.id", id) 481 + err = backend.inner.AppendAuditLog(ctx, id, record) 482 + span.Finish() 483 + return 484 + } 485 + 486 + func (backend *observedBackend) QueryAuditLog(ctx context.Context, id AuditID) (record *AuditRecord, err error) { 487 + span, ctx := ObserveFunction(ctx, "QueryAuditLog", "audit.id", id) 488 + record, err = backend.inner.QueryAuditLog(ctx, id) 489 + span.Finish() 490 + return 491 + } 492 + 493 + func (backend *observedBackend) SearchAuditLog( 494 + ctx context.Context, opts SearchAuditLogOptions, 495 + ) iter.Seq2[AuditID, error] { 496 + return func(yield func(AuditID, error) bool) { 497 + span, ctx := ObserveFunction(ctx, "SearchAuditLog", 498 + "audit.search.since", opts.Since, 499 + "audit.search.until", opts.Until, 500 + ) 501 + for id, err := range backend.inner.SearchAuditLog(ctx, opts) { 502 + if !yield(id, err) { 503 + break 504 + } 505 + } 506 + span.Finish() 507 + } 508 + }
+281 -99
src/pages.go
··· 8 8 "errors" 9 9 "fmt" 10 10 "io" 11 - "log" 12 11 "maps" 13 12 "net/http" 14 13 "net/url" 15 14 "os" 16 15 "path" 16 + "slices" 17 17 "strconv" 18 18 "strings" 19 19 "time" ··· 22 22 "github.com/pquerna/cachecontrol/cacheobject" 23 23 "github.com/prometheus/client_golang/prometheus" 24 24 "github.com/prometheus/client_golang/prometheus/promauto" 25 + "google.golang.org/protobuf/proto" 25 26 ) 26 27 27 28 const notFoundPage = "404.html" 28 29 29 30 var ( 31 + serveEncodingCount = promauto.NewCounterVec(prometheus.CounterOpts{ 32 + Name: "git_pages_serve_encoding_count", 33 + Help: "Count of blob transform vs negotiated encoding", 34 + }, []string{"transform", "negotiated"}) 35 + 30 36 siteUpdatesCount = promauto.NewCounterVec(prometheus.CounterOpts{ 31 37 Name: "git_pages_site_updates", 32 38 Help: "Count of site updates in total", ··· 41 47 }, []string{"cause"}) 42 48 ) 43 49 44 - func reportSiteUpdate(via string, result *UpdateResult) { 50 + func observeSiteUpdate(via string, result *UpdateResult) { 45 51 siteUpdatesCount.With(prometheus.Labels{"via": via}).Inc() 46 - 47 52 switch result.outcome { 48 53 case UpdateError: 49 54 siteUpdateErrorCount.With(prometheus.Labels{"cause": "other"}).Inc() ··· 61 66 } 62 67 63 68 func makeWebRoot(host string, projectName string) string { 64 - return fmt.Sprintf("%s/%s", strings.ToLower(host), projectName) 69 + return path.Join(strings.ToLower(host), projectName) 70 + } 71 + 72 + func getWebRoot(r *http.Request) (string, error) { 73 + host, err := GetHost(r) 74 + if err != nil { 75 + return "", err 76 + } 77 + 78 + projectName, err := GetProjectName(r) 79 + if err != nil { 80 + return "", err 81 + } 82 + 83 + return makeWebRoot(host, projectName), nil 65 84 } 66 85 67 86 func writeRedirect(w http.ResponseWriter, code int, path string) { ··· 78 97 var err error 79 98 var sitePath string 80 99 var manifest *Manifest 81 - var manifestMtime time.Time 100 + var metadata ManifestMetadata 82 101 83 102 cacheControl, err := cacheobject.ParseRequestCacheControl(r.Header.Get("Cache-Control")) 84 103 if err != nil { ··· 97 116 } 98 117 99 118 type indexManifestResult struct { 100 - manifest *Manifest 101 - manifestMtime time.Time 102 - err error 119 + manifest *Manifest 120 + metadata ManifestMetadata 121 + err error 103 122 } 104 123 indexManifestCh := make(chan indexManifestResult, 1) 105 124 go func() { 106 - manifest, mtime, err := backend.GetManifest( 125 + manifest, metadata, err := backend.GetManifest( 107 126 r.Context(), makeWebRoot(host, ".index"), 108 127 GetManifestOptions{BypassCache: bypassCache}, 109 128 ) 110 - indexManifestCh <- (indexManifestResult{manifest, mtime, err}) 129 + indexManifestCh <- (indexManifestResult{manifest, metadata, err}) 111 130 }() 112 131 113 132 err = nil 114 133 sitePath = strings.TrimPrefix(r.URL.Path, "/") 115 134 if projectName, projectPath, hasProjectSlash := strings.Cut(sitePath, "/"); projectName != "" { 116 - var projectManifest *Manifest 117 - var projectManifestMtime time.Time 118 - projectManifest, projectManifestMtime, err = backend.GetManifest( 119 - r.Context(), makeWebRoot(host, projectName), 120 - GetManifestOptions{BypassCache: bypassCache}, 121 - ) 122 - if err == nil { 123 - if !hasProjectSlash { 124 - writeRedirect(w, http.StatusFound, r.URL.Path+"/") 125 - return nil 135 + if IsValidProjectName(projectName) { 136 + var projectManifest *Manifest 137 + var projectMetadata ManifestMetadata 138 + projectManifest, projectMetadata, err = backend.GetManifest( 139 + r.Context(), makeWebRoot(host, projectName), 140 + GetManifestOptions{BypassCache: bypassCache}, 141 + ) 142 + if err == nil { 143 + if !hasProjectSlash { 144 + writeRedirect(w, http.StatusFound, r.URL.Path+"/") 145 + return nil 146 + } 147 + sitePath, manifest, metadata = projectPath, projectManifest, projectMetadata 126 148 } 127 - sitePath, manifest, manifestMtime = projectPath, projectManifest, projectManifestMtime 128 149 } 129 150 } 130 151 if manifest == nil && (err == nil || errors.Is(err, ErrObjectNotFound)) { 131 152 result := <-indexManifestCh 132 - manifest, manifestMtime, err = result.manifest, result.manifestMtime, result.err 153 + manifest, metadata, err = result.manifest, result.metadata, result.err 133 154 if manifest == nil && errors.Is(err, ErrObjectNotFound) { 134 - if found, fallbackErr := HandleWildcardFallback(w, r); found { 135 - return fallbackErr 155 + if fallback != nil { 156 + logc.Printf(r.Context(), "fallback: %s via %s", host, config.Fallback.ProxyTo) 157 + fallback.ServeHTTP(w, r) 158 + return nil 136 159 } else { 137 160 w.WriteHeader(http.StatusNotFound) 138 161 fmt.Fprintf(w, "site not found\n") ··· 159 182 return nil 160 183 } 161 184 if metadataPath, found := strings.CutPrefix(sitePath, ".git-pages/"); found { 162 - lastModified := manifestMtime.UTC().Format(http.TimeFormat) 185 + lastModified := metadata.LastModified.UTC().Format(http.TimeFormat) 163 186 switch { 164 187 case metadataPath == "health": 165 188 w.Header().Add("Last-Modified", lastModified) 189 + w.Header().Add("ETag", fmt.Sprintf("\"%s\"", metadata.ETag)) 166 190 w.WriteHeader(http.StatusOK) 167 191 fmt.Fprintf(w, "ok\n") 168 192 return nil ··· 177 201 178 202 w.Header().Add("Content-Type", "application/json; charset=utf-8") 179 203 w.Header().Add("Last-Modified", lastModified) 204 + w.Header().Add("ETag", fmt.Sprintf("\"%s-manifest\"", metadata.ETag)) 180 205 w.WriteHeader(http.StatusOK) 181 - w.Write([]byte(ManifestDebugJSON(manifest))) 206 + w.Write(ManifestJSON(manifest)) 182 207 return nil 183 208 184 - case metadataPath == "archive.tar" && config.Feature("archive-site"): 209 + case metadataPath == "archive.tar": 185 210 // same as above 186 211 _, err := AuthorizeMetadataRetrieval(r) 187 212 if err != nil { ··· 190 215 191 216 // we only offer `/.git-pages/archive.tar` and not the `.tar.gz`/`.tar.zst` variants 192 217 // because HTTP can already request compression using the `Content-Encoding` mechanism 193 - acceptedEncodings := parseHTTPEncodings(r.Header.Get("Accept-Encoding")) 218 + acceptedEncodings := ParseAcceptEncodingHeader(r.Header.Get("Accept-Encoding")) 219 + w.Header().Add("Vary", "Accept-Encoding") 194 220 negotiated := acceptedEncodings.Negotiate("zstd", "gzip", "identity") 195 221 if negotiated != "" { 196 222 w.Header().Set("Content-Encoding", negotiated) 197 223 } 198 224 w.Header().Add("Content-Type", "application/x-tar") 199 225 w.Header().Add("Last-Modified", lastModified) 226 + w.Header().Add("ETag", fmt.Sprintf("\"%s-archive\"", metadata.ETag)) 200 227 w.Header().Add("Transfer-Encoding", "chunked") 201 228 w.WriteHeader(http.StatusOK) 202 229 var iow io.Writer ··· 208 235 case "zstd": 209 236 iow, _ = zstd.NewWriter(w) 210 237 } 211 - return CollectTar(r.Context(), iow, manifest, manifestMtime) 238 + return CollectTar(r.Context(), iow, manifest, metadata) 212 239 213 240 default: 214 241 w.WriteHeader(http.StatusNotFound) ··· 220 247 entryPath := sitePath 221 248 entry := (*Entry)(nil) 222 249 appliedRedirect := false 223 - status := 200 250 + status := http.StatusOK 224 251 reader := io.ReadSeeker(nil) 225 252 mtime := time.Time{} 226 253 for { ··· 234 261 entry = manifest.Contents[entryPath] 235 262 if !appliedRedirect { 236 263 redirectKind := RedirectAny 237 - if entry != nil && entry.GetType() != Type_Invalid { 264 + if entry != nil && entry.GetType() != Type_InvalidEntry { 238 265 redirectKind = RedirectForce 239 266 } 240 267 originalURL := (&url.URL{Host: r.Host}).ResolveReference(r.URL) 241 - redirectURL, redirectStatus := ApplyRedirectRules(manifest, originalURL, redirectKind) 268 + _, redirectURL, redirectStatus := ApplyRedirectRules(manifest, originalURL, redirectKind) 242 269 if Is3xxHTTPStatus(redirectStatus) { 243 270 writeRedirect(w, redirectStatus, redirectURL.String()) 244 271 return nil ··· 251 278 continue 252 279 } 253 280 } 254 - if entry == nil || entry.GetType() == Type_Invalid { 255 - status = 404 281 + if entry == nil || entry.GetType() == Type_InvalidEntry { 282 + status = http.StatusNotFound 256 283 if entryPath != notFoundPage { 257 284 entryPath = notFoundPage 258 285 continue ··· 268 295 w.WriteHeader(http.StatusNotModified) 269 296 return nil 270 297 } else { 271 - reader, _, mtime, err = backend.GetBlob(r.Context(), string(entry.Data)) 298 + var metadata BlobMetadata 299 + reader, metadata, err = backend.GetBlob(r.Context(), string(entry.Data)) 272 300 if err != nil { 273 301 ObserveError(err) // all storage errors must be reported 274 302 w.WriteHeader(http.StatusInternalServerError) 275 303 fmt.Fprintf(w, "internal server error: %s\n", err) 276 304 return err 277 305 } 306 + mtime = metadata.LastModified 278 307 w.Header().Set("ETag", etag) 279 308 } 280 309 } else if entry.GetType() == Type_Directory { ··· 297 326 defer closer.Close() 298 327 } 299 328 300 - acceptedEncodings := parseHTTPEncodings(r.Header.Get("Accept-Encoding")) 329 + var offeredEncodings []string 330 + acceptedEncodings := ParseAcceptEncodingHeader(r.Header.Get("Accept-Encoding")) 331 + w.Header().Add("Vary", "Accept-Encoding") 301 332 negotiatedEncoding := true 302 333 switch entry.GetTransform() { 303 - case Transform_None: 304 - if acceptedEncodings.Negotiate("identity") != "identity" { 334 + case Transform_Identity: 335 + offeredEncodings = []string{"identity"} 336 + switch acceptedEncodings.Negotiate(offeredEncodings...) { 337 + case "identity": 338 + serveEncodingCount. 339 + With(prometheus.Labels{"transform": "identity", "negotiated": "identity"}). 340 + Inc() 341 + default: 305 342 negotiatedEncoding = false 343 + serveEncodingCount. 344 + With(prometheus.Labels{"transform": "identity", "negotiated": "failure"}). 345 + Inc() 306 346 } 307 - case Transform_Zstandard: 308 - supported := []string{"zstd", "identity"} 347 + case Transform_Zstd: 348 + offeredEncodings = []string{"zstd", "identity"} 309 349 if entry.ContentType == nil { 310 350 // If Content-Type is unset, `http.ServeContent` will try to sniff 311 351 // the file contents. That won't work if it's compressed. 312 - supported = []string{"identity"} 352 + offeredEncodings = []string{"identity"} 313 353 } 314 - switch acceptedEncodings.Negotiate(supported...) { 354 + switch acceptedEncodings.Negotiate(offeredEncodings...) { 315 355 case "zstd": 316 356 // Set Content-Length ourselves since `http.ServeContent` only sets 317 357 // it if Content-Encoding is unset or if it's a range request. 318 - w.Header().Set("Content-Length", strconv.FormatInt(*entry.Size, 10)) 358 + w.Header().Set("Content-Length", strconv.FormatInt(entry.GetCompressedSize(), 10)) 319 359 w.Header().Set("Content-Encoding", "zstd") 360 + serveEncodingCount. 361 + With(prometheus.Labels{"transform": "zstd", "negotiated": "zstd"}). 362 + Inc() 320 363 case "identity": 321 364 compressedData, _ := io.ReadAll(reader) 322 365 decompressedData, err := zstdDecoder.DecodeAll(compressedData, []byte{}) ··· 326 369 return err 327 370 } 328 371 reader = bytes.NewReader(decompressedData) 372 + serveEncodingCount. 373 + With(prometheus.Labels{"transform": "zstd", "negotiated": "identity"}). 374 + Inc() 329 375 default: 330 376 negotiatedEncoding = false 377 + serveEncodingCount. 378 + With(prometheus.Labels{"transform": "zstd", "negotiated": "failure"}). 379 + Inc() 331 380 } 332 381 default: 333 382 return fmt.Errorf("unexpected transform") 334 383 } 335 384 if !negotiatedEncoding { 385 + w.Header().Set("Accept-Encoding", strings.Join(offeredEncodings, ", ")) 336 386 w.WriteHeader(http.StatusNotAcceptable) 337 - return fmt.Errorf("no supported content encodings (accept-encoding: %q)", 387 + return fmt.Errorf("no supported content encodings (Accept-Encoding: %s)", 338 388 r.Header.Get("Accept-Encoding")) 339 389 } 340 390 ··· 369 419 io.Copy(w, reader) 370 420 } 371 421 } else { 372 - // consider content fresh for 60 seconds (the same as the freshness interval of 373 - // manifests in the S3 backend), and use stale content anyway as long as it's not 374 - // older than a hour; while it is cheap to handle If-Modified-Since queries 375 - // server-side, on the client `max-age=0, must-revalidate` causes every resource 376 - // to block the page load every time 377 - w.Header().Set("Cache-Control", "max-age=60, stale-while-revalidate=3600") 378 - // see https://web.dev/articles/stale-while-revalidate for details 422 + if _, hasCacheControl := w.Header()["Cache-Control"]; !hasCacheControl { 423 + // consider content fresh for 60 seconds (the same as the freshness interval of 424 + // manifests in the S3 backend), and use stale content anyway as long as it's not 425 + // older than a hour; while it is cheap to handle If-Modified-Since queries 426 + // server-side, on the client `max-age=0, must-revalidate` causes every resource 427 + // to block the page load every time 428 + w.Header().Set("Cache-Control", "max-age=60, stale-while-revalidate=3600") 429 + // see https://web.dev/articles/stale-while-revalidate for details 430 + } 379 431 380 432 // http.ServeContent handles conditional requests and range requests 381 433 http.ServeContent(w, r, entryPath, mtime, reader) ··· 383 435 return nil 384 436 } 385 437 438 + func checkDryRun(w http.ResponseWriter, r *http.Request) bool { 439 + // "Dry run" requests are used to non-destructively check if the request would have 440 + // successfully been authorized. 441 + if r.Header.Get("Dry-Run") != "" { 442 + fmt.Fprintln(w, "dry-run ok") 443 + return true 444 + } 445 + return false 446 + } 447 + 386 448 func putPage(w http.ResponseWriter, r *http.Request) error { 387 449 var result UpdateResult 388 450 389 - host, err := GetHost(r) 390 - if err != nil { 391 - return err 451 + for _, header := range []string{ 452 + "If-Modified-Since", "If-Unmodified-Since", "If-Match", "If-None-Match", 453 + } { 454 + if r.Header.Get(header) != "" { 455 + http.Error(w, fmt.Sprintf("unsupported precondition %s", header), http.StatusBadRequest) 456 + return nil 457 + } 392 458 } 393 459 394 - projectName, err := GetProjectName(r) 460 + webRoot, err := getWebRoot(r) 395 461 if err != nil { 396 462 return err 397 463 } 398 464 399 - webRoot := makeWebRoot(host, projectName) 400 - 401 - updateCtx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 465 + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 402 466 defer cancel() 403 467 404 468 contentType := getMediaType(r.Header.Get("Content-Type")) 405 - 406 - if contentType == "application/x-www-form-urlencoded" { 469 + switch contentType { 470 + case "application/x-www-form-urlencoded": 407 471 auth, err := AuthorizeUpdateFromRepository(r) 408 472 if err != nil { 409 473 return err ··· 428 492 return err 429 493 } 430 494 431 - result = UpdateFromRepository(updateCtx, webRoot, repoURL, branch) 432 - } else { 495 + if checkDryRun(w, r) { 496 + return nil 497 + } 498 + 499 + result = UpdateFromRepository(ctx, webRoot, repoURL, branch) 500 + 501 + default: 433 502 _, err := AuthorizeUpdateFromArchive(r) 434 503 if err != nil { 435 504 return err 436 505 } 437 506 507 + if checkDryRun(w, r) { 508 + return nil 509 + } 510 + 438 511 // request body contains archive 439 512 reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) 440 - result = UpdateFromArchive(updateCtx, webRoot, contentType, reader) 513 + result = UpdateFromArchive(ctx, webRoot, contentType, reader) 514 + } 515 + 516 + return reportUpdateResult(w, r, result) 517 + } 518 + 519 + func patchPage(w http.ResponseWriter, r *http.Request) error { 520 + for _, header := range []string{ 521 + "If-Modified-Since", "If-Unmodified-Since", "If-Match", "If-None-Match", 522 + } { 523 + if r.Header.Get(header) != "" { 524 + http.Error(w, fmt.Sprintf("unsupported precondition %s", header), http.StatusBadRequest) 525 + return nil 526 + } 527 + } 528 + 529 + webRoot, err := getWebRoot(r) 530 + if err != nil { 531 + return err 532 + } 533 + 534 + if _, err = AuthorizeUpdateFromArchive(r); err != nil { 535 + return err 536 + } 537 + 538 + if checkDryRun(w, r) { 539 + return nil 540 + } 541 + 542 + // Providing atomic compare-and-swap operations might be difficult or impossible depending 543 + // on the backend in use and its configuration, but for applications where a mostly-atomic 544 + // compare-and-swap operation is good enough (e.g. generating page previews) we don't want 545 + // to prevent the use of partial updates. 546 + wantAtomicCAS := r.Header.Get("Atomic") 547 + hasAtomicCAS := backend.HasAtomicCAS(r.Context()) 548 + switch { 549 + case wantAtomicCAS == "yes" && hasAtomicCAS || wantAtomicCAS == "no": 550 + // all good 551 + case wantAtomicCAS == "yes": 552 + http.Error(w, "atomic partial updates unsupported", http.StatusPreconditionFailed) 553 + return nil 554 + case wantAtomicCAS == "": 555 + http.Error(w, "must provide \"Atomic: yes|no\" header", http.StatusPreconditionRequired) 556 + return nil 557 + default: 558 + http.Error(w, "malformed Atomic: header", http.StatusBadRequest) 559 + return nil 560 + } 561 + 562 + var parents CreateParentsMode 563 + switch r.Header.Get("Create-Parents") { 564 + case "", "no": 565 + parents = RequireParents 566 + case "yes": 567 + parents = CreateParents 568 + default: 569 + http.Error(w, "malformed Create-Parents: header", http.StatusBadRequest) 570 + return nil 571 + } 572 + 573 + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(config.Limits.UpdateTimeout)) 574 + defer cancel() 575 + 576 + contentType := getMediaType(r.Header.Get("Content-Type")) 577 + reader := http.MaxBytesReader(w, r.Body, int64(config.Limits.MaxSiteSize.Bytes())) 578 + result := PartialUpdateFromArchive(ctx, webRoot, contentType, reader, parents) 579 + return reportUpdateResult(w, r, result) 580 + } 581 + 582 + func reportUpdateResult(w http.ResponseWriter, r *http.Request, result UpdateResult) error { 583 + var unresolvedRefErr UnresolvedRefError 584 + if result.outcome == UpdateError && errors.As(result.err, &unresolvedRefErr) { 585 + offeredContentTypes := []string{"text/plain", "application/vnd.git-pages.unresolved"} 586 + acceptedContentTypes := ParseAcceptHeader(r.Header.Get("Accept")) 587 + switch acceptedContentTypes.Negotiate(offeredContentTypes...) { 588 + default: 589 + w.Header().Set("Accept", strings.Join(offeredContentTypes, ", ")) 590 + w.WriteHeader(http.StatusNotAcceptable) 591 + return fmt.Errorf("no supported content types (Accept: %s)", r.Header.Get("Accept")) 592 + case "application/vnd.git-pages.unresolved": 593 + w.Header().Set("Content-Type", "application/vnd.git-pages.unresolved") 594 + w.WriteHeader(http.StatusUnprocessableEntity) 595 + for _, missingRef := range unresolvedRefErr.missing { 596 + fmt.Fprintln(w, missingRef) 597 + } 598 + return nil 599 + case "text/plain": 600 + // handled below 601 + } 441 602 } 442 603 443 604 switch result.outcome { 444 605 case UpdateError: 445 - if errors.Is(result.err, ErrManifestTooLarge) { 446 - w.WriteHeader(http.StatusRequestEntityTooLarge) 606 + if errors.Is(result.err, ErrSiteTooLarge) { 607 + w.WriteHeader(http.StatusUnprocessableEntity) 608 + } else if errors.Is(result.err, ErrManifestTooLarge) { 609 + w.WriteHeader(http.StatusUnprocessableEntity) 447 610 } else if errors.Is(result.err, errArchiveFormat) { 448 611 w.WriteHeader(http.StatusUnsupportedMediaType) 449 612 } else if errors.Is(result.err, ErrArchiveTooLarge) { 450 613 w.WriteHeader(http.StatusRequestEntityTooLarge) 614 + } else if errors.Is(result.err, ErrRepositoryTooLarge) { 615 + w.WriteHeader(http.StatusUnprocessableEntity) 616 + } else if errors.Is(result.err, ErrMalformedPatch) { 617 + w.WriteHeader(http.StatusUnprocessableEntity) 618 + } else if errors.Is(result.err, ErrPreconditionFailed) { 619 + w.WriteHeader(http.StatusPreconditionFailed) 620 + } else if errors.Is(result.err, ErrWriteConflict) { 621 + w.WriteHeader(http.StatusConflict) 622 + } else if errors.Is(result.err, ErrDomainFrozen) { 623 + w.WriteHeader(http.StatusForbidden) 624 + } else if errors.As(result.err, &unresolvedRefErr) { 625 + w.WriteHeader(http.StatusUnprocessableEntity) 451 626 } else { 452 627 w.WriteHeader(http.StatusServiceUnavailable) 453 628 } ··· 476 651 } else { 477 652 fmt.Fprintln(w, "internal error") 478 653 } 479 - reportSiteUpdate("rest", &result) 654 + observeSiteUpdate("rest", &result) 480 655 return nil 481 656 } 482 657 483 658 func deletePage(w http.ResponseWriter, r *http.Request) error { 484 - _, err := AuthorizeUpdateFromRepository(r) 659 + webRoot, err := getWebRoot(r) 485 660 if err != nil { 486 661 return err 487 662 } 488 663 489 - host, err := GetHost(r) 664 + _, err = AuthorizeUpdateFromRepository(r) 490 665 if err != nil { 491 666 return err 492 667 } 493 668 494 - projectName, err := GetProjectName(r) 495 - if err != nil { 496 - return err 669 + if checkDryRun(w, r) { 670 + return nil 497 671 } 498 672 499 - err = backend.DeleteManifest(r.Context(), makeWebRoot(host, projectName)) 500 - if err != nil { 673 + if err = backend.DeleteManifest(r.Context(), webRoot, ModifyManifestOptions{}); err != nil { 501 674 w.WriteHeader(http.StatusInternalServerError) 675 + fmt.Fprintln(w, err) 502 676 } else { 503 677 w.Header().Add("Update-Result", "deleted") 504 678 w.WriteHeader(http.StatusOK) 505 679 } 506 - if err != nil { 507 - fmt.Fprintln(w, err) 508 - } 509 680 return err 510 681 } 511 682 ··· 514 685 requestTimeout := 3 * time.Second 515 686 requestTimer := time.NewTimer(requestTimeout) 516 687 517 - auth, err := AuthorizeUpdateFromRepository(r) 518 - if err != nil { 519 - return err 520 - } 521 - 522 - host, err := GetHost(r) 688 + webRoot, err := getWebRoot(r) 523 689 if err != nil { 524 690 return err 525 691 } 526 692 527 - projectName, err := GetProjectName(r) 693 + auth, err := AuthorizeUpdateFromRepository(r) 528 694 if err != nil { 529 695 return err 530 696 } 531 697 532 - webRoot := makeWebRoot(host, projectName) 533 - 534 698 eventName := "" 535 699 for _, header := range []string{ 536 700 "X-Forgejo-Event", ··· 578 742 return err 579 743 } 580 744 581 - if event.Ref != fmt.Sprintf("refs/heads/%s", auth.branch) { 745 + if event.Ref != path.Join("refs", "heads", auth.branch) { 582 746 code := http.StatusUnauthorized 583 747 if strings.Contains(r.Header.Get("User-Agent"), "GitHub-Hookshot") { 584 748 // GitHub has no way to restrict branches for a webhook, and responding with 401 ··· 596 760 return err 597 761 } 598 762 763 + if checkDryRun(w, r) { 764 + return nil 765 + } 766 + 599 767 resultChan := make(chan UpdateResult) 600 768 go func(ctx context.Context) { 601 769 ctx, cancel := context.WithTimeout(ctx, time.Duration(config.Limits.UpdateTimeout)) ··· 603 771 604 772 result := UpdateFromRepository(ctx, webRoot, repoURL, auth.branch) 605 773 resultChan <- result 606 - reportSiteUpdate("webhook", &result) 607 - }(context.Background()) 774 + observeSiteUpdate("webhook", &result) 775 + }(context.WithoutCancel(r.Context())) 608 776 609 777 var result UpdateResult 610 778 select { ··· 623 791 w.WriteHeader(http.StatusGatewayTimeout) 624 792 fmt.Fprintln(w, "update timeout") 625 793 case UpdateNoChange: 626 - w.WriteHeader(http.StatusOK) 627 794 fmt.Fprintln(w, "unchanged") 628 795 case UpdateCreated: 629 - w.WriteHeader(http.StatusOK) 630 796 fmt.Fprintln(w, "created") 631 797 case UpdateReplaced: 632 - w.WriteHeader(http.StatusOK) 633 798 fmt.Fprintln(w, "replaced") 634 799 case UpdateDeleted: 635 - w.WriteHeader(http.StatusOK) 636 800 fmt.Fprintln(w, "deleted") 637 801 } 638 802 if result.manifest != nil { ··· 648 812 } 649 813 650 814 func ServePages(w http.ResponseWriter, r *http.Request) { 815 + r = r.WithContext(WithPrincipal(r.Context())) 816 + if config.Audit.IncludeIPs != "" { 817 + GetPrincipal(r.Context()).IpAddress = proto.String(r.RemoteAddr) 818 + } 651 819 // We want upstream health checks to be done as closely to the normal flow as possible; 652 820 // any intentional deviation is an opportunity to miss an issue that will affect our 653 821 // visitors but not our health checks. 654 822 if r.Header.Get("Health-Check") == "" { 655 - log.Println("pages:", r.Method, r.Host, r.URL, r.Header.Get("Content-Type")) 823 + var mediaType string 824 + switch r.Method { 825 + case "HEAD", "GET": 826 + mediaType = r.Header.Get("Accept") 827 + default: 828 + mediaType = r.Header.Get("Content-Type") 829 + } 830 + logc.Println(r.Context(), "pages:", r.Method, r.Host, r.URL, mediaType) 656 831 if region := os.Getenv("FLY_REGION"); region != "" { 657 832 machine_id := os.Getenv("FLY_MACHINE_ID") 658 833 w.Header().Add("Server", fmt.Sprintf("git-pages (fly.io; %s; %s)", region, machine_id)) ··· 667 842 } 668 843 } 669 844 } 845 + allowedMethods := []string{"OPTIONS", "HEAD", "GET", "PUT", "PATCH", "DELETE", "POST"} 846 + if r.Method == "OPTIONS" || !slices.Contains(allowedMethods, r.Method) { 847 + w.Header().Add("Allow", strings.Join(allowedMethods, ", ")) 848 + } 670 849 err := error(nil) 671 850 switch r.Method { 672 851 // REST API 673 - case http.MethodHead, http.MethodGet: 852 + case "OPTIONS": 853 + // no preflight options 854 + case "HEAD", "GET": 674 855 err = getPage(w, r) 675 - case http.MethodPut: 856 + case "PUT": 676 857 err = putPage(w, r) 677 - case http.MethodDelete: 858 + case "PATCH": 859 + err = patchPage(w, r) 860 + case "DELETE": 678 861 err = deletePage(w, r) 679 862 // webhook API 680 - case http.MethodPost: 863 + case "POST": 681 864 err = postPage(w, r) 682 865 default: 683 - w.Header().Add("Allow", "HEAD, GET, PUT, DELETE, POST") 684 866 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 685 867 err = fmt.Errorf("method %s not allowed", r.Method) 686 868 } ··· 695 877 http.Error(w, message, http.StatusRequestEntityTooLarge) 696 878 err = errors.New(message) 697 879 } 698 - log.Println("pages err:", err) 880 + logc.Println(r.Context(), "pages err:", err) 699 881 } 700 882 }
+55
src/pages_test.go
··· 1 + package git_pages 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + "testing" 7 + ) 8 + 9 + func checkHost(t *testing.T, host string, expectOk string, expectErr string) { 10 + host, err := GetHost(&http.Request{Host: host}) 11 + if expectErr != "" { 12 + if err == nil || !strings.HasPrefix(err.Error(), expectErr) { 13 + t.Errorf("%s: expect err %s, got err %s", host, expectErr, err) 14 + } 15 + } 16 + if expectOk != "" { 17 + if err != nil { 18 + t.Errorf("%s: expect ok %s, got err %s", host, expectOk, err) 19 + } else if host != expectOk { 20 + t.Errorf("%s: expect ok %s, got ok %s", host, expectOk, host) 21 + } 22 + } 23 + } 24 + 25 + func TestHelloName(t *testing.T) { 26 + config = &Config{Features: []string{}} 27 + 28 + checkHost(t, "foo.bar", "foo.bar", "") 29 + checkHost(t, "foo-baz.bar", "foo-baz.bar", "") 30 + checkHost(t, "foo--baz.bar", "foo--baz.bar", "") 31 + checkHost(t, "foo.bar.", "foo.bar", "") 32 + checkHost(t, ".foo.bar", "", "reserved host name") 33 + checkHost(t, "..foo.bar", "", "reserved host name") 34 + 35 + checkHost(t, "รŸ.bar", "xn--zca.bar", "") 36 + checkHost(t, "xn--zca.bar", "xn--zca.bar", "") 37 + 38 + checkHost(t, "foo-.bar", "", "malformed host name") 39 + checkHost(t, "-foo.bar", "", "malformed host name") 40 + checkHost(t, "foo_.bar", "", "malformed host name") 41 + checkHost(t, "_foo.bar", "", "malformed host name") 42 + checkHost(t, "foo_baz.bar", "", "malformed host name") 43 + checkHost(t, "foo__baz.bar", "", "malformed host name") 44 + checkHost(t, "*.foo.bar", "", "malformed host name") 45 + 46 + config = &Config{Features: []string{"relaxed-idna"}} 47 + 48 + checkHost(t, "foo-.bar", "", "malformed host name") 49 + checkHost(t, "-foo.bar", "", "malformed host name") 50 + checkHost(t, "foo_.bar", "foo_.bar", "") 51 + checkHost(t, "_foo.bar", "", "reserved host name") 52 + checkHost(t, "foo_baz.bar", "foo_baz.bar", "") 53 + checkHost(t, "foo__baz.bar", "foo__baz.bar", "") 54 + checkHost(t, "*.foo.bar", "", "malformed host name") 55 + }
+142
src/patch.go
··· 1 + package git_pages 2 + 3 + import ( 4 + "archive/tar" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "maps" 9 + "slices" 10 + "strings" 11 + ) 12 + 13 + var ErrMalformedPatch = errors.New("malformed patch") 14 + 15 + type CreateParentsMode int 16 + 17 + const ( 18 + RequireParents CreateParentsMode = iota 19 + CreateParents 20 + ) 21 + 22 + // Mutates `manifest` according to a tar stream and the following rules: 23 + // - A character device with major 0 and minor 0 is a "whiteout marker". When placed 24 + // at a given path, this path and its entire subtree (if any) are removed from the manifest. 25 + // - When a directory is placed at a given path, this path and its entire subtree (if any) are 26 + // removed from the manifest and replaced with the contents of the directory. 27 + func ApplyTarPatch(manifest *Manifest, reader io.Reader, parents CreateParentsMode) error { 28 + type Node struct { 29 + entry *Entry 30 + children map[string]*Node 31 + } 32 + 33 + // Extract the manifest contents (which is using a flat hash map) into a directory tree 34 + // so that recursive delete operations have O(1) complexity. s 35 + var root *Node 36 + sortedNames := slices.Sorted(maps.Keys(manifest.GetContents())) 37 + for _, name := range sortedNames { 38 + entry := manifest.Contents[name] 39 + node := &Node{entry: entry} 40 + if entry.GetType() == Type_Directory { 41 + node.children = map[string]*Node{} 42 + } 43 + if name == "" { 44 + root = node 45 + } else { 46 + segments := strings.Split(name, "/") 47 + fileName := segments[len(segments)-1] 48 + iter := root 49 + for _, segment := range segments[:len(segments)-1] { 50 + if iter.children == nil { 51 + panic("malformed manifest") 52 + } else if _, exists := iter.children[segment]; !exists { 53 + panic("malformed manifest") 54 + } else { 55 + iter = iter.children[segment] 56 + } 57 + } 58 + iter.children[fileName] = node 59 + } 60 + } 61 + manifest.Contents = map[string]*Entry{} 62 + 63 + // Process the archive as a patch operation. 64 + archive := tar.NewReader(reader) 65 + for { 66 + header, err := archive.Next() 67 + if err == io.EOF { 68 + break 69 + } else if err != nil { 70 + return err 71 + } 72 + 73 + segments := strings.Split(strings.TrimRight(header.Name, "/"), "/") 74 + fileName := segments[len(segments)-1] 75 + node := root 76 + for index, segment := range segments[:len(segments)-1] { 77 + if node.children == nil { 78 + dirName := strings.Join(segments[:index], "/") 79 + return fmt.Errorf("%w: %s: not a directory", ErrMalformedPatch, dirName) 80 + } 81 + if _, exists := node.children[segment]; !exists { 82 + switch parents { 83 + case RequireParents: 84 + nodeName := strings.Join(segments[:index+1], "/") 85 + return fmt.Errorf("%w: %s: path not found", ErrMalformedPatch, nodeName) 86 + case CreateParents: 87 + node.children[segment] = &Node{ 88 + entry: NewManifestEntry(Type_Directory, nil), 89 + children: map[string]*Node{}, 90 + } 91 + } 92 + } 93 + node = node.children[segment] 94 + } 95 + if node.children == nil { 96 + dirName := strings.Join(segments[:len(segments)-1], "/") 97 + return fmt.Errorf("%w: %s: not a directory", ErrMalformedPatch, dirName) 98 + } 99 + 100 + switch header.Typeflag { 101 + case tar.TypeReg: 102 + fileData, err := io.ReadAll(archive) 103 + if err != nil { 104 + return fmt.Errorf("tar: %s: %w", header.Name, err) 105 + } 106 + node.children[fileName] = &Node{ 107 + entry: NewManifestEntry(Type_InlineFile, fileData), 108 + } 109 + case tar.TypeSymlink: 110 + node.children[fileName] = &Node{ 111 + entry: NewManifestEntry(Type_Symlink, []byte(header.Linkname)), 112 + } 113 + case tar.TypeDir: 114 + node.children[fileName] = &Node{ 115 + entry: NewManifestEntry(Type_Directory, nil), 116 + children: map[string]*Node{}, 117 + } 118 + case tar.TypeChar: 119 + if header.Devmajor == 0 && header.Devminor == 0 { 120 + delete(node.children, fileName) 121 + } else { 122 + AddProblem(manifest, header.Name, 123 + "tar: unsupported chardev %d,%d", header.Devmajor, header.Devminor) 124 + } 125 + default: 126 + AddProblem(manifest, header.Name, 127 + "tar: unsupported type '%c'", header.Typeflag) 128 + continue 129 + } 130 + } 131 + 132 + // Repopulate manifest contents with the updated directory tree. 133 + var traverse func([]string, *Node) 134 + traverse = func(segments []string, node *Node) { 135 + manifest.Contents[strings.Join(segments, "/")] = node.entry 136 + for fileName, childNode := range node.children { 137 + traverse(append(segments, fileName), childNode) 138 + } 139 + } 140 + traverse([]string{}, root) 141 + return nil 142 + }
+58 -14
src/redirects.go
··· 13 13 14 14 const RedirectsFileName string = "_redirects" 15 15 16 - func unparseRule(rule redirects.Rule) string { 16 + // Converts our Protobuf representation to tj/go-redirects. 17 + func exportRedirectRule(rule *RedirectRule) *redirects.Rule { 18 + return &redirects.Rule{ 19 + From: rule.GetFrom(), 20 + To: rule.GetTo(), 21 + Status: int(rule.GetStatus()), 22 + Force: rule.GetForce(), 23 + } 24 + } 25 + 26 + func unparseRedirectRule(rule *redirects.Rule) string { 17 27 var statusPart string 18 28 if rule.Force { 19 29 statusPart = fmt.Sprintf("%d!", rule.Status) ··· 49 59 return status >= 300 && status <= 399 50 60 } 51 61 52 - func validateRedirectRule(rule redirects.Rule) error { 62 + func validateRedirectRule(rule *redirects.Rule) error { 53 63 if len(rule.Params) > 0 { 54 64 return fmt.Errorf("rules with parameters are not supported") 55 65 } ··· 103 113 } 104 114 105 115 for index, rule := range rules { 106 - if err := validateRedirectRule(rule); err != nil { 116 + if err := validateRedirectRule(&rule); err != nil { 107 117 AddProblem(manifest, RedirectsFileName, 108 - "rule #%d %q: %s", index+1, unparseRule(rule), err) 118 + "rule #%d %q: %s", index+1, unparseRedirectRule(&rule), err) 109 119 continue 110 120 } 111 121 manifest.Redirects = append(manifest.Redirects, &RedirectRule{ ··· 121 131 func CollectRedirectsFile(manifest *Manifest) string { 122 132 var rules []string 123 133 for _, rule := range manifest.GetRedirects() { 124 - rules = append(rules, unparseRule(redirects.Rule{ 125 - From: rule.GetFrom(), 126 - To: rule.GetTo(), 127 - Status: int(rule.GetStatus()), 128 - Force: rule.GetForce(), 129 - })+"\n") 134 + rules = append(rules, unparseRedirectRule(exportRedirectRule(rule))+"\n") 130 135 } 131 136 return strings.Join(rules, "") 132 137 } ··· 147 152 148 153 const ( 149 154 RedirectAny RedirectKind = iota 155 + RedirectNormal 150 156 RedirectForce 151 157 ) 152 158 153 159 func ApplyRedirectRules( 154 160 manifest *Manifest, fromURL *url.URL, kind RedirectKind, 155 161 ) ( 156 - toURL *url.URL, status int, 162 + rule *RedirectRule, toURL *url.URL, status int, 157 163 ) { 158 164 fromSegments := pathSegments(fromURL.Path) 159 165 next: 160 - for _, rule := range manifest.Redirects { 161 - if kind == RedirectForce && !*rule.Force { 166 + for _, rule = range manifest.Redirects { 167 + switch { 168 + case kind == RedirectNormal && *rule.Force: 169 + continue 170 + case kind == RedirectForce && !*rule.Force: 162 171 continue 163 172 } 164 173 // check if the rule matches fromURL ··· 205 214 RawQuery: fromURL.RawQuery, 206 215 } 207 216 status = int(*rule.Status) 208 - break 217 + return 209 218 } 210 219 // no redirect found 220 + rule = nil 211 221 return 212 222 } 223 + 224 + func redirectHasSplat(rule *RedirectRule) bool { 225 + ruleFromURL, _ := url.Parse(*rule.From) // pre-validated in `validateRedirectRule` 226 + ruleFromSegments := pathSegments(ruleFromURL.Path) 227 + return slices.Contains(ruleFromSegments, "*") 228 + } 229 + 230 + func LintRedirects(manifest *Manifest) { 231 + for name, entry := range manifest.GetContents() { 232 + nameURL, err := url.Parse("/" + name) 233 + if err != nil { 234 + continue 235 + } 236 + 237 + // Check if the entry URL would trigger a non-forced redirect if the entry didn't exist. 238 + // If the redirect matches exactly one URL (i.e. has no splat) then it will never be 239 + // triggered and an issue is reported; if the rule has a splat, it will always be possible 240 + // to trigger it, as it matches an infinite number of URLs. 241 + rule, _, _ := ApplyRedirectRules(manifest, nameURL, RedirectNormal) 242 + if rule != nil && !redirectHasSplat(rule) { 243 + entryDesc := "file" 244 + if entry.GetType() == Type_Directory { 245 + entryDesc = "directory" 246 + } 247 + AddProblem(manifest, name, 248 + "%s shadows redirect %q; remove the %s or use a %d! forced redirect instead", 249 + entryDesc, 250 + unparseRedirectRule(exportRedirectRule(rule)), 251 + entryDesc, 252 + rule.GetStatus(), 253 + ) 254 + } 255 + } 256 + }
+326 -61
src/schema.pb.go
··· 9 9 import ( 10 10 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 11 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 + timestamppb "google.golang.org/protobuf/types/known/timestamppb" 12 13 reflect "reflect" 13 14 sync "sync" 14 15 unsafe "unsafe" ··· 25 26 26 27 const ( 27 28 // Invalid entry. 28 - Type_Invalid Type = 0 29 + Type_InvalidEntry Type = 0 29 30 // Directory. 30 31 Type_Directory Type = 1 31 32 // Inline file. `Blob.Data` contains file contents. ··· 39 40 // Enum value maps for Type. 40 41 var ( 41 42 Type_name = map[int32]string{ 42 - 0: "Invalid", 43 + 0: "InvalidEntry", 43 44 1: "Directory", 44 45 2: "InlineFile", 45 46 3: "ExternalFile", 46 47 4: "Symlink", 47 48 } 48 49 Type_value = map[string]int32{ 49 - "Invalid": 0, 50 + "InvalidEntry": 0, 50 51 "Directory": 1, 51 52 "InlineFile": 2, 52 53 "ExternalFile": 3, ··· 81 82 return file_schema_proto_rawDescGZIP(), []int{0} 82 83 } 83 84 85 + // Transformation names should match HTTP `Accept-Encoding:` header. 84 86 type Transform int32 85 87 86 88 const ( 87 89 // No transformation. 88 - Transform_None Transform = 0 90 + Transform_Identity Transform = 0 89 91 // Zstandard compression. 90 - Transform_Zstandard Transform = 1 92 + Transform_Zstd Transform = 1 91 93 ) 92 94 93 95 // Enum value maps for Transform. 94 96 var ( 95 97 Transform_name = map[int32]string{ 96 - 0: "None", 97 - 1: "Zstandard", 98 + 0: "Identity", 99 + 1: "Zstd", 98 100 } 99 101 Transform_value = map[string]int32{ 100 - "None": 0, 101 - "Zstandard": 1, 102 + "Identity": 0, 103 + "Zstd": 1, 102 104 } 103 105 ) 104 106 ··· 129 131 return file_schema_proto_rawDescGZIP(), []int{1} 130 132 } 131 133 134 + type AuditEvent int32 135 + 136 + const ( 137 + // Invalid event. 138 + AuditEvent_InvalidEvent AuditEvent = 0 139 + // A manifest was committed (a site was created or updated). 140 + AuditEvent_CommitManifest AuditEvent = 1 141 + // A manifest was deleted (a site was deleted). 142 + AuditEvent_DeleteManifest AuditEvent = 2 143 + // A domain was frozen. 144 + AuditEvent_FreezeDomain AuditEvent = 3 145 + // A domain was thawed. 146 + AuditEvent_UnfreezeDomain AuditEvent = 4 147 + ) 148 + 149 + // Enum value maps for AuditEvent. 150 + var ( 151 + AuditEvent_name = map[int32]string{ 152 + 0: "InvalidEvent", 153 + 1: "CommitManifest", 154 + 2: "DeleteManifest", 155 + 3: "FreezeDomain", 156 + 4: "UnfreezeDomain", 157 + } 158 + AuditEvent_value = map[string]int32{ 159 + "InvalidEvent": 0, 160 + "CommitManifest": 1, 161 + "DeleteManifest": 2, 162 + "FreezeDomain": 3, 163 + "UnfreezeDomain": 4, 164 + } 165 + ) 166 + 167 + func (x AuditEvent) Enum() *AuditEvent { 168 + p := new(AuditEvent) 169 + *p = x 170 + return p 171 + } 172 + 173 + func (x AuditEvent) String() string { 174 + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) 175 + } 176 + 177 + func (AuditEvent) Descriptor() protoreflect.EnumDescriptor { 178 + return file_schema_proto_enumTypes[2].Descriptor() 179 + } 180 + 181 + func (AuditEvent) Type() protoreflect.EnumType { 182 + return &file_schema_proto_enumTypes[2] 183 + } 184 + 185 + func (x AuditEvent) Number() protoreflect.EnumNumber { 186 + return protoreflect.EnumNumber(x) 187 + } 188 + 189 + // Deprecated: Use AuditEvent.Descriptor instead. 190 + func (AuditEvent) EnumDescriptor() ([]byte, []int) { 191 + return file_schema_proto_rawDescGZIP(), []int{2} 192 + } 193 + 132 194 type Entry struct { 133 195 state protoimpl.MessageState `protogen:"open.v1"` 134 196 Type *Type `protobuf:"varint,1,opt,name=type,enum=Type" json:"type,omitempty"` 135 197 // Only present for `type == InlineFile` and `type == ExternalFile`. 136 - // For transformed entries, refers to the post-transformation (compressed) size. 137 - Size *int64 `protobuf:"varint,2,opt,name=size" json:"size,omitempty"` 198 + // For transformed entries, refers to the pre-transformation (decompressed) size; otherwise 199 + // equal to `compressed_size`. 200 + OriginalSize *int64 `protobuf:"varint,7,opt,name=original_size,json=originalSize" json:"original_size,omitempty"` 201 + // Only present for `type == InlineFile` and `type == ExternalFile`. 202 + // For transformed entries, refers to the post-transformation (compressed) size; otherwise 203 + // equal to `original_size`. 204 + CompressedSize *int64 `protobuf:"varint,2,opt,name=compressed_size,json=compressedSize" json:"compressed_size,omitempty"` 138 205 // Meaning depends on `type`: 139 206 // - If `type == InlineFile`, contains file data. 140 207 // - If `type == ExternalFile`, contains blob name (an otherwise unspecified ··· 147 214 Transform *Transform `protobuf:"varint,4,opt,name=transform,enum=Transform" json:"transform,omitempty"` 148 215 // Only present for `type == InlineFile` and `type == ExternalFile`. 149 216 // Currently, optional (not present on certain legacy manifests). 150 - ContentType *string `protobuf:"bytes,5,opt,name=content_type,json=contentType" json:"content_type,omitempty"` 217 + ContentType *string `protobuf:"bytes,5,opt,name=content_type,json=contentType" json:"content_type,omitempty"` 218 + // May be present for `type == InlineFile` and `type == ExternalFile`. 219 + // Used to reduce the amount of work being done during git checkouts. 220 + // The type of hash used is determined by the length: 221 + // - 40 bytes: SHA1DC (as hex) 222 + // - 64 bytes: SHA256 (as hex) 223 + GitHash *string `protobuf:"bytes,6,opt,name=git_hash,json=gitHash" json:"git_hash,omitempty"` 151 224 unknownFields protoimpl.UnknownFields 152 225 sizeCache protoimpl.SizeCache 153 226 } ··· 186 259 if x != nil && x.Type != nil { 187 260 return *x.Type 188 261 } 189 - return Type_Invalid 262 + return Type_InvalidEntry 190 263 } 191 264 192 - func (x *Entry) GetSize() int64 { 193 - if x != nil && x.Size != nil { 194 - return *x.Size 265 + func (x *Entry) GetOriginalSize() int64 { 266 + if x != nil && x.OriginalSize != nil { 267 + return *x.OriginalSize 268 + } 269 + return 0 270 + } 271 + 272 + func (x *Entry) GetCompressedSize() int64 { 273 + if x != nil && x.CompressedSize != nil { 274 + return *x.CompressedSize 195 275 } 196 276 return 0 197 277 } ··· 207 287 if x != nil && x.Transform != nil { 208 288 return *x.Transform 209 289 } 210 - return Transform_None 290 + return Transform_Identity 211 291 } 212 292 213 293 func (x *Entry) GetContentType() string { 214 294 if x != nil && x.ContentType != nil { 215 295 return *x.ContentType 296 + } 297 + return "" 298 + } 299 + 300 + func (x *Entry) GetGitHash() string { 301 + if x != nil && x.GitHash != nil { 302 + return *x.GitHash 216 303 } 217 304 return "" 218 305 } ··· 446 533 447 534 type Manifest struct { 448 535 state protoimpl.MessageState `protogen:"open.v1"` 449 - // Source metadata 536 + // Source metadata. 450 537 RepoUrl *string `protobuf:"bytes,1,opt,name=repo_url,json=repoUrl" json:"repo_url,omitempty"` 451 538 Branch *string `protobuf:"bytes,2,opt,name=branch" json:"branch,omitempty"` 452 539 Commit *string `protobuf:"bytes,3,opt,name=commit" json:"commit,omitempty"` 453 - // Contents 540 + // Site contents. 454 541 Contents map[string]*Entry `protobuf:"bytes,4,rep,name=contents" json:"contents,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` 455 - OriginalSize *int64 `protobuf:"varint,10,opt,name=original_size,json=originalSize" json:"original_size,omitempty"` // total size of entries before compression 456 - CompressedSize *int64 `protobuf:"varint,5,opt,name=compressed_size,json=compressedSize" json:"compressed_size,omitempty"` // simple sum of each `entry.size` 457 - StoredSize *int64 `protobuf:"varint,8,opt,name=stored_size,json=storedSize" json:"stored_size,omitempty"` // total size of (deduplicated) external objects 458 - // Netlify-style `_redirects` and `_headers` 542 + OriginalSize *int64 `protobuf:"varint,10,opt,name=original_size,json=originalSize" json:"original_size,omitempty"` // sum of each `entry.original_size` 543 + CompressedSize *int64 `protobuf:"varint,5,opt,name=compressed_size,json=compressedSize" json:"compressed_size,omitempty"` // sum of each `entry.compressed_size` 544 + StoredSize *int64 `protobuf:"varint,8,opt,name=stored_size,json=storedSize" json:"stored_size,omitempty"` // sum of deduplicated `entry.compressed_size` for external files only 545 + // Netlify-style `_redirects` and `_headers` rules. 459 546 Redirects []*RedirectRule `protobuf:"bytes,6,rep,name=redirects" json:"redirects,omitempty"` 460 547 Headers []*HeaderRule `protobuf:"bytes,9,rep,name=headers" json:"headers,omitempty"` 461 - // Diagnostics for non-fatal errors 548 + // Diagnostics for non-fatal errors. 462 549 Problems []*Problem `protobuf:"bytes,7,rep,name=problems" json:"problems,omitempty"` 463 550 unknownFields protoimpl.UnknownFields 464 551 sizeCache protoimpl.SizeCache ··· 564 651 return nil 565 652 } 566 653 654 + type AuditRecord struct { 655 + state protoimpl.MessageState `protogen:"open.v1"` 656 + // Audit event metadata. 657 + Id *int64 `protobuf:"varint,1,opt,name=id" json:"id,omitempty"` 658 + Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp" json:"timestamp,omitempty"` 659 + Event *AuditEvent `protobuf:"varint,3,opt,name=event,enum=AuditEvent" json:"event,omitempty"` 660 + Principal *Principal `protobuf:"bytes,4,opt,name=principal" json:"principal,omitempty"` 661 + // Affected resource. 662 + Domain *string `protobuf:"bytes,10,opt,name=domain" json:"domain,omitempty"` 663 + Project *string `protobuf:"bytes,11,opt,name=project" json:"project,omitempty"` // only for `*Manifest` events 664 + // Snapshot of site manifest. 665 + Manifest *Manifest `protobuf:"bytes,12,opt,name=manifest" json:"manifest,omitempty"` // only for `*Manifest` events 666 + unknownFields protoimpl.UnknownFields 667 + sizeCache protoimpl.SizeCache 668 + } 669 + 670 + func (x *AuditRecord) Reset() { 671 + *x = AuditRecord{} 672 + mi := &file_schema_proto_msgTypes[6] 673 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 674 + ms.StoreMessageInfo(mi) 675 + } 676 + 677 + func (x *AuditRecord) String() string { 678 + return protoimpl.X.MessageStringOf(x) 679 + } 680 + 681 + func (*AuditRecord) ProtoMessage() {} 682 + 683 + func (x *AuditRecord) ProtoReflect() protoreflect.Message { 684 + mi := &file_schema_proto_msgTypes[6] 685 + if x != nil { 686 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 687 + if ms.LoadMessageInfo() == nil { 688 + ms.StoreMessageInfo(mi) 689 + } 690 + return ms 691 + } 692 + return mi.MessageOf(x) 693 + } 694 + 695 + // Deprecated: Use AuditRecord.ProtoReflect.Descriptor instead. 696 + func (*AuditRecord) Descriptor() ([]byte, []int) { 697 + return file_schema_proto_rawDescGZIP(), []int{6} 698 + } 699 + 700 + func (x *AuditRecord) GetId() int64 { 701 + if x != nil && x.Id != nil { 702 + return *x.Id 703 + } 704 + return 0 705 + } 706 + 707 + func (x *AuditRecord) GetTimestamp() *timestamppb.Timestamp { 708 + if x != nil { 709 + return x.Timestamp 710 + } 711 + return nil 712 + } 713 + 714 + func (x *AuditRecord) GetEvent() AuditEvent { 715 + if x != nil && x.Event != nil { 716 + return *x.Event 717 + } 718 + return AuditEvent_InvalidEvent 719 + } 720 + 721 + func (x *AuditRecord) GetPrincipal() *Principal { 722 + if x != nil { 723 + return x.Principal 724 + } 725 + return nil 726 + } 727 + 728 + func (x *AuditRecord) GetDomain() string { 729 + if x != nil && x.Domain != nil { 730 + return *x.Domain 731 + } 732 + return "" 733 + } 734 + 735 + func (x *AuditRecord) GetProject() string { 736 + if x != nil && x.Project != nil { 737 + return *x.Project 738 + } 739 + return "" 740 + } 741 + 742 + func (x *AuditRecord) GetManifest() *Manifest { 743 + if x != nil { 744 + return x.Manifest 745 + } 746 + return nil 747 + } 748 + 749 + type Principal struct { 750 + state protoimpl.MessageState `protogen:"open.v1"` 751 + IpAddress *string `protobuf:"bytes,1,opt,name=ip_address,json=ipAddress" json:"ip_address,omitempty"` 752 + CliAdmin *bool `protobuf:"varint,2,opt,name=cli_admin,json=cliAdmin" json:"cli_admin,omitempty"` 753 + unknownFields protoimpl.UnknownFields 754 + sizeCache protoimpl.SizeCache 755 + } 756 + 757 + func (x *Principal) Reset() { 758 + *x = Principal{} 759 + mi := &file_schema_proto_msgTypes[7] 760 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 761 + ms.StoreMessageInfo(mi) 762 + } 763 + 764 + func (x *Principal) String() string { 765 + return protoimpl.X.MessageStringOf(x) 766 + } 767 + 768 + func (*Principal) ProtoMessage() {} 769 + 770 + func (x *Principal) ProtoReflect() protoreflect.Message { 771 + mi := &file_schema_proto_msgTypes[7] 772 + if x != nil { 773 + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 774 + if ms.LoadMessageInfo() == nil { 775 + ms.StoreMessageInfo(mi) 776 + } 777 + return ms 778 + } 779 + return mi.MessageOf(x) 780 + } 781 + 782 + // Deprecated: Use Principal.ProtoReflect.Descriptor instead. 783 + func (*Principal) Descriptor() ([]byte, []int) { 784 + return file_schema_proto_rawDescGZIP(), []int{7} 785 + } 786 + 787 + func (x *Principal) GetIpAddress() string { 788 + if x != nil && x.IpAddress != nil { 789 + return *x.IpAddress 790 + } 791 + return "" 792 + } 793 + 794 + func (x *Principal) GetCliAdmin() bool { 795 + if x != nil && x.CliAdmin != nil { 796 + return *x.CliAdmin 797 + } 798 + return false 799 + } 800 + 567 801 var File_schema_proto protoreflect.FileDescriptor 568 802 569 803 const file_schema_proto_rawDesc = "" + 570 804 "\n" + 571 - "\fschema.proto\"\x97\x01\n" + 805 + "\fschema.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xec\x01\n" + 572 806 "\x05Entry\x12\x19\n" + 573 - "\x04type\x18\x01 \x01(\x0e2\x05.TypeR\x04type\x12\x12\n" + 574 - "\x04size\x18\x02 \x01(\x03R\x04size\x12\x12\n" + 807 + "\x04type\x18\x01 \x01(\x0e2\x05.TypeR\x04type\x12#\n" + 808 + "\roriginal_size\x18\a \x01(\x03R\foriginalSize\x12'\n" + 809 + "\x0fcompressed_size\x18\x02 \x01(\x03R\x0ecompressedSize\x12\x12\n" + 575 810 "\x04data\x18\x03 \x01(\fR\x04data\x12(\n" + 576 811 "\ttransform\x18\x04 \x01(\x0e2\n" + 577 812 ".TransformR\ttransform\x12!\n" + 578 - "\fcontent_type\x18\x05 \x01(\tR\vcontentType\"`\n" + 813 + "\fcontent_type\x18\x05 \x01(\tR\vcontentType\x12\x19\n" + 814 + "\bgit_hash\x18\x06 \x01(\tR\agitHash\"`\n" + 579 815 "\fRedirectRule\x12\x12\n" + 580 816 "\x04from\x18\x01 \x01(\tR\x04from\x12\x0e\n" + 581 817 "\x02to\x18\x02 \x01(\tR\x02to\x12\x16\n" + ··· 607 843 "\bproblems\x18\a \x03(\v2\b.ProblemR\bproblems\x1aC\n" + 608 844 "\rContentsEntry\x12\x10\n" + 609 845 "\x03key\x18\x01 \x01(\tR\x03key\x12\x1c\n" + 610 - "\x05value\x18\x02 \x01(\v2\x06.EntryR\x05value:\x028\x01*Q\n" + 611 - "\x04Type\x12\v\n" + 612 - "\aInvalid\x10\x00\x12\r\n" + 846 + "\x05value\x18\x02 \x01(\v2\x06.EntryR\x05value:\x028\x01\"\xfd\x01\n" + 847 + "\vAuditRecord\x12\x0e\n" + 848 + "\x02id\x18\x01 \x01(\x03R\x02id\x128\n" + 849 + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12!\n" + 850 + "\x05event\x18\x03 \x01(\x0e2\v.AuditEventR\x05event\x12(\n" + 851 + "\tprincipal\x18\x04 \x01(\v2\n" + 852 + ".PrincipalR\tprincipal\x12\x16\n" + 853 + "\x06domain\x18\n" + 854 + " \x01(\tR\x06domain\x12\x18\n" + 855 + "\aproject\x18\v \x01(\tR\aproject\x12%\n" + 856 + "\bmanifest\x18\f \x01(\v2\t.ManifestR\bmanifest\"G\n" + 857 + "\tPrincipal\x12\x1d\n" + 858 + "\n" + 859 + "ip_address\x18\x01 \x01(\tR\tipAddress\x12\x1b\n" + 860 + "\tcli_admin\x18\x02 \x01(\bR\bcliAdmin*V\n" + 861 + "\x04Type\x12\x10\n" + 862 + "\fInvalidEntry\x10\x00\x12\r\n" + 613 863 "\tDirectory\x10\x01\x12\x0e\n" + 614 864 "\n" + 615 865 "InlineFile\x10\x02\x12\x10\n" + 616 866 "\fExternalFile\x10\x03\x12\v\n" + 617 - "\aSymlink\x10\x04*$\n" + 618 - "\tTransform\x12\b\n" + 619 - "\x04None\x10\x00\x12\r\n" + 620 - "\tZstandard\x10\x01B,Z*codeberg.org/git-pages/git-pages/git_pagesb\beditionsp\xe8\a" 867 + "\aSymlink\x10\x04*#\n" + 868 + "\tTransform\x12\f\n" + 869 + "\bIdentity\x10\x00\x12\b\n" + 870 + "\x04Zstd\x10\x01*l\n" + 871 + "\n" + 872 + "AuditEvent\x12\x10\n" + 873 + "\fInvalidEvent\x10\x00\x12\x12\n" + 874 + "\x0eCommitManifest\x10\x01\x12\x12\n" + 875 + "\x0eDeleteManifest\x10\x02\x12\x10\n" + 876 + "\fFreezeDomain\x10\x03\x12\x12\n" + 877 + "\x0eUnfreezeDomain\x10\x04B,Z*codeberg.org/git-pages/git-pages/git_pagesb\beditionsp\xe8\a" 621 878 622 879 var ( 623 880 file_schema_proto_rawDescOnce sync.Once ··· 631 888 return file_schema_proto_rawDescData 632 889 } 633 890 634 - var file_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 2) 635 - var file_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 7) 891 + var file_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 3) 892 + var file_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 9) 636 893 var file_schema_proto_goTypes = []any{ 637 - (Type)(0), // 0: Type 638 - (Transform)(0), // 1: Transform 639 - (*Entry)(nil), // 2: Entry 640 - (*RedirectRule)(nil), // 3: RedirectRule 641 - (*Header)(nil), // 4: Header 642 - (*HeaderRule)(nil), // 5: HeaderRule 643 - (*Problem)(nil), // 6: Problem 644 - (*Manifest)(nil), // 7: Manifest 645 - nil, // 8: Manifest.ContentsEntry 894 + (Type)(0), // 0: Type 895 + (Transform)(0), // 1: Transform 896 + (AuditEvent)(0), // 2: AuditEvent 897 + (*Entry)(nil), // 3: Entry 898 + (*RedirectRule)(nil), // 4: RedirectRule 899 + (*Header)(nil), // 5: Header 900 + (*HeaderRule)(nil), // 6: HeaderRule 901 + (*Problem)(nil), // 7: Problem 902 + (*Manifest)(nil), // 8: Manifest 903 + (*AuditRecord)(nil), // 9: AuditRecord 904 + (*Principal)(nil), // 10: Principal 905 + nil, // 11: Manifest.ContentsEntry 906 + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp 646 907 } 647 908 var file_schema_proto_depIdxs = []int32{ 648 - 0, // 0: Entry.type:type_name -> Type 649 - 1, // 1: Entry.transform:type_name -> Transform 650 - 4, // 2: HeaderRule.header_map:type_name -> Header 651 - 8, // 3: Manifest.contents:type_name -> Manifest.ContentsEntry 652 - 3, // 4: Manifest.redirects:type_name -> RedirectRule 653 - 5, // 5: Manifest.headers:type_name -> HeaderRule 654 - 6, // 6: Manifest.problems:type_name -> Problem 655 - 2, // 7: Manifest.ContentsEntry.value:type_name -> Entry 656 - 8, // [8:8] is the sub-list for method output_type 657 - 8, // [8:8] is the sub-list for method input_type 658 - 8, // [8:8] is the sub-list for extension type_name 659 - 8, // [8:8] is the sub-list for extension extendee 660 - 0, // [0:8] is the sub-list for field type_name 909 + 0, // 0: Entry.type:type_name -> Type 910 + 1, // 1: Entry.transform:type_name -> Transform 911 + 5, // 2: HeaderRule.header_map:type_name -> Header 912 + 11, // 3: Manifest.contents:type_name -> Manifest.ContentsEntry 913 + 4, // 4: Manifest.redirects:type_name -> RedirectRule 914 + 6, // 5: Manifest.headers:type_name -> HeaderRule 915 + 7, // 6: Manifest.problems:type_name -> Problem 916 + 12, // 7: AuditRecord.timestamp:type_name -> google.protobuf.Timestamp 917 + 2, // 8: AuditRecord.event:type_name -> AuditEvent 918 + 10, // 9: AuditRecord.principal:type_name -> Principal 919 + 8, // 10: AuditRecord.manifest:type_name -> Manifest 920 + 3, // 11: Manifest.ContentsEntry.value:type_name -> Entry 921 + 12, // [12:12] is the sub-list for method output_type 922 + 12, // [12:12] is the sub-list for method input_type 923 + 12, // [12:12] is the sub-list for extension type_name 924 + 12, // [12:12] is the sub-list for extension extendee 925 + 0, // [0:12] is the sub-list for field type_name 661 926 } 662 927 663 928 func init() { file_schema_proto_init() } ··· 670 935 File: protoimpl.DescBuilder{ 671 936 GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 672 937 RawDescriptor: unsafe.Slice(unsafe.StringData(file_schema_proto_rawDesc), len(file_schema_proto_rawDesc)), 673 - NumEnums: 2, 674 - NumMessages: 7, 938 + NumEnums: 3, 939 + NumMessages: 9, 675 940 NumExtensions: 0, 676 941 NumServices: 0, 677 942 },
+59 -12
src/schema.proto
··· 2 2 3 3 option go_package = "codeberg.org/git-pages/git-pages/git_pages"; 4 4 5 + import "google/protobuf/timestamp.proto"; 6 + 5 7 enum Type { 6 8 // Invalid entry. 7 - Invalid = 0; 9 + InvalidEntry = 0; 8 10 // Directory. 9 11 Directory = 1; 10 12 // Inline file. `Blob.Data` contains file contents. ··· 15 17 Symlink = 4; 16 18 } 17 19 20 + // Transformation names should match HTTP `Accept-Encoding:` header. 18 21 enum Transform { 19 22 // No transformation. 20 - None = 0; 23 + Identity = 0; 21 24 // Zstandard compression. 22 - Zstandard = 1; 25 + Zstd = 1; 23 26 } 24 27 25 28 message Entry { 26 29 Type type = 1; 27 30 // Only present for `type == InlineFile` and `type == ExternalFile`. 28 - // For transformed entries, refers to the post-transformation (compressed) size. 29 - int64 size = 2; 31 + // For transformed entries, refers to the pre-transformation (decompressed) size; otherwise 32 + // equal to `compressed_size`. 33 + int64 original_size = 7; 34 + // Only present for `type == InlineFile` and `type == ExternalFile`. 35 + // For transformed entries, refers to the post-transformation (compressed) size; otherwise 36 + // equal to `original_size`. 37 + int64 compressed_size = 2; 30 38 // Meaning depends on `type`: 31 39 // * If `type == InlineFile`, contains file data. 32 40 // * If `type == ExternalFile`, contains blob name (an otherwise unspecified ··· 40 48 // Only present for `type == InlineFile` and `type == ExternalFile`. 41 49 // Currently, optional (not present on certain legacy manifests). 42 50 string content_type = 5; 51 + // May be present for `type == InlineFile` and `type == ExternalFile`. 52 + // Used to reduce the amount of work being done during git checkouts. 53 + // The type of hash used is determined by the length: 54 + // * 40 bytes: SHA1DC (as hex) 55 + // * 64 bytes: SHA256 (as hex) 56 + string git_hash = 6; 43 57 } 44 58 45 59 // See https://docs.netlify.com/manage/routing/redirects/overview/ for details. ··· 68 82 } 69 83 70 84 message Manifest { 71 - // Source metadata 85 + // Source metadata. 72 86 string repo_url = 1; 73 87 string branch = 2; 74 88 string commit = 3; 75 89 76 - // Contents 90 + // Site contents. 77 91 map<string, Entry> contents = 4; 78 - int64 original_size = 10; // total size of entries before compression 79 - int64 compressed_size = 5; // simple sum of each `entry.size` 80 - int64 stored_size = 8; // total size of (deduplicated) external objects 92 + int64 original_size = 10; // sum of each `entry.original_size` 93 + int64 compressed_size = 5; // sum of each `entry.compressed_size` 94 + int64 stored_size = 8; // sum of deduplicated `entry.compressed_size` for external files only 81 95 82 - // Netlify-style `_redirects` and `_headers` 96 + // Netlify-style `_redirects` and `_headers` rules. 83 97 repeated RedirectRule redirects = 6; 84 98 repeated HeaderRule headers = 9; 85 99 86 - // Diagnostics for non-fatal errors 100 + // Diagnostics for non-fatal errors. 87 101 repeated Problem problems = 7; 88 102 } 103 + 104 + enum AuditEvent { 105 + // Invalid event. 106 + InvalidEvent = 0; 107 + // A manifest was committed (a site was created or updated). 108 + CommitManifest = 1; 109 + // A manifest was deleted (a site was deleted). 110 + DeleteManifest = 2; 111 + // A domain was frozen. 112 + FreezeDomain = 3; 113 + // A domain was thawed. 114 + UnfreezeDomain = 4; 115 + } 116 + 117 + message AuditRecord { 118 + // Audit event metadata. 119 + int64 id = 1; 120 + google.protobuf.Timestamp timestamp = 2; 121 + AuditEvent event = 3; 122 + Principal principal = 4; 123 + 124 + // Affected resource. 125 + string domain = 10; 126 + string project = 11; // only for `*Manifest` events 127 + 128 + // Snapshot of site manifest. 129 + Manifest manifest = 12; // only for `*Manifest` events 130 + } 131 + 132 + message Principal { 133 + string ip_address = 1; 134 + bool cli_admin = 2; 135 + }
+29
src/signal.go
··· 1 + // See https://pkg.go.dev/os/signal#hdr-Windows for a description of what this module 2 + // will do on Windows (tl;dr nothing calls the reload handler, the interrupt handler works 3 + // more or less how you'd expect). 4 + 5 + package git_pages 6 + 7 + import ( 8 + "os" 9 + "os/signal" 10 + "syscall" 11 + ) 12 + 13 + func OnReload(handler func()) { 14 + sighup := make(chan os.Signal, 1) 15 + signal.Notify(sighup, syscall.SIGHUP) 16 + go func() { 17 + for { 18 + <-sighup 19 + handler() 20 + } 21 + }() 22 + } 23 + 24 + func WaitForInterrupt() { 25 + sigint := make(chan os.Signal, 1) 26 + signal.Notify(sigint, syscall.SIGINT, syscall.SIGTERM) 27 + <-sigint 28 + signal.Stop(sigint) 29 + }
-7
src/signal_other.go
··· 1 - //go:build !unix 2 - 3 - package git_pages 4 - 5 - func OnReload(handler func()) { 6 - // not implemented 7 - }
-20
src/signal_posix.go
··· 1 - //go:build unix 2 - 3 - package git_pages 4 - 5 - import ( 6 - "os" 7 - "os/signal" 8 - "syscall" 9 - ) 10 - 11 - func OnReload(handler func()) { 12 - sighup := make(chan os.Signal, 1) 13 - signal.Notify(sighup, syscall.SIGHUP) 14 - go func() { 15 - for { 16 - <-sighup 17 - handler() 18 - } 19 - }() 20 - }
+117 -29
src/update.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "io" 8 - "log" 9 8 "strings" 9 + 10 + "google.golang.org/protobuf/proto" 10 11 ) 11 12 12 13 type UpdateOutcome int ··· 26 27 err error 27 28 } 28 29 29 - func Update(ctx context.Context, webRoot string, manifest *Manifest) UpdateResult { 30 - var oldManifest, newManifest *Manifest 30 + func Update( 31 + ctx context.Context, webRoot string, oldManifest, newManifest *Manifest, 32 + opts ModifyManifestOptions, 33 + ) UpdateResult { 31 34 var err error 35 + var storedManifest *Manifest 32 36 33 37 outcome := UpdateError 34 - oldManifest, _, _ = backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 35 - if IsManifestEmpty(manifest) { 36 - newManifest, err = manifest, backend.DeleteManifest(ctx, webRoot) 38 + if IsManifestEmpty(newManifest) { 39 + storedManifest, err = newManifest, backend.DeleteManifest(ctx, webRoot, opts) 37 40 if err == nil { 38 41 if oldManifest == nil { 39 42 outcome = UpdateNoChange ··· 41 44 outcome = UpdateDeleted 42 45 } 43 46 } 44 - } else if err = PrepareManifest(ctx, manifest); err == nil { 45 - newManifest, err = StoreManifest(ctx, webRoot, manifest) 47 + } else if err = PrepareManifest(ctx, newManifest); err == nil { 48 + storedManifest, err = StoreManifest(ctx, webRoot, newManifest, opts) 46 49 if err == nil { 47 50 domain, _, _ := strings.Cut(webRoot, "/") 48 51 err = backend.CreateDomain(ctx, domain) ··· 50 53 if err == nil { 51 54 if oldManifest == nil { 52 55 outcome = UpdateCreated 53 - } else if CompareManifest(oldManifest, newManifest) { 56 + } else if CompareManifest(oldManifest, storedManifest) { 54 57 outcome = UpdateNoChange 55 58 } else { 56 59 outcome = UpdateReplaced ··· 70 73 case UpdateNoChange: 71 74 status = "unchanged" 72 75 } 73 - if newManifest.Commit != nil { 74 - log.Printf("update %s ok: %s %s", webRoot, status, *newManifest.Commit) 76 + if storedManifest.Commit != nil { 77 + logc.Printf(ctx, "update %s ok: %s %s", webRoot, *storedManifest.Commit, status) 75 78 } else { 76 - log.Printf("update %s ok: %s", webRoot, status) 79 + logc.Printf(ctx, "update %s ok: %s", webRoot, status) 77 80 } 78 81 } else { 79 - log.Printf("update %s err: %s", webRoot, err) 82 + logc.Printf(ctx, "update %s err: %s", webRoot, err) 80 83 } 81 84 82 - return UpdateResult{outcome, newManifest, err} 85 + return UpdateResult{outcome, storedManifest, err} 83 86 } 84 87 85 88 func UpdateFromRepository( ··· 91 94 span, ctx := ObserveFunction(ctx, "UpdateFromRepository", "repo.url", repoURL) 92 95 defer span.Finish() 93 96 94 - log.Printf("update %s: %s %s\n", webRoot, repoURL, branch) 97 + logc.Printf(ctx, "update %s: %s %s\n", webRoot, repoURL, branch) 95 98 96 - manifest, err := FetchRepository(ctx, repoURL, branch) 99 + // Ignore errors; worst case we have to re-fetch all of the blobs. 100 + oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 101 + 102 + newManifest, err := FetchRepository(ctx, repoURL, branch, oldManifest) 97 103 if errors.Is(err, context.DeadlineExceeded) { 98 104 result = UpdateResult{UpdateTimeout, nil, fmt.Errorf("update timeout")} 99 105 } else if err != nil { 100 106 result = UpdateResult{UpdateError, nil, err} 101 107 } else { 102 - result = Update(ctx, webRoot, manifest) 108 + result = Update(ctx, webRoot, oldManifest, newManifest, ModifyManifestOptions{}) 103 109 } 104 110 105 111 observeUpdateResult(result) ··· 114 120 contentType string, 115 121 reader io.Reader, 116 122 ) (result UpdateResult) { 117 - var manifest *Manifest 118 123 var err error 119 124 125 + // Ignore errors; worst case we have to re-fetch all of the blobs. 126 + oldManifest, _, _ := backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 127 + 128 + extractTar := func(ctx context.Context, reader io.Reader) (*Manifest, error) { 129 + return ExtractTar(ctx, reader, oldManifest) 130 + } 131 + 132 + var newManifest *Manifest 120 133 switch contentType { 121 134 case "application/x-tar": 122 - log.Printf("update %s: (tar)", webRoot) 123 - manifest, err = ExtractTar(reader) // yellow? 135 + logc.Printf(ctx, "update %s: (tar)", webRoot) 136 + newManifest, err = extractTar(ctx, reader) // yellow? 124 137 case "application/x-tar+gzip": 125 - log.Printf("update %s: (tar.gz)", webRoot) 126 - manifest, err = ExtractTarGzip(reader) // definitely yellow. 138 + logc.Printf(ctx, "update %s: (tar.gz)", webRoot) 139 + newManifest, err = ExtractGzip(ctx, reader, extractTar) // definitely yellow. 127 140 case "application/x-tar+zstd": 128 - log.Printf("update %s: (tar.zst)", webRoot) 129 - manifest, err = ExtractTarZstd(reader) 141 + logc.Printf(ctx, "update %s: (tar.zst)", webRoot) 142 + newManifest, err = ExtractZstd(ctx, reader, extractTar) 130 143 case "application/zip": 131 - log.Printf("update %s: (zip)", webRoot) 132 - manifest, err = ExtractZip(reader) 144 + logc.Printf(ctx, "update %s: (zip)", webRoot) 145 + newManifest, err = ExtractZip(ctx, reader, oldManifest) 133 146 default: 134 147 err = errArchiveFormat 135 148 } 136 149 137 150 if err != nil { 138 - log.Printf("update %s err: %s", webRoot, err) 151 + logc.Printf(ctx, "update %s err: %s", webRoot, err) 139 152 result = UpdateResult{UpdateError, nil, err} 140 153 } else { 141 - result = Update(ctx, webRoot, manifest) 154 + result = Update(ctx, webRoot, oldManifest, newManifest, ModifyManifestOptions{}) 155 + } 156 + 157 + observeUpdateResult(result) 158 + return 159 + } 160 + 161 + func PartialUpdateFromArchive( 162 + ctx context.Context, 163 + webRoot string, 164 + contentType string, 165 + reader io.Reader, 166 + parents CreateParentsMode, 167 + ) (result UpdateResult) { 168 + var err error 169 + 170 + // Here the old manifest is used both as a substrate to which a patch is applied, as well 171 + // as a "load linked" operation for a future "store conditional" update which, taken together, 172 + // create an atomic compare-and-swap operation. 173 + oldManifest, oldMetadata, err := backend.GetManifest(ctx, webRoot, 174 + GetManifestOptions{BypassCache: true}) 175 + if err != nil { 176 + logc.Printf(ctx, "patch %s err: %s", webRoot, err) 177 + return UpdateResult{UpdateError, nil, err} 178 + } 179 + 180 + applyTarPatch := func(ctx context.Context, reader io.Reader) (*Manifest, error) { 181 + // Clone the manifest before starting to mutate it. `GetManifest` may return cached 182 + // `*Manifest` objects, which should never be mutated. 183 + newManifest := &Manifest{} 184 + proto.Merge(newManifest, oldManifest) 185 + newManifest.RepoUrl = nil 186 + newManifest.Branch = nil 187 + newManifest.Commit = nil 188 + if err := ApplyTarPatch(newManifest, reader, parents); err != nil { 189 + return nil, err 190 + } else { 191 + return newManifest, nil 192 + } 193 + } 194 + 195 + var newManifest *Manifest 196 + switch contentType { 197 + case "application/x-tar": 198 + logc.Printf(ctx, "patch %s: (tar)", webRoot) 199 + newManifest, err = applyTarPatch(ctx, reader) 200 + case "application/x-tar+gzip": 201 + logc.Printf(ctx, "patch %s: (tar.gz)", webRoot) 202 + newManifest, err = ExtractGzip(ctx, reader, applyTarPatch) 203 + case "application/x-tar+zstd": 204 + logc.Printf(ctx, "patch %s: (tar.zst)", webRoot) 205 + newManifest, err = ExtractZstd(ctx, reader, applyTarPatch) 206 + default: 207 + err = errArchiveFormat 208 + } 209 + 210 + if err != nil { 211 + logc.Printf(ctx, "patch %s err: %s", webRoot, err) 212 + result = UpdateResult{UpdateError, nil, err} 213 + } else { 214 + result = Update(ctx, webRoot, oldManifest, newManifest, 215 + ModifyManifestOptions{ 216 + IfUnmodifiedSince: oldMetadata.LastModified, 217 + IfMatch: oldMetadata.ETag, 218 + }) 219 + // The `If-Unmodified-Since` precondition is internally generated here, which means its 220 + // failure shouldn't be surfaced as-is in the HTTP response. If we also accepted options 221 + // from the client, then that precondition failure should surface in the response. 222 + if errors.Is(result.err, ErrPreconditionFailed) { 223 + result.err = ErrWriteConflict 224 + } 142 225 } 143 226 144 227 observeUpdateResult(result) ··· 146 229 } 147 230 148 231 func observeUpdateResult(result UpdateResult) { 149 - if result.err != nil { 232 + var unresolvedRefErr UnresolvedRefError 233 + if errors.As(result.err, &unresolvedRefErr) { 234 + // This error is an expected outcome of an incremental update's probe phase. 235 + } else if errors.Is(result.err, ErrWriteConflict) { 236 + // This error is an expected outcome of an incremental update losing a race. 237 + } else if result.err != nil { 150 238 ObserveError(result.err) 151 239 } 152 240 }
-55
src/wildcard.go
··· 1 1 package git_pages 2 2 3 3 import ( 4 - "crypto/tls" 5 4 "fmt" 6 - "log" 7 - "net/http" 8 - "net/http/httputil" 9 - "net/url" 10 5 "slices" 11 6 "strings" 12 7 ··· 19 14 IndexRepos []*fasttemplate.Template 20 15 IndexBranch string 21 16 Authorization bool 22 - FallbackURL *url.URL 23 - Fallback http.Handler 24 17 } 25 18 26 19 func (pattern *WildcardPattern) GetHost() string { ··· 79 72 return repoURLs, branch 80 73 } 81 74 82 - func (pattern *WildcardPattern) IsFallbackFor(host string) bool { 83 - if pattern.Fallback == nil { 84 - return false 85 - } 86 - _, found := pattern.Matches(host) 87 - return found 88 - } 89 - 90 - func HandleWildcardFallback(w http.ResponseWriter, r *http.Request) (bool, error) { 91 - host, err := GetHost(r) 92 - if err != nil { 93 - return false, err 94 - } 95 - 96 - for _, pattern := range wildcards { 97 - if pattern.IsFallbackFor(host) { 98 - log.Printf("proxy: %s via %s", pattern.GetHost(), pattern.FallbackURL) 99 - pattern.Fallback.ServeHTTP(w, r) 100 - return true, nil 101 - } 102 - } 103 - return false, nil 104 - } 105 - 106 75 func TranslateWildcards(configs []WildcardConfig) ([]*WildcardPattern, error) { 107 76 var wildcardPatterns []*WildcardPattern 108 77 for _, config := range configs { ··· 135 104 } 136 105 } 137 106 138 - var fallbackURL *url.URL 139 - var fallback http.Handler 140 - if config.FallbackProxyTo != "" { 141 - fallbackURL, err = url.Parse(config.FallbackProxyTo) 142 - if err != nil { 143 - return nil, fmt.Errorf("wildcard pattern: fallback URL: %w", err) 144 - } 145 - 146 - fallback = &httputil.ReverseProxy{ 147 - Rewrite: func(r *httputil.ProxyRequest) { 148 - r.SetURL(fallbackURL) 149 - r.Out.Host = r.In.Host 150 - r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] 151 - }, 152 - Transport: &http.Transport{ 153 - TLSClientConfig: &tls.Config{ 154 - InsecureSkipVerify: config.FallbackInsecure, 155 - }, 156 - }, 157 - } 158 - } 159 - 160 107 wildcardPatterns = append(wildcardPatterns, &WildcardPattern{ 161 108 Domain: strings.Split(config.Domain, "."), 162 109 CloneURL: cloneURLTemplate, 163 110 IndexRepos: indexRepoTemplates, 164 111 IndexBranch: indexRepoBranch, 165 112 Authorization: authorization, 166 - FallbackURL: fallbackURL, 167 - Fallback: fallback, 168 113 }) 169 114 } 170 115 return wildcardPatterns, nil
+112
test/stresspatch/main.go
··· 1 + package main 2 + 3 + import ( 4 + "archive/tar" 5 + "bytes" 6 + "flag" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "sync" 11 + "time" 12 + ) 13 + 14 + func makeInit() []byte { 15 + writer := bytes.NewBuffer(nil) 16 + archive := tar.NewWriter(writer) 17 + archive.WriteHeader(&tar.Header{ 18 + Typeflag: tar.TypeReg, 19 + Name: "index.html", 20 + }) 21 + archive.Write([]byte{}) 22 + archive.Flush() 23 + return writer.Bytes() 24 + } 25 + 26 + func initSite() { 27 + req, err := http.NewRequest(http.MethodPut, "http://localhost:3000", 28 + bytes.NewReader(makeInit())) 29 + if err != nil { 30 + panic(err) 31 + } 32 + 33 + req.Header.Add("Content-Type", "application/x-tar") 34 + resp, err := http.DefaultClient.Do(req) 35 + if err != nil { 36 + panic(err) 37 + } 38 + defer resp.Body.Close() 39 + } 40 + 41 + func makePatch(n int) []byte { 42 + writer := bytes.NewBuffer(nil) 43 + archive := tar.NewWriter(writer) 44 + archive.WriteHeader(&tar.Header{ 45 + Typeflag: tar.TypeReg, 46 + Name: fmt.Sprintf("%d.txt", n), 47 + }) 48 + archive.Write([]byte{}) 49 + archive.Flush() 50 + return writer.Bytes() 51 + } 52 + 53 + func patchRequest(n int) int { 54 + req, err := http.NewRequest(http.MethodPatch, "http://localhost:3000", 55 + bytes.NewReader(makePatch(n))) 56 + if err != nil { 57 + panic(err) 58 + } 59 + 60 + req.Header.Add("Atomic", "no") 61 + req.Header.Add("Content-Type", "application/x-tar") 62 + resp, err := http.DefaultClient.Do(req) 63 + if err != nil { 64 + panic(err) 65 + } 66 + defer resp.Body.Close() 67 + 68 + data, err := io.ReadAll(resp.Body) 69 + if err != nil { 70 + panic(err) 71 + } 72 + 73 + fmt.Printf("%d: %s %q\n", n, resp.Status, string(data)) 74 + return resp.StatusCode 75 + } 76 + 77 + func concurrentWriter(wg *sync.WaitGroup, n int) { 78 + for { 79 + if patchRequest(n) == 200 { 80 + break 81 + } 82 + } 83 + wg.Done() 84 + } 85 + 86 + var count = flag.Int("count", 10, "request count") 87 + 88 + func main() { 89 + flag.Parse() 90 + 91 + initSite() 92 + time.Sleep(time.Second) 93 + 94 + wg := &sync.WaitGroup{} 95 + for n := range *count { 96 + wg.Add(1) 97 + go concurrentWriter(wg, n) 98 + } 99 + wg.Wait() 100 + 101 + success := 0 102 + for n := range *count { 103 + resp, err := http.Get(fmt.Sprintf("http://localhost:3000/%d.txt", n)) 104 + if err != nil { 105 + panic(err) 106 + } 107 + if resp.StatusCode == 200 { 108 + success++ 109 + } 110 + } 111 + fmt.Printf("written: %d of %d\n", success, *count) 112 + }