Compare commits
18 Commits
remove-whi
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
| c1ec038c99 | |||
| bdd86a4c1e | |||
| 8cb09b6aaf | |||
| 3963ec31c1 | |||
| 61f42e6602 | |||
| 5d0b5f864e | |||
| 6573b9d1ef | |||
| 275e145a6d | |||
| b6e9ac2a93 | |||
| 504afea4f8 | |||
| 2fb909283d | |||
| 6b4a1d7607 | |||
| e34743f070 | |||
| 7010d55d72 | |||
| a50364bfca | |||
| e85b5ff033 | |||
| 55a609dd77 | |||
| 9c29cb57df |
@@ -6,4 +6,4 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
- run: docker build .
|
- run: script/cibuild
|
||||||
|
|||||||
35
Makefile
35
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: check lint test fmt fmt-check build clean docker docker-test devserver devserver-stop hooks
|
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-versioned docker-test devserver devserver-stop hooks
|
||||||
|
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
LDFLAGS := -X main.Version=$(VERSION)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
@@ -15,27 +15,30 @@ else
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
# Default target: run all checks
|
# Default target: run all checks
|
||||||
check: fmt-check lint test
|
check:
|
||||||
|
@script/check
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
@script/bootstrap
|
||||||
|
|
||||||
|
setup:
|
||||||
|
@script/setup
|
||||||
|
|
||||||
# Check formatting without modifying files
|
# Check formatting without modifying files
|
||||||
fmt-check:
|
fmt-check:
|
||||||
@echo "Checking formatting..."
|
@script/fmt-check
|
||||||
@test -z "$$(gofmt -l . | grep -v '^vendor/')" || (echo "Files need formatting:"; gofmt -l . | grep -v '^vendor/'; exit 1)
|
|
||||||
|
|
||||||
# Format code
|
# Format code
|
||||||
fmt:
|
fmt:
|
||||||
@echo "Formatting code..."
|
@script/fmt
|
||||||
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
|
|
||||||
|
|
||||||
# Run linter
|
# Run linter
|
||||||
lint:
|
lint:
|
||||||
@echo "Running linter..."
|
@script/lint
|
||||||
$(NIX_RUN_PREFIX)golangci-lint run$(NIX_RUN_SUFFIX)
|
|
||||||
|
|
||||||
# Run tests (30-second timeout)
|
# Run tests (30-second timeout)
|
||||||
test:
|
test:
|
||||||
@echo "Running tests..."
|
@script/test
|
||||||
$(NIX_RUN_PREFIX)CGO_ENABLED=1 go test -timeout 30s -v ./...$(NIX_RUN_SUFFIX)
|
|
||||||
|
|
||||||
# Build the binary
|
# Build the binary
|
||||||
build:
|
build:
|
||||||
@@ -47,8 +50,12 @@ clean:
|
|||||||
rm -rf bin/
|
rm -rf bin/
|
||||||
rm -rf ./data
|
rm -rf ./data
|
||||||
|
|
||||||
# Build Docker image
|
# Build Docker image (tagged via script/projectname)
|
||||||
docker:
|
docker:
|
||||||
|
@script/docker
|
||||||
|
|
||||||
|
# Build Docker image tagged pixad:$(VERSION) and pixad:latest
|
||||||
|
docker-versioned:
|
||||||
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
|
||||||
|
|
||||||
# Run tests in Docker (needed for CGO/libvips)
|
# Run tests in Docker (needed for CGO/libvips)
|
||||||
@@ -57,7 +64,7 @@ docker-test:
|
|||||||
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
|
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
|
||||||
|
|
||||||
# Run local dev server in Docker
|
# Run local dev server in Docker
|
||||||
devserver: docker devserver-stop
|
devserver: docker-versioned devserver-stop
|
||||||
docker run -d --name pixad-dev -p 8080:8080 \
|
docker run -d --name pixad-dev -p 8080:8080 \
|
||||||
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
|
||||||
pixad:latest
|
pixad:latest
|
||||||
@@ -70,6 +77,4 @@ devserver-stop:
|
|||||||
|
|
||||||
# Install pre-commit hook
|
# Install pre-commit hook
|
||||||
hooks:
|
hooks:
|
||||||
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
|
@script/install-precommit
|
||||||
@printf 'make check\n' >> .git/hooks/pre-commit
|
|
||||||
@chmod +x .git/hooks/pre-commit
|
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -29,7 +29,7 @@ Image-heavy web applications need a fast, caching reverse proxy that
|
|||||||
can resize and transcode images on the fly. pixa fills that role as a
|
can resize and transcode images on the fly. pixa fills that role as a
|
||||||
single, self-contained binary with no external runtime dependencies
|
single, self-contained binary with no external runtime dependencies
|
||||||
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
|
||||||
prevent abuse, and whitelisted source hosts for open access.
|
prevent abuse, and allowlisted source hosts for open access.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -61,13 +61,16 @@ Images are only fetched from origins using TLS with valid certificates.
|
|||||||
|
|
||||||
### Source Hosts
|
### Source Hosts
|
||||||
|
|
||||||
Source hosts may be whitelisted in the configuration. Non-whitelisted
|
Source hosts may be allowlisted in the configuration. Non-allowlisted
|
||||||
hosts require an HMAC-SHA256 signature.
|
hosts require an HMAC-SHA256 signature.
|
||||||
|
|
||||||
#### Signature Specification
|
#### Signature Specification
|
||||||
|
|
||||||
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
Signatures use HMAC-SHA256 and include an expiration timestamp to
|
||||||
prevent replay attacks.
|
prevent replay attacks. Signatures are **exact match only**: every
|
||||||
|
component (host, path, query, dimensions, format, expiration) must
|
||||||
|
match exactly what was signed. No suffix matching, wildcard matching,
|
||||||
|
or partial matching is supported.
|
||||||
|
|
||||||
**Signed data format** (colon-separated):
|
**Signed data format** (colon-separated):
|
||||||
|
|
||||||
@@ -96,7 +99,7 @@ expiration 1704067200:
|
|||||||
4. URL:
|
4. URL:
|
||||||
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
|
||||||
|
|
||||||
**Whitelist patterns:**
|
**Allowlist patterns:**
|
||||||
|
|
||||||
- **Exact match**: `cdn.example.com` — matches only that host
|
- **Exact match**: `cdn.example.com` — matches only that host
|
||||||
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
|
||||||
@@ -107,11 +110,14 @@ expiration 1704067200:
|
|||||||
Configured via YAML file (`--config`). Key settings:
|
Configured via YAML file (`--config`). Key settings:
|
||||||
|
|
||||||
- `access_control_allow_origin` — CORS origin
|
- `access_control_allow_origin` — CORS origin
|
||||||
- `source_host_whitelist` — list of allowed upstream hosts
|
- `allowlist_hosts` — list of allowed upstream hosts
|
||||||
- `upstream_fetch_timeout` — timeout for origin requests
|
- `upstream_fetch_timeout` — timeout for origin requests
|
||||||
- `upstream_max_response_size` — max origin response size
|
- `upstream_max_response_size` — max origin response size
|
||||||
- `downstream_timeout` — client response timeout
|
- `downstream_timeout` — client response timeout
|
||||||
- `signing_key` — HMAC secret for URL signatures
|
- `signing_key` — HMAC secret for URL signatures
|
||||||
|
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
||||||
|
disk cache entirely; omitted defaults to 75% of the free space on
|
||||||
|
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
|
||||||
|
|
||||||
See `config.example.yml` for all options with defaults.
|
See `config.example.yml` for all options with defaults.
|
||||||
|
|
||||||
@@ -125,6 +131,31 @@ See `config.example.yml` for all options with defaults.
|
|||||||
- **Metrics**: Prometheus
|
- **Metrics**: Prometheus
|
||||||
- **Logging**: stdlib slog
|
- **Logging**: stdlib slog
|
||||||
|
|
||||||
|
## Entrypoints
|
||||||
|
|
||||||
|
This repository adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
|
them. We provide:
|
||||||
|
|
||||||
|
- `script/bootstrap` — install all dependencies (idempotent)
|
||||||
|
- `script/setup` — make a fresh clone ready for development
|
||||||
|
(bootstrap, then install-precommit)
|
||||||
|
- `script/projectname` — output the project name ("pixa")
|
||||||
|
- `script/test` — run the test suite
|
||||||
|
- `script/lint` — run golangci-lint
|
||||||
|
- `script/fmt` — format all code (writes)
|
||||||
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
|
- `script/check` — run test, lint, and fmt-check
|
||||||
|
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||||
|
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
||||||
|
runs the checks, so a green build implies a green repo)
|
||||||
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
|
`script/check`)
|
||||||
|
- `script/install-precommit` — install the git pre-commit hook that
|
||||||
|
runs `script/precommit`
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
See [TODO.md](TODO.md) for the full prioritized task list.
|
See [TODO.md](TODO.md) for the full prioritized task list.
|
||||||
|
|||||||
252
REPO_POLICIES.md
252
REPO_POLICIES.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-02-22
|
last_modified: 2026-07-06
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -34,10 +34,46 @@ style conventions are in separate documents:
|
|||||||
every file before committing. There are zero exceptions to this rule.
|
every file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
- Every repo with software must have a root `Makefile` with these targets:
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
|
||||||
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
|
||||||
`make hooks` (installs pre-commit hook). A model Makefile is at
|
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Repos follow the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
pattern: the implementation of each Makefile target lives in an executable
|
||||||
|
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
|
||||||
|
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
|
||||||
|
`script/docker`), and the Makefile targets are thin shims that call them. The
|
||||||
|
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
|
||||||
|
minimal containers (e.g. alpine images have no bash); locate the repo root
|
||||||
|
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
|
||||||
|
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
|
||||||
|
for development after a fresh clone: runs `bootstrap`, then
|
||||||
|
`install-precommit`, plus any repo-specific initialization), `test`, and
|
||||||
|
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
|
||||||
|
assumes nothing is present: base tools come from nix, apt, brew, or apk
|
||||||
|
(detected in that order; apt runs noninteractive). For node it uses the
|
||||||
|
installed node if present; otherwise it installs a PINNED node version via
|
||||||
|
nvm, first installing nvm itself if missing — from a hash-verified GitHub
|
||||||
|
release archive (never `curl | sh`), with bash installed as an explicit
|
||||||
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
|
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||||
|
scripts are our own extensions to the standard: `script/check` runs
|
||||||
|
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||||
|
what the git pre-commit hook runs, and it calls `script/check`;
|
||||||
|
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||||
|
target shims to it); and `script/projectname` (literally that filename) simply
|
||||||
|
outputs the project's name. Scripts that need the name call
|
||||||
|
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||||
|
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||||
|
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||||
|
`script/precommit`, not in the hook itself. Model scripts are at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
|
README requirements below).
|
||||||
|
|
||||||
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
instead of invoking the underlying tools directly. The Makefile is the single
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
@@ -57,11 +93,83 @@ style conventions are in separate documents:
|
|||||||
as a build step so the build fails if the branch is not green. For non-server
|
as a build step so the build fails if the branch is not green. For non-server
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
repos, the Dockerfile should bring up a development environment and run
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
`make check`. For server repos, `make check` should run as an early build
|
||||||
stage before the final image is assembled.
|
stage before the final image is assembled. Dockerfiles install development
|
||||||
|
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||||
|
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||||
|
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||||
|
layer stays cached until dependencies change.
|
||||||
|
|
||||||
|
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||||
|
repos use a multistage build where linting runs in an independent stage based
|
||||||
|
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||||
|
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||||
|
then declares an explicit dependency on the lint stage via
|
||||||
|
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||||
|
linting before proceeding to compilation and tests. This ensures lint failures
|
||||||
|
surface in seconds rather than minutes, without blocking on dependency
|
||||||
|
download or compilation in the build stage.
|
||||||
|
|
||||||
|
The standard pattern for a Go repo Dockerfile is:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Force BuildKit to run the lint stage before proceeding
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make test
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine@sha256:...
|
||||||
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
|
ENTRYPOINT ["app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||||
|
includes both Go and the linter), so there is no need to install the
|
||||||
|
linter separately.
|
||||||
|
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||||
|
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||||
|
this line, the build stage would not wait for lint to finish and a lint
|
||||||
|
failure might not fail the overall build.
|
||||||
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
|
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||||
|
create placeholder files so the embed directives resolve. Example:
|
||||||
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
|
The lint stage should not depend on the actual build output — it exists to
|
||||||
|
fail fast.
|
||||||
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
|
`vips-dev`), install them in the lint stage with `apk add`.
|
||||||
|
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||||
|
build stage, not the lint stage, because they may require compiled
|
||||||
|
artifacts or heavier dependencies.
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||||
a successful build implies all checks pass.
|
Dockerfile already runs `make check`, a successful build implies all checks
|
||||||
|
pass.
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -69,9 +177,11 @@ style conventions are in separate documents:
|
|||||||
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
|
||||||
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
testing is not possible in the repo, `script/precommit` may skip `script/test`
|
||||||
target to install the pre-commit hook.
|
and run only `script/lint` and `script/fmt-check`. The hook is installed by
|
||||||
|
`script/install-precommit`; the Makefile must provide a `make hooks` target
|
||||||
|
that shims to it.
|
||||||
|
|
||||||
- All repos with software must have tests that run via the platform-standard
|
- All repos with software must have tests that run via the platform-standard
|
||||||
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
@@ -82,6 +192,42 @@ style conventions are in separate documents:
|
|||||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||||
Makefile.
|
Makefile.
|
||||||
|
|
||||||
|
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||||
|
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||||
|
show full output. This keeps CI logs and `docker build` output clean on
|
||||||
|
success (just package/suite summaries) while providing full diagnostic detail
|
||||||
|
on failure (every test case, every assertion). The general shell pattern:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@<test-command> || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
<test-command-with-v>; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Go example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@go test -timeout 30s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 30s -race -v ./...; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Python example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@python -m pytest || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
python -m pytest -v; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `exit 1` ensures the target always fails after a rerun — the first run
|
||||||
|
already proved the tests are broken, so the build must not pass even if a
|
||||||
|
flaky test happens to succeed on the second attempt. The rerun exists solely
|
||||||
|
for diagnostic output.
|
||||||
|
|
||||||
- Docker builds must complete in under 5 minutes.
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
- `make check` must not modify any files in the repo. Tests may use temporary
|
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||||
@@ -98,6 +244,13 @@ style conventions are in separate documents:
|
|||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
a new repo.
|
a new repo.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
- Never force-push to `main`.
|
- Never force-push to `main`.
|
||||||
@@ -121,12 +274,76 @@ style conventions are in separate documents:
|
|||||||
- Dockerized web services listen on port 8080 by default, overridable with
|
- Dockerized web services listen on port 8080 by default, overridable with
|
||||||
`PORT`.
|
`PORT`.
|
||||||
|
|
||||||
|
- **HTTP/web services must be hardened for production internet exposure before
|
||||||
|
tagging 1.0.** This means full compliance with security best practices
|
||||||
|
including, without limitation, all of the following:
|
||||||
|
- **Security headers** on every response:
|
||||||
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||||
|
and `includeSubDomains`.
|
||||||
|
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||||
|
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||||
|
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||||
|
unavoidable, and document the reason.
|
||||||
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||||
|
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||||
|
- `X-Content-Type-Options: nosniff`.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||||
|
- `Permissions-Policy` restricting access to browser features the
|
||||||
|
application does not use (camera, microphone, geolocation, etc.).
|
||||||
|
- **Request and response limits:**
|
||||||
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||||
|
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||||
|
unbounded input.
|
||||||
|
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||||
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||||
|
against slowloris attacks.
|
||||||
|
- `WriteTimeout` on the `http.Server`.
|
||||||
|
- `IdleTimeout` on the `http.Server`.
|
||||||
|
- Per-handler execution time limits via `context.WithTimeout` or
|
||||||
|
chi/stdlib `middleware.Timeout`.
|
||||||
|
- **Authentication and session security:**
|
||||||
|
- Rate limiting on password-based authentication endpoints. API keys are
|
||||||
|
high-entropy and not susceptible to brute force, so they are exempt.
|
||||||
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||||
|
authenticated via `Authorization` header (Bearer token, API key) are
|
||||||
|
exempt because the browser does not attach these automatically.
|
||||||
|
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||||
|
MD5, or SHA.
|
||||||
|
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||||
|
`Strict`) attributes.
|
||||||
|
- **Reverse proxy awareness:**
|
||||||
|
- True client IP detection when behind a reverse proxy
|
||||||
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
|
forwarded headers only from a configured set of trusted proxy
|
||||||
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||||
|
- **CORS:**
|
||||||
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||||
|
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||||
|
only for public, unauthenticated read-only APIs.
|
||||||
|
- **Error handling:**
|
||||||
|
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||||
|
or other implementation details to the client. Return generic error
|
||||||
|
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||||
|
- **TLS:**
|
||||||
|
- Services never terminate TLS directly. They are always deployed behind
|
||||||
|
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||||
|
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||||
|
set by the application so that the browser enforces HTTPS end-to-end.
|
||||||
|
|
||||||
|
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||||
|
hardening measure exists for HTTP services and is not listed here, it is
|
||||||
|
still expected. When in doubt, harden.
|
||||||
|
|
||||||
- `README.md` is the primary documentation. Required sections:
|
- `README.md` is the primary documentation. Required sections:
|
||||||
- **Description**: First line must include the project name, purpose,
|
- **Description**: First line must include the project name, purpose,
|
||||||
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
git-frontend webhooks and deploys applications via Docker in realtime."
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
- **Getting Started**: Copy-pasteable install/usage code block.
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard (with that link), then documents each provided `script/`
|
||||||
|
entrypoint and its purpose.
|
||||||
- **Rationale**: Why does this exist?
|
- **Rationale**: Why does this exist?
|
||||||
- **Design**: How is the program structured?
|
- **Design**: How is the program structured?
|
||||||
- **TODO**: Update meticulously, even between commits. When planning, put
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||||
@@ -144,8 +361,14 @@ style conventions are in separate documents:
|
|||||||
- Use SemVer.
|
- Use SemVer.
|
||||||
|
|
||||||
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
|
the binary.
|
||||||
Post-1.0.0: add new migration files.
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
- All repos should have an `.editorconfig` enforcing the project's indentation
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
settings.
|
settings.
|
||||||
@@ -175,6 +398,9 @@ style conventions are in separate documents:
|
|||||||
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
- `Makefile`
|
- `Makefile`
|
||||||
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
|
||||||
|
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
|
||||||
|
`install-precommit`)
|
||||||
- `Dockerfile`, `.dockerignore`
|
- `Dockerfile`, `.dockerignore`
|
||||||
- `.gitea/workflows/check.yml`
|
- `.gitea/workflows/check.yml`
|
||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
|||||||
172
TODO.md
172
TODO.md
@@ -1,65 +1,125 @@
|
|||||||
# Pixa 1.0 TODO
|
# Workflow
|
||||||
|
|
||||||
Remaining tasks sorted by priority for a working 1.0 release.
|
* branch (from `main`)
|
||||||
|
* do the work in Next Step
|
||||||
|
* move Next Step to the top of Completed Steps
|
||||||
|
* move the top item of Future Steps into Next Step
|
||||||
|
* commit (`TODO.md` changes in the same commit as the work)
|
||||||
|
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
|
* push
|
||||||
|
|
||||||
## P0: Critical for 1.0
|
# Status
|
||||||
|
|
||||||
### Image Processing
|
pre-1.0. No git tags exist. Recent work extracted the internal/magic,
|
||||||
- [x] Add WebP encoding support (currently returns error)
|
internal/allowlist, internal/httpfetcher, and internal/signature
|
||||||
- [x] Add AVIF encoding support (implemented via govips)
|
packages. The gosec findings from the 2026-07-06 survey are resolved
|
||||||
|
and `make check` is green on main. The disk cache is now size-bounded
|
||||||
|
with LRU eviction (`cache_max_bytes`), closing the unbounded disk
|
||||||
|
growth DoS vector.
|
||||||
|
|
||||||
### Manual Testing (verify auth/encrypted URLs work)
|
# Next Step
|
||||||
- [ ] Manual test: visit `/`, see login form
|
|
||||||
- [ ] Manual test: enter wrong key, see error
|
|
||||||
- [ ] Manual test: enter correct signing key, see generator form
|
|
||||||
- [ ] Manual test: generate encrypted URL, verify it works
|
|
||||||
- [ ] Manual test: wait for expiration or use short TTL, verify expired URL returns 410
|
|
||||||
- [ ] Manual test: logout, verify redirected to login
|
|
||||||
|
|
||||||
### Cache Management
|
P1: implement blocked networks configuration to extend SSRF protection
|
||||||
- [ ] Implement cache size management/eviction (prevent disk from filling up)
|
|
||||||
|
|
||||||
### Configuration
|
# Completed Steps
|
||||||
- [ ] Validate configuration on startup (fail fast on bad config)
|
|
||||||
|
|
||||||
## P1: Important for Production
|
- 2026-08-07 implement cache size management and eviction (closes
|
||||||
|
#51): new `cache_max_bytes` config key validated by the startup
|
||||||
|
framework (explicit values used exactly with no floor, `0` disables
|
||||||
|
the disk cache entirely, omitted defaults to max(75% of free space
|
||||||
|
on the filesystem containing `<state_dir>/cache/`, 500 MiB), logged
|
||||||
|
at startup); processed variants are now tracked in the database
|
||||||
|
(migration 002 adds `variant_content` and an LRU timestamp on
|
||||||
|
`source_content`) so total usage is two SUMs, never a directory scan
|
||||||
|
on the hot path; a background goroutine evicts globally
|
||||||
|
least-recently-used entries (variants and source blobs merged) to
|
||||||
|
the limit, woken by a periodic ticker and by write-pressure
|
||||||
|
notifications from stores; a source blob and ALL of its
|
||||||
|
`source_metadata` references are deleted in one transaction before
|
||||||
|
the file is unlinked, so multi-referenced blobs are never removed
|
||||||
|
while referenced and rows never point at deleted files; a startup
|
||||||
|
reconciliation pass adopts untracked variant files, drops rows for
|
||||||
|
missing files, removes unreachable source blobs, and sweeps stale
|
||||||
|
temp files
|
||||||
|
- 2026-08-07 validate configuration on startup, fail fast on bad
|
||||||
|
config (closes #52): a config value that is set but unparseable or
|
||||||
|
invalid aborts startup naming the key and value (defaults apply only
|
||||||
|
to omitted keys), unknown config keys abort startup, a malformed
|
||||||
|
config file aborts instead of being skipped, and `state_dir` is
|
||||||
|
verified creatable and writable before the listener binds
|
||||||
|
- 2026-08-07 manual test pass of the auth and encrypted URL flows
|
||||||
|
against a locally built and running `pixad` (built from `main` at
|
||||||
|
`6573b9d`, port 18099, local throwaway config); all six checks
|
||||||
|
passed, plus all nine tests in `scripts/manual-test.sh` (closes #49):
|
||||||
|
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login`
|
||||||
|
page with `name="key"` password form
|
||||||
|
- [x] wrong key shows an error: POST `/` with `key=wrong-key`
|
||||||
|
returned HTTP 200 login page containing "Invalid signing key"
|
||||||
|
- [x] correct signing key shows the generator form: POST `/`
|
||||||
|
returned HTTP 303 to `/` with
|
||||||
|
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`;
|
||||||
|
GET `/` with that cookie rendered `Pixa - URL Generator` with the
|
||||||
|
`/generate` form and logout link
|
||||||
|
- [x] a generated encrypted URL serves the image: POST `/generate`
|
||||||
|
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned
|
||||||
|
HTTP 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of
|
||||||
|
61706 bytes
|
||||||
|
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched
|
||||||
|
after 3 s returned HTTP 410 Gone with
|
||||||
|
`{"error":"URL has expired","status":410,...}`
|
||||||
|
- [x] logout redirects back to login: GET `/logout` returned HTTP
|
||||||
|
303 to `/` with `Set-Cookie: pixa_session=; Max-Age=0`;
|
||||||
|
subsequent GET `/` rendered the login form again
|
||||||
|
- 2026-08-07 fix the two remaining gosec findings (G124 in
|
||||||
|
internal/session): session cookies now always carry
|
||||||
|
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
|
||||||
|
`make check` green (closes #47)
|
||||||
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
|
Makefile shims, README Entrypoints section
|
||||||
|
- 2026-04-07 extract magic byte detection into internal/magic (#42)
|
||||||
|
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
|
||||||
|
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
|
||||||
|
- 2026-03-20 enforce and document exact-match-only signature
|
||||||
|
verification (#40)
|
||||||
|
- 2026-03-20 bound imageprocessor.Process input read to prevent
|
||||||
|
unbounded memory use (#37); consolidate appname into an
|
||||||
|
internal/globals constant (#34)
|
||||||
|
- 2026-03-18 parse version prefix from migration filenames (#33)
|
||||||
|
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
|
||||||
|
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for
|
||||||
|
faster CI (#23)
|
||||||
|
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
|
||||||
|
golangci-lint and gosec fixes of that date (#14); arm64 Docker build
|
||||||
|
fix (#16)
|
||||||
|
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0
|
||||||
|
image processing items, now done)
|
||||||
|
|
||||||
### Security
|
# Future Steps
|
||||||
- [ ] Implement blocked networks configuration (extend SSRF protection)
|
|
||||||
- [ ] Add rate limiting global concurrent fetches (prevent resource exhaustion)
|
|
||||||
|
|
||||||
### Image Processing
|
- P1: rate limit global concurrent upstream fetches to prevent
|
||||||
- [ ] Implement EXIF/metadata stripping (privacy)
|
resource exhaustion
|
||||||
|
- P1: strip EXIF and other metadata from processed images (privacy)
|
||||||
## P2: Nice to Have
|
- P2: security
|
||||||
|
- referer blacklist
|
||||||
### Security
|
- per-IP rate limiting
|
||||||
- [ ] Implement referer blacklist
|
- per-origin rate limiting
|
||||||
- [ ] Add rate limiting per-IP
|
- P2: HTTP response handling
|
||||||
- [ ] Add rate limiting per-origin
|
- Last-Modified headers
|
||||||
|
- Vary header for content negotiation
|
||||||
### HTTP Response Handling
|
- X-Request-ID propagation
|
||||||
- [ ] Implement Last-Modified headers
|
- P2: auto format selection (format=auto based on Accept header)
|
||||||
- [ ] Implement Vary header for content negotiation
|
- P2: configuration
|
||||||
- [ ] Implement X-Request-ID propagation
|
- add all configuration options from README
|
||||||
|
- environment variable overrides
|
||||||
### Additional Endpoints
|
- YAML config file support
|
||||||
- [ ] Implement auto-format selection (format=auto based on Accept header)
|
- P2: operational
|
||||||
|
- optional Sentry error reporting
|
||||||
### Configuration
|
- comprehensive request logging
|
||||||
- [ ] Add all configuration options from README
|
- Prometheus performance metrics
|
||||||
- [ ] Implement environment variable overrides
|
- integration tests for the image proxy flow
|
||||||
- [ ] Implement YAML config file support
|
- load tests to verify the 1k to 5k req/s target
|
||||||
|
- P2: documentation
|
||||||
### Operational
|
- configuration options
|
||||||
- [ ] Implement Sentry error reporting (optional)
|
- API endpoints
|
||||||
- [ ] Add comprehensive request logging
|
- deployment guide
|
||||||
- [ ] Add performance metrics (Prometheus)
|
- example nginx or caddy reverse proxy config
|
||||||
- [ ] Write integration tests for image proxy flow
|
|
||||||
- [ ] Write load tests to verify 1-5k req/s target
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- [ ] Document configuration options
|
|
||||||
- [ ] Document API endpoints
|
|
||||||
- [ ] Document deployment guide
|
|
||||||
- [ ] Add example nginx/caddy reverse proxy config
|
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/server"
|
"sneak.berlin/go/pixa/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var Version string //nolint:gochecknoglobals // set by ldflags
|
||||||
Appname = "pixad" //nolint:gochecknoglobals // set by ldflags
|
|
||||||
Version string //nolint:gochecknoglobals // set by ldflags
|
|
||||||
)
|
|
||||||
|
|
||||||
var configPath string //nolint:gochecknoglobals // cobra flag
|
var configPath string //nolint:gochecknoglobals // cobra flag
|
||||||
|
|
||||||
@@ -40,7 +37,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(_ *cobra.Command, _ []string) {
|
func run(_ *cobra.Command, _ []string) {
|
||||||
globals.Appname = Appname
|
|
||||||
globals.Version = Version
|
globals.Version = Version
|
||||||
|
|
||||||
// Set config path in environment if specified via flag
|
// Set config path in environment if specified via flag
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ maintenance_mode: false
|
|||||||
state_dir: ./data
|
state_dir: ./data
|
||||||
|
|
||||||
# Image proxy settings
|
# Image proxy settings
|
||||||
# HMAC signing key for URL signatures (leave empty to require whitelist for all requests)
|
# HMAC signing key for URL signatures (required, at least 32 characters)
|
||||||
# Generate with: openssl rand -base64 32
|
# Generate with: openssl rand -base64 32
|
||||||
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||||
|
|
||||||
# Hosts that don't require signatures
|
# Hosts that don't require signatures
|
||||||
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
|
||||||
whitelist_hosts:
|
allowlist_hosts:
|
||||||
- s3.sneak.cloud
|
- s3.sneak.cloud
|
||||||
- static.sneak.cloud
|
- static.sneak.cloud
|
||||||
- sneak.berlin
|
- sneak.berlin
|
||||||
@@ -28,6 +28,13 @@ allow_http: false
|
|||||||
# Maximum concurrent connections per upstream host (default: 20)
|
# Maximum concurrent connections per upstream host (default: 20)
|
||||||
upstream_connections_per_host: 20
|
upstream_connections_per_host: 20
|
||||||
|
|
||||||
|
# Maximum disk cache size in bytes. Explicit values are used exactly as
|
||||||
|
# given; 0 disables the disk cache entirely (every request fetches and
|
||||||
|
# processes uncached). When omitted, the default is 75% of the free
|
||||||
|
# space on the filesystem containing <state_dir>/cache/ at startup,
|
||||||
|
# with a minimum of 500 MiB.
|
||||||
|
# cache_max_bytes: 10737418240
|
||||||
|
|
||||||
# Sentry error reporting (optional)
|
# Sentry error reporting (optional)
|
||||||
sentry_dsn: ""
|
sentry_dsn: ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
package imgcache
|
// Package allowlist provides host-based URL allow-listing for the image proxy.
|
||||||
|
package allowlist
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostWhitelist implements the Whitelist interface for checking allowed source hosts.
|
// HostAllowList checks whether source hosts are permitted.
|
||||||
type HostWhitelist struct {
|
type HostAllowList struct {
|
||||||
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
|
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
|
||||||
exactHosts map[string]struct{}
|
exactHosts map[string]struct{}
|
||||||
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
|
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
|
||||||
suffixHosts []string
|
suffixHosts []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostWhitelist creates a whitelist from a list of host patterns.
|
// New creates a HostAllowList from a list of host patterns.
|
||||||
// Patterns starting with "." are treated as suffix matches.
|
// Patterns starting with "." are treated as suffix matches.
|
||||||
// Examples:
|
// Examples:
|
||||||
// - "cdn.example.com" - exact match only
|
// - "cdn.example.com" - exact match only
|
||||||
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
|
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
|
||||||
func NewHostWhitelist(patterns []string) *HostWhitelist {
|
func New(patterns []string) *HostAllowList {
|
||||||
w := &HostWhitelist{
|
w := &HostAllowList{
|
||||||
exactHosts: make(map[string]struct{}),
|
exactHosts: make(map[string]struct{}),
|
||||||
suffixHosts: make([]string, 0),
|
suffixHosts: make([]string, 0),
|
||||||
}
|
}
|
||||||
@@ -40,8 +41,8 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsWhitelisted checks if a URL's host is in the whitelist.
|
// IsAllowed checks if a URL's host is in the allow list.
|
||||||
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
|
func (w *HostAllowList) IsAllowed(u *url.URL) bool {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -71,12 +72,12 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty returns true if the whitelist has no entries.
|
// IsEmpty returns true if the allow list has no entries.
|
||||||
func (w *HostWhitelist) IsEmpty() bool {
|
func (w *HostAllowList) IsEmpty() bool {
|
||||||
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
|
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the total number of whitelist entries.
|
// Count returns the total number of allow list entries.
|
||||||
func (w *HostWhitelist) Count() int {
|
func (w *HostAllowList) Count() int {
|
||||||
return len(w.exactHosts) + len(w.suffixHosts)
|
return len(w.exactHosts) + len(w.suffixHosts)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package imgcache
|
package allowlist_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHostWhitelist_IsWhitelisted(t *testing.T) {
|
func TestHostAllowList_IsAllowed(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -67,7 +69,7 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
|
|||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty whitelist",
|
name: "empty allow list",
|
||||||
patterns: []string{},
|
patterns: []string{},
|
||||||
testURL: "https://cdn.example.com/image.jpg",
|
testURL: "https://cdn.example.com/image.jpg",
|
||||||
want: false,
|
want: false,
|
||||||
@@ -94,7 +96,7 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
w := NewHostWhitelist(tt.patterns)
|
w := allowlist.New(tt.patterns)
|
||||||
|
|
||||||
var u *url.URL
|
var u *url.URL
|
||||||
if tt.testURL != "" {
|
if tt.testURL != "" {
|
||||||
@@ -105,15 +107,15 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
got := w.IsWhitelisted(u)
|
got := w.IsAllowed(u)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("IsWhitelisted() = %v, want %v", got, tt.want)
|
t.Errorf("IsAllowed() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWhitelist_IsEmpty(t *testing.T) {
|
func TestHostAllowList_IsEmpty(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -143,7 +145,7 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
w := NewHostWhitelist(tt.patterns)
|
w := allowlist.New(tt.patterns)
|
||||||
if got := w.IsEmpty(); got != tt.want {
|
if got := w.IsEmpty(); got != tt.want {
|
||||||
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
t.Errorf("IsEmpty() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
@@ -151,7 +153,7 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWhitelist_Count(t *testing.T) {
|
func TestHostAllowList_Count(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
patterns []string
|
patterns []string
|
||||||
@@ -181,7 +183,7 @@ func TestHostWhitelist_Count(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
w := NewHostWhitelist(tt.patterns)
|
w := allowlist.New(tt.patterns)
|
||||||
if got := w.Count(); got != tt.want {
|
if got := w.Count(); got != tt.want {
|
||||||
t.Errorf("Count() = %v, want %v", got, tt.want)
|
t.Errorf("Count() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
271
internal/config/cache_max_bytes_test.go
Normal file
271
internal/config/cache_max_bytes_test.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// discardLogger returns a logger that swallows all output, for tests
|
||||||
|
// that exercise code paths which log.
|
||||||
|
func discardLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
|
||||||
|
// explicitly configured cache_max_bytes value is used exactly as
|
||||||
|
// given: the 500 MiB floor applies only to the computed default, never
|
||||||
|
// to explicit values.
|
||||||
|
func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 1024 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 1024 (no floor for explicit values)",
|
||||||
|
c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesZeroIsValidAndDisablesCache verifies that an
|
||||||
|
// explicit zero is a valid value (it disables the disk cache), not an
|
||||||
|
// error.
|
||||||
|
func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 0\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 0 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 0", c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesLargeExplicitValueParses verifies that values above
|
||||||
|
// 32-bit range parse correctly (the field is an int64 byte count).
|
||||||
|
func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 10737418240\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("large cache_max_bytes must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 10737418240 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want 10737418240", c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheMaxBytesInvalidValuesAbortStartup verifies that a SET but
|
||||||
|
// invalid cache_max_bytes value aborts startup naming the key and the
|
||||||
|
// offending value, per the no-silent-fallback rule: defaults apply
|
||||||
|
// only to omitted keys.
|
||||||
|
func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
// wantErrSubstrings must all appear in the error message.
|
||||||
|
wantErrSubstrings []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "negative",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: -1024\n",
|
||||||
|
wantErrSubstrings: []string{"cache_max_bytes", "-1024"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "float",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: 3.5\n",
|
||||||
|
wantErrSubstrings: []string{"cache_max_bytes", "3.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-numeric string",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: banana\n",
|
||||||
|
wantErrSubstrings: []string{"cache_max_bytes", "banana"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit null",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: null\n",
|
||||||
|
wantErrSubstrings: []string{"cache_max_bytes", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare key no value",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes:\n",
|
||||||
|
wantErrSubstrings: []string{"cache_max_bytes", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes: true\n",
|
||||||
|
wantErrSubstrings: []string{"cache_max_bytes", "true"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list",
|
||||||
|
yaml: signingKeyLine + "cache_max_bytes:\n - 1\n",
|
||||||
|
wantErrSubstrings: []string{"cache_max_bytes"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c, err := configFromYAML(t, tc.yaml)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with %s cache_max_bytes must abort startup, got config: %+v",
|
||||||
|
tc.name, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range tc.wantErrSubstrings {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace verifies the
|
||||||
|
// computed default is 75% of the probed free space when that exceeds
|
||||||
|
// the floor.
|
||||||
|
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
||||||
|
// 4 GiB free -> 3 GiB default.
|
||||||
|
probe := func(string) (uint64, error) { return 4294967296, nil }
|
||||||
|
|
||||||
|
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != 3221225472 {
|
||||||
|
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault
|
||||||
|
// verifies that when 75% of free space is below 500 MiB, the computed
|
||||||
|
// default is floored at DefaultCacheMaxBytesFloor.
|
||||||
|
func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
freeBytes uint64
|
||||||
|
}{
|
||||||
|
{name: "100 MiB free", freeBytes: 104857600},
|
||||||
|
{name: "zero free", freeBytes: 0},
|
||||||
|
{name: "just below floor threshold", freeBytes: 699050665},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
probe := func(string) (uint64, error) { return tc.freeBytes, nil }
|
||||||
|
|
||||||
|
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != DefaultCacheMaxBytesFloor {
|
||||||
|
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want floor %d",
|
||||||
|
got, DefaultCacheMaxBytesFloor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestComputeDefaultCacheMaxBytesPropagatesProbeError verifies that a
|
||||||
|
// failing free-space probe produces an error naming the config key,
|
||||||
|
// instead of a silently wrong default.
|
||||||
|
func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
|
||||||
|
probe := func(string) (uint64, error) { return 0, errors.New("statfs failed") }
|
||||||
|
|
||||||
|
_, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("probe failure must produce an error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "cache_max_bytes") {
|
||||||
|
t.Errorf("error %q does not name the config key cache_max_bytes", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveCacheMaxBytesComputesDefaultWhenOmitted verifies that an
|
||||||
|
// omitted cache_max_bytes key resolves to the computed default, that
|
||||||
|
// the probe is pointed at <state_dir>/cache/ (which must be created
|
||||||
|
// first so statfs measures the right filesystem), and that the result
|
||||||
|
// lands on the Config.
|
||||||
|
func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
|
||||||
|
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.StateDir = t.TempDir()
|
||||||
|
wantCacheDir := filepath.Join(c.StateDir, "cache")
|
||||||
|
|
||||||
|
var probedPath string
|
||||||
|
|
||||||
|
// 4 GiB free -> 3 GiB default.
|
||||||
|
probe := func(path string) (uint64, error) {
|
||||||
|
probedPath = path
|
||||||
|
|
||||||
|
return 4294967296, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
|
||||||
|
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 3221225472 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472", c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if probedPath != wantCacheDir {
|
||||||
|
t.Errorf("free space probed at %q, want cache directory %q", probedPath, wantCacheDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(wantCacheDir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
|
||||||
|
wantCacheDir, info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveCacheMaxBytesDoesNotOverrideExplicitValue verifies that
|
||||||
|
// an explicitly configured value survives resolution untouched and
|
||||||
|
// that the free-space probe is never consulted for it.
|
||||||
|
func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.StateDir = t.TempDir()
|
||||||
|
|
||||||
|
probe := func(string) (uint64, error) {
|
||||||
|
t.Error("free-space probe must not be consulted for explicit values")
|
||||||
|
|
||||||
|
return 0, errors.New("probe must not be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
|
||||||
|
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CacheMaxBytes != 1024 {
|
||||||
|
t.Errorf("CacheMaxBytes = %d, want explicit 1024 (no floor, no recompute)",
|
||||||
|
c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
internal/config/cachesize.go
Normal file
110
internal/config/cachesize.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCacheMaxBytesFloor is the minimum computed default for the
|
||||||
|
// cache_max_bytes setting: 500 MiB. The floor applies only to the
|
||||||
|
// computed default (when the key is omitted from the configuration),
|
||||||
|
// never to explicitly configured values.
|
||||||
|
const DefaultCacheMaxBytesFloor int64 = 524288000
|
||||||
|
|
||||||
|
// cacheDirPerms is the permission mode for the cache directory created
|
||||||
|
// before probing free space, matching the state directory permissions.
|
||||||
|
const cacheDirPerms = 0o750
|
||||||
|
|
||||||
|
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
|
||||||
|
// the 75% share of free space used for the computed default limit as
|
||||||
|
// integer arithmetic (dividing before multiplying avoids overflow).
|
||||||
|
const (
|
||||||
|
freeSpaceFractionNumerator uint64 = 3
|
||||||
|
freeSpaceFractionDenominator uint64 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreeSpaceProbeFunc reports the number of free bytes available on the
|
||||||
|
// filesystem containing path. It is a function type so tests can
|
||||||
|
// inject a fake probe instead of depending on the host disk.
|
||||||
|
type FreeSpaceProbeFunc func(path string) (uint64, error)
|
||||||
|
|
||||||
|
// defaultFreeSpaceProbe reports free filesystem bytes via statfs on
|
||||||
|
// the given path, as available to unprivileged processes.
|
||||||
|
func defaultFreeSpaceProbe(path string) (uint64, error) {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &stat); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.Bsize < 0 {
|
||||||
|
return 0, fmt.Errorf("statfs reported negative block size %d for %q", stat.Bsize, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockSize := uint64(stat.Bsize) //nolint:gosec // G115: negative Bsize rejected above
|
||||||
|
|
||||||
|
return stat.Bavail * blockSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeDefaultCacheMaxBytes returns the default cache size limit for
|
||||||
|
// the filesystem containing cacheDir: 75% of the free bytes reported
|
||||||
|
// by probe, with a floor of DefaultCacheMaxBytesFloor.
|
||||||
|
func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int64, error) {
|
||||||
|
freeBytes, err := probe(cacheDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config key %q: cannot determine free space for %q: %w",
|
||||||
|
"cache_max_bytes", cacheDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
computed := freeBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
||||||
|
if computed > math.MaxInt64 {
|
||||||
|
computed = math.MaxInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(computed) //nolint:gosec // G115: clamped to MaxInt64 above
|
||||||
|
|
||||||
|
if limit < DefaultCacheMaxBytesFloor {
|
||||||
|
limit = DefaultCacheMaxBytesFloor
|
||||||
|
}
|
||||||
|
|
||||||
|
return limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCacheMaxBytes finalizes CacheMaxBytes after state_dir
|
||||||
|
// validation: an explicitly configured value is kept as-is (no floor
|
||||||
|
// applies), while an omitted key receives the computed default based
|
||||||
|
// on free space in <state_dir>/cache/. The cache directory is created
|
||||||
|
// first so statfs measures the filesystem that will actually hold the
|
||||||
|
// cache. The effective limit is logged either way.
|
||||||
|
func (c *Config) resolveCacheMaxBytes(log *slog.Logger, probe FreeSpaceProbeFunc) error {
|
||||||
|
if !c.cacheMaxBytesExplicit {
|
||||||
|
cacheDir := filepath.Join(c.StateDir, "cache")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cacheDir, cacheDirPerms); err != nil {
|
||||||
|
return fmt.Errorf("config key %q: cannot create cache directory %q: %w",
|
||||||
|
"cache_max_bytes", cacheDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := ComputeDefaultCacheMaxBytes(cacheDir, probe)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.CacheMaxBytes = limit
|
||||||
|
|
||||||
|
log.Info("computed default cache size limit from free space",
|
||||||
|
"cache_max_bytes", limit,
|
||||||
|
"cache_dir", cacheDir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("effective cache size limit",
|
||||||
|
"cache_max_bytes", c.CacheMaxBytes,
|
||||||
|
"cache_disabled", c.CacheMaxBytes == 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -4,8 +4,12 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -41,9 +45,22 @@ type Config struct {
|
|||||||
|
|
||||||
// Image proxy settings
|
// Image proxy settings
|
||||||
SigningKey string // HMAC signing key for URL signatures
|
SigningKey string // HMAC signing key for URL signatures
|
||||||
WhitelistHosts []string // Hosts that don't require signatures
|
AllowlistHosts []string // Hosts that don't require signatures
|
||||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||||
|
|
||||||
|
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
||||||
|
// disables the disk cache entirely. When cache_max_bytes is
|
||||||
|
// omitted from the configuration, this holds the computed default
|
||||||
|
// (75% of free space on the filesystem containing
|
||||||
|
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
|
||||||
|
CacheMaxBytes int64
|
||||||
|
|
||||||
|
// cacheMaxBytesExplicit records whether cache_max_bytes was
|
||||||
|
// explicitly set in the configuration file. Explicit values are
|
||||||
|
// used exactly as given; only an omitted key gets the computed
|
||||||
|
// default (and its floor) in resolveCacheMaxBytes.
|
||||||
|
cacheMaxBytesExplicit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Config instance by loading configuration from file.
|
// New creates a new Config instance by loading configuration from file.
|
||||||
@@ -60,31 +77,89 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
log.Info("no config file found, using defaults")
|
log.Info("no config file found, using defaults")
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &Config{
|
c, err := newFromSmartConfig(sc)
|
||||||
Debug: getBool(sc, "debug", false),
|
if err != nil {
|
||||||
MaintenanceMode: getBool(sc, "maintenance_mode", false),
|
return nil, err
|
||||||
Port: getInt(sc, "port", DefaultPort),
|
|
||||||
StateDir: getString(sc, "state_dir", DefaultStateDir),
|
|
||||||
SentryDSN: getString(sc, "sentry_dsn", ""),
|
|
||||||
MetricsUsername: getString(sc, "metrics.username", ""),
|
|
||||||
MetricsPassword: getString(sc, "metrics.password", ""),
|
|
||||||
SigningKey: getString(sc, "signing_key", ""),
|
|
||||||
WhitelistHosts: getStringSlice(sc, "whitelist_hosts"),
|
|
||||||
AllowHTTP: getBool(sc, "allow_http", false),
|
|
||||||
UpstreamConnectionsPerHost: getInt(sc, "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build DBURL from StateDir if not explicitly set
|
if err := c.ensureStateDirWritable(); err != nil {
|
||||||
c.DBURL = getString(sc, "db_url", "")
|
return nil, err
|
||||||
if c.DBURL == "" {
|
}
|
||||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
|
||||||
|
if err := c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Debug {
|
if c.Debug {
|
||||||
params.Logger.EnableDebugLogging()
|
params.Logger.EnableDebugLogging()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required configuration
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFromSmartConfig constructs a Config from a loaded smartconfig
|
||||||
|
// instance and validates it. A nil sc means no config file was found,
|
||||||
|
// in which case every option takes its default value. A key that is
|
||||||
|
// present but unparseable or invalid is an error: defaults apply only
|
||||||
|
// to omitted keys, never to invalid explicit values.
|
||||||
|
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||||
|
if sc != nil {
|
||||||
|
if err := validateKnownKeys(sc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAllowlistHostsValue(sc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := &strictLoader{sc: sc}
|
||||||
|
|
||||||
|
c := &Config{
|
||||||
|
Debug: loader.boolVal("debug", false),
|
||||||
|
MaintenanceMode: loader.boolVal("maintenance_mode", false),
|
||||||
|
Port: loader.intVal("port", DefaultPort),
|
||||||
|
StateDir: loader.stringVal("state_dir", DefaultStateDir),
|
||||||
|
SentryDSN: loader.stringVal("sentry_dsn", ""),
|
||||||
|
MetricsUsername: loader.stringVal("metrics.username", ""),
|
||||||
|
MetricsPassword: loader.stringVal("metrics.password", ""),
|
||||||
|
SigningKey: loader.stringVal("signing_key", ""),
|
||||||
|
AllowlistHosts: getStringSlice(sc, "allowlist_hosts"),
|
||||||
|
AllowHTTP: loader.boolVal("allow_http", false),
|
||||||
|
UpstreamConnectionsPerHost: loader.intVal(
|
||||||
|
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
||||||
|
CacheMaxBytes: loader.int64Val("cache_max_bytes", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The computed default for cache_max_bytes needs a validated
|
||||||
|
// state_dir, so it is resolved later (resolveCacheMaxBytes); here
|
||||||
|
// we only record whether the operator set the key explicitly.
|
||||||
|
if sc != nil {
|
||||||
|
if _, present := sc.Get("cache_max_bytes"); present {
|
||||||
|
c.cacheMaxBytesExplicit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||||
|
// is a default: it applies only when db_url is omitted, never to an
|
||||||
|
// explicitly empty value.
|
||||||
|
c.DBURL = loader.stringVal("db_url", "")
|
||||||
|
if c.DBURL == "" && loader.err == nil {
|
||||||
|
if sc != nil {
|
||||||
|
if _, present := sc.Get("db_url"); present {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"config key %q: value must not be empty; omit the key to derive it from state_dir",
|
||||||
|
"db_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loader.err != nil {
|
||||||
|
return nil, loader.err
|
||||||
|
}
|
||||||
|
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -92,16 +167,202 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate checks that all required configuration values are set.
|
// validateKnownKeys rejects configuration files containing keys the
|
||||||
|
// application does not understand, so typos fail at startup instead of
|
||||||
|
// being silently ignored, and rejects keys that are explicitly set to
|
||||||
|
// null: a null is a SET value, never an omission, so it must not
|
||||||
|
// silently take the default. The env section is permitted because
|
||||||
|
// smartconfig consumes it for environment variable injection.
|
||||||
|
func validateKnownKeys(sc *smartconfig.Config) error {
|
||||||
|
var unknown, nullKeys []string
|
||||||
|
|
||||||
|
for key, value := range sc.Data() {
|
||||||
|
if !isKnownConfigKey(key) {
|
||||||
|
unknown = append(unknown, key)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == nil {
|
||||||
|
nullKeys = append(nullKeys, key)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "metrics" {
|
||||||
|
metricsMap, ok := value.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config key %q: value %v is not a map of metrics settings",
|
||||||
|
"metrics", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
for subkey, subvalue := range metricsMap {
|
||||||
|
if subkey != "username" && subkey != "password" {
|
||||||
|
unknown = append(unknown, "metrics."+subkey)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if subvalue == nil {
|
||||||
|
nullKeys = append(nullKeys, "metrics."+subkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unknown) > 0 {
|
||||||
|
sort.Strings(unknown)
|
||||||
|
|
||||||
|
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nullKeys) > 0 {
|
||||||
|
sort.Strings(nullKeys)
|
||||||
|
|
||||||
|
if len(nullKeys) == 1 {
|
||||||
|
return errNullConfigValue(nullKeys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config keys %s: value is null; omit a key entirely to use its default",
|
||||||
|
strings.Join(nullKeys, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNullConfigValue reports a config key that is explicitly set to
|
||||||
|
// null (including the bare "key:" form and the "~" alias). Silently
|
||||||
|
// applying the default would mask a truncated or typo'd config entry.
|
||||||
|
func errNullConfigValue(key string) error {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config key %q: value is null; omit the key entirely to use the default", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKnownConfigKey reports whether key is a permitted top-level
|
||||||
|
// configuration key.
|
||||||
|
func isKnownConfigKey(key string) bool {
|
||||||
|
switch key {
|
||||||
|
case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn",
|
||||||
|
"db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http",
|
||||||
|
"upstream_connections_per_host", "cache_max_bytes", "env":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureStateDirWritable verifies at startup that StateDir can be
|
||||||
|
// created and written to, so a misconfigured path aborts startup
|
||||||
|
// instead of failing later at first use.
|
||||||
|
func (c *Config) ensureStateDirWritable() error {
|
||||||
|
const stateDirPerms = 0o750
|
||||||
|
|
||||||
|
if err := os.MkdirAll(c.StateDir, stateDirPerms); err != nil {
|
||||||
|
return fmt.Errorf("config key %q: cannot create directory %q: %w",
|
||||||
|
"state_dir", c.StateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config key %q: directory %q is not writable: %w",
|
||||||
|
"state_dir", c.StateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
probePath := probe.Name()
|
||||||
|
|
||||||
|
if err := probe.Close(); err != nil {
|
||||||
|
return fmt.Errorf("config key %q: cannot close probe file %q: %w",
|
||||||
|
"state_dir", probePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir
|
||||||
|
if err := os.Remove(probePath); err != nil {
|
||||||
|
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
|
||||||
|
"state_dir", probePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate checks that all required configuration values are set and
|
||||||
|
// that every value is within its valid range.
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
|
// The signing key value is never echoed in error messages.
|
||||||
if c.SigningKey == "" {
|
if c.SigningKey == "" {
|
||||||
return fmt.Errorf("signing_key is required")
|
return fmt.Errorf("config key %q: a value is required", "signing_key")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum key length for security (32 bytes = 256 bits)
|
// Minimum key length for security (32 bytes = 256 bits)
|
||||||
const minKeyLength = 32
|
const minKeyLength = 32
|
||||||
if len(c.SigningKey) < minKeyLength {
|
if len(c.SigningKey) < minKeyLength {
|
||||||
return fmt.Errorf("signing_key must be at least %d characters", minKeyLength)
|
return fmt.Errorf("config key %q: value must be at least %d characters, got %d",
|
||||||
|
"signing_key", minKeyLength, len(c.SigningKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPort = 65535
|
||||||
|
if c.Port < 1 || c.Port > maxPort {
|
||||||
|
return fmt.Errorf("config key %q: value %d is outside the valid port range 1-%d",
|
||||||
|
"port", c.Port, maxPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnectionsPerHost < 1 {
|
||||||
|
return fmt.Errorf("config key %q: value %d must be at least 1",
|
||||||
|
"upstream_connections_per_host", c.UpstreamConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir == "" {
|
||||||
|
return fmt.Errorf("config key %q: value must not be empty", "state_dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero is valid (it disables the disk cache); only negative
|
||||||
|
// values are rejected. No floor applies to explicit values.
|
||||||
|
if c.CacheMaxBytes < 0 {
|
||||||
|
return fmt.Errorf("config key %q: value %d must not be negative",
|
||||||
|
"cache_max_bytes", c.CacheMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range c.AllowlistHosts {
|
||||||
|
if err := validateAllowlistHost(host); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.SentryDSN != "" {
|
||||||
|
parsed, err := url.Parse(c.SentryDSN)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("config key %q: value %q is not a valid URL",
|
||||||
|
"sentry_dsn", c.SentryDSN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
||||||
|
return fmt.Errorf("config keys %q and %q must be set together",
|
||||||
|
"metrics.username", "metrics.password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
|
||||||
|
// hostname, optionally with a leading dot for suffix matching. URLs,
|
||||||
|
// paths, and whitespace indicate a misconfigured entry. An entry with
|
||||||
|
// no hostname labels (such as ".") is rejected: the allowlist matcher
|
||||||
|
// treats a leading dot as a suffix pattern, so a bare "." would match
|
||||||
|
// any upstream host written in FQDN trailing-dot form and effectively
|
||||||
|
// disable URL signing.
|
||||||
|
func validateAllowlistHost(host string) error {
|
||||||
|
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config key %q: entry %q must be a bare hostname without scheme, path, or whitespace",
|
||||||
|
"allowlist_hosts", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Trim(host, ".") == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config key %q: entry %q contains no hostname labels",
|
||||||
|
"allowlist_hosts", host)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -135,11 +396,11 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
//nolint:gosec // G703: paths are hardcoded config locations
|
//nolint:gosec // G703: paths are hardcoded config locations
|
||||||
if _, statErr := os.Stat(cleanPath); statErr == nil {
|
if _, statErr := os.Stat(cleanPath); statErr == nil {
|
||||||
|
// A config file that exists but does not parse is a fatal
|
||||||
|
// startup error, never something to skip over.
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
sc, err := smartconfig.NewFromConfigPath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("failed to parse config file", "path", path, "error", err)
|
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("loaded config file", "path", path)
|
log.Info("loaded config file", "path", path)
|
||||||
@@ -151,45 +412,269 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getString(sc *smartconfig.Config, key, defaultVal string) string {
|
// strictLoader accumulates the first error encountered while reading
|
||||||
if sc == nil {
|
// typed values out of a smartconfig instance, so Config construction
|
||||||
return defaultVal
|
// can stay a single struct literal.
|
||||||
|
type strictLoader struct {
|
||||||
|
sc *smartconfig.Config
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *strictLoader) stringVal(key, defaultVal string) string {
|
||||||
|
if l.err != nil {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val, err := sc.GetString(key)
|
val, err := getString(l.sc, key, defaultVal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return defaultVal
|
l.err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInt(sc *smartconfig.Config, key string, defaultVal int) int {
|
func (l *strictLoader) intVal(key string, defaultVal int) int {
|
||||||
if sc == nil {
|
if l.err != nil {
|
||||||
return defaultVal
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
val, err := sc.GetInt(key)
|
val, err := getInt(l.sc, key, defaultVal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return defaultVal
|
l.err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBool(sc *smartconfig.Config, key string, defaultVal bool) bool {
|
func (l *strictLoader) int64Val(key string, defaultVal int64) int64 {
|
||||||
if sc == nil {
|
if l.err != nil {
|
||||||
return defaultVal
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
val, err := sc.GetBool(key)
|
val, err := getInt64(l.sc, key, defaultVal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return defaultVal
|
l.err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
||||||
|
if l.err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := getBool(l.sc, key, defaultVal)
|
||||||
|
if err != nil {
|
||||||
|
l.err = err
|
||||||
|
}
|
||||||
|
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// getString returns the string value for key, or defaultVal if the key
|
||||||
|
// is omitted. A present value that is not a string, or is explicitly
|
||||||
|
// null, is an error.
|
||||||
|
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
|
||||||
|
if sc == nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := sc.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return "", errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
str, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("config key %q: value %v (%T) is not a string",
|
||||||
|
key, raw, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return str, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInt returns the integer value for key, or defaultVal if the key is
|
||||||
|
// omitted. A present value that is not a whole number, or is explicitly
|
||||||
|
// null, is an error; fractional values are never truncated.
|
||||||
|
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
|
||||||
|
if sc == nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := sc.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return 0, errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := raw.(type) {
|
||||||
|
case int:
|
||||||
|
return val, nil
|
||||||
|
case int64:
|
||||||
|
return int(val), nil
|
||||||
|
case float64:
|
||||||
|
if val != math.Trunc(val) {
|
||||||
|
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(val), nil
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(val))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
|
||||||
|
key, raw, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInt64 returns the 64-bit integer value for key, or defaultVal if
|
||||||
|
// the key is omitted. A present value that is not a whole number, or
|
||||||
|
// is explicitly null, is an error; fractional values are never
|
||||||
|
// truncated and out-of-range values are never clamped.
|
||||||
|
func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, error) {
|
||||||
|
if sc == nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := sc.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return 0, errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := raw.(type) {
|
||||||
|
case int:
|
||||||
|
return int64(val), nil
|
||||||
|
case int64:
|
||||||
|
return val, nil
|
||||||
|
case uint64:
|
||||||
|
if val > math.MaxInt64 {
|
||||||
|
return 0, fmt.Errorf("config key %q: value %d overflows a 64-bit integer",
|
||||||
|
key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int64(val), nil //nolint:gosec // G115: bounds checked above
|
||||||
|
case float64:
|
||||||
|
if val != math.Trunc(val) {
|
||||||
|
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int64(val), nil
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
|
||||||
|
key, raw, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBool returns the boolean value for key, or defaultVal if the key
|
||||||
|
// is omitted. A present value that is not a boolean (or a ParseBool-able
|
||||||
|
// string), or is explicitly null, is an error; numbers are not accepted
|
||||||
|
// as booleans.
|
||||||
|
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
|
||||||
|
if sc == nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, ok := sc.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return false, errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := raw.(type) {
|
||||||
|
case bool:
|
||||||
|
return val, nil
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.ParseBool(strings.TrimSpace(val))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("config key %q: value %q is not a boolean", key, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("config key %q: value %v (%T) is not a boolean",
|
||||||
|
key, raw, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAllowlistHostsValue checks the raw shape of the
|
||||||
|
// allowlist_hosts value before the lenient extraction in getStringSlice
|
||||||
|
// runs: an explicitly null value, a value that is not a list of strings
|
||||||
|
// (or a comma-separated string), a non-string entry, or an empty entry
|
||||||
|
// is an error, never silently skipped.
|
||||||
|
func validateAllowlistHostsValue(sc *smartconfig.Config) error {
|
||||||
|
const key = "allowlist_hosts"
|
||||||
|
|
||||||
|
raw, ok := sc.Get(key)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := raw.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range val {
|
||||||
|
str, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config key %q: list entry %v (%T) is not a string", key, item, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(str) == "" {
|
||||||
|
return fmt.Errorf("config key %q: list contains an empty entry", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(val) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range strings.Split(val, ",") {
|
||||||
|
if strings.TrimSpace(part) == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config key %q: value %q contains an empty entry", key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("config key %q: value %v (%T) is not a list of strings",
|
||||||
|
key, raw, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStringSlice returns the list of strings for key, or nil if the key
|
||||||
|
// is omitted. It accepts a YAML list of strings or a comma-separated
|
||||||
|
// string (backwards compatibility). Malformed entries are rejected
|
||||||
|
// beforehand by validateAllowlistHostsValue.
|
||||||
func getStringSlice(sc *smartconfig.Config, key string) []string {
|
func getStringSlice(sc *smartconfig.Config, key string) []string {
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestGetStringSlice_YAMLList(t *testing.T) {
|
|||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
yamlContent := `
|
yamlContent := `
|
||||||
whitelist_hosts:
|
allowlist_hosts:
|
||||||
- static.sneak.cloud
|
- static.sneak.cloud
|
||||||
- sneak.berlin
|
- sneak.berlin
|
||||||
- s3.sneak.cloud
|
- s3.sneak.cloud
|
||||||
@@ -31,7 +31,7 @@ whitelist_hosts:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test that getStringSlice correctly parses YAML list
|
// Test that getStringSlice correctly parses YAML list
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
if len(hosts) != 3 {
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||||
@@ -54,7 +54,7 @@ func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "config.yml")
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
yamlContent := `whitelist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
|
||||||
|
|
||||||
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
err := os.WriteFile(configPath, []byte(yamlContent), 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,7 +66,7 @@ func TestGetStringSlice_CommaSeparated(t *testing.T) {
|
|||||||
t.Fatalf("failed to load config: %v", err)
|
t.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||||
|
|
||||||
if len(hosts) != 3 {
|
if len(hosts) != 3 {
|
||||||
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
|
||||||
@@ -100,7 +100,7 @@ func TestGetStringSlice_Empty(t *testing.T) {
|
|||||||
t.Fatalf("failed to load config: %v", err)
|
t.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hosts := getStringSlice(sc, "whitelist_hosts")
|
hosts := getStringSlice(sc, "allowlist_hosts")
|
||||||
|
|
||||||
if hosts != nil && len(hosts) != 0 {
|
if hosts != nil && len(hosts) != 0 {
|
||||||
t.Errorf("expected nil or empty slice, got %v", hosts)
|
t.Errorf("expected nil or empty slice, got %v", hosts)
|
||||||
|
|||||||
547
internal/config/config_validation_test.go
Normal file
547
internal/config/config_validation_test.go
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||||
|
// minimum length requirement in validate().
|
||||||
|
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
// configFromYAML writes yamlContent to a temporary config file, loads it
|
||||||
|
// via smartconfig, and constructs a Config from it using the same code
|
||||||
|
// path the server uses at startup.
|
||||||
|
func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte(yamlContent), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc, err := smartconfig.NewFromConfigPath(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFromSmartConfig(sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOmittedValuesUseDefaults(t *testing.T) {
|
||||||
|
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != DefaultPort {
|
||||||
|
t.Errorf("Port = %d, want default %d", c.Port, DefaultPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir != DefaultStateDir {
|
||||||
|
t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnectionsPerHost != DefaultUpstreamConnectionsPerHost {
|
||||||
|
t.Errorf("UpstreamConnectionsPerHost = %d, want default %d",
|
||||||
|
c.UpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Debug {
|
||||||
|
t.Error("Debug = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaintenanceMode {
|
||||||
|
t.Error("MaintenanceMode = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.AllowHTTP {
|
||||||
|
t.Error("AllowHTTP = true, want default false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 0 {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
|
||||||
|
if c.DBURL != wantDBURL {
|
||||||
|
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||||
|
yamlContent := `
|
||||||
|
port: 9090
|
||||||
|
debug: true
|
||||||
|
maintenance_mode: true
|
||||||
|
state_dir: /tmp/pixa-test-state
|
||||||
|
db_url: "file:/tmp/pixa-test-state/other.sqlite3"
|
||||||
|
signing_key: ` + validTestSigningKey + `
|
||||||
|
allowlist_hosts:
|
||||||
|
- s3.sneak.cloud
|
||||||
|
- .example.com
|
||||||
|
allow_http: true
|
||||||
|
upstream_connections_per_host: 5
|
||||||
|
sentry_dsn: "https://abc123@sentry.example.com/42"
|
||||||
|
metrics:
|
||||||
|
username: metricsuser
|
||||||
|
password: metricspass
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid config should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != 9090 {
|
||||||
|
t.Errorf("Port = %d, want 9090", c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.Debug || !c.MaintenanceMode || !c.AllowHTTP {
|
||||||
|
t.Errorf("bool fields = debug %v maintenance %v allow_http %v, want all true",
|
||||||
|
c.Debug, c.MaintenanceMode, c.AllowHTTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.StateDir != "/tmp/pixa-test-state" {
|
||||||
|
t.Errorf("StateDir = %q, want /tmp/pixa-test-state", c.StateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.DBURL != "file:/tmp/pixa-test-state/other.sqlite3" {
|
||||||
|
t.Errorf("DBURL = %q, want explicit value", c.DBURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" ||
|
||||||
|
c.AllowlistHosts[1] != ".example.com" {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]", c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UpstreamConnectionsPerHost != 5 {
|
||||||
|
t.Errorf("UpstreamConnectionsPerHost = %d, want 5", c.UpstreamConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.SentryDSN != "https://abc123@sentry.example.com/42" {
|
||||||
|
t.Errorf("SentryDSN = %q, want explicit value", c.SentryDSN)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MetricsUsername != "metricsuser" || c.MetricsPassword != "metricspass" {
|
||||||
|
t.Errorf("metrics = %q/%q, want metricsuser/metricspass",
|
||||||
|
c.MetricsUsername, c.MetricsPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
|
||||||
|
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||||
|
allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("comma-separated allowlist should load, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" ||
|
||||||
|
c.AllowlistHosts[1] != "sneak.berlin" {
|
||||||
|
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]", c.AllowlistHosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetButInvalidValueAbortsStartup verifies the no-silent-fallback
|
||||||
|
// rule: a key that is explicitly set to an unparseable or out-of-range
|
||||||
|
// value must produce a startup error naming the offending key, never
|
||||||
|
// silently fall back to the default.
|
||||||
|
func TestSetButInvalidValueAbortsStartup(t *testing.T) {
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
// wantErrSubstrings must all appear in the error message.
|
||||||
|
wantErrSubstrings []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "port not a number",
|
||||||
|
yaml: signingKeyLine + "port: banana\n",
|
||||||
|
wantErrSubstrings: []string{"port", "banana"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port zero",
|
||||||
|
yaml: signingKeyLine + "port: 0\n",
|
||||||
|
wantErrSubstrings: []string{"port", "0"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port above 65535",
|
||||||
|
yaml: signingKeyLine + "port: 99999\n",
|
||||||
|
wantErrSubstrings: []string{"port", "99999"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port fractional",
|
||||||
|
yaml: signingKeyLine + "port: 8080.5\n",
|
||||||
|
wantErrSubstrings: []string{"port", "8080.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug not a bool",
|
||||||
|
yaml: signingKeyLine + "debug: notabool\n",
|
||||||
|
wantErrSubstrings: []string{"debug", "notabool"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maintenance_mode not a bool",
|
||||||
|
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
|
||||||
|
wantErrSubstrings: []string{"maintenance_mode", "sometimes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow_http numeric",
|
||||||
|
yaml: signingKeyLine + "allow_http: 2\n",
|
||||||
|
wantErrSubstrings: []string{"allow_http", "2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host zero",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
|
||||||
|
wantErrSubstrings: []string{"upstream_connections_per_host", "0"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host negative",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
|
||||||
|
wantErrSubstrings: []string{"upstream_connections_per_host", "-3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host not a number",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
|
||||||
|
wantErrSubstrings: []string{"upstream_connections_per_host", "many"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with scheme",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
"allowlist_hosts", "https://example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with path",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
"allowlist_hosts", "example.com/images",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist host with whitespace",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
|
||||||
|
wantErrSubstrings: []string{"allowlist_hosts", "exa mple.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist entry not a string",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
|
||||||
|
wantErrSubstrings: []string{"allowlist_hosts", "123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist not a list",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
|
||||||
|
wantErrSubstrings: []string{"allowlist_hosts"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key too short",
|
||||||
|
yaml: "signing_key: short\n",
|
||||||
|
wantErrSubstrings: []string{"signing_key"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key missing",
|
||||||
|
yaml: "port: 8080\n",
|
||||||
|
wantErrSubstrings: []string{"signing_key"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_dir explicitly empty",
|
||||||
|
yaml: signingKeyLine + "state_dir: \"\"\n",
|
||||||
|
wantErrSubstrings: []string{"state_dir"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentry_dsn not a URL",
|
||||||
|
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
|
||||||
|
wantErrSubstrings: []string{"sentry_dsn", "not a url"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics username without password",
|
||||||
|
yaml: signingKeyLine + "metrics:\n username: bob\n",
|
||||||
|
wantErrSubstrings: []string{"metrics"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics password without username",
|
||||||
|
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
|
||||||
|
wantErrSubstrings: []string{"metrics"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c, err := configFromYAML(t, tc.yaml)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range tc.wantErrSubstrings {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitNullValueAbortsStartup verifies that a key explicitly
|
||||||
|
// set to null (including the bare "key:" form and the "~" alias) aborts
|
||||||
|
// startup naming the key. An explicit null is a SET value: it must
|
||||||
|
// never silently fall back to the default the way an omitted key does.
|
||||||
|
func TestExplicitNullValueAbortsStartup(t *testing.T) {
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
// wantErrSubstrings must all appear in the error message.
|
||||||
|
wantErrSubstrings []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "port explicit null",
|
||||||
|
yaml: signingKeyLine + "port: null\n",
|
||||||
|
wantErrSubstrings: []string{"port", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port bare key no value",
|
||||||
|
yaml: signingKeyLine + "port:\n",
|
||||||
|
wantErrSubstrings: []string{"port", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug tilde null",
|
||||||
|
yaml: signingKeyLine + "debug: ~\n",
|
||||||
|
wantErrSubstrings: []string{"debug", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maintenance_mode null",
|
||||||
|
yaml: signingKeyLine + "maintenance_mode: null\n",
|
||||||
|
wantErrSubstrings: []string{"maintenance_mode", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow_http null",
|
||||||
|
yaml: signingKeyLine + "allow_http: null\n",
|
||||||
|
wantErrSubstrings: []string{"allow_http", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_dir null",
|
||||||
|
yaml: signingKeyLine + "state_dir: null\n",
|
||||||
|
wantErrSubstrings: []string{"state_dir", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "db_url null",
|
||||||
|
yaml: signingKeyLine + "db_url: null\n",
|
||||||
|
wantErrSubstrings: []string{"db_url", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentry_dsn null",
|
||||||
|
yaml: signingKeyLine + "sentry_dsn: null\n",
|
||||||
|
wantErrSubstrings: []string{"sentry_dsn", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host null",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
|
||||||
|
wantErrSubstrings: []string{"upstream_connections_per_host", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist_hosts null",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts: null\n",
|
||||||
|
wantErrSubstrings: []string{"allowlist_hosts", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key null",
|
||||||
|
yaml: "signing_key: null\n",
|
||||||
|
wantErrSubstrings: []string{"signing_key", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics null",
|
||||||
|
yaml: signingKeyLine + "metrics: null\n",
|
||||||
|
wantErrSubstrings: []string{"metrics", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics subkeys null",
|
||||||
|
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
"metrics.username", "metrics.password", "null",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c, err := configFromYAML(t, tc.yaml)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range tc.wantErrSubstrings {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
|
||||||
|
// empty string aborts startup: the derived file:...state.sqlite3 URL is
|
||||||
|
// a default, and defaults apply only to omitted keys. This matches
|
||||||
|
// state_dir, where an explicitly empty value already aborts.
|
||||||
|
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ndb_url: \"\"\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("explicitly empty db_url must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "db_url") {
|
||||||
|
t.Errorf("error %q does not name the offending key db_url", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowlistHostsRejectsDotOnlyEntries verifies that entries with no
|
||||||
|
// hostname labels are rejected. The allowlist matcher treats a leading
|
||||||
|
// dot as a suffix pattern, so a bare "." entry would match any upstream
|
||||||
|
// host written in FQDN trailing-dot form (e.g. evil.com.) and
|
||||||
|
// effectively disable URL signing with a single character.
|
||||||
|
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
for _, entry := range []string{".", ".."} {
|
||||||
|
t.Run(entry, func(t *testing.T) {
|
||||||
|
yamlContent := signingKeyLine +
|
||||||
|
"allowlist_hosts:\n - \"" + entry + "\"\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("allowlist entry %q must abort startup, got config: %+v",
|
||||||
|
entry, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "allowlist_hosts") {
|
||||||
|
t.Errorf("error %q does not name the offending key allowlist_hosts",
|
||||||
|
err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
|
||||||
|
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||||
|
whitelist_hosts:
|
||||||
|
- example.com
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with unknown key must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "whitelist_hosts") {
|
||||||
|
t.Errorf("error %q does not name the unknown key whitelist_hosts", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) {
|
||||||
|
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||||
|
metrics:
|
||||||
|
username: bob
|
||||||
|
password: hunter2
|
||||||
|
port: 9100
|
||||||
|
`
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with unknown metrics subkey must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "metrics.port") {
|
||||||
|
t.Errorf("error %q does not name the unknown key metrics.port", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvSectionIsPermitted(t *testing.T) {
|
||||||
|
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||||
|
env:
|
||||||
|
PIXA_TEST_ENV_INJECTION: injected
|
||||||
|
`
|
||||||
|
|
||||||
|
if _, err := configFromYAML(t, yamlContent); err != nil {
|
||||||
|
t.Fatalf("env section must be permitted (smartconfig consumes it), got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "config.yml")
|
||||||
|
|
||||||
|
if err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write malformed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigFile falls through to the relative config.yml candidate;
|
||||||
|
// the appname is chosen so no /etc or $HOME candidate can exist.
|
||||||
|
t.Setenv("PIXA_CONFIG_PATH", "")
|
||||||
|
t.Chdir(tmpDir)
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
|
||||||
|
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("malformed config file must abort startup, got config: %v", sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||||
|
stateDir := filepath.Join(t.TempDir(), "nested", "state")
|
||||||
|
|
||||||
|
c := &Config{StateDir: stateDir}
|
||||||
|
if err := c.ensureStateDirWritable(); err != nil {
|
||||||
|
t.Fatalf("creatable state_dir must validate, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(stateDir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("state_dir was not created: info=%v err=%v", info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
|
||||||
|
// A path below /dev/null can never be created, even when running
|
||||||
|
// as root (as in the Docker build).
|
||||||
|
c := &Config{StateDir: "/dev/null/pixa-state"}
|
||||||
|
|
||||||
|
err := c.ensureStateDirWritable()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("uncreatable state_dir must abort startup, got nil error")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "state_dir") {
|
||||||
|
t.Errorf("error %q does not name the offending key state_dir", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -21,6 +22,10 @@ import (
|
|||||||
//go:embed schema/*.sql
|
//go:embed schema/*.sql
|
||||||
var schemaFS embed.FS
|
var schemaFS embed.FS
|
||||||
|
|
||||||
|
// bootstrapVersion is the migration that creates the schema_migrations
|
||||||
|
// table itself. It is applied before the normal migration loop.
|
||||||
|
const bootstrapVersion = 0
|
||||||
|
|
||||||
// Params defines dependencies for Database.
|
// Params defines dependencies for Database.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -35,6 +40,46 @@ type Database struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseMigrationVersion extracts the numeric version prefix from a migration
|
||||||
|
// filename. Filenames must follow the pattern "<version>.sql" or
|
||||||
|
// "<version>_<description>.sql", where version is a zero-padded numeric
|
||||||
|
// string (e.g. "001", "002"). Returns the version as an integer and an
|
||||||
|
// error if the filename does not match the expected pattern.
|
||||||
|
func ParseMigrationVersion(filename string) (int, error) {
|
||||||
|
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
if name == "" {
|
||||||
|
return 0, fmt.Errorf("invalid migration filename %q: empty name", filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on underscore to separate version from description.
|
||||||
|
// If there's no underscore, the entire stem is the version.
|
||||||
|
versionStr := name
|
||||||
|
if idx := strings.IndexByte(name, '_'); idx >= 0 {
|
||||||
|
versionStr = name[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if versionStr == "" {
|
||||||
|
return 0, fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the version is purely numeric.
|
||||||
|
for _, ch := range versionStr {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"invalid migration filename %q: version %q contains non-numeric character %q",
|
||||||
|
filename, versionStr, string(ch),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := strconv.Atoi(versionStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid migration filename %q: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new Database instance.
|
// New creates a new Database instance.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
||||||
s := &Database{
|
s := &Database{
|
||||||
@@ -84,127 +129,86 @@ func (s *Database) connect(ctx context.Context) error {
|
|||||||
s.db = db
|
s.db = db
|
||||||
s.log.Info("database connected")
|
s.log.Info("database connected")
|
||||||
|
|
||||||
return s.runMigrations(ctx)
|
return ApplyMigrations(ctx, s.db, s.log)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Database) runMigrations(ctx context.Context) error {
|
// collectMigrations reads the embedded schema directory and returns
|
||||||
// Create migrations tracking table
|
// migration filenames sorted lexicographically.
|
||||||
_, err := s.db.ExecContext(ctx, `
|
func collectMigrations() ([]string, error) {
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version TEXT PRIMARY KEY,
|
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get list of migration files
|
|
||||||
entries, err := schemaFS.ReadDir("schema")
|
entries, err := schemaFS.ReadDir("schema")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read schema directory: %w", err)
|
return nil, fmt.Errorf("failed to read schema directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort migration files by name (001.sql, 002.sql, etc.)
|
|
||||||
var migrations []string
|
var migrations []string
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
migrations = append(migrations, entry.Name())
|
migrations = append(migrations, entry.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(migrations)
|
sort.Strings(migrations)
|
||||||
|
|
||||||
// Apply each migration that hasn't been applied yet
|
return migrations, nil
|
||||||
for _, migration := range migrations {
|
}
|
||||||
version := strings.TrimSuffix(migration, filepath.Ext(migration))
|
|
||||||
|
|
||||||
// Check if already applied
|
// bootstrapMigrationsTable ensures the schema_migrations table exists
|
||||||
var count int
|
// by applying 000.sql if the table is missing.
|
||||||
err := s.db.QueryRowContext(ctx,
|
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
var tableExists int
|
||||||
version,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check migration status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if count > 0 {
|
err := db.QueryRowContext(ctx,
|
||||||
s.log.Debug("migration already applied", "version", version)
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
).Scan(&tableExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
if tableExists > 0 {
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Read and apply migration
|
content, err := schemaFS.ReadFile("schema/000.sql")
|
||||||
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
|
||||||
return fmt.Errorf("failed to read migration %s: %w", migration, err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Info("applying migration", "version", version)
|
if log != nil {
|
||||||
|
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = s.db.ExecContext(ctx, string(content))
|
_, err = db.ExecContext(ctx, string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
// Record migration as applied
|
|
||||||
_, err = s.db.ExecContext(ctx,
|
|
||||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
|
||||||
version,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to record migration %s: %w", migration, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Info("migration applied successfully", "version", version)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB returns the underlying sql.DB.
|
// ApplyMigrations applies all pending migrations to db. An optional logger
|
||||||
func (s *Database) DB() *sql.DB {
|
// may be provided for informational output; pass nil for silent operation.
|
||||||
return s.db
|
// This is exported so tests can apply the real schema without the full fx
|
||||||
}
|
// lifecycle.
|
||||||
|
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
|
if err := bootstrapMigrationsTable(ctx, db, log); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyMigrations applies all migrations to the given database.
|
migrations, err := collectMigrations()
|
||||||
// This is useful for testing where you want to use the real schema
|
|
||||||
// without the full fx lifecycle.
|
|
||||||
func ApplyMigrations(db *sql.DB) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Create migrations tracking table
|
|
||||||
_, err := db.ExecContext(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version TEXT PRIMARY KEY,
|
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create migrations table: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get list of migration files
|
|
||||||
entries, err := schemaFS.ReadDir("schema")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read schema directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort migration files by name (001.sql, 002.sql, etc.)
|
|
||||||
var migrations []string
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
|
||||||
migrations = append(migrations, entry.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(migrations)
|
|
||||||
|
|
||||||
// Apply each migration that hasn't been applied yet
|
|
||||||
for _, migration := range migrations {
|
for _, migration := range migrations {
|
||||||
version := strings.TrimSuffix(migration, filepath.Ext(migration))
|
version, parseErr := ParseMigrationVersion(migration)
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already applied
|
// Check if already applied.
|
||||||
var count int
|
var count int
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||||
version,
|
version,
|
||||||
@@ -214,29 +218,46 @@ func ApplyMigrations(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
|
if log != nil {
|
||||||
|
log.Debug("migration already applied", "version", version)
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and apply migration
|
// Read and apply migration.
|
||||||
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
|
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
|
||||||
if err != nil {
|
if readErr != nil {
|
||||||
return fmt.Errorf("failed to read migration %s: %w", migration, err)
|
return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, string(content))
|
if log != nil {
|
||||||
if err != nil {
|
log.Info("applying migration", "version", version)
|
||||||
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record migration as applied
|
_, execErr := db.ExecContext(ctx, string(content))
|
||||||
_, err = db.ExecContext(ctx,
|
if execErr != nil {
|
||||||
|
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record migration as applied.
|
||||||
|
_, recErr := db.ExecContext(ctx,
|
||||||
"INSERT INTO schema_migrations (version) VALUES (?)",
|
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||||
version,
|
version,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if recErr != nil {
|
||||||
return fmt.Errorf("failed to record migration %s: %w", migration, err)
|
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("migration applied successfully", "version", version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DB returns the underlying sql.DB.
|
||||||
|
func (s *Database) DB() *sql.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|||||||
224
internal/database/database_test.go
Normal file
224
internal/database/database_test.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // SQLite driver registration
|
||||||
|
)
|
||||||
|
|
||||||
|
// openTestDB returns a fresh in-memory SQLite database.
|
||||||
|
func openTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMigrationVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
want int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "version only",
|
||||||
|
filename: "001.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version with description",
|
||||||
|
filename: "001_initial_schema.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi-digit version",
|
||||||
|
filename: "042_add_indexes.sql",
|
||||||
|
want: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long version number",
|
||||||
|
filename: "00001_long_prefix.sql",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description with multiple underscores",
|
||||||
|
filename: "003_add_user_auth_tables.sql",
|
||||||
|
want: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty filename",
|
||||||
|
filename: ".sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading underscore",
|
||||||
|
filename: "_description.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-numeric version",
|
||||||
|
filename: "abc_migration.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed alphanumeric version",
|
||||||
|
filename: "001a_migration.sql",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseMigrationVersion(tt.filename)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) expected error, got %d", tt.filename, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v", tt.filename, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ParseMigrationVersion(%q) = %d, want %d", tt.filename, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := ApplyMigrations(ctx, db, nil); err != nil {
|
||||||
|
t.Fatalf("ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The schema_migrations table must exist and contain at least
|
||||||
|
// version 0 (the bootstrap) and 1 (the initial schema).
|
||||||
|
rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var versions []int
|
||||||
|
for rows.Next() {
|
||||||
|
var v int
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
t.Fatalf("failed to scan version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("row iteration error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(versions) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 migrations recorded, got %d: %v", len(versions), versions)
|
||||||
|
}
|
||||||
|
|
||||||
|
if versions[0] != 0 {
|
||||||
|
t.Errorf("first recorded migration = %d, want %d", versions[0], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if versions[1] != 1 {
|
||||||
|
t.Errorf("second recorded migration = %d, want %d", versions[1], 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the application tables created by 001.sql exist.
|
||||||
|
for _, table := range []string{"source_content", "source_metadata", "output_content", "request_cache", "negative_cache", "cache_stats"} {
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := db.QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
table,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check for table %s: %v", table, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("table %s does not exist after migrations", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMigrations_Idempotent(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := ApplyMigrations(ctx, db, nil); err != nil {
|
||||||
|
t.Fatalf("first ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running a second time must succeed without errors.
|
||||||
|
if err := ApplyMigrations(ctx, db, nil); err != nil {
|
||||||
|
t.Fatalf("second ApplyMigrations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no duplicate rows in schema_migrations.
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = 0").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to count version 0 rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected exactly 1 row for version 0, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := bootstrapMigrationsTable(ctx, db, nil); err != nil {
|
||||||
|
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// schema_migrations table must exist.
|
||||||
|
var tableCount int
|
||||||
|
|
||||||
|
err := db.QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
).Scan(&tableCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check for table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableCount != 1 {
|
||||||
|
t.Fatalf("schema_migrations table not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version 0 must be recorded.
|
||||||
|
var recorded int
|
||||||
|
|
||||||
|
err = db.QueryRow(
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
|
||||||
|
).Scan(&recorded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check version: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded != 1 {
|
||||||
|
t.Errorf("expected version 0 to be recorded, got count %d", recorded)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/database/schema/000.sql
Normal file
9
internal/database/schema/000.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration 000: Schema migrations tracking table
|
||||||
|
-- Applied as a bootstrap step before the normal migration loop.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||||
25
internal/database/schema/002_cache_eviction.sql
Normal file
25
internal/database/schema/002_cache_eviction.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Migration 002: cache size accounting and eviction
|
||||||
|
--
|
||||||
|
-- Tracks processed variants in the database (source content blobs are
|
||||||
|
-- already tracked in source_content) so total cache usage can be
|
||||||
|
-- computed without directory scans, and adds last-access timestamps
|
||||||
|
-- for LRU eviction ordering.
|
||||||
|
|
||||||
|
-- Processed variant blobs
|
||||||
|
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a
|
||||||
|
-- .meta sidecar with the content type)
|
||||||
|
CREATE TABLE IF NOT EXISTS variant_content (
|
||||||
|
cache_key TEXT PRIMARY KEY,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
|
||||||
|
ON variant_content(last_accessed_at);
|
||||||
|
|
||||||
|
-- LRU timestamp for source content blobs. Rows written before this
|
||||||
|
-- migration have NULL here; eviction falls back to fetched_at.
|
||||||
|
ALTER TABLE source_content ADD COLUMN last_accessed_at DATETIME;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
|
||||||
|
ON source_content(last_accessed_at);
|
||||||
@@ -5,11 +5,10 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build-time variables populated from main() via ldflags.
|
const appname = "pixad"
|
||||||
var (
|
|
||||||
Appname string //nolint:gochecknoglobals // set from main
|
// Version is populated from main() via ldflags.
|
||||||
Version string //nolint:gochecknoglobals // set from main
|
var Version string //nolint:gochecknoglobals // set from main
|
||||||
)
|
|
||||||
|
|
||||||
// Globals holds application-wide constants.
|
// Globals holds application-wide constants.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
@@ -20,7 +19,7 @@ type Globals struct {
|
|||||||
// New creates a new Globals instance from build-time variables.
|
// New creates a new Globals instance from build-time variables.
|
||||||
func New(_ fx.Lifecycle) (*Globals, error) {
|
func New(_ fx.Lifecycle) (*Globals, error) {
|
||||||
return &Globals{
|
return &Globals{
|
||||||
Appname: Appname,
|
Appname: appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
@@ -52,6 +53,13 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
return s.initImageService()
|
return s.initImageService()
|
||||||
},
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
if s.imgCache != nil {
|
||||||
|
s.imgCache.StopEviction()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -59,11 +67,15 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
|
|
||||||
// initImageService initializes the image cache and service.
|
// initImageService initializes the image cache and service.
|
||||||
func (s *Handlers) initImageService() error {
|
func (s *Handlers) initImageService() error {
|
||||||
// Create the cache
|
// Create the cache. cache_max_bytes: 0 disables the disk cache
|
||||||
|
// entirely; any other value is the eviction limit in bytes.
|
||||||
cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{
|
||||||
StateDir: s.config.StateDir,
|
StateDir: s.config.StateDir,
|
||||||
CacheTTL: imgcache.DefaultCacheTTL,
|
CacheTTL: imgcache.DefaultCacheTTL,
|
||||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||||
|
MaxBytes: s.config.CacheMaxBytes,
|
||||||
|
DisableDiskCache: s.config.CacheMaxBytes == 0,
|
||||||
|
Logger: s.log,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -71,8 +83,12 @@ func (s *Handlers) initImageService() error {
|
|||||||
|
|
||||||
s.imgCache = cache
|
s.imgCache = cache
|
||||||
|
|
||||||
|
// Background eviction: startup reconciliation, then periodic and
|
||||||
|
// write-pressure passes. No-op when the disk cache is disabled.
|
||||||
|
cache.StartEviction(imgcache.DefaultEvictionInterval)
|
||||||
|
|
||||||
// Create the fetcher config
|
// Create the fetcher config
|
||||||
fetcherCfg := imgcache.DefaultFetcherConfig()
|
fetcherCfg := httpfetcher.DefaultConfig()
|
||||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||||
if s.config.UpstreamConnectionsPerHost > 0 {
|
if s.config.UpstreamConnectionsPerHost > 0 {
|
||||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||||
@@ -83,7 +99,7 @@ func (s *Handlers) initImageService() error {
|
|||||||
Cache: cache,
|
Cache: cache,
|
||||||
FetcherConfig: fetcherCfg,
|
FetcherConfig: fetcherCfg,
|
||||||
SigningKey: s.config.SigningKey,
|
SigningKey: s.config.SigningKey,
|
||||||
Whitelist: s.config.WhitelistHosts,
|
Allowlist: s.config.AllowlistHosts,
|
||||||
Logger: s.log,
|
Logger: s.log,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,8 +109,9 @@ func (s *Handlers) initImageService() error {
|
|||||||
s.imgSvc = svc
|
s.imgSvc = svc
|
||||||
s.log.Info("image service initialized")
|
s.log.Info("image service initialized")
|
||||||
|
|
||||||
// Initialize session manager (signing key is validated at config load time)
|
// Initialize session manager (signing key is validated at config load
|
||||||
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug)
|
// time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
|
||||||
|
sessMgr, err := session.NewManager(s.config.SigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: newMockFetcher(mockFS),
|
Fetcher: newMockFetcher(mockFS),
|
||||||
SigningKey: "test-signing-key-must-be-32-chars",
|
SigningKey: "test-signing-key-must-be-32-chars",
|
||||||
Whitelist: []string{goodHost},
|
Allowlist: []string{goodHost},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
@@ -82,7 +83,7 @@ func setupTestDB(t *testing.T) *sql.DB {
|
|||||||
t.Fatalf("failed to open test db: %v", err)
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.ApplyMigrations(db); err != nil {
|
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
||||||
t.Fatalf("failed to apply migrations: %v", err)
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,16 +117,16 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
|
|||||||
return &mockFetcher{fs: fs}
|
return &mockFetcher{fs: fs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchResult, error) {
|
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*httpfetcher.FetchResult, error) {
|
||||||
// Remove https:// prefix
|
// Remove https:// prefix
|
||||||
path := url[8:] // Remove "https://"
|
path := url[8:] // Remove "https://"
|
||||||
|
|
||||||
data, err := fs.ReadFile(f.fs, path)
|
data, err := fs.ReadFile(f.fs, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, imgcache.ErrUpstreamError
|
return nil, httpfetcher.ErrUpstreamError
|
||||||
}
|
}
|
||||||
|
|
||||||
return &imgcache.FetchResult{
|
return &httpfetcher.FetchResult{
|
||||||
Content: io.NopCloser(bytes.NewReader(data)),
|
Content: io.NopCloser(bytes.NewReader(data)),
|
||||||
ContentLength: int64(len(data)),
|
ContentLength: int64(len(data)),
|
||||||
ContentType: "image/jpeg",
|
ContentType: "image/jpeg",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,13 +98,13 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Check for specific error types
|
// Check for specific error types
|
||||||
if errors.Is(err, imgcache.ErrSSRFBlocked) {
|
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, imgcache.ErrUpstreamError) {
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/encurl"
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,11 +101,11 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
// handleImageError converts image service errors to HTTP responses.
|
// handleImageError converts image service errors to HTTP responses.
|
||||||
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, imgcache.ErrSSRFBlocked):
|
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
||||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||||
case errors.Is(err, imgcache.ErrUpstreamError):
|
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
||||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||||
case errors.Is(err, imgcache.ErrUpstreamTimeout):
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
||||||
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
||||||
default:
|
default:
|
||||||
s.log.Error("image request failed", "error", err)
|
s.log.Error("image request failed", "error", err)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
package imgcache
|
// Package httpfetcher fetches content from upstream HTTP origins with SSRF
|
||||||
|
// protection, per-host connection limits, and content-type validation.
|
||||||
|
package httpfetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -37,25 +39,55 @@ var (
|
|||||||
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
ErrUpstreamTimeout = errors.New("upstream request timeout")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FetcherConfig holds configuration for the upstream fetcher.
|
// Fetcher retrieves content from upstream origins.
|
||||||
type FetcherConfig struct {
|
type Fetcher interface {
|
||||||
// Timeout for upstream requests
|
// Fetch retrieves content from the given URL.
|
||||||
|
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchResult contains the result of fetching from upstream.
|
||||||
|
type FetchResult struct {
|
||||||
|
// Content is the raw image data.
|
||||||
|
Content io.ReadCloser
|
||||||
|
// ContentLength is the size in bytes (-1 if unknown).
|
||||||
|
ContentLength int64
|
||||||
|
// ContentType is the MIME type from upstream.
|
||||||
|
ContentType string
|
||||||
|
// Headers contains all response headers from upstream.
|
||||||
|
Headers map[string][]string
|
||||||
|
// StatusCode is the HTTP status code from upstream.
|
||||||
|
StatusCode int
|
||||||
|
// FetchDurationMs is how long the fetch took in milliseconds.
|
||||||
|
FetchDurationMs int64
|
||||||
|
// RemoteAddr is the IP:port of the upstream server.
|
||||||
|
RemoteAddr string
|
||||||
|
// HTTPVersion is the protocol version (e.g., "1.1", "2.0").
|
||||||
|
HTTPVersion string
|
||||||
|
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3").
|
||||||
|
TLSVersion string
|
||||||
|
// TLSCipherSuite is the negotiated cipher suite name.
|
||||||
|
TLSCipherSuite string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds configuration for the upstream fetcher.
|
||||||
|
type Config struct {
|
||||||
|
// Timeout for upstream requests.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// MaxResponseSize is the maximum allowed response body size
|
// MaxResponseSize is the maximum allowed response body size.
|
||||||
MaxResponseSize int64
|
MaxResponseSize int64
|
||||||
// UserAgent to send to upstream servers
|
// UserAgent to send to upstream servers.
|
||||||
UserAgent string
|
UserAgent string
|
||||||
// AllowedContentTypes is a whitelist of MIME types to accept
|
// AllowedContentTypes is an allow list of MIME types to accept.
|
||||||
AllowedContentTypes []string
|
AllowedContentTypes []string
|
||||||
// AllowHTTP allows non-TLS connections (for testing only)
|
// AllowHTTP allows non-TLS connections (for testing only).
|
||||||
AllowHTTP bool
|
AllowHTTP bool
|
||||||
// MaxConnectionsPerHost limits concurrent connections to each upstream host
|
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
|
||||||
MaxConnectionsPerHost int
|
MaxConnectionsPerHost int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultFetcherConfig returns sensible defaults.
|
// DefaultConfig returns a Config with sensible defaults.
|
||||||
func DefaultFetcherConfig() *FetcherConfig {
|
func DefaultConfig() *Config {
|
||||||
return &FetcherConfig{
|
return &Config{
|
||||||
Timeout: DefaultFetchTimeout,
|
Timeout: DefaultFetchTimeout,
|
||||||
MaxResponseSize: DefaultMaxResponseSize,
|
MaxResponseSize: DefaultMaxResponseSize,
|
||||||
UserAgent: "pixa/1.0",
|
UserAgent: "pixa/1.0",
|
||||||
@@ -72,18 +104,18 @@ func DefaultFetcherConfig() *FetcherConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPFetcher implements the Fetcher interface with SSRF protection.
|
// HTTPFetcher implements Fetcher with SSRF protection and per-host connection limits.
|
||||||
type HTTPFetcher struct {
|
type HTTPFetcher struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
config *FetcherConfig
|
config *Config
|
||||||
hostSems map[string]chan struct{} // per-host semaphores
|
hostSems map[string]chan struct{} // per-host semaphores
|
||||||
hostSemMu sync.Mutex // protects hostSems map
|
hostSemMu sync.Mutex // protects hostSems map
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPFetcher creates a new fetcher with SSRF protection.
|
// New creates a new HTTPFetcher with SSRF protection.
|
||||||
func NewHTTPFetcher(config *FetcherConfig) *HTTPFetcher {
|
func New(config *Config) *HTTPFetcher {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
config = DefaultFetcherConfig()
|
config = DefaultConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create transport with SSRF-safe dialer
|
// Create transport with SSRF-safe dialer
|
||||||
@@ -250,7 +282,7 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAllowedContentType checks if the content type is in the whitelist.
|
// isAllowedContentType checks if the content type is in the allow list.
|
||||||
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
|
||||||
// Extract the MIME type without parameters
|
// Extract the MIME type without parameters
|
||||||
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||||
329
internal/httpfetcher/httpfetcher_test.go
Normal file
329
internal/httpfetcher/httpfetcher_test.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
package httpfetcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
if cfg.Timeout != DefaultFetchTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", cfg.Timeout, DefaultFetchTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxResponseSize != DefaultMaxResponseSize {
|
||||||
|
t.Errorf("MaxResponseSize = %d, want %d", cfg.MaxResponseSize, DefaultMaxResponseSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MaxConnectionsPerHost != DefaultMaxConnectionsPerHost {
|
||||||
|
t.Errorf("MaxConnectionsPerHost = %d, want %d",
|
||||||
|
cfg.MaxConnectionsPerHost, DefaultMaxConnectionsPerHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AllowHTTP {
|
||||||
|
t.Error("AllowHTTP should default to false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AllowedContentTypes) == 0 {
|
||||||
|
t.Error("AllowedContentTypes should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWithNilConfigUsesDefaults(t *testing.T) {
|
||||||
|
f := New(nil)
|
||||||
|
|
||||||
|
if f == nil {
|
||||||
|
t.Fatal("New(nil) returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.config == nil {
|
||||||
|
t.Fatal("config should be populated from DefaultConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.config.Timeout != DefaultFetchTimeout {
|
||||||
|
t.Errorf("Timeout = %v, want %v", f.config.Timeout, DefaultFetchTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAllowedContentType(t *testing.T) {
|
||||||
|
f := New(DefaultConfig())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
contentType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"image/jpeg", true},
|
||||||
|
{"image/png", true},
|
||||||
|
{"image/webp", true},
|
||||||
|
{"image/jpeg; charset=utf-8", true},
|
||||||
|
{"IMAGE/JPEG", true},
|
||||||
|
{"text/html", false},
|
||||||
|
{"application/octet-stream", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.contentType, func(t *testing.T) {
|
||||||
|
got := f.isAllowedContentType(tc.contentType)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/path", "example.com"},
|
||||||
|
{"http://example.com:8080/path", "example.com:8080"},
|
||||||
|
{"https://example.com", "example.com"},
|
||||||
|
{"https://example.com?q=1", "example.com"},
|
||||||
|
{"example.com/path", "example.com"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.url, func(t *testing.T) {
|
||||||
|
got := extractHost(tc.url)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsLocalhost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"localhost", true},
|
||||||
|
{"LOCALHOST", true},
|
||||||
|
{"127.0.0.1", true},
|
||||||
|
{"::1", true},
|
||||||
|
{"[::1]", true},
|
||||||
|
{"foo.localhost", true},
|
||||||
|
{"foo.local", true},
|
||||||
|
{"example.com", false},
|
||||||
|
{"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.host, func(t *testing.T) {
|
||||||
|
got := isLocalhost(tc.host)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
ip string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"127.0.0.1", true}, // loopback
|
||||||
|
{"10.0.0.1", true}, // private
|
||||||
|
{"192.168.1.1", true}, // private
|
||||||
|
{"172.16.0.1", true}, // private
|
||||||
|
{"169.254.1.1", true}, // link-local
|
||||||
|
{"0.0.0.0", true}, // unspecified
|
||||||
|
{"224.0.0.1", true}, // multicast
|
||||||
|
{"::1", true}, // IPv6 loopback
|
||||||
|
{"fe80::1", true}, // IPv6 link-local
|
||||||
|
{"8.8.8.8", false}, // public
|
||||||
|
{"2001:4860:4860::8888", false}, // public IPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.ip, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tc.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", tc.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := isPrivateIP(ip)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPrivateIP(nil) {
|
||||||
|
t.Error("isPrivateIP(nil) should return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
|
||||||
|
err := validateURL("http://example.com/path", false)
|
||||||
|
if !errors.Is(err, ErrUnsupportedScheme) {
|
||||||
|
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
|
||||||
|
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
|
||||||
|
err := validateURL("http://nonexistent.invalid/path", true)
|
||||||
|
// We expect a host resolution error, not ErrUnsupportedScheme.
|
||||||
|
if errors.Is(err, ErrUnsupportedScheme) {
|
||||||
|
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_RejectsLocalhost(t *testing.T) {
|
||||||
|
err := validateURL("https://localhost/path", false)
|
||||||
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
|
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURL_EmptyHost(t *testing.T) {
|
||||||
|
err := validateURL("https:///path", false)
|
||||||
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
|
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_FetchesFile(t *testing.T) {
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")},
|
||||||
|
}
|
||||||
|
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fetch() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = result.Content.Close() }()
|
||||||
|
|
||||||
|
if result.ContentType != "image/jpeg" {
|
||||||
|
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(result.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "fake-jpeg-data" {
|
||||||
|
t.Errorf("Content = %q, want %q", string(data), "fake-jpeg-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ContentLength != int64(len("fake-jpeg-data")) {
|
||||||
|
t.Errorf("ContentLength = %d, want %d", result.ContentLength, len("fake-jpeg-data"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
|
||||||
|
mockFS := fstest.MapFS{}
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
|
||||||
|
if !errors.Is(err, ErrUpstreamError) {
|
||||||
|
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
|
||||||
|
mockFS := fstest.MapFS{
|
||||||
|
"example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")},
|
||||||
|
}
|
||||||
|
m := NewMock(mockFS)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("Fetch() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectContentTypeFromPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"foo/bar.jpg", "image/jpeg"},
|
||||||
|
{"foo/bar.JPG", "image/jpeg"},
|
||||||
|
{"foo/bar.jpeg", "image/jpeg"},
|
||||||
|
{"foo/bar.png", "image/png"},
|
||||||
|
{"foo/bar.gif", "image/gif"},
|
||||||
|
{"foo/bar.webp", "image/webp"},
|
||||||
|
{"foo/bar.avif", "image/avif"},
|
||||||
|
{"foo/bar.svg", "image/svg+xml"},
|
||||||
|
{"foo/bar.bin", "application/octet-stream"},
|
||||||
|
{"foo/bar", "application/octet-stream"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
got := detectContentTypeFromPath(tc.path)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitedReader_EnforcesLimit(t *testing.T) {
|
||||||
|
src := make([]byte, 100)
|
||||||
|
r := &limitedReader{
|
||||||
|
reader: &byteReader{data: src},
|
||||||
|
remaining: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 100)
|
||||||
|
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Read error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n > 50 {
|
||||||
|
t.Errorf("read %d bytes, should be capped at 50", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain until limit is exhausted.
|
||||||
|
total := n
|
||||||
|
for total < 50 {
|
||||||
|
nn, err := r.Read(buf)
|
||||||
|
total += nn
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("during drain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the limit is exhausted — next read should error.
|
||||||
|
_, err = r.Read(buf)
|
||||||
|
if !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Errorf("exhausted Read error = %v, want ErrResponseTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteReader is a minimal io.Reader over a byte slice for testing.
|
||||||
|
type byteReader struct {
|
||||||
|
data []byte
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *byteReader) Read(p []byte) (int, error) {
|
||||||
|
if r.pos >= len(r.data) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
n := copy(p, r.data[r.pos:])
|
||||||
|
r.pos += n
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package httpfetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockFetcher implements the Fetcher interface using an embedded filesystem.
|
// MockFetcher implements Fetcher using an embedded filesystem.
|
||||||
// Files are organized as: hostname/path/to/file.ext
|
// Files are organized as: hostname/path/to/file.ext
|
||||||
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg
|
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
|
||||||
type MockFetcher struct {
|
type MockFetcher struct {
|
||||||
fs fs.FS
|
fs fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMockFetcher creates a new mock fetcher backed by the given filesystem.
|
// NewMock creates a new mock fetcher backed by the given filesystem.
|
||||||
func NewMockFetcher(fsys fs.FS) *MockFetcher {
|
func NewMock(fsys fs.FS) *MockFetcher {
|
||||||
return &MockFetcher{fs: fsys}
|
return &MockFetcher{fs: fsys}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package imgcache
|
// Package imageprocessor provides image format conversion and resizing using libvips.
|
||||||
|
package imageprocessor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -22,38 +23,133 @@ func initVips() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format represents supported output image formats.
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
// Supported image output formats.
|
||||||
|
const (
|
||||||
|
FormatOriginal Format = "orig"
|
||||||
|
FormatJPEG Format = "jpeg"
|
||||||
|
FormatPNG Format = "png"
|
||||||
|
FormatWebP Format = "webp"
|
||||||
|
FormatAVIF Format = "avif"
|
||||||
|
FormatGIF Format = "gif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FitMode represents how to fit an image into requested dimensions.
|
||||||
|
type FitMode string
|
||||||
|
|
||||||
|
// Supported image fit modes.
|
||||||
|
const (
|
||||||
|
FitCover FitMode = "cover"
|
||||||
|
FitContain FitMode = "contain"
|
||||||
|
FitFill FitMode = "fill"
|
||||||
|
FitInside FitMode = "inside"
|
||||||
|
FitOutside FitMode = "outside"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
|
||||||
|
var ErrInvalidFitMode = errors.New("invalid fit mode")
|
||||||
|
|
||||||
|
// Size represents requested image dimensions.
|
||||||
|
type Size struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request holds the parameters for image processing.
|
||||||
|
type Request struct {
|
||||||
|
Size Size
|
||||||
|
Format Format
|
||||||
|
Quality int
|
||||||
|
FitMode FitMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result contains the output of image processing.
|
||||||
|
type Result struct {
|
||||||
|
// Content is the processed image data.
|
||||||
|
Content io.ReadCloser
|
||||||
|
// ContentLength is the size in bytes.
|
||||||
|
ContentLength int64
|
||||||
|
// ContentType is the MIME type of the output.
|
||||||
|
ContentType string
|
||||||
|
// Width is the output image width.
|
||||||
|
Width int
|
||||||
|
// Height is the output image height.
|
||||||
|
Height int
|
||||||
|
// InputWidth is the original image width before processing.
|
||||||
|
InputWidth int
|
||||||
|
// InputHeight is the original image height before processing.
|
||||||
|
InputHeight int
|
||||||
|
// InputFormat is the detected input format (e.g., "jpeg", "png").
|
||||||
|
InputFormat string
|
||||||
|
}
|
||||||
|
|
||||||
// MaxInputDimension is the maximum allowed width or height for input images.
|
// MaxInputDimension is the maximum allowed width or height for input images.
|
||||||
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
// Images larger than this are rejected to prevent DoS via decompression bombs.
|
||||||
const MaxInputDimension = 8192
|
const MaxInputDimension = 8192
|
||||||
|
|
||||||
|
// DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB).
|
||||||
|
// This matches the default upstream fetcher limit.
|
||||||
|
const DefaultMaxInputBytes = 50 << 20
|
||||||
|
|
||||||
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
|
||||||
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
|
||||||
|
|
||||||
|
// ErrInputDataTooLarge is returned when the raw input data exceeds the configured byte limit.
|
||||||
|
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
||||||
|
|
||||||
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
|
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
|
||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
// ImageProcessor implements the Processor interface using libvips via govips.
|
// ImageProcessor implements image transformation using libvips via govips.
|
||||||
type ImageProcessor struct{}
|
type ImageProcessor struct {
|
||||||
|
maxInputBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
// NewImageProcessor creates a new image processor.
|
// Params holds configuration for creating an ImageProcessor.
|
||||||
func NewImageProcessor() *ImageProcessor {
|
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
|
||||||
|
type Params struct {
|
||||||
|
// MaxInputBytes is the maximum allowed input size in bytes.
|
||||||
|
// If <= 0, DefaultMaxInputBytes is used.
|
||||||
|
MaxInputBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new image processor with the given parameters.
|
||||||
|
// A zero-value Params{} uses sensible defaults.
|
||||||
|
func New(params Params) *ImageProcessor {
|
||||||
initVips()
|
initVips()
|
||||||
|
|
||||||
return &ImageProcessor{}
|
maxInputBytes := params.MaxInputBytes
|
||||||
|
if maxInputBytes <= 0 {
|
||||||
|
maxInputBytes = DefaultMaxInputBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ImageProcessor{
|
||||||
|
maxInputBytes: maxInputBytes,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process transforms an image according to the request.
|
// Process transforms an image according to the request.
|
||||||
func (p *ImageProcessor) Process(
|
func (p *ImageProcessor) Process(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
input io.Reader,
|
input io.Reader,
|
||||||
req *ImageRequest,
|
req *Request,
|
||||||
) (*ProcessResult, error) {
|
) (*Result, error) {
|
||||||
// Read input
|
// Read input with a size limit to prevent unbounded memory consumption.
|
||||||
data, err := io.ReadAll(input)
|
// We read at most maxInputBytes+1 so we can detect if the input exceeds
|
||||||
|
// the limit without consuming additional memory.
|
||||||
|
limited := io.LimitReader(input, p.maxInputBytes+1)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read input: %w", err)
|
return nil, fmt.Errorf("failed to read input: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > p.maxInputBytes {
|
||||||
|
return nil, ErrInputDataTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
// Decode image
|
// Decode image
|
||||||
img, err := vips.NewImageFromBuffer(data)
|
img, err := vips.NewImageFromBuffer(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,10 +205,10 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ProcessResult{
|
return &Result{
|
||||||
Content: io.NopCloser(bytes.NewReader(output)),
|
Content: io.NopCloser(bytes.NewReader(output)),
|
||||||
ContentLength: int64(len(output)),
|
ContentLength: int64(len(output)),
|
||||||
ContentType: ImageFormatToMIME(outputFormat),
|
ContentType: FormatToMIME(outputFormat),
|
||||||
Width: img.Width(),
|
Width: img.Width(),
|
||||||
Height: img.Height(),
|
Height: img.Height(),
|
||||||
InputWidth: origWidth,
|
InputWidth: origWidth,
|
||||||
@@ -124,17 +220,17 @@ func (p *ImageProcessor) Process(
|
|||||||
// SupportedInputFormats returns MIME types this processor can read.
|
// SupportedInputFormats returns MIME types this processor can read.
|
||||||
func (p *ImageProcessor) SupportedInputFormats() []string {
|
func (p *ImageProcessor) SupportedInputFormats() []string {
|
||||||
return []string{
|
return []string{
|
||||||
string(MIMETypeJPEG),
|
"image/jpeg",
|
||||||
string(MIMETypePNG),
|
"image/png",
|
||||||
string(MIMETypeGIF),
|
"image/gif",
|
||||||
string(MIMETypeWebP),
|
"image/webp",
|
||||||
string(MIMETypeAVIF),
|
"image/avif",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportedOutputFormats returns formats this processor can write.
|
// SupportedOutputFormats returns formats this processor can write.
|
||||||
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
func (p *ImageProcessor) SupportedOutputFormats() []Format {
|
||||||
return []ImageFormat{
|
return []Format{
|
||||||
FormatJPEG,
|
FormatJPEG,
|
||||||
FormatPNG,
|
FormatPNG,
|
||||||
FormatGIF,
|
FormatGIF,
|
||||||
@@ -143,6 +239,24 @@ func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatToMIME converts a Format to its MIME type string.
|
||||||
|
func FormatToMIME(format Format) string {
|
||||||
|
switch format {
|
||||||
|
case FormatJPEG:
|
||||||
|
return "image/jpeg"
|
||||||
|
case FormatPNG:
|
||||||
|
return "image/png"
|
||||||
|
case FormatWebP:
|
||||||
|
return "image/webp"
|
||||||
|
case FormatGIF:
|
||||||
|
return "image/gif"
|
||||||
|
case FormatAVIF:
|
||||||
|
return "image/avif"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// detectFormat returns the format string from a vips image.
|
// detectFormat returns the format string from a vips image.
|
||||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
||||||
format := img.Format()
|
format := img.Format()
|
||||||
@@ -171,7 +285,6 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
|
|
||||||
case FitContain:
|
case FitContain:
|
||||||
// Resize to fit within dimensions, maintaining aspect ratio
|
// Resize to fit within dimensions, maintaining aspect ratio
|
||||||
// Calculate target dimensions maintaining aspect ratio
|
|
||||||
imgW, imgH := img.Width(), img.Height()
|
imgW, imgH := img.Width(), img.Height()
|
||||||
scaleW := float64(width) / float64(imgW)
|
scaleW := float64(width) / float64(imgW)
|
||||||
scaleH := float64(height) / float64(imgH)
|
scaleH := float64(height) / float64(imgH)
|
||||||
@@ -182,7 +295,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||||
|
|
||||||
case FitFill:
|
case FitFill:
|
||||||
// Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force
|
// Resize to exact dimensions (may distort)
|
||||||
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
|
||||||
|
|
||||||
case FitInside:
|
case FitInside:
|
||||||
@@ -218,7 +331,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
|
|||||||
const defaultQuality = 85
|
const defaultQuality = 85
|
||||||
|
|
||||||
// encode encodes an image to the specified format.
|
// encode encodes an image to the specified format.
|
||||||
func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) {
|
func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) {
|
||||||
if quality <= 0 {
|
if quality <= 0 {
|
||||||
quality = defaultQuality
|
quality = defaultQuality
|
||||||
}
|
}
|
||||||
@@ -266,8 +379,8 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality
|
|||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatFromString converts a format string to ImageFormat.
|
// formatFromString converts a format string to Format.
|
||||||
func (p *ImageProcessor) formatFromString(format string) ImageFormat {
|
func (p *ImageProcessor) formatFromString(format string) Format {
|
||||||
switch format {
|
switch format {
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
return FormatJPEG
|
return FormatJPEG
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package imageprocessor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -70,13 +70,36 @@ func createTestPNG(t *testing.T, width, height int) []byte {
|
|||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectMIME is a minimal magic-byte detector for test assertions.
|
||||||
|
func detectMIME(data []byte) string {
|
||||||
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
if len(data) >= 4 && string(data[:4]) == "GIF8" {
|
||||||
|
return "image/gif"
|
||||||
|
}
|
||||||
|
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||||
|
return "image/webp"
|
||||||
|
}
|
||||||
|
if len(data) >= 12 && string(data[4:8]) == "ftyp" {
|
||||||
|
brand := string(data[8:12])
|
||||||
|
if brand == "avif" || brand == "avis" {
|
||||||
|
return "image/avif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 800, 600)
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 400, Height: 300},
|
Size: Size{Width: 400, Height: 300},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -107,23 +130,19 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
|
|||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != "image/jpeg" {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
}
|
|
||||||
|
|
||||||
if mime != MIMETypeJPEG {
|
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
@@ -140,23 +159,19 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
|
|||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != "image/png" {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want image/png", mime)
|
||||||
}
|
|
||||||
|
|
||||||
if mime != MIMETypePNG {
|
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypePNG)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_OriginalSize(t *testing.T) {
|
func TestImageProcessor_OriginalSize(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 640, 480)
|
input := createTestJPEG(t, 640, 480)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 0, Height: 0}, // Original size
|
Size: Size{Width: 0, Height: 0}, // Original size
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -179,14 +194,14 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_FitContain(t *testing.T) {
|
func TestImageProcessor_FitContain(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
// 800x400 image (2:1 aspect) into 400x400 box with contain
|
||||||
// Should result in 400x200 (maintaining aspect ratio)
|
// Should result in 400x200 (maintaining aspect ratio)
|
||||||
input := createTestJPEG(t, 800, 400)
|
input := createTestJPEG(t, 800, 400)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 400, Height: 400},
|
Size: Size{Width: 400, Height: 400},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -206,14 +221,14 @@ func TestImageProcessor_FitContain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 800x600 image, request width=400 height=0
|
// 800x600 image, request width=400 height=0
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
input := createTestJPEG(t, 800, 600)
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 400, Height: 0},
|
Size: Size{Width: 400, Height: 0},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -236,14 +251,14 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// 800x600 image, request width=0 height=300
|
// 800x600 image, request width=0 height=300
|
||||||
// Should scale proportionally to 400x300
|
// Should scale proportionally to 400x300
|
||||||
input := createTestJPEG(t, 800, 600)
|
input := createTestJPEG(t, 800, 600)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 0, Height: 300},
|
Size: Size{Width: 0, Height: 300},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -266,12 +281,12 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
func TestImageProcessor_ProcessPNG(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestPNG(t, 400, 300)
|
input := createTestPNG(t, 400, 300)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 200, Height: 150},
|
Size: Size{Width: 200, Height: 150},
|
||||||
Format: FormatPNG,
|
Format: FormatPNG,
|
||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
@@ -292,13 +307,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_ImplementsInterface(t *testing.T) {
|
|
||||||
// Verify ImageProcessor implements Processor interface
|
|
||||||
var _ Processor = (*ImageProcessor)(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
func TestImageProcessor_SupportedFormats(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
|
|
||||||
inputFormats := proc.SupportedInputFormats()
|
inputFormats := proc.SupportedInputFormats()
|
||||||
if len(inputFormats) == 0 {
|
if len(inputFormats) == 0 {
|
||||||
@@ -312,14 +322,14 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create an image that exceeds MaxInputDimension (e.g., 10000x100)
|
// Create an image that exceeds MaxInputDimension (e.g., 10000x100)
|
||||||
// This should be rejected before processing to prevent DoS
|
// This should be rejected before processing to prevent DoS
|
||||||
input := createTestJPEG(t, 10000, 100)
|
input := createTestJPEG(t, 10000, 100)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -337,13 +347,13 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
|
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create an image with oversized height
|
// Create an image with oversized height
|
||||||
input := createTestJPEG(t, 100, 10000)
|
input := createTestJPEG(t, 100, 10000)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -361,14 +371,13 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create an image at exactly MaxInputDimension - should be accepted
|
// Create an image at exactly MaxInputDimension - should be accepted
|
||||||
// Using smaller dimensions to keep test fast
|
|
||||||
input := createTestJPEG(t, MaxInputDimension, 100)
|
input := createTestJPEG(t, MaxInputDimension, 100)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 100},
|
Size: Size{Width: 100, Height: 100},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -383,12 +392,12 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
func TestImageProcessor_EncodeWebP(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: Size{Width: 100, Height: 75},
|
||||||
Format: FormatWebP,
|
Format: FormatWebP,
|
||||||
Quality: 80,
|
Quality: 80,
|
||||||
@@ -407,13 +416,9 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
|
|||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != "image/webp" {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want image/webp", mime)
|
||||||
}
|
|
||||||
|
|
||||||
if mime != MIMETypeWebP {
|
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeWebP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify dimensions
|
// Verify dimensions
|
||||||
@@ -426,7 +431,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Load test AVIF file
|
// Load test AVIF file
|
||||||
@@ -436,7 +441,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request resize and convert to JPEG
|
// Request resize and convert to JPEG
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 2, Height: 2},
|
Size: Size{Width: 2, Height: 2},
|
||||||
Format: FormatJPEG,
|
Format: FormatJPEG,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -455,23 +460,84 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
|
|||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != "image/jpeg" {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want image/jpeg", mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
|
||||||
|
// Create a processor with a very small byte limit
|
||||||
|
const limit = 1024
|
||||||
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a valid JPEG that exceeds the byte limit
|
||||||
|
input := createTestJPEG(t, 800, 600) // will be well over 1 KiB
|
||||||
|
if int64(len(input)) <= limit {
|
||||||
|
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
if mime != MIMETypeJPEG {
|
req := &Request{
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
|
Size: Size{Width: 100, Height: 75},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Process() should reject input exceeding maxInputBytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != ErrInputDataTooLarge {
|
||||||
|
t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
|
||||||
|
// Create a small image and set limit well above its size
|
||||||
|
input := createTestJPEG(t, 10, 10)
|
||||||
|
limit := int64(len(input)) * 10 // 10× headroom
|
||||||
|
|
||||||
|
proc := New(Params{MaxInputBytes: limit})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Size: Size{Width: 10, Height: 10},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := proc.Process(ctx, bytes.NewReader(input), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Process() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
defer result.Content.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
|
||||||
|
// Passing 0 should use the default
|
||||||
|
proc := New(Params{})
|
||||||
|
if proc.maxInputBytes != DefaultMaxInputBytes {
|
||||||
|
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passing negative should also use the default
|
||||||
|
proc = New(Params{MaxInputBytes: -1})
|
||||||
|
if proc.maxInputBytes != DefaultMaxInputBytes {
|
||||||
|
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
||||||
proc := NewImageProcessor()
|
proc := New(Params{})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
input := createTestJPEG(t, 200, 150)
|
input := createTestJPEG(t, 200, 150)
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &Request{
|
||||||
Size: Size{Width: 100, Height: 75},
|
Size: Size{Width: 100, Height: 75},
|
||||||
Format: FormatAVIF,
|
Format: FormatAVIF,
|
||||||
Quality: 85,
|
Quality: 85,
|
||||||
@@ -490,13 +556,9 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
|
|||||||
t.Fatalf("failed to read result: %v", err)
|
t.Fatalf("failed to read result: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mime, err := DetectFormat(data)
|
mime := detectMIME(data)
|
||||||
if err != nil {
|
if mime != "image/avif" {
|
||||||
t.Fatalf("DetectFormat() error = %v", err)
|
t.Errorf("Output format = %v, want image/avif", mime)
|
||||||
}
|
|
||||||
|
|
||||||
if mime != MIMETypeAVIF {
|
|
||||||
t.Errorf("Output format = %v, want %v", mime, MIMETypeAVIF)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify dimensions
|
// Verify dimensions
|
||||||
BIN
internal/imageprocessor/testdata/red.avif
vendored
Normal file
BIN
internal/imageprocessor/testdata/red.avif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 B |
@@ -7,8 +7,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache errors.
|
// Cache errors.
|
||||||
@@ -25,6 +29,22 @@ type CacheConfig struct {
|
|||||||
StateDir string
|
StateDir string
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
NegativeTTL time.Duration
|
NegativeTTL time.Duration
|
||||||
|
|
||||||
|
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||||
|
// enforces. Zero means no limit is enforced (no eviction). The
|
||||||
|
// config layer supplies the computed default when the operator
|
||||||
|
// omits cache_max_bytes.
|
||||||
|
MaxBytes int64
|
||||||
|
|
||||||
|
// DisableDiskCache turns the disk cache off entirely: no cache
|
||||||
|
// directories are created, lookups always miss, stores are
|
||||||
|
// no-ops, and no eviction machinery runs. The config layer sets
|
||||||
|
// this when the operator configures cache_max_bytes: 0.
|
||||||
|
DisableDiskCache bool
|
||||||
|
|
||||||
|
// Logger receives accounting and eviction log output. A nil
|
||||||
|
// Logger means slog.Default().
|
||||||
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// variantMeta stores content type for fast cache hits without reading .meta file.
|
// variantMeta stores content type for fast cache hits without reading .meta file.
|
||||||
@@ -40,6 +60,19 @@ type Cache struct {
|
|||||||
variants *VariantStorage // processed variants by cache key
|
variants *VariantStorage // processed variants by cache key
|
||||||
srcMetadata *MetadataStorage // source metadata by host/path
|
srcMetadata *MetadataStorage // source metadata by host/path
|
||||||
config CacheConfig
|
config CacheConfig
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
// disabled means the disk cache is turned off entirely: lookups
|
||||||
|
// always miss, stores are no-ops, and no eviction runs.
|
||||||
|
disabled bool
|
||||||
|
|
||||||
|
// Eviction machinery. The channels are created in NewCache so
|
||||||
|
// stores can signal write pressure without racing StartEviction.
|
||||||
|
evictionPressure chan struct{}
|
||||||
|
evictionStop chan struct{}
|
||||||
|
evictionDone chan struct{}
|
||||||
|
evictionStarted bool
|
||||||
|
evictionStopOnce sync.Once
|
||||||
|
|
||||||
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files
|
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files
|
||||||
metaCache map[VariantKey]variantMeta
|
metaCache map[VariantKey]variantMeta
|
||||||
@@ -47,6 +80,26 @@ type Cache struct {
|
|||||||
|
|
||||||
// NewCache creates a new cache instance.
|
// NewCache creates a new cache instance.
|
||||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||||
|
log := config.Logger
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &Cache{
|
||||||
|
db: db,
|
||||||
|
config: config,
|
||||||
|
log: log,
|
||||||
|
disabled: config.DisableDiskCache,
|
||||||
|
evictionPressure: make(chan struct{}, 1),
|
||||||
|
evictionStop: make(chan struct{}),
|
||||||
|
evictionDone: make(chan struct{}),
|
||||||
|
metaCache: make(map[VariantKey]variantMeta),
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.disabled {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources"))
|
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
||||||
@@ -62,14 +115,11 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
|||||||
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Cache{
|
c.srcContent = srcContent
|
||||||
db: db,
|
c.variants = variants
|
||||||
srcContent: srcContent,
|
c.srcMetadata = srcMetadata
|
||||||
variants: variants,
|
|
||||||
srcMetadata: srcMetadata,
|
return c, nil
|
||||||
config: config,
|
|
||||||
metaCache: make(map[VariantKey]variantMeta),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupResult contains the result of a cache lookup.
|
// LookupResult contains the result of a cache lookup.
|
||||||
@@ -81,12 +131,15 @@ type LookupResult struct {
|
|||||||
CacheStatus CacheStatus
|
CacheStatus CacheStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if a processed variant exists on disk (no DB access for hits).
|
// Lookup checks if a processed variant exists on disk. Hits touch the
|
||||||
func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, error) {
|
// variant's LRU timestamp; a disabled cache always misses.
|
||||||
|
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
|
||||||
cacheKey := CacheKey(req)
|
cacheKey := CacheKey(req)
|
||||||
|
|
||||||
// Check variant storage directly - no DB needed for cache hits
|
// Check variant storage directly - no DB needed for cache hits
|
||||||
if c.variants.Exists(cacheKey) {
|
if !c.disabled && c.variants.Exists(cacheKey) {
|
||||||
|
c.touchVariant(ctx, cacheKey)
|
||||||
|
|
||||||
return &LookupResult{
|
return &LookupResult{
|
||||||
Hit: true,
|
Hit: true,
|
||||||
CacheKey: cacheKey,
|
CacheKey: cacheKey,
|
||||||
@@ -101,18 +154,53 @@ func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, err
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// touchVariant updates the LRU timestamp of a variant, best-effort:
|
||||||
|
// a failed touch only makes the entry look colder to eviction.
|
||||||
|
func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) {
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE cache_key = ?
|
||||||
|
`, string(cacheKey))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Debug("failed to touch variant LRU timestamp",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchSourceContent updates the LRU timestamp of a source content
|
||||||
|
// blob, best-effort: a failed touch only makes the blob look colder.
|
||||||
|
func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash) {
|
||||||
|
_, err := c.db.ExecContext(ctx, `
|
||||||
|
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE content_hash = ?
|
||||||
|
`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
c.log.Debug("failed to touch source content LRU timestamp",
|
||||||
|
"content_hash", contentHash, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetVariant returns a reader, size, and content type for a cached variant.
|
// GetVariant returns a reader, size, and content type for a cached variant.
|
||||||
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return nil, 0, "", ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
return c.variants.LoadWithMeta(cacheKey)
|
return c.variants.LoadWithMeta(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreSource stores fetched source content and metadata.
|
// StoreSource stores fetched source content and metadata. On a
|
||||||
|
// disabled cache it is a no-op returning an empty hash.
|
||||||
func (c *Cache) StoreSource(
|
func (c *Cache) StoreSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
content io.Reader,
|
content io.Reader,
|
||||||
result *FetchResult,
|
result *httpfetcher.FetchResult,
|
||||||
) (ContentHash, error) {
|
) (ContentHash, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// Store content
|
// Store content
|
||||||
contentHash, size, err := c.srcContent.Store(content)
|
contentHash, size, err := c.srcContent.Store(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,19 +257,52 @@ func (c *Cache) StoreSource(
|
|||||||
_ = err
|
_ = err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.notifyWritePressure()
|
||||||
|
|
||||||
return contentHash, nil
|
return contentHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreVariant stores a processed variant by its cache key.
|
// StoreVariant stores a processed variant by its cache key and records
|
||||||
|
// it in the size accounting. On a disabled cache it is a no-op. The
|
||||||
|
// accounting insert is best-effort (the startup reconciliation pass
|
||||||
|
// adopts any variant file that misses its accounting row).
|
||||||
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error {
|
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error {
|
||||||
_, err := c.variants.Store(cacheKey, content, contentType)
|
if c.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
size, err := c.variants.Store(cacheKey, content, contentType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.db.Exec(`
|
||||||
|
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(cache_key) DO UPDATE SET
|
||||||
|
size_bytes = excluded.size_bytes,
|
||||||
|
content_type = excluded.content_type,
|
||||||
|
last_accessed_at = CURRENT_TIMESTAMP
|
||||||
|
`, string(cacheKey), size, contentType)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("failed to record variant in size accounting",
|
||||||
|
"cache_key", cacheKey, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.notifyWritePressure()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupSource checks if we have cached source content for a request.
|
// LookupSource checks if we have cached source content for a request.
|
||||||
// Returns the content hash and content type if found, or empty values if not.
|
// Returns the content hash and content type if found, or empty values
|
||||||
|
// if not. Hits touch the blob's LRU timestamp; a disabled cache always
|
||||||
|
// reports no cached source.
|
||||||
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) {
|
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
var hashStr, contentType string
|
var hashStr, contentType string
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
err := c.db.QueryRowContext(ctx, `
|
||||||
@@ -204,6 +325,8 @@ func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHas
|
|||||||
return "", "", nil
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.touchSourceContent(ctx, contentHash)
|
||||||
|
|
||||||
return contentHash, contentType, nil
|
return contentHash, contentType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +399,10 @@ func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int
|
|||||||
|
|
||||||
// GetSourceContent returns a reader for cached source content by its hash.
|
// GetSourceContent returns a reader for cached source content by its hash.
|
||||||
func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) {
|
func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
return c.srcContent.Load(contentHash)
|
return c.srcContent.Load(contentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *sql.DB {
|
func setupTestDB(t *testing.T) *sql.DB {
|
||||||
@@ -152,7 +153,7 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
|
|
||||||
// Store source content
|
// Store source content
|
||||||
sourceContent := []byte("fake jpeg data")
|
sourceContent := []byte("fake jpeg data")
|
||||||
fetchResult := &FetchResult{
|
fetchResult := &httpfetcher.FetchResult{
|
||||||
ContentType: "image/jpeg",
|
ContentType: "image/jpeg",
|
||||||
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
|
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
|
||||||
}
|
}
|
||||||
|
|||||||
741
internal/imgcache/eviction.go
Normal file
741
internal/imgcache/eviction.go
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultEvictionInterval is how often the background evictor checks
|
||||||
|
// cache usage against the configured limit, in addition to the
|
||||||
|
// write-pressure wakeups triggered by stores.
|
||||||
|
const DefaultEvictionInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// evictionBatchSize is how many LRU candidates of each class (variants
|
||||||
|
// and source blobs) one eviction pass fetches from the database.
|
||||||
|
const evictionBatchSize = 100
|
||||||
|
|
||||||
|
// staleTempFileAge is how old an orphaned temp file (left behind by a
|
||||||
|
// crashed write) must be before reconciliation removes it. Fresh temp
|
||||||
|
// files may still belong to an in-flight store.
|
||||||
|
const staleTempFileAge = time.Hour
|
||||||
|
|
||||||
|
// sqliteTimestampLayout matches SQLite's CURRENT_TIMESTAMP format, so
|
||||||
|
// timestamps written by reconciliation order correctly against ones
|
||||||
|
// written by the hot path.
|
||||||
|
const sqliteTimestampLayout = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
|
// tempFilePrefix is the prefix os.CreateTemp uses for in-flight cache
|
||||||
|
// writes (".tmp-*" patterns in the storage layer).
|
||||||
|
const tempFilePrefix = ".tmp-"
|
||||||
|
|
||||||
|
// variantMetaSuffix is the sidecar suffix VariantStorage writes next
|
||||||
|
// to each variant file.
|
||||||
|
const variantMetaSuffix = ".meta"
|
||||||
|
|
||||||
|
// fallbackContentType is recorded when a reconciled variant file has
|
||||||
|
// no readable .meta sidecar.
|
||||||
|
const fallbackContentType = "application/octet-stream"
|
||||||
|
|
||||||
|
// UsageBytes returns the total number of bytes of cache content
|
||||||
|
// tracked in the database (source content blobs plus processed
|
||||||
|
// variants). It never scans the cache directories.
|
||||||
|
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
|
||||||
|
if c.disabled {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
|
||||||
|
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
|
||||||
|
`).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionCandidate is one LRU eviction victim candidate: either a
|
||||||
|
// processed variant (isVariant true, identified by cacheKey) or a
|
||||||
|
// source content blob (identified by contentHash).
|
||||||
|
type evictionCandidate struct {
|
||||||
|
isVariant bool
|
||||||
|
cacheKey VariantKey
|
||||||
|
contentHash ContentHash
|
||||||
|
sizeBytes int64
|
||||||
|
lastAccessedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictToLimit evicts least-recently-used cache entries until total
|
||||||
|
// tracked usage is at or below the configured MaxBytes limit. It is a
|
||||||
|
// no-op when the cache is disabled or no limit is configured.
|
||||||
|
func (c *Cache) EvictToLimit(ctx context.Context) error {
|
||||||
|
if c.disabled || c.config.MaxBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
usage, err := c.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage <= c.config.MaxBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
freed, err := c.evictBatch(ctx, usage-c.config.MaxBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if freed == 0 {
|
||||||
|
c.log.Warn("cache eviction made no progress",
|
||||||
|
"usage_bytes", usage,
|
||||||
|
"cache_max_bytes", c.config.MaxBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("evicted cache content",
|
||||||
|
"freed_bytes", freed,
|
||||||
|
"usage_bytes", usage-freed,
|
||||||
|
"cache_max_bytes", c.config.MaxBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictBatch fetches one batch of LRU candidates across variants and
|
||||||
|
// source blobs and evicts them oldest-first until excessBytes are
|
||||||
|
// freed or the batch is exhausted. It returns the bytes freed.
|
||||||
|
func (c *Cache) evictBatch(ctx context.Context, excessBytes int64) (int64, error) {
|
||||||
|
candidates, err := c.evictionCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var freed int64
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if freed >= excessBytes {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.evictCandidate(ctx, candidate); err != nil {
|
||||||
|
c.log.Warn("failed to evict cache entry",
|
||||||
|
"cache_key", candidate.cacheKey,
|
||||||
|
"content_hash", candidate.contentHash,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
freed += candidate.sizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return freed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictCandidate removes a single eviction victim.
|
||||||
|
func (c *Cache) evictCandidate(ctx context.Context, candidate evictionCandidate) error {
|
||||||
|
if candidate.isVariant {
|
||||||
|
return c.evictVariant(ctx, candidate.cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.evictSourceBlob(ctx, candidate.contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionCandidates returns up to evictionBatchSize variants and
|
||||||
|
// evictionBatchSize source blobs, merged into a single list ordered by
|
||||||
|
// last access time (oldest first).
|
||||||
|
func (c *Cache) evictionCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
variants, err := c.variantCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := c.sourceCandidates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the two lists, each already sorted oldest-first. SQLite
|
||||||
|
// CURRENT_TIMESTAMP strings compare correctly lexicographically.
|
||||||
|
merged := make([]evictionCandidate, 0, len(variants)+len(sources))
|
||||||
|
|
||||||
|
for len(variants) > 0 && len(sources) > 0 {
|
||||||
|
if variants[0].lastAccessedAt <= sources[0].lastAccessedAt {
|
||||||
|
merged = append(merged, variants[0])
|
||||||
|
variants = variants[1:]
|
||||||
|
} else {
|
||||||
|
merged = append(merged, sources[0])
|
||||||
|
sources = sources[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = append(merged, variants...)
|
||||||
|
merged = append(merged, sources...)
|
||||||
|
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantCandidates returns the least recently used variants.
|
||||||
|
func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT cache_key, size_bytes, last_accessed_at
|
||||||
|
FROM variant_content
|
||||||
|
ORDER BY last_accessed_at ASC, cache_key ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, evictionBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query variant eviction candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var candidates []evictionCandidate
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
candidate := evictionCandidate{isVariant: true}
|
||||||
|
|
||||||
|
var key string
|
||||||
|
if err := rows.Scan(&key, &candidate.sizeBytes, &candidate.lastAccessedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.cacheKey = VariantKey(key)
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("variant candidate iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceCandidates returns the least recently used source blobs. Rows
|
||||||
|
// written before the LRU column existed fall back to fetched_at.
|
||||||
|
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT content_hash, size_bytes,
|
||||||
|
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
|
||||||
|
FROM source_content
|
||||||
|
ORDER BY lru ASC, content_hash ASC
|
||||||
|
LIMIT ?
|
||||||
|
`, evictionBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var candidates []evictionCandidate
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var candidate evictionCandidate
|
||||||
|
|
||||||
|
var hash string
|
||||||
|
if err := rows.Scan(&hash, &candidate.sizeBytes, &candidate.lastAccessedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.contentHash = ContentHash(hash)
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("source candidate iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictVariant removes one variant: accounting row first, then the
|
||||||
|
// content and .meta files, so the database never references a deleted
|
||||||
|
// file.
|
||||||
|
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
||||||
|
_, err := c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.variants.DeleteWithMeta(cacheKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceReference identifies one source_metadata row's JSON sidecar.
|
||||||
|
type sourceReference struct {
|
||||||
|
host string
|
||||||
|
pathHash PathHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictSourceBlob removes one source content blob. All source_metadata
|
||||||
|
// rows referencing the blob are deleted together with its
|
||||||
|
// source_content row in a single transaction BEFORE the file is
|
||||||
|
// unlinked: a blob referenced by multiple source paths is only ever
|
||||||
|
// removed together with all of its references, and database rows never
|
||||||
|
// point at deleted files. The JSON metadata sidecars for the removed
|
||||||
|
// rows are deleted afterwards.
|
||||||
|
func (c *Cache) evictSourceBlob(ctx context.Context, contentHash ContentHash) error {
|
||||||
|
references, err := c.sourceReferences(ctx, contentHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := c.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin eviction transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash)); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete source metadata rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_content WHERE content_hash = ?`, string(contentHash)); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete source content row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit eviction transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only after the rows are gone may the files be removed.
|
||||||
|
for _, reference := range references {
|
||||||
|
if err := c.srcMetadata.Delete(reference.host, reference.pathHash); err != nil {
|
||||||
|
c.log.Warn("failed to delete metadata sidecar",
|
||||||
|
"host", reference.host, "path_hash", reference.pathHash, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.srcContent.Delete(contentHash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceReferences lists the metadata sidecar locations of every
|
||||||
|
// source_metadata row referencing the given blob.
|
||||||
|
func (c *Cache) sourceReferences(
|
||||||
|
ctx context.Context, contentHash ContentHash,
|
||||||
|
) ([]sourceReference, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `
|
||||||
|
SELECT source_host, path_hash FROM source_metadata WHERE content_hash = ?
|
||||||
|
`, string(contentHash))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query source references: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var references []sourceReference
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var reference sourceReference
|
||||||
|
|
||||||
|
var pathHash string
|
||||||
|
if err := rows.Scan(&reference.host, &pathHash); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan source reference: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reference.pathHash = PathHash(pathHash)
|
||||||
|
references = append(references, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("source reference iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return references, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyWritePressure wakes the background evictor after a store, so
|
||||||
|
// eviction under write pressure happens promptly without blocking the
|
||||||
|
// storing request. The notification channel has capacity one and drops
|
||||||
|
// when a wakeup is already pending.
|
||||||
|
func (c *Cache) notifyWritePressure() {
|
||||||
|
if c.disabled || c.config.MaxBytes <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c.evictionPressure <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartEviction launches the background eviction goroutine, which
|
||||||
|
// reconciles the database accounting with the cache directories once
|
||||||
|
// at startup and then evicts to the configured limit on the given
|
||||||
|
// periodic interval and on write-pressure notifications. It is a
|
||||||
|
// no-op on a disabled cache or when already started.
|
||||||
|
func (c *Cache) StartEviction(interval time.Duration) {
|
||||||
|
if c.disabled || c.evictionStarted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.evictionStarted = true
|
||||||
|
|
||||||
|
go c.evictionLoop(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopEviction stops the background eviction goroutine and waits for
|
||||||
|
// it to exit. It is safe to call when eviction was never started, and
|
||||||
|
// safe to call more than once.
|
||||||
|
func (c *Cache) StopEviction() {
|
||||||
|
if !c.evictionStarted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.evictionStopOnce.Do(func() {
|
||||||
|
close(c.evictionStop)
|
||||||
|
<-c.evictionDone
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictionLoop is the body of the background eviction goroutine.
|
||||||
|
func (c *Cache) evictionLoop(interval time.Duration) {
|
||||||
|
defer close(c.evictionDone)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := c.reconcileAccounting(ctx); err != nil {
|
||||||
|
c.log.Warn("cache accounting reconciliation failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.runEvictionPass(ctx)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.evictionStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
case <-c.evictionPressure:
|
||||||
|
}
|
||||||
|
|
||||||
|
c.runEvictionPass(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEvictionPass runs one eviction pass, logging failures instead of
|
||||||
|
// propagating them (the loop must keep running).
|
||||||
|
func (c *Cache) runEvictionPass(ctx context.Context) {
|
||||||
|
if err := c.EvictToLimit(ctx); err != nil {
|
||||||
|
c.log.Warn("cache eviction pass failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileAccounting synchronizes the database size accounting with
|
||||||
|
// the actual contents of the cache directories. It runs once when the
|
||||||
|
// background evictor starts, off the request hot path: it adopts
|
||||||
|
// variant files that predate the accounting table, drops accounting
|
||||||
|
// rows whose files are missing, removes source blob files the database
|
||||||
|
// does not know (and rows whose files are gone), and sweeps stale temp
|
||||||
|
// files left behind by crashed writes.
|
||||||
|
func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
||||||
|
if c.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.reconcileVariantFiles(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.reconcileVariantRows(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.reconcileSourceFiles(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.reconcileSourceRows(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileVariantFiles walks the variant storage directory, adopting
|
||||||
|
// files without accounting rows and sweeping stale temp files.
|
||||||
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
||||||
|
return filepath.WalkDir(c.variants.baseDir, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
if strings.HasPrefix(name, tempFilePrefix) {
|
||||||
|
c.sweepStaleTempFile(path, entry)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(name, variantMetaSuffix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptVariantFile inserts an accounting row for a variant file that
|
||||||
|
// has none, using the file's size and modification time.
|
||||||
|
func (c *Cache) adoptVariantFile(
|
||||||
|
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
||||||
|
) error {
|
||||||
|
var rowExists int
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
||||||
|
).Scan(&rowExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat variant file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
||||||
|
contentType := c.variantContentTypeFromSidecar(path)
|
||||||
|
|
||||||
|
_, err = c.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO variant_content
|
||||||
|
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, string(cacheKey), info.Size(), contentType, modTime, modTime)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("adopted untracked variant file into size accounting",
|
||||||
|
"cache_key", cacheKey, "size_bytes", info.Size())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantContentTypeFromSidecar reads the content type from a variant
|
||||||
|
// .meta sidecar, falling back to application/octet-stream.
|
||||||
|
func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
|
||||||
|
metaData, err := os.ReadFile(variantPath + variantMetaSuffix) //nolint:gosec // path from cache walk
|
||||||
|
if err != nil {
|
||||||
|
return fallbackContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta VariantMeta
|
||||||
|
if json.Unmarshal(metaData, &meta) != nil || meta.ContentType == "" {
|
||||||
|
return fallbackContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta.ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileVariantRows drops accounting rows whose variant files are
|
||||||
|
// missing, so the database never references deleted content.
|
||||||
|
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
|
||||||
|
keys, err := c.allVariantKeys(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if c.variants.Exists(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM variant_content WHERE cache_key = ?`, string(key)); err != nil {
|
||||||
|
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allVariantKeys returns every tracked variant cache key.
|
||||||
|
func (c *Cache) allVariantKeys(ctx context.Context) ([]VariantKey, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `SELECT cache_key FROM variant_content`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query variant keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var keys []VariantKey
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
if err := rows.Scan(&key); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan variant key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = append(keys, VariantKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("variant key iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileSourceFiles walks the source content directory, removing
|
||||||
|
// blob files the database does not track (they are unreachable: source
|
||||||
|
// lookups always go through source_metadata) and sweeping stale temp
|
||||||
|
// files.
|
||||||
|
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
||||||
|
return filepath.WalkDir(c.srcContent.baseDir, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil || entry.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
if strings.HasPrefix(name, tempFilePrefix) {
|
||||||
|
c.sweepStaleTempFile(path, entry)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||||
|
// source_content row. Any source_metadata rows referencing the hash
|
||||||
|
// are removed first so no row ever points at a deleted file.
|
||||||
|
func (c *Cache) removeUntrackedSourceFile(
|
||||||
|
ctx context.Context, path string, contentHash ContentHash,
|
||||||
|
) error {
|
||||||
|
var rowExists int
|
||||||
|
|
||||||
|
err := c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(contentHash),
|
||||||
|
).Scan(&rowExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check source content row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash)); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G703: path comes from walking our own cache directory
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to remove untracked source file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("removed untracked source content file", "content_hash", contentHash)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileSourceRows removes source_content rows (and their metadata
|
||||||
|
// references and sidecars) whose blob files are missing on disk.
|
||||||
|
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
||||||
|
hashes, err := c.allSourceContentHashes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hash := range hashes {
|
||||||
|
if c.srcContent.Exists(hash) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The blob file is already gone; evictSourceBlob removes the
|
||||||
|
// rows and sidecars and tolerates the missing file.
|
||||||
|
if err := c.evictSourceBlob(ctx, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allSourceContentHashes returns every tracked source content hash.
|
||||||
|
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
|
||||||
|
rows, err := c.db.QueryContext(ctx, `SELECT content_hash FROM source_content`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query source content hashes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var hashes []ContentHash
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var hash string
|
||||||
|
if err := rows.Scan(&hash); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan content hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashes = append(hashes, ContentHash(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("content hash iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepStaleTempFile removes a temp file left behind by a crashed
|
||||||
|
// write once it is old enough that no in-flight store can own it.
|
||||||
|
func (c *Cache) sweepStaleTempFile(path string, entry fs.DirEntry) {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(info.ModTime()) < staleTempFileAge {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G703: path comes from walking our own cache directory
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
c.log.Warn("failed to remove stale temp file", "path", path, "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("removed stale temp file", "path", path)
|
||||||
|
}
|
||||||
669
internal/imgcache/eviction_test.go
Normal file
669
internal/imgcache/eviction_test.go
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
|
||||||
|
// produces, so injected timestamps compare correctly against ones the
|
||||||
|
// implementation writes.
|
||||||
|
const sqliteTimestampFormat = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
|
// evictionTestDB creates an in-memory SQLite database with the real
|
||||||
|
// production schema, limited to a single connection so the background
|
||||||
|
// eviction goroutine shares the same in-memory database as the test.
|
||||||
|
func evictionTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open test db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
||||||
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEvictionTestCache creates a Cache backed by a temp directory and
|
||||||
|
// an in-memory database, with the given size limit.
|
||||||
|
func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
db := evictionTestDB(t)
|
||||||
|
|
||||||
|
// maxBytes zero mirrors the production mapping of
|
||||||
|
// cache_max_bytes: 0 (handlers sets DisableDiskCache); at the
|
||||||
|
// CacheConfig layer itself a zero MaxBytes means "no limit" for
|
||||||
|
// backwards compatibility with existing fixtures.
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: tmpDir,
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
MaxBytes: maxBytes,
|
||||||
|
DisableDiskCache: maxBytes == 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache, tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeEvictionTestSource stores content as a fetched source for
|
||||||
|
// host/path and returns the resulting content hash.
|
||||||
|
func storeEvictionTestSource(
|
||||||
|
t *testing.T, cache *Cache, host, path string, content []byte,
|
||||||
|
) ContentHash {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: host,
|
||||||
|
SourcePath: path,
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &httpfetcher.FetchResult{
|
||||||
|
StatusCode: 200,
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
ContentLength: int64(len(content)),
|
||||||
|
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := cache.StoreSource(context.Background(), req, bytes.NewReader(content), result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeEvictionTestVariant stores content as a processed variant under
|
||||||
|
// the given cache key.
|
||||||
|
func storeEvictionTestVariant(t *testing.T, cache *Cache, key VariantKey, content []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := cache.StoreVariant(key, bytes.NewReader(content), "image/webp"); err != nil {
|
||||||
|
t.Fatalf("StoreVariant(%s) failed: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setVariantLastAccessed backdates the last access time of a tracked
|
||||||
|
// variant, to make LRU ordering deterministic in tests.
|
||||||
|
func setVariantLastAccessed(t *testing.T, cache *Cache, key VariantKey, when time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := cache.db.Exec(
|
||||||
|
`UPDATE variant_content SET last_accessed_at = ? WHERE cache_key = ?`,
|
||||||
|
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set variant last_accessed_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read affected rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected != 1 {
|
||||||
|
t.Fatalf("variant %s has no accounting row (affected=%d); "+
|
||||||
|
"stores must track variants in the database", key, affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSourceLastAccessed backdates the last access time of a tracked
|
||||||
|
// source content blob.
|
||||||
|
func setSourceLastAccessed(t *testing.T, cache *Cache, hash ContentHash, when time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
res, err := cache.db.Exec(
|
||||||
|
`UPDATE source_content SET last_accessed_at = ? WHERE content_hash = ?`,
|
||||||
|
when.UTC().Format(sqliteTimestampFormat), string(hash),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read affected rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected != 1 {
|
||||||
|
t.Fatalf("source %s has no accounting row (affected=%d)", hash, affected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRows returns the number of rows the given query yields.
|
||||||
|
func countRows(t *testing.T, cache *Cache, query string, args ...interface{}) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var n int
|
||||||
|
if err := cache.db.QueryRow(query, args...).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count query %q failed: %v", query, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoDanglingReferences verifies the core eviction invariant:
|
||||||
|
// every database row that references cache content on disk points at a
|
||||||
|
// file that actually exists.
|
||||||
|
func assertNoDanglingReferences(t *testing.T, cache *Cache) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rows, err := cache.db.Query(
|
||||||
|
`SELECT content_hash FROM source_metadata
|
||||||
|
WHERE content_hash IS NOT NULL AND content_hash != ''`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query source_metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var hash string
|
||||||
|
if err := rows.Scan(&hash); err != nil {
|
||||||
|
t.Fatalf("failed to scan content_hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.srcContent.Exists(ContentHash(hash)) {
|
||||||
|
t.Errorf("source_metadata references content %s but the file is missing", hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("source_metadata iteration failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
variantRows, err := cache.db.Query(`SELECT cache_key FROM variant_content`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query variant_content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = variantRows.Close() }()
|
||||||
|
|
||||||
|
for variantRows.Next() {
|
||||||
|
var key string
|
||||||
|
if err := variantRows.Scan(&key); err != nil {
|
||||||
|
t.Fatalf("failed to scan cache_key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.variants.Exists(VariantKey(key)) {
|
||||||
|
t.Errorf("variant_content references key %s but the file is missing", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := variantRows.Err(); err != nil {
|
||||||
|
t.Fatalf("variant_content iteration failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForUsageAtOrBelow polls UsageBytes until it reaches limit or the
|
||||||
|
// timeout expires, returning the last observed usage.
|
||||||
|
func waitForUsageAtOrBelow(t *testing.T, cache *Cache, limit int64, timeout time.Duration) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
|
||||||
|
var usage int64
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
usage, err = cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage <= limit {
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageBytesAccountsSourceAndVariantBytes(t *testing.T) {
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg",
|
||||||
|
bytes.Repeat([]byte{0xAA}, 1000))
|
||||||
|
storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg",
|
||||||
|
bytes.Repeat([]byte{0xAB}, 2000))
|
||||||
|
storeEvictionTestVariant(t, cache, "aabbccdd0001", bytes.Repeat([]byte{0xAC}, 500))
|
||||||
|
storeEvictionTestVariant(t, cache, "aabbccdd0002", bytes.Repeat([]byte{0xAD}, 250))
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 3750 {
|
||||||
|
t.Errorf("UsageBytes = %d, want 3750 (1000+2000+500+250)", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageBytesCountsMultiReferencedBlobOnce(t *testing.T) {
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
content := bytes.Repeat([]byte{0xCC}, 1200)
|
||||||
|
|
||||||
|
hashOne := storeEvictionTestSource(t, cache, "src.example.com", "/one.jpg", content)
|
||||||
|
hashTwo := storeEvictionTestSource(t, cache, "src.example.com", "/two.jpg", content)
|
||||||
|
|
||||||
|
if hashOne != hashTwo {
|
||||||
|
t.Fatalf("identical content produced different hashes: %s vs %s", hashOne, hashTwo)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 1200 {
|
||||||
|
t.Errorf("UsageBytes = %d, want 1200 (deduplicated blob counted once)", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
||||||
|
const limit = 3000
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
keys := []VariantKey{"aabbccdd0001", "aabbccdd0002", "aabbccdd0003", "aabbccdd0004"}
|
||||||
|
fills := []byte{0x01, 0x02, 0x03, 0x04}
|
||||||
|
ages := []time.Duration{4 * time.Hour, 3 * time.Hour, 2 * time.Hour, 1 * time.Hour}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
storeEvictionTestVariant(t, cache, key, bytes.Repeat([]byte{fills[i]}, 1000))
|
||||||
|
setVariantLastAccessed(t, cache, key, now.Add(-ages[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cache.EvictToLimit(context.Background()); err != nil {
|
||||||
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage > limit {
|
||||||
|
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.variants.Exists(keys[0]) {
|
||||||
|
t.Errorf("least recently used variant %s must be evicted", keys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(keys[0]),
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("evicted variant %s still has %d accounting rows", keys[0], n)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys[1:] {
|
||||||
|
if !cache.variants.Exists(key) {
|
||||||
|
t.Errorf("more recently used variant %s must survive eviction", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionRemovesMultiReferencedBlobTogetherWithAllReferences(t *testing.T) {
|
||||||
|
const limit = 1000
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// One 800-byte blob referenced by two source paths.
|
||||||
|
sharedContent := bytes.Repeat([]byte{0xDD}, 800)
|
||||||
|
sharedHash := storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", sharedContent)
|
||||||
|
|
||||||
|
if h := storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", sharedContent); h != sharedHash {
|
||||||
|
t.Fatalf("identical content produced different hashes: %s vs %s", h, sharedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newer 600-byte blob referenced by one source path.
|
||||||
|
recentHash := storeEvictionTestSource(t, cache, "src.example.com", "/c.jpg",
|
||||||
|
bytes.Repeat([]byte{0xEE}, 600))
|
||||||
|
|
||||||
|
setSourceLastAccessed(t, cache, sharedHash, now.Add(-2*time.Hour))
|
||||||
|
setSourceLastAccessed(t, cache, recentHash, now.Add(-time.Minute))
|
||||||
|
|
||||||
|
if err := cache.EvictToLimit(context.Background()); err != nil {
|
||||||
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage > limit {
|
||||||
|
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The multi-referenced blob must be gone from disk, from
|
||||||
|
// source_content, and from BOTH source_metadata rows: references
|
||||||
|
// are removed together with the blob, never left dangling.
|
||||||
|
if cache.srcContent.Exists(sharedHash) {
|
||||||
|
t.Errorf("evicted blob %s still exists on disk", sharedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(sharedHash),
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("evicted blob %s still has %d source_content rows", sharedHash, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(sharedHash),
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("evicted blob %s still has %d source_metadata references", sharedHash, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The JSON metadata sidecars for both referencing paths must be
|
||||||
|
// removed along with the rows.
|
||||||
|
for _, path := range []string{"/a.jpg", "/b.jpg"} {
|
||||||
|
pathHash := HashPath(path + "?")
|
||||||
|
if cache.srcMetadata.Exists("src.example.com", pathHash) {
|
||||||
|
t.Errorf("metadata sidecar for %s must be removed with its row", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The more recently used blob survives fully intact.
|
||||||
|
if !cache.srcContent.Exists(recentHash) {
|
||||||
|
t.Errorf("recently used blob %s must survive eviction", recentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(recentHash),
|
||||||
|
); n != 1 {
|
||||||
|
t.Errorf("recently used blob %s has %d source_metadata rows, want 1", recentHash, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
content := bytes.Repeat([]byte{0xDF}, 800)
|
||||||
|
hash := storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content)
|
||||||
|
|
||||||
|
if h := storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content); h != hash {
|
||||||
|
t.Fatalf("identical content produced different hashes: %s vs %s", h, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
storeEvictionTestVariant(t, cache, "aabbccdd0001", bytes.Repeat([]byte{0xE0}, 500))
|
||||||
|
|
||||||
|
if err := cache.EvictToLimit(context.Background()); err != nil {
|
||||||
|
t.Fatalf("EvictToLimit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.srcContent.Exists(hash) {
|
||||||
|
t.Errorf("blob %s must not be evicted while usage is under the limit", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(hash),
|
||||||
|
); n != 2 {
|
||||||
|
t.Errorf("blob %s has %d source_metadata rows, want 2", hash, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cache.variants.Exists("aabbccdd0001") {
|
||||||
|
t.Error("variant must not be evicted while usage is under the limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
|
||||||
|
cache, tmpDir := newEvictionTestCache(t, 0)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "src.example.com",
|
||||||
|
SourcePath: "/a.jpg",
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes are no-ops that report success.
|
||||||
|
if err := cache.StoreVariant(CacheKey(req), bytes.NewReader([]byte("data")), "image/webp"); err != nil {
|
||||||
|
t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &httpfetcher.FetchResult{
|
||||||
|
StatusCode: 200,
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
ContentLength: 4,
|
||||||
|
Headers: map[string][]string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash != "" {
|
||||||
|
t.Errorf("StoreSource on disabled cache returned hash %q, want empty", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads always miss.
|
||||||
|
lookup, err := cache.Lookup(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup on disabled cache failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lookup.Hit {
|
||||||
|
t.Error("Lookup on disabled cache must always miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
srcHash, srcType, err := cache.LookupSource(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srcHash != "" || srcType != "" {
|
||||||
|
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty", srcHash, srcType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is tracked and nothing is written to disk.
|
||||||
|
usage, err := cache.UsageBytes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 0 {
|
||||||
|
t.Errorf("UsageBytes on disabled cache = %d, want 0", usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
|
||||||
|
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_metadata`); n != 0 {
|
||||||
|
t.Errorf("disabled cache wrote %d source_metadata rows, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "cache")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("disabled cache must not create the cache directory tree (stat err=%v)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var foundFiles []string
|
||||||
|
|
||||||
|
walkErr := filepath.WalkDir(tmpDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.IsDir() {
|
||||||
|
foundFiles = append(foundFiles, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
t.Fatalf("failed to walk state dir: %v", walkErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(foundFiles) != 0 {
|
||||||
|
t.Errorf("disabled cache wrote files to disk: %v", foundFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
||||||
|
const limit = 1500
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
|
// An interval far longer than the test ensures only write
|
||||||
|
// pressure can trigger eviction here.
|
||||||
|
cache.StartEviction(time.Hour)
|
||||||
|
defer cache.StopEviction()
|
||||||
|
|
||||||
|
keys := []VariantKey{"aabbccdd0001", "aabbccdd0002", "aabbccdd0003"}
|
||||||
|
fills := []byte{0x11, 0x12, 0x13}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
storeEvictionTestVariant(t, cache, key, bytes.Repeat([]byte{fills[i]}, 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
||||||
|
if usage > limit {
|
||||||
|
t.Errorf("write pressure did not trigger eviction: usage = %d, want <= %d",
|
||||||
|
usage, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||||
|
const limit = 1500
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, limit)
|
||||||
|
|
||||||
|
// Start the evictor while the cache is empty, then create tracked
|
||||||
|
// over-limit state WITHOUT going through the store methods, so no
|
||||||
|
// write-pressure notification fires and only the periodic ticker
|
||||||
|
// can trigger eviction.
|
||||||
|
cache.StartEviction(100 * time.Millisecond)
|
||||||
|
defer cache.StopEviction()
|
||||||
|
|
||||||
|
keys := []VariantKey{"aabbccdd0001", "aabbccdd0002", "aabbccdd0003"}
|
||||||
|
fills := []byte{0x21, 0x22, 0x23}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
||||||
|
|
||||||
|
if _, err := cache.variants.Store(key, bytes.NewReader(content), "image/webp"); err != nil {
|
||||||
|
t.Fatalf("failed to store variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := cache.db.Exec(
|
||||||
|
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
string(key), len(content), "image/webp",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
||||||
|
if usage > limit {
|
||||||
|
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
|
||||||
|
usage, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDanglingReferences(t, cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
// An untracked variant file on disk (e.g. written before this
|
||||||
|
// feature existed) must be adopted into the accounting.
|
||||||
|
untracked := bytes.Repeat([]byte{0x31}, 1000)
|
||||||
|
if _, err := cache.variants.Store("aabbccdd0001", bytes.NewReader(untracked), "image/webp"); err != nil {
|
||||||
|
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An accounting row whose file is missing must be dropped.
|
||||||
|
if _, err := cache.db.Exec(
|
||||||
|
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
"deadbeef0001", 700, "image/webp",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("failed to insert stale variant accounting row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.StartEviction(time.Hour)
|
||||||
|
defer cache.StopEviction()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
var usage int64
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
usage, err = cache.UsageBytes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageBytes failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage == 1000 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage != 1000 {
|
||||||
|
t.Errorf("usage after reconciliation = %d, want 1000 "+
|
||||||
|
"(untracked file adopted, stale row dropped)", usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, "aabbccdd0001",
|
||||||
|
); n != 1 {
|
||||||
|
t.Errorf("untracked variant file was not adopted into accounting (rows=%d)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, "deadbeef0001",
|
||||||
|
); n != 0 {
|
||||||
|
t.Errorf("stale accounting row without a file was not dropped (rows=%d)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ type ImageRequest struct {
|
|||||||
Quality int
|
Quality int
|
||||||
// FitMode is how to fit the image into requested dimensions
|
// FitMode is how to fit the image into requested dimensions
|
||||||
FitMode FitMode
|
FitMode FitMode
|
||||||
// Signature is the HMAC signature for non-whitelisted hosts
|
// Signature is the HMAC signature for non-allowlisted hosts
|
||||||
Signature string
|
Signature string
|
||||||
// Expires is the signature expiration timestamp
|
// Expires is the signature expiration timestamp
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
@@ -163,70 +163,10 @@ type SignatureValidator interface {
|
|||||||
Generate(req *ImageRequest) string
|
Generate(req *ImageRequest) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whitelist checks if a URL is whitelisted (no signature required)
|
// Allowlist checks if a URL is allowlisted (no signature required)
|
||||||
type Whitelist interface {
|
type Allowlist interface {
|
||||||
// IsWhitelisted returns true if the URL doesn't require a signature
|
// IsAllowlisted returns true if the URL doesn't require a signature
|
||||||
IsWhitelisted(u *url.URL) bool
|
IsAllowlisted(u *url.URL) bool
|
||||||
}
|
|
||||||
|
|
||||||
// Fetcher fetches images from upstream origins
|
|
||||||
type Fetcher interface {
|
|
||||||
// Fetch retrieves an image from the origin
|
|
||||||
Fetch(ctx context.Context, url string) (*FetchResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchResult contains the result of fetching from upstream
|
|
||||||
type FetchResult struct {
|
|
||||||
// Content is the raw image data
|
|
||||||
Content io.ReadCloser
|
|
||||||
// ContentLength is the size in bytes (-1 if unknown)
|
|
||||||
ContentLength int64
|
|
||||||
// ContentType is the MIME type from upstream
|
|
||||||
ContentType string
|
|
||||||
// Headers contains all response headers from upstream
|
|
||||||
Headers map[string][]string
|
|
||||||
// StatusCode is the HTTP status code from upstream
|
|
||||||
StatusCode int
|
|
||||||
// FetchDurationMs is how long the fetch took in milliseconds
|
|
||||||
FetchDurationMs int64
|
|
||||||
// RemoteAddr is the IP:port of the upstream server
|
|
||||||
RemoteAddr string
|
|
||||||
// HTTPVersion is the protocol version (e.g., "1.1", "2.0")
|
|
||||||
HTTPVersion string
|
|
||||||
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3")
|
|
||||||
TLSVersion string
|
|
||||||
// TLSCipherSuite is the negotiated cipher suite name
|
|
||||||
TLSCipherSuite string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Processor handles image transformation (resize, format conversion)
|
|
||||||
type Processor interface {
|
|
||||||
// Process transforms an image according to the request
|
|
||||||
Process(ctx context.Context, input io.Reader, req *ImageRequest) (*ProcessResult, error)
|
|
||||||
// SupportedInputFormats returns MIME types this processor can read
|
|
||||||
SupportedInputFormats() []string
|
|
||||||
// SupportedOutputFormats returns formats this processor can write
|
|
||||||
SupportedOutputFormats() []ImageFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProcessResult contains the result of image processing
|
|
||||||
type ProcessResult struct {
|
|
||||||
// Content is the processed image data
|
|
||||||
Content io.ReadCloser
|
|
||||||
// ContentLength is the size in bytes
|
|
||||||
ContentLength int64
|
|
||||||
// ContentType is the MIME type of the output
|
|
||||||
ContentType string
|
|
||||||
// Width is the output image width
|
|
||||||
Width int
|
|
||||||
// Height is the output image height
|
|
||||||
Height int
|
|
||||||
// InputWidth is the original image width before processing
|
|
||||||
InputWidth int
|
|
||||||
// InputHeight is the original image height before processing
|
|
||||||
InputHeight int
|
|
||||||
// InputFormat is the detected input format (e.g., "jpeg", "png")
|
|
||||||
InputFormat string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage handles persistent storage of cached content
|
// Storage handles persistent storage of cached content
|
||||||
|
|||||||
@@ -11,17 +11,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/pixa/internal/allowlist"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cache *Cache
|
cache *Cache
|
||||||
fetcher Fetcher
|
fetcher httpfetcher.Fetcher
|
||||||
processor Processor
|
processor *imageprocessor.ImageProcessor
|
||||||
signer *Signer
|
signer *signature.Signer
|
||||||
whitelist *HostWhitelist
|
allowlist *allowlist.HostAllowList
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
allowHTTP bool
|
allowHTTP bool
|
||||||
|
maxResponseSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceConfig holds configuration for the image service.
|
// ServiceConfig holds configuration for the image service.
|
||||||
@@ -29,13 +35,13 @@ type ServiceConfig struct {
|
|||||||
// Cache is the cache instance
|
// Cache is the cache instance
|
||||||
Cache *Cache
|
Cache *Cache
|
||||||
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
|
||||||
FetcherConfig *FetcherConfig
|
FetcherConfig *httpfetcher.Config
|
||||||
// Fetcher is an optional custom fetcher (for testing)
|
// Fetcher is an optional custom fetcher (for testing)
|
||||||
Fetcher Fetcher
|
Fetcher httpfetcher.Fetcher
|
||||||
// SigningKey is the HMAC signing key (empty disables signing)
|
// SigningKey is the HMAC signing key (empty disables signing)
|
||||||
SigningKey string
|
SigningKey string
|
||||||
// Whitelist is the list of hosts that don't require signatures
|
// Allowlist is the list of hosts that don't require signatures
|
||||||
Whitelist []string
|
Allowlist []string
|
||||||
// Logger for logging
|
// Logger for logging
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -50,19 +56,21 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
return nil, errors.New("signing key is required")
|
return nil, errors.New("signing key is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve fetcher config for defaults
|
||||||
|
fetcherCfg := cfg.FetcherConfig
|
||||||
|
if fetcherCfg == nil {
|
||||||
|
fetcherCfg = httpfetcher.DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// Use custom fetcher if provided, otherwise create HTTP fetcher
|
// Use custom fetcher if provided, otherwise create HTTP fetcher
|
||||||
var fetcher Fetcher
|
var fetcher httpfetcher.Fetcher
|
||||||
if cfg.Fetcher != nil {
|
if cfg.Fetcher != nil {
|
||||||
fetcher = cfg.Fetcher
|
fetcher = cfg.Fetcher
|
||||||
} else {
|
} else {
|
||||||
fetcherCfg := cfg.FetcherConfig
|
fetcher = httpfetcher.New(fetcherCfg)
|
||||||
if fetcherCfg == nil {
|
|
||||||
fetcherCfg = DefaultFetcherConfig()
|
|
||||||
}
|
|
||||||
fetcher = NewHTTPFetcher(fetcherCfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signer := NewSigner(cfg.SigningKey)
|
signer := signature.New(cfg.SigningKey)
|
||||||
|
|
||||||
log := cfg.Logger
|
log := cfg.Logger
|
||||||
if log == nil {
|
if log == nil {
|
||||||
@@ -74,14 +82,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxResponseSize := fetcherCfg.MaxResponseSize
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
cache: cfg.Cache,
|
cache: cfg.Cache,
|
||||||
fetcher: fetcher,
|
fetcher: fetcher,
|
||||||
processor: NewImageProcessor(),
|
processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
|
||||||
signer: signer,
|
signer: signer,
|
||||||
whitelist: NewHostWhitelist(cfg.Whitelist),
|
allowlist: allowlist.New(cfg.Allowlist),
|
||||||
log: log,
|
log: log,
|
||||||
allowHTTP: allowHTTP,
|
allowHTTP: allowHTTP,
|
||||||
|
maxResponseSize: maxResponseSize,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +115,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
"path", req.SourcePath,
|
"path", req.SourcePath,
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil, fmt.Errorf("%w: %w", ErrUpstreamError, ErrNegativeCached)
|
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check variant cache first (disk only, no DB)
|
// Check variant cache first (disk only, no DB)
|
||||||
@@ -146,6 +157,40 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadCachedSource attempts to load source content from cache, returning nil
|
||||||
|
// if the cached data is unavailable or exceeds maxResponseSize.
|
||||||
|
func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
|
||||||
|
reader, err := s.cache.GetSourceContent(contentHash)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to load cached source, fetching", "error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound the read to maxResponseSize to prevent unbounded memory use
|
||||||
|
// from unexpectedly large cached files.
|
||||||
|
limited := io.LimitReader(reader, s.maxResponseSize+1)
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("failed to read cached source, fetching", "error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > s.maxResponseSize {
|
||||||
|
s.log.Warn("cached source exceeds max response size, discarding",
|
||||||
|
"hash", contentHash,
|
||||||
|
"max_bytes", s.maxResponseSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// processFromSourceOrFetch processes an image, using cached source content if available.
|
// processFromSourceOrFetch processes an image, using cached source content if available.
|
||||||
func (s *Service) processFromSourceOrFetch(
|
func (s *Service) processFromSourceOrFetch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -162,22 +207,8 @@ func (s *Service) processFromSourceOrFetch(
|
|||||||
var fetchBytes int64
|
var fetchBytes int64
|
||||||
|
|
||||||
if contentHash != "" {
|
if contentHash != "" {
|
||||||
// We have cached source - load it
|
|
||||||
s.log.Debug("using cached source", "hash", contentHash)
|
s.log.Debug("using cached source", "hash", contentHash)
|
||||||
|
sourceData = s.loadCachedSource(contentHash)
|
||||||
reader, err := s.cache.GetSourceContent(contentHash)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to load cached source, fetching", "error", err)
|
|
||||||
// Fall through to fetch
|
|
||||||
} else {
|
|
||||||
sourceData, err = io.ReadAll(reader)
|
|
||||||
_ = reader.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("failed to read cached source, fetching", "error", err)
|
|
||||||
// Fall through to fetch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from upstream if we don't have source data or it's empty
|
// Fetch from upstream if we don't have source data or it's empty
|
||||||
@@ -249,7 +280,7 @@ func (s *Service) fetchAndProcess(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Validate magic bytes match content type
|
// Validate magic bytes match content type
|
||||||
if err := ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil {
|
if err := magic.ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil {
|
||||||
return nil, fmt.Errorf("content validation failed: %w", err)
|
return nil, fmt.Errorf("content validation failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +305,14 @@ func (s *Service) processAndStore(
|
|||||||
// Process the image
|
// Process the image
|
||||||
processStart := time.Now()
|
processStart := time.Now()
|
||||||
|
|
||||||
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req)
|
processReq := &imageprocessor.Request{
|
||||||
|
Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height},
|
||||||
|
Format: imageprocessor.Format(req.Format),
|
||||||
|
Quality: req.Quality,
|
||||||
|
FitMode: imageprocessor.FitMode(req.FitMode),
|
||||||
|
}
|
||||||
|
|
||||||
|
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), processReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("image processing failed: %w", err)
|
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -347,7 +385,7 @@ func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
|
|||||||
|
|
||||||
// ValidateRequest validates the request signature if required.
|
// ValidateRequest validates the request signature if required.
|
||||||
func (s *Service) ValidateRequest(req *ImageRequest) error {
|
func (s *Service) ValidateRequest(req *ImageRequest) error {
|
||||||
// Check if host is whitelisted (no signature required)
|
// Check if host is allowed (no signature required)
|
||||||
sourceURL := req.SourceURL()
|
sourceURL := req.SourceURL()
|
||||||
|
|
||||||
parsedURL, err := url.Parse(sourceURL)
|
parsedURL, err := url.Parse(sourceURL)
|
||||||
@@ -355,12 +393,12 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
|
|||||||
return fmt.Errorf("invalid source URL: %w", err)
|
return fmt.Errorf("invalid source URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.whitelist.IsWhitelisted(parsedURL) {
|
if s.allowlist.IsAllowed(parsedURL) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature required for non-whitelisted hosts
|
// Signature required for non-allowed hosts
|
||||||
return s.signer.Verify(req)
|
return s.signer.Verify(signatureRequest(req))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSignedURL generates a signed URL for the given request.
|
// GenerateSignedURL generates a signed URL for the given request.
|
||||||
@@ -369,11 +407,32 @@ func (s *Service) GenerateSignedURL(
|
|||||||
req *ImageRequest,
|
req *ImageRequest,
|
||||||
ttl time.Duration,
|
ttl time.Duration,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
path, sig, exp := s.signer.GenerateSignedURL(req, ttl)
|
sigReq := signatureRequest(req)
|
||||||
|
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
||||||
|
|
||||||
|
// Propagate the generated signature and expiration back onto the request.
|
||||||
|
req.Expires = sigReq.Expires
|
||||||
|
req.Signature = sigReq.Signature
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signatureRequest projects an ImageRequest onto the standalone
|
||||||
|
// signature.Request type used by the signature package. This keeps the
|
||||||
|
// import edge one-way: imgcache depends on signature, never the reverse.
|
||||||
|
func signatureRequest(req *ImageRequest) *signature.Request {
|
||||||
|
return &signature.Request{
|
||||||
|
SourceHost: req.SourceHost,
|
||||||
|
SourcePath: req.SourcePath,
|
||||||
|
SourceQuery: req.SourceQuery,
|
||||||
|
Width: req.Size.Width,
|
||||||
|
Height: req.Size.Height,
|
||||||
|
Format: string(req.Format),
|
||||||
|
Signature: req.Signature,
|
||||||
|
Expires: req.Expires,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HTTP status codes for error responses.
|
// HTTP status codes for error responses.
|
||||||
const (
|
const (
|
||||||
httpStatusBadGateway = 502
|
httpStatusBadGateway = 502
|
||||||
@@ -382,13 +441,13 @@ const (
|
|||||||
|
|
||||||
// isNegativeCacheable returns true if the error should be cached.
|
// isNegativeCacheable returns true if the error should be cached.
|
||||||
func isNegativeCacheable(err error) bool {
|
func isNegativeCacheable(err error) bool {
|
||||||
return errors.Is(err, ErrUpstreamError)
|
return errors.Is(err, httpfetcher.ErrUpstreamError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractStatusCode extracts HTTP status code from error message.
|
// extractStatusCode extracts HTTP status code from error message.
|
||||||
func extractStatusCode(err error) int {
|
func extractStatusCode(err error) int {
|
||||||
// Default to 502 Bad Gateway for upstream errors
|
// Default to 502 Bad Gateway for upstream errors
|
||||||
if errors.Is(err, ErrUpstreamError) {
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||||
return httpStatusBadGateway
|
return httpStatusBadGateway
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/magic"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestService_Get_WhitelistedHost(t *testing.T) {
|
func TestService_Get_AllowlistedHost(t *testing.T) {
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -41,7 +44,7 @@ func TestService_Get_WhitelistedHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
|
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
@@ -53,14 +56,14 @@ func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) {
|
|||||||
FitMode: FitCover,
|
FitMode: FitCover,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should fail validation - not whitelisted and no signature
|
// Should fail validation - not allowlisted and no signature
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ValidateRequest() expected error for non-whitelisted host without signature")
|
t.Error("ValidateRequest() expected error for non-allowlisted host without signature")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
signingKey := "test-signing-key-12345"
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -75,9 +78,9 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate a valid signature
|
// Generate a valid signature
|
||||||
signer := NewSigner(signingKey)
|
signer := signature.New(signingKey)
|
||||||
req.Expires = time.Now().Add(time.Hour)
|
req.Expires = time.Now().Add(time.Hour)
|
||||||
req.Signature = signer.Sign(req)
|
req.Signature = signer.Sign(signatureRequest(req))
|
||||||
|
|
||||||
// Should pass validation
|
// Should pass validation
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
@@ -102,7 +105,7 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
signingKey := "test-signing-key-12345"
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
|
|
||||||
@@ -116,9 +119,9 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate an expired signature
|
// Generate an expired signature
|
||||||
signer := NewSigner(signingKey)
|
signer := signature.New(signingKey)
|
||||||
req.Expires = time.Now().Add(-time.Hour) // Already expired
|
req.Expires = time.Now().Add(-time.Hour) // Already expired
|
||||||
req.Signature = signer.Sign(req)
|
req.Signature = signer.Sign(signatureRequest(req))
|
||||||
|
|
||||||
// Should fail validation
|
// Should fail validation
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
@@ -127,7 +130,7 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
|
func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
|
||||||
signingKey := "test-signing-key-12345"
|
signingKey := "test-signing-key-12345"
|
||||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||||
|
|
||||||
@@ -151,6 +154,74 @@ func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestService_ValidateRequest_SignatureExactHostMatch verifies that
|
||||||
|
// ValidateRequest enforces exact host matching for signatures. A
|
||||||
|
// signature for one host must not verify for a different host, even
|
||||||
|
// if they share a domain suffix.
|
||||||
|
func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||||
|
signingKey := "test-signing-key-must-be-32-chars"
|
||||||
|
svc, _ := SetupTestService(t,
|
||||||
|
WithSigningKey(signingKey),
|
||||||
|
WithNoAllowlist(),
|
||||||
|
)
|
||||||
|
|
||||||
|
signer := signature.New(signingKey)
|
||||||
|
|
||||||
|
// Sign a request for "cdn.example.com"
|
||||||
|
signedReq := &ImageRequest{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Size: Size{Width: 50, Height: 50},
|
||||||
|
Format: FormatJPEG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: FitCover,
|
||||||
|
Expires: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
signedReq.Signature = signer.Sign(signatureRequest(signedReq))
|
||||||
|
|
||||||
|
// The original request should pass validation
|
||||||
|
t.Run("exact host passes", func(t *testing.T) {
|
||||||
|
err := svc.ValidateRequest(signedReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ValidateRequest() exact host failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to reuse the signature with different hosts
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
}{
|
||||||
|
{"parent domain", "example.com"},
|
||||||
|
{"sibling subdomain", "images.example.com"},
|
||||||
|
{"deeper subdomain", "a.cdn.example.com"},
|
||||||
|
{"evil suffix domain", "cdn.example.com.evil.com"},
|
||||||
|
{"prefixed host", "evilcdn.example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name+" rejected", func(t *testing.T) {
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: tt.host,
|
||||||
|
SourcePath: signedReq.SourcePath,
|
||||||
|
SourceQuery: signedReq.SourceQuery,
|
||||||
|
Size: signedReq.Size,
|
||||||
|
Format: signedReq.Format,
|
||||||
|
Quality: signedReq.Quality,
|
||||||
|
FitMode: signedReq.FitMode,
|
||||||
|
Expires: signedReq.Expires,
|
||||||
|
Signature: signedReq.Signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := svc.ValidateRequest(req)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ValidateRequest() should reject signature for host %q (signed for %q)",
|
||||||
|
tt.host, signedReq.SourceHost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestService_Get_InvalidFile(t *testing.T) {
|
func TestService_Get_InvalidFile(t *testing.T) {
|
||||||
svc, fixtures := SetupTestService(t)
|
svc, fixtures := SetupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -247,17 +318,17 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
|||||||
t.Fatalf("failed to read response: %v", err)
|
t.Fatalf("failed to read response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
detectedMIME, err := DetectFormat(data)
|
detectedMIME, err := magic.DetectFormat(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to detect format: %v", err)
|
t.Fatalf("failed to detect format: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedFormat, ok := MIMEToImageFormat(tt.wantMIME)
|
expectedFormat, ok := magic.MIMEToImageFormat(tt.wantMIME)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
|
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
|
||||||
}
|
}
|
||||||
|
|
||||||
detectedFormat, ok := MIMEToImageFormat(string(detectedMIME))
|
detectedFormat, ok := magic.MIMEToImageFormat(string(detectedMIME))
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
|
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
|
||||||
}
|
}
|
||||||
@@ -367,8 +438,8 @@ func TestService_Get_DifferentSizes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
||||||
// Service with no signing key - all non-whitelisted requests should fail
|
// Service with no signing key - all non-allowlisted requests should fail
|
||||||
svc, fixtures := SetupTestService(t, WithNoWhitelist())
|
svc, fixtures := SetupTestService(t, WithNoAllowlist())
|
||||||
|
|
||||||
req := &ImageRequest{
|
req := &ImageRequest{
|
||||||
SourceHost: fixtures.OtherHost,
|
SourceHost: fixtures.OtherHost,
|
||||||
@@ -381,7 +452,7 @@ func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
|||||||
|
|
||||||
err := svc.ValidateRequest(req)
|
err := svc.ValidateRequest(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("ValidateRequest() expected error when no signing key and host not whitelisted")
|
t.Error("ValidateRequest() expected error when no signing key and host not allowlisted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Signature errors.
|
|
||||||
var (
|
|
||||||
ErrSignatureRequired = errors.New("signature required for non-whitelisted host")
|
|
||||||
ErrSignatureInvalid = errors.New("invalid signature")
|
|
||||||
ErrSignatureExpired = errors.New("signature has expired")
|
|
||||||
ErrMissingExpiration = errors.New("signature expiration is required")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Signer handles HMAC-SHA256 signature generation and verification.
|
|
||||||
type Signer struct {
|
|
||||||
secretKey []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSigner creates a new Signer with the given secret key.
|
|
||||||
func NewSigner(secretKey string) *Signer {
|
|
||||||
return &Signer{
|
|
||||||
secretKey: []byte(secretKey),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign generates an HMAC-SHA256 signature for the given image request.
|
|
||||||
// The signature covers: host + path + query + width + height + format + expiration.
|
|
||||||
func (s *Signer) Sign(req *ImageRequest) string {
|
|
||||||
data := s.buildSignatureData(req)
|
|
||||||
mac := hmac.New(sha256.New, s.secretKey)
|
|
||||||
mac.Write([]byte(data))
|
|
||||||
sig := mac.Sum(nil)
|
|
||||||
|
|
||||||
return base64.URLEncoding.EncodeToString(sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checks if the signature on the request is valid and not expired.
|
|
||||||
func (s *Signer) Verify(req *ImageRequest) error {
|
|
||||||
// Check expiration first
|
|
||||||
if req.Expires.IsZero() {
|
|
||||||
return ErrMissingExpiration
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(req.Expires) {
|
|
||||||
return ErrSignatureExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute expected signature
|
|
||||||
expected := s.Sign(req)
|
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
|
||||||
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
|
|
||||||
return ErrSignatureInvalid
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSignatureData creates the string to be signed.
|
|
||||||
// Format: "host:path:query:width:height:format:expiration"
|
|
||||||
func (s *Signer) buildSignatureData(req *ImageRequest) string {
|
|
||||||
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
req.SourceQuery,
|
|
||||||
req.Size.Width,
|
|
||||||
req.Size.Height,
|
|
||||||
req.Format,
|
|
||||||
req.Expires.Unix(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateSignedURL creates a complete URL with signature and expiration.
|
|
||||||
// Returns the path portion that should be appended to the base URL.
|
|
||||||
func (s *Signer) GenerateSignedURL(req *ImageRequest, ttl time.Duration) (path string, sig string, exp int64) {
|
|
||||||
// Set expiration
|
|
||||||
req.Expires = time.Now().Add(ttl)
|
|
||||||
exp = req.Expires.Unix()
|
|
||||||
|
|
||||||
// Generate signature
|
|
||||||
sig = s.Sign(req)
|
|
||||||
req.Signature = sig
|
|
||||||
|
|
||||||
// Build the size component
|
|
||||||
var sizeStr string
|
|
||||||
if req.Size.OriginalSize() {
|
|
||||||
sizeStr = "orig"
|
|
||||||
} else {
|
|
||||||
sizeStr = fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the path.
|
|
||||||
// When a source query is present, it is embedded as a path segment
|
|
||||||
// (e.g. /host/path?query/size.fmt) so that ParseImagePath can extract
|
|
||||||
// it from the last-slash split. The "?" inside a path segment is
|
|
||||||
// percent-encoded by clients but chi delivers it decoded, which is
|
|
||||||
// exactly what the URL parser expects.
|
|
||||||
if req.SourceQuery != "" {
|
|
||||||
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
url.PathEscape(req.SourceQuery),
|
|
||||||
sizeStr,
|
|
||||||
req.Format,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
path = fmt.Sprintf("/v1/image/%s%s/%s.%s",
|
|
||||||
req.SourceHost,
|
|
||||||
req.SourcePath,
|
|
||||||
sizeStr,
|
|
||||||
req.Format,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path, sig, exp
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseSignatureParams extracts signature and expiration from query parameters.
|
|
||||||
func ParseSignatureParams(sig, expStr string) (signature string, expires time.Time, err error) {
|
|
||||||
signature = sig
|
|
||||||
|
|
||||||
if expStr == "" {
|
|
||||||
return signature, time.Time{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
expUnix, err := strconv.ParseInt(expStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expires = time.Unix(expUnix, 0)
|
|
||||||
|
|
||||||
return signature, expires, nil
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key-for-testing!")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "token=abc&v=2",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
// The path must NOT contain a bare "?" that would be interpreted as a query string delimiter.
|
|
||||||
// The size segment must appear as the last path component.
|
|
||||||
if strings.Contains(path, "?token=abc") {
|
|
||||||
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The size segment must be present in the path
|
|
||||||
if !strings.Contains(path, "/800x600.webp") {
|
|
||||||
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path should end with the size.format, not with query params
|
|
||||||
if !strings.HasSuffix(path, "/800x600.webp") {
|
|
||||||
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key-for-testing!")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
expected := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
|
||||||
if path != expected {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
package imgcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSigner_Sign(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
|
|
||||||
}
|
|
||||||
|
|
||||||
sig1 := signer.Sign(req)
|
|
||||||
sig2 := signer.Sign(req)
|
|
||||||
|
|
||||||
// Same input should produce same signature
|
|
||||||
if sig1 != sig2 {
|
|
||||||
t.Errorf("Sign() produced different signatures for same input: %q vs %q", sig1, sig2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature should be non-empty
|
|
||||||
if sig1 == "" {
|
|
||||||
t.Error("Sign() produced empty signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different input should produce different signature
|
|
||||||
req2 := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/dog.jpg", // Different path
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Unix(1704067200, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
sig3 := signer.Sign(req2)
|
|
||||||
if sig1 == sig3 {
|
|
||||||
t.Error("Sign() produced same signature for different input")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigner_Verify(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setup func() *ImageRequest
|
|
||||||
wantErr error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "expired signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(-1 * time.Hour), // Expired
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureExpired,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid signature",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
Signature: "invalid-signature",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureInvalid,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing expiration",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
return &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Signature: "some-signature",
|
|
||||||
// Expires is zero
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wantErr: ErrMissingExpiration,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tampered request",
|
|
||||||
setup: func() *ImageRequest {
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
req.Signature = signer.Sign(req)
|
|
||||||
// Tamper with the request
|
|
||||||
req.SourcePath = "/photos/secret.jpg"
|
|
||||||
|
|
||||||
return req
|
|
||||||
},
|
|
||||||
wantErr: ErrSignatureInvalid,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
req := tt.setup()
|
|
||||||
err := signer.Verify(req)
|
|
||||||
|
|
||||||
if tt.wantErr == nil {
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Verify() unexpected error = %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err != tt.wantErr {
|
|
||||||
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigner_DifferentKeys(t *testing.T) {
|
|
||||||
signer1 := NewSigner("secret-key-1")
|
|
||||||
signer2 := NewSigner("secret-key-2")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
Expires: time.Now().Add(1 * time.Hour),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign with key 1
|
|
||||||
req.Signature = signer1.Sign(req)
|
|
||||||
|
|
||||||
// Verify with key 1 should succeed
|
|
||||||
if err := signer1.Verify(req); err != nil {
|
|
||||||
t.Errorf("Verify() with same key failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify with key 2 should fail
|
|
||||||
if err := signer2.Verify(req); err != ErrSignatureInvalid {
|
|
||||||
t.Errorf("Verify() with different key should fail, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
SourceQuery: "",
|
|
||||||
Size: Size{Width: 800, Height: 600},
|
|
||||||
Format: FormatWebP,
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl := 1 * time.Hour
|
|
||||||
path, sig, exp := signer.GenerateSignedURL(req, ttl)
|
|
||||||
|
|
||||||
// Path should be correct format
|
|
||||||
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
|
||||||
if path != expectedPath {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature should be non-empty
|
|
||||||
if sig == "" {
|
|
||||||
t.Error("GenerateSignedURL() produced empty signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expiration should be approximately now + TTL
|
|
||||||
expTime := time.Unix(exp, 0)
|
|
||||||
expectedExp := time.Now().Add(ttl)
|
|
||||||
if expTime.Sub(expectedExp) > time.Second {
|
|
||||||
t.Errorf("GenerateSignedURL() exp time off by too much")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request should have been updated with signature and expiration
|
|
||||||
if req.Signature != sig {
|
|
||||||
t.Errorf("GenerateSignedURL() didn't update request signature")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignedURL_OrigSize(t *testing.T) {
|
|
||||||
signer := NewSigner("test-secret-key")
|
|
||||||
|
|
||||||
req := &ImageRequest{
|
|
||||||
SourceHost: "cdn.example.com",
|
|
||||||
SourcePath: "/photos/cat.jpg",
|
|
||||||
Size: Size{Width: 0, Height: 0}, // Original size
|
|
||||||
Format: FormatPNG,
|
|
||||||
}
|
|
||||||
|
|
||||||
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
|
||||||
|
|
||||||
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
|
|
||||||
if path != expectedPath {
|
|
||||||
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSignatureParams(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
sig string
|
|
||||||
expStr string
|
|
||||||
wantSig string
|
|
||||||
wantErr bool
|
|
||||||
checkTime bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid params",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "1704067200",
|
|
||||||
wantSig: "abc123",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty expiration",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "",
|
|
||||||
wantSig: "abc123",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid expiration",
|
|
||||||
sig: "abc123",
|
|
||||||
expStr: "not-a-number",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sig, exp, err := ParseSignatureParams(tt.sig, tt.expStr)
|
|
||||||
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Error("ParseSignatureParams() expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseSignatureParams() unexpected error = %v", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if sig != tt.wantSig {
|
|
||||||
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.expStr != "" && exp.IsZero() {
|
|
||||||
t.Error("exp should not be zero when expStr is provided")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ func setupStatsTestDB(t *testing.T) *sql.DB {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.ApplyMigrations(db); err != nil {
|
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Close() })
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|||||||
@@ -493,6 +493,24 @@ func (s *VariantStorage) Delete(key VariantKey) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteWithMeta removes the content at the given key together with
|
||||||
|
// its .meta sidecar file. A missing file is not an error.
|
||||||
|
func (s *VariantStorage) DeleteWithMeta(key VariantKey) error {
|
||||||
|
if err := s.Delete(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metaPath := s.keyToPath(key) + ".meta"
|
||||||
|
|
||||||
|
//nolint:gosec // G703: path derived from cache key
|
||||||
|
err := os.Remove(metaPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to delete variant metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
|
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
|
||||||
func (s *VariantStorage) keyToPath(key VariantKey) string {
|
func (s *VariantStorage) keyToPath(key VariantKey) string {
|
||||||
k := string(key)
|
k := string(key)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -14,16 +15,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestFixtures contains paths to test files in the mock filesystem.
|
// TestFixtures contains paths to test files in the mock filesystem.
|
||||||
type TestFixtures struct {
|
type TestFixtures struct {
|
||||||
// Valid image files
|
// Valid image files
|
||||||
GoodHostJPEG string // whitelisted host, valid JPEG
|
GoodHostJPEG string // allowlisted host, valid JPEG
|
||||||
GoodHostPNG string // whitelisted host, valid PNG
|
GoodHostPNG string // allowlisted host, valid PNG
|
||||||
GoodHostGIF string // whitelisted host, valid GIF
|
GoodHostGIF string // allowlisted host, valid GIF
|
||||||
OtherHostJPEG string // non-whitelisted host, valid JPEG
|
OtherHostJPEG string // non-allowlisted host, valid JPEG
|
||||||
OtherHostPNG string // non-whitelisted host, valid PNG
|
OtherHostPNG string // non-allowlisted host, valid PNG
|
||||||
|
|
||||||
// Invalid/edge case files
|
// Invalid/edge case files
|
||||||
InvalidFile string // file with wrong magic bytes
|
InvalidFile string // file with wrong magic bytes
|
||||||
@@ -31,8 +33,8 @@ type TestFixtures struct {
|
|||||||
TextFile string // text file masquerading as image
|
TextFile string // text file masquerading as image
|
||||||
|
|
||||||
// Hostnames
|
// Hostnames
|
||||||
GoodHost string // whitelisted hostname
|
GoodHost string // allowlisted hostname
|
||||||
OtherHost string // non-whitelisted hostname
|
OtherHost string // non-allowlisted hostname
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultFixtures returns the standard test fixture paths.
|
// DefaultFixtures returns the standard test fixture paths.
|
||||||
@@ -146,7 +148,7 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
|
|||||||
mockFS, fixtures := NewTestFS(t)
|
mockFS, fixtures := NewTestFS(t)
|
||||||
|
|
||||||
cfg := &testServiceConfig{
|
cfg := &testServiceConfig{
|
||||||
whitelist: []string{fixtures.GoodHost},
|
allowlist: []string{fixtures.GoodHost},
|
||||||
signingKey: "test-signing-key-must-be-32-chars",
|
signingKey: "test-signing-key-must-be-32-chars",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +173,9 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
|
|||||||
|
|
||||||
svc, err := NewService(&ServiceConfig{
|
svc, err := NewService(&ServiceConfig{
|
||||||
Cache: cache,
|
Cache: cache,
|
||||||
Fetcher: NewMockFetcher(mockFS),
|
Fetcher: httpfetcher.NewMock(mockFS),
|
||||||
SigningKey: cfg.signingKey,
|
SigningKey: cfg.signingKey,
|
||||||
Whitelist: cfg.whitelist,
|
Allowlist: cfg.allowlist,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create service: %v", err)
|
t.Fatalf("failed to create service: %v", err)
|
||||||
@@ -193,7 +195,7 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the real production schema via migrations
|
// Use the real production schema via migrations
|
||||||
if err := database.ApplyMigrations(db); err != nil {
|
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
||||||
t.Fatalf("failed to apply migrations: %v", err)
|
t.Fatalf("failed to apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,17 +203,17 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type testServiceConfig struct {
|
type testServiceConfig struct {
|
||||||
whitelist []string
|
allowlist []string
|
||||||
signingKey string
|
signingKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServiceOption configures the test service.
|
// TestServiceOption configures the test service.
|
||||||
type TestServiceOption func(*testServiceConfig)
|
type TestServiceOption func(*testServiceConfig)
|
||||||
|
|
||||||
// WithWhitelist sets the whitelist for the test service.
|
// WithAllowlist sets the allowlist for the test service.
|
||||||
func WithWhitelist(hosts ...string) TestServiceOption {
|
func WithAllowlist(hosts ...string) TestServiceOption {
|
||||||
return func(c *testServiceConfig) {
|
return func(c *testServiceConfig) {
|
||||||
c.whitelist = hosts
|
c.allowlist = hosts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +224,9 @@ func WithSigningKey(key string) TestServiceOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithNoWhitelist removes all whitelisted hosts.
|
// WithNoAllowlist removes all allowlisted hosts.
|
||||||
func WithNoWhitelist() TestServiceOption {
|
func WithNoAllowlist() TestServiceOption {
|
||||||
return func(c *testServiceConfig) {
|
return func(c *testServiceConfig) {
|
||||||
c.whitelist = nil
|
c.allowlist = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
package imgcache
|
// Package magic detects image formats from magic bytes and validates
|
||||||
|
// content against declared MIME types.
|
||||||
|
package magic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -27,6 +29,20 @@ const (
|
|||||||
MIMETypeSVG = MIMEType("image/svg+xml")
|
MIMETypeSVG = MIMEType("image/svg+xml")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ImageFormat represents supported output image formats.
|
||||||
|
// This mirrors the type in imgcache to avoid circular imports.
|
||||||
|
type ImageFormat string
|
||||||
|
|
||||||
|
// Supported image output formats.
|
||||||
|
const (
|
||||||
|
FormatOriginal ImageFormat = "orig"
|
||||||
|
FormatJPEG ImageFormat = "jpeg"
|
||||||
|
FormatPNG ImageFormat = "png"
|
||||||
|
FormatWebP ImageFormat = "webp"
|
||||||
|
FormatAVIF ImageFormat = "avif"
|
||||||
|
FormatGIF ImageFormat = "gif"
|
||||||
|
)
|
||||||
|
|
||||||
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
||||||
const MinMagicBytes = 12
|
const MinMagicBytes = 12
|
||||||
|
|
||||||
@@ -189,7 +205,7 @@ func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
|
|||||||
return io.MultiReader(bytes.NewReader(buf), r), nil
|
return io.MultiReader(bytes.NewReader(buf), r), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIMEToImageFormat converts a MIME type to our ImageFormat type.
|
// MIMEToImageFormat converts a MIME type to an ImageFormat.
|
||||||
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
||||||
normalized := normalizeMIMEType(mimeType)
|
normalized := normalizeMIMEType(mimeType)
|
||||||
switch MIMEType(normalized) {
|
switch MIMEType(normalized) {
|
||||||
@@ -208,7 +224,7 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageFormatToMIME converts our ImageFormat to a MIME type string.
|
// ImageFormatToMIME converts an ImageFormat to a MIME type string.
|
||||||
func ImageFormatToMIME(format ImageFormat) string {
|
func ImageFormatToMIME(format ImageFormat) string {
|
||||||
switch format {
|
switch format {
|
||||||
case FormatJPEG:
|
case FormatJPEG:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package imgcache
|
package magic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -36,14 +36,16 @@ type Data struct {
|
|||||||
|
|
||||||
// Manager handles session creation and validation using encrypted cookies.
|
// Manager handles session creation and validation using encrypted cookies.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
sc *securecookie.SecureCookie
|
sc *securecookie.SecureCookie
|
||||||
secure bool // Set Secure flag on cookies (should be true in production)
|
|
||||||
sameSite http.SameSite
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a session manager with keys derived from the signing key.
|
// NewManager creates a session manager with keys derived from the signing key.
|
||||||
// Set secure=true in production to require HTTPS for cookies.
|
//
|
||||||
func NewManager(signingKey string, secure bool) (*Manager, error) {
|
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
|
||||||
|
// attributes; this cannot be configured. Browsers treat http://localhost as a
|
||||||
|
// trustworthy origin and accept Secure cookies there, so local development
|
||||||
|
// keeps working.
|
||||||
|
func NewManager(signingKey string) (*Manager, error) {
|
||||||
masterKey := []byte(signingKey)
|
masterKey := []byte(signingKey)
|
||||||
|
|
||||||
// Derive separate keys for HMAC (hash) and encryption (block)
|
// Derive separate keys for HMAC (hash) and encryption (block)
|
||||||
@@ -61,9 +63,7 @@ func NewManager(signingKey string, secure bool) (*Manager, error) {
|
|||||||
sc.MaxAge(int(SessionTTL.Seconds()))
|
sc.MaxAge(int(SessionTTL.Seconds()))
|
||||||
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
sc: sc,
|
sc: sc,
|
||||||
secure: secure,
|
|
||||||
sameSite: http.SameSiteStrictMode,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ func (m *Manager) CreateSession(w http.ResponseWriter) error {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(SessionTTL.Seconds()),
|
MaxAge: int(SessionTTL.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: m.secure,
|
Secure: true,
|
||||||
SameSite: m.sameSite,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -131,8 +131,8 @@ func (m *Manager) ClearSession(w http.ResponseWriter) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: -1, // Delete immediately
|
MaxAge: -1, // Delete immediately
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: m.secure,
|
Secure: true,
|
||||||
SameSite: m.sameSite,
|
SameSite: http.SameSiteStrictMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
internal/session/session_cookie_attributes_test.go
Normal file
82
internal/session/session_cookie_attributes_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSessionCookieAttributesAlwaysSecure verifies that every cookie
|
||||||
|
// emitted by the session manager carries HttpOnly, Secure, and a
|
||||||
|
// SameSite mode of Lax or stricter. Session cookies contain the
|
||||||
|
// authentication state and must never be exposed to script (HttpOnly),
|
||||||
|
// sent over plaintext HTTP (Secure), or attached to cross-site
|
||||||
|
// requests (SameSite). Nothing may weaken these attributes.
|
||||||
|
//
|
||||||
|
// This covers both cookie-writing paths: CreateSession (the login
|
||||||
|
// set-cookie path) and ClearSession (the logout delete-cookie path).
|
||||||
|
func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
|
||||||
|
mgr, err := NewManager("test-signing-key-12345")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewManager() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writePaths := []struct {
|
||||||
|
name string
|
||||||
|
setCookie func(t *testing.T, w http.ResponseWriter)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CreateSession",
|
||||||
|
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
||||||
|
t.Helper()
|
||||||
|
if err := mgr.CreateSession(w); err != nil {
|
||||||
|
t.Fatalf("CreateSession() error = %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ClearSession",
|
||||||
|
setCookie: func(t *testing.T, w http.ResponseWriter) {
|
||||||
|
t.Helper()
|
||||||
|
mgr.ClearSession(w)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, writePath := range writePaths {
|
||||||
|
t.Run(writePath.name, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
writePath.setCookie(t, w)
|
||||||
|
|
||||||
|
var sessionCookie *http.Cookie
|
||||||
|
for _, c := range w.Result().Cookies() {
|
||||||
|
if c.Name == CookieName {
|
||||||
|
sessionCookie = c
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionCookie == nil {
|
||||||
|
t.Fatalf("no cookie named %q was set", CookieName)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("cookie attributes: HttpOnly=%v Secure=%v SameSite=%v",
|
||||||
|
sessionCookie.HttpOnly, sessionCookie.Secure, sessionCookie.SameSite)
|
||||||
|
|
||||||
|
if !sessionCookie.HttpOnly {
|
||||||
|
t.Error("session cookie must have HttpOnly set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sessionCookie.Secure {
|
||||||
|
t.Error("session cookie must have Secure set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionCookie.SameSite != http.SameSiteLaxMode &&
|
||||||
|
sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||||
|
t.Errorf("session cookie SameSite = %v, want Lax (%v) or Strict (%v)",
|
||||||
|
sessionCookie.SameSite, http.SameSiteLaxMode, http.SameSiteStrictMode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestManager_CreateAndValidate(t *testing.T) {
|
func TestManager_CreateAndValidate(t *testing.T) {
|
||||||
mgr, err := NewManager("test-signing-key-12345", false)
|
mgr, err := NewManager("test-signing-key-12345")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewManager() error = %v", err)
|
t.Fatalf("NewManager() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestManager_CreateAndValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_NoCookie(t *testing.T) {
|
func TestManager_ValidateSession_NoCookie(t *testing.T) {
|
||||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
mgr, _ := NewManager("test-signing-key-12345")
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ func TestManager_ValidateSession_NoCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
||||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
mgr, _ := NewManager("test-signing-key-12345")
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.AddCookie(&http.Cookie{
|
req.AddCookie(&http.Cookie{
|
||||||
@@ -91,8 +91,8 @@ func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
||||||
mgr1, _ := NewManager("signing-key-1", false)
|
mgr1, _ := NewManager("signing-key-1")
|
||||||
mgr2, _ := NewManager("signing-key-2", false)
|
mgr2, _ := NewManager("signing-key-2")
|
||||||
|
|
||||||
// Create session with mgr1
|
// Create session with mgr1
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -118,7 +118,7 @@ func TestManager_ValidateSession_WrongKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ClearSession(t *testing.T) {
|
func TestManager_ClearSession(t *testing.T) {
|
||||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
mgr, _ := NewManager("test-signing-key-12345")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
mgr.ClearSession(w)
|
mgr.ClearSession(w)
|
||||||
@@ -144,7 +144,7 @@ func TestManager_ClearSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_IsAuthenticated(t *testing.T) {
|
func TestManager_IsAuthenticated(t *testing.T) {
|
||||||
mgr, _ := NewManager("test-signing-key-12345", false)
|
mgr, _ := NewManager("test-signing-key-12345")
|
||||||
|
|
||||||
// No session - should return false
|
// No session - should return false
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
@@ -175,8 +175,7 @@ func TestManager_IsAuthenticated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_CookieAttributes(t *testing.T) {
|
func TestManager_CookieAttributes(t *testing.T) {
|
||||||
// Test with secure=true
|
mgr, _ := NewManager("test-key")
|
||||||
mgr, _ := NewManager("test-key", true)
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
_ = mgr.CreateSession(w)
|
_ = mgr.CreateSession(w)
|
||||||
|
|||||||
105
internal/signature/golden_test.go
Normal file
105
internal/signature/golden_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package signature
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goldenExpiresUnix is the fixed expiration timestamp used by all golden
|
||||||
|
// vectors: 2024-01-01T00:00:00Z.
|
||||||
|
const goldenExpiresUnix int64 = 1704067200
|
||||||
|
|
||||||
|
// goldenSigningKey is the fixed signing key used by all golden vectors.
|
||||||
|
const goldenSigningKey = "golden-test-key"
|
||||||
|
|
||||||
|
// TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output and
|
||||||
|
// the exact generated signed URL path for fully-specified requests with a
|
||||||
|
// hardcoded signing key. The expected values were computed once and are
|
||||||
|
// hardcoded here as known answers.
|
||||||
|
//
|
||||||
|
// If any of these assertions fail, the signed byte format
|
||||||
|
// ("host:path:query:width:height:format:expiration"), the base64url
|
||||||
|
// encoding, or the signed URL layout has changed. Such a change breaks
|
||||||
|
// every signature already issued to clients, so it must be made
|
||||||
|
// deliberately: update these constants only as part of an intentional,
|
||||||
|
// documented signature format migration.
|
||||||
|
func TestSigner_GoldenVectors(t *testing.T) {
|
||||||
|
signer := New(goldenSigningKey)
|
||||||
|
|
||||||
|
vectors := []struct {
|
||||||
|
name string
|
||||||
|
req Request
|
||||||
|
// wantSignature is the exact base64url (RFC 4648 URL-safe,
|
||||||
|
// padded) HMAC-SHA256 signature for the request with Expires
|
||||||
|
// set to goldenExpiresUnix.
|
||||||
|
wantSignature string
|
||||||
|
// wantSignedPath is the exact path returned by
|
||||||
|
// GenerateSignedURL for the request. The signature and
|
||||||
|
// expiration are returned separately by GenerateSignedURL and
|
||||||
|
// are not embedded in the path.
|
||||||
|
wantSignedPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "resized without query",
|
||||||
|
req: Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
},
|
||||||
|
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200"
|
||||||
|
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
|
||||||
|
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resized with query string",
|
||||||
|
req: Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
SourceQuery: "token=abc&v=2",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
},
|
||||||
|
// Signed data: "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
|
||||||
|
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
|
||||||
|
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg%3Ftoken=abc&v=2/800x600.webp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "original size without query",
|
||||||
|
req: Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 0,
|
||||||
|
Height: 0,
|
||||||
|
Format: "png",
|
||||||
|
},
|
||||||
|
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
|
||||||
|
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
|
||||||
|
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range vectors {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
signReq := tt.req
|
||||||
|
signReq.Expires = time.Unix(goldenExpiresUnix, 0)
|
||||||
|
|
||||||
|
gotSignature := signer.Sign(&signReq)
|
||||||
|
if gotSignature != tt.wantSignature {
|
||||||
|
t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
|
||||||
|
gotSignature, tt.wantSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
urlReq := tt.req
|
||||||
|
gotPath, _, _ := signer.GenerateSignedURL(&urlReq, time.Hour)
|
||||||
|
if gotPath != tt.wantSignedPath {
|
||||||
|
t.Errorf("GenerateSignedURL() path = %q, want %q (signed URL layout changed?)",
|
||||||
|
gotPath, tt.wantSignedPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
173
internal/signature/signature.go
Normal file
173
internal/signature/signature.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// Package signature provides HMAC-SHA256 signing and verification of image
|
||||||
|
// requests.
|
||||||
|
package signature
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signature errors.
|
||||||
|
var (
|
||||||
|
ErrRequired = errors.New("signature required for non-allowlisted host")
|
||||||
|
ErrInvalid = errors.New("invalid signature")
|
||||||
|
ErrExpired = errors.New("signature has expired")
|
||||||
|
ErrMissingExpiration = errors.New("signature expiration is required")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request carries the components an image request signature covers. It is a
|
||||||
|
// standalone type so that this package does not depend on imgcache, keeping
|
||||||
|
// the import edge one-way (imgcache depends on signature, never the reverse).
|
||||||
|
type Request struct {
|
||||||
|
// SourceHost is the origin host (e.g. "cdn.example.com").
|
||||||
|
SourceHost string
|
||||||
|
// SourcePath is the path on the origin (e.g. "/photos/cat.jpg").
|
||||||
|
SourcePath string
|
||||||
|
// SourceQuery is the optional query string for the origin URL.
|
||||||
|
SourceQuery string
|
||||||
|
// Width is the requested output width in pixels.
|
||||||
|
Width int
|
||||||
|
// Height is the requested output height in pixels.
|
||||||
|
Height int
|
||||||
|
// Format is the requested output format (e.g. "webp").
|
||||||
|
Format string
|
||||||
|
// Signature is the HMAC signature to verify.
|
||||||
|
Signature string
|
||||||
|
// Expires is the signature expiration timestamp.
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signer handles HMAC-SHA256 signature generation and verification.
|
||||||
|
type Signer struct {
|
||||||
|
secretKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Signer with the given secret key.
|
||||||
|
func New(secretKey string) *Signer {
|
||||||
|
return &Signer{
|
||||||
|
secretKey: []byte(secretKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign generates an HMAC-SHA256 signature for the given request.
|
||||||
|
// The signature covers: host + path + query + width + height + format + expiration.
|
||||||
|
func (s *Signer) Sign(req *Request) string {
|
||||||
|
data := s.buildSignatureData(req)
|
||||||
|
mac := hmac.New(sha256.New, s.secretKey)
|
||||||
|
mac.Write([]byte(data))
|
||||||
|
sig := mac.Sum(nil)
|
||||||
|
|
||||||
|
return base64.URLEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks if the signature on the request is valid and not expired.
|
||||||
|
// Signatures are exact-match only: every component of the signed data
|
||||||
|
// (host, path, query, dimensions, format, expiration) must match exactly.
|
||||||
|
// No suffix matching, wildcard matching, or partial matching is supported.
|
||||||
|
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
||||||
|
// "other.cdn.example.com", and vice versa.
|
||||||
|
func (s *Signer) Verify(req *Request) error {
|
||||||
|
// Check expiration first
|
||||||
|
if req.Expires.IsZero() {
|
||||||
|
return ErrMissingExpiration
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(req.Expires) {
|
||||||
|
return ErrExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute expected signature
|
||||||
|
expected := s.Sign(req)
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSignatureData creates the string to be signed.
|
||||||
|
// Format: "host:path:query:width:height:format:expiration"
|
||||||
|
// All components are used verbatim (exact match). No normalization,
|
||||||
|
// suffix matching, or wildcard expansion is performed.
|
||||||
|
func (s *Signer) buildSignatureData(req *Request) string {
|
||||||
|
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
|
||||||
|
req.SourceHost,
|
||||||
|
req.SourcePath,
|
||||||
|
req.SourceQuery,
|
||||||
|
req.Width,
|
||||||
|
req.Height,
|
||||||
|
req.Format,
|
||||||
|
req.Expires.Unix(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSignedURL creates a complete URL with signature and expiration.
|
||||||
|
// Returns the path portion that should be appended to the base URL.
|
||||||
|
func (s *Signer) GenerateSignedURL(req *Request, ttl time.Duration) (path string, sig string, exp int64) {
|
||||||
|
// Set expiration
|
||||||
|
req.Expires = time.Now().Add(ttl)
|
||||||
|
exp = req.Expires.Unix()
|
||||||
|
|
||||||
|
// Generate signature
|
||||||
|
sig = s.Sign(req)
|
||||||
|
req.Signature = sig
|
||||||
|
|
||||||
|
// Build the size component
|
||||||
|
var sizeStr string
|
||||||
|
if req.Width == 0 && req.Height == 0 {
|
||||||
|
sizeStr = "orig"
|
||||||
|
} else {
|
||||||
|
sizeStr = fmt.Sprintf("%dx%d", req.Width, req.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the path.
|
||||||
|
// When a source query is present, it is embedded as a path segment
|
||||||
|
// (e.g. /host/path?query/size.fmt) so that the URL parser can extract
|
||||||
|
// it from the last-slash split. The "?" inside a path segment is
|
||||||
|
// percent-encoded by clients but chi delivers it decoded, which is
|
||||||
|
// exactly what the URL parser expects.
|
||||||
|
if req.SourceQuery != "" {
|
||||||
|
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
|
||||||
|
req.SourceHost,
|
||||||
|
req.SourcePath,
|
||||||
|
url.PathEscape(req.SourceQuery),
|
||||||
|
sizeStr,
|
||||||
|
req.Format,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
path = fmt.Sprintf("/v1/image/%s%s/%s.%s",
|
||||||
|
req.SourceHost,
|
||||||
|
req.SourcePath,
|
||||||
|
sizeStr,
|
||||||
|
req.Format,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, sig, exp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseParams extracts signature and expiration from query parameters.
|
||||||
|
func ParseParams(sig, expStr string) (parsed string, expires time.Time, err error) {
|
||||||
|
parsed = sig
|
||||||
|
|
||||||
|
if expStr == "" {
|
||||||
|
return parsed, time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expUnix, err := strconv.ParseInt(expStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expires = time.Unix(expUnix, 0)
|
||||||
|
|
||||||
|
return parsed, expires, nil
|
||||||
|
}
|
||||||
530
internal/signature/signature_test.go
Normal file
530
internal/signature/signature_test.go
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
package signature
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSigner_Sign(t *testing.T) {
|
||||||
|
signer := New("test-secret-key")
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
|
||||||
|
}
|
||||||
|
|
||||||
|
sig1 := signer.Sign(req)
|
||||||
|
sig2 := signer.Sign(req)
|
||||||
|
|
||||||
|
// Same input should produce same signature
|
||||||
|
if sig1 != sig2 {
|
||||||
|
t.Errorf("Sign() produced different signatures for same input: %q vs %q", sig1, sig2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature should be non-empty
|
||||||
|
if sig1 == "" {
|
||||||
|
t.Error("Sign() produced empty signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different input should produce different signature
|
||||||
|
req2 := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/dog.jpg", // Different path
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Unix(1704067200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
sig3 := signer.Sign(req2)
|
||||||
|
if sig1 == sig3 {
|
||||||
|
t.Error("Sign() produced same signature for different input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSigner_Verify(t *testing.T) {
|
||||||
|
signer := New("test-secret-key")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func() *Request
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid signature",
|
||||||
|
setup: func() *Request {
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired signature",
|
||||||
|
setup: func() *Request {
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Now().Add(-1 * time.Hour), // Expired
|
||||||
|
}
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
wantErr: ErrExpired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid signature",
|
||||||
|
setup: func() *Request {
|
||||||
|
return &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
Signature: "invalid-signature",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: ErrInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing expiration",
|
||||||
|
setup: func() *Request {
|
||||||
|
return &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Signature: "some-signature",
|
||||||
|
// Expires is zero
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: ErrMissingExpiration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tampered request",
|
||||||
|
setup: func() *Request {
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
// Tamper with the request
|
||||||
|
req.SourcePath = "/photos/secret.jpg"
|
||||||
|
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
wantErr: ErrInvalid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := tt.setup()
|
||||||
|
err := signer.Verify(req)
|
||||||
|
|
||||||
|
if tt.wantErr == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Verify() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != tt.wantErr {
|
||||||
|
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_Verify_ExactMatchOnly verifies that signatures enforce exact
|
||||||
|
// matching on every URL component. No suffix matching, wildcard matching,
|
||||||
|
// or partial matching is supported.
|
||||||
|
func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
|
||||||
|
signer := New("test-secret-key")
|
||||||
|
|
||||||
|
// Base request that we'll sign, then tamper with individual fields.
|
||||||
|
baseReq := func() *Request {
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
SourceQuery: "token=abc",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tamper func(req *Request)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "parent domain does not match subdomain",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
// Signed for cdn.example.com, try example.com
|
||||||
|
req.SourceHost = "example.com"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain does not match parent domain",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
// Signed for cdn.example.com, try images.cdn.example.com
|
||||||
|
req.SourceHost = "images.cdn.example.com"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sibling subdomain does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
// Signed for cdn.example.com, try images.example.com
|
||||||
|
req.SourceHost = "images.example.com"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host with suffix appended does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
// Signed for cdn.example.com, try cdn.example.com.evil.com
|
||||||
|
req.SourceHost = "cdn.example.com.evil.com"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host with prefix does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
// Signed for cdn.example.com, try evilcdn.example.com
|
||||||
|
req.SourceHost = "evilcdn.example.com"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different path does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.SourcePath = "/photos/dog.jpg"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path suffix does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.SourcePath = "/photos/cat.jpg/extra"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path prefix does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.SourcePath = "/other/photos/cat.jpg"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different query does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.SourceQuery = "token=xyz"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "added query does not match empty query",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.SourceQuery = "extra=1"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removed query does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.SourceQuery = ""
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different width does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.Width = 801
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different height does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.Height = 601
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different format does not match",
|
||||||
|
tamper: func(req *Request) {
|
||||||
|
req.Format = "png"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := baseReq()
|
||||||
|
tt.tamper(req)
|
||||||
|
|
||||||
|
err := signer.Verify(req)
|
||||||
|
if err != ErrInvalid {
|
||||||
|
t.Errorf("Verify() = %v, want %v", err, ErrInvalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the unmodified base request still passes
|
||||||
|
t.Run("unmodified request passes", func(t *testing.T) {
|
||||||
|
req := baseReq()
|
||||||
|
if err := signer.Verify(req); err != nil {
|
||||||
|
t.Errorf("Verify() unmodified request failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_Sign_ExactHostInData verifies that Sign uses the exact host
|
||||||
|
// string in the signature data, producing different signatures for
|
||||||
|
// suffix-related hosts.
|
||||||
|
func TestSigner_Sign_ExactHostInData(t *testing.T) {
|
||||||
|
signer := New("test-secret-key")
|
||||||
|
|
||||||
|
hosts := []string{
|
||||||
|
"cdn.example.com",
|
||||||
|
"example.com",
|
||||||
|
"images.example.com",
|
||||||
|
"images.cdn.example.com",
|
||||||
|
"cdn.example.com.evil.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs := make(map[string]string)
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: host,
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Unix(1704067200, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := signer.Sign(req)
|
||||||
|
if existing, ok := sigs[sig]; ok {
|
||||||
|
t.Errorf("hosts %q and %q produced the same signature", existing, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs[sig] = host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSigner_DifferentKeys(t *testing.T) {
|
||||||
|
signer1 := New("secret-key-1")
|
||||||
|
signer2 := New("secret-key-2")
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign with key 1
|
||||||
|
req.Signature = signer1.Sign(req)
|
||||||
|
|
||||||
|
// Verify with key 1 should succeed
|
||||||
|
if err := signer1.Verify(req); err != nil {
|
||||||
|
t.Errorf("Verify() with same key failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with key 2 should fail
|
||||||
|
if err := signer2.Verify(req); err != ErrInvalid {
|
||||||
|
t.Errorf("Verify() with different key should fail, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignedURL(t *testing.T) {
|
||||||
|
signer := New("test-secret-key")
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
SourceQuery: "",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := 1 * time.Hour
|
||||||
|
path, sig, exp := signer.GenerateSignedURL(req, ttl)
|
||||||
|
|
||||||
|
// Path should be correct format
|
||||||
|
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
||||||
|
if path != expectedPath {
|
||||||
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature should be non-empty
|
||||||
|
if sig == "" {
|
||||||
|
t.Error("GenerateSignedURL() produced empty signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiration should be approximately now + TTL
|
||||||
|
expTime := time.Unix(exp, 0)
|
||||||
|
expectedExp := time.Now().Add(ttl)
|
||||||
|
if expTime.Sub(expectedExp) > time.Second {
|
||||||
|
t.Errorf("GenerateSignedURL() exp time off by too much")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request should have been updated with signature and expiration
|
||||||
|
if req.Signature != sig {
|
||||||
|
t.Errorf("GenerateSignedURL() didn't update request signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignedURL_OrigSize(t *testing.T) {
|
||||||
|
signer := New("test-secret-key")
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Width: 0, // Original size
|
||||||
|
Height: 0,
|
||||||
|
Format: "png",
|
||||||
|
}
|
||||||
|
|
||||||
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
||||||
|
|
||||||
|
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
|
||||||
|
if path != expectedPath {
|
||||||
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
|
||||||
|
signer := New("test-secret-key-for-testing!")
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
SourceQuery: "token=abc&v=2",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
||||||
|
|
||||||
|
// The path must NOT contain a bare "?" that would be interpreted as a query string delimiter.
|
||||||
|
// The size segment must appear as the last path component.
|
||||||
|
if strings.Contains(path, "?token=abc") {
|
||||||
|
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The size segment must be present in the path
|
||||||
|
if !strings.Contains(path, "/800x600.webp") {
|
||||||
|
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path should end with the size.format, not with query params
|
||||||
|
if !strings.HasSuffix(path, "/800x600.webp") {
|
||||||
|
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
|
||||||
|
signer := New("test-secret-key-for-testing!")
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: "webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
|
||||||
|
|
||||||
|
expected := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
|
||||||
|
if path != expected {
|
||||||
|
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseParams(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sig string
|
||||||
|
expStr string
|
||||||
|
wantSig string
|
||||||
|
wantErr bool
|
||||||
|
checkTime bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid params",
|
||||||
|
sig: "abc123",
|
||||||
|
expStr: "1704067200",
|
||||||
|
wantSig: "abc123",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty expiration",
|
||||||
|
sig: "abc123",
|
||||||
|
expStr: "",
|
||||||
|
wantSig: "abc123",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid expiration",
|
||||||
|
sig: "abc123",
|
||||||
|
expStr: "not-a-number",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
sig, exp, err := ParseParams(tt.sig, tt.expStr)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Error("ParseParams() expected error, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseParams() unexpected error = %v", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sig != tt.wantSig {
|
||||||
|
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.expStr != "" && exp.IsZero() {
|
||||||
|
t.Error("exp should not be zero when expStr is provided")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
138
script/bootstrap
Executable file
138
script/bootstrap
Executable file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/bootstrap: install all dependencies needed to build and develop
|
||||||
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
|
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
||||||
|
# it is installed from a hash-verified GitHub release archive (never
|
||||||
|
# curl | sh). CGO image libraries (pkg-config, vips, libheif) are
|
||||||
|
# installed for the govips bindings.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# Pinned versions, 2026-07-07. Never "latest"; exact versions only.
|
||||||
|
GOLANGCI_LINT_VERSION="2.10.1"
|
||||||
|
# sha256 of golangci-lint-2.10.1-linux-<arch>.tar.gz release archives
|
||||||
|
GOLANGCI_LINT_SHA256_AMD64="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99"
|
||||||
|
GOLANGCI_LINT_SHA256_ARM64="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8"
|
||||||
|
|
||||||
|
PKGMGR=""
|
||||||
|
SUDO=""
|
||||||
|
|
||||||
|
detect_pkgmgr() {
|
||||||
|
[ -n "$PKGMGR" ] && return 0
|
||||||
|
if command -v nix-env >/dev/null 2>&1; then
|
||||||
|
PKGMGR="nix"
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
PKGMGR="apt"
|
||||||
|
elif command -v brew >/dev/null 2>&1; then
|
||||||
|
PKGMGR="brew"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
PKGMGR="apk"
|
||||||
|
else
|
||||||
|
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$PKGMGR" = "apt" ]; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if [ "$(id -u)" != "0" ]; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
||||||
|
pkg_install() {
|
||||||
|
detect_pkgmgr
|
||||||
|
case "$PKGMGR" in
|
||||||
|
nix) nix-env -iA "nixpkgs.$1" ;;
|
||||||
|
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
||||||
|
brew) brew install "$3" ;;
|
||||||
|
apk) apk add --no-cache "$4" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
missing() {
|
||||||
|
! command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# verify_sha256 <file> <expected-hash>
|
||||||
|
verify_sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
actual="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||||
|
fi
|
||||||
|
if [ "$actual" != "$2" ]; then
|
||||||
|
echo "bootstrap: sha256 mismatch for $1" >&2
|
||||||
|
echo " expected: $2" >&2
|
||||||
|
echo " actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# apt has no golangci-lint package: install a pinned release archive
|
||||||
|
# from GitHub, verified by hardcoded sha256 (never curl | sh).
|
||||||
|
install_golangci_lint_release() {
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
|
||||||
|
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
|
||||||
|
*)
|
||||||
|
echo "bootstrap: unsupported architecture $(uname -m)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if missing curl; then pkg_install curl curl curl curl; fi
|
||||||
|
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL -o "$tmp/$name.tar.gz" \
|
||||||
|
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
|
||||||
|
verify_sha256 "$tmp/$name.tar.gz" "$sha"
|
||||||
|
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
||||||
|
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_golangci_lint() {
|
||||||
|
if ! missing golangci-lint; then return 0; fi
|
||||||
|
detect_pkgmgr
|
||||||
|
case "$PKGMGR" in
|
||||||
|
apt) install_golangci_lint_release ;;
|
||||||
|
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# CGO dependencies for govips (image processing)
|
||||||
|
ensure_cgo_deps() {
|
||||||
|
if missing pkg-config; then
|
||||||
|
pkg_install pkg-config pkg-config pkg-config pkgconfig
|
||||||
|
fi
|
||||||
|
if ! pkg-config --exists vips; then
|
||||||
|
pkg_install vips libvips-dev vips vips-dev
|
||||||
|
fi
|
||||||
|
if ! pkg-config --exists libheif; then
|
||||||
|
pkg_install libheif libheif-dev libheif libheif-dev
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Base tooling
|
||||||
|
if missing git; then pkg_install git git git git; fi
|
||||||
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
|
|
||||||
|
# Go toolchain and linter
|
||||||
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
ensure_golangci_lint
|
||||||
|
|
||||||
|
# CGO image libraries
|
||||||
|
ensure_cgo_deps
|
||||||
|
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
echo "bootstrap complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
15
script/check
Executable file
15
script/check
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||||
|
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||||
|
# Generic: usually needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
"$SCRIPT_DIR/test"
|
||||||
|
"$SCRIPT_DIR/lint"
|
||||||
|
"$SCRIPT_DIR/fmt-check"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
15
script/cibuild
Executable file
15
script/cibuild
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/cibuild: run the CI build. The Dockerfile runs the checks
|
||||||
|
# (make fmt-check, lint, test), so a successful build implies a green
|
||||||
|
# repo. Generic: needs no adaptation. The Gitea workflow runs this on
|
||||||
|
# push.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
15
script/docker
Executable file
15
script/docker
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
|
# Generic: needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
14
script/fmt
Executable file
14
script/fmt
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fmt: format all files (writes).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Formatting code..."
|
||||||
|
# shellcheck disable=SC2046 # word splitting of file list is wanted
|
||||||
|
gofmt -w $(find . -name '*.go' -not -path './vendor/*')
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
18
script/fmt-check
Executable file
18
script/fmt-check
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fmt-check: check formatting (read-only). Same scope as
|
||||||
|
# script/fmt, but fails instead of writing.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Checking formatting..."
|
||||||
|
if [ -n "$(gofmt -l . | grep -v '^vendor/')" ]; then
|
||||||
|
echo "Files need formatting:"
|
||||||
|
gofmt -l . | grep -v '^vendor/'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
16
script/install-precommit
Executable file
16
script/install-precommit
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/install-precommit: install the git pre-commit hook that runs
|
||||||
|
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
||||||
|
# Generic: needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
|
||||||
|
chmod +x .git/hooks/pre-commit
|
||||||
|
echo "pre-commit hook installed: runs script/precommit"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
23
script/lint
Executable file
23
script/lint
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/lint: run the linter. CGO dependencies (pkg-config, vips,
|
||||||
|
# libheif) come from nix-shell when not already available (e.g. inside
|
||||||
|
# a Docker build or an existing nix-shell).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
run_with_cgo_deps() {
|
||||||
|
if command -v pkg-config >/dev/null 2>&1; then
|
||||||
|
sh -c "$1"
|
||||||
|
else
|
||||||
|
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Running linter..."
|
||||||
|
run_with_cgo_deps "golangci-lint run"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
21
script/precommit
Executable file
21
script/precommit
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/precommit: run by the git pre-commit hook; fails the commit if
|
||||||
|
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
|
||||||
|
# extras: go mod tidy must not change go.mod/go.sum.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code -- go.mod go.sum || {
|
||||||
|
echo "precommit: go mod tidy changed go.mod/go.sum;" \
|
||||||
|
"stage the changes and retry" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
"$SCRIPT_DIR/check"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
12
script/projectname
Executable file
12
script/projectname
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/projectname: output the name of this project. Our own
|
||||||
|
# extension to scripts-to-rule-them-all. Other scripts that need the
|
||||||
|
# name (e.g. script/docker) call this, so they can stay identical
|
||||||
|
# across all repos.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "pixa"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
14
script/setup
Executable file
14
script/setup
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/setup: set up the repo for development after a fresh clone:
|
||||||
|
# installs dependencies (script/bootstrap) and the git pre-commit hook.
|
||||||
|
# Add any repo-specific initialization (db init, .env template) here.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
"$SCRIPT_DIR/bootstrap"
|
||||||
|
"$SCRIPT_DIR/install-precommit"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
23
script/test
Executable file
23
script/test
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/test: run the test suite. CGO dependencies (pkg-config, vips,
|
||||||
|
# libheif) come from nix-shell when not already available (e.g. inside
|
||||||
|
# a Docker build or an existing nix-shell).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
run_with_cgo_deps() {
|
||||||
|
if command -v pkg-config >/dev/null 2>&1; then
|
||||||
|
sh -c "$1"
|
||||||
|
else
|
||||||
|
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "Running tests..."
|
||||||
|
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -v ./..."
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -97,8 +97,8 @@ else
|
|||||||
fail "No encrypted URL to test"
|
fail "No encrypted URL to test"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 7: Fetch image via whitelisted host (direct proxy)
|
# Test 7: Fetch image via allowlisted host (direct proxy)
|
||||||
echo "--- Test 7: Fetch image via direct proxy (whitelisted host) ---"
|
echo "--- Test 7: Fetch image via direct proxy (allowlisted host) ---"
|
||||||
# URL format: /v1/image/<host>/<path>/<WxH>.<format>
|
# URL format: /v1/image/<host>/<path>/<WxH>.<format>
|
||||||
PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg"
|
PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg"
|
||||||
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH")
|
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH")
|
||||||
|
|||||||
Reference in New Issue
Block a user