1 Commits

Author SHA1 Message Date
clawbot
a853fe7ee7 refactor: extract httpfetcher package from imgcache
All checks were successful
check / check (push) Successful in 57s
Move HTTPFetcher, Config (was FetcherConfig), SSRF-safe dialer, rate
limiting, content-type validation, and related error vars from
internal/imgcache/fetcher.go into new internal/httpfetcher/ package.

The Fetcher interface and FetchResult type also move to httpfetcher
to avoid circular imports (imgcache imports httpfetcher, not the other
way around).

Renames to avoid stuttering:
  NewHTTPFetcher -> httpfetcher.New
  FetcherConfig  -> httpfetcher.Config
  NewMockFetcher -> httpfetcher.NewMock

The ServiceConfig.FetcherConfig field is retained (it describes what
kind of config it holds, not a stutter).

Pure refactor - no behavior changes. Unit tests for the httpfetcher
package are included.

refs #39
2026-04-17 06:47:05 +00:00
29 changed files with 499 additions and 761 deletions

View File

@@ -6,4 +6,4 @@ jobs:
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild
- run: docker build .

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-versioned docker-test devserver devserver-stop hooks
.PHONY: check lint test fmt fmt-check build clean docker docker-test devserver devserver-stop hooks
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION)
@@ -15,30 +15,27 @@ else
endif
# Default target: run all checks
check:
@script/check
bootstrap:
@script/bootstrap
setup:
@script/setup
check: fmt-check lint test
# Check formatting without modifying files
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
fmt:
@script/fmt
@echo "Formatting code..."
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
# Run linter
lint:
@script/lint
@echo "Running linter..."
$(NIX_RUN_PREFIX)golangci-lint run$(NIX_RUN_SUFFIX)
# Run tests (30-second timeout)
test:
@script/test
@echo "Running tests..."
$(NIX_RUN_PREFIX)CGO_ENABLED=1 go test -timeout 30s -v ./...$(NIX_RUN_SUFFIX)
# Build the binary
build:
@@ -50,12 +47,8 @@ clean:
rm -rf bin/
rm -rf ./data
# Build Docker image (tagged via script/projectname)
# Build Docker image
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 .
# 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 ./..."
# Run local dev server in Docker
devserver: docker-versioned devserver-stop
devserver: docker devserver-stop
docker run -d --name pixad-dev -p 8080:8080 \
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
pixad:latest
@@ -77,4 +70,6 @@ devserver-stop:
# Install pre-commit hook
hooks:
@script/install-precommit
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit

View File

@@ -128,31 +128,6 @@ See `config.example.yml` for all options with defaults.
- **Metrics**: Prometheus
- **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
See [TODO.md](TODO.md) for the full prioritized task list.

View File

@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-07-06
last_modified: 2026-02-22
---
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 repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
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
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
stage before the final image is assembled.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
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,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- All repos with software must have tests that run via the platform-standard
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
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.
- `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
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 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
`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:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **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?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
@@ -361,14 +144,8 @@ style conventions are in separate documents:
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
Post-1.0.0: add new migration files.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
@@ -398,9 +175,6 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.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`)
* 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
Remaining tasks sorted by priority for a working 1.0 release.
# Status
## P0: Critical for 1.0
pre-1.0. No git tags exist. main (6b4a1d7, 2026-04-07) is current with
origin; recent work extracted the internal/magic and internal/allowlist
packages. The 10 gosec findings from the 2026-07-06 morning survey are
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.
### Image Processing
- [x] Add WebP encoding support (currently returns error)
- [x] Add AVIF encoding support (implemented via govips)
# 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
(main must always be green; no gosec fix commits have landed since
2026-02-25). No blanket suppressions; fix or justify each finding
individually.
### Cache Management
- [ ] Implement cache size management/eviction (prevent disk from filling up)
# Completed Steps
### Configuration
- [ ] Validate configuration on startup (fail fast on bad config)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-04-07 extract magic byte detection into internal/magic (#42)
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
- 2026-03-20 enforce and document exact-match-only signature
verification (#40)
- 2026-03-20 bound imageprocessor.Process input read to prevent
unbounded memory use (#37); consolidate appname into an
internal/globals constant (#34)
- 2026-03-18 parse version prefix from migration filenames (#33)
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for
faster CI (#23)
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
golangci-lint and gosec fixes of that date (#14); arm64 Docker build
fix (#16)
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0
image processing items, now done)
## P1: Important for Production
# 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
commit the checked-off results to TODO.md: visit / and see the login
form; wrong key shows an error; correct signing key shows the
generator form; a generated encrypted URL serves the image; an
expired URL (short TTL) returns 410; logout redirects back to login
- P0: implement cache size management and eviction so the disk cannot
fill up
- P0: validate configuration on startup, fail fast on bad config
- P1: implement blocked networks configuration to extend SSRF
protection
- P1: rate limit global concurrent upstream fetches to prevent
resource exhaustion
- P1: strip EXIF and other metadata from processed images (privacy)
- P2: security
- referer blacklist
- per-IP rate limiting
- per-origin rate limiting
- P2: HTTP response handling
- Last-Modified headers
- Vary header for content negotiation
- X-Request-ID propagation
- P2: auto format selection (format=auto based on Accept header)
- P2: configuration
- add all configuration options from README
- environment variable overrides
- YAML config file support
- P2: operational
- optional Sentry error reporting
- comprehensive request logging
- Prometheus performance metrics
- integration tests for the image proxy flow
- load tests to verify the 1k to 5k req/s target
- P2: documentation
- configuration options
- API endpoints
- deployment guide
- example nginx or caddy reverse proxy config
### Image Processing
- [ ] Implement EXIF/metadata stripping (privacy)
## P2: Nice to Have
### Security
- [ ] Implement referer blacklist
- [ ] Add rate limiting per-IP
- [ ] Add rate limiting per-origin
### HTTP Response Handling
- [ ] Implement Last-Modified headers
- [ ] Implement Vary header for content negotiation
- [ ] Implement X-Request-ID propagation
### Additional Endpoints
- [ ] Implement auto-format selection (format=auto based on Accept header)
### Configuration
- [ ] Add all configuration options from README
- [ ] Implement environment variable overrides
- [ ] Implement YAML config file support
### Operational
- [ ] Implement Sentry error reporting (optional)
- [ ] Add comprehensive request logging
- [ ] Add performance metrics (Prometheus)
- [ ] Write integration tests for image proxy flow
- [ ] Write load tests to verify 1-5k req/s target
### Documentation
- [ ] Document configuration options
- [ ] Document API endpoints
- [ ] Document deployment guide
- [ ] Add example nginx/caddy reverse proxy config

View File

@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/session"
@@ -72,7 +73,7 @@ func (s *Handlers) initImageService() error {
s.imgCache = cache
// Create the fetcher config
fetcherCfg := imgcache.DefaultFetcherConfig()
fetcherCfg := httpfetcher.DefaultConfig()
fetcherCfg.AllowHTTP = s.config.AllowHTTP
if s.config.UpstreamConnectionsPerHost > 0 {
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost

View File

@@ -18,6 +18,7 @@ import (
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache"
)
@@ -116,16 +117,16 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
return &mockFetcher{fs: fs}
}
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchResult, error) {
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*httpfetcher.FetchResult, error) {
// Remove https:// prefix
path := url[8:] // Remove "https://"
data, err := fs.ReadFile(f.fs, path)
if err != nil {
return nil, imgcache.ErrUpstreamError
return nil, httpfetcher.ErrUpstreamError
}
return &imgcache.FetchResult{
return &httpfetcher.FetchResult{
Content: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)),
ContentType: "image/jpeg",

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache"
)
@@ -97,13 +98,13 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
)
// Check for specific error types
if errors.Is(err, imgcache.ErrSSRFBlocked) {
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
s.respondError(w, "forbidden", http.StatusForbidden)
return
}
if errors.Is(err, imgcache.ErrUpstreamError) {
if errors.Is(err, httpfetcher.ErrUpstreamError) {
s.respondError(w, "upstream error", http.StatusBadGateway)
return

View File

@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache"
)
@@ -100,11 +101,11 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
// handleImageError converts image service errors to HTTP responses.
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, imgcache.ErrSSRFBlocked):
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
s.respondError(w, "forbidden", http.StatusForbidden)
case errors.Is(err, imgcache.ErrUpstreamError):
case errors.Is(err, httpfetcher.ErrUpstreamError):
s.respondError(w, "upstream error", http.StatusBadGateway)
case errors.Is(err, imgcache.ErrUpstreamTimeout):
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
default:
s.log.Error("image request failed", "error", err)

View File

@@ -1,4 +1,6 @@
package imgcache
// Package httpfetcher fetches content from upstream HTTP origins with SSRF
// protection, per-host connection limits, and content-type validation.
package httpfetcher
import (
"context"
@@ -37,25 +39,55 @@ var (
ErrUpstreamTimeout = errors.New("upstream request timeout")
)
// FetcherConfig holds configuration for the upstream fetcher.
type FetcherConfig struct {
// Timeout for upstream requests
// Fetcher retrieves content from upstream origins.
type Fetcher interface {
// Fetch retrieves content from the given URL.
Fetch(ctx context.Context, url string) (*FetchResult, error)
}
// FetchResult contains the result of fetching from upstream.
type FetchResult struct {
// Content is the raw image data.
Content io.ReadCloser
// ContentLength is the size in bytes (-1 if unknown).
ContentLength int64
// ContentType is the MIME type from upstream.
ContentType string
// Headers contains all response headers from upstream.
Headers map[string][]string
// StatusCode is the HTTP status code from upstream.
StatusCode int
// FetchDurationMs is how long the fetch took in milliseconds.
FetchDurationMs int64
// RemoteAddr is the IP:port of the upstream server.
RemoteAddr string
// HTTPVersion is the protocol version (e.g., "1.1", "2.0").
HTTPVersion string
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3").
TLSVersion string
// TLSCipherSuite is the negotiated cipher suite name.
TLSCipherSuite string
}
// Config holds configuration for the upstream fetcher.
type Config struct {
// Timeout for upstream requests.
Timeout time.Duration
// MaxResponseSize is the maximum allowed response body size
// MaxResponseSize is the maximum allowed response body size.
MaxResponseSize int64
// UserAgent to send to upstream servers
// UserAgent to send to upstream servers.
UserAgent string
// AllowedContentTypes is a whitelist of MIME types to accept
// AllowedContentTypes is an allow list of MIME types to accept.
AllowedContentTypes []string
// AllowHTTP allows non-TLS connections (for testing only)
// AllowHTTP allows non-TLS connections (for testing only).
AllowHTTP bool
// MaxConnectionsPerHost limits concurrent connections to each upstream host
// MaxConnectionsPerHost limits concurrent connections to each upstream host.
MaxConnectionsPerHost int
}
// DefaultFetcherConfig returns sensible defaults.
func DefaultFetcherConfig() *FetcherConfig {
return &FetcherConfig{
// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() *Config {
return &Config{
Timeout: DefaultFetchTimeout,
MaxResponseSize: DefaultMaxResponseSize,
UserAgent: "pixa/1.0",
@@ -72,18 +104,18 @@ func DefaultFetcherConfig() *FetcherConfig {
}
}
// HTTPFetcher implements the Fetcher interface with SSRF protection.
// HTTPFetcher implements Fetcher with SSRF protection and per-host connection limits.
type HTTPFetcher struct {
client *http.Client
config *FetcherConfig
config *Config
hostSems map[string]chan struct{} // per-host semaphores
hostSemMu sync.Mutex // protects hostSems map
}
// NewHTTPFetcher creates a new fetcher with SSRF protection.
func NewHTTPFetcher(config *FetcherConfig) *HTTPFetcher {
// New creates a new HTTPFetcher with SSRF protection.
func New(config *Config) *HTTPFetcher {
if config == nil {
config = DefaultFetcherConfig()
config = DefaultConfig()
}
// Create transport with SSRF-safe dialer
@@ -250,7 +282,7 @@ func (f *HTTPFetcher) Fetch(ctx context.Context, url string) (*FetchResult, erro
}, nil
}
// isAllowedContentType checks if the content type is in the whitelist.
// isAllowedContentType checks if the content type is in the allow list.
func (f *HTTPFetcher) isAllowedContentType(contentType string) bool {
// Extract the MIME type without parameters
mediaType := strings.TrimSpace(strings.Split(contentType, ";")[0])

View File

@@ -0,0 +1,329 @@
package httpfetcher
import (
"context"
"errors"
"io"
"net"
"testing"
"testing/fstest"
)
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.Timeout != DefaultFetchTimeout {
t.Errorf("Timeout = %v, want %v", cfg.Timeout, DefaultFetchTimeout)
}
if cfg.MaxResponseSize != DefaultMaxResponseSize {
t.Errorf("MaxResponseSize = %d, want %d", cfg.MaxResponseSize, DefaultMaxResponseSize)
}
if cfg.MaxConnectionsPerHost != DefaultMaxConnectionsPerHost {
t.Errorf("MaxConnectionsPerHost = %d, want %d",
cfg.MaxConnectionsPerHost, DefaultMaxConnectionsPerHost)
}
if cfg.AllowHTTP {
t.Error("AllowHTTP should default to false")
}
if len(cfg.AllowedContentTypes) == 0 {
t.Error("AllowedContentTypes should not be empty")
}
}
func TestNewWithNilConfigUsesDefaults(t *testing.T) {
f := New(nil)
if f == nil {
t.Fatal("New(nil) returned nil")
}
if f.config == nil {
t.Fatal("config should be populated from DefaultConfig")
}
if f.config.Timeout != DefaultFetchTimeout {
t.Errorf("Timeout = %v, want %v", f.config.Timeout, DefaultFetchTimeout)
}
}
func TestIsAllowedContentType(t *testing.T) {
f := New(DefaultConfig())
tests := []struct {
contentType string
want bool
}{
{"image/jpeg", true},
{"image/png", true},
{"image/webp", true},
{"image/jpeg; charset=utf-8", true},
{"IMAGE/JPEG", true},
{"text/html", false},
{"application/octet-stream", false},
{"", false},
}
for _, tc := range tests {
t.Run(tc.contentType, func(t *testing.T) {
got := f.isAllowedContentType(tc.contentType)
if got != tc.want {
t.Errorf("isAllowedContentType(%q) = %v, want %v", tc.contentType, got, tc.want)
}
})
}
}
func TestExtractHost(t *testing.T) {
tests := []struct {
url string
want string
}{
{"https://example.com/path", "example.com"},
{"http://example.com:8080/path", "example.com:8080"},
{"https://example.com", "example.com"},
{"https://example.com?q=1", "example.com"},
{"example.com/path", "example.com"},
{"", ""},
}
for _, tc := range tests {
t.Run(tc.url, func(t *testing.T) {
got := extractHost(tc.url)
if got != tc.want {
t.Errorf("extractHost(%q) = %q, want %q", tc.url, got, tc.want)
}
})
}
}
func TestIsLocalhost(t *testing.T) {
tests := []struct {
host string
want bool
}{
{"localhost", true},
{"LOCALHOST", true},
{"127.0.0.1", true},
{"::1", true},
{"[::1]", true},
{"foo.localhost", true},
{"foo.local", true},
{"example.com", false},
{"127.0.0.2", false}, // Handled by isPrivateIP, not isLocalhost string match
}
for _, tc := range tests {
t.Run(tc.host, func(t *testing.T) {
got := isLocalhost(tc.host)
if got != tc.want {
t.Errorf("isLocalhost(%q) = %v, want %v", tc.host, got, tc.want)
}
})
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
ip string
want bool
}{
{"127.0.0.1", true}, // loopback
{"10.0.0.1", true}, // private
{"192.168.1.1", true}, // private
{"172.16.0.1", true}, // private
{"169.254.1.1", true}, // link-local
{"0.0.0.0", true}, // unspecified
{"224.0.0.1", true}, // multicast
{"::1", true}, // IPv6 loopback
{"fe80::1", true}, // IPv6 link-local
{"8.8.8.8", false}, // public
{"2001:4860:4860::8888", false}, // public IPv6
}
for _, tc := range tests {
t.Run(tc.ip, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("failed to parse IP %q", tc.ip)
}
got := isPrivateIP(ip)
if got != tc.want {
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
}
})
}
if !isPrivateIP(nil) {
t.Error("isPrivateIP(nil) should return true")
}
}
func TestValidateURL_RejectsNonHTTPS(t *testing.T) {
err := validateURL("http://example.com/path", false)
if !errors.Is(err, ErrUnsupportedScheme) {
t.Errorf("validateURL http = %v, want ErrUnsupportedScheme", err)
}
}
func TestValidateURL_AllowsHTTPWhenConfigured(t *testing.T) {
// Use a host that won't resolve (explicit .invalid TLD) so we don't hit DNS.
err := validateURL("http://nonexistent.invalid/path", true)
// We expect a host resolution error, not ErrUnsupportedScheme.
if errors.Is(err, ErrUnsupportedScheme) {
t.Error("validateURL with AllowHTTP should not return ErrUnsupportedScheme")
}
}
func TestValidateURL_RejectsLocalhost(t *testing.T) {
err := validateURL("https://localhost/path", false)
if !errors.Is(err, ErrSSRFBlocked) {
t.Errorf("validateURL localhost = %v, want ErrSSRFBlocked", err)
}
}
func TestValidateURL_EmptyHost(t *testing.T) {
err := validateURL("https:///path", false)
if !errors.Is(err, ErrInvalidHost) {
t.Errorf("validateURL empty host = %v, want ErrInvalidHost", err)
}
}
func TestMockFetcher_FetchesFile(t *testing.T) {
mockFS := fstest.MapFS{
"example.com/images/photo.jpg": &fstest.MapFile{Data: []byte("fake-jpeg-data")},
}
m := NewMock(mockFS)
result, err := m.Fetch(context.Background(), "https://example.com/images/photo.jpg")
if err != nil {
t.Fatalf("Fetch() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
if result.ContentType != "image/jpeg" {
t.Errorf("ContentType = %q, want image/jpeg", result.ContentType)
}
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("read content: %v", err)
}
if string(data) != "fake-jpeg-data" {
t.Errorf("Content = %q, want %q", string(data), "fake-jpeg-data")
}
if result.ContentLength != int64(len("fake-jpeg-data")) {
t.Errorf("ContentLength = %d, want %d", result.ContentLength, len("fake-jpeg-data"))
}
}
func TestMockFetcher_MissingFileReturnsUpstreamError(t *testing.T) {
mockFS := fstest.MapFS{}
m := NewMock(mockFS)
_, err := m.Fetch(context.Background(), "https://example.com/missing.jpg")
if !errors.Is(err, ErrUpstreamError) {
t.Errorf("Fetch() error = %v, want ErrUpstreamError", err)
}
}
func TestMockFetcher_RespectsContextCancellation(t *testing.T) {
mockFS := fstest.MapFS{
"example.com/photo.jpg": &fstest.MapFile{Data: []byte("data")},
}
m := NewMock(mockFS)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := m.Fetch(ctx, "https://example.com/photo.jpg")
if !errors.Is(err, context.Canceled) {
t.Errorf("Fetch() error = %v, want context.Canceled", err)
}
}
func TestDetectContentTypeFromPath(t *testing.T) {
tests := []struct {
path string
want string
}{
{"foo/bar.jpg", "image/jpeg"},
{"foo/bar.JPG", "image/jpeg"},
{"foo/bar.jpeg", "image/jpeg"},
{"foo/bar.png", "image/png"},
{"foo/bar.gif", "image/gif"},
{"foo/bar.webp", "image/webp"},
{"foo/bar.avif", "image/avif"},
{"foo/bar.svg", "image/svg+xml"},
{"foo/bar.bin", "application/octet-stream"},
{"foo/bar", "application/octet-stream"},
}
for _, tc := range tests {
t.Run(tc.path, func(t *testing.T) {
got := detectContentTypeFromPath(tc.path)
if got != tc.want {
t.Errorf("detectContentTypeFromPath(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
func TestLimitedReader_EnforcesLimit(t *testing.T) {
src := make([]byte, 100)
r := &limitedReader{
reader: &byteReader{data: src},
remaining: 50,
}
buf := make([]byte, 100)
n, err := r.Read(buf)
if err != nil {
t.Fatalf("first Read error = %v", err)
}
if n > 50 {
t.Errorf("read %d bytes, should be capped at 50", n)
}
// Drain until limit is exhausted.
total := n
for total < 50 {
nn, err := r.Read(buf)
total += nn
if err != nil {
t.Fatalf("during drain: %v", err)
}
}
// Now the limit is exhausted — next read should error.
_, err = r.Read(buf)
if !errors.Is(err, ErrResponseTooLarge) {
t.Errorf("exhausted Read error = %v, want ErrResponseTooLarge", err)
}
}
// byteReader is a minimal io.Reader over a byte slice for testing.
type byteReader struct {
data []byte
pos int
}
func (r *byteReader) Read(p []byte) (int, error) {
if r.pos >= len(r.data) {
return 0, io.EOF
}
n := copy(p, r.data[r.pos:])
r.pos += n
return n, nil
}

View File

@@ -1,4 +1,4 @@
package imgcache
package httpfetcher
import (
"context"
@@ -10,15 +10,15 @@ import (
"strings"
)
// MockFetcher implements the Fetcher interface using an embedded filesystem.
// MockFetcher implements Fetcher using an embedded filesystem.
// Files are organized as: hostname/path/to/file.ext
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg
// URLs like https://example.com/images/photo.jpg map to example.com/images/photo.jpg.
type MockFetcher struct {
fs fs.FS
}
// NewMockFetcher creates a new mock fetcher backed by the given filesystem.
func NewMockFetcher(fsys fs.FS) *MockFetcher {
// NewMock creates a new mock fetcher backed by the given filesystem.
func NewMock(fsys fs.FS) *MockFetcher {
return &MockFetcher{fs: fsys}
}

View File

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

View File

@@ -9,6 +9,7 @@ import (
"time"
_ "modernc.org/sqlite"
"sneak.berlin/go/pixa/internal/httpfetcher"
)
func setupTestDB(t *testing.T) *sql.DB {
@@ -152,7 +153,7 @@ func TestCache_StoreAndLookup(t *testing.T) {
// Store source content
sourceContent := []byte("fake jpeg data")
fetchResult := &FetchResult{
fetchResult := &httpfetcher.FetchResult{
ContentType: "image/jpeg",
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
}

View File

@@ -169,36 +169,6 @@ type Whitelist interface {
IsWhitelisted(u *url.URL) bool
}
// Fetcher fetches images from upstream origins
type Fetcher interface {
// Fetch retrieves an image from the origin
Fetch(ctx context.Context, url string) (*FetchResult, error)
}
// FetchResult contains the result of fetching from upstream
type FetchResult struct {
// Content is the raw image data
Content io.ReadCloser
// ContentLength is the size in bytes (-1 if unknown)
ContentLength int64
// ContentType is the MIME type from upstream
ContentType string
// Headers contains all response headers from upstream
Headers map[string][]string
// StatusCode is the HTTP status code from upstream
StatusCode int
// FetchDurationMs is how long the fetch took in milliseconds
FetchDurationMs int64
// RemoteAddr is the IP:port of the upstream server
RemoteAddr string
// HTTPVersion is the protocol version (e.g., "1.1", "2.0")
HTTPVersion string
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3")
TLSVersion string
// TLSCipherSuite is the negotiated cipher suite name
TLSCipherSuite string
}
// Storage handles persistent storage of cached content
type Storage interface {
// Store saves content and returns its hash

View File

@@ -12,6 +12,7 @@ import (
"github.com/dustin/go-humanize"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/magic"
)
@@ -19,7 +20,7 @@ import (
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
type Service struct {
cache *Cache
fetcher Fetcher
fetcher httpfetcher.Fetcher
processor *imageprocessor.ImageProcessor
signer *Signer
allowlist *allowlist.HostAllowList
@@ -33,9 +34,9 @@ type ServiceConfig struct {
// Cache is the cache instance
Cache *Cache
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
FetcherConfig *FetcherConfig
FetcherConfig *httpfetcher.Config
// Fetcher is an optional custom fetcher (for testing)
Fetcher Fetcher
Fetcher httpfetcher.Fetcher
// SigningKey is the HMAC signing key (empty disables signing)
SigningKey string
// Whitelist is the list of hosts that don't require signatures
@@ -57,15 +58,15 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
// Resolve fetcher config for defaults
fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil {
fetcherCfg = DefaultFetcherConfig()
fetcherCfg = httpfetcher.DefaultConfig()
}
// Use custom fetcher if provided, otherwise create HTTP fetcher
var fetcher Fetcher
var fetcher httpfetcher.Fetcher
if cfg.Fetcher != nil {
fetcher = cfg.Fetcher
} else {
fetcher = NewHTTPFetcher(fetcherCfg)
fetcher = httpfetcher.New(fetcherCfg)
}
signer := NewSigner(cfg.SigningKey)
@@ -113,7 +114,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
"path", req.SourcePath,
)
return nil, fmt.Errorf("%w: %w", ErrUpstreamError, ErrNegativeCached)
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
}
// Check variant cache first (disk only, no DB)
@@ -418,13 +419,13 @@ const (
// isNegativeCacheable returns true if the error should be cached.
func isNegativeCacheable(err error) bool {
return errors.Is(err, ErrUpstreamError)
return errors.Is(err, httpfetcher.ErrUpstreamError)
}
// extractStatusCode extracts HTTP status code from error message.
func extractStatusCode(err error) int {
// Default to 502 Bad Gateway for upstream errors
if errors.Is(err, ErrUpstreamError) {
if errors.Is(err, httpfetcher.ErrUpstreamError) {
return httpStatusBadGateway
}

View File

@@ -15,6 +15,7 @@ import (
"time"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
)
// TestFixtures contains paths to test files in the mock filesystem.
@@ -172,7 +173,7 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
svc, err := NewService(&ServiceConfig{
Cache: cache,
Fetcher: NewMockFetcher(mockFS),
Fetcher: httpfetcher.NewMock(mockFS),
SigningKey: cfg.signingKey,
Whitelist: cfg.whitelist,
})

View File

@@ -1,138 +0,0 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git,
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
# it is installed from a hash-verified GitHub release archive (never
# curl | sh). CGO image libraries (pkg-config, vips, libheif) are
# installed for the govips bindings.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07. Never "latest"; exact versions only.
GOLANGCI_LINT_VERSION="2.10.1"
# sha256 of golangci-lint-2.10.1-linux-<arch>.tar.gz release archives
GOLANGCI_LINT_SHA256_AMD64="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99"
GOLANGCI_LINT_SHA256_ARM64="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# apt has no golangci-lint package: install a pinned release archive
# from GitHub, verified by hardcoded sha256 (never curl | sh).
install_golangci_lint_release() {
case "$(uname -m)" in
x86_64) goarch="amd64"; sha="$GOLANGCI_LINT_SHA256_AMD64" ;;
aarch64|arm64) goarch="arm64"; sha="$GOLANGCI_LINT_SHA256_ARM64" ;;
*)
echo "bootstrap: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
if missing curl; then pkg_install curl curl curl curl; fi
name="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${goarch}"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/$name.tar.gz" \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/${name}.tar.gz"
verify_sha256 "$tmp/$name.tar.gz" "$sha"
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
$SUDO install -m 0755 "$tmp/$name/golangci-lint" /usr/local/bin/golangci-lint
rm -rf "$tmp"
}
ensure_golangci_lint() {
if ! missing golangci-lint; then return 0; fi
detect_pkgmgr
case "$PKGMGR" in
apt) install_golangci_lint_release ;;
*) pkg_install golangci-lint golangci-lint golangci-lint golangci-lint ;;
esac
}
# CGO dependencies for govips (image processing)
ensure_cgo_deps() {
if missing pkg-config; then
pkg_install pkg-config pkg-config pkg-config pkgconfig
fi
if ! pkg-config --exists vips; then
pkg_install vips libvips-dev vips vips-dev
fi
if ! pkg-config --exists libheif; then
pkg_install libheif libheif-dev libheif libheif-dev
fi
}
main() {
cd "$ROOT"
# Base tooling
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
# Go toolchain and linter
if missing go; then pkg_install go golang go go; fi
ensure_golangci_lint
# CGO image libraries
ensure_cgo_deps
go mod download
echo "bootstrap complete"
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
# Generic: usually needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs the checks
# (make fmt-check, lint, test), so a successful build implies a green
# repo. Generic: needs no adaptation. The Gitea workflow runs this on
# push.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

View File

@@ -1,15 +0,0 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# Generic: needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Formatting code..."
# shellcheck disable=SC2046 # word splitting of file list is wanted
gofmt -w $(find . -name '*.go' -not -path './vendor/*')
}
main "$@"

View File

@@ -1,18 +0,0 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Checking formatting..."
if [ -n "$(gofmt -l . | grep -v '^vendor/')" ]; then
echo "Files need formatting:"
gofmt -l . | grep -v '^vendor/'
exit 1
fi
}
main "$@"

View File

@@ -1,16 +0,0 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
# Generic: needs no adaptation.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

View File

@@ -1,23 +0,0 @@
#!/bin/sh
# script/lint: run the linter. CGO dependencies (pkg-config, vips,
# libheif) come from nix-shell when not already available (e.g. inside
# a Docker build or an existing nix-shell).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
run_with_cgo_deps() {
if command -v pkg-config >/dev/null 2>&1; then
sh -c "$1"
else
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
fi
}
main() {
cd "$ROOT"
echo "Running linter..."
run_with_cgo_deps "golangci-lint run"
}
main "$@"

View File

@@ -1,21 +0,0 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
# extras: go mod tidy must not change go.mod/go.sum.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
git diff --exit-code -- go.mod go.sum || {
echo "precommit: go mod tidy changed go.mod/go.sum;" \
"stage the changes and retry" >&2
exit 1
}
"$SCRIPT_DIR/check"
}
main "$@"

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "pixa"
}
main "$@"

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies (script/bootstrap) and the git pre-commit hook.
# Add any repo-specific initialization (db init, .env template) here.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

View File

@@ -1,23 +0,0 @@
#!/bin/sh
# script/test: run the test suite. CGO dependencies (pkg-config, vips,
# libheif) come from nix-shell when not already available (e.g. inside
# a Docker build or an existing nix-shell).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
run_with_cgo_deps() {
if command -v pkg-config >/dev/null 2>&1; then
sh -c "$1"
else
nix-shell -p pkg-config vips libheif golangci-lint git --run "$1"
fi
}
main() {
cd "$ROOT"
echo "Running tests..."
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -v ./..."
}
main "$@"