1 Commits

Author SHA1 Message Date
744f20861a Update TODO.md: standard structure and Workflow section
All checks were successful
check / check (push) Successful in 1m42s
2026-07-06 21:09:14 +02:00
33 changed files with 271 additions and 1248 deletions

View File

@@ -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: script/cibuild - run: docker build .

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-versioned docker-test devserver devserver-stop hooks .PHONY: check lint test fmt fmt-check build clean docker 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,30 +15,27 @@ else
endif endif
# Default target: run all checks # Default target: run all checks
check: check: fmt-check lint test
@script/check
bootstrap:
@script/bootstrap
setup:
@script/setup
# Check formatting without modifying files # Check formatting without modifying files
fmt-check: fmt-check:
@script/fmt-check @echo "Checking formatting..."
@test -z "$$(gofmt -l . | grep -v '^vendor/')" || (echo "Files need formatting:"; gofmt -l . | grep -v '^vendor/'; exit 1)
# Format code # Format code
fmt: fmt:
@script/fmt @echo "Formatting code..."
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
# Run linter # Run linter
lint: lint:
@script/lint @echo "Running linter..."
$(NIX_RUN_PREFIX)golangci-lint run$(NIX_RUN_SUFFIX)
# Run tests (30-second timeout) # Run tests (30-second timeout)
test: test:
@script/test @echo "Running tests..."
$(NIX_RUN_PREFIX)CGO_ENABLED=1 go test -timeout 30s -v ./...$(NIX_RUN_SUFFIX)
# Build the binary # Build the binary
build: build:
@@ -50,12 +47,8 @@ clean:
rm -rf bin/ rm -rf bin/
rm -rf ./data rm -rf ./data
# Build Docker image (tagged via script/projectname) # Build Docker image
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)
@@ -64,7 +57,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-versioned devserver-stop devserver: docker 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
@@ -77,4 +70,6 @@ devserver-stop:
# Install pre-commit hook # Install pre-commit hook
hooks: hooks:
@script/install-precommit @printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit

View File

@@ -128,31 +128,6 @@ 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.

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-07-06 last_modified: 2026-02-22
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -34,46 +34,10 @@ 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 bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), `make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), `make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile `make hooks` (installs pre-commit hook). A model Makefile is at
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. `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
@@ -93,83 +57,11 @@ 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. Dockerfiles install development stage before the final image is assembled.
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 `script/cibuild` (which runs `docker build .`) on push. Since the runs `docker build .` on push. Since the Dockerfile already runs `make check`,
Dockerfile already runs `make check`, a successful build implies all checks a successful build implies all checks pass.
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
@@ -177,11 +69,9 @@ 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: runs `script/precommit`, which calls `script/check`. If local - Pre-commit hook: `make check` if local testing is possible, otherwise
testing is not possible in the repo, `script/precommit` may skip `script/test` `make lint && make fmt-check`. The Makefile should provide a `make hooks`
and run only `script/lint` and `script/fmt-check`. The hook is installed by target to install the pre-commit hook.
`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
@@ -192,42 +82,6 @@ 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
@@ -244,13 +98,6 @@ 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`.
@@ -274,76 +121,12 @@ 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
@@ -361,14 +144,8 @@ 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. the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
- `000_migration.sql` — contains ONLY the creation of the migrations Post-1.0.0: add new migration files.
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.
@@ -398,9 +175,6 @@ 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`

View File

@@ -26,8 +26,6 @@ individually.
# Completed Steps # Completed Steps
- 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-04-07 extract magic byte detection into internal/magic (#42)
- 2026-03-25 extract allowlist package from internal/imgcache (#41) - 2026-03-25 extract allowlist package from internal/imgcache (#41)
- 2026-03-25 move schema_migrations table creation into 000.sql (#36) - 2026-03-25 move schema_migrations table creation into 000.sql (#36)

View File

@@ -13,7 +13,6 @@ 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"
@@ -73,7 +72,7 @@ func (s *Handlers) initImageService() error {
s.imgCache = cache s.imgCache = cache
// Create the fetcher config // Create the fetcher config
fetcherCfg := httpfetcher.DefaultConfig() fetcherCfg := imgcache.DefaultFetcherConfig()
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

View File

@@ -18,7 +18,6 @@ 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"
) )
@@ -117,16 +116,16 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
return &mockFetcher{fs: fs} return &mockFetcher{fs: fs}
} }
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*httpfetcher.FetchResult, error) { func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.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, httpfetcher.ErrUpstreamError return nil, imgcache.ErrUpstreamError
} }
return &httpfetcher.FetchResult{ return &imgcache.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",

View File

@@ -8,7 +8,6 @@ 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"
) )
@@ -98,13 +97,13 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
) )
// Check for specific error types // Check for specific error types
if errors.Is(err, httpfetcher.ErrSSRFBlocked) { if errors.Is(err, imgcache.ErrSSRFBlocked) {
s.respondError(w, "forbidden", http.StatusForbidden) s.respondError(w, "forbidden", http.StatusForbidden)
return return
} }
if errors.Is(err, httpfetcher.ErrUpstreamError) { if errors.Is(err, imgcache.ErrUpstreamError) {
s.respondError(w, "upstream error", http.StatusBadGateway) s.respondError(w, "upstream error", http.StatusBadGateway)
return return

View File

@@ -11,7 +11,6 @@ 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"
) )
@@ -101,11 +100,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, httpfetcher.ErrSSRFBlocked): case errors.Is(err, imgcache.ErrSSRFBlocked):
s.respondError(w, "forbidden", http.StatusForbidden) s.respondError(w, "forbidden", http.StatusForbidden)
case errors.Is(err, httpfetcher.ErrUpstreamError): case errors.Is(err, imgcache.ErrUpstreamError):
s.respondError(w, "upstream error", http.StatusBadGateway) s.respondError(w, "upstream error", http.StatusBadGateway)
case errors.Is(err, httpfetcher.ErrUpstreamTimeout): case errors.Is(err, imgcache.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)

View File

@@ -1,329 +0,0 @@
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
}

View File

@@ -9,8 +9,6 @@ import (
"io" "io"
"path/filepath" "path/filepath"
"time" "time"
"sneak.berlin/go/pixa/internal/httpfetcher"
) )
// Cache errors. // Cache errors.
@@ -113,7 +111,7 @@ func (c *Cache) StoreSource(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *ImageRequest,
content io.Reader, content io.Reader,
result *httpfetcher.FetchResult, result *FetchResult,
) (ContentHash, error) { ) (ContentHash, error) {
// Store content // Store content
contentHash, size, err := c.srcContent.Store(content) contentHash, size, err := c.srcContent.Store(content)

View File

@@ -9,7 +9,6 @@ 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 {
@@ -153,7 +152,7 @@ func TestCache_StoreAndLookup(t *testing.T) {
// Store source content // Store source content
sourceContent := []byte("fake jpeg data") sourceContent := []byte("fake jpeg data")
fetchResult := &httpfetcher.FetchResult{ fetchResult := &FetchResult{
ContentType: "image/jpeg", ContentType: "image/jpeg",
Headers: map[string][]string{"Content-Type": {"image/jpeg"}}, Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
} }

View File

@@ -1,6 +1,4 @@
// Package httpfetcher fetches content from upstream HTTP origins with SSRF package imgcache
// protection, per-host connection limits, and content-type validation.
package httpfetcher
import ( import (
"context" "context"
@@ -39,55 +37,25 @@ var (
ErrUpstreamTimeout = errors.New("upstream request timeout") ErrUpstreamTimeout = errors.New("upstream request timeout")
) )
// Fetcher retrieves content from upstream origins. // FetcherConfig holds configuration for the upstream fetcher.
type Fetcher interface { type FetcherConfig struct {
// Fetch retrieves content from the given URL. // Timeout for upstream requests
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 an allow list of MIME types to accept. // AllowedContentTypes is a whitelist 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
} }
// DefaultConfig returns a Config with sensible defaults. // DefaultFetcherConfig returns sensible defaults.
func DefaultConfig() *Config { func DefaultFetcherConfig() *FetcherConfig {
return &Config{ return &FetcherConfig{
Timeout: DefaultFetchTimeout, Timeout: DefaultFetchTimeout,
MaxResponseSize: DefaultMaxResponseSize, MaxResponseSize: DefaultMaxResponseSize,
UserAgent: "pixa/1.0", UserAgent: "pixa/1.0",
@@ -104,18 +72,18 @@ func DefaultConfig() *Config {
} }
} }
// HTTPFetcher implements Fetcher with SSRF protection and per-host connection limits. // HTTPFetcher implements the Fetcher interface with SSRF protection.
type HTTPFetcher struct { type HTTPFetcher struct {
client *http.Client client *http.Client
config *Config config *FetcherConfig
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
} }
// New creates a new HTTPFetcher with SSRF protection. // NewHTTPFetcher creates a new fetcher with SSRF protection.
func New(config *Config) *HTTPFetcher { func NewHTTPFetcher(config *FetcherConfig) *HTTPFetcher {
if config == nil { if config == nil {
config = DefaultConfig() config = DefaultFetcherConfig()
} }
// Create transport with SSRF-safe dialer // Create transport with SSRF-safe dialer
@@ -282,7 +250,7 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
}, nil }, nil
} }
// isAllowedContentType checks if the content type is in the allow list. // isAllowedContentType checks if the content type is in the whitelist.
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])

View File

@@ -169,6 +169,36 @@ type Whitelist interface {
IsWhitelisted(u *url.URL) bool IsWhitelisted(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
}
// Storage handles persistent storage of cached content // Storage handles persistent storage of cached content
type Storage interface { type Storage interface {
// Store saves content and returns its hash // Store saves content and returns its hash

View File

@@ -1,4 +1,4 @@
package httpfetcher package imgcache
import ( import (
"context" "context"
@@ -10,15 +10,15 @@ import (
"strings" "strings"
) )
// MockFetcher implements Fetcher using an embedded filesystem. // MockFetcher implements the Fetcher interface 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
} }
// NewMock creates a new mock fetcher backed by the given filesystem. // NewMockFetcher creates a new mock fetcher backed by the given filesystem.
func NewMock(fsys fs.FS) *MockFetcher { func NewMockFetcher(fsys fs.FS) *MockFetcher {
return &MockFetcher{fs: fsys} return &MockFetcher{fs: fsys}
} }

View File

@@ -12,18 +12,16 @@ import (
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"sneak.berlin/go/pixa/internal/allowlist" "sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/magic" "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 httpfetcher.Fetcher fetcher Fetcher
processor *imageprocessor.ImageProcessor processor *imageprocessor.ImageProcessor
signer *signature.Signer signer *Signer
allowlist *allowlist.HostAllowList allowlist *allowlist.HostAllowList
log *slog.Logger log *slog.Logger
allowHTTP bool allowHTTP bool
@@ -35,9 +33,9 @@ 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 *httpfetcher.Config FetcherConfig *FetcherConfig
// Fetcher is an optional custom fetcher (for testing) // Fetcher is an optional custom fetcher (for testing)
Fetcher httpfetcher.Fetcher Fetcher 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 // Whitelist is the list of hosts that don't require signatures
@@ -59,18 +57,18 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
// Resolve fetcher config for defaults // Resolve fetcher config for defaults
fetcherCfg := cfg.FetcherConfig fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil { if fetcherCfg == nil {
fetcherCfg = httpfetcher.DefaultConfig() fetcherCfg = DefaultFetcherConfig()
} }
// Use custom fetcher if provided, otherwise create HTTP fetcher // Use custom fetcher if provided, otherwise create HTTP fetcher
var fetcher httpfetcher.Fetcher var fetcher Fetcher
if cfg.Fetcher != nil { if cfg.Fetcher != nil {
fetcher = cfg.Fetcher fetcher = cfg.Fetcher
} else { } else {
fetcher = httpfetcher.New(fetcherCfg) fetcher = NewHTTPFetcher(fetcherCfg)
} }
signer := signature.New(cfg.SigningKey) signer := NewSigner(cfg.SigningKey)
log := cfg.Logger log := cfg.Logger
if log == nil { if log == nil {
@@ -115,7 +113,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
"path", req.SourcePath, "path", req.SourcePath,
) )
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached) return nil, fmt.Errorf("%w: %w", ErrUpstreamError, ErrNegativeCached)
} }
// Check variant cache first (disk only, no DB) // Check variant cache first (disk only, no DB)
@@ -398,7 +396,7 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
} }
// Signature required for non-allowed hosts // Signature required for non-allowed hosts
return s.signer.Verify(signatureRequest(req)) return s.signer.Verify(req)
} }
// GenerateSignedURL generates a signed URL for the given request. // GenerateSignedURL generates a signed URL for the given request.
@@ -407,32 +405,11 @@ func (s *Service) GenerateSignedURL(
req *ImageRequest, req *ImageRequest,
ttl time.Duration, ttl time.Duration,
) (string, error) { ) (string, error) {
sigReq := signatureRequest(req) path, sig, exp := s.signer.GenerateSignedURL(req, ttl)
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
@@ -441,13 +418,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, httpfetcher.ErrUpstreamError) return errors.Is(err, 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, httpfetcher.ErrUpstreamError) { if errors.Is(err, ErrUpstreamError) {
return httpStatusBadGateway return httpStatusBadGateway
} }

View File

@@ -7,7 +7,6 @@ import (
"time" "time"
"sneak.berlin/go/pixa/internal/magic" "sneak.berlin/go/pixa/internal/magic"
"sneak.berlin/go/pixa/internal/signature"
) )
func TestService_Get_WhitelistedHost(t *testing.T) { func TestService_Get_WhitelistedHost(t *testing.T) {
@@ -78,9 +77,9 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
} }
// Generate a valid signature // Generate a valid signature
signer := signature.New(signingKey) signer := NewSigner(signingKey)
req.Expires = time.Now().Add(time.Hour) req.Expires = time.Now().Add(time.Hour)
req.Signature = signer.Sign(signatureRequest(req)) req.Signature = signer.Sign(req)
// Should pass validation // Should pass validation
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -119,9 +118,9 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
} }
// Generate an expired signature // Generate an expired signature
signer := signature.New(signingKey) signer := NewSigner(signingKey)
req.Expires = time.Now().Add(-time.Hour) // Already expired req.Expires = time.Now().Add(-time.Hour) // Already expired
req.Signature = signer.Sign(signatureRequest(req)) req.Signature = signer.Sign(req)
// Should fail validation // Should fail validation
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -165,7 +164,7 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
WithNoWhitelist(), WithNoWhitelist(),
) )
signer := signature.New(signingKey) signer := NewSigner(signingKey)
// Sign a request for "cdn.example.com" // Sign a request for "cdn.example.com"
signedReq := &ImageRequest{ signedReq := &ImageRequest{
@@ -177,7 +176,7 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
FitMode: FitCover, FitMode: FitCover,
Expires: time.Now().Add(time.Hour), Expires: time.Now().Add(time.Hour),
} }
signedReq.Signature = signer.Sign(signatureRequest(signedReq)) signedReq.Signature = signer.Sign(signedReq)
// The original request should pass validation // The original request should pass validation
t.Run("exact host passes", func(t *testing.T) { t.Run("exact host passes", func(t *testing.T) {

View File

@@ -1,6 +1,4 @@
// Package signature provides HMAC-SHA256 signing and verification of image package imgcache
// requests.
package signature
import ( import (
"crypto/hmac" "crypto/hmac"
@@ -15,49 +13,27 @@ import (
// Signature errors. // Signature errors.
var ( var (
ErrRequired = errors.New("signature required for non-allowlisted host") ErrSignatureRequired = errors.New("signature required for non-whitelisted host")
ErrInvalid = errors.New("invalid signature") ErrSignatureInvalid = errors.New("invalid signature")
ErrExpired = errors.New("signature has expired") ErrSignatureExpired = errors.New("signature has expired")
ErrMissingExpiration = errors.New("signature expiration is required") 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. // Signer handles HMAC-SHA256 signature generation and verification.
type Signer struct { type Signer struct {
secretKey []byte secretKey []byte
} }
// New creates a new Signer with the given secret key. // NewSigner creates a new Signer with the given secret key.
func New(secretKey string) *Signer { func NewSigner(secretKey string) *Signer {
return &Signer{ return &Signer{
secretKey: []byte(secretKey), secretKey: []byte(secretKey),
} }
} }
// Sign generates an HMAC-SHA256 signature for the given request. // Sign generates an HMAC-SHA256 signature for the given image request.
// The signature covers: host + path + query + width + height + format + expiration. // The signature covers: host + path + query + width + height + format + expiration.
func (s *Signer) Sign(req *Request) string { func (s *Signer) Sign(req *ImageRequest) string {
data := s.buildSignatureData(req) data := s.buildSignatureData(req)
mac := hmac.New(sha256.New, s.secretKey) mac := hmac.New(sha256.New, s.secretKey)
mac.Write([]byte(data)) mac.Write([]byte(data))
@@ -72,14 +48,14 @@ func (s *Signer) Sign(req *Request) string {
// No suffix matching, wildcard matching, or partial matching is supported. // No suffix matching, wildcard matching, or partial matching is supported.
// A signature for "cdn.example.com" will NOT verify for "example.com" or // A signature for "cdn.example.com" will NOT verify for "example.com" or
// "other.cdn.example.com", and vice versa. // "other.cdn.example.com", and vice versa.
func (s *Signer) Verify(req *Request) error { func (s *Signer) Verify(req *ImageRequest) error {
// Check expiration first // Check expiration first
if req.Expires.IsZero() { if req.Expires.IsZero() {
return ErrMissingExpiration return ErrMissingExpiration
} }
if time.Now().After(req.Expires) { if time.Now().After(req.Expires) {
return ErrExpired return ErrSignatureExpired
} }
// Compute expected signature // Compute expected signature
@@ -87,7 +63,7 @@ func (s *Signer) Verify(req *Request) error {
// Constant-time comparison to prevent timing attacks // Constant-time comparison to prevent timing attacks
if !hmac.Equal([]byte(req.Signature), []byte(expected)) { if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
return ErrInvalid return ErrSignatureInvalid
} }
return nil return nil
@@ -97,13 +73,13 @@ func (s *Signer) Verify(req *Request) error {
// Format: "host:path:query:width:height:format:expiration" // Format: "host:path:query:width:height:format:expiration"
// All components are used verbatim (exact match). No normalization, // All components are used verbatim (exact match). No normalization,
// suffix matching, or wildcard expansion is performed. // suffix matching, or wildcard expansion is performed.
func (s *Signer) buildSignatureData(req *Request) string { func (s *Signer) buildSignatureData(req *ImageRequest) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d", return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
req.SourceHost, req.SourceHost,
req.SourcePath, req.SourcePath,
req.SourceQuery, req.SourceQuery,
req.Width, req.Size.Width,
req.Height, req.Size.Height,
req.Format, req.Format,
req.Expires.Unix(), req.Expires.Unix(),
) )
@@ -111,7 +87,7 @@ func (s *Signer) buildSignatureData(req *Request) string {
// GenerateSignedURL creates a complete URL with signature and expiration. // GenerateSignedURL creates a complete URL with signature and expiration.
// Returns the path portion that should be appended to the base URL. // 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) { func (s *Signer) GenerateSignedURL(req *ImageRequest, ttl time.Duration) (path string, sig string, exp int64) {
// Set expiration // Set expiration
req.Expires = time.Now().Add(ttl) req.Expires = time.Now().Add(ttl)
exp = req.Expires.Unix() exp = req.Expires.Unix()
@@ -122,15 +98,15 @@ func (s *Signer) GenerateSignedURL(req *Request, ttl time.Duration) (path string
// Build the size component // Build the size component
var sizeStr string var sizeStr string
if req.Width == 0 && req.Height == 0 { if req.Size.OriginalSize() {
sizeStr = "orig" sizeStr = "orig"
} else { } else {
sizeStr = fmt.Sprintf("%dx%d", req.Width, req.Height) sizeStr = fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height)
} }
// Build the path. // Build the path.
// When a source query is present, it is embedded as a path segment // 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 // (e.g. /host/path?query/size.fmt) so that ParseImagePath can extract
// it from the last-slash split. The "?" inside a path segment is // it from the last-slash split. The "?" inside a path segment is
// percent-encoded by clients but chi delivers it decoded, which is // percent-encoded by clients but chi delivers it decoded, which is
// exactly what the URL parser expects. // exactly what the URL parser expects.
@@ -154,12 +130,12 @@ func (s *Signer) GenerateSignedURL(req *Request, ttl time.Duration) (path string
return path, sig, exp return path, sig, exp
} }
// ParseParams extracts signature and expiration from query parameters. // ParseSignatureParams extracts signature and expiration from query parameters.
func ParseParams(sig, expStr string) (parsed string, expires time.Time, err error) { func ParseSignatureParams(sig, expStr string) (signature string, expires time.Time, err error) {
parsed = sig signature = sig
if expStr == "" { if expStr == "" {
return parsed, time.Time{}, nil return signature, time.Time{}, nil
} }
expUnix, err := strconv.ParseInt(expStr, 10, 64) expUnix, err := strconv.ParseInt(expStr, 10, 64)
@@ -169,5 +145,5 @@ func ParseParams(sig, expStr string) (parsed string, expires time.Time, err erro
expires = time.Unix(expUnix, 0) expires = time.Unix(expUnix, 0)
return parsed, expires, nil return signature, expires, nil
} }

View File

@@ -0,0 +1,55 @@
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)
}
}

View File

@@ -1,21 +1,19 @@
package signature package imgcache
import ( import (
"strings"
"testing" "testing"
"time" "time"
) )
func TestSigner_Sign(t *testing.T) { func TestSigner_Sign(t *testing.T) {
signer := New("test-secret-key") signer := NewSigner("test-secret-key")
req := &Request{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
SourceQuery: "", SourceQuery: "",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
} }
@@ -33,13 +31,12 @@ func TestSigner_Sign(t *testing.T) {
} }
// Different input should produce different signature // Different input should produce different signature
req2 := &Request{ req2 := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/dog.jpg", // Different path SourcePath: "/photos/dog.jpg", // Different path
SourceQuery: "", SourceQuery: "",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Unix(1704067200, 0), Expires: time.Unix(1704067200, 0),
} }
@@ -50,22 +47,21 @@ func TestSigner_Sign(t *testing.T) {
} }
func TestSigner_Verify(t *testing.T) { func TestSigner_Verify(t *testing.T) {
signer := New("test-secret-key") signer := NewSigner("test-secret-key")
tests := []struct { tests := []struct {
name string name string
setup func() *Request setup func() *ImageRequest
wantErr error wantErr error
}{ }{
{ {
name: "valid signature", name: "valid signature",
setup: func() *Request { setup: func() *ImageRequest {
req := &Request{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour), Expires: time.Now().Add(1 * time.Hour),
} }
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
@@ -76,45 +72,42 @@ func TestSigner_Verify(t *testing.T) {
}, },
{ {
name: "expired signature", name: "expired signature",
setup: func() *Request { setup: func() *ImageRequest {
req := &Request{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Now().Add(-1 * time.Hour), // Expired Expires: time.Now().Add(-1 * time.Hour), // Expired
} }
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
return req return req
}, },
wantErr: ErrExpired, wantErr: ErrSignatureExpired,
}, },
{ {
name: "invalid signature", name: "invalid signature",
setup: func() *Request { setup: func() *ImageRequest {
return &Request{ return &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour), Expires: time.Now().Add(1 * time.Hour),
Signature: "invalid-signature", Signature: "invalid-signature",
} }
}, },
wantErr: ErrInvalid, wantErr: ErrSignatureInvalid,
}, },
{ {
name: "missing expiration", name: "missing expiration",
setup: func() *Request { setup: func() *ImageRequest {
return &Request{ return &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Signature: "some-signature", Signature: "some-signature",
// Expires is zero // Expires is zero
} }
@@ -123,13 +116,12 @@ func TestSigner_Verify(t *testing.T) {
}, },
{ {
name: "tampered request", name: "tampered request",
setup: func() *Request { setup: func() *ImageRequest {
req := &Request{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour), Expires: time.Now().Add(1 * time.Hour),
} }
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
@@ -138,7 +130,7 @@ func TestSigner_Verify(t *testing.T) {
return req return req
}, },
wantErr: ErrInvalid, wantErr: ErrSignatureInvalid,
}, },
} }
@@ -164,17 +156,16 @@ func TestSigner_Verify(t *testing.T) {
// matching on every URL component. No suffix matching, wildcard matching, // matching on every URL component. No suffix matching, wildcard matching,
// or partial matching is supported. // or partial matching is supported.
func TestSigner_Verify_ExactMatchOnly(t *testing.T) { func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
signer := New("test-secret-key") signer := NewSigner("test-secret-key")
// Base request that we'll sign, then tamper with individual fields. // Base request that we'll sign, then tamper with individual fields.
baseReq := func() *Request { baseReq := func() *ImageRequest {
req := &Request{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
SourceQuery: "token=abc", SourceQuery: "token=abc",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour), Expires: time.Now().Add(1 * time.Hour),
} }
req.Signature = signer.Sign(req) req.Signature = signer.Sign(req)
@@ -184,95 +175,95 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
tamper func(req *Request) tamper func(req *ImageRequest)
}{ }{
{ {
name: "parent domain does not match subdomain", name: "parent domain does not match subdomain",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
// Signed for cdn.example.com, try example.com // Signed for cdn.example.com, try example.com
req.SourceHost = "example.com" req.SourceHost = "example.com"
}, },
}, },
{ {
name: "subdomain does not match parent domain", name: "subdomain does not match parent domain",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
// Signed for cdn.example.com, try images.cdn.example.com // Signed for cdn.example.com, try images.cdn.example.com
req.SourceHost = "images.cdn.example.com" req.SourceHost = "images.cdn.example.com"
}, },
}, },
{ {
name: "sibling subdomain does not match", name: "sibling subdomain does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
// Signed for cdn.example.com, try images.example.com // Signed for cdn.example.com, try images.example.com
req.SourceHost = "images.example.com" req.SourceHost = "images.example.com"
}, },
}, },
{ {
name: "host with suffix appended does not match", name: "host with suffix appended does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
// Signed for cdn.example.com, try cdn.example.com.evil.com // Signed for cdn.example.com, try cdn.example.com.evil.com
req.SourceHost = "cdn.example.com.evil.com" req.SourceHost = "cdn.example.com.evil.com"
}, },
}, },
{ {
name: "host with prefix does not match", name: "host with prefix does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
// Signed for cdn.example.com, try evilcdn.example.com // Signed for cdn.example.com, try evilcdn.example.com
req.SourceHost = "evilcdn.example.com" req.SourceHost = "evilcdn.example.com"
}, },
}, },
{ {
name: "different path does not match", name: "different path does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.SourcePath = "/photos/dog.jpg" req.SourcePath = "/photos/dog.jpg"
}, },
}, },
{ {
name: "path suffix does not match", name: "path suffix does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.SourcePath = "/photos/cat.jpg/extra" req.SourcePath = "/photos/cat.jpg/extra"
}, },
}, },
{ {
name: "path prefix does not match", name: "path prefix does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.SourcePath = "/other/photos/cat.jpg" req.SourcePath = "/other/photos/cat.jpg"
}, },
}, },
{ {
name: "different query does not match", name: "different query does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.SourceQuery = "token=xyz" req.SourceQuery = "token=xyz"
}, },
}, },
{ {
name: "added query does not match empty query", name: "added query does not match empty query",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.SourceQuery = "extra=1" req.SourceQuery = "extra=1"
}, },
}, },
{ {
name: "removed query does not match", name: "removed query does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.SourceQuery = "" req.SourceQuery = ""
}, },
}, },
{ {
name: "different width does not match", name: "different width does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.Width = 801 req.Size.Width = 801
}, },
}, },
{ {
name: "different height does not match", name: "different height does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.Height = 601 req.Size.Height = 601
}, },
}, },
{ {
name: "different format does not match", name: "different format does not match",
tamper: func(req *Request) { tamper: func(req *ImageRequest) {
req.Format = "png" req.Format = FormatPNG
}, },
}, },
} }
@@ -283,8 +274,8 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
tt.tamper(req) tt.tamper(req)
err := signer.Verify(req) err := signer.Verify(req)
if err != ErrInvalid { if err != ErrSignatureInvalid {
t.Errorf("Verify() = %v, want %v", err, ErrInvalid) t.Errorf("Verify() = %v, want %v", err, ErrSignatureInvalid)
} }
}) })
} }
@@ -302,7 +293,7 @@ func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
// string in the signature data, producing different signatures for // string in the signature data, producing different signatures for
// suffix-related hosts. // suffix-related hosts.
func TestSigner_Sign_ExactHostInData(t *testing.T) { func TestSigner_Sign_ExactHostInData(t *testing.T) {
signer := New("test-secret-key") signer := NewSigner("test-secret-key")
hosts := []string{ hosts := []string{
"cdn.example.com", "cdn.example.com",
@@ -315,13 +306,12 @@ func TestSigner_Sign_ExactHostInData(t *testing.T) {
sigs := make(map[string]string) sigs := make(map[string]string)
for _, host := range hosts { for _, host := range hosts {
req := &Request{ req := &ImageRequest{
SourceHost: host, SourceHost: host,
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
SourceQuery: "", SourceQuery: "",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Unix(1704067200, 0), Expires: time.Unix(1704067200, 0),
} }
@@ -335,15 +325,14 @@ func TestSigner_Sign_ExactHostInData(t *testing.T) {
} }
func TestSigner_DifferentKeys(t *testing.T) { func TestSigner_DifferentKeys(t *testing.T) {
signer1 := New("secret-key-1") signer1 := NewSigner("secret-key-1")
signer2 := New("secret-key-2") signer2 := NewSigner("secret-key-2")
req := &Request{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour), Expires: time.Now().Add(1 * time.Hour),
} }
@@ -356,21 +345,20 @@ func TestSigner_DifferentKeys(t *testing.T) {
} }
// Verify with key 2 should fail // Verify with key 2 should fail
if err := signer2.Verify(req); err != ErrInvalid { if err := signer2.Verify(req); err != ErrSignatureInvalid {
t.Errorf("Verify() with different key should fail, got: %v", err) t.Errorf("Verify() with different key should fail, got: %v", err)
} }
} }
func TestGenerateSignedURL(t *testing.T) { func TestGenerateSignedURL(t *testing.T) {
signer := New("test-secret-key") signer := NewSigner("test-secret-key")
req := &Request{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
SourceQuery: "", SourceQuery: "",
Width: 800, Size: Size{Width: 800, Height: 600},
Height: 600, Format: FormatWebP,
Format: "webp",
} }
ttl := 1 * time.Hour ttl := 1 * time.Hour
@@ -401,14 +389,13 @@ func TestGenerateSignedURL(t *testing.T) {
} }
func TestGenerateSignedURL_OrigSize(t *testing.T) { func TestGenerateSignedURL_OrigSize(t *testing.T) {
signer := New("test-secret-key") signer := NewSigner("test-secret-key")
req := &Request{ req := &ImageRequest{
SourceHost: "cdn.example.com", SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg", SourcePath: "/photos/cat.jpg",
Width: 0, // Original size Size: Size{Width: 0, Height: 0}, // Original size
Height: 0, Format: FormatPNG,
Format: "png",
} }
path, _, _ := signer.GenerateSignedURL(req, time.Hour) path, _, _ := signer.GenerateSignedURL(req, time.Hour)
@@ -419,57 +406,7 @@ func TestGenerateSignedURL_OrigSize(t *testing.T) {
} }
} }
func TestGenerateSignedURL_WithQueryString(t *testing.T) { func TestParseSignatureParams(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 { tests := []struct {
name string name string
sig string sig string
@@ -502,18 +439,18 @@ func TestParseParams(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) {
sig, exp, err := ParseParams(tt.sig, tt.expStr) sig, exp, err := ParseSignatureParams(tt.sig, tt.expStr)
if tt.wantErr { if tt.wantErr {
if err == nil { if err == nil {
t.Error("ParseParams() expected error, got nil") t.Error("ParseSignatureParams() expected error, got nil")
} }
return return
} }
if err != nil { if err != nil {
t.Errorf("ParseParams() unexpected error = %v", err) t.Errorf("ParseSignatureParams() unexpected error = %v", err)
return return
} }

View File

@@ -15,7 +15,6 @@ 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.
@@ -173,7 +172,7 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
svc, err := NewService(&ServiceConfig{ svc, err := NewService(&ServiceConfig{
Cache: cache, Cache: cache,
Fetcher: httpfetcher.NewMock(mockFS), Fetcher: NewMockFetcher(mockFS),
SigningKey: cfg.signingKey, SigningKey: cfg.signingKey,
Whitelist: cfg.whitelist, Whitelist: cfg.whitelist,
}) })

View File

@@ -1,138 +0,0 @@
#!/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 "$@"

View File

@@ -1,15 +0,0 @@
#!/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 "$@"

View File

@@ -1,15 +0,0 @@
#!/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 "$@"

View File

@@ -1,15 +0,0 @@
#!/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 "$@"

View File

@@ -1,14 +0,0 @@
#!/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 "$@"

View File

@@ -1,18 +0,0 @@
#!/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 "$@"

View File

@@ -1,16 +0,0 @@
#!/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 "$@"

View File

@@ -1,23 +0,0 @@
#!/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 "$@"

View File

@@ -1,21 +0,0 @@
#!/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 "$@"

View File

@@ -1,12 +0,0 @@
#!/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 "$@"

View File

@@ -1,14 +0,0 @@
#!/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 "$@"

View File

@@ -1,23 +0,0 @@
#!/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 "$@"