1 Commits

Author SHA1 Message Date
user
fff7789dfb fix: update Dockerfile to Go 1.25.4 and resolve gosec lint findings
Some checks failed
check / check (push) Has been cancelled
- Update Dockerfile base image from golang:1.24-alpine to golang:1.25.4-alpine
  (pinned by sha256 digest) to match go.mod requirement of go >= 1.25.4
- Fix gosec G703 (path traversal) false positives by adding filepath.Clean()
  at call sites with nolint annotations for internally-constructed paths
- Fix gosec G704 (SSRF) false positive with nolint annotation; URL is already
  validated by validateURL() which checks scheme, resolves DNS, and blocks
  private IPs
- All make check passes clean (lint + tests)
2026-02-25 05:44:43 -08:00
40 changed files with 414 additions and 1732 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,29 +1,7 @@
# Lint stage
# golangci/golangci-lint:v2.10.1-alpine, 2026-02-17
FROM golangci/golangci-lint:v2.10.1-alpine@sha256:33bc6b6156d4c7da87175f187090019769903d04dd408833b83083ed214b0ddf AS lint
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
WORKDIR /src
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Run formatting check and linter
RUN make fmt-check
RUN make lint
# Build stage # Build stage
# golang:1.25.4-alpine, 2026-02-25 # golang:1.25.4-alpine, 2026-02-25
FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
ARG VERSION=dev ARG VERSION=dev
# Install build dependencies for CGO image libraries # Install build dependencies for CGO image libraries
@@ -31,7 +9,15 @@ RUN apk add --no-cache \
build-base \ build-base \
vips-dev \ vips-dev \
libheif-dev \ libheif-dev \
pkgconfig pkgconfig \
curl
# golangci-lint v2.10.1, 2026-02-25
RUN curl -sSfL https://github.com/golangci/golangci-lint/releases/download/v2.10.1/golangci-lint-2.10.1-linux-amd64.tar.gz -o /tmp/golangci-lint.tar.gz && \
echo "dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99 /tmp/golangci-lint.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/golangci-lint.tar.gz -C /tmp && \
mv /tmp/golangci-lint-2.10.1-linux-amd64/golangci-lint /usr/local/bin/ && \
rm -rf /tmp/golangci-lint*
WORKDIR /src WORKDIR /src
@@ -42,8 +28,8 @@ RUN GOTOOLCHAIN=auto go mod download
# Copy source code # Copy source code
COPY . . COPY . .
# Run tests # Run all checks (fmt-check, lint, test)
RUN make test RUN make check
# Build with CGO enabled # Build with CGO enabled
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad

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

@@ -67,10 +67,7 @@ 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. Signatures are **exact match only**: every prevent replay attacks.
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):
@@ -128,31 +125,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`

134
TODO.md
View File

@@ -1,87 +1,65 @@
# Workflow # Pixa 1.0 TODO
* branch (from `main`) Remaining tasks sorted by priority for a working 1.0 release.
* 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
# Status ## P0: Critical for 1.0
pre-1.0. No git tags exist. main (6b4a1d7, 2026-04-07) is current with ### Image Processing
origin; recent work extracted the internal/magic and internal/allowlist - [x] Add WebP encoding support (currently returns error)
packages. The 10 gosec findings from the 2026-07-06 morning survey are - [ ] Add AVIF encoding support (currently returns error)
NOT fixed on main: the newest gosec/lint fix commits are from
2026-02-25 (85729d9, ce6db76) and nothing since touches gosec, so those
findings remain open.
# Next Step ### Manual Testing (verify auth/encrypted URLs work)
- [ ] 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
Fix the 10 open gosec lint findings so make check passes on main again ### Cache Management
(main must always be green; no gosec fix commits have landed since - [ ] Implement cache size management/eviction (prevent disk from filling up)
2026-02-25). No blanket suppressions; fix or justify each finding
individually.
# Completed Steps ### Configuration
- [ ] Validate configuration on startup (fail fast on bad config)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, ## P1: Important for Production
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)
# Future Steps ### Security
- [ ] Implement blocked networks configuration (extend SSRF protection)
- [ ] Add rate limiting global concurrent fetches (prevent resource exhaustion)
- P0: manual test pass of the auth and encrypted URL flows, then ### Image Processing
commit the checked-off results to TODO.md: visit / and see the login - [ ] Implement EXIF/metadata stripping (privacy)
form; wrong key shows an error; correct signing key shows the
generator form; a generated encrypted URL serves the image; an ## P2: Nice to Have
expired URL (short TTL) returns 410; logout redirects back to login
- P0: implement cache size management and eviction so the disk cannot ### Security
fill up - [ ] Implement referer blacklist
- P0: validate configuration on startup, fail fast on bad config - [ ] Add rate limiting per-IP
- P1: implement blocked networks configuration to extend SSRF - [ ] Add rate limiting per-origin
protection
- P1: rate limit global concurrent upstream fetches to prevent ### HTTP Response Handling
resource exhaustion - [ ] Implement Last-Modified headers
- P1: strip EXIF and other metadata from processed images (privacy) - [ ] Implement Vary header for content negotiation
- P2: security - [ ] Implement X-Request-ID propagation
- referer blacklist
- per-IP rate limiting ### Additional Endpoints
- per-origin rate limiting - [ ] Implement auto-format selection (format=auto based on Accept header)
- P2: HTTP response handling
- Last-Modified headers ### Configuration
- Vary header for content negotiation - [ ] Add all configuration options from README
- X-Request-ID propagation - [ ] Implement environment variable overrides
- P2: auto format selection (format=auto based on Accept header) - [ ] Implement YAML config file support
- P2: configuration
- add all configuration options from README ### Operational
- environment variable overrides - [ ] Implement Sentry error reporting (optional)
- YAML config file support - [ ] Add comprehensive request logging
- P2: operational - [ ] Add performance metrics (Prometheus)
- optional Sentry error reporting - [ ] Write integration tests for image proxy flow
- comprehensive request logging - [ ] Write load tests to verify 1-5k req/s target
- Prometheus performance metrics
- integration tests for the image proxy flow ### Documentation
- load tests to verify the 1k to 5k req/s target - [ ] Document configuration options
- P2: documentation - [ ] Document API endpoints
- configuration options - [ ] Document deployment guide
- API endpoints - [ ] Add example nginx/caddy reverse proxy config
- deployment guide
- example nginx or caddy reverse proxy config

View File

@@ -17,7 +17,10 @@ import (
"sneak.berlin/go/pixa/internal/server" "sneak.berlin/go/pixa/internal/server"
) )
var Version string //nolint:gochecknoglobals // set by ldflags var (
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
@@ -37,6 +40,7 @@ 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

View File

@@ -9,7 +9,6 @@ import (
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"go.uber.org/fx" "go.uber.org/fx"
@@ -22,10 +21,6 @@ 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
@@ -40,46 +35,6 @@ 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{
@@ -129,87 +84,43 @@ 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 ApplyMigrations(ctx, s.db, s.log) return s.runMigrations(ctx)
} }
// collectMigrations reads the embedded schema directory and returns func (s *Database) runMigrations(ctx context.Context) error {
// migration filenames sorted lexicographically. // Create migrations tracking table
func collectMigrations() ([]string, error) { _, err := s.db.ExecContext(ctx, `
entries, err := schemaFS.ReadDir("schema") CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read schema directory: %w", err) return fmt.Errorf("failed to create migrations table: %w", err)
} }
var migrations []string // 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 { 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)
return migrations, nil // Apply each migration that hasn't been applied yet
}
// bootstrapMigrationsTable ensures the schema_migrations table exists
// by applying 000.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var tableExists int
err := db.QueryRowContext(ctx,
"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)
}
if tableExists > 0 {
return nil
}
content, err := schemaFS.ReadFile("schema/000.sql")
if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
}
if log != nil {
log.Info("applying bootstrap migration", "version", bootstrapVersion)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
}
return nil
}
// ApplyMigrations applies all pending migrations to db. An optional logger
// may be provided for informational output; pass nil for silent operation.
// 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
}
migrations, err := collectMigrations()
if err != nil {
return err
}
for _, migration := range migrations { for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration) version := strings.TrimSuffix(migration, filepath.Ext(migration))
if parseErr != nil {
return parseErr
}
// Check if already applied. // Check if already applied
var count int var count int
err := s.db.QueryRowContext(ctx,
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", "SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version, version,
).Scan(&count) ).Scan(&count)
@@ -218,40 +129,34 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
} }
if count > 0 { if count > 0 {
if log != nil { s.log.Debug("migration already applied", "version", version)
log.Debug("migration already applied", "version", version)
}
continue continue
} }
// Read and apply migration. // Read and apply migration
content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration)) content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if readErr != nil { if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, readErr) return fmt.Errorf("failed to read migration %s: %w", migration, err)
} }
if log != nil { s.log.Info("applying migration", "version", version)
log.Info("applying migration", "version", version)
_, err = s.db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
} }
_, execErr := db.ExecContext(ctx, string(content)) // Record migration as applied
if execErr != nil { _, err = s.db.ExecContext(ctx,
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 recErr != nil { if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, recErr) return fmt.Errorf("failed to record migration %s: %w", migration, err)
} }
if log != nil { s.log.Info("migration applied successfully", "version", version)
log.Info("migration applied successfully", "version", version)
}
} }
return nil return nil
@@ -261,3 +166,77 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
func (s *Database) DB() *sql.DB { func (s *Database) DB() *sql.DB {
return s.db return s.db
} }
// ApplyMigrations applies all migrations to the given database.
// 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 {
return fmt.Errorf("failed to create migrations table: %w", 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 {
version := strings.TrimSuffix(migration, filepath.Ext(migration))
// Check if already applied
var count int
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
}
if count > 0 {
continue
}
// Read and apply migration
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
}
// Record migration as applied
_, err = db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
}
return nil
}

View File

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

View File

@@ -1,9 +0,0 @@
-- 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);

View File

@@ -5,10 +5,11 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
) )
const appname = "pixad" // Build-time variables populated from main() via ldflags.
var (
// Version is populated from main() via ldflags. Appname string //nolint:gochecknoglobals // set from main
var Version string //nolint:gochecknoglobals // set from main Version string //nolint:gochecknoglobals // set from main
)
// Globals holds application-wide constants. // Globals holds application-wide constants.
type Globals struct { type Globals struct {
@@ -19,7 +20,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
} }

View File

@@ -82,7 +82,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(context.Background(), db, nil); err != nil { if err := database.ApplyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err) t.Fatalf("failed to apply migrations: %v", err)
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 B

View File

@@ -199,6 +199,36 @@ type FetchResult struct {
TLSCipherSuite string 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
type Storage interface { type Storage interface {
// Store saves content and returns its hash // Store saves content and returns its hash

View File

@@ -1,6 +1,4 @@
// Package magic detects image formats from magic bytes and validates package imgcache
// content against declared MIME types.
package magic
import ( import (
"bytes" "bytes"
@@ -29,20 +27,6 @@ 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
@@ -205,7 +189,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 an ImageFormat. // MIMEToImageFormat converts a MIME type to our ImageFormat type.
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) { func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
normalized := normalizeMIMEType(mimeType) normalized := normalizeMIMEType(mimeType)
switch MIMEType(normalized) { switch MIMEType(normalized) {
@@ -224,7 +208,7 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
} }
} }
// ImageFormatToMIME converts an ImageFormat to a MIME type string. // ImageFormatToMIME converts our ImageFormat to a MIME type string.
func ImageFormatToMIME(format ImageFormat) string { func ImageFormatToMIME(format ImageFormat) string {
switch format { switch format {
case FormatJPEG: case FormatJPEG:

View File

@@ -1,4 +1,4 @@
package magic package imgcache
import ( import (
"bytes" "bytes"

View File

@@ -1,5 +1,4 @@
// Package imageprocessor provides image format conversion and resizing using libvips. package imgcache
package imageprocessor
import ( import (
"bytes" "bytes"
@@ -23,133 +22,38 @@ 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 image transformation using libvips via govips. // ImageProcessor implements the Processor interface using libvips via govips.
type ImageProcessor struct { type ImageProcessor struct{}
maxInputBytes int64
}
// Params holds configuration for creating an ImageProcessor. // NewImageProcessor creates a new image processor.
// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes). func NewImageProcessor() *ImageProcessor {
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()
maxInputBytes := params.MaxInputBytes return &ImageProcessor{}
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 *Request, req *ImageRequest,
) (*Result, error) { ) (*ProcessResult, error) {
// Read input with a size limit to prevent unbounded memory consumption. // Read input
// We read at most maxInputBytes+1 so we can detect if the input exceeds data, err := io.ReadAll(input)
// 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 {
@@ -205,10 +109,10 @@ func (p *ImageProcessor) Process(
return nil, fmt.Errorf("failed to encode: %w", err) return nil, fmt.Errorf("failed to encode: %w", err)
} }
return &Result{ return &ProcessResult{
Content: io.NopCloser(bytes.NewReader(output)), Content: io.NopCloser(bytes.NewReader(output)),
ContentLength: int64(len(output)), ContentLength: int64(len(output)),
ContentType: FormatToMIME(outputFormat), ContentType: ImageFormatToMIME(outputFormat),
Width: img.Width(), Width: img.Width(),
Height: img.Height(), Height: img.Height(),
InputWidth: origWidth, InputWidth: origWidth,
@@ -220,17 +124,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{
"image/jpeg", string(MIMETypeJPEG),
"image/png", string(MIMETypePNG),
"image/gif", string(MIMETypeGIF),
"image/webp", string(MIMETypeWebP),
"image/avif", string(MIMETypeAVIF),
} }
} }
// SupportedOutputFormats returns formats this processor can write. // SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []Format { func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
return []Format{ return []ImageFormat{
FormatJPEG, FormatJPEG,
FormatPNG, FormatPNG,
FormatGIF, FormatGIF,
@@ -239,24 +143,6 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
} }
} }
// 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()
@@ -285,6 +171,7 @@ 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)
@@ -295,7 +182,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) // Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
case FitInside: case FitInside:
@@ -331,7 +218,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 Format, quality int) ([]byte, error) { func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) {
if quality <= 0 { if quality <= 0 {
quality = defaultQuality quality = defaultQuality
} }
@@ -379,8 +266,8 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int)
return output, nil return output, nil
} }
// formatFromString converts a format string to Format. // formatFromString converts a format string to ImageFormat.
func (p *ImageProcessor) formatFromString(format string) Format { func (p *ImageProcessor) formatFromString(format string) ImageFormat {
switch format { switch format {
case "jpeg": case "jpeg":
return FormatJPEG return FormatJPEG

View File

@@ -1,4 +1,4 @@
package imageprocessor package imgcache
import ( import (
"bytes" "bytes"
@@ -70,36 +70,13 @@ 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 := New(Params{}) proc := NewImageProcessor()
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
req := &Request{ req := &ImageRequest{
Size: Size{Width: 400, Height: 300}, Size: Size{Width: 400, Height: 300},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -130,19 +107,23 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime := detectMIME(data) mime, err := DetectFormat(data)
if mime != "image/jpeg" { if err != nil {
t.Errorf("Output format = %v, want image/jpeg", mime) t.Fatalf("DetectFormat() error = %v", err)
}
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 := New(Params{}) proc := NewImageProcessor()
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &Request{ req := &ImageRequest{
Size: Size{Width: 200, Height: 150}, Size: Size{Width: 200, Height: 150},
Format: FormatPNG, Format: FormatPNG,
FitMode: FitCover, FitMode: FitCover,
@@ -159,19 +140,23 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime := detectMIME(data) mime, err := DetectFormat(data)
if mime != "image/png" { if err != nil {
t.Errorf("Output format = %v, want image/png", mime) t.Fatalf("DetectFormat() error = %v", err)
}
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 := New(Params{}) proc := NewImageProcessor()
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 640, 480) input := createTestJPEG(t, 640, 480)
req := &Request{ req := &ImageRequest{
Size: Size{Width: 0, Height: 0}, // Original size Size: Size{Width: 0, Height: 0}, // Original size
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -194,14 +179,14 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
} }
func TestImageProcessor_FitContain(t *testing.T) { func TestImageProcessor_FitContain(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
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 := &Request{ req := &ImageRequest{
Size: Size{Width: 400, Height: 400}, Size: Size{Width: 400, Height: 400},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -221,14 +206,14 @@ func TestImageProcessor_FitContain(t *testing.T) {
} }
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
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 := &Request{ req := &ImageRequest{
Size: Size{Width: 400, Height: 0}, Size: Size{Width: 400, Height: 0},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -251,14 +236,14 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
} }
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
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 := &Request{ req := &ImageRequest{
Size: Size{Width: 0, Height: 300}, Size: Size{Width: 0, Height: 300},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -281,12 +266,12 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
} }
func TestImageProcessor_ProcessPNG(t *testing.T) { func TestImageProcessor_ProcessPNG(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
ctx := context.Background() ctx := context.Background()
input := createTestPNG(t, 400, 300) input := createTestPNG(t, 400, 300)
req := &Request{ req := &ImageRequest{
Size: Size{Width: 200, Height: 150}, Size: Size{Width: 200, Height: 150},
Format: FormatPNG, Format: FormatPNG,
FitMode: FitCover, FitMode: FitCover,
@@ -307,8 +292,13 @@ 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 := New(Params{}) proc := NewImageProcessor()
inputFormats := proc.SupportedInputFormats() inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 { if len(inputFormats) == 0 {
@@ -322,14 +312,14 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
} }
func TestImageProcessor_RejectsOversizedInput(t *testing.T) { func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
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 := &Request{ req := &ImageRequest{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -347,13 +337,13 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
} }
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
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 := &Request{ req := &ImageRequest{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -371,13 +361,14 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
} }
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
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 := &Request{ req := &ImageRequest{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -392,12 +383,12 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
} }
func TestImageProcessor_EncodeWebP(t *testing.T) { func TestImageProcessor_EncodeWebP(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &Request{ req := &ImageRequest{
Size: Size{Width: 100, Height: 75}, Size: Size{Width: 100, Height: 75},
Format: FormatWebP, Format: FormatWebP,
Quality: 80, Quality: 80,
@@ -416,9 +407,13 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime := detectMIME(data) mime, err := DetectFormat(data)
if mime != "image/webp" { if err != nil {
t.Errorf("Output format = %v, want image/webp", mime) t.Fatalf("DetectFormat() error = %v", err)
}
if mime != MIMETypeWebP {
t.Errorf("Output format = %v, want %v", mime, MIMETypeWebP)
} }
// Verify dimensions // Verify dimensions
@@ -431,7 +426,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
} }
func TestImageProcessor_DecodeAVIF(t *testing.T) { func TestImageProcessor_DecodeAVIF(t *testing.T) {
proc := New(Params{}) proc := NewImageProcessor()
ctx := context.Background() ctx := context.Background()
// Load test AVIF file // Load test AVIF file
@@ -441,7 +436,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
} }
// Request resize and convert to JPEG // Request resize and convert to JPEG
req := &Request{ req := &ImageRequest{
Size: Size{Width: 2, Height: 2}, Size: Size{Width: 2, Height: 2},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -460,84 +455,23 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime := detectMIME(data) mime, err := DetectFormat(data)
if mime != "image/jpeg" {
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))
}
req := &Request{
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 { if err != nil {
t.Fatalf("Process() error = %v, want nil", err) t.Fatalf("DetectFormat() error = %v", 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 if mime != MIMETypeJPEG {
proc = New(Params{MaxInputBytes: -1}) t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
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 := New(Params{}) proc := NewImageProcessor()
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &Request{ req := &ImageRequest{
Size: Size{Width: 100, Height: 75}, Size: Size{Width: 100, Height: 75},
Format: FormatAVIF, Format: FormatAVIF,
Quality: 85, Quality: 85,
@@ -556,9 +490,13 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime := detectMIME(data) mime, err := DetectFormat(data)
if mime != "image/avif" { if err != nil {
t.Errorf("Output format = %v, want image/avif", mime) t.Fatalf("DetectFormat() error = %v", err)
}
if mime != MIMETypeAVIF {
t.Errorf("Output format = %v, want %v", mime, MIMETypeAVIF)
} }
// Verify dimensions // Verify dimensions

View File

@@ -11,21 +11,17 @@ import (
"time" "time"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/magic"
) )
// 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 Fetcher
processor *imageprocessor.ImageProcessor processor Processor
signer *Signer signer *Signer
allowlist *allowlist.HostAllowList whitelist *HostWhitelist
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.
@@ -54,17 +50,15 @@ 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 = DefaultFetcherConfig()
}
// Use custom fetcher if provided, otherwise create HTTP fetcher // Use custom fetcher if provided, otherwise create HTTP fetcher
var fetcher Fetcher var fetcher Fetcher
if cfg.Fetcher != nil { if cfg.Fetcher != nil {
fetcher = cfg.Fetcher fetcher = cfg.Fetcher
} else { } else {
fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil {
fetcherCfg = DefaultFetcherConfig()
}
fetcher = NewHTTPFetcher(fetcherCfg) fetcher = NewHTTPFetcher(fetcherCfg)
} }
@@ -80,17 +74,14 @@ 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: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}), processor: NewImageProcessor(),
signer: signer, signer: signer,
allowlist: allowlist.New(cfg.Whitelist), whitelist: NewHostWhitelist(cfg.Whitelist),
log: log, log: log,
allowHTTP: allowHTTP, allowHTTP: allowHTTP,
maxResponseSize: maxResponseSize,
}, nil }, nil
} }
@@ -155,40 +146,6 @@ 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,
@@ -205,8 +162,22 @@ 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
@@ -278,7 +249,7 @@ func (s *Service) fetchAndProcess(
) )
// Validate magic bytes match content type // Validate magic bytes match content type
if err := magic.ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil { if err := ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil {
return nil, fmt.Errorf("content validation failed: %w", err) return nil, fmt.Errorf("content validation failed: %w", err)
} }
@@ -303,14 +274,7 @@ func (s *Service) processAndStore(
// Process the image // Process the image
processStart := time.Now() processStart := time.Now()
processReq := &imageprocessor.Request{ processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req)
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)
} }
@@ -383,7 +347,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 allowed (no signature required) // Check if host is whitelisted (no signature required)
sourceURL := req.SourceURL() sourceURL := req.SourceURL()
parsedURL, err := url.Parse(sourceURL) parsedURL, err := url.Parse(sourceURL)
@@ -391,11 +355,11 @@ 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.allowlist.IsAllowed(parsedURL) { if s.whitelist.IsWhitelisted(parsedURL) {
return nil return nil
} }
// Signature required for non-allowed hosts // Signature required for non-whitelisted hosts
return s.signer.Verify(req) return s.signer.Verify(req)
} }

View File

@@ -5,8 +5,6 @@ import (
"io" "io"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/magic"
) )
func TestService_Get_WhitelistedHost(t *testing.T) { func TestService_Get_WhitelistedHost(t *testing.T) {
@@ -153,74 +151,6 @@ 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),
WithNoWhitelist(),
)
signer := NewSigner(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(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()
@@ -317,17 +247,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 := magic.DetectFormat(data) detectedMIME, err := 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 := magic.MIMEToImageFormat(tt.wantMIME) expectedFormat, ok := 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 := magic.MIMEToImageFormat(string(detectedMIME)) detectedFormat, ok := 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)
} }

View File

@@ -43,11 +43,6 @@ func (s *Signer) Sign(req *ImageRequest) string {
} }
// Verify checks if the signature on the request is valid and not expired. // 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 *ImageRequest) error { func (s *Signer) Verify(req *ImageRequest) error {
// Check expiration first // Check expiration first
if req.Expires.IsZero() { if req.Expires.IsZero() {
@@ -71,8 +66,6 @@ func (s *Signer) Verify(req *ImageRequest) error {
// buildSignatureData creates the string to be signed. // buildSignatureData creates the string to be signed.
// 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,
// suffix matching, or wildcard expansion is performed.
func (s *Signer) buildSignatureData(req *ImageRequest) 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,

View File

@@ -152,178 +152,6 @@ func TestSigner_Verify(t *testing.T) {
} }
} }
// 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 := NewSigner("test-secret-key")
// Base request that we'll sign, then tamper with individual fields.
baseReq := func() *ImageRequest {
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "token=abc",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req)
return req
}
tests := []struct {
name string
tamper func(req *ImageRequest)
}{
{
name: "parent domain does not match subdomain",
tamper: func(req *ImageRequest) {
// Signed for cdn.example.com, try example.com
req.SourceHost = "example.com"
},
},
{
name: "subdomain does not match parent domain",
tamper: func(req *ImageRequest) {
// 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 *ImageRequest) {
// 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 *ImageRequest) {
// 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 *ImageRequest) {
// Signed for cdn.example.com, try evilcdn.example.com
req.SourceHost = "evilcdn.example.com"
},
},
{
name: "different path does not match",
tamper: func(req *ImageRequest) {
req.SourcePath = "/photos/dog.jpg"
},
},
{
name: "path suffix does not match",
tamper: func(req *ImageRequest) {
req.SourcePath = "/photos/cat.jpg/extra"
},
},
{
name: "path prefix does not match",
tamper: func(req *ImageRequest) {
req.SourcePath = "/other/photos/cat.jpg"
},
},
{
name: "different query does not match",
tamper: func(req *ImageRequest) {
req.SourceQuery = "token=xyz"
},
},
{
name: "added query does not match empty query",
tamper: func(req *ImageRequest) {
req.SourceQuery = "extra=1"
},
},
{
name: "removed query does not match",
tamper: func(req *ImageRequest) {
req.SourceQuery = ""
},
},
{
name: "different width does not match",
tamper: func(req *ImageRequest) {
req.Size.Width = 801
},
},
{
name: "different height does not match",
tamper: func(req *ImageRequest) {
req.Size.Height = 601
},
},
{
name: "different format does not match",
tamper: func(req *ImageRequest) {
req.Format = FormatPNG
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := baseReq()
tt.tamper(req)
err := signer.Verify(req)
if err != ErrSignatureInvalid {
t.Errorf("Verify() = %v, want %v", err, ErrSignatureInvalid)
}
})
}
// 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 := NewSigner("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 := &ImageRequest{
SourceHost: host,
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
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) { func TestSigner_DifferentKeys(t *testing.T) {
signer1 := NewSigner("secret-key-1") signer1 := NewSigner("secret-key-1")
signer2 := NewSigner("secret-key-2") signer2 := NewSigner("secret-key-2")

View File

@@ -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(context.Background(), db, nil); err != nil { if err := database.ApplyMigrations(db); err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })

View File

@@ -2,7 +2,6 @@ package imgcache
import ( import (
"bytes" "bytes"
"context"
"database/sql" "database/sql"
"image" "image"
"image/color" "image/color"
@@ -194,7 +193,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(context.Background(), db, nil); err != nil { if err := database.ApplyMigrations(db); err != nil {
t.Fatalf("failed to apply migrations: %v", err) t.Fatalf("failed to apply migrations: %v", err)
} }

View File

@@ -1,26 +1,25 @@
// Package allowlist provides host-based URL allow-listing for the image proxy. package imgcache
package allowlist
import ( import (
"net/url" "net/url"
"strings" "strings"
) )
// HostAllowList checks whether source hosts are permitted. // HostWhitelist implements the Whitelist interface for checking allowed source hosts.
type HostAllowList struct { type HostWhitelist 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
} }
// New creates a HostAllowList from a list of host patterns. // NewHostWhitelist creates a whitelist 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 New(patterns []string) *HostAllowList { func NewHostWhitelist(patterns []string) *HostWhitelist {
w := &HostAllowList{ w := &HostWhitelist{
exactHosts: make(map[string]struct{}), exactHosts: make(map[string]struct{}),
suffixHosts: make([]string, 0), suffixHosts: make([]string, 0),
} }
@@ -41,8 +40,8 @@ func New(patterns []string) *HostAllowList {
return w return w
} }
// IsAllowed checks if a URL's host is in the allow list. // IsWhitelisted checks if a URL's host is in the whitelist.
func (w *HostAllowList) IsAllowed(u *url.URL) bool { func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
if u == nil { if u == nil {
return false return false
} }
@@ -72,12 +71,12 @@ func (w *HostAllowList) IsAllowed(u *url.URL) bool {
return false return false
} }
// IsEmpty returns true if the allow list has no entries. // IsEmpty returns true if the whitelist has no entries.
func (w *HostAllowList) IsEmpty() bool { func (w *HostWhitelist) 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 allow list entries. // Count returns the total number of whitelist entries.
func (w *HostAllowList) Count() int { func (w *HostWhitelist) Count() int {
return len(w.exactHosts) + len(w.suffixHosts) return len(w.exactHosts) + len(w.suffixHosts)
} }

View File

@@ -1,13 +1,11 @@
package allowlist_test package imgcache
import ( import (
"net/url" "net/url"
"testing" "testing"
"sneak.berlin/go/pixa/internal/allowlist"
) )
func TestHostAllowList_IsAllowed(t *testing.T) { func TestHostWhitelist_IsWhitelisted(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
patterns []string patterns []string
@@ -69,7 +67,7 @@ func TestHostAllowList_IsAllowed(t *testing.T) {
want: true, want: true,
}, },
{ {
name: "empty allow list", name: "empty whitelist",
patterns: []string{}, patterns: []string{},
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: false, want: false,
@@ -96,7 +94,7 @@ func TestHostAllowList_IsAllowed(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 := allowlist.New(tt.patterns) w := NewHostWhitelist(tt.patterns)
var u *url.URL var u *url.URL
if tt.testURL != "" { if tt.testURL != "" {
@@ -107,15 +105,15 @@ func TestHostAllowList_IsAllowed(t *testing.T) {
} }
} }
got := w.IsAllowed(u) got := w.IsWhitelisted(u)
if got != tt.want { if got != tt.want {
t.Errorf("IsAllowed() = %v, want %v", got, tt.want) t.Errorf("IsWhitelisted() = %v, want %v", got, tt.want)
} }
}) })
} }
} }
func TestHostAllowList_IsEmpty(t *testing.T) { func TestHostWhitelist_IsEmpty(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
patterns []string patterns []string
@@ -145,7 +143,7 @@ func TestHostAllowList_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 := allowlist.New(tt.patterns) w := NewHostWhitelist(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)
} }
@@ -153,7 +151,7 @@ func TestHostAllowList_IsEmpty(t *testing.T) {
} }
} }
func TestHostAllowList_Count(t *testing.T) { func TestHostWhitelist_Count(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
patterns []string patterns []string
@@ -183,7 +181,7 @@ func TestHostAllowList_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 := allowlist.New(tt.patterns) w := NewHostWhitelist(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)
} }

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 "$@"

View File

@@ -48,7 +48,7 @@ fi
# Test 3: Wrong password shows error # Test 3: Wrong password shows error
echo "--- Test 3: Login with wrong password ---" echo "--- Test 3: Login with wrong password ---"
WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "key=wrong-key" -c "$COOKIE_JAR") WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "password=wrong-key" -c "$COOKIE_JAR")
if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then if echo "$WRONG_LOGIN" | grep -qi "invalid\|error\|incorrect\|wrong"; then
pass "Wrong password shows error message" pass "Wrong password shows error message"
else else
@@ -57,7 +57,7 @@ fi
# Test 4: Correct password redirects to generator # Test 4: Correct password redirects to generator
echo "--- Test 4: Login with correct signing key ---" echo "--- Test 4: Login with correct signing key ---"
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR") GENERATOR_PAGE=$(curl -sf "$BASE_URL/" -b "$COOKIE_JAR")
if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then if echo "$GENERATOR_PAGE" | grep -qi "generate\|url\|source\|logout"; then
pass "Correct password shows generator page" pass "Correct password shows generator page"
@@ -68,12 +68,12 @@ fi
# Test 5: Generate encrypted URL # Test 5: Generate encrypted URL
echo "--- Test 5: Generate encrypted URL ---" echo "--- Test 5: Generate encrypted URL ---"
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \ GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
-d "url=$TEST_IMAGE_URL" \ -d "source_url=$TEST_IMAGE_URL" \
-d "width=800" \ -d "width=800" \
-d "height=600" \ -d "height=600" \
-d "format=jpeg" \ -d "format=jpeg" \
-d "quality=85" \ -d "quality=85" \
-d "fit=cover" \ -d "fit_mode=cover" \
-d "ttl=3600") -d "ttl=3600")
if echo "$GEN_RESULT" | grep -q "/v1/e/"; then if echo "$GEN_RESULT" | grep -q "/v1/e/"; then
pass "Encrypted URL generated" pass "Encrypted URL generated"
@@ -121,10 +121,10 @@ fi
# Test 9: Generate short-TTL URL and verify expiration # Test 9: Generate short-TTL URL and verify expiration
echo "--- Test 9: Expired URL returns 410 ---" echo "--- Test 9: Expired URL returns 410 ---"
# Login again # Login again
curl -sf -X POST "$BASE_URL/" -d "key=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null curl -sf -X POST "$BASE_URL/" -d "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null
# Generate URL with 1 second TTL # Generate URL with 1 second TTL
GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \ GEN_RESULT=$(curl -sf -X POST "$BASE_URL/generate" -b "$COOKIE_JAR" \
-d "url=$TEST_IMAGE_URL" \ -d "source_url=$TEST_IMAGE_URL" \
-d "width=100" \ -d "width=100" \
-d "height=100" \ -d "height=100" \
-d "format=jpeg" \ -d "format=jpeg" \