mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-14 11:11:35 +00:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5c5306688 | ||
|
|
27a6de792c | ||
|
|
2c109a5e1e | ||
|
|
d17c645927 | ||
|
|
57e9d05c7f | ||
|
|
1e6afe6570 | ||
|
|
b3692362d8 | ||
|
|
021c493daa | ||
|
|
b54664258b | ||
|
|
57dc8f8520 | ||
|
|
2b35996f62 | ||
|
|
cf050f505b | ||
|
|
6097a9abb8 | ||
|
|
fe329d748d | ||
|
|
bbdaae7280 | ||
|
|
f400f8d246 | ||
|
|
86259acf9c | ||
|
|
af7657a787 | ||
|
|
ed24f08d5f | ||
|
|
d7651941c0 | ||
|
|
bcd628fa6b | ||
|
|
6a3372a36a | ||
|
|
8d4ea36dec | ||
|
|
6509a8e1d2 | ||
|
|
6775f4aab5 | ||
|
|
1df1402f6b | ||
|
|
8dffd9cf11 | ||
|
|
5258bf756b | ||
|
|
38eb8afd0e | ||
|
|
2fdf0b805d | ||
|
|
e28d8cf0f2 | ||
|
|
ccabfc22a6 | ||
|
|
005e0fefed | ||
|
|
2267ab929c | ||
|
|
338487c048 | ||
|
|
b84a533be7 | ||
|
|
678868f7e6 | ||
|
|
1ca67f0590 | ||
|
|
c74ec4ad23 | ||
|
|
b37ca8cd14 | ||
|
|
310cc7d438 | ||
|
|
ad327b0382 | ||
|
|
b737e1bb9b | ||
|
|
8711e35c8e | ||
|
|
d2b5144182 | ||
|
|
34985c89bf | ||
|
|
050a002ddc | ||
|
|
559f0c6ae8 | ||
|
|
52fa8d1462 | ||
|
|
3830af5392 | ||
|
|
9e9664013b | ||
|
|
3e377986bc | ||
|
|
c85c7327bf | ||
|
|
886ee2ddae | ||
|
|
ac751e23b5 | ||
|
|
ebe7d07b3b | ||
|
|
4f14c345a6 | ||
|
|
7e293d6ef9 | ||
|
|
f7067b939b | ||
|
|
6bf4200f26 | ||
|
|
e9a5a901ec | ||
|
|
d3c8db6229 | ||
|
|
8f811147d6 | ||
|
|
0d33c64372 |
@@ -12,12 +12,12 @@ jobs:
|
||||
check:
|
||||
runs-on: debian-trixie
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie-slim@sha256:a16979bcaf12a2fd24888eb8e89874b11bd1038a3e3f1881c26a5e2b8fb92b5c
|
||||
image: docker.io/library/node:24-trixie-slim@sha256:28fd420825d8e922eab0fd91740c7cf88ddbdc8116a2b20a82049f0c946feb03
|
||||
steps:
|
||||
- name: Check out source code
|
||||
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up toolchain
|
||||
uses: https://code.forgejo.org/actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: https://code.forgejo.org/actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: '>=1.25.6'
|
||||
- name: Install dependencies
|
||||
@@ -42,18 +42,22 @@ jobs:
|
||||
needs: [check]
|
||||
runs-on: debian-trixie
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie-slim@sha256:a16979bcaf12a2fd24888eb8e89874b11bd1038a3e3f1881c26a5e2b8fb92b5c
|
||||
image: docker.io/library/node:24-trixie-slim@sha256:28fd420825d8e922eab0fd91740c7cf88ddbdc8116a2b20a82049f0c946feb03
|
||||
steps:
|
||||
- name: Check out source code
|
||||
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up toolchain
|
||||
uses: https://code.forgejo.org/actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version: '>=1.25.6'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get -y update
|
||||
apt-get -y install ca-certificates
|
||||
apt-get -y install ca-certificates git
|
||||
# git needs to be installed for build information embedding to work
|
||||
- name: Check out source code
|
||||
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- name: Set up toolchain
|
||||
uses: https://code.forgejo.org/actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: '>=1.25.6'
|
||||
- name: Build release assets
|
||||
# If you want more platforms to be represented, send a pull request.
|
||||
run: |
|
||||
@@ -64,7 +68,7 @@ jobs:
|
||||
build linux arm64
|
||||
build darwin arm64
|
||||
- name: Create release
|
||||
uses: https://code.forgejo.org/actions/forgejo-release@fc0488c944626f9265d87fbc4dd6c08f78014c63 # v2.7.3
|
||||
uses: https://code.forgejo.org/actions/forgejo-release@6a9510a9ea01b8b9b435933bf3c0fa45597ad530 # v2.11.3
|
||||
with:
|
||||
tag: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }}
|
||||
release-dir: assets
|
||||
@@ -77,7 +81,7 @@ jobs:
|
||||
needs: [check]
|
||||
runs-on: debian-trixie
|
||||
container:
|
||||
image: docker.io/library/node:24-trixie-slim@sha256:a16979bcaf12a2fd24888eb8e89874b11bd1038a3e3f1881c26a5e2b8fb92b5c
|
||||
image: docker.io/library/node:24-trixie-slim@sha256:28fd420825d8e922eab0fd91740c7cf88ddbdc8116a2b20a82049f0c946feb03
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
||||
/config*.toml*
|
||||
/git-pages
|
||||
/site
|
||||
/assets
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM docker.io/library/alpine:3 AS ca-certificates-builder
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
# Build supervisor.
|
||||
FROM docker.io/library/golang:1.25-alpine@sha256:e6898559d553d81b245eb8eadafcb3ca38ef320a9e26674df59d4f07a4fd0b07 AS supervisor-builder
|
||||
FROM docker.io/library/golang:1.26-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS supervisor-builder
|
||||
RUN apk --no-cache add git
|
||||
WORKDIR /build
|
||||
RUN git clone https://github.com/ochinchina/supervisord . && \
|
||||
@@ -11,12 +11,12 @@ RUN git clone https://github.com/ochinchina/supervisord . && \
|
||||
RUN GOBIN=/usr/bin go install -ldflags "-s -w"
|
||||
|
||||
# Build Caddy with S3 storage backend.
|
||||
FROM docker.io/library/caddy:2.10.2-builder@sha256:b6424b4a90e25fde5cb9fd8e1da716159a313869ac3ba1c34b11c50781acab81 AS caddy-builder
|
||||
FROM docker.io/library/caddy:2.11.2-builder@sha256:10ed0251c5cd1dbb4db0b71ad43121147961a51adfec35febce2c93ea25c24f4 AS caddy-builder
|
||||
RUN xcaddy build ${CADDY_VERSION} \
|
||||
--with=github.com/ss098/certmagic-s3@v0.0.0-20250922022452-8af482af5f39
|
||||
|
||||
# Build git-pages.
|
||||
FROM docker.io/library/golang:1.25-alpine@sha256:e6898559d553d81b245eb8eadafcb3ca38ef320a9e26674df59d4f07a4fd0b07 AS git-pages-builder
|
||||
FROM docker.io/library/golang:1.26-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS git-pages-builder
|
||||
RUN apk --no-cache add git
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
@@ -26,7 +26,7 @@ COPY src/ ./src/
|
||||
RUN go build -ldflags "-s -w" -o git-pages .
|
||||
|
||||
# Compose git-pages and Caddy.
|
||||
FROM docker.io/library/busybox:1.37.0-musl@sha256:03db190ed4c1ceb1c55d179a0940e2d71d42130636a780272629735893292223
|
||||
FROM docker.io/library/busybox:1.37.0-musl@sha256:19b646668802469d968a05342a601e78da4322a414a7c09b1c9ee25165042138
|
||||
COPY --from=ca-certificates-builder /etc/ssl/cert.pem /etc/ssl/cert.pem
|
||||
COPY --from=supervisor-builder /usr/bin/supervisord /bin/supervisord
|
||||
COPY --from=caddy-builder /usr/bin/caddy /bin/caddy
|
||||
|
||||
14
README.md
14
README.md
@@ -90,13 +90,16 @@ Features
|
||||
* Files with a certain name, when placed in the root of a site, have special functions:
|
||||
- [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.)
|
||||
- [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.
|
||||
- [Netlify `Basic-Auth:`][basic-auth] pseudo-header in the `_headers` file can be used to password-protect parts of a site, if enabled via the `[limits].allow-basic-auth` configuration option. **This is not a security feature: credentials are stored in cleartext and are accessible to anyone who can update the site. *Only* use it in low-stakes applications, e.g. preventing search engines from indexing parts of a site.** The authors of _git-pages_ shall not be held liable for any unauthorized information disclosures resulting from the use of this feature.
|
||||
* Incremental updates can be made using `PUT` or `PATCH` requests where the body contains an archive (both tar and zip are supported).
|
||||
- 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`.
|
||||
- Any archive entry that is a symlink to `/git/blobs/<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`.
|
||||
- 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.
|
||||
* 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.
|
||||
* Git LFS is not supported: it is a single-vendor specification/implementation with no stable Go API and a risk of misuse for reflected HTTP DoS attacks. A diagnostic is emitted for any files uploaded have the `filter=lfs` attribute set via `.gitattributes`.
|
||||
|
||||
[_redirects]: https://docs.netlify.com/manage/routing/redirects/overview/
|
||||
[_headers]: https://docs.netlify.com/manage/routing/headers/
|
||||
[basic-auth]: https://docs.netlify.com/manage/security/secure-access-to-sites/basic-authentication-with-custom-http-headers/
|
||||
[cors]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
|
||||
[go-git-sha256]: https://github.com/go-git/go-git/issues/706
|
||||
[whiteout]: https://docs.kernel.org/filesystems/overlayfs.html#whiteouts-and-opaque-directories
|
||||
@@ -117,8 +120,9 @@ The authorization flow for content updates (`PUT`, `PATCH`, `DELETE`, `POST` req
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
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.)
|
||||
5. **Default Deny:** Otherwise, the request is not authorized.
|
||||
5. **Forge Authorization (wildcard):** If the method is `PUT` or `PATCH` or `DELETE`, and (unless the method is `DELETE`) 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.
|
||||
6. **Forge Authorization (DNS allowlist):** If the method is `PUT` or `PATCH` or `DELETE`, and (unless the method is `DELETE`) the body contains an archive, and the request URL is `scheme://<user>.<host>/`, and a TXT record lookup at `_git-pages-forge-allowlist.<host>` returns a set of well-formed absolute URLs, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) grants push permissions to a repository at any of the URLs in the TXT records as determined by an API call to the forge, the request is authorized.
|
||||
7. **Default Deny:** Otherwise, the request is not authorized.
|
||||
|
||||
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:
|
||||
|
||||
@@ -133,10 +137,6 @@ Observability
|
||||
|
||||
_git-pages_ has robust observability features built in:
|
||||
* The metrics endpoint (bound to `:3002` by default) returns Go, pages server, and storage backend metrics in the [Prometheus](https://prometheus.io/) format.
|
||||
* Optional [Sentry](https://sentry.io/) integration allows greater visibility into the application. The `ENVIRONMENT` environment variable configures the deploy environment name (`development` by default).
|
||||
* If `SENTRY_DSN` environment variable is set, panics are reported to Sentry.
|
||||
* If `SENTRY_DSN` and `SENTRY_LOGS=1` environment variables are set, logs are uploaded to Sentry.
|
||||
* If `SENTRY_DSN` and `SENTRY_TRACING=1` environment variables are set, traces are uploaded to Sentry.
|
||||
* 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:
|
||||
* a Unix datagram socket: `unixgram//dev/log`;
|
||||
* TLS over TCP: `tcp+tls/host:port`;
|
||||
|
||||
37
conf/config.default.toml
Normal file
37
conf/config.default.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
# This is a configuration containing default values only. The `config.example.toml` file contains
|
||||
# a configuration more useful for demonstration purposes.
|
||||
|
||||
log-format = 'text'
|
||||
|
||||
[server]
|
||||
pages = 'tcp/localhost:3000'
|
||||
caddy = 'tcp/localhost:3001'
|
||||
metrics = 'tcp/localhost:3002'
|
||||
|
||||
[storage]
|
||||
type = 'fs'
|
||||
|
||||
[storage.fs]
|
||||
root = './data'
|
||||
|
||||
[limits]
|
||||
max-site-size = '128MB'
|
||||
max-manifest-size = '1MB'
|
||||
max-inline-file-size = '256B'
|
||||
git-large-object-threshold = '1MB'
|
||||
max-symlink-depth = 16
|
||||
update-timeout = '1m0s'
|
||||
concurrent-uploads = 1024
|
||||
max-heap-size-ratio = 0.5
|
||||
forbidden-domains = []
|
||||
allowed-repository-url-prefixes = []
|
||||
allowed-custom-headers = ['X-Clacks-Overhead']
|
||||
allow-basic-auth = false
|
||||
|
||||
[audit]
|
||||
node-id = 0
|
||||
collect = false
|
||||
include-ip = ''
|
||||
|
||||
[observability]
|
||||
slow-response-threshold = '500ms'
|
||||
@@ -1,5 +1,5 @@
|
||||
# Unless otherwise noted, every value in this file is the same
|
||||
# as the intrinsic default value.
|
||||
# This is a configuration used for demonstration purposes. The `config.default.toml` file contains
|
||||
# a configuration corresponding to default values only.
|
||||
|
||||
log-format = "text"
|
||||
|
||||
@@ -12,7 +12,7 @@ metrics = "tcp/localhost:3002"
|
||||
[[wildcard]] # non-default section
|
||||
domain = "codeberg.page"
|
||||
clone-url = "https://codeberg.org/<user>/<project>.git"
|
||||
index-repos = ["<user>.codeberg.page", "pages"]
|
||||
index-repo = "pages"
|
||||
index-repo-branch = "main"
|
||||
authorization = "forgejo"
|
||||
|
||||
@@ -48,10 +48,12 @@ max-inline-file-size = "256B"
|
||||
git-large-object-threshold = "1M"
|
||||
max-symlink-depth = 16
|
||||
update-timeout = "60s"
|
||||
concurrent-uploads = 1024
|
||||
max-heap-size-ratio = 0.5 # * RAM_size
|
||||
forbidden-domains = []
|
||||
allowed-repository-url-prefixes = []
|
||||
allowed-custom-headers = ["X-Clacks-Overhead"]
|
||||
allow-basic-auth = false
|
||||
|
||||
[audit]
|
||||
node-id = 0
|
||||
|
||||
12
flake.nix
12
flake.nix
@@ -46,12 +46,18 @@
|
||||
];
|
||||
};
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
pkgsStatic.musl
|
||||
];
|
||||
buildInputs = pkgs.lib.optionals pkgs.stdenv.targetPlatform.isLinux (
|
||||
with pkgs;
|
||||
[
|
||||
pkgsStatic.musl
|
||||
]
|
||||
);
|
||||
|
||||
ldflags = [
|
||||
"-linkmode external"
|
||||
"-X main.versionOverride=${self.shortRev or self.dirtyShortRev}"
|
||||
]
|
||||
++ pkgs.lib.optionals pkgs.stdenv.targetPlatform.isLinux [
|
||||
"-extldflags -static"
|
||||
"-s -w"
|
||||
];
|
||||
|
||||
40
go.mod
40
go.mod
@@ -6,48 +6,47 @@ require (
|
||||
codeberg.org/git-pages/go-headers v1.1.1
|
||||
codeberg.org/git-pages/go-slog-syslog v0.0.0-20251207093707-892f654e80b7
|
||||
github.com/KimMachineGun/automemlimit v0.7.5
|
||||
github.com/bits-and-blooms/bloom/v3 v3.7.1
|
||||
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
|
||||
github.com/creasty/defaults v1.8.0
|
||||
github.com/dghubble/trie v0.1.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/getsentry/sentry-go v0.41.0
|
||||
github.com/getsentry/sentry-go/slog v0.41.0
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260410103409-85b6241850b5
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2
|
||||
github.com/jpillora/backoff v1.0.0
|
||||
github.com/kankanreno/go-snowflake v1.2.0
|
||||
github.com/klauspost/compress v1.18.3
|
||||
github.com/klauspost/compress v1.18.5
|
||||
github.com/maypok86/otter/v2 v2.3.0
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/minio/minio-go/v7 v7.0.100
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/pquerna/cachecontrol v0.2.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/samber/slog-multi v1.7.0
|
||||
github.com/samber/slog-multi v1.8.0
|
||||
github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37
|
||||
github.com/valyala/fasttemplate v1.2.2
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/net v0.53.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.6.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-syslog/v4 v4.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
@@ -61,8 +60,8 @@ require (
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/samber/slog-common v0.19.0 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
github.com/samber/slog-common v0.21.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
@@ -70,8 +69,9 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
103
go.sum
103
go.sum
@@ -6,20 +6,24 @@ github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43Dw
|
||||
github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0=
|
||||
github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bloom/v3 v3.7.1 h1:WXovk4TRKZttAMJfoQx6K2DM0zNIt8w+c67UqO+etV0=
|
||||
github.com/bits-and-blooms/bloom/v3 v3.7.1/go.mod h1:rZzYLLje2dfzXfAkJNxQQHsKurAyK55KUnL43Euk0hU=
|
||||
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4=
|
||||
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
|
||||
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
@@ -33,28 +37,20 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/getsentry/sentry-go v0.41.0 h1:q/dQZOlEIb4lhxQSjJhQqtRr3vwrJ6Ahe1C9zv+ryRo=
|
||||
github.com/getsentry/sentry-go v0.41.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
github.com/getsentry/sentry-go/slog v0.41.0 h1:tjCFcH9KvG7XFufje4gCZTKVVCTxkuAdX7muwKImvD0=
|
||||
github.com/getsentry/sentry-go/slog v0.41.0/go.mod h1:YvnAFFkin7eJ8zNVsfeUC97ZTRw//P6JfeK285Aft+Y=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146/go.mod h1:QE/75B8tBSLNGyUUbA9tw3EGHoFtYOtypa2h8YJxsWI=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6 h1:Yo1MlE8LpvD0pr7mZ04b6hKZKQcPvLrQFgyY1jNMEyU=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6/go.mod h1:enMzPHv+9hL4B7tH7OJGQKNzCkMzXovUoaiXfsLF7Xs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260410103409-85b6241850b5 h1:r5Y4Hn9QwQj+u6vN0Ib1MipHkanYaG8Zj0kxsnv8Bu4=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20260410103409-85b6241850b5/go.mod h1:CdBVp7CXl9l3sOyNEog46cP1Pvx/hjCe9AD0mtaIUYU=
|
||||
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc=
|
||||
github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s=
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI=
|
||||
github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -63,10 +59,10 @@ github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2E
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/kankanreno/go-snowflake v1.2.0 h1:Zx2SctsH5pivIj9vyhwyDyQS23jcDJx4iT49Bjv81kk=
|
||||
github.com/kankanreno/go-snowflake v1.2.0/go.mod h1:6CZ+10PeVsFXKZUTYyJzPiRIjn1IXbInaWLCX/LDJ0g=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
|
||||
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -76,16 +72,14 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-syslog/v4 v4.3.0 h1:bbSpI/41bYK9iSdlYzcwvlxuLOE8yi4VTFmedtnghdA=
|
||||
github.com/leodido/go-syslog/v4 v4.3.0/go.mod h1:eJ8rUfDN5OS6dOkCOBYlg2a+hbAg6pJa99QXXgMrd98=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
|
||||
@@ -94,18 +88,16 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
|
||||
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -122,16 +114,16 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI=
|
||||
github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M=
|
||||
github.com/samber/slog-multi v1.7.0 h1:GKhbkxU3ujkyMsefkuz4qvE6EcgtSuqjFisPnfdzVLI=
|
||||
github.com/samber/slog-multi v1.7.0/go.mod h1:qTqzmKdPpT0h4PFsTN5rYRgLwom1v+fNGuIrl1Xnnts=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/samber/slog-common v0.21.0 h1:Wo2hTly1Br5RjYqX/BTWJJeDnTE85oWk/7vqlpZuAUc=
|
||||
github.com/samber/slog-common v0.21.0/go.mod h1:d/6OaSlzdkl9PFpfRLgn8FwY1OW6EFmPtBpsHX4MrU0=
|
||||
github.com/samber/slog-multi v1.8.0 h1:E05c1wnQ+8M58oQDBABlJ4TEIJWssNgtckso3zlaLlI=
|
||||
github.com/samber/slog-multi v1.8.0/go.mod h1:6+3j/ILxDvAcLD75YdQAm6iKWu6AmwlohLgQxL/2aiI=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -145,6 +137,8 @@ github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
|
||||
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
|
||||
github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37 h1:K11tjwz8zTTSZkz4TUjfLN+y8uJWP38BbyPqZ2yB/Yk=
|
||||
github.com/tj/go-redirects v0.0.0-20200911105812-fd1ba1020b37/go.mod h1:E0E2H2gQA+uoi27VCSU+a/BULPtadQA78q3cpTjZbZw=
|
||||
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
|
||||
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
@@ -155,18 +149,19 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -14,11 +14,17 @@ schema = 3
|
||||
version = "v0.6.2"
|
||||
hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU="
|
||||
[mod."github.com/ProtonMail/go-crypto"]
|
||||
version = "v1.3.0"
|
||||
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
|
||||
version = "v1.4.1"
|
||||
hash = "sha256-6iGAFCjoNveY+ipbKqq2gt+RXpi2eQyPXAY01rxPcWc="
|
||||
[mod."github.com/beorn7/perks"]
|
||||
version = "v1.0.1"
|
||||
hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4="
|
||||
[mod."github.com/bits-and-blooms/bitset"]
|
||||
version = "v1.24.2"
|
||||
hash = "sha256-hT88EpdWmKnqdxApJhs/aIAptf33HmtSp2KXPI+Ym7o="
|
||||
[mod."github.com/bits-and-blooms/bloom/v3"]
|
||||
version = "v3.7.1"
|
||||
hash = "sha256-KZduCu+k4+xqBcFRTfg8Yc/PEf5jfpjn0I1YoxfnVPo="
|
||||
[mod."github.com/c2h5oh/datasize"]
|
||||
version = "v0.0.0-20231215233829-aa82cc1e6500"
|
||||
hash = "sha256-8MqL7xCvE6fIjanz2jwkaLP1OE5kLu62TOcQx452DHQ="
|
||||
@@ -26,8 +32,8 @@ schema = 3
|
||||
version = "v2.3.0"
|
||||
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
|
||||
[mod."github.com/cloudflare/circl"]
|
||||
version = "v1.6.1"
|
||||
hash = "sha256-Dc69V12eIFnJoUNmwg6VKXHfAMijbAeEVSDe8AiOaLo="
|
||||
version = "v1.6.3"
|
||||
hash = "sha256-XZm4EastgX67Dgm5BpOEW/PY4aLcHM/O8+Xbz26PuTY="
|
||||
[mod."github.com/creasty/defaults"]
|
||||
version = "v1.8.0"
|
||||
hash = "sha256-I1LE1cfOhMS5JxB7+fWTKieefw2Gge1UhIZh+A6pa6s="
|
||||
@@ -47,29 +53,20 @@ schema = 3
|
||||
version = "v1.18.1"
|
||||
hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4="
|
||||
[mod."github.com/fatih/color"]
|
||||
version = "v1.18.0"
|
||||
hash = "sha256-pP5y72FSbi4j/BjyVq/XbAOFjzNjMxZt2R/lFFxGWvY="
|
||||
[mod."github.com/getsentry/sentry-go"]
|
||||
version = "v0.40.0"
|
||||
hash = "sha256-mJ+EzM8WRzJ2Yp7ithDJNceU4+GbzQyi46yc8J8d13Y="
|
||||
[mod."github.com/getsentry/sentry-go/slog"]
|
||||
version = "v0.40.0"
|
||||
hash = "sha256-uc9TpKiWMEpRbxwV2uGQeq1DDdZi+APOgu2StVzzEkw="
|
||||
version = "v1.19.0"
|
||||
hash = "sha256-YgMm1nid8yigNLG6aHfuMbsvMI1UYVf/Rkg44pp/NTU="
|
||||
[mod."github.com/go-git/gcfg/v2"]
|
||||
version = "v2.0.2"
|
||||
hash = "sha256-icqMDeC/tEg/3979EuEN67Ml5KjdDA0R3QvR6iLLrSI="
|
||||
[mod."github.com/go-git/go-billy/v6"]
|
||||
version = "v6.0.0-20251217170237-e9738f50a3cd"
|
||||
hash = "sha256-b2yunYcPUiLTU+Rr8qTBdsDEfsIhZDYmyqKW5udmpFY="
|
||||
version = "v6.0.0-20260410103409-85b6241850b5"
|
||||
hash = "sha256-2qQeUjkswSqI9joCKhvMB1lvnKHL9INbAzy4UBveHsw="
|
||||
[mod."github.com/go-git/go-git/v6"]
|
||||
version = "v6.0.0-20251224103503-78aff6aa5ea9"
|
||||
hash = "sha256-kYjDqH0NZ+sxQnj5K8xKfO2WOVKtQ/7tWcqY6KYqAZE="
|
||||
version = "v6.0.0-alpha.2"
|
||||
hash = "sha256-nUjRn1uIZKhIKqdNXfTirGtm07XCUKF2z3aat9O0dqM="
|
||||
[mod."github.com/go-ini/ini"]
|
||||
version = "v1.67.0"
|
||||
hash = "sha256-V10ahGNGT+NLRdKUyRg1dos5RxLBXBk1xutcnquc/+4="
|
||||
[mod."github.com/golang/groupcache"]
|
||||
version = "v0.0.0-20241129210726-2c02b8208cf8"
|
||||
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
|
||||
[mod."github.com/google/uuid"]
|
||||
version = "v1.6.0"
|
||||
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
|
||||
@@ -80,11 +77,11 @@ schema = 3
|
||||
version = "v1.2.0"
|
||||
hash = "sha256-713xGEqjwaUGIu2EHII5sldWmcquFpxZmte/7R/O6LA="
|
||||
[mod."github.com/kevinburke/ssh_config"]
|
||||
version = "v1.4.0"
|
||||
hash = "sha256-UclxB7Ll1FZCgU2SrGkiGdr4CoSRJ127MNnZtxKTsvg="
|
||||
version = "v1.6.0"
|
||||
hash = "sha256-i/EYNJx0+HbAGFVoiKV4QF/zqb4fWewh+bpBKUkXDCc="
|
||||
[mod."github.com/klauspost/compress"]
|
||||
version = "v1.18.2"
|
||||
hash = "sha256-mRa+6qEi5joqQao13ZFogmq67rOQzHCVbCCjKA+HKEc="
|
||||
version = "v1.18.5"
|
||||
hash = "sha256-H9b5iFJf4XbEnkGQCjGQAJ3aYhVDiolKrDewTbhuzQo="
|
||||
[mod."github.com/klauspost/cpuid/v2"]
|
||||
version = "v2.3.0"
|
||||
hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc="
|
||||
@@ -95,8 +92,8 @@ schema = 3
|
||||
version = "v4.3.0"
|
||||
hash = "sha256-fCJ2rgrrPR/Ey/PoAsJhd8Sl8mblAnnMAmBuoWFBTgg="
|
||||
[mod."github.com/mattn/go-colorable"]
|
||||
version = "v0.1.13"
|
||||
hash = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8="
|
||||
version = "v0.1.14"
|
||||
hash = "sha256-JC60PjKj7MvhZmUHTZ9p372FV72I9Mxvli3fivTbxuA="
|
||||
[mod."github.com/mattn/go-isatty"]
|
||||
version = "v0.0.20"
|
||||
hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ="
|
||||
@@ -104,14 +101,14 @@ schema = 3
|
||||
version = "v2.3.0"
|
||||
hash = "sha256-ELzmi/s2WqDeUmzSGnfx+ys2Hs28XHqF7vlEzyRotIA="
|
||||
[mod."github.com/minio/crc64nvme"]
|
||||
version = "v1.1.0"
|
||||
hash = "sha256-OwlE70X91WO4HdbpGsOaB4w12Qrk0duCpfLeAskiqY8="
|
||||
version = "v1.1.1"
|
||||
hash = "sha256-RVVi/gWPBEQqcW4n+KIKxlA3uY5+77e2rhkVk8fFNUo="
|
||||
[mod."github.com/minio/md5-simd"]
|
||||
version = "v1.1.2"
|
||||
hash = "sha256-vykcXvy2VBBAXnJott/XsGTT0gk2UL36JzZKfJ1KAUY="
|
||||
[mod."github.com/minio/minio-go/v7"]
|
||||
version = "v7.0.97"
|
||||
hash = "sha256-IwF14tWVYjBi28jUG9iFYd4Lpbc7Fvyy0zRzEZ82UEE="
|
||||
version = "v7.0.100"
|
||||
hash = "sha256-MjWYoX4b+OOSOkjsitQQqcTbpQ7CYNghN9XCdrqgYaM="
|
||||
[mod."github.com/munnerz/goautoneg"]
|
||||
version = "v0.0.0-20191010083416-a7dc8b61c822"
|
||||
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
|
||||
@@ -119,8 +116,8 @@ schema = 3
|
||||
version = "v0.0.0-20210728143218-7b4eea64cf58"
|
||||
hash = "sha256-QI+F1oPLOOtwNp8+m45OOoSfYFs3QVjGzE0rFdpF/IA="
|
||||
[mod."github.com/pelletier/go-toml/v2"]
|
||||
version = "v2.2.4"
|
||||
hash = "sha256-8qQIPldbsS5RO8v/FW/se3ZsAyvLzexiivzJCbGRg2Q="
|
||||
version = "v2.3.0"
|
||||
hash = "sha256-3ftKBqSwUp5rs10NigReAJ8RxfnP4Aol45EkP0XRaa4="
|
||||
[mod."github.com/philhofer/fwd"]
|
||||
version = "v1.2.0"
|
||||
hash = "sha256-cGx2/0QQay46MYGZuamFmU0TzNaFyaO+J7Ddzlr/3dI="
|
||||
@@ -152,14 +149,14 @@ schema = 3
|
||||
version = "v1.6.0"
|
||||
hash = "sha256-rJB7h3KuH1DPp5n4dY3MiGnV1Y96A10lf5OUl+MLkzU="
|
||||
[mod."github.com/samber/lo"]
|
||||
version = "v1.52.0"
|
||||
hash = "sha256-xgMsPJv3rydHH10NZU8wz/DhK2VbbR8ymivOg1ChTp0="
|
||||
version = "v1.53.0"
|
||||
hash = "sha256-RCf4Buf357TTWQnMPSWKrfdJ4L/RqOHNBD0g3+VpMw8="
|
||||
[mod."github.com/samber/slog-common"]
|
||||
version = "v0.19.0"
|
||||
hash = "sha256-OYXVbZML7c3mFClVy8GEnNoWW+4OfcBsxWDtKh1u7B8="
|
||||
version = "v0.21.0"
|
||||
hash = "sha256-i9Nl4xRbk8qYM+0n48IQ6+vGZiS7xFe+GgyV3X9/Spc="
|
||||
[mod."github.com/samber/slog-multi"]
|
||||
version = "v1.6.0"
|
||||
hash = "sha256-uebbTcvsBP2LdOUIjDptES+HZOXxThnIt3+FKL0qJy4="
|
||||
version = "v1.8.0"
|
||||
hash = "sha256-KsFwNP9QMDr8golYoevpGtcqUuCrIT7zmGwR7/E6gzo="
|
||||
[mod."github.com/sergi/go-diff"]
|
||||
version = "v1.4.0"
|
||||
hash = "sha256-rs9NKpv/qcQEMRg7CmxGdP4HGuFdBxlpWf9LbA9wS4k="
|
||||
@@ -167,8 +164,8 @@ schema = 3
|
||||
version = "v1.11.1"
|
||||
hash = "sha256-sWfjkuKJyDllDEtnM8sb/pdLzPQmUYWYtmeWz/5suUc="
|
||||
[mod."github.com/tinylib/msgp"]
|
||||
version = "v1.3.0"
|
||||
hash = "sha256-PnpndO7k5Yl036vhWJGDsrcz0jsTX8sUiTqm/D3rAVw="
|
||||
version = "v1.6.1"
|
||||
hash = "sha256-R2LutHQFZ7HAqeyzHqzMeyAJHxcYc+n1x7ysyrXefmQ="
|
||||
[mod."github.com/tj/assert"]
|
||||
version = "v0.0.3"
|
||||
hash = "sha256-4xhmZcHpUafabaXejE9ucVnGxG/txomvKzBg6cbkusg="
|
||||
@@ -184,18 +181,24 @@ schema = 3
|
||||
[mod."go.yaml.in/yaml/v2"]
|
||||
version = "v2.4.2"
|
||||
hash = "sha256-oC8RWdf1zbMYCtmR0ATy/kCkhIwPR9UqFZSMOKLVF/A="
|
||||
[mod."go.yaml.in/yaml/v3"]
|
||||
version = "v3.0.4"
|
||||
hash = "sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4="
|
||||
[mod."golang.org/x/crypto"]
|
||||
version = "v0.46.0"
|
||||
hash = "sha256-I8N/spcw3/h0DFA+V1WK38HctckWIB9ep93DEVCALxU="
|
||||
version = "v0.50.0"
|
||||
hash = "sha256-vC1BJT7+3UBWLyEE5n3to0NKhMo6m2HGow2HiFgpQLo="
|
||||
[mod."golang.org/x/net"]
|
||||
version = "v0.48.0"
|
||||
hash = "sha256-oZpddsiJwWCH3Aipa+XXpy7G/xHY5fEagUSok7T0bXE="
|
||||
version = "v0.53.0"
|
||||
hash = "sha256-G9gKLmyaf6lIV429NKX+YlL6oUPJwlv+BrG6qGhzvmU="
|
||||
[mod."golang.org/x/sync"]
|
||||
version = "v0.20.0"
|
||||
hash = "sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y="
|
||||
[mod."golang.org/x/sys"]
|
||||
version = "v0.39.0"
|
||||
hash = "sha256-dxTBu/JAWUkPbjFIXXRFdhQWyn+YyEpIC+tWqGo0Y6U="
|
||||
version = "v0.43.0"
|
||||
hash = "sha256-aDQXqSTZES2l/132PBxhZN4ywldpPyfm7LByYCHzzwM="
|
||||
[mod."golang.org/x/text"]
|
||||
version = "v0.32.0"
|
||||
hash = "sha256-9PXtWBKKY9rG4AgjSP4N+I1DhepXhy8SF/vWSIDIoWs="
|
||||
version = "v0.36.0"
|
||||
hash = "sha256-/0t9C6Mc8kYjxweFB0us2lGKo8GovHhBiq5nlMOppC0="
|
||||
[mod."google.golang.org/protobuf"]
|
||||
version = "v1.36.11"
|
||||
hash = "sha256-7W+6jntfI/awWL3JP6yQedxqP5S9o3XvPgJ2XxxsIeE="
|
||||
|
||||
25
main.go
25
main.go
@@ -2,6 +2,27 @@
|
||||
|
||||
package main
|
||||
|
||||
import gitpages "codeberg.org/git-pages/git-pages/src"
|
||||
import (
|
||||
"runtime/debug"
|
||||
|
||||
func main() { gitpages.Main() }
|
||||
git_pages "codeberg.org/git-pages/git-pages/src"
|
||||
)
|
||||
|
||||
// By default the version information is retrieved from VCS. If not available during build,
|
||||
// override this variable using linker flags to change the displayed version.
|
||||
// Example: `-ldflags "-X main.versionOverride=v1.2.3"`
|
||||
var versionOverride = ""
|
||||
|
||||
func extractVersion() string {
|
||||
if versionOverride != "" {
|
||||
return versionOverride
|
||||
} else if buildInfo, ok := debug.ReadBuildInfo(); ok {
|
||||
return buildInfo.Main.Version
|
||||
} else {
|
||||
panic("version information not available")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
git_pages.Main(extractVersion())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
{
|
||||
"matchPackageNames": ["actions/buildah-simple"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["github.com/pelletier/go-toml/v2"],
|
||||
"enabled": false // added AGENTS.md; v2.3.0 has been manually reviewed
|
||||
}
|
||||
],
|
||||
"automerge": false,
|
||||
|
||||
@@ -102,12 +102,18 @@ func (record *AuditRecord) DescribePrincipal() string {
|
||||
if record.Principal.GetIpAddress() != "" {
|
||||
items = append(items, record.Principal.GetIpAddress())
|
||||
}
|
||||
if record.Principal.GetForgeUser() != nil {
|
||||
items = append(items, fmt.Sprintf("%s/%s(%d)",
|
||||
record.Principal.GetForgeUser().GetOrigin(),
|
||||
record.Principal.GetForgeUser().GetHandle(),
|
||||
record.Principal.GetForgeUser().GetId()))
|
||||
}
|
||||
if record.Principal.GetCliAdmin() {
|
||||
items = append(items, "<cli-admin>")
|
||||
}
|
||||
}
|
||||
if len(items) > 0 {
|
||||
return strings.Join(items, ";")
|
||||
return strings.Join(items, ",")
|
||||
} else {
|
||||
return "<unknown>"
|
||||
}
|
||||
|
||||
314
src/auth.go
314
src/auth.go
@@ -106,6 +106,8 @@ type Authorization struct {
|
||||
repoURLs []string
|
||||
// Only the exact branch is allowed.
|
||||
branch string
|
||||
// The authorized forge user.
|
||||
forgeUser *ForgeUser
|
||||
}
|
||||
|
||||
func authorizeDNSChallenge(r *http.Request) (*Authorization, error) {
|
||||
@@ -177,7 +179,7 @@ func authorizeDNSChallenge(r *http.Request) (*Authorization, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authorizeDNSAllowlist(r *http.Request) (*Authorization, error) {
|
||||
func authorizeDNSAllowlist(r *http.Request, scope string) (*Authorization, error) {
|
||||
host, err := GetHost(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -188,7 +190,7 @@ func authorizeDNSAllowlist(r *http.Request) (*Authorization, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowlistHostname := fmt.Sprintf("_git-pages-repository.%s", host)
|
||||
allowlistHostname := fmt.Sprintf("_%s.%s", scope, host)
|
||||
records, err := net.LookupTXT(allowlistHostname)
|
||||
if err != nil {
|
||||
return nil, AuthError{http.StatusUnauthorized,
|
||||
@@ -265,8 +267,8 @@ func authorizeWildcardMatchSite(r *http.Request, pattern *WildcardPattern) (*Aut
|
||||
}
|
||||
|
||||
if userName, found := pattern.Matches(host); found {
|
||||
repoURLs, branch := pattern.ApplyTemplate(userName, projectName)
|
||||
return &Authorization{repoURLs, branch}, nil
|
||||
repoURL, branch := pattern.ApplyTemplate(userName, projectName)
|
||||
return &Authorization{repoURLs: []string{repoURL}, branch: branch}, nil
|
||||
} else {
|
||||
return nil, AuthError{
|
||||
http.StatusUnauthorized,
|
||||
@@ -313,17 +315,16 @@ func authorizeCodebergPagesV2(r *http.Request) (*Authorization, error) {
|
||||
if domainParts[0] == "page" && domainParts[1] == "codeberg" {
|
||||
// map of domain names to allowed repository and branch:
|
||||
// * {username}.codeberg.page =>
|
||||
// https://codeberg.org/{username}/pages.git#main
|
||||
// https://codeberg.org/{username}/pages.git#pages
|
||||
// * {reponame}.{username}.codeberg.page =>
|
||||
// https://codeberg.org/{username}/{reponame}.git#pages
|
||||
// * {branch}.{reponame}.{username}.codeberg.page =>
|
||||
// https://codeberg.org/{username}/{reponame}.git#{branch}
|
||||
username := domainParts[2]
|
||||
reponame := "pages"
|
||||
branch := "main"
|
||||
branch := "pages"
|
||||
if len(domainParts) >= 4 {
|
||||
reponame = domainParts[3]
|
||||
branch = "pages"
|
||||
}
|
||||
if len(domainParts) == 5 {
|
||||
branch = domainParts[4]
|
||||
@@ -345,7 +346,7 @@ func authorizeCodebergPagesV2(r *http.Request) (*Authorization, error) {
|
||||
}
|
||||
|
||||
// Checks whether an operation that enables enumerating site contents is allowed.
|
||||
func AuthorizeMetadataRetrieval(r *http.Request) (*Authorization, error) {
|
||||
func AuthorizeMetadataRetrieval(r *http.Request, hasBasicAuth bool) (*Authorization, error) {
|
||||
causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
|
||||
|
||||
auth := authorizeInsecure(r)
|
||||
@@ -363,36 +364,38 @@ func AuthorizeMetadataRetrieval(r *http.Request) (*Authorization, error) {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
for _, pattern := range wildcards {
|
||||
auth, err = authorizeWildcardMatchHost(r, pattern)
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
return nil, err
|
||||
} else {
|
||||
logc.Printf(r.Context(), "auth: wildcard %s\n", pattern.GetHost())
|
||||
return auth, nil
|
||||
// Normally, sites that correspond to a forge via a wildcard match are considered completely
|
||||
// public and safe to retrieve without authorization. However, this is no longer the case if
|
||||
// they have password-protected sections.
|
||||
if !hasBasicAuth {
|
||||
for _, pattern := range wildcards {
|
||||
auth, err = authorizeWildcardMatchHost(r, pattern)
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
return nil, err
|
||||
} else {
|
||||
logc.Printf(r.Context(), "auth: wildcard %s\n", pattern.GetHost())
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.Feature("codeberg-pages-compat") {
|
||||
auth, err = authorizeCodebergPagesV2(r)
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
return nil, err
|
||||
} else {
|
||||
logc.Printf(r.Context(), "auth: codeberg %s\n", r.Host)
|
||||
return auth, nil
|
||||
if config.Feature("codeberg-pages-compat") {
|
||||
auth, err = authorizeCodebergPagesV2(r)
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
return nil, err
|
||||
} else {
|
||||
logc.Printf(r.Context(), "auth: codeberg %s\n", r.Host)
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, joinErrors(causes...)
|
||||
}
|
||||
|
||||
// Returns `repoURLs, err` where if `err == nil` then the request is authorized to clone from
|
||||
// any repository URL included in `repoURLs` (by case-insensitive comparison), or any URL at all
|
||||
// if `repoURLs == nil`.
|
||||
func AuthorizeUpdateFromRepository(r *http.Request) (*Authorization, error) {
|
||||
causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
|
||||
|
||||
@@ -418,7 +421,7 @@ func AuthorizeUpdateFromRepository(r *http.Request) (*Authorization, error) {
|
||||
|
||||
// DNS allowlist gives authority to update but not delete.
|
||||
if r.Method == http.MethodPut || r.Method == http.MethodPost {
|
||||
auth, err = authorizeDNSAllowlist(r)
|
||||
auth, err = authorizeDNSAllowlist(r, "git-pages-repository")
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
@@ -460,21 +463,23 @@ func AuthorizeUpdateFromRepository(r *http.Request) (*Authorization, error) {
|
||||
return nil, joinErrors(causes...)
|
||||
}
|
||||
|
||||
func checkAllowedURLPrefix(repoURL string) error {
|
||||
func checkAllowedURLPrefixes(repoURLs ...string) error {
|
||||
if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 {
|
||||
allowedPrefix := false
|
||||
repoURL = strings.ToLower(repoURL)
|
||||
for _, allowedRepoURLPrefix := range config.Limits.AllowedRepositoryURLPrefixes {
|
||||
if strings.HasPrefix(repoURL, strings.ToLower(allowedRepoURLPrefix)) {
|
||||
allowedPrefix = true
|
||||
break
|
||||
for _, repoURL := range repoURLs {
|
||||
allowedPrefix := false
|
||||
repoURL = strings.ToLower(repoURL)
|
||||
for _, allowedRepoURLPrefix := range config.Limits.AllowedRepositoryURLPrefixes {
|
||||
if strings.HasPrefix(repoURL, strings.ToLower(allowedRepoURLPrefix)) {
|
||||
allowedPrefix = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !allowedPrefix {
|
||||
return AuthError{
|
||||
http.StatusUnauthorized,
|
||||
fmt.Sprintf("clone URL not in prefix allowlist %v",
|
||||
config.Limits.AllowedRepositoryURLPrefixes),
|
||||
if !allowedPrefix {
|
||||
return AuthError{
|
||||
http.StatusUnauthorized,
|
||||
fmt.Sprintf("clone URL %v not in prefix allowlist %v",
|
||||
repoURL, config.Limits.AllowedRepositoryURLPrefixes),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -507,7 +512,7 @@ func AuthorizeRepository(repoURL string, auth *Authorization) error {
|
||||
return nil // any
|
||||
}
|
||||
|
||||
if err = checkAllowedURLPrefix(repoURL); err != nil {
|
||||
if err = checkAllowedURLPrefixes(repoURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -575,6 +580,11 @@ func checkGogsRepositoryPushPermission(baseURL *url.URL, authorization string) e
|
||||
http.StatusNotFound,
|
||||
fmt.Sprintf("no repository %s", ownerAndRepo),
|
||||
}
|
||||
} else if response.StatusCode == http.StatusUnauthorized {
|
||||
return AuthError{
|
||||
http.StatusUnauthorized,
|
||||
fmt.Sprintf("no access to %s or invalid token", ownerAndRepo),
|
||||
}
|
||||
} else if response.StatusCode != http.StatusOK {
|
||||
return AuthError{
|
||||
http.StatusServiceUnavailable,
|
||||
@@ -609,9 +619,89 @@ func checkGogsRepositoryPushPermission(baseURL *url.URL, authorization string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorizeForgeWithToken(r *http.Request) (*Authorization, error) {
|
||||
authorization := r.Header.Get("Forge-Authorization")
|
||||
if authorization == "" {
|
||||
// Gogs, Gitea, and Forgejo all support the same API here.
|
||||
func fetchGogsAuthorizedUser(baseURL *url.URL, forgeToken string) (*ForgeUser, error) {
|
||||
request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{
|
||||
Path: "/api/v1/user",
|
||||
}).String(), nil)
|
||||
if err != nil {
|
||||
panic(err) // misconfiguration
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", forgeToken)
|
||||
|
||||
httpClient := http.Client{Timeout: 5 * time.Second}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, AuthError{
|
||||
http.StatusServiceUnavailable,
|
||||
fmt.Sprintf("cannot fetch authorized forge user: %s", err),
|
||||
}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, AuthError{
|
||||
http.StatusServiceUnavailable,
|
||||
fmt.Sprintf(
|
||||
"cannot fetch authorized forge user: GET %s returned %s",
|
||||
request.URL,
|
||||
response.Status,
|
||||
),
|
||||
}
|
||||
}
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
|
||||
var userInfo struct {
|
||||
ID int64
|
||||
Login string
|
||||
}
|
||||
if err = decoder.Decode(&userInfo); err != nil {
|
||||
return nil, errors.Join(AuthError{
|
||||
http.StatusServiceUnavailable,
|
||||
fmt.Sprintf(
|
||||
"cannot fetch authorized forge user: GET %s returned malformed JSON",
|
||||
request.URL,
|
||||
),
|
||||
}, err)
|
||||
}
|
||||
|
||||
origin := request.URL.Hostname()
|
||||
return &ForgeUser{
|
||||
Origin: &origin,
|
||||
Id: &userInfo.ID,
|
||||
Handle: &userInfo.Login,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check whether a forge token has access to a repository, and if it does, which user it
|
||||
// belongs to. Precondition: `repoURL` is well-formed.
|
||||
func authorizeGogsUser(repoURL string, forgeToken string) (*Authorization, error) {
|
||||
parsedRepoURL, err := url.Parse(repoURL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = checkGogsRepositoryPushPermission(parsedRepoURL, forgeToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authorizedUser, err := fetchGogsAuthorizedUser(parsedRepoURL, forgeToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Authorization{
|
||||
repoURLs: []string{repoURL},
|
||||
forgeUser: authorizedUser,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validates a provided forge token against a repository URL constructed by mapping the host
|
||||
// and project name via the `[[wildcard]]` section of the configuration file.
|
||||
func authorizeForgeWildcard(r *http.Request) (*Authorization, error) {
|
||||
forgeToken := r.Header.Get("Forge-Authorization")
|
||||
if forgeToken == "" {
|
||||
return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"}
|
||||
}
|
||||
|
||||
@@ -627,40 +717,61 @@ func authorizeForgeWithToken(r *http.Request) (*Authorization, error) {
|
||||
|
||||
var errs []error
|
||||
for _, pattern := range wildcards {
|
||||
if !pattern.Authorization {
|
||||
continue
|
||||
}
|
||||
|
||||
if userName, found := pattern.Matches(host); found {
|
||||
repoURLs, branch := pattern.ApplyTemplate(userName, projectName)
|
||||
for _, repoURL := range repoURLs {
|
||||
parsedRepoURL, err := url.Parse(repoURL)
|
||||
if pattern.Authorization {
|
||||
if userName, found := pattern.Matches(host); found {
|
||||
repoURL, branch := pattern.ApplyTemplate(userName, projectName)
|
||||
auth, err := authorizeGogsUser(repoURL, forgeToken)
|
||||
if err != nil {
|
||||
panic(err) // misconfiguration
|
||||
}
|
||||
|
||||
if err = checkGogsRepositoryPushPermission(parsedRepoURL, authorization); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
} else {
|
||||
auth.branch = branch
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
errs = append(errs, AuthError{http.StatusUnauthorized, "no matching wildcard domain"})
|
||||
}
|
||||
|
||||
// This will actually be ignored by the caller of AuthorizeUpdateFromArchive,
|
||||
// but we return this information as it makes sense to do contextually here.
|
||||
return &Authorization{
|
||||
[]string{repoURL},
|
||||
branch,
|
||||
}, nil
|
||||
errs = append([]error{
|
||||
AuthError{http.StatusUnauthorized, "not authorized by forge (wildcard)"},
|
||||
}, errs...)
|
||||
return nil, joinErrors(errs...)
|
||||
}
|
||||
|
||||
// Validates a provided forge token against a repository URL extracted from the DNS allowlist
|
||||
// records of the target domain specified in `_git-pages-forge-authorization.*`.
|
||||
func authorizeForgeDNSAllowlist(r *http.Request) (*Authorization, error) {
|
||||
forgeToken := r.Header.Get("Forge-Authorization")
|
||||
if forgeToken == "" {
|
||||
return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
if dnsAuth, err := authorizeDNSAllowlist(r, "git-pages-forge-allowlist"); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else if dnsAuth != nil {
|
||||
// DNS allows uploads from some repositories, but we don't know yet if the forge token
|
||||
// has a push permission to any of these repositories.
|
||||
for _, repoURL := range dnsAuth.repoURLs {
|
||||
auth, err := authorizeGogsUser(repoURL, forgeToken)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
// There is both DNS authorization and forge authorization.
|
||||
return auth, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errs = append([]error{
|
||||
AuthError{http.StatusUnauthorized, "not authorized by forge"},
|
||||
AuthError{http.StatusUnauthorized, "not authorized by forge (DNS allowlist)"},
|
||||
}, errs...)
|
||||
return nil, joinErrors(errs...)
|
||||
}
|
||||
|
||||
func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) {
|
||||
func authorizeDNSChallengeOrForgeWithToken(r *http.Request) (*Authorization, error) {
|
||||
causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
|
||||
|
||||
if err := CheckForbiddenDomain(r); err != nil {
|
||||
@@ -672,35 +783,70 @@ func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Token authorization allows updating a site on a wildcard domain from an archive.
|
||||
auth, err := authorizeForgeWithToken(r)
|
||||
// DNS challenge gives absolute authority.
|
||||
auth, err := authorizeDNSChallenge(r)
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
return nil, err
|
||||
} else {
|
||||
logc.Printf(r.Context(), "auth: forge token: allow\n")
|
||||
logc.Println(r.Context(), "auth: DNS challenge: allow *")
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 {
|
||||
causes = append(causes, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"})
|
||||
// Token authorization allows updating a site on a wildcard domain from an archive.
|
||||
// This sub-method uses the `[[wildcard]]` configuration section to derive repository URL.
|
||||
auth, err = authorizeForgeWildcard(r)
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
return nil, err
|
||||
} else {
|
||||
// DNS challenge gives absolute authority.
|
||||
auth, err = authorizeDNSChallenge(r)
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
return nil, err
|
||||
} else {
|
||||
logc.Println(r.Context(), "auth: DNS challenge")
|
||||
return auth, nil
|
||||
}
|
||||
logc.Printf(r.Context(), "auth: forge (wildcard): allow\n")
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Token authorization allows updating a site on a wildcard domain from an archive.
|
||||
// This sub-method uses the DNS allowlist authorization mechanism to derive repository URL.
|
||||
auth, err = authorizeForgeDNSAllowlist(r)
|
||||
if err != nil && IsUnauthorized(err) {
|
||||
causes = append(causes, err)
|
||||
} else if err != nil { // bad request
|
||||
return nil, err
|
||||
} else {
|
||||
logc.Printf(r.Context(), "auth: forge (DNS allowlist): allow\n")
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
return nil, joinErrors(causes...)
|
||||
}
|
||||
|
||||
func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) {
|
||||
auth, err := authorizeDNSChallengeOrForgeWithToken(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If only uploads from specific repositories are allowed, then only forge authorization
|
||||
// is acceptable, and the repository must match the configured limits.
|
||||
if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 {
|
||||
if len(auth.repoURLs) == 0 {
|
||||
logc.Println(r.Context(), "auth: DNS challenge: deny (limits)")
|
||||
return nil, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"}
|
||||
}
|
||||
|
||||
if err = checkAllowedURLPrefixes(auth.repoURLs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func AuthorizeDeletion(r *http.Request) (*Authorization, error) {
|
||||
return authorizeDNSChallengeOrForgeWithToken(r)
|
||||
}
|
||||
|
||||
func CheckForbiddenDomain(r *http.Request) error {
|
||||
host, err := GetHost(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -124,9 +124,13 @@ type Backend interface {
|
||||
// Delete a manifest.
|
||||
DeleteManifest(ctx context.Context, name string, opts ModifyManifestOptions) error
|
||||
|
||||
// Iterate through all manifests. Whether manifests that are newly added during iteration
|
||||
// will appear in the results is unspecified.
|
||||
EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error]
|
||||
// Iterate through metadata of all manifests. Whether manifests that are newly added during
|
||||
// iteration will appear in the results is unspecified.
|
||||
EnumerateManifests(ctx context.Context) iter.Seq2[*ManifestMetadata, error]
|
||||
|
||||
// Iterate through contents of all manifests. Same considerations apply as for
|
||||
// `EnumerateManifests`.
|
||||
GetAllManifests(ctx context.Context) iter.Seq2[tuple[*ManifestMetadata, *Manifest], error]
|
||||
|
||||
// Check whether a domain has any deployments.
|
||||
CheckDomain(ctx context.Context, domain string) (found bool, err error)
|
||||
@@ -134,21 +138,27 @@ type Backend interface {
|
||||
// Create a domain. This allows us to start serving content for the domain.
|
||||
CreateDomain(ctx context.Context, domain string) error
|
||||
|
||||
// Freeze a domain. This allows a site to be administratively locked, e.g. if it
|
||||
// Freeze a domain. This allows a site to be administratively locked, e.g. if it
|
||||
// is discovered serving abusive content.
|
||||
FreezeDomain(ctx context.Context, domain string) error
|
||||
|
||||
// Thaw a domain. This removes the previously placed administrative lock (if any).
|
||||
UnfreezeDomain(ctx context.Context, domain string) error
|
||||
|
||||
// Check whether the set of domains we serve has changed since the time passed to this method.
|
||||
HaveDomainsChanged(ctx context.Context, since time.Time) (changed bool, err error)
|
||||
|
||||
// Append a record to the audit log.
|
||||
AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) error
|
||||
|
||||
// Retrieve a single record from the audit log.
|
||||
QueryAuditLog(ctx context.Context, id AuditID) (record *AuditRecord, err error)
|
||||
|
||||
// Retrieve records from the audit log by time range.
|
||||
// Retrieve record IDs from the audit log by time range.
|
||||
SearchAuditLog(ctx context.Context, opts SearchAuditLogOptions) iter.Seq2[AuditID, error]
|
||||
|
||||
// Retrieve audit record contents for given IDs.
|
||||
GetAuditLogRecords(ctx context.Context, ids iter.Seq2[AuditID, error]) iter.Seq2[*AuditRecord, error]
|
||||
}
|
||||
|
||||
func CreateBackend(ctx context.Context, config *StorageConfig) (backend Backend, err error) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FSBackend struct {
|
||||
@@ -152,7 +153,13 @@ func (fs *FSBackend) PutBlob(ctx context.Context, name string, data []byte) erro
|
||||
}
|
||||
|
||||
if err := fs.blobRoot.Chmod(tempPath, 0o444); err != nil {
|
||||
return fmt.Errorf("chmod: %w", err)
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
// NFSv4 configured with ACLs doesn't have a working `chmod` even though it's a Unix
|
||||
// system. This `chmod` call is done entirely for convenience (to help the system
|
||||
// administrator avoid accidentally overwriting files), so just skip it.
|
||||
} else {
|
||||
return fmt.Errorf("chmod: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
again:
|
||||
@@ -396,12 +403,12 @@ func (fs *FSBackend) DeleteManifest(
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FSBackend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] {
|
||||
return func(yield func(ManifestMetadata, error) bool) {
|
||||
func (fs *FSBackend) EnumerateManifests(ctx context.Context) iter.Seq2[*ManifestMetadata, error] {
|
||||
return func(yield func(*ManifestMetadata, error) bool) {
|
||||
iofs.WalkDir(fs.siteRoot.FS(), ".",
|
||||
func(path string, entry iofs.DirEntry, err error) error {
|
||||
_, project, _ := strings.Cut(path, "/")
|
||||
var metadata ManifestMetadata
|
||||
var metadata *ManifestMetadata
|
||||
if err != nil {
|
||||
// report error
|
||||
} else if entry.IsDir() {
|
||||
@@ -414,9 +421,11 @@ func (fs *FSBackend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestM
|
||||
// report error
|
||||
} else {
|
||||
// report blob
|
||||
metadata.Name = path
|
||||
metadata.Size = info.Size()
|
||||
metadata.LastModified = info.ModTime()
|
||||
metadata = &ManifestMetadata{
|
||||
Name: path,
|
||||
Size: info.Size(),
|
||||
LastModified: info.ModTime(),
|
||||
}
|
||||
// not setting metadata.ETag since it is too costly
|
||||
}
|
||||
if !yield(metadata, err) {
|
||||
@@ -427,6 +436,22 @@ func (fs *FSBackend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestM
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FSBackend) GetAllManifests(ctx context.Context) iter.Seq2[tuple[*ManifestMetadata, *Manifest], error] {
|
||||
return func(yield func(tuple[*ManifestMetadata, *Manifest], error) bool) {
|
||||
for metadata, err := range fs.EnumerateManifests(ctx) {
|
||||
var item tuple[*ManifestMetadata, *Manifest]
|
||||
if err == nil {
|
||||
var manifest *Manifest
|
||||
manifest, _, err = backend.GetManifest(ctx, metadata.Name, GetManifestOptions{})
|
||||
item = tuple[*ManifestMetadata, *Manifest]{metadata, manifest}
|
||||
}
|
||||
if !yield(item, err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FSBackend) CheckDomain(ctx context.Context, domain string) (bool, error) {
|
||||
_, err := fs.siteRoot.Stat(domain)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -455,6 +480,10 @@ func (fs *FSBackend) UnfreezeDomain(ctx context.Context, domain string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FSBackend) HaveDomainsChanged(ctx context.Context, since time.Time) (bool, error) {
|
||||
return true, nil // not implemented
|
||||
}
|
||||
|
||||
func (fs *FSBackend) AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) error {
|
||||
if _, err := fs.auditRoot.Stat(id.String()); err == nil {
|
||||
panic(fmt.Errorf("audit ID collision: %s", id))
|
||||
@@ -500,3 +529,19 @@ func (fs *FSBackend) SearchAuditLog(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FSBackend) GetAuditLogRecords(
|
||||
ctx context.Context, ids iter.Seq2[AuditID, error],
|
||||
) iter.Seq2[*AuditRecord, error] {
|
||||
return func(yield func(*AuditRecord, error) bool) {
|
||||
for id, err := range ids {
|
||||
var record *AuditRecord
|
||||
if err == nil {
|
||||
record, err = fs.QueryAuditLog(ctx, id)
|
||||
}
|
||||
if !yield(record, err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/c2h5oh/datasize"
|
||||
@@ -178,6 +179,12 @@ func NewS3Backend(ctx context.Context, config *S3Config) (*S3Backend, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = (&S3Backend{client: client, bucket: bucket}).
|
||||
EnableFeature(ctx, FeatureCheckDomainMarker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
initS3BackendMetrics()
|
||||
@@ -583,7 +590,7 @@ func (s3 *S3Backend) CommitManifest(
|
||||
data := EncodeManifest(manifest)
|
||||
logc.Printf(ctx, "s3: commit manifest %x -> %s", sha256.Sum256(data), name)
|
||||
|
||||
_, domain, _ := strings.Cut(name, "/")
|
||||
domain, _, _ := strings.Cut(name, "/")
|
||||
if err := s3.checkDomainFrozen(ctx, domain); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -625,7 +632,7 @@ func (s3 *S3Backend) DeleteManifest(
|
||||
) error {
|
||||
logc.Printf(ctx, "s3: delete manifest %s\n", name)
|
||||
|
||||
_, domain, _ := strings.Cut(name, "/")
|
||||
domain, _, _ := strings.Cut(name, "/")
|
||||
if err := s3.checkDomainFrozen(ctx, domain); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -636,12 +643,15 @@ func (s3 *S3Backend) DeleteManifest(
|
||||
|
||||
err := s3.client.RemoveObject(ctx, s3.bucket, manifestObjectName(name),
|
||||
minio.RemoveObjectOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s3.siteCache.Cache.Invalidate(name)
|
||||
return err
|
||||
return s3.bumpLastDomainUpdateTimestamp(ctx)
|
||||
}
|
||||
|
||||
func (s3 *S3Backend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] {
|
||||
return func(yield func(ManifestMetadata, error) bool) {
|
||||
func (s3 *S3Backend) EnumerateManifests(ctx context.Context) iter.Seq2[*ManifestMetadata, error] {
|
||||
return func(yield func(*ManifestMetadata, error) bool) {
|
||||
logc.Print(ctx, "s3: enumerate manifests")
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
@@ -652,7 +662,7 @@ func (s3 *S3Backend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestM
|
||||
Prefix: prefix,
|
||||
Recursive: true,
|
||||
}) {
|
||||
var metadata ManifestMetadata
|
||||
var metadata *ManifestMetadata
|
||||
var err error
|
||||
if err = object.Err; err == nil {
|
||||
key := strings.TrimPrefix(object.Key, prefix)
|
||||
@@ -662,10 +672,12 @@ func (s3 *S3Backend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestM
|
||||
} else if project == "" || strings.HasPrefix(project, ".") && project != ".index" {
|
||||
continue // internal; skip
|
||||
} else {
|
||||
metadata.Name = key
|
||||
metadata.Size = object.Size
|
||||
metadata.LastModified = object.LastModified
|
||||
metadata.ETag = object.ETag
|
||||
metadata = &ManifestMetadata{
|
||||
Name: key,
|
||||
Size: object.Size,
|
||||
LastModified: object.LastModified,
|
||||
ETag: object.ETag,
|
||||
}
|
||||
}
|
||||
}
|
||||
if !yield(metadata, err) {
|
||||
@@ -675,6 +687,48 @@ func (s3 *S3Backend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestM
|
||||
}
|
||||
}
|
||||
|
||||
// Limits the number of concurrent uploads, globally across the entire git-pages process.
|
||||
// Not currently configurable as there seems to be little need.
|
||||
var getAllManifestsSemaphore = make(chan struct{}, 64)
|
||||
|
||||
func (s3 *S3Backend) GetAllManifests(ctx context.Context) iter.Seq2[tuple[*ManifestMetadata, *Manifest], error] {
|
||||
type result struct {
|
||||
metadata *ManifestMetadata
|
||||
manifest *Manifest
|
||||
err error
|
||||
}
|
||||
|
||||
resultsChan := make(chan result)
|
||||
enumeratorCtx, cancel := context.WithCancel(ctx)
|
||||
go func(ctx context.Context) {
|
||||
wg := sync.WaitGroup{}
|
||||
for metadata, err := range s3.EnumerateManifests(ctx) {
|
||||
if err != nil {
|
||||
resultsChan <- result{nil, nil, err}
|
||||
} else {
|
||||
getAllManifestsSemaphore <- struct{}{} // acquire
|
||||
wg.Go(func() {
|
||||
defer func() { <-getAllManifestsSemaphore }() // release
|
||||
manifest, _, err := backend.GetManifest(ctx, metadata.Name, GetManifestOptions{})
|
||||
resultsChan <- result{metadata, manifest, err}
|
||||
})
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
close(resultsChan)
|
||||
}(enumeratorCtx)
|
||||
|
||||
return func(yield func(tuple[*ManifestMetadata, *Manifest], error) bool) {
|
||||
for result := range resultsChan {
|
||||
item := tuple[*ManifestMetadata, *Manifest]{result.metadata, result.manifest}
|
||||
if !yield(item, result.err) {
|
||||
cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func domainCheckObjectName(domain string) string {
|
||||
return manifestObjectName(fmt.Sprintf("%s/.exists", domain))
|
||||
}
|
||||
@@ -713,8 +767,19 @@ func (s3 *S3Backend) CheckDomain(ctx context.Context, domain string) (exists boo
|
||||
func (s3 *S3Backend) CreateDomain(ctx context.Context, domain string) error {
|
||||
logc.Printf(ctx, "s3: create domain %s\n", domain)
|
||||
|
||||
_, err := s3.client.PutObject(ctx, s3.bucket, domainCheckObjectName(domain),
|
||||
exists, err := s3.CheckDomain(ctx, domain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s3.client.PutObject(ctx, s3.bucket, domainCheckObjectName(domain),
|
||||
&bytes.Reader{}, 0, minio.PutObjectOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
err = s3.bumpLastDomainUpdateTimestamp(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -739,6 +804,25 @@ func (s3 *S3Backend) UnfreezeDomain(ctx context.Context, domain string) error {
|
||||
}
|
||||
}
|
||||
|
||||
const lastDomainUpdateObjectName = "meta/last-domain-update"
|
||||
|
||||
func (s3 *S3Backend) HaveDomainsChanged(ctx context.Context, since time.Time) (bool, error) {
|
||||
info, err := s3.client.StatObject(ctx, s3.bucket, lastDomainUpdateObjectName,
|
||||
minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return info.LastModified.After(since), nil
|
||||
}
|
||||
|
||||
func (s3 *S3Backend) bumpLastDomainUpdateTimestamp(ctx context.Context) error {
|
||||
logc.Print(ctx, "s3: bumping last domain update timestamp")
|
||||
_, err := s3.client.PutObject(ctx, s3.bucket, lastDomainUpdateObjectName,
|
||||
&bytes.Reader{}, 0, minio.PutObjectOptions{})
|
||||
return err
|
||||
}
|
||||
|
||||
func auditObjectName(id AuditID) string {
|
||||
return fmt.Sprintf("audit/%s", id)
|
||||
}
|
||||
@@ -803,3 +887,40 @@ func (s3 *S3Backend) SearchAuditLog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var getAuditLogRecordsSemaphore = make(chan struct{}, 64)
|
||||
|
||||
func (s3 *S3Backend) GetAuditLogRecords(
|
||||
ctx context.Context, ids iter.Seq2[AuditID, error],
|
||||
) iter.Seq2[*AuditRecord, error] {
|
||||
return func(yield func(*AuditRecord, error) bool) {
|
||||
resultsChan := make(chan tuple[*AuditRecord, error])
|
||||
enumeratorCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func(ctx context.Context) {
|
||||
wg := sync.WaitGroup{}
|
||||
for id, err := range ids {
|
||||
if err != nil {
|
||||
resultsChan <- tuple[*AuditRecord, error]{nil, err}
|
||||
} else {
|
||||
getAuditLogRecordsSemaphore <- struct{}{} // acquire
|
||||
wg.Go(func() {
|
||||
defer func() { <-getAuditLogRecordsSemaphore }() // release
|
||||
record, err := s3.QueryAuditLog(ctx, id)
|
||||
resultsChan <- tuple[*AuditRecord, error]{record, err}
|
||||
})
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
close(resultsChan)
|
||||
}(enumeratorCtx)
|
||||
|
||||
for result := range resultsChan {
|
||||
record, err := result.Splat()
|
||||
if !yield(record, err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
src/caddy.go
12
src/caddy.go
@@ -26,7 +26,17 @@ func ServeCaddy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
found, err := backend.CheckDomain(r.Context(), strings.ToLower(domain))
|
||||
var err error
|
||||
domain = strings.ToLower(domain)
|
||||
|
||||
// Run a cheap check as to whether we might be serving the domain.
|
||||
var found = domainCache.CheckDomain(r.Context(), domain)
|
||||
|
||||
if !found {
|
||||
// Run an expensive check as to whether we are actually serving the domain.
|
||||
found, err = backend.CheckDomain(r.Context(), domain)
|
||||
}
|
||||
|
||||
if !found {
|
||||
// If we don't serve the domain, but a fallback server does, then we should let our
|
||||
// Caddy instance request a TLS certificate. Otherwise, we'll never have an opportunity
|
||||
|
||||
@@ -84,7 +84,11 @@ func CollectTar(
|
||||
header.Typeflag = tar.TypeSymlink
|
||||
header.Mode = 0644
|
||||
header.ModTime = metadata.LastModified
|
||||
err = appendFile(&header, entry.GetData(), Transform_Identity)
|
||||
header.Linkname = string(entry.GetData())
|
||||
err = archive.WriteHeader(&header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
panic(fmt.Errorf("CollectTar encountered invalid entry: %v, %v",
|
||||
|
||||
@@ -79,11 +79,11 @@ type ServerConfig struct {
|
||||
}
|
||||
|
||||
type WildcardConfig struct {
|
||||
Domain string `toml:"domain"`
|
||||
CloneURL string `toml:"clone-url"` // URL template, not an exact URL
|
||||
IndexRepos []string `toml:"index-repos" default:"[]"`
|
||||
IndexRepoBranch string `toml:"index-repo-branch" default:"pages"`
|
||||
Authorization string `toml:"authorization"`
|
||||
Domain string `toml:"domain"`
|
||||
CloneURL string `toml:"clone-url"` // URL template, not an exact URL
|
||||
IndexRepo string `toml:"index-repo" default:"pages"`
|
||||
IndexRepoBranch string `toml:"index-repo-branch" default:"pages"`
|
||||
Authorization string `toml:"authorization"`
|
||||
}
|
||||
|
||||
type FallbackConfig struct {
|
||||
@@ -134,6 +134,8 @@ type LimitsConfig struct {
|
||||
// Maximum time that an update operation (PUT or POST request) could take before being
|
||||
// interrupted.
|
||||
UpdateTimeout Duration `toml:"update-timeout" default:"60s"`
|
||||
// Maximum number of concurrent blob uploads, globally across every update request.
|
||||
ConcurrentUploads uint `toml:"concurrent-uploads" default:"1024"`
|
||||
// Soft limit on Go heap size, expressed as a fraction of total available RAM.
|
||||
MaxHeapSizeRatio float64 `toml:"max-heap-size-ratio" default:"0.5"`
|
||||
// List of domains unconditionally forbidden for uploads.
|
||||
@@ -144,6 +146,9 @@ type LimitsConfig struct {
|
||||
// e.g. `Foo-Bar`. Setting this option permits including this custom header in `_headers`,
|
||||
// unless it is fundamentally unsafe.
|
||||
AllowedCustomHeaders []string `toml:"allowed-custom-headers" default:"[\"X-Clacks-Overhead\"]"`
|
||||
// Whether to allow Netlify-style credentials specified in a `Basic-Auth:` pseudo-header.
|
||||
// These credentials are plaintext.
|
||||
AllowBasicAuth bool `toml:"allow-basic-auth" default:"false"`
|
||||
}
|
||||
|
||||
type AuditConfig struct {
|
||||
@@ -304,19 +309,17 @@ func PrintConfigEnvVars() {
|
||||
})
|
||||
}
|
||||
|
||||
func Configure(tomlPath string) (config *Config, err error) {
|
||||
// start with an all-default configuration
|
||||
config = new(Config)
|
||||
defaults.MustSet(config)
|
||||
|
||||
// inject values from `config.toml`
|
||||
func ReadConfigFile(config *Config, tomlPath string) (err error) {
|
||||
if tomlPath != "" {
|
||||
var file *os.File
|
||||
file, err = os.Open(tomlPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
defer func(file *os.File) {
|
||||
err = file.Close()
|
||||
}(file)
|
||||
|
||||
decoder := toml.NewDecoder(file)
|
||||
decoder.DisallowUnknownFields()
|
||||
@@ -325,6 +328,21 @@ func Configure(tomlPath string) (config *Config, err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Configure(tomlPaths ...string) (config *Config, err error) {
|
||||
// start with an all-default configuration
|
||||
config = new(Config)
|
||||
defaults.MustSet(config)
|
||||
|
||||
// inject values from each toml file
|
||||
for _, tomlPath := range tomlPaths {
|
||||
err := ReadConfigFile(config, tomlPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// inject values from the environment, overriding everything else
|
||||
err = walkConfig(config, func(envName string, reflValue reflect.Value) error {
|
||||
|
||||
132
src/domain_cache.go
Normal file
132
src/domain_cache.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package git_pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bits-and-blooms/bloom/v3"
|
||||
)
|
||||
|
||||
type DomainCache interface {
|
||||
// Check if we might be serving the domain.
|
||||
CheckDomain(ctx context.Context, domain string) (found bool)
|
||||
|
||||
// Add the domain to the cache.
|
||||
AddDomain(ctx context.Context, domain string)
|
||||
}
|
||||
|
||||
func CreateDomainCache(ctx context.Context) (DomainCache, error) {
|
||||
if !config.Feature("domain-existence-cache") {
|
||||
return &dummyDomainCache{}, nil
|
||||
}
|
||||
return createBloomDomainCache(ctx)
|
||||
}
|
||||
|
||||
type bloomDomainCache struct {
|
||||
filter *bloom.BloomFilter
|
||||
filterMu sync.Mutex
|
||||
|
||||
accessCh chan struct{}
|
||||
refreshMu sync.Mutex
|
||||
lastRefresh time.Time
|
||||
maxAge time.Duration
|
||||
}
|
||||
|
||||
func createBloomDomainCache(ctx context.Context) (DomainCache, error) {
|
||||
cache := bloomDomainCache{
|
||||
accessCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
switch config.Storage.Type {
|
||||
case "fs":
|
||||
// the FS backend has no cache
|
||||
case "s3":
|
||||
cache.maxAge = time.Duration(config.Storage.S3.SiteCache.MaxAge)
|
||||
default:
|
||||
panic(fmt.Errorf("unknown backend: %s", config.Storage.Type))
|
||||
}
|
||||
|
||||
if err := cache.refresh(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go cache.handleFilterUpdates(ctx)
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
func (c *bloomDomainCache) handleFilterUpdates(ctx context.Context) {
|
||||
for range c.accessCh {
|
||||
if time.Since(c.lastRefresh) > c.maxAge {
|
||||
logc.Print(ctx, "domain cache: refreshing")
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
logc.Printf(ctx, "domain cache: refresh error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *bloomDomainCache) refresh(ctx context.Context) error {
|
||||
c.refreshMu.Lock()
|
||||
defer c.refreshMu.Unlock()
|
||||
|
||||
if changed, err := backend.HaveDomainsChanged(ctx, c.lastRefresh); err != nil {
|
||||
return err
|
||||
} else if !changed {
|
||||
logc.Print(ctx, "domain cache: unchanged")
|
||||
c.lastRefresh = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a 256 KiB Bloom filter that will fit ~150K entries with 0.1% false positive rate.
|
||||
filter := bloom.New(256*1024, 10)
|
||||
for metadata, err := range backend.EnumerateManifests(ctx) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("enum manifests: %w", err)
|
||||
}
|
||||
domain, _, _ := strings.Cut(metadata.Name, "/")
|
||||
filter.AddString(domain)
|
||||
}
|
||||
|
||||
c.filterMu.Lock()
|
||||
c.filter = filter
|
||||
c.filterMu.Unlock()
|
||||
|
||||
logc.Printf(ctx, "domain cache: refreshed with approx. %d domains", filter.ApproximatedSize())
|
||||
c.lastRefresh = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *bloomDomainCache) CheckDomain(ctx context.Context, domain string) (found bool) {
|
||||
select {
|
||||
case c.accessCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
c.filterMu.Lock()
|
||||
found = c.filter.TestString(domain)
|
||||
c.filterMu.Unlock()
|
||||
|
||||
logc.Printf(ctx, "domain cache: bloom filter returns %v for %q", found, domain)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *bloomDomainCache) AddDomain(ctx context.Context, domain string) {
|
||||
c.refreshMu.Lock()
|
||||
defer c.refreshMu.Unlock()
|
||||
|
||||
c.filterMu.Lock()
|
||||
c.filter.AddString(domain)
|
||||
c.filterMu.Unlock()
|
||||
|
||||
logc.Printf(ctx, "domain cache: added %q", domain)
|
||||
}
|
||||
|
||||
type dummyDomainCache struct{}
|
||||
|
||||
func (d dummyDomainCache) CheckDomain(context.Context, string) bool { return true }
|
||||
|
||||
func (d dummyDomainCache) AddDomain(context.Context, string) {}
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/c2h5oh/datasize"
|
||||
"github.com/go-git/go-git/v6/plumbing"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
@@ -51,29 +51,14 @@ func ExtractZstd(
|
||||
return next(ctx, boundArchiveStream(stream))
|
||||
}
|
||||
|
||||
const BlobReferencePrefix = "/git/blobs/"
|
||||
|
||||
type UnresolvedRefError struct {
|
||||
missing []string
|
||||
}
|
||||
|
||||
func (err UnresolvedRefError) Error() string {
|
||||
return fmt.Sprintf("%d unresolved blob references", len(err.missing))
|
||||
}
|
||||
|
||||
// Returns a map of git hash to entry. If `manifest` is nil, returns an empty map.
|
||||
func indexManifestByGitHash(manifest *Manifest) map[string]*Entry {
|
||||
index := map[string]*Entry{}
|
||||
for _, entry := range manifest.GetContents() {
|
||||
if hash := entry.GetGitHash(); hash != "" {
|
||||
if _, ok := plumbing.FromHex(hash); ok {
|
||||
index[hash] = entry
|
||||
} else {
|
||||
panic(fmt.Errorf("index: malformed hash: %s", hash))
|
||||
}
|
||||
}
|
||||
func normalizeArchiveMemberName(fileName string) string {
|
||||
// Strip the leading slash and any extraneous path segments.
|
||||
fileName = path.Clean(fileName)
|
||||
fileName = strings.TrimPrefix(fileName, "/")
|
||||
if fileName == "." {
|
||||
fileName = ""
|
||||
}
|
||||
return index
|
||||
return fileName
|
||||
}
|
||||
|
||||
func addSymlinkOrBlobReference(
|
||||
@@ -99,9 +84,10 @@ func ExtractTar(ctx context.Context, reader io.Reader, oldManifest *Manifest) (*
|
||||
var dataBytesRecycled int64
|
||||
var dataBytesTransferred int64
|
||||
|
||||
index := indexManifestByGitHash(oldManifest)
|
||||
index := IndexManifestByGitHash(oldManifest)
|
||||
missing := []string{}
|
||||
manifest := NewManifest()
|
||||
hardLinks := map[string]*Entry{}
|
||||
for {
|
||||
header, err := archive.Next()
|
||||
if err == io.EOF {
|
||||
@@ -110,15 +96,10 @@ func ExtractTar(ctx context.Context, reader io.Reader, oldManifest *Manifest) (*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For some reason, GNU tar includes any leading `.` path segments in archive filenames,
|
||||
// unless there is a `..` path segment anywhere in the input filenames.
|
||||
fileName := header.Name
|
||||
for {
|
||||
if strippedName, found := strings.CutPrefix(fileName, "./"); found {
|
||||
fileName = strippedName
|
||||
} else {
|
||||
break
|
||||
}
|
||||
fileName := normalizeArchiveMemberName(header.Name)
|
||||
if fileName == "" {
|
||||
// This must be the root directory. It will be filled in by EnsureLeadingDirectories.
|
||||
continue
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
@@ -127,12 +108,27 @@ func ExtractTar(ctx context.Context, reader io.Reader, oldManifest *Manifest) (*
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tar: %s: %w", fileName, err)
|
||||
}
|
||||
AddFile(manifest, fileName, fileData)
|
||||
entry := AddFile(manifest, fileName, fileData)
|
||||
hardLinks[header.Name] = entry
|
||||
dataBytesTransferred += int64(len(fileData))
|
||||
case tar.TypeSymlink:
|
||||
entry := addSymlinkOrBlobReference(
|
||||
manifest, fileName, header.Linkname, index, &missing)
|
||||
dataBytesRecycled += entry.GetOriginalSize()
|
||||
hardLinks[header.Name] = entry
|
||||
switch {
|
||||
case entry == nil:
|
||||
// unresolved blob reference
|
||||
case entry.GetType() != Type_Symlink:
|
||||
dataBytesRecycled += entry.GetOriginalSize() // resolved blob reference
|
||||
default:
|
||||
dataBytesTransferred += int64(len(header.Linkname)) // actual symlink
|
||||
}
|
||||
case tar.TypeLink:
|
||||
if entry, found := hardLinks[header.Linkname]; found {
|
||||
manifest.Contents[fileName] = entry
|
||||
} else {
|
||||
AddProblem(manifest, fileName, "tar: invalid hardlink %q", header.Linkname)
|
||||
}
|
||||
case tar.TypeDir:
|
||||
AddDirectory(manifest, fileName)
|
||||
default:
|
||||
@@ -196,12 +192,13 @@ func ExtractZip(ctx context.Context, reader io.Reader, oldManifest *Manifest) (*
|
||||
var dataBytesRecycled int64
|
||||
var dataBytesTransferred int64
|
||||
|
||||
index := indexManifestByGitHash(oldManifest)
|
||||
index := IndexManifestByGitHash(oldManifest)
|
||||
missing := []string{}
|
||||
manifest := NewManifest()
|
||||
for _, file := range archive.File {
|
||||
normalizedName := normalizeArchiveMemberName(file.Name)
|
||||
if strings.HasSuffix(file.Name, "/") {
|
||||
AddDirectory(manifest, file.Name)
|
||||
AddDirectory(manifest, normalizedName)
|
||||
} else {
|
||||
fileReader, err := file.Open()
|
||||
if err != nil {
|
||||
@@ -216,10 +213,17 @@ func ExtractZip(ctx context.Context, reader io.Reader, oldManifest *Manifest) (*
|
||||
|
||||
if file.Mode()&os.ModeSymlink != 0 {
|
||||
entry := addSymlinkOrBlobReference(
|
||||
manifest, file.Name, string(fileData), index, &missing)
|
||||
dataBytesRecycled += entry.GetOriginalSize()
|
||||
manifest, normalizedName, string(fileData), index, &missing)
|
||||
switch {
|
||||
case entry == nil:
|
||||
// unresolved blob reference
|
||||
case entry.GetType() != Type_Symlink:
|
||||
dataBytesRecycled += entry.GetOriginalSize() // resolved blob reference
|
||||
default:
|
||||
dataBytesTransferred += int64(len(fileData)) // actual symlink
|
||||
}
|
||||
} else {
|
||||
AddFile(manifest, file.Name, fileData)
|
||||
AddFile(manifest, normalizedName, fileData)
|
||||
dataBytesTransferred += int64(len(fileData))
|
||||
}
|
||||
}
|
||||
@@ -240,4 +244,3 @@ func ExtractZip(ctx context.Context, reader io.Reader, oldManifest *Manifest) (*
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
|
||||
43
src/fetch.go
43
src/fetch.go
@@ -9,12 +9,14 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/c2h5oh/datasize"
|
||||
"github.com/go-git/go-billy/v6/osfs"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/plumbing"
|
||||
"github.com/go-git/go-git/v6/plumbing/cache"
|
||||
"github.com/go-git/go-git/v6/plumbing/client"
|
||||
"github.com/go-git/go-git/v6/plumbing/filemode"
|
||||
"github.com/go-git/go-git/v6/plumbing/object"
|
||||
"github.com/go-git/go-git/v6/plumbing/protocol/packp"
|
||||
@@ -164,28 +166,18 @@ func FetchRepository(
|
||||
// Third, if we still don't have data for some manifest entries, re-establish a git transport
|
||||
// and request the missing blobs (only) from the server.
|
||||
if len(blobsNeeded) > 0 {
|
||||
client, err := transport.Get(parsedRepoURL.Scheme)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git transport: %w", err)
|
||||
}
|
||||
gitClient := client.New()
|
||||
request := &transport.Request{
|
||||
URL: parsedRepoURL,
|
||||
Command: transport.UploadPackService}
|
||||
|
||||
endpoint, err := transport.NewEndpoint(repoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git endpoint: %w", err)
|
||||
}
|
||||
|
||||
session, err := client.NewSession(storer, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git session: %w", err)
|
||||
}
|
||||
|
||||
connection, err := session.Handshake(ctx, transport.UploadPackService)
|
||||
session, err := gitClient.Handshake(ctx, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git connection: %w", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
defer session.Close()
|
||||
|
||||
if err := connection.Fetch(ctx, &transport.FetchRequest{
|
||||
if err := session.Fetch(ctx, storer, &transport.FetchRequest{
|
||||
Wants: slices.Collect(maps.Keys(blobsNeeded)),
|
||||
Depth: 1,
|
||||
// Git CLI behaves like this, even if the wants above are references to blobs.
|
||||
@@ -209,6 +201,8 @@ func FetchRepository(
|
||||
datasize.ByteSize(dataBytesTransferred).HR(),
|
||||
)
|
||||
|
||||
warnAboutGitLFS(ctx, manifest)
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
@@ -254,3 +248,18 @@ func readGitBlob(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func warnAboutGitLFS(ctx context.Context, manifest *Manifest) {
|
||||
gitattributes := ReadGitAttributes(ctx, manifest)
|
||||
for _, name := range slices.Sorted(maps.Keys(manifest.GetContents())) {
|
||||
entry := manifest.GetContents()[name]
|
||||
if !IsEntryRegularFile(entry) {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(name, "/")
|
||||
attrs, _ := gitattributes.Match(parts, nil)
|
||||
if attr, ok := attrs["filter"]; ok && attr.Value() == "lfs" {
|
||||
AddProblem(manifest, name, "git-pages does not support Git LFS; move this file into Git or use incremental uploads")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,8 @@ func TraceGarbage(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Enumerate blobs live via site manifests.
|
||||
for metadata, err := range backend.EnumerateManifests(ctx) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("trace sites err: %w", err)
|
||||
}
|
||||
manifest, _, err := backend.GetManifest(ctx, metadata.Name, GetManifestOptions{})
|
||||
for item, err := range backend.GetAllManifests(ctx) {
|
||||
metadata, manifest := item.Splat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("trace sites err: %w", err)
|
||||
}
|
||||
@@ -59,16 +56,14 @@ func TraceGarbage(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Enumerate blobs live via audit records.
|
||||
for auditID, err := range backend.SearchAuditLog(ctx, SearchAuditLogOptions{}) {
|
||||
|
||||
auditIDs := backend.SearchAuditLog(ctx, SearchAuditLogOptions{})
|
||||
for record, err := range backend.GetAuditLogRecords(ctx, auditIDs) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("trace audit err: %w", err)
|
||||
logc.Fatalln(ctx, err)
|
||||
}
|
||||
auditRecord, err := backend.QueryAuditLog(ctx, auditID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trace audit err: %w", err)
|
||||
}
|
||||
if auditRecord.Manifest != nil {
|
||||
err = traceManifest(auditID.String(), auditRecord.Manifest)
|
||||
if record.Manifest != nil {
|
||||
err = traceManifest(record.GetAuditID().String(), record.Manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trace audit err: %w", err)
|
||||
}
|
||||
|
||||
61
src/gitattributes.go
Normal file
61
src/gitattributes.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package git_pages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v6/plumbing/format/gitattributes"
|
||||
)
|
||||
|
||||
func ReadGitAttributes(ctx context.Context, manifest *Manifest) gitattributes.Matcher {
|
||||
type entryPair struct {
|
||||
parts []string
|
||||
entry *Entry
|
||||
}
|
||||
|
||||
// Collect all .gitattributes files.
|
||||
var files []entryPair
|
||||
for name, entry := range manifest.GetContents() {
|
||||
switch entry.GetType() {
|
||||
case Type_InlineFile, Type_ExternalFile:
|
||||
parts := strings.Split(name, "/")
|
||||
if parts[len(parts)-1] == ".gitattributes" {
|
||||
files = append(files, entryPair{parts, entry})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the file list by depth, then by name.
|
||||
slices.SortFunc(files, func(a entryPair, b entryPair) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(len(a.parts), len(b.parts)),
|
||||
slices.Compare(a.parts, b.parts),
|
||||
)
|
||||
})
|
||||
|
||||
// Gather all .gitattributes rules, sorted by depth.
|
||||
var rules []gitattributes.MatchAttribute
|
||||
for _, pair := range files {
|
||||
parts, entry := pair.parts, pair.entry
|
||||
data, err := GetEntryContents(ctx, entry)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dirs := parts[:len(parts)-1]
|
||||
isRoot := len(parts) == 1
|
||||
newRules, err := gitattributes.ReadAttributes(bytes.NewReader(data), dirs, isRoot)
|
||||
if err != nil {
|
||||
AddProblem(manifest, strings.Join(parts, "/"), "parsing .gitattributes: %v", err)
|
||||
continue
|
||||
}
|
||||
rules = append(rules, newRules...)
|
||||
}
|
||||
|
||||
// gitattributes.Matcher applies rules in reverse.
|
||||
slices.Reverse(rules)
|
||||
matcher := gitattributes.NewMatcher(rules)
|
||||
return matcher
|
||||
}
|
||||
125
src/headers.go
125
src/headers.go
@@ -1,6 +1,7 @@
|
||||
package git_pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
var ErrHeaderNotAllowed = errors.New("custom header not allowed")
|
||||
var ErrBasicAuthNotAllowed = errors.New("basic authorization not allowed")
|
||||
|
||||
const HeadersFileName string = "_headers"
|
||||
|
||||
@@ -74,28 +76,40 @@ func validateHeaderRule(rule headers.Rule) error {
|
||||
if slices.Contains(unsafeHeaders, header) {
|
||||
return fmt.Errorf("rule sets header %q (fundamentally unsafe)", header)
|
||||
}
|
||||
if !slices.Contains(config.Limits.AllowedCustomHeaders, header) {
|
||||
return fmt.Errorf("rule sets header %q (not allowlisted)", header)
|
||||
}
|
||||
if !IsAllowedCustomHeader(header) { // make sure we don't desync
|
||||
panic(errors.New("header check inconsistency"))
|
||||
switch header {
|
||||
case "Basic-Auth":
|
||||
if !config.Limits.AllowBasicAuth {
|
||||
return fmt.Errorf("rule sets header %q (forbidden by policy)", header)
|
||||
}
|
||||
default:
|
||||
if !slices.Contains(config.Limits.AllowedCustomHeaders, header) {
|
||||
return fmt.Errorf("rule sets header %q (not allowlisted)", header)
|
||||
}
|
||||
if !IsAllowedCustomHeader(header) { // make sure we don't desync
|
||||
panic(errors.New("header check inconsistency"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parses redirects file and injects rules into the manifest.
|
||||
func ProcessHeadersFile(manifest *Manifest) error {
|
||||
func ProcessHeadersFile(ctx context.Context, manifest *Manifest) error {
|
||||
headersEntry := manifest.Contents[HeadersFileName]
|
||||
delete(manifest.Contents, HeadersFileName)
|
||||
if headersEntry == nil {
|
||||
return nil
|
||||
} else if headersEntry.GetType() != Type_InlineFile {
|
||||
return AddProblem(manifest, HeadersFileName,
|
||||
"not a regular file")
|
||||
}
|
||||
|
||||
rules, err := headers.ParseString(string(headersEntry.GetData()))
|
||||
data, err := GetEntryContents(ctx, headersEntry)
|
||||
if errors.Is(err, ErrNotRegularFile) {
|
||||
return AddProblem(manifest, HeadersFileName,
|
||||
"not a regular file")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rules, err := headers.ParseString(string(data))
|
||||
if err != nil {
|
||||
return AddProblem(manifest, HeadersFileName,
|
||||
"syntax error: %s", err)
|
||||
@@ -108,16 +122,52 @@ func ProcessHeadersFile(manifest *Manifest) error {
|
||||
continue
|
||||
}
|
||||
headerMap := []*Header{}
|
||||
credentials := []*BasicCredential{}
|
||||
hasBasicAuth := false
|
||||
for header, values := range rule.Headers {
|
||||
headerMap = append(headerMap, &Header{
|
||||
Name: proto.String(header),
|
||||
Values: values,
|
||||
})
|
||||
switch header {
|
||||
case "Basic-Auth":
|
||||
hasBasicAuth = true
|
||||
for _, value := range values {
|
||||
for _, usernamePassword := range strings.Split(value, " ") {
|
||||
if usernamePassword == "" {
|
||||
continue
|
||||
}
|
||||
if username, password, found := strings.Cut(usernamePassword, ":"); !found {
|
||||
AddProblem(manifest, HeadersFileName,
|
||||
"rule #%d %q: malformed Basic-Auth credential", index+1, rule.Path)
|
||||
continue
|
||||
} else {
|
||||
credentials = append(credentials, &BasicCredential{
|
||||
Username: proto.String(username),
|
||||
Password: proto.String(password),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
headerMap = append(headerMap, &Header{
|
||||
Name: proto.String(header),
|
||||
Values: values,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Note that we may add an empty `headerMap` here even if only credentials are defined.
|
||||
// This is intentional: in `_headers` files processing terminates at the first matching
|
||||
// clause, and Netlify mixes Basic-Auth with all the other headers.
|
||||
manifest.Headers = append(manifest.Headers, &HeaderRule{
|
||||
Path: proto.String(rule.Path),
|
||||
HeaderMap: headerMap,
|
||||
})
|
||||
// We're using `hasBasicAuth` instead of `len(credentials) > 0` so that if a `_headers`
|
||||
// file defines only malformed credentials, we still add a rule (that in effect always
|
||||
// denies access).
|
||||
if hasBasicAuth {
|
||||
manifest.BasicAuth = append(manifest.BasicAuth, &BasicAuthRule{
|
||||
Path: proto.String(rule.Path),
|
||||
Credentials: credentials,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -137,13 +187,14 @@ func CollectHeadersFile(manifest *Manifest) string {
|
||||
return headers.Must(headers.UnparseString(headersRules))
|
||||
}
|
||||
|
||||
func ApplyHeaderRules(manifest *Manifest, url *url.URL) (headers http.Header, err error) {
|
||||
headers = http.Header{}
|
||||
func matchPathRules[
|
||||
Rule interface{ GetPath() string },
|
||||
](rules []Rule, url *url.URL) (matched Rule) {
|
||||
fromSegments := pathSegments(url.Path)
|
||||
next:
|
||||
for _, rule := range manifest.Headers {
|
||||
for _, rule := range rules {
|
||||
// check if the rule matches url
|
||||
ruleURL, _ := url.Parse(*rule.Path) // pre-validated in `validateHeaderRule`
|
||||
ruleURL, _ := url.Parse(rule.GetPath()) // pre-validated in `validateHeaderRule`
|
||||
ruleSegments := pathSegments(ruleURL.Path)
|
||||
if ruleSegments[len(ruleSegments)-1] != "*" {
|
||||
if len(ruleSegments) < len(fromSegments) {
|
||||
@@ -161,8 +212,19 @@ next:
|
||||
continue next
|
||||
}
|
||||
}
|
||||
matched = rule
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ApplyHeaderRules(manifest *Manifest, url *url.URL) (
|
||||
headers http.Header, err error,
|
||||
) {
|
||||
headers = http.Header{}
|
||||
if rule := matchPathRules(manifest.Headers, url); rule != nil {
|
||||
// the rule has matched url, validate headers against up-to-date policy
|
||||
for _, header := range rule.HeaderMap {
|
||||
for _, header := range rule.GetHeaderMap() {
|
||||
name := header.GetName()
|
||||
if !IsAllowedCustomHeader(name) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrHeaderNotAllowed, name)
|
||||
@@ -171,7 +233,30 @@ next:
|
||||
headers.Add(name, value)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ApplyBasicAuthRules(manifest *Manifest, url *url.URL, r *http.Request) (bool, error) {
|
||||
if rule := matchPathRules(manifest.BasicAuth, url); rule == nil {
|
||||
// no matches, authorized by default
|
||||
return true, nil
|
||||
} else {
|
||||
// the rule has matched url, check that basic auth is allowed per up-to-date policy
|
||||
if !config.Limits.AllowBasicAuth {
|
||||
// basic auth configured in the past but not allowed any more
|
||||
return false, ErrBasicAuthNotAllowed
|
||||
}
|
||||
if username, password, ok := r.BasicAuth(); ok {
|
||||
// request has credentials, check them
|
||||
for _, credential := range rule.GetCredentials() {
|
||||
if credential.GetUsername() == username && credential.GetPassword() == password {
|
||||
// authorized!
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// request has no credentials, unauthorized
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
35
src/histogram.go
Normal file
35
src/histogram.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package git_pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DomainStatistics struct {
|
||||
Domain string
|
||||
OriginalSize int64
|
||||
CompressedSize int64
|
||||
StoredSize int64
|
||||
}
|
||||
|
||||
func SizeHistogram(ctx context.Context) ([]*DomainStatistics, error) {
|
||||
statisticsMap := map[string]*DomainStatistics{}
|
||||
for item, err := range backend.GetAllManifests(ctx) {
|
||||
metadata, manifest := item.Splat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("size histogram err: %w", err)
|
||||
}
|
||||
domain, _, _ := strings.Cut(metadata.Name, "/")
|
||||
if _, found := statisticsMap[domain]; !found {
|
||||
statisticsMap[domain] = &DomainStatistics{Domain: domain}
|
||||
}
|
||||
statistics := statisticsMap[domain]
|
||||
statistics.OriginalSize += manifest.GetOriginalSize()
|
||||
statistics.CompressedSize += manifest.GetCompressedSize()
|
||||
statistics.StoredSize += manifest.GetStoredSize()
|
||||
}
|
||||
return slices.Collect(maps.Values(statisticsMap)), nil
|
||||
}
|
||||
123
src/main.go
123
src/main.go
@@ -1,6 +1,7 @@
|
||||
package git_pages
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +33,7 @@ var config *Config
|
||||
var wildcards []*WildcardPattern
|
||||
var fallback http.Handler
|
||||
var backend Backend
|
||||
var domainCache DomainCache
|
||||
|
||||
func configureFeatures(ctx context.Context) (err error) {
|
||||
if len(config.Features) > 0 {
|
||||
@@ -61,6 +64,12 @@ func configureMemLimit(ctx context.Context) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Can only be safely called during initial configuration.
|
||||
func configureConcurrency(_ context.Context) (err error) {
|
||||
putBlobSemaphore = make(chan struct{}, config.Limits.ConcurrentUploads)
|
||||
return
|
||||
}
|
||||
|
||||
func configureWildcards(_ context.Context) (err error) {
|
||||
newWildcards, err := TranslateWildcards(config.Wildcard)
|
||||
if err != nil {
|
||||
@@ -119,6 +128,9 @@ func panicHandler(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
if err, ok := err.(error); ok && errors.Is(err, http.ErrAbortHandler) {
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
logc.Printf(r.Context(), "panic: %s %s %s: %s\n%s",
|
||||
r.Method, r.Host, r.URL.Path, err, string(debug.Stack()))
|
||||
http.Error(w,
|
||||
@@ -172,7 +184,7 @@ func usage() {
|
||||
fmt.Fprintf(os.Stderr, "(server) "+
|
||||
"git-pages [-config <file>|-no-config]\n")
|
||||
fmt.Fprintf(os.Stderr, "(info) "+
|
||||
"git-pages {-print-config-env-vars|-print-config}\n")
|
||||
"git-pages {-version|-print-config-env-vars|-print-config}\n")
|
||||
fmt.Fprintf(os.Stderr, "(debug) "+
|
||||
"git-pages {-list-blobs|-list-manifests}\n")
|
||||
fmt.Fprintf(os.Stderr, "(debug) "+
|
||||
@@ -182,16 +194,18 @@ func usage() {
|
||||
fmt.Fprintf(os.Stderr, "(audit) "+
|
||||
"git-pages {-audit-log|-audit-read <id>|-audit-server <endpoint> <program> [args...]}\n")
|
||||
fmt.Fprintf(os.Stderr, "(maint) "+
|
||||
"git-pages {-run-migration <name>|-trace-garbage}\n")
|
||||
"git-pages {-run-migration <name>|-trace-garbage|-size-histogram {original|stored}}\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func Main() {
|
||||
func Main(versionInfo string) {
|
||||
ctx := context.Background()
|
||||
|
||||
flag.Usage = usage
|
||||
configTomlPath := flag.String("config", "",
|
||||
"load configuration from `filename` (default: 'config.toml')")
|
||||
secretTomlPath := flag.String("secrets", "",
|
||||
"load additional configuration values from `filename` (default: '$CREDENTIALS_DIRECTORY/secrets.toml' if it exists)")
|
||||
noConfig := flag.Bool("no-config", false,
|
||||
"run without configuration file (configure via environment variables)")
|
||||
printConfigEnvVars := flag.Bool("print-config-env-vars", false,
|
||||
@@ -224,10 +238,19 @@ func Main() {
|
||||
"listen for notifications on `endpoint` and spawn a process for each audit event")
|
||||
runMigration := flag.String("run-migration", "",
|
||||
"run a store `migration` (one of: create-domain-markers)")
|
||||
sizeHistogram := flag.String("size-histogram", "",
|
||||
"display histogram of `size-type` (original or stored) per domain")
|
||||
traceGarbage := flag.Bool("trace-garbage", false,
|
||||
"estimate total size of unreachable blobs")
|
||||
version := flag.Bool("version", false,
|
||||
"display version")
|
||||
flag.Parse()
|
||||
|
||||
if *version {
|
||||
fmt.Printf("git-pages %s\n", versionInfo)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
var cliOperations int
|
||||
for _, selected := range []bool{
|
||||
*listBlobs,
|
||||
@@ -243,6 +266,7 @@ func Main() {
|
||||
*auditRollback != "",
|
||||
*auditServer != "",
|
||||
*runMigration != "",
|
||||
*sizeHistogram != "",
|
||||
*traceGarbage,
|
||||
} {
|
||||
if selected {
|
||||
@@ -252,8 +276,8 @@ func Main() {
|
||||
if cliOperations > 1 {
|
||||
logc.Fatalln(ctx, "-list-blobs, -list-manifests, -get-blob, -get-manifest, -get-archive, "+
|
||||
"-update-site, -freeze-domain, -unfreeze-domain, -audit-log, -audit-read, "+
|
||||
"-audit-rollback, -audit-server, -run-migration, and -trace-garbage are "+
|
||||
"mutually exclusive")
|
||||
"-audit-rollback, -audit-server, -run-migration, -size-histogram, "+
|
||||
"and -trace-garbage are mutually exclusive")
|
||||
}
|
||||
|
||||
if *configTomlPath != "" && *noConfig {
|
||||
@@ -269,7 +293,19 @@ func Main() {
|
||||
if *configTomlPath == "" && !*noConfig {
|
||||
*configTomlPath = "config.toml"
|
||||
}
|
||||
if config, err = Configure(*configTomlPath); err != nil {
|
||||
|
||||
if *secretTomlPath == "" && !*noConfig {
|
||||
// check for a second config file at $CREDENTIALS_DIRECTORY/secrets.toml, and use it
|
||||
if systemdCredentialsDir := os.Getenv("CREDENTIALS_DIRECTORY"); systemdCredentialsDir != "" {
|
||||
secretTomlTestPath := path.Join(systemdCredentialsDir, "secrets.toml")
|
||||
_, err := os.Stat(secretTomlTestPath)
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
*secretTomlPath = secretTomlTestPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config, err = Configure(*configTomlPath, *secretTomlPath); err != nil {
|
||||
logc.Fatalln(ctx, "config:", err)
|
||||
}
|
||||
|
||||
@@ -284,6 +320,7 @@ func Main() {
|
||||
if err = errors.Join(
|
||||
configureFeatures(ctx),
|
||||
configureMemLimit(ctx),
|
||||
configureConcurrency(ctx),
|
||||
configureWildcards(ctx),
|
||||
configureFallback(ctx),
|
||||
configureAudit(ctx),
|
||||
@@ -440,30 +477,20 @@ func Main() {
|
||||
}
|
||||
|
||||
case *auditLog:
|
||||
ch := make(chan *AuditRecord)
|
||||
ids := []AuditID{}
|
||||
for id, err := range backend.SearchAuditLog(ctx, SearchAuditLogOptions{}) {
|
||||
records := []*AuditRecord{}
|
||||
ids := backend.SearchAuditLog(ctx, SearchAuditLogOptions{})
|
||||
for record, err := range backend.GetAuditLogRecords(ctx, ids) {
|
||||
if err != nil {
|
||||
logc.Fatalln(ctx, err)
|
||||
}
|
||||
go func() {
|
||||
if record, err := backend.QueryAuditLog(ctx, id); err != nil {
|
||||
logc.Fatalln(ctx, err)
|
||||
} else {
|
||||
ch <- record
|
||||
}
|
||||
}()
|
||||
ids = append(ids, id)
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
records := map[AuditID]*AuditRecord{}
|
||||
for len(records) < len(ids) {
|
||||
record := <-ch
|
||||
records[record.GetAuditID()] = record
|
||||
}
|
||||
slices.SortFunc(records, func(a, b *AuditRecord) int {
|
||||
return cmp.Compare(a.GetAuditID(), b.GetAuditID())
|
||||
})
|
||||
|
||||
for _, id := range ids {
|
||||
record := records[id]
|
||||
for _, record := range records {
|
||||
fmt.Fprintf(color.Output, "%s %s %s %s %s\n",
|
||||
record.GetAuditID().String(),
|
||||
color.HiWhiteString(record.GetTimestamp().AsTime().UTC().Format(time.RFC3339)),
|
||||
@@ -533,6 +560,48 @@ func Main() {
|
||||
logc.Fatalln(ctx, err)
|
||||
}
|
||||
|
||||
case *sizeHistogram != "":
|
||||
extractSize := func(s *DomainStatistics) int64 { return 0 }
|
||||
switch *sizeHistogram {
|
||||
case "original":
|
||||
// Displays a size histogram using the `manifest.OriginalSize`, which is useful to see
|
||||
// which site is the closest to hitting the size limit (checked against apparent size).
|
||||
// This apparent size does not have any direct relationship with used storage.
|
||||
extractSize = func(s *DomainStatistics) int64 { return s.OriginalSize }
|
||||
case "stored":
|
||||
// Displays a size histogram using the `manifest.StoredSize`, which is useful to see
|
||||
// which site consumes the most resources. The site is keeping at least this many
|
||||
// bytes worth of blobs alive, but removing it may not free any space because
|
||||
// deduplication is global.
|
||||
extractSize = func(s *DomainStatistics) int64 { return s.StoredSize }
|
||||
default:
|
||||
logc.Fatalln(ctx, "unknown histogram type")
|
||||
}
|
||||
|
||||
histogram, err := SizeHistogram(ctx)
|
||||
if err != nil {
|
||||
logc.Fatalln(ctx, err)
|
||||
}
|
||||
slices.SortFunc(histogram, func(a *DomainStatistics, b *DomainStatistics) int {
|
||||
return cmp.Compare(extractSize(a), extractSize(b))
|
||||
})
|
||||
|
||||
if len(histogram) > 0 {
|
||||
fullScaleSize := max(extractSize(histogram[len(histogram)-1]), 1)
|
||||
fullScaleWidth := int64(40)
|
||||
for _, statistics := range histogram {
|
||||
size := extractSize(statistics)
|
||||
barWidth := size * fullScaleWidth / fullScaleSize
|
||||
spaceWidth := fullScaleWidth - barWidth
|
||||
bar := strings.Repeat("*", int(barWidth)) + strings.Repeat(" ", int(spaceWidth))
|
||||
fmt.Fprintf(color.Output, "%s %s %s\n",
|
||||
color.HiBlackString(fmt.Sprint("|", bar, "|")),
|
||||
statistics.Domain,
|
||||
color.HiGreenString(datasize.ByteSize(extractSize(statistics)).HR()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case *traceGarbage:
|
||||
if err = TraceGarbage(ctx); err != nil {
|
||||
logc.Fatalln(ctx, err)
|
||||
@@ -547,7 +616,7 @@ func Main() {
|
||||
// Note that not all of the configuration is updated on reload. Listeners are kept as-is.
|
||||
// The backend is not recreated (this is intentional as it allows preserving the cache).
|
||||
OnReload(func() {
|
||||
if newConfig, err := Configure(*configTomlPath); err != nil {
|
||||
if newConfig, err := Configure(*configTomlPath, *secretTomlPath); err != nil {
|
||||
logc.Println(ctx, "config: reload err:", err)
|
||||
} else {
|
||||
// From https://go.dev/ref/mem:
|
||||
@@ -585,6 +654,10 @@ func Main() {
|
||||
}
|
||||
backend = NewObservedBackend(backend)
|
||||
|
||||
if domainCache, err = CreateDomainCache(ctx); err != nil {
|
||||
logc.Fatalln(ctx, err)
|
||||
}
|
||||
|
||||
middleware := chainHTTPMiddleware(
|
||||
panicHandler,
|
||||
remoteAddrMiddleware,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
@@ -144,6 +145,63 @@ func AddProblem(manifest *Manifest, pathName, format string, args ...any) error
|
||||
return fmt.Errorf("%s: %s", pathName, cause)
|
||||
}
|
||||
|
||||
// Returns a map of git hash to entry. If `manifest` is nil, returns an empty map.
|
||||
func IndexManifestByGitHash(manifest *Manifest) map[string]*Entry {
|
||||
index := map[string]*Entry{}
|
||||
for _, entry := range manifest.GetContents() {
|
||||
if hash := entry.GetGitHash(); hash != "" {
|
||||
if _, ok := plumbing.FromHex(hash); ok {
|
||||
index[hash] = entry
|
||||
} else {
|
||||
panic(fmt.Errorf("index: malformed hash: %s", hash))
|
||||
}
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func ManifestHasBasicAuth(manifest *Manifest) bool {
|
||||
return len(manifest.GetBasicAuth()) > 0
|
||||
}
|
||||
|
||||
func IsEntryRegularFile(entry *Entry) bool {
|
||||
return entry.GetType() == Type_InlineFile ||
|
||||
entry.GetType() == Type_ExternalFile
|
||||
}
|
||||
|
||||
var ErrNotRegularFile = errors.New("not a regular file")
|
||||
|
||||
func GetEntryContents(ctx context.Context, entry *Entry) (data []byte, err error) {
|
||||
switch entry.GetType() {
|
||||
case Type_InlineFile:
|
||||
data = entry.GetData()
|
||||
case Type_ExternalFile:
|
||||
reader, _, err := backend.GetBlob(ctx, string(entry.GetData()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err = io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, ErrNotRegularFile
|
||||
}
|
||||
|
||||
switch entry.GetTransform() {
|
||||
case Transform_Identity:
|
||||
case Transform_Zstd:
|
||||
data, err = zstdDecoder.DecodeAll(data, []byte{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected transform")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// EnsureLeadingDirectories adds directory entries for any parent directories
|
||||
// that are implicitly referenced by files in the manifest but don't have
|
||||
// explicit directory entries. (This can be the case if an archive is created
|
||||
@@ -151,6 +209,9 @@ func AddProblem(manifest *Manifest, pathName, format string, args ...any) error
|
||||
func EnsureLeadingDirectories(manifest *Manifest) {
|
||||
for name := range manifest.Contents {
|
||||
for dir := path.Dir(name); dir != "." && dir != ""; dir = path.Dir(dir) {
|
||||
if dir == "/" {
|
||||
panic("malformed manifest (paths must not be rooted in /)")
|
||||
}
|
||||
if _, exists := manifest.Contents[dir]; !exists {
|
||||
AddDirectory(manifest, dir)
|
||||
}
|
||||
@@ -272,7 +333,7 @@ func CompressFiles(ctx context.Context, manifest *Manifest) {
|
||||
// (Perhaps in the future they could be exposed at `.git-pages/status.txt`?)
|
||||
func PrepareManifest(ctx context.Context, manifest *Manifest) error {
|
||||
// Parse Netlify-style `_redirects`.
|
||||
if err := ProcessRedirectsFile(manifest); err != nil {
|
||||
if err := ProcessRedirectsFile(ctx, manifest); err != nil {
|
||||
logc.Printf(ctx, "redirects err: %s\n", err)
|
||||
} else if len(manifest.Redirects) > 0 {
|
||||
logc.Printf(ctx, "redirects ok: %d rules\n", len(manifest.Redirects))
|
||||
@@ -282,7 +343,7 @@ func PrepareManifest(ctx context.Context, manifest *Manifest) error {
|
||||
LintRedirects(manifest)
|
||||
|
||||
// Parse Netlify-style `_headers`.
|
||||
if err := ProcessHeadersFile(manifest); err != nil {
|
||||
if err := ProcessHeadersFile(ctx, manifest); err != nil {
|
||||
logc.Printf(ctx, "headers err: %s\n", err)
|
||||
} else if len(manifest.Headers) > 0 {
|
||||
logc.Printf(ctx, "headers ok: %d rules\n", len(manifest.Headers))
|
||||
@@ -300,6 +361,12 @@ func PrepareManifest(ctx context.Context, manifest *Manifest) error {
|
||||
var ErrSiteTooLarge = errors.New("site too large")
|
||||
var ErrManifestTooLarge = errors.New("manifest too large")
|
||||
|
||||
// Limits the number of concurrent uploads, globally across the entire git-pages process.
|
||||
// As created, there is no limit, but reinitializing the semaphore with a bounded channel
|
||||
// limits the concurrency to the channel size. Note that the default *configuration* does
|
||||
// limit the number of uploads.
|
||||
var putBlobSemaphore = make(chan struct{})
|
||||
|
||||
// Uploads inline file data over certain size to the storage backend. Returns a copy of
|
||||
// the manifest updated to refer to an external content-addressable store.
|
||||
func StoreManifest(
|
||||
@@ -308,19 +375,11 @@ func StoreManifest(
|
||||
span, ctx := ObserveFunction(ctx, "StoreManifest", "manifest.name", name)
|
||||
defer span.Finish()
|
||||
|
||||
extManifest := &Manifest{}
|
||||
proto.Merge(extManifest, manifest)
|
||||
|
||||
// Replace inline files over certain size with references to external data.
|
||||
extManifest := Manifest{
|
||||
RepoUrl: manifest.RepoUrl,
|
||||
Branch: manifest.Branch,
|
||||
Commit: manifest.Commit,
|
||||
Contents: make(map[string]*Entry),
|
||||
Redirects: manifest.Redirects,
|
||||
Headers: manifest.Headers,
|
||||
Problems: manifest.Problems,
|
||||
OriginalSize: manifest.OriginalSize,
|
||||
CompressedSize: manifest.CompressedSize,
|
||||
StoredSize: proto.Int64(0),
|
||||
}
|
||||
extManifest.Contents = make(map[string]*Entry)
|
||||
for name, entry := range manifest.Contents {
|
||||
cannotBeInlined := entry.GetType() == Type_InlineFile &&
|
||||
entry.GetCompressedSize() > int64(config.Limits.MaxInlineFileSize.Bytes())
|
||||
@@ -356,12 +415,13 @@ func StoreManifest(
|
||||
config.Limits.MaxSiteSize.HR(),
|
||||
)
|
||||
}
|
||||
extManifest.StoredSize = proto.Int64(0)
|
||||
for _, blobSize := range blobSizes {
|
||||
*extManifest.StoredSize += blobSize
|
||||
}
|
||||
|
||||
// Upload the resulting manifest and the blob it references.
|
||||
extManifestData := EncodeManifest(&extManifest)
|
||||
extManifestData := EncodeManifest(extManifest)
|
||||
if uint64(len(extManifestData)) > config.Limits.MaxManifestSize.Bytes() {
|
||||
return nil, fmt.Errorf("%w: manifest size %s exceeds %s limit",
|
||||
ErrManifestTooLarge,
|
||||
@@ -370,7 +430,7 @@ func StoreManifest(
|
||||
)
|
||||
}
|
||||
|
||||
if err := backend.StageManifest(ctx, &extManifest); err != nil {
|
||||
if err := backend.StageManifest(ctx, extManifest); err != nil {
|
||||
return nil, fmt.Errorf("stage manifest: %w", err)
|
||||
}
|
||||
|
||||
@@ -381,7 +441,9 @@ func StoreManifest(
|
||||
// If the entry in the original manifest is already an external reference, there's no need
|
||||
// to externalize it (and no way for us to do so, since the entry only contains the blob name).
|
||||
if entry.GetType() == Type_ExternalFile && manifest.Contents[name].GetType() == Type_InlineFile {
|
||||
putBlobSemaphore <- struct{}{} // acquire (and maybe block)
|
||||
wg.Go(func() {
|
||||
defer func() { <-putBlobSemaphore }() // release
|
||||
err := backend.PutBlob(ctx, string(entry.Data), manifest.Contents[name].Data)
|
||||
if err != nil {
|
||||
ch <- fmt.Errorf("put blob %s: %w", name, err)
|
||||
@@ -395,7 +457,7 @@ func StoreManifest(
|
||||
return nil, err // currently ignores all but 1st error
|
||||
}
|
||||
|
||||
if err := backend.CommitManifest(ctx, name, &extManifest, opts); err != nil {
|
||||
if err := backend.CommitManifest(ctx, name, extManifest, opts); err != nil {
|
||||
if errors.Is(err, ErrDomainFrozen) {
|
||||
return nil, err
|
||||
} else {
|
||||
@@ -403,5 +465,5 @@ func StoreManifest(
|
||||
}
|
||||
}
|
||||
|
||||
return &extManifest, nil
|
||||
return extManifest, nil
|
||||
}
|
||||
|
||||
179
src/observe.go
179
src/observe.go
@@ -8,11 +8,9 @@ import (
|
||||
"iter"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -22,10 +20,6 @@ import (
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
sentryslog "github.com/getsentry/sentry-go/slog"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -47,71 +41,9 @@ var (
|
||||
|
||||
var syslogHandler syslog.Handler
|
||||
|
||||
func hasSentry() bool {
|
||||
return os.Getenv("SENTRY_DSN") != ""
|
||||
}
|
||||
|
||||
func chainSentryMiddleware(
|
||||
middleware ...func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event,
|
||||
) func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||
return func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||
for idx := 0; idx < len(middleware) && event != nil; idx++ {
|
||||
event = middleware[idx](event, hint)
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// sensitiveHTTPHeaders extends the list of sensitive headers defined in the Sentry Go SDK with our
|
||||
// own application-specific header field names.
|
||||
var sensitiveHTTPHeaders = map[string]struct{}{
|
||||
"Forge-Authorization": {},
|
||||
}
|
||||
|
||||
// scrubSentryEvent removes sensitive HTTP header fields from the Sentry event.
|
||||
func scrubSentryEvent(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||
if event.Request != nil && event.Request.Headers != nil {
|
||||
for key := range event.Request.Headers {
|
||||
if _, ok := sensitiveHTTPHeaders[key]; ok {
|
||||
delete(event.Request.Headers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
// sampleSentryEvent returns a function that discards a Sentry event according to the sample rate,
|
||||
// unless the associated HTTP request triggers a mutation or it took too long to produce a response,
|
||||
// in which case the event is never discarded.
|
||||
func sampleSentryEvent(sampleRate float64) func(*sentry.Event, *sentry.EventHint) *sentry.Event {
|
||||
return func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
|
||||
newSampleRate := sampleRate
|
||||
if event.Request != nil {
|
||||
switch event.Request.Method {
|
||||
case "PUT", "POST", "DELETE":
|
||||
newSampleRate = 1
|
||||
}
|
||||
}
|
||||
duration := event.Timestamp.Sub(event.StartTime)
|
||||
threshold := time.Duration(config.Observability.SlowResponseThreshold)
|
||||
if duration >= threshold {
|
||||
newSampleRate = 1
|
||||
}
|
||||
if rand.Float64() < newSampleRate {
|
||||
return event
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func InitObservability() {
|
||||
debug.SetPanicOnFault(true)
|
||||
|
||||
environment := "development"
|
||||
if value, ok := os.LookupEnv("ENVIRONMENT"); ok {
|
||||
environment = value
|
||||
}
|
||||
|
||||
logHandlers := []slog.Handler{}
|
||||
|
||||
switch config.LogFormat {
|
||||
@@ -140,46 +72,6 @@ func InitObservability() {
|
||||
logHandlers = append(logHandlers, syslogHandler)
|
||||
}
|
||||
|
||||
if hasSentry() {
|
||||
enableLogs := false
|
||||
if value, err := strconv.ParseBool(os.Getenv("SENTRY_LOGS")); err == nil {
|
||||
enableLogs = value
|
||||
}
|
||||
|
||||
enableTracing := false
|
||||
if value, err := strconv.ParseBool(os.Getenv("SENTRY_TRACING")); err == nil {
|
||||
enableTracing = value
|
||||
}
|
||||
|
||||
tracesSampleRate := 1.00
|
||||
switch environment {
|
||||
case "development", "staging":
|
||||
default:
|
||||
tracesSampleRate = 0.05
|
||||
}
|
||||
|
||||
options := sentry.ClientOptions{}
|
||||
options.DisableTelemetryBuffer = !config.Feature("sentry-telemetry-buffer")
|
||||
options.Environment = environment
|
||||
options.EnableLogs = enableLogs
|
||||
options.EnableTracing = enableTracing
|
||||
options.TracesSampleRate = 1 // use our own custom sampling logic
|
||||
options.BeforeSend = scrubSentryEvent
|
||||
options.BeforeSendTransaction = chainSentryMiddleware(
|
||||
sampleSentryEvent(tracesSampleRate),
|
||||
scrubSentryEvent,
|
||||
)
|
||||
if err := sentry.Init(options); err != nil {
|
||||
log.Fatalf("sentry: %s\n", err)
|
||||
}
|
||||
|
||||
if enableLogs {
|
||||
logHandlers = append(logHandlers, sentryslog.Option{
|
||||
AddSource: true,
|
||||
}.NewSentryHandler(context.Background()))
|
||||
}
|
||||
}
|
||||
|
||||
slog.SetDefault(slog.New(slogmulti.Fanout(logHandlers...)))
|
||||
}
|
||||
|
||||
@@ -189,9 +81,6 @@ func FiniObservability() {
|
||||
if syslogHandler != nil {
|
||||
wg.Go(func() { syslogHandler.Flush(timeout) })
|
||||
}
|
||||
if hasSentry() {
|
||||
wg.Go(func() { sentry.Flush(timeout) })
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -201,10 +90,6 @@ func ObserveError(err error) {
|
||||
// Timeout results in a different error.
|
||||
return
|
||||
}
|
||||
|
||||
if hasSentry() {
|
||||
sentry.CaptureException(err)
|
||||
}
|
||||
}
|
||||
|
||||
type observedResponseWriter struct {
|
||||
@@ -237,22 +122,6 @@ func (w *observedResponseWriter) WriteHeader(statusCode int) {
|
||||
}
|
||||
|
||||
func ObserveHTTPHandler(handler http.Handler) http.Handler {
|
||||
if hasSentry() {
|
||||
handler = func(next http.Handler) http.Handler {
|
||||
next = sentryhttp.New(sentryhttp.Options{
|
||||
Repanic: true,
|
||||
}).Handle(handler)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Prevent the Sentry SDK from continuing traces as we don't use this feature.
|
||||
r.Header.Del(sentry.SentryTraceHeader)
|
||||
r.Header.Del(sentry.SentryBaggageHeader)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}(handler)
|
||||
}
|
||||
|
||||
handler = func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ow := newObservedResponseWriter(w)
|
||||
@@ -283,23 +152,12 @@ func ObserveFunction(
|
||||
interface{ Finish() }, context.Context,
|
||||
) {
|
||||
switch {
|
||||
case hasSentry():
|
||||
span := sentry.StartSpan(ctx, "function")
|
||||
span.Description = funcName
|
||||
ObserveData(span.Context(), data...)
|
||||
return span, span.Context()
|
||||
default:
|
||||
return noopSpan{}, ctx
|
||||
}
|
||||
}
|
||||
|
||||
func ObserveData(ctx context.Context, data ...any) {
|
||||
if span := sentry.SpanFromContext(ctx); span != nil {
|
||||
for i := 0; i < len(data); i += 2 {
|
||||
name, value := data[i], data[i+1]
|
||||
span.SetData(name.(string), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -436,8 +294,8 @@ func (backend *observedBackend) DeleteManifest(ctx context.Context, name string,
|
||||
return
|
||||
}
|
||||
|
||||
func (backend *observedBackend) EnumerateManifests(ctx context.Context) iter.Seq2[ManifestMetadata, error] {
|
||||
return func(yield func(ManifestMetadata, error) bool) {
|
||||
func (backend *observedBackend) EnumerateManifests(ctx context.Context) iter.Seq2[*ManifestMetadata, error] {
|
||||
return func(yield func(*ManifestMetadata, error) bool) {
|
||||
span, ctx := ObserveFunction(ctx, "EnumerateManifests")
|
||||
for metadata, err := range backend.inner.EnumerateManifests(ctx) {
|
||||
if !yield(metadata, err) {
|
||||
@@ -448,6 +306,18 @@ func (backend *observedBackend) EnumerateManifests(ctx context.Context) iter.Seq
|
||||
}
|
||||
}
|
||||
|
||||
func (backend *observedBackend) GetAllManifests(ctx context.Context) iter.Seq2[tuple[*ManifestMetadata, *Manifest], error] {
|
||||
return func(yield func(tuple[*ManifestMetadata, *Manifest], error) bool) {
|
||||
span, ctx := ObserveFunction(ctx, "GetAllManifests")
|
||||
for item, err := range backend.inner.GetAllManifests(ctx) {
|
||||
if !yield(item, err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
span.Finish()
|
||||
}
|
||||
}
|
||||
|
||||
func (backend *observedBackend) CheckDomain(ctx context.Context, domain string) (found bool, err error) {
|
||||
span, ctx := ObserveFunction(ctx, "CheckDomain", "domain.name", domain)
|
||||
found, err = backend.inner.CheckDomain(ctx, domain)
|
||||
@@ -476,6 +346,13 @@ func (backend *observedBackend) UnfreezeDomain(ctx context.Context, domain strin
|
||||
return
|
||||
}
|
||||
|
||||
func (backend *observedBackend) HaveDomainsChanged(ctx context.Context, since time.Time) (changed bool, err error) {
|
||||
span, ctx := ObserveFunction(ctx, "HaveDomainsChanged", "since", since)
|
||||
changed, err = backend.inner.HaveDomainsChanged(ctx, since)
|
||||
span.Finish()
|
||||
return
|
||||
}
|
||||
|
||||
func (backend *observedBackend) AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) (err error) {
|
||||
span, ctx := ObserveFunction(ctx, "AppendAuditLog", "audit.id", id)
|
||||
err = backend.inner.AppendAuditLog(ctx, id, record)
|
||||
@@ -506,3 +383,17 @@ func (backend *observedBackend) SearchAuditLog(
|
||||
span.Finish()
|
||||
}
|
||||
}
|
||||
|
||||
func (backend *observedBackend) GetAuditLogRecords(
|
||||
ctx context.Context, ids iter.Seq2[AuditID, error],
|
||||
) iter.Seq2[*AuditRecord, error] {
|
||||
return func(yield func(*AuditRecord, error) bool) {
|
||||
span, ctx := ObserveFunction(ctx, "GetAuditLogRecords")
|
||||
for item, err := range backend.inner.GetAuditLogRecords(ctx, ids) {
|
||||
if !yield(item, err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
span.Finish()
|
||||
}
|
||||
}
|
||||
|
||||
91
src/pages.go
91
src/pages.go
@@ -65,8 +65,12 @@ func observeSiteUpdate(via string, result *UpdateResult) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
func makeWebRoot(host string, projectName string) string {
|
||||
return path.Join(strings.ToLower(host), projectName)
|
||||
return path.Join(normalizeHost(host), projectName)
|
||||
}
|
||||
|
||||
func getWebRoot(r *http.Request) (string, error) {
|
||||
@@ -115,6 +119,13 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
|
||||
return err
|
||||
}
|
||||
|
||||
host = normalizeHost(host)
|
||||
if !domainCache.CheckDomain(r.Context(), host) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "site not found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
type indexManifestResult struct {
|
||||
manifest *Manifest
|
||||
metadata ManifestMetadata
|
||||
@@ -193,8 +204,8 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
case metadataPath == "manifest.json":
|
||||
// metadata requests require authorization to avoid making pushes from private
|
||||
// repositories enumerable
|
||||
_, err := AuthorizeMetadataRetrieval(r)
|
||||
// repositories enumerable or exposing basic-auth protected sections
|
||||
_, err := AuthorizeMetadataRetrieval(r, ManifestHasBasicAuth(manifest))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,7 +219,7 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
case metadataPath == "archive.tar":
|
||||
// same as above
|
||||
_, err := AuthorizeMetadataRetrieval(r)
|
||||
_, err := AuthorizeMetadataRetrieval(r, ManifestHasBasicAuth(manifest))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -244,6 +255,19 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply basic-auth rules before checking existence of a path to avoid leaking the latter.
|
||||
authorized, err := ApplyBasicAuthRules(manifest, &url.URL{Path: sitePath}, r)
|
||||
if err != nil {
|
||||
// See comment below for the error case under `ApplyHeaderRules`.
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "%s\n", err)
|
||||
return err
|
||||
} else if !authorized {
|
||||
w.Header().Set("WWW-Authenticate", `Basic charset="UTF-8"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return nil
|
||||
}
|
||||
|
||||
entryPath := sitePath
|
||||
entry := (*Entry)(nil)
|
||||
appliedRedirect := false
|
||||
@@ -499,9 +523,10 @@ func putPage(w http.ResponseWriter, r *http.Request) error {
|
||||
result = UpdateFromRepository(ctx, webRoot, repoURL, branch)
|
||||
|
||||
default:
|
||||
_, err := AuthorizeUpdateFromArchive(r)
|
||||
if err != nil {
|
||||
if auth, err := AuthorizeUpdateFromArchive(r); err != nil {
|
||||
return err
|
||||
} else if auth.forgeUser != nil {
|
||||
GetPrincipal(r.Context()).ForgeUser = auth.forgeUser
|
||||
}
|
||||
|
||||
if checkDryRun(w, r) {
|
||||
@@ -531,8 +556,10 @@ func patchPage(w http.ResponseWriter, r *http.Request) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = AuthorizeUpdateFromArchive(r); err != nil {
|
||||
if auth, err := AuthorizeUpdateFromArchive(r); err != nil {
|
||||
return err
|
||||
} else if auth.forgeUser != nil {
|
||||
GetPrincipal(r.Context()).ForgeUser = auth.forgeUser
|
||||
}
|
||||
|
||||
if checkDryRun(w, r) {
|
||||
@@ -661,9 +688,10 @@ func deletePage(w http.ResponseWriter, r *http.Request) error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = AuthorizeUpdateFromRepository(r)
|
||||
if err != nil {
|
||||
if auth, err := AuthorizeDeletion(r); err != nil {
|
||||
return err
|
||||
} else if auth.forgeUser != nil {
|
||||
GetPrincipal(r.Context()).ForgeUser = auth.forgeUser
|
||||
}
|
||||
|
||||
if checkDryRun(w, r) {
|
||||
@@ -681,7 +709,9 @@ func deletePage(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
|
||||
func postPage(w http.ResponseWriter, r *http.Request) error {
|
||||
// Start a timer for the request timeout immediately.
|
||||
// The HTTP requests for webhook delivery usually have a short timeout. We start the timer
|
||||
// before doing any time-consuming work so that it's closely aligned to the client's timeout and
|
||||
// we can respond before the webhook delivery is considered failed.
|
||||
requestTimeout := 3 * time.Second
|
||||
requestTimer := time.NewTimer(requestTimeout)
|
||||
|
||||
@@ -816,31 +846,24 @@ func ServePages(w http.ResponseWriter, r *http.Request) {
|
||||
if config.Audit.IncludeIPs != "" {
|
||||
GetPrincipal(r.Context()).IpAddress = proto.String(r.RemoteAddr)
|
||||
}
|
||||
// We want upstream health checks to be done as closely to the normal flow as possible;
|
||||
// any intentional deviation is an opportunity to miss an issue that will affect our
|
||||
// visitors but not our health checks.
|
||||
if r.Header.Get("Health-Check") == "" {
|
||||
var mediaType string
|
||||
switch r.Method {
|
||||
case "HEAD", "GET":
|
||||
mediaType = r.Header.Get("Accept")
|
||||
default:
|
||||
mediaType = r.Header.Get("Content-Type")
|
||||
}
|
||||
logc.Println(r.Context(), "pages:", r.Method, r.Host, r.URL, mediaType)
|
||||
if region := os.Getenv("FLY_REGION"); region != "" {
|
||||
machine_id := os.Getenv("FLY_MACHINE_ID")
|
||||
w.Header().Add("Server", fmt.Sprintf("git-pages (fly.io; %s; %s)", region, machine_id))
|
||||
ObserveData(r.Context(), "server.name", machine_id, "server.region", region)
|
||||
} else if hostname, err := os.Hostname(); err == nil {
|
||||
if region := os.Getenv("PAGES_REGION"); region != "" {
|
||||
w.Header().Add("Server", fmt.Sprintf("git-pages (%s; %s)", region, hostname))
|
||||
ObserveData(r.Context(), "server.name", hostname, "server.region", region)
|
||||
} else {
|
||||
w.Header().Add("Server", fmt.Sprintf("git-pages (%s)", hostname))
|
||||
ObserveData(r.Context(), "server.name", hostname)
|
||||
}
|
||||
var mediaType string
|
||||
switch r.Method {
|
||||
case "HEAD", "GET":
|
||||
mediaType = r.Header.Get("Accept")
|
||||
default:
|
||||
mediaType = r.Header.Get("Content-Type")
|
||||
}
|
||||
logc.Println(r.Context(), "pages:", r.Method, r.Host, r.URL, mediaType)
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
if region := os.Getenv("PAGES_REGION"); region != "" {
|
||||
w.Header().Add("Server", fmt.Sprintf("git-pages (%s; %s)", region, hostname))
|
||||
ObserveData(r.Context(), "server.name", hostname, "server.region", region)
|
||||
} else {
|
||||
w.Header().Add("Server", fmt.Sprintf("git-pages (%s)", hostname))
|
||||
ObserveData(r.Context(), "server.name", hostname)
|
||||
}
|
||||
} else {
|
||||
w.Header().Add("Server", "git-pages")
|
||||
}
|
||||
allowedMethods := []string{"OPTIONS", "HEAD", "GET", "PUT", "PATCH", "DELETE", "POST"}
|
||||
if r.Method == "OPTIONS" || !slices.Contains(allowedMethods, r.Method) {
|
||||
|
||||
28
src/patch.go
28
src/patch.go
@@ -30,8 +30,12 @@ func ApplyTarPatch(manifest *Manifest, reader io.Reader, parents CreateParentsMo
|
||||
children map[string]*Node
|
||||
}
|
||||
|
||||
// Index the manifest for incremental update operations.
|
||||
index := IndexManifestByGitHash(manifest)
|
||||
missing := []string{}
|
||||
|
||||
// Extract the manifest contents (which is using a flat hash map) into a directory tree
|
||||
// so that recursive delete operations have O(1) complexity. s
|
||||
// so that recursive delete operations have O(1) complexity.
|
||||
var root *Node
|
||||
sortedNames := slices.Sorted(maps.Keys(manifest.GetContents()))
|
||||
for _, name := range sortedNames {
|
||||
@@ -48,9 +52,9 @@ func ApplyTarPatch(manifest *Manifest, reader io.Reader, parents CreateParentsMo
|
||||
iter := root
|
||||
for _, segment := range segments[:len(segments)-1] {
|
||||
if iter.children == nil {
|
||||
panic("malformed manifest")
|
||||
panic("malformed manifest (not a directory)")
|
||||
} else if _, exists := iter.children[segment]; !exists {
|
||||
panic("malformed manifest")
|
||||
panic("malformed manifest (node does not exist)")
|
||||
} else {
|
||||
iter = iter.children[segment]
|
||||
}
|
||||
@@ -70,7 +74,7 @@ func ApplyTarPatch(manifest *Manifest, reader io.Reader, parents CreateParentsMo
|
||||
return err
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.TrimRight(header.Name, "/"), "/")
|
||||
segments := strings.Split(normalizeArchiveMemberName(header.Name), "/")
|
||||
fileName := segments[len(segments)-1]
|
||||
node := root
|
||||
for index, segment := range segments[:len(segments)-1] {
|
||||
@@ -107,8 +111,16 @@ func ApplyTarPatch(manifest *Manifest, reader io.Reader, parents CreateParentsMo
|
||||
entry: NewManifestEntry(Type_InlineFile, fileData),
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
node.children[fileName] = &Node{
|
||||
entry: NewManifestEntry(Type_Symlink, []byte(header.Linkname)),
|
||||
if hash, found := strings.CutPrefix(header.Linkname, BlobReferencePrefix); found {
|
||||
if entry, found := index[hash]; found {
|
||||
node.children[fileName] = &Node{entry: entry}
|
||||
} else {
|
||||
missing = append(missing, hash)
|
||||
}
|
||||
} else {
|
||||
node.children[fileName] = &Node{
|
||||
entry: NewManifestEntry(Type_Symlink, []byte(header.Linkname)),
|
||||
}
|
||||
}
|
||||
case tar.TypeDir:
|
||||
node.children[fileName] = &Node{
|
||||
@@ -129,6 +141,10 @@ func ApplyTarPatch(manifest *Manifest, reader io.Reader, parents CreateParentsMo
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return UnresolvedRefError{missing}
|
||||
}
|
||||
|
||||
// Repopulate manifest contents with the updated directory tree.
|
||||
var traverse func([]string, *Node)
|
||||
traverse = func(segments []string, node *Node) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package git_pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -96,17 +98,22 @@ func validateRedirectRule(rule *redirects.Rule) error {
|
||||
}
|
||||
|
||||
// Parses redirects file and injects rules into the manifest.
|
||||
func ProcessRedirectsFile(manifest *Manifest) error {
|
||||
func ProcessRedirectsFile(ctx context.Context, manifest *Manifest) error {
|
||||
redirectsEntry := manifest.Contents[RedirectsFileName]
|
||||
delete(manifest.Contents, RedirectsFileName)
|
||||
if redirectsEntry == nil {
|
||||
return nil
|
||||
} else if redirectsEntry.GetType() != Type_InlineFile {
|
||||
return AddProblem(manifest, RedirectsFileName,
|
||||
"not a regular file")
|
||||
}
|
||||
|
||||
rules, err := redirects.ParseString(string(redirectsEntry.GetData()))
|
||||
data, err := GetEntryContents(ctx, redirectsEntry)
|
||||
if errors.Is(err, ErrNotRegularFile) {
|
||||
return AddProblem(manifest, RedirectsFileName,
|
||||
"not a regular file")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rules, err := redirects.ParseString(string(data))
|
||||
if err != nil {
|
||||
return AddProblem(manifest, RedirectsFileName,
|
||||
"syntax error: %s", err)
|
||||
|
||||
283
src/schema.pb.go
283
src/schema.pb.go
@@ -479,6 +479,110 @@ func (x *HeaderRule) GetHeaderMap() []*Header {
|
||||
return nil
|
||||
}
|
||||
|
||||
type BasicCredential struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Username *string `protobuf:"bytes,1,opt,name=username" json:"username,omitempty"`
|
||||
Password *string `protobuf:"bytes,2,opt,name=password" json:"password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *BasicCredential) Reset() {
|
||||
*x = BasicCredential{}
|
||||
mi := &file_schema_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *BasicCredential) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*BasicCredential) ProtoMessage() {}
|
||||
|
||||
func (x *BasicCredential) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_schema_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use BasicCredential.ProtoReflect.Descriptor instead.
|
||||
func (*BasicCredential) Descriptor() ([]byte, []int) {
|
||||
return file_schema_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *BasicCredential) GetUsername() string {
|
||||
if x != nil && x.Username != nil {
|
||||
return *x.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *BasicCredential) GetPassword() string {
|
||||
if x != nil && x.Password != nil {
|
||||
return *x.Password
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type BasicAuthRule struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Path *string `protobuf:"bytes,1,opt,name=path" json:"path,omitempty"`
|
||||
Credentials []*BasicCredential `protobuf:"bytes,2,rep,name=credentials" json:"credentials,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *BasicAuthRule) Reset() {
|
||||
*x = BasicAuthRule{}
|
||||
mi := &file_schema_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *BasicAuthRule) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*BasicAuthRule) ProtoMessage() {}
|
||||
|
||||
func (x *BasicAuthRule) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_schema_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use BasicAuthRule.ProtoReflect.Descriptor instead.
|
||||
func (*BasicAuthRule) Descriptor() ([]byte, []int) {
|
||||
return file_schema_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *BasicAuthRule) GetPath() string {
|
||||
if x != nil && x.Path != nil {
|
||||
return *x.Path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *BasicAuthRule) GetCredentials() []*BasicCredential {
|
||||
if x != nil {
|
||||
return x.Credentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Path *string `protobuf:"bytes,1,opt,name=path" json:"path,omitempty"`
|
||||
@@ -489,7 +593,7 @@ type Problem struct {
|
||||
|
||||
func (x *Problem) Reset() {
|
||||
*x = Problem{}
|
||||
mi := &file_schema_proto_msgTypes[4]
|
||||
mi := &file_schema_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -501,7 +605,7 @@ func (x *Problem) String() string {
|
||||
func (*Problem) ProtoMessage() {}
|
||||
|
||||
func (x *Problem) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_schema_proto_msgTypes[4]
|
||||
mi := &file_schema_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -514,7 +618,7 @@ func (x *Problem) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Problem.ProtoReflect.Descriptor instead.
|
||||
func (*Problem) Descriptor() ([]byte, []int) {
|
||||
return file_schema_proto_rawDescGZIP(), []int{4}
|
||||
return file_schema_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *Problem) GetPath() string {
|
||||
@@ -543,8 +647,9 @@ type Manifest struct {
|
||||
CompressedSize *int64 `protobuf:"varint,5,opt,name=compressed_size,json=compressedSize" json:"compressed_size,omitempty"` // sum of each `entry.compressed_size`
|
||||
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
|
||||
// Netlify-style `_redirects` and `_headers` rules.
|
||||
Redirects []*RedirectRule `protobuf:"bytes,6,rep,name=redirects" json:"redirects,omitempty"`
|
||||
Headers []*HeaderRule `protobuf:"bytes,9,rep,name=headers" json:"headers,omitempty"`
|
||||
Redirects []*RedirectRule `protobuf:"bytes,6,rep,name=redirects" json:"redirects,omitempty"`
|
||||
Headers []*HeaderRule `protobuf:"bytes,9,rep,name=headers" json:"headers,omitempty"`
|
||||
BasicAuth []*BasicAuthRule `protobuf:"bytes,11,rep,name=basic_auth,json=basicAuth" json:"basic_auth,omitempty"`
|
||||
// Diagnostics for non-fatal errors.
|
||||
Problems []*Problem `protobuf:"bytes,7,rep,name=problems" json:"problems,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -553,7 +658,7 @@ type Manifest struct {
|
||||
|
||||
func (x *Manifest) Reset() {
|
||||
*x = Manifest{}
|
||||
mi := &file_schema_proto_msgTypes[5]
|
||||
mi := &file_schema_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -565,7 +670,7 @@ func (x *Manifest) String() string {
|
||||
func (*Manifest) ProtoMessage() {}
|
||||
|
||||
func (x *Manifest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_schema_proto_msgTypes[5]
|
||||
mi := &file_schema_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -578,7 +683,7 @@ func (x *Manifest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Manifest.ProtoReflect.Descriptor instead.
|
||||
func (*Manifest) Descriptor() ([]byte, []int) {
|
||||
return file_schema_proto_rawDescGZIP(), []int{5}
|
||||
return file_schema_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *Manifest) GetRepoUrl() string {
|
||||
@@ -644,6 +749,13 @@ func (x *Manifest) GetHeaders() []*HeaderRule {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Manifest) GetBasicAuth() []*BasicAuthRule {
|
||||
if x != nil {
|
||||
return x.BasicAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Manifest) GetProblems() []*Problem {
|
||||
if x != nil {
|
||||
return x.Problems
|
||||
@@ -669,7 +781,7 @@ type AuditRecord struct {
|
||||
|
||||
func (x *AuditRecord) Reset() {
|
||||
*x = AuditRecord{}
|
||||
mi := &file_schema_proto_msgTypes[6]
|
||||
mi := &file_schema_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -681,7 +793,7 @@ func (x *AuditRecord) String() string {
|
||||
func (*AuditRecord) ProtoMessage() {}
|
||||
|
||||
func (x *AuditRecord) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_schema_proto_msgTypes[6]
|
||||
mi := &file_schema_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -694,7 +806,7 @@ func (x *AuditRecord) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use AuditRecord.ProtoReflect.Descriptor instead.
|
||||
func (*AuditRecord) Descriptor() ([]byte, []int) {
|
||||
return file_schema_proto_rawDescGZIP(), []int{6}
|
||||
return file_schema_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *AuditRecord) GetId() int64 {
|
||||
@@ -750,13 +862,14 @@ type Principal struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
IpAddress *string `protobuf:"bytes,1,opt,name=ip_address,json=ipAddress" json:"ip_address,omitempty"`
|
||||
CliAdmin *bool `protobuf:"varint,2,opt,name=cli_admin,json=cliAdmin" json:"cli_admin,omitempty"`
|
||||
ForgeUser *ForgeUser `protobuf:"bytes,3,opt,name=forge_user,json=forgeUser" json:"forge_user,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Principal) Reset() {
|
||||
*x = Principal{}
|
||||
mi := &file_schema_proto_msgTypes[7]
|
||||
mi := &file_schema_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -768,7 +881,7 @@ func (x *Principal) String() string {
|
||||
func (*Principal) ProtoMessage() {}
|
||||
|
||||
func (x *Principal) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_schema_proto_msgTypes[7]
|
||||
mi := &file_schema_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -781,7 +894,7 @@ func (x *Principal) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Principal.ProtoReflect.Descriptor instead.
|
||||
func (*Principal) Descriptor() ([]byte, []int) {
|
||||
return file_schema_proto_rawDescGZIP(), []int{7}
|
||||
return file_schema_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *Principal) GetIpAddress() string {
|
||||
@@ -798,6 +911,73 @@ func (x *Principal) GetCliAdmin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *Principal) GetForgeUser() *ForgeUser {
|
||||
if x != nil {
|
||||
return x.ForgeUser
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ForgeUser struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Origin *string `protobuf:"bytes,1,opt,name=origin" json:"origin,omitempty"`
|
||||
Id *int64 `protobuf:"varint,2,opt,name=id" json:"id,omitempty"`
|
||||
Handle *string `protobuf:"bytes,3,opt,name=handle" json:"handle,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ForgeUser) Reset() {
|
||||
*x = ForgeUser{}
|
||||
mi := &file_schema_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ForgeUser) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ForgeUser) ProtoMessage() {}
|
||||
|
||||
func (x *ForgeUser) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_schema_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ForgeUser.ProtoReflect.Descriptor instead.
|
||||
func (*ForgeUser) Descriptor() ([]byte, []int) {
|
||||
return file_schema_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *ForgeUser) GetOrigin() string {
|
||||
if x != nil && x.Origin != nil {
|
||||
return *x.Origin
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ForgeUser) GetId() int64 {
|
||||
if x != nil && x.Id != nil {
|
||||
return *x.Id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ForgeUser) GetHandle() string {
|
||||
if x != nil && x.Handle != nil {
|
||||
return *x.Handle
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_schema_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_schema_proto_rawDesc = "" +
|
||||
@@ -824,10 +1004,16 @@ const file_schema_proto_rawDesc = "" +
|
||||
"HeaderRule\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12&\n" +
|
||||
"\n" +
|
||||
"header_map\x18\x02 \x03(\v2\a.HeaderR\theaderMap\"3\n" +
|
||||
"header_map\x18\x02 \x03(\v2\a.HeaderR\theaderMap\"I\n" +
|
||||
"\x0fBasicCredential\x12\x1a\n" +
|
||||
"\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" +
|
||||
"\bpassword\x18\x02 \x01(\tR\bpassword\"W\n" +
|
||||
"\rBasicAuthRule\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x122\n" +
|
||||
"\vcredentials\x18\x02 \x03(\v2\x10.BasicCredentialR\vcredentials\"3\n" +
|
||||
"\aProblem\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" +
|
||||
"\x05cause\x18\x02 \x01(\tR\x05cause\"\xb8\x03\n" +
|
||||
"\x05cause\x18\x02 \x01(\tR\x05cause\"\xe7\x03\n" +
|
||||
"\bManifest\x12\x19\n" +
|
||||
"\brepo_url\x18\x01 \x01(\tR\arepoUrl\x12\x16\n" +
|
||||
"\x06branch\x18\x02 \x01(\tR\x06branch\x12\x16\n" +
|
||||
@@ -839,7 +1025,9 @@ const file_schema_proto_rawDesc = "" +
|
||||
"\vstored_size\x18\b \x01(\x03R\n" +
|
||||
"storedSize\x12+\n" +
|
||||
"\tredirects\x18\x06 \x03(\v2\r.RedirectRuleR\tredirects\x12%\n" +
|
||||
"\aheaders\x18\t \x03(\v2\v.HeaderRuleR\aheaders\x12$\n" +
|
||||
"\aheaders\x18\t \x03(\v2\v.HeaderRuleR\aheaders\x12-\n" +
|
||||
"\n" +
|
||||
"basic_auth\x18\v \x03(\v2\x0e.BasicAuthRuleR\tbasicAuth\x12$\n" +
|
||||
"\bproblems\x18\a \x03(\v2\b.ProblemR\bproblems\x1aC\n" +
|
||||
"\rContentsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x1c\n" +
|
||||
@@ -853,11 +1041,18 @@ const file_schema_proto_rawDesc = "" +
|
||||
"\x06domain\x18\n" +
|
||||
" \x01(\tR\x06domain\x12\x18\n" +
|
||||
"\aproject\x18\v \x01(\tR\aproject\x12%\n" +
|
||||
"\bmanifest\x18\f \x01(\v2\t.ManifestR\bmanifest\"G\n" +
|
||||
"\bmanifest\x18\f \x01(\v2\t.ManifestR\bmanifest\"r\n" +
|
||||
"\tPrincipal\x12\x1d\n" +
|
||||
"\n" +
|
||||
"ip_address\x18\x01 \x01(\tR\tipAddress\x12\x1b\n" +
|
||||
"\tcli_admin\x18\x02 \x01(\bR\bcliAdmin*V\n" +
|
||||
"\tcli_admin\x18\x02 \x01(\bR\bcliAdmin\x12)\n" +
|
||||
"\n" +
|
||||
"forge_user\x18\x03 \x01(\v2\n" +
|
||||
".ForgeUserR\tforgeUser\"K\n" +
|
||||
"\tForgeUser\x12\x16\n" +
|
||||
"\x06origin\x18\x01 \x01(\tR\x06origin\x12\x0e\n" +
|
||||
"\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" +
|
||||
"\x06handle\x18\x03 \x01(\tR\x06handle*V\n" +
|
||||
"\x04Type\x12\x10\n" +
|
||||
"\fInvalidEntry\x10\x00\x12\r\n" +
|
||||
"\tDirectory\x10\x01\x12\x0e\n" +
|
||||
@@ -889,7 +1084,7 @@ func file_schema_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
|
||||
var file_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
|
||||
var file_schema_proto_goTypes = []any{
|
||||
(Type)(0), // 0: Type
|
||||
(Transform)(0), // 1: Transform
|
||||
@@ -898,31 +1093,37 @@ var file_schema_proto_goTypes = []any{
|
||||
(*RedirectRule)(nil), // 4: RedirectRule
|
||||
(*Header)(nil), // 5: Header
|
||||
(*HeaderRule)(nil), // 6: HeaderRule
|
||||
(*Problem)(nil), // 7: Problem
|
||||
(*Manifest)(nil), // 8: Manifest
|
||||
(*AuditRecord)(nil), // 9: AuditRecord
|
||||
(*Principal)(nil), // 10: Principal
|
||||
nil, // 11: Manifest.ContentsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
|
||||
(*BasicCredential)(nil), // 7: BasicCredential
|
||||
(*BasicAuthRule)(nil), // 8: BasicAuthRule
|
||||
(*Problem)(nil), // 9: Problem
|
||||
(*Manifest)(nil), // 10: Manifest
|
||||
(*AuditRecord)(nil), // 11: AuditRecord
|
||||
(*Principal)(nil), // 12: Principal
|
||||
(*ForgeUser)(nil), // 13: ForgeUser
|
||||
nil, // 14: Manifest.ContentsEntry
|
||||
(*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp
|
||||
}
|
||||
var file_schema_proto_depIdxs = []int32{
|
||||
0, // 0: Entry.type:type_name -> Type
|
||||
1, // 1: Entry.transform:type_name -> Transform
|
||||
5, // 2: HeaderRule.header_map:type_name -> Header
|
||||
11, // 3: Manifest.contents:type_name -> Manifest.ContentsEntry
|
||||
4, // 4: Manifest.redirects:type_name -> RedirectRule
|
||||
6, // 5: Manifest.headers:type_name -> HeaderRule
|
||||
7, // 6: Manifest.problems:type_name -> Problem
|
||||
12, // 7: AuditRecord.timestamp:type_name -> google.protobuf.Timestamp
|
||||
2, // 8: AuditRecord.event:type_name -> AuditEvent
|
||||
10, // 9: AuditRecord.principal:type_name -> Principal
|
||||
8, // 10: AuditRecord.manifest:type_name -> Manifest
|
||||
3, // 11: Manifest.ContentsEntry.value:type_name -> Entry
|
||||
12, // [12:12] is the sub-list for method output_type
|
||||
12, // [12:12] is the sub-list for method input_type
|
||||
12, // [12:12] is the sub-list for extension type_name
|
||||
12, // [12:12] is the sub-list for extension extendee
|
||||
0, // [0:12] is the sub-list for field type_name
|
||||
7, // 3: BasicAuthRule.credentials:type_name -> BasicCredential
|
||||
14, // 4: Manifest.contents:type_name -> Manifest.ContentsEntry
|
||||
4, // 5: Manifest.redirects:type_name -> RedirectRule
|
||||
6, // 6: Manifest.headers:type_name -> HeaderRule
|
||||
8, // 7: Manifest.basic_auth:type_name -> BasicAuthRule
|
||||
9, // 8: Manifest.problems:type_name -> Problem
|
||||
15, // 9: AuditRecord.timestamp:type_name -> google.protobuf.Timestamp
|
||||
2, // 10: AuditRecord.event:type_name -> AuditEvent
|
||||
12, // 11: AuditRecord.principal:type_name -> Principal
|
||||
10, // 12: AuditRecord.manifest:type_name -> Manifest
|
||||
13, // 13: Principal.forge_user:type_name -> ForgeUser
|
||||
3, // 14: Manifest.ContentsEntry.value:type_name -> Entry
|
||||
15, // [15:15] is the sub-list for method output_type
|
||||
15, // [15:15] is the sub-list for method input_type
|
||||
15, // [15:15] is the sub-list for extension type_name
|
||||
15, // [15:15] is the sub-list for extension extendee
|
||||
0, // [0:15] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_schema_proto_init() }
|
||||
@@ -936,7 +1137,7 @@ func file_schema_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_schema_proto_rawDesc), len(file_schema_proto_rawDesc)),
|
||||
NumEnums: 3,
|
||||
NumMessages: 9,
|
||||
NumMessages: 12,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -76,6 +76,16 @@ message HeaderRule {
|
||||
repeated Header header_map = 2;
|
||||
}
|
||||
|
||||
message BasicCredential {
|
||||
string username = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
message BasicAuthRule {
|
||||
string path = 1;
|
||||
repeated BasicCredential credentials = 2;
|
||||
}
|
||||
|
||||
message Problem {
|
||||
string path = 1;
|
||||
string cause = 2;
|
||||
@@ -96,6 +106,7 @@ message Manifest {
|
||||
// Netlify-style `_redirects` and `_headers` rules.
|
||||
repeated RedirectRule redirects = 6;
|
||||
repeated HeaderRule headers = 9;
|
||||
repeated BasicAuthRule basic_auth = 11;
|
||||
|
||||
// Diagnostics for non-fatal errors.
|
||||
repeated Problem problems = 7;
|
||||
@@ -132,4 +143,11 @@ message AuditRecord {
|
||||
message Principal {
|
||||
string ip_address = 1;
|
||||
bool cli_admin = 2;
|
||||
ForgeUser forge_user = 3;
|
||||
}
|
||||
|
||||
message ForgeUser {
|
||||
string origin = 1;
|
||||
int64 id = 2;
|
||||
string handle = 3;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const BlobReferencePrefix = "/git/blobs/"
|
||||
|
||||
type UnresolvedRefError struct {
|
||||
missing []string
|
||||
}
|
||||
|
||||
func (err UnresolvedRefError) Error() string {
|
||||
return fmt.Sprintf("%d unresolved blob references", len(err.missing))
|
||||
}
|
||||
|
||||
type UpdateOutcome int
|
||||
|
||||
const (
|
||||
@@ -49,6 +59,7 @@ func Update(
|
||||
if err == nil {
|
||||
domain, _, _ := strings.Cut(webRoot, "/")
|
||||
err = backend.CreateDomain(ctx, domain)
|
||||
domainCache.AddDomain(ctx, domain)
|
||||
}
|
||||
if err == nil {
|
||||
if oldManifest == nil {
|
||||
|
||||
@@ -6,6 +6,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type tuple[A, B any] struct {
|
||||
A A
|
||||
B B
|
||||
}
|
||||
|
||||
func (t tuple[A, B]) Splat() (A, B) {
|
||||
return t.A, t.B
|
||||
}
|
||||
|
||||
type BoundedReader struct {
|
||||
inner io.Reader
|
||||
fuel int64
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type WildcardPattern struct {
|
||||
Domain []string
|
||||
CloneURL *fasttemplate.Template
|
||||
IndexRepos []*fasttemplate.Template
|
||||
IndexRepo *fasttemplate.Template
|
||||
IndexBranch string
|
||||
Authorization bool
|
||||
}
|
||||
@@ -49,27 +49,24 @@ func (pattern *WildcardPattern) Matches(host string) (string, bool) {
|
||||
return subdomain, true
|
||||
}
|
||||
|
||||
func (pattern *WildcardPattern) ApplyTemplate(userName string, projectName string) ([]string, string) {
|
||||
var repoURLs []string
|
||||
func (pattern *WildcardPattern) ApplyTemplate(userName string, projectName string) (string, string) {
|
||||
var repoURL string
|
||||
var branch string
|
||||
repoURLTemplate := pattern.CloneURL
|
||||
if projectName == ".index" {
|
||||
for _, indexRepoTemplate := range pattern.IndexRepos {
|
||||
indexRepo := indexRepoTemplate.ExecuteString(map[string]any{"user": userName})
|
||||
repoURLs = append(repoURLs, repoURLTemplate.ExecuteString(map[string]any{
|
||||
"user": userName,
|
||||
"project": indexRepo,
|
||||
}))
|
||||
}
|
||||
repoURL = repoURLTemplate.ExecuteString(map[string]any{
|
||||
"user": userName,
|
||||
"project": pattern.IndexRepo.ExecuteString(map[string]any{"user": userName}),
|
||||
})
|
||||
branch = pattern.IndexBranch
|
||||
} else {
|
||||
repoURLs = append(repoURLs, repoURLTemplate.ExecuteString(map[string]any{
|
||||
repoURL = repoURLTemplate.ExecuteString(map[string]any{
|
||||
"user": userName,
|
||||
"project": projectName,
|
||||
}))
|
||||
})
|
||||
branch = "pages"
|
||||
}
|
||||
return repoURLs, branch
|
||||
return repoURL, branch
|
||||
}
|
||||
|
||||
func TranslateWildcards(configs []WildcardConfig) ([]*WildcardPattern, error) {
|
||||
@@ -80,14 +77,10 @@ func TranslateWildcards(configs []WildcardConfig) ([]*WildcardPattern, error) {
|
||||
return nil, fmt.Errorf("wildcard pattern: clone URL: %w", err)
|
||||
}
|
||||
|
||||
var indexRepoTemplates []*fasttemplate.Template
|
||||
var indexRepoBranch string = config.IndexRepoBranch
|
||||
for _, indexRepo := range config.IndexRepos {
|
||||
indexRepoTemplate, err := fasttemplate.NewTemplate(indexRepo, "<", ">")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wildcard pattern: index repo: %w", err)
|
||||
}
|
||||
indexRepoTemplates = append(indexRepoTemplates, indexRepoTemplate)
|
||||
indexRepoTemplate, err := fasttemplate.NewTemplate(config.IndexRepo, "<", ">")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wildcard pattern: index repo: %w", err)
|
||||
}
|
||||
|
||||
authorization := false
|
||||
@@ -107,7 +100,7 @@ func TranslateWildcards(configs []WildcardConfig) ([]*WildcardPattern, error) {
|
||||
wildcardPatterns = append(wildcardPatterns, &WildcardPattern{
|
||||
Domain: strings.Split(config.Domain, "."),
|
||||
CloneURL: cloneURLTemplate,
|
||||
IndexRepos: indexRepoTemplates,
|
||||
IndexRepo: indexRepoTemplate,
|
||||
IndexBranch: indexRepoBranch,
|
||||
Authorization: authorization,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user