20 Commits

Author SHA1 Message Date
c1ec038c99 docs: document cache_max_bytes, update TODO.md (closes #51)
All checks were successful
check / check (push) Successful in 1m40s
Add cache_max_bytes to config.example.yml and the README key settings
list. TODO.md: move cache size management and eviction to Completed
Steps, promote P1 blocked networks configuration into Next Step, and
note in Status that the unbounded disk growth DoS vector is closed.
2026-08-07 21:12:05 +00:00
bdd86a4c1e feat: DB-tracked cache size accounting with background LRU eviction
Migration 002 adds a variant_content table (processed variants were
untracked on disk) and an LRU timestamp on source_content. Total usage
is two SUMs, never a directory scan on the hot path; hits touch LRU
timestamps best-effort. A background goroutine evicts globally
least-recently-used entries (variants and source blobs merged) until
usage is under MaxBytes, woken by a periodic ticker and by non-blocking
write-pressure notifications from stores. Evicting a source blob
deletes all source_metadata rows referencing it plus its
source_content row in one transaction before the file is unlinked, so
multi-referenced blobs are removed only with all their references and
rows never point at deleted files; JSON sidecars are cleaned up too. A
one-time startup reconciliation walk adopts untracked variant files,
drops rows whose files are missing, removes unreachable source blobs,
and sweeps stale temp files. CacheConfig.DisableDiskCache turns the
disk cache off entirely (config maps cache_max_bytes: 0 to it): no
directories, lookups miss, stores no-op, no evictor. Handlers wire the
limit, start eviction on startup, and stop it on shutdown.
2026-08-07 21:06:03 +00:00
8cb09b6aaf feat: add cache_max_bytes config key with statfs-derived default
Strict int64 parsing via the startup validation framework: a SET but
invalid value (negative, float, null, non-numeric) aborts startup
naming the key and value. An omitted key resolves after state_dir
validation to max(75% of free bytes on the filesystem containing
<state_dir>/cache/, 500 MiB), measured via an injectable statfs probe;
the floor never applies to explicit values. Zero is valid and means
the disk cache is disabled. The effective limit is logged at startup.
2026-08-07 21:02:08 +00:00
3963ec31c1 test: add failing tests for cache_max_bytes config and cache eviction
Red phase for #51: covers strict cache_max_bytes parsing (invalid
explicit values abort naming key and value), the computed default of
max(75% of free space, 500 MiB) via an injectable free-space probe,
explicit-value-no-floor, zero-disables-cache, size accounting over
source blobs and variants, LRU eviction under the limit, the
multi-referenced blob case, write-pressure and periodic eviction
triggers, and startup reconciliation. Minimal API skeletons keep the
tree compiling and lint-clean; only the new tests fail.
2026-08-07 20:58:03 +00:00
61f42e6602 feat: validate configuration on startup, fail fast on bad config (closes #52) (#53)
All checks were successful
check / check (push) Successful in 4s
closes #52

Implements startup configuration validation per the plan on #52. Two commits, TDD: the first commit adds the enforcement tests (red — six test functions fail against the lenient behavior) plus a mechanical extraction of `newFromSmartConfig` from `config.New` so construction is testable without fx; the second commit makes them green and carries the `TODO.md` bookkeeping.

## Behavior

- **No silent fallbacks**: a config value that is SET but unparseable or invalid aborts startup with an error naming the key and value. Defaults apply only to OMITTED keys. The old `getString`/`getInt`/`getBool` helpers swallowed every conversion error and returned the default; they are now strict. Fractional ports are rejected, not truncated (smartconfig's `GetInt` would have turned `8080.5` into `8080`).
- **Unknown keys abort**: unknown top-level keys and unknown `metrics` subkeys are fatal, each named in the error (`unknown config keys: whitelist_hosts`). The `env` section stays permitted because smartconfig consumes it for environment injection.
- **Malformed config file aborts**: a config file that exists at a standard location but fails to parse was previously logged as a warning and skipped (the server would start on defaults); it is now fatal.
- **Range/sanity checks**: `port` in 1-65535; `upstream_connections_per_host` at least 1; `signing_key` required, at least 32 characters (keyless mode was never implemented; the stale "leave empty" comment in `config.example.yml` is corrected); `allowlist_hosts` entries must be bare hostnames (leading-dot suffix patterns still allowed; schemes, paths, whitespace, non-string and empty entries rejected); `state_dir` non-empty and verified creatable+writable with a probe file before the listener binds; `sentry_dsn` must be a URL with scheme and host when set; `metrics.username`/`metrics.password` must be set together.

## Verification

- `make check` green on the branch head (all tests, golangci-lint 0 issues, fmt-check clean).
- End-to-end: `./bin/pixad` with `port: banana` exits 1 printing `config key "port": value "banana" is not an integer`; with `whitelist_hosts:` it exits 1 printing `unknown config keys: whitelist_hosts`.

## Notes for review

- `getStringSlice` keeps its lenient signature because the existing tests in `config_test.go` exercise it and modifying existing tests requires explicit approval. Strictness for `allowlist_hosts` is instead enforced up front on the raw value by `validateAllowlistHostsValue`, so nothing is silently skipped; extraction then reuses the existing parser. If you prefer the helper folded into a single strict function, that requires retargeting those three tests — happy to do that as a follow-up with approval.
- `TODO.md` here is edited against current `main`; PR #50 (merge-ready) edits adjacent lines, so whichever merges second will need a trivial rebase of `TODO.md` only.
- The README Configuration section lists keys that have never existed in the code (`access_control_allow_origin`, `upstream_fetch_timeout`, `upstream_max_response_size`, `downstream_timeout`). Under this change a config using them now fails fast instead of silently doing nothing — that is the intended behavior. Implementing them is already tracked as the P2 "add all configuration options from README" item in `TODO.md`.

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #53
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 22:39:40 +02:00
5d0b5f864e docs: record manual test pass of auth and encrypted URL flows (closes #49) (#50)
All checks were successful
check / check (push) Successful in 4s
closes #49

Records the P0 manual test pass in `TODO.md` per its Workflow section
(checked-off results into Completed Steps; cache size management and
eviction promoted to Next Step). `TODO.md` is the only changed file —
no production code changes, as the issue requires.

## Test setup

`pixad` built from `main` at `6573b9d` via `make build`, run on port
18099 with a throwaway local config (temp state dir, known
`signing_key`, `allowlist_hosts` including `s3.sneak.cloud`), driven
with curl using explicit cookie replay (session cookies are
`Secure`/`HttpOnly`/`SameSite=Strict`).

## Results — all six checks PASS

1. **Login form**: GET `/` → HTTP 200, `Pixa - Login` page with
   `name="key"` password form.
2. **Wrong key error**: POST `/` with `key=wrong-key` → HTTP 200 login
   page containing "Invalid signing key".
3. **Generator form**: POST `/` with the correct signing key → HTTP 303
   to `/` with `Set-Cookie: pixa_session=...; HttpOnly; Secure;
   SameSite=Strict`; GET `/` with that cookie → `Pixa - URL Generator`
   with the `/generate` form and logout link.
4. **Encrypted URL serves image**: POST `/generate` (ttl=3600) produced
   a `/v1/e/<token>/img.jpeg` URL → HTTP 200, `Content-Type:
   image/jpeg`, 800x600 baseline JPEG, 61706 bytes.
5. **Expired URL → 410**: a ttl=1 URL fetched after 3 s → HTTP 410 Gone
   with `{"error":"URL has expired","status":410,...}`.
6. **Logout**: GET `/logout` → HTTP 303 to `/` with `Set-Cookie:
   pixa_session=; Max-Age=0`; subsequent GET `/` → login form again.

Additionally, all nine checks in `scripts/manual-test.sh` passed
against the same server instance.

## Verification

`make check` green on the branch head (all tests, golangci-lint 0
issues, fmt-check clean) — the first fully green `make check` on a
`main`-derived branch under the current linter, confirming the #47/#48
fix on merged `main`.

Note for review: the test execution was performed this session; the
adversarial re-review (independently re-running the six flows) is still
pending and should happen before merge.

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #50
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 18:44:01 +02:00
6573b9d1ef refactor: extract signature package from imgcache (#46)
All checks were successful
check / check (push) Successful in 5s
Extracts HMAC-SHA256 request signing out of `internal/imgcache/` into its own `internal/signature/` package, per the plan in [issue #39](#39).

This is one of the remaining "easily separable" extractions (`imageprocessor`, `allowlist`, `magic`, and `httpfetcher` already landed). Only the signer is moved here so the diff stays reviewable.

## What moved

From `internal/imgcache/signature.go` and its tests into `internal/signature/`:

- `Signer` type, its `New` constructor, `Sign`, `Verify`, `GenerateSignedURL`
- `ParseParams` (query-string signature/expiration parsing)
- Signature error sentinels

## One-way import edge

To keep the import edge one-way (`imgcache` depends on `signature`, never the reverse), the package defines a standalone `Request` type carrying just the fields the signature covers, instead of importing `imgcache.ImageRequest`. `imgcache` projects its `ImageRequest` onto `signature.Request` via a small unexported `signatureRequest` helper. This mirrors how the `magic` extraction defined its own `ImageFormat` type.

## Renames (no stuttering)

- `NewSigner` -> `signature.New`
- `ParseSignatureParams` -> `signature.ParseParams`
- `ErrSignatureRequired`/`Invalid`/`Expired` -> `signature.ErrRequired`/`Invalid`/`Expired`

The `ErrRequired` message is updated from "non-whitelisted host" to "non-allowlisted host" for inclusive terminology, consistent with the `allowlist` rename.

## Rework (post-review)

Three commits added after review feedback:

- `d69019b` — golden known-answer test pinning the exact HMAC signatures and signed URL paths for three fixed vectors (resized, resized+query, orig size), cross-validated against an independent HMAC implementation. Any change to the signed byte format now fails loudly.
- `43b9f1c` — whitelist→allowlist rename completed across `internal/imgcache` and `internal/handlers` (`ServiceConfig.Allowlist`, `Allowlist` interface, `IsAllowlisted`, test helpers and test names).
- `3dc1999` — one-pass config surface rename, no back-compat alias: YAML key `whitelist_hosts` → `allowlist_hosts`, `Config.WhitelistHosts` → `Config.AllowlistHosts`, `config.example.yml`, `scripts/manual-test.sh`, and `README.md` (which also documented a nonexistent `source_host_whitelist` key — now fixed to the real one).

## Behavior

Pure refactor apart from the config key rename above. The bytes fed to the HMAC are unchanged (`host:path:query:width:height:format:expiration`), so previously issued signatures remain valid — now enforced by the golden test. All existing tests move with the package. `script/cibuild` passes at head `3dc1999` (fmt-check, lint, test, build).

refs #39

Co-authored-by: sneak <sneak@sneak.berlin>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #46
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 17:44:00 +02:00
275e145a6d fix: set Secure/HttpOnly/SameSite on session cookies (closes #47) (#48)
All checks were successful
check / check (push) Successful in 5s
closes #47

Fixes the two remaining `gosec` findings on `main`, both `G124`
(http.Cookie missing or has insecure `Secure`, `HttpOnly`, or
`SameSite` attribute):

- `internal/session/session.go:84` (`CreateSession`, the login
  set-cookie path)
- `internal/session/session.go:128` (`ClearSession`, the logout
  delete-cookie path)

## What changed

- Both cookie-writing paths now unconditionally set `Secure: true`,
  `HttpOnly: true`, and `SameSite: http.SameSiteStrictMode`.
- The `secure` field (previously wired to `!config.Debug`) and the
  `sameSite` field are removed from `session.Manager`, and the dead
  secure-toggle parameter is removed from `session.NewManager`, which
  now takes only the signing key (reviewer-directed; the mechanical
  call-shape updates in `session_test.go` leave every assertion
  untouched).
- TDD per repo rules: the first commit adds
  `TestSessionCookieAttributesAlwaysSecure` (failing), asserting that
  every cookie emitted by the session manager carries `HttpOnly`,
  `Secure`, and `SameSite` of Lax or stricter, for both write paths.
  The second commit makes it pass.
- `TODO.md` updated per its Workflow section (Next Step completed,
  next Future Step promoted, stale "10 open findings" Status text
  corrected).

## Attribute choices and reasoning

- `Secure: true` always: the `G124` analyzer only accepts a constant
  `true` store, and there is no legitimate configuration in which the
  authentication cookie should be sent over plaintext HTTP. The old
  behavior disabled `Secure` whenever `debug` was on. Local development
  over `http://localhost` keeps working: browsers treat `localhost` as
  a trustworthy origin and accept `Secure` cookies there. Any
  plain-HTTP flow on a non-localhost host will no longer keep a
  session, which is the point of the fix.
- `SameSite: Strict` (unchanged from current production behavior, and
  stricter than the Lax minimum): the login form is a same-origin POST
  to `/` followed by a same-site redirect, so `Strict` breaks nothing.
- `HttpOnly: true` (unchanged).

## Verification

`make check` (tests, golangci-lint, fmt-check) is fully green on the
branch head `cb9e14e`: all tests pass and the linter reports 0 issues,
independently confirmed by the reviewer in a fresh worktree. Commit
history: `ca15f52` (failing test) → `02ca16a` (fix + TODO.md, closes
#47) → `cb9e14e` (drop the dead `NewManager` parameter).

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #48
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 17:41:03 +02:00
b6e9ac2a93 refactor: extract httpfetcher package from imgcache (#43)
All checks were successful
check / check (push) Successful in 4s
Extracts the HTTP fetcher concern out of `internal/imgcache/` into its own `internal/httpfetcher/` package, per the plan in [issue #39](#39).

This is one of four planned extractions; only the fetcher is moved here so the diff stays reviewable. The remaining three (signature, magic, urlparser) will land in separate PRs.

## What moved

From `internal/imgcache/fetcher.go` and `internal/imgcache/mock_fetcher.go` into `internal/httpfetcher/`:

- `HTTPFetcher` type, its `New` constructor, and `Config` (formerly `FetcherConfig`) with `DefaultConfig`
- SSRF-safe dialer, `validateURL`, `isPrivateIP`, `isLocalhost`, `extractHost`
- Per-host connection semaphore (rate limiting) and `limitedReader`
- Content-type validation (`isAllowedContentType`, `detectContentTypeFromPath`)
- All related error values (`ErrSSRFBlocked`, `ErrUpstreamError`, `ErrUpstreamTimeout`, `ErrPayloadTooLarge`, `ErrDisallowedContentType`)
- All related constants (`DefaultFetchTimeout`, `DefaultMaxPayloadBytes`, `DefaultMaxConnectionsPerHost`, etc.)
- The `Fetcher` interface and `FetchResult` type (moved here to keep the import edge one-way: `imgcache` depends on `httpfetcher`, never the reverse)
- `MockFetcher` test helper

## Renames (no stuttering)

- `NewHTTPFetcher` → `httpfetcher.New`
- `FetcherConfig` → `httpfetcher.Config`
- `DefaultFetcherConfig` → `httpfetcher.DefaultConfig`
- `NewMockFetcher` → `httpfetcher.NewMock`

The `ServiceConfig.FetcherConfig` field name is retained — it describes what kind of config the field holds (not a stutter).

## Behavior

Pure refactor. No behavior changes. All existing tests pass; unit tests for the new package are included.

`docker build .` passes (fmt-check, lint, test, build).

refs #39

Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #43
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-07-25 12:26:18 +02:00
504afea4f8 scripts-to-rule-them-all (#45)
All checks were successful
check / check (push) Successful in 4s
Reviewed-on: #45
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-07 02:14:03 +02:00
2fb909283d Update TODO.md: standard structure and Workflow section (#44)
All checks were successful
check / check (push) Successful in 5s
Reviewed-on: #44
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-06 21:20:56 +02:00
6b4a1d7607 refactor: extract magic byte detection into internal/magic package (#42)
All checks were successful
check / check (push) Successful in 1m39s
## Summary

Extract magic byte detection and MIME type handling from `internal/imgcache/` into a new focused `internal/magic/` package.

Part of [issue #39](#39)

## Changes

### New package: `internal/magic/`

Moved the following from `internal/imgcache/magic.go`:
- `MIMEType` type and constants (`MIMETypeJPEG`, `MIMETypePNG`, etc.)
- `DetectFormat()` — detects image format from magic bytes
- `ValidateMagicBytes()` — validates content matches declared MIME type
- `PeekAndValidate()` — reads minimum bytes, validates, returns combined reader
- `IsSupportedMIMEType()` — checks if a MIME type is supported
- `MIMEToImageFormat()` — converts MIME type to ImageFormat
- `ImageFormatToMIME()` — converts ImageFormat to MIME string
- All error sentinels (`ErrUnknownFormat`, `ErrMagicByteMismatch`, `ErrNotEnoughData`)
- All helper functions (`detectSVG`, `skipBOM`, `normalizeMIMEType`)

The magic package defines its own `ImageFormat` type and constants to avoid circular imports (`imgcache` → `magic` for validation; `magic` cannot import `imgcache`).

### Updated imports
- `internal/imgcache/service.go`: uses `magic.ValidateMagicBytes()`
- `internal/imgcache/service_test.go`: uses `magic.DetectFormat()` and `magic.MIMEToImageFormat()`

### Naming
- Clean package-qualified names: `magic.DetectFormat()`, `magic.ValidateMagicBytes()`, etc.
- No stuttering names

### Tests
- Full test suite moved to `internal/magic/magic_test.go` (all 15 test functions preserved)
- All existing tests pass unchanged
- `docker build .` passes (includes `make check`: fmt, lint, tests)

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #42
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-04-07 00:41:48 +02:00
e34743f070 refactor: extract whitelist package from internal/imgcache (#41)
All checks were successful
check / check (push) Successful in 4s
Extract `HostWhitelist`, `NewHostWhitelist`, `IsWhitelisted`, `IsEmpty`, and `Count` from `internal/imgcache/` into the new `internal/whitelist/` package.

The whitelist package is completely self-contained, depending only on `net/url` and `strings` from the standard library. No circular imports introduced.

**Changes:**
- Moved `whitelist.go` → `internal/whitelist/whitelist.go` (added package comment)
- Moved `whitelist_test.go` → `internal/whitelist/whitelist_test.go` (adapted to external test style)
- Updated `internal/imgcache/service.go` to import from `sneak.berlin/go/pixa/internal/whitelist`

`docker build .` passes (lint, tests, build).

Part of [issue #39](#39)

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #41
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-25 20:44:56 +01:00
7010d55d72 Move schema_migrations table creation into 000.sql (#36)
All checks were successful
check / check (push) Successful in 1m43s
## Summary

Moves the `schema_migrations` table definition from inline Go code into `internal/database/schema/000.sql`, so the migration tracking table schema lives alongside all other schema files.

closes #29

## Changes

### New file: `internal/database/schema/000.sql`
- Contains the `CREATE TABLE IF NOT EXISTS schema_migrations` DDL
- This is applied as a bootstrap step before the normal migration loop

### Refactored: `internal/database/database.go`
- Removed the inline `CREATE TABLE IF NOT EXISTS schema_migrations` SQL from both `runMigrations` and `ApplyMigrations`
- Added `bootstrapMigrationsTable()` which:
  - Checks `sqlite_master` to see if the table already exists
  - If missing: reads and executes `000.sql` to create it, then records version `000`
  - If present (backwards compat with existing DBs created by old inline code): back-fills version `000` so the normal loop skips the bootstrap file
- Deduplicated: both `Database.runMigrations()` and the exported `ApplyMigrations()` now delegate to a single `applyMigrations()` helper
- Added `logInfo`/`logDebug` helpers to handle the optional logger (nil when called from `ApplyMigrations` in tests)

### New file: `internal/database/database_test.go`
- `TestApplyMigrations_CreatesSchemaAndTables` — verifies all migrations apply and all expected tables exist
- `TestApplyMigrations_Idempotent` — verifies running migrations twice produces no errors or duplicates
- `TestBootstrapMigrationsTable_FreshDatabase` — verifies bootstrap creates the table and records version 000
- `TestBootstrapMigrationsTable_ExistingTableBackwardsCompat` — verifies existing DBs (from old inline-SQL code) get version 000 back-filled without data loss

## Conflict note

[PR #33](#33) (for [issue #28](#28)) is also modifying migration code. This PR is based on current `main` and the conflict will be resolved at merge time.

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@sneak.berlin>
Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #36
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-25 02:20:52 +01:00
a50364bfca Enforce and document exact-match-only for signature verification (#40)
All checks were successful
check / check (push) Successful in 58s
Closes #27

Signatures are per-URL only — this PR adds explicit tests and documentation enforcing that HMAC-SHA256 signatures verify against exact URLs only. No suffix matching, wildcard matching, or partial matching is supported.

## What this does NOT touch

**The host whitelist code (`whitelist.go`) is not modified.** This PR is exclusively about signature verification, per sneak's instructions on [issue #27](#27), [PR #32](#32), and [PR #35](#35).

## Changes

### `internal/imgcache/signature.go`
- Added documentation comments on `Verify()` and `buildSignatureData()` explicitly specifying that signatures are exact-match only — no suffix, wildcard, or partial matching

### `internal/imgcache/signature_test.go`
- **`TestSigner_Verify_ExactMatchOnly`**: 14 tamper cases verifying that modifying any signed component (host, path, query, dimensions, format) causes verification to fail. Host-specific cases include:
  - Parent domain (`example.com`) does not match subdomain signature (`cdn.example.com`)
  - Sibling subdomain (`images.example.com`) does not match
  - Deeper subdomain (`images.cdn.example.com`) does not match
  - Evil suffix domain (`cdn.example.com.evil.com`) does not match
  - Prefixed host (`evilcdn.example.com`) does not match
- **`TestSigner_Sign_ExactHostInData`**: Verifies that suffix-related hosts (`cdn.example.com`, `example.com`, `images.example.com`, etc.) all produce distinct signatures

### `internal/imgcache/service_test.go`
- **`TestService_ValidateRequest_SignatureExactHostMatch`**: Integration test through `ValidateRequest` verifying that a valid signature for `cdn.example.com` is rejected when presented with a different host (parent domain, sibling subdomain, deeper subdomain, evil suffix, prefixed host)

### `README.md`
- Updated Signature Specification section to explicitly document exact-match-only semantics

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #40
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-20 23:56:45 +01:00
e85b5ff033 Consolidate appname to internal/globals as a constant (#34)
All checks were successful
check / check (push) Successful in 1m48s
Closes [issue #30](#30).

The appname `"pixad"` was redundantly defined in both `cmd/pixad/main.go` (as a package-level var) and `internal/globals/globals.go` (as a package-level var that got copied from main). Since the appname is always `"pixad"` and is not actually set via ldflags (only `Version` is), this PR:

- Defines `appname` once as an unexported constant in `internal/globals/globals.go`
- Removes the `Appname` var from `cmd/pixad/main.go`
- Removes the `globals.Appname = Appname` assignment from `main.run()`
- Keeps `Version` flow unchanged (still set via ldflags in main, passed to globals)

The `Globals.Appname` struct field remains available to all consumers — they just get it from the constant now instead of a package var that was always `"pixad"`.

All existing tests pass, `docker build .` succeeds.

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #34
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-20 07:04:54 +01:00
55a609dd77 Bound imageprocessor.Process input read to prevent unbounded memory use (#37)
All checks were successful
check / check (push) Successful in 4s
closes #31

## Problem

`ImageProcessor.Process` used `io.ReadAll(input)` without any size limit, allowing arbitrarily large inputs to exhaust all available memory. This is a DoS vector — even though the upstream fetcher has a `MaxResponseSize` limit (50 MiB), the processor interface accepts any `io.Reader` and should defend itself independently.

Additionally, the service layer's `processFromSourceOrFetch` read cached source content with `io.ReadAll` without a bound, so an unexpectedly large cached file could also cause unbounded memory consumption.

## Changes

### Processor (`processor.go`)
- Added `maxInputBytes` field to `ImageProcessor` (configurable, defaults to 50 MiB via `DefaultMaxInputBytes`)
- `NewImageProcessor` now accepts a `maxInputBytes` parameter (0 or negative uses the default)
- `Process` now wraps the input reader with `io.LimitReader` and rejects inputs exceeding the limit with `ErrInputDataTooLarge`
- Added `DefaultMaxInputBytes` and `ErrInputDataTooLarge` exported constants/errors

### Service (`service.go`)
- `NewService` now wires the fetcher's `MaxResponseSize` through to the processor
- Extracted `loadCachedSource` helper method to flatten nesting in `processFromSourceOrFetch`
- Cached source reads are now bounded by `maxResponseSize` — oversized cached files are discarded and re-fetched

### Tests (`processor_test.go`)
- `TestImageProcessor_RejectsOversizedInputData` — verifies that inputs exceeding `maxInputBytes` are rejected with `ErrInputDataTooLarge`
- `TestImageProcessor_AcceptsInputWithinLimit` — verifies that inputs within the limit are processed normally
- `TestImageProcessor_DefaultMaxInputBytes` — verifies that 0 and negative values use the default
- All existing tests updated to use `NewImageProcessor(0)` (default limit)

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #37
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-20 07:01:15 +01:00
9c29cb57df feat: parse version prefix from migration filenames (#33)
All checks were successful
check / check (push) Successful in 1m49s
Closes #28

Migration filenames now follow the pattern `<version>_<description>.sql` (e.g. `001_initial_schema.sql`). The version stored in `schema_migrations` is the numeric prefix only, not the full filename stem.

## Changes

- **`ParseMigrationVersion()`** — new exported function that extracts the numeric prefix from migration filenames. Validates that the prefix is purely numeric and rejects malformed filenames (empty prefix, non-numeric characters, leading underscore).
- **Renamed `001.sql` → `001_initial_schema.sql`** — migration files can now have descriptive names while the tracked version remains `001`. This is safe pre-1.0.0 (no installed base).
- **Deduplicated migration logic** — `runMigrations()` and `ApplyMigrations()` now share a single `applyMigrations()` implementation, plus extracted `collectMigrations()` and `ensureMigrationsTable()` helpers.
- **Unit tests** — `TestParseMigrationVersion` covers valid patterns (version-only, with description, multi-digit, multiple underscores) and error cases (empty, leading underscore, non-numeric, mixed alphanumeric). `TestApplyMigrations` and `TestApplyMigrationsIdempotent` verify end-to-end migration application against an in-memory SQLite database.

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #33
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-18 03:18:38 +01:00
2e934c8894 fix: QA audit fixes for 1.0/MVP readiness (#25)
All checks were successful
check / check (push) Successful in 5s
closes #24

## QA Audit Fixes

This PR addresses issues found during the 1.0/MVP QA audit.

### Changes

1. **TODO.md: Mark AVIF encoding as done** — AVIF encoding is fully implemented via govips in `processor.go` but was still listed as a TODO item.

2. **scripts/manual-test.sh: Fix form field names** — The manual test script was using wrong field names:
   - Login form: was sending `password=...`, should be `key=...` (matching the HTML form's `name="key"`)
   - Generator form: was sending `source_url`, `fit_mode` — should be `url`, `fit` (matching the handler's `r.FormValue()` calls)
   - This means **the manual test script never actually worked** — login always failed silently because the `key` field was empty.

### Full QA Audit Results

The comprehensive QA audit report has been posted as a comment on [issue #24](#24).

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #25
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-15 17:58:13 +01:00
2f15340f26 Split Dockerfile: pre-built golangci-lint stage for faster CI (#23)
All checks were successful
check / check (push) Successful in 5s
## Summary

Splits the Dockerfile into a dedicated lint stage using the pre-built `golangci/golangci-lint:v2.10.1-alpine` Docker image, replacing the manual binary download with curl/sha256 verification.

## Changes

- **Lint stage** (`AS lint`): Uses `golangci/golangci-lint:v2.10.1-alpine` pinned by sha256. Runs `make fmt-check` + `make lint`. Includes CGO deps (`build-base`, `vips-dev`, `libheif-dev`, `pkgconfig`) needed for type-checking govips imports.
- **Build stage** (`AS builder`): Depends on lint stage via `COPY --from=lint /src/go.sum /dev/null`. Runs `make test` + builds the binary. Removes `curl` (no longer needed) and the manual golangci-lint download block.
- **Runtime stage**: Unchanged.

## Benefits

- Eliminates slow multi-arch binary download + sha256 verification step
- Lint and build stages can potentially run in parallel with BuildKit
- Better Docker layer caching — lint deps cached separately from build deps
- All images remain pinned by sha256 with version+date comments

## Verification

- `docker build .` passes: fmt-check , lint (0 issues) , all tests pass , binary builds 

Closes [#18](#18)

<!-- session: agent:sdlc-manager:subagent:7aac9c54-81c8-4494-94ab-0843f97a1e62 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #23
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:09:51 +01:00
65 changed files with 6060 additions and 1116 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: docker build . - run: script/cibuild

View File

@@ -1,7 +1,29 @@
# 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
@@ -9,25 +31,7 @@ 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
# SHA-256 checksums per architecture (amd64 / arm64)
RUN set -e; \
ARCH="$(uname -m)"; \
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
GOARCH="arm64"; \
HASH="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8"; \
else \
GOARCH="amd64"; \
HASH="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99"; \
fi; \
curl -sSfL "https://github.com/golangci/golangci-lint/releases/download/v2.10.1/golangci-lint-2.10.1-linux-${GOARCH}.tar.gz" -o /tmp/golangci-lint.tar.gz && \
echo "${HASH} /tmp/golangci-lint.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/golangci-lint.tar.gz -C /tmp && \
mv "/tmp/golangci-lint-2.10.1-linux-${GOARCH}/golangci-lint" /usr/local/bin/ && \
rm -rf /tmp/golangci-lint*
WORKDIR /src WORKDIR /src
@@ -38,8 +42,8 @@ RUN GOTOOLCHAIN=auto go mod download
# Copy source code # Copy source code
COPY . . COPY . .
# Run all checks (fmt-check, lint, test) # Run tests
RUN make check RUN make test
# 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: check lint test fmt fmt-check build clean docker docker-test devserver devserver-stop hooks .PHONY: bootstrap setup check lint test fmt fmt-check build clean docker docker-versioned docker-test devserver devserver-stop hooks
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
@@ -15,27 +15,30 @@ else
endif endif
# Default target: run all checks # Default target: run all checks
check: fmt-check lint test check:
@script/check
bootstrap:
@script/bootstrap
setup:
@script/setup
# Check formatting without modifying files # Check formatting without modifying files
fmt-check: fmt-check:
@echo "Checking formatting..." @script/fmt-check
@test -z "$$(gofmt -l . | grep -v '^vendor/')" || (echo "Files need formatting:"; gofmt -l . | grep -v '^vendor/'; exit 1)
# Format code # Format code
fmt: fmt:
@echo "Formatting code..." @script/fmt
gofmt -w $$(find . -name '*.go' -not -path './vendor/*')
# Run linter # Run linter
lint: lint:
@echo "Running linter..." @script/lint
$(NIX_RUN_PREFIX)golangci-lint run$(NIX_RUN_SUFFIX)
# Run tests (30-second timeout) # Run tests (30-second timeout)
test: test:
@echo "Running tests..." @script/test
$(NIX_RUN_PREFIX)CGO_ENABLED=1 go test -timeout 30s -v ./...$(NIX_RUN_SUFFIX)
# Build the binary # Build the binary
build: build:
@@ -47,8 +50,12 @@ clean:
rm -rf bin/ rm -rf bin/
rm -rf ./data rm -rf ./data
# Build Docker image # Build Docker image (tagged via script/projectname)
docker: docker:
@script/docker
# Build Docker image tagged pixad:$(VERSION) and pixad:latest
docker-versioned:
docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest . docker build --build-arg VERSION=$(VERSION) -t pixad:$(VERSION) -t pixad:latest .
# Run tests in Docker (needed for CGO/libvips) # Run tests in Docker (needed for CGO/libvips)
@@ -57,7 +64,7 @@ docker-test:
docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..." docker run --rm pixad-builder sh -c "CGO_ENABLED=1 GOTOOLCHAIN=auto go test -v ./..."
# Run local dev server in Docker # Run local dev server in Docker
devserver: docker devserver-stop devserver: docker-versioned devserver-stop
docker run -d --name pixad-dev -p 8080:8080 \ docker run -d --name pixad-dev -p 8080:8080 \
-v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \ -v $(CURDIR)/config.dev.yml:/etc/pixa/config.yml:ro \
pixad:latest pixad:latest
@@ -70,6 +77,4 @@ devserver-stop:
# Install pre-commit hook # Install pre-commit hook
hooks: hooks:
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit @script/install-precommit
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit

View File

@@ -29,7 +29,7 @@ Image-heavy web applications need a fast, caching reverse proxy that
can resize and transcode images on the fly. pixa fills that role as a can resize and transcode images on the fly. pixa fills that role as a
single, self-contained binary with no external runtime dependencies single, self-contained binary with no external runtime dependencies
beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to beyond libvips. It supports HMAC-SHA256 signed URLs with expiration to
prevent abuse, and whitelisted source hosts for open access. prevent abuse, and allowlisted source hosts for open access.
## Design ## Design
@@ -61,13 +61,16 @@ Images are only fetched from origins using TLS with valid certificates.
### Source Hosts ### Source Hosts
Source hosts may be whitelisted in the configuration. Non-whitelisted Source hosts may be allowlisted in the configuration. Non-allowlisted
hosts require an HMAC-SHA256 signature. hosts require an HMAC-SHA256 signature.
#### Signature Specification #### Signature Specification
Signatures use HMAC-SHA256 and include an expiration timestamp to Signatures use HMAC-SHA256 and include an expiration timestamp to
prevent replay attacks. prevent replay attacks. Signatures are **exact match only**: every
component (host, path, query, dimensions, format, expiration) must
match exactly what was signed. No suffix matching, wildcard matching,
or partial matching is supported.
**Signed data format** (colon-separated): **Signed data format** (colon-separated):
@@ -96,7 +99,7 @@ expiration 1704067200:
4. URL: 4. URL:
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200` `/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
**Whitelist patterns:** **Allowlist patterns:**
- **Exact match**: `cdn.example.com` — matches only that host - **Exact match**: `cdn.example.com` — matches only that host
- **Suffix match**: `.example.com` — matches `cdn.example.com`, - **Suffix match**: `.example.com` — matches `cdn.example.com`,
@@ -107,11 +110,14 @@ expiration 1704067200:
Configured via YAML file (`--config`). Key settings: Configured via YAML file (`--config`). Key settings:
- `access_control_allow_origin` — CORS origin - `access_control_allow_origin` — CORS origin
- `source_host_whitelist` — list of allowed upstream hosts - `allowlist_hosts` — list of allowed upstream hosts
- `upstream_fetch_timeout` — timeout for origin requests - `upstream_fetch_timeout` — timeout for origin requests
- `upstream_max_response_size` — max origin response size - `upstream_max_response_size` — max origin response size
- `downstream_timeout` — client response timeout - `downstream_timeout` — client response timeout
- `signing_key` — HMAC secret for URL signatures - `signing_key` — HMAC secret for URL signatures
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
disk cache entirely; omitted defaults to 75% of the free space on
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
See `config.example.yml` for all options with defaults. See `config.example.yml` for all options with defaults.
@@ -125,6 +131,31 @@ See `config.example.yml` for all options with defaults.
- **Metrics**: Prometheus - **Metrics**: Prometheus
- **Logging**: stdlib slog - **Logging**: stdlib slog
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (idempotent)
- `script/setup` — make a fresh clone ready for development
(bootstrap, then install-precommit)
- `script/projectname` — output the project name ("pixa")
- `script/test` — run the test suite
- `script/lint` — run golangci-lint
- `script/fmt` — format all code (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
runs the checks, so a green build implies a green repo)
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
`script/check`)
- `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit`
## TODO ## TODO
See [TODO.md](TODO.md) for the full prioritized task list. See [TODO.md](TODO.md) for the full prioritized task list.

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-02-22 last_modified: 2026-07-06
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -34,10 +34,46 @@ style conventions are in separate documents:
every file before committing. There are zero exceptions to this rule. every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets: - Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only), `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make hooks` (installs pre-commit hook). A model Makefile is at `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single instead of invoking the underlying tools directly. The Makefile is the single
@@ -57,11 +93,83 @@ style conventions are in separate documents:
as a build step so the build fails if the branch is not green. For non-server as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build `make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`, runs `script/cibuild` (which runs `docker build .`) on push. Since the
a successful build implies all checks pass. Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -69,9 +177,11 @@ style conventions are in separate documents:
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`. HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise - Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
`make lint && make fmt-check`. The Makefile should provide a `make hooks` testing is not possible in the repo, `script/precommit` may skip `script/test`
target to install the pre-commit hook. and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard - All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
@@ -82,6 +192,42 @@ style conventions are in separate documents:
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile. Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes. - Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary - `make check` must not modify any files in the repo. Tests may use temporary
@@ -98,6 +244,13 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`. - Never force-push to `main`.
@@ -121,12 +274,76 @@ style conventions are in separate documents:
- Dockerized web services listen on port 8080 by default, overridable with - Dockerized web services listen on port 8080 by default, overridable with
`PORT`. `PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections: - `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose, - **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example: category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives "µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime." git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block. - **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist? - **Rationale**: Why does this exist?
- **Design**: How is the program structured? - **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put - **TODO**: Update meticulously, even between commits. When planning, put
@@ -144,8 +361,14 @@ style conventions are in separate documents:
- Use SemVer. - Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in - Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. Pre-1.0.0: modify existing migrations (no installed base assumed). the binary.
Post-1.0.0: add new migration files. - `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings. settings.
@@ -175,6 +398,9 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig` - `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile` - `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml` - `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`

172
TODO.md
View File

@@ -1,65 +1,125 @@
# Pixa 1.0 TODO # Workflow
Remaining tasks sorted by priority for a working 1.0 release. * branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
## P0: Critical for 1.0 # Status
### Image Processing pre-1.0. No git tags exist. Recent work extracted the internal/magic,
- [x] Add WebP encoding support (currently returns error) internal/allowlist, internal/httpfetcher, and internal/signature
- [ ] Add AVIF encoding support (currently returns error) packages. The gosec findings from the 2026-07-06 survey are resolved
and `make check` is green on main. The disk cache is now size-bounded
with LRU eviction (`cache_max_bytes`), closing the unbounded disk
growth DoS vector.
### Manual Testing (verify auth/encrypted URLs work) # Next Step
- [ ] Manual test: visit `/`, see login form
- [ ] Manual test: enter wrong key, see error
- [ ] Manual test: enter correct signing key, see generator form
- [ ] Manual test: generate encrypted URL, verify it works
- [ ] Manual test: wait for expiration or use short TTL, verify expired URL returns 410
- [ ] Manual test: logout, verify redirected to login
### Cache Management P1: implement blocked networks configuration to extend SSRF protection
- [ ] Implement cache size management/eviction (prevent disk from filling up)
### Configuration # Completed Steps
- [ ] Validate configuration on startup (fail fast on bad config)
## P1: Important for Production - 2026-08-07 implement cache size management and eviction (closes
#51): new `cache_max_bytes` config key validated by the startup
framework (explicit values used exactly with no floor, `0` disables
the disk cache entirely, omitted defaults to max(75% of free space
on the filesystem containing `<state_dir>/cache/`, 500 MiB), logged
at startup); processed variants are now tracked in the database
(migration 002 adds `variant_content` and an LRU timestamp on
`source_content`) so total usage is two SUMs, never a directory scan
on the hot path; a background goroutine evicts globally
least-recently-used entries (variants and source blobs merged) to
the limit, woken by a periodic ticker and by write-pressure
notifications from stores; a source blob and ALL of its
`source_metadata` references are deleted in one transaction before
the file is unlinked, so multi-referenced blobs are never removed
while referenced and rows never point at deleted files; a startup
reconciliation pass adopts untracked variant files, drops rows for
missing files, removes unreachable source blobs, and sweeps stale
temp files
- 2026-08-07 validate configuration on startup, fail fast on bad
config (closes #52): a config value that is set but unparseable or
invalid aborts startup naming the key and value (defaults apply only
to omitted keys), unknown config keys abort startup, a malformed
config file aborts instead of being skipped, and `state_dir` is
verified creatable and writable before the listener binds
- 2026-08-07 manual test pass of the auth and encrypted URL flows
against a locally built and running `pixad` (built from `main` at
`6573b9d`, port 18099, local throwaway config); all six checks
passed, plus all nine tests in `scripts/manual-test.sh` (closes #49):
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login`
page with `name="key"` password form
- [x] wrong key shows an error: POST `/` with `key=wrong-key`
returned HTTP 200 login page containing "Invalid signing key"
- [x] correct signing key shows the generator form: POST `/`
returned HTTP 303 to `/` with
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`;
GET `/` with that cookie rendered `Pixa - URL Generator` with the
`/generate` form and logout link
- [x] a generated encrypted URL serves the image: POST `/generate`
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned
HTTP 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of
61706 bytes
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched
after 3 s returned HTTP 410 Gone with
`{"error":"URL has expired","status":410,...}`
- [x] logout redirects back to login: GET `/logout` returned HTTP
303 to `/` with `Set-Cookie: pixa_session=; Max-Age=0`;
subsequent GET `/` rendered the login form again
- 2026-08-07 fix the two remaining gosec findings (G124 in
internal/session): session cookies now always carry
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
`make check` green (closes #47)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-04-07 extract magic byte detection into internal/magic (#42)
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
- 2026-03-20 enforce and document exact-match-only signature
verification (#40)
- 2026-03-20 bound imageprocessor.Process input read to prevent
unbounded memory use (#37); consolidate appname into an
internal/globals constant (#34)
- 2026-03-18 parse version prefix from migration filenames (#33)
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for
faster CI (#23)
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
golangci-lint and gosec fixes of that date (#14); arm64 Docker build
fix (#16)
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0
image processing items, now done)
### Security # Future Steps
- [ ] Implement blocked networks configuration (extend SSRF protection)
- [ ] Add rate limiting global concurrent fetches (prevent resource exhaustion)
### Image Processing - P1: rate limit global concurrent upstream fetches to prevent
- [ ] Implement EXIF/metadata stripping (privacy) resource exhaustion
- P1: strip EXIF and other metadata from processed images (privacy)
## P2: Nice to Have - P2: security
- referer blacklist
### Security - per-IP rate limiting
- [ ] Implement referer blacklist - per-origin rate limiting
- [ ] Add rate limiting per-IP - P2: HTTP response handling
- [ ] Add rate limiting per-origin - Last-Modified headers
- Vary header for content negotiation
### HTTP Response Handling - X-Request-ID propagation
- [ ] Implement Last-Modified headers - P2: auto format selection (format=auto based on Accept header)
- [ ] Implement Vary header for content negotiation - P2: configuration
- [ ] Implement X-Request-ID propagation - add all configuration options from README
- environment variable overrides
### Additional Endpoints - YAML config file support
- [ ] Implement auto-format selection (format=auto based on Accept header) - P2: operational
- optional Sentry error reporting
### Configuration - comprehensive request logging
- [ ] Add all configuration options from README - Prometheus performance metrics
- [ ] Implement environment variable overrides - integration tests for the image proxy flow
- [ ] Implement YAML config file support - load tests to verify the 1k to 5k req/s target
- P2: documentation
### Operational - configuration options
- [ ] Implement Sentry error reporting (optional) - API endpoints
- [ ] Add comprehensive request logging - deployment guide
- [ ] Add performance metrics (Prometheus) - example nginx or caddy reverse proxy config
- [ ] Write integration tests for image proxy flow
- [ ] Write load tests to verify 1-5k req/s target
### Documentation
- [ ] Document configuration options
- [ ] Document API endpoints
- [ ] Document deployment guide
- [ ] Add example nginx/caddy reverse proxy config

View File

@@ -17,10 +17,7 @@ import (
"sneak.berlin/go/pixa/internal/server" "sneak.berlin/go/pixa/internal/server"
) )
var ( var Version string //nolint:gochecknoglobals // set by ldflags
Appname = "pixad" //nolint:gochecknoglobals // set by ldflags
Version string //nolint:gochecknoglobals // set by ldflags
)
var configPath string //nolint:gochecknoglobals // cobra flag var configPath string //nolint:gochecknoglobals // cobra flag
@@ -40,7 +37,6 @@ func main() {
} }
func run(_ *cobra.Command, _ []string) { func run(_ *cobra.Command, _ []string) {
globals.Appname = Appname
globals.Version = Version globals.Version = Version
// Set config path in environment if specified via flag // Set config path in environment if specified via flag

View File

@@ -9,13 +9,13 @@ maintenance_mode: false
state_dir: ./data state_dir: ./data
# Image proxy settings # Image proxy settings
# HMAC signing key for URL signatures (leave empty to require whitelist for all requests) # HMAC signing key for URL signatures (required, at least 32 characters)
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32" signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
# Hosts that don't require signatures # Hosts that don't require signatures
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com") # Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
whitelist_hosts: allowlist_hosts:
- s3.sneak.cloud - s3.sneak.cloud
- static.sneak.cloud - static.sneak.cloud
- sneak.berlin - sneak.berlin
@@ -28,6 +28,13 @@ allow_http: false
# Maximum concurrent connections per upstream host (default: 20) # Maximum concurrent connections per upstream host (default: 20)
upstream_connections_per_host: 20 upstream_connections_per_host: 20
# Maximum disk cache size in bytes. Explicit values are used exactly as
# given; 0 disables the disk cache entirely (every request fetches and
# processes uncached). When omitted, the default is 75% of the free
# space on the filesystem containing <state_dir>/cache/ at startup,
# with a minimum of 500 MiB.
# cache_max_bytes: 10737418240
# Sentry error reporting (optional) # Sentry error reporting (optional)
sentry_dsn: "" sentry_dsn: ""

View File

@@ -1,25 +1,26 @@
package imgcache // Package allowlist provides host-based URL allow-listing for the image proxy.
package allowlist
import ( import (
"net/url" "net/url"
"strings" "strings"
) )
// HostWhitelist implements the Whitelist interface for checking allowed source hosts. // HostAllowList checks whether source hosts are permitted.
type HostWhitelist struct { type HostAllowList struct {
// exactHosts contains hosts that must match exactly (e.g., "cdn.example.com") // exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
exactHosts map[string]struct{} exactHosts map[string]struct{}
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com") // suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
suffixHosts []string suffixHosts []string
} }
// NewHostWhitelist creates a whitelist from a list of host patterns. // New creates a HostAllowList from a list of host patterns.
// Patterns starting with "." are treated as suffix matches. // Patterns starting with "." are treated as suffix matches.
// Examples: // Examples:
// - "cdn.example.com" - exact match only // - "cdn.example.com" - exact match only
// - ".example.com" - matches cdn.example.com, images.example.com, etc. // - ".example.com" - matches cdn.example.com, images.example.com, etc.
func NewHostWhitelist(patterns []string) *HostWhitelist { func New(patterns []string) *HostAllowList {
w := &HostWhitelist{ w := &HostAllowList{
exactHosts: make(map[string]struct{}), exactHosts: make(map[string]struct{}),
suffixHosts: make([]string, 0), suffixHosts: make([]string, 0),
} }
@@ -40,8 +41,8 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
return w return w
} }
// IsWhitelisted checks if a URL's host is in the whitelist. // IsAllowed checks if a URL's host is in the allow list.
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool { func (w *HostAllowList) IsAllowed(u *url.URL) bool {
if u == nil { if u == nil {
return false return false
} }
@@ -71,12 +72,12 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
return false return false
} }
// IsEmpty returns true if the whitelist has no entries. // IsEmpty returns true if the allow list has no entries.
func (w *HostWhitelist) IsEmpty() bool { func (w *HostAllowList) IsEmpty() bool {
return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0 return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
} }
// Count returns the total number of whitelist entries. // Count returns the total number of allow list entries.
func (w *HostWhitelist) Count() int { func (w *HostAllowList) Count() int {
return len(w.exactHosts) + len(w.suffixHosts) return len(w.exactHosts) + len(w.suffixHosts)
} }

View File

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

View File

@@ -0,0 +1,271 @@
package config
import (
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
// discardLogger returns a logger that swallows all output, for tests
// that exercise code paths which log.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// TestCacheMaxBytesExplicitValueUsedWithoutFloor verifies that an
// explicitly configured cache_max_bytes value is used exactly as
// given: the 500 MiB floor applies only to the computed default, never
// to explicit values.
func TestCacheMaxBytesExplicitValueUsedWithoutFloor(t *testing.T) {
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
}
if c.CacheMaxBytes != 1024 {
t.Errorf("CacheMaxBytes = %d, want 1024 (no floor for explicit values)",
c.CacheMaxBytes)
}
}
// TestCacheMaxBytesZeroIsValidAndDisablesCache verifies that an
// explicit zero is a valid value (it disables the disk cache), not an
// error.
func TestCacheMaxBytesZeroIsValidAndDisablesCache(t *testing.T) {
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 0\n"
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
}
if c.CacheMaxBytes != 0 {
t.Errorf("CacheMaxBytes = %d, want 0", c.CacheMaxBytes)
}
}
// TestCacheMaxBytesLargeExplicitValueParses verifies that values above
// 32-bit range parse correctly (the field is an int64 byte count).
func TestCacheMaxBytesLargeExplicitValueParses(t *testing.T) {
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 10737418240\n"
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("large cache_max_bytes must be accepted, got error: %v", err)
}
if c.CacheMaxBytes != 10737418240 {
t.Errorf("CacheMaxBytes = %d, want 10737418240", c.CacheMaxBytes)
}
}
// TestCacheMaxBytesInvalidValuesAbortStartup verifies that a SET but
// invalid cache_max_bytes value aborts startup naming the key and the
// offending value, per the no-silent-fallback rule: defaults apply
// only to omitted keys.
func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
cases := []struct {
name string
yaml string
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string
}{
{
name: "negative",
yaml: signingKeyLine + "cache_max_bytes: -1024\n",
wantErrSubstrings: []string{"cache_max_bytes", "-1024"},
},
{
name: "float",
yaml: signingKeyLine + "cache_max_bytes: 3.5\n",
wantErrSubstrings: []string{"cache_max_bytes", "3.5"},
},
{
name: "non-numeric string",
yaml: signingKeyLine + "cache_max_bytes: banana\n",
wantErrSubstrings: []string{"cache_max_bytes", "banana"},
},
{
name: "explicit null",
yaml: signingKeyLine + "cache_max_bytes: null\n",
wantErrSubstrings: []string{"cache_max_bytes", "null"},
},
{
name: "bare key no value",
yaml: signingKeyLine + "cache_max_bytes:\n",
wantErrSubstrings: []string{"cache_max_bytes", "null"},
},
{
name: "boolean",
yaml: signingKeyLine + "cache_max_bytes: true\n",
wantErrSubstrings: []string{"cache_max_bytes", "true"},
},
{
name: "list",
yaml: signingKeyLine + "cache_max_bytes:\n - 1\n",
wantErrSubstrings: []string{"cache_max_bytes"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s cache_max_bytes must abort startup, got config: %+v",
tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
}
}
// TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace verifies the
// computed default is 75% of the probed free space when that exceeds
// the floor.
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
// 4 GiB free -> 3 GiB default.
probe := func(string) (uint64, error) { return 4294967296, nil }
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
if err != nil {
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
}
if got != 3221225472 {
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want 3221225472 (75%% of 4 GiB)", got)
}
}
// TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault
// verifies that when 75% of free space is below 500 MiB, the computed
// default is floored at DefaultCacheMaxBytesFloor.
func TestComputeDefaultCacheMaxBytesAppliesFloorToComputedDefault(t *testing.T) {
cases := []struct {
name string
freeBytes uint64
}{
{name: "100 MiB free", freeBytes: 104857600},
{name: "zero free", freeBytes: 0},
{name: "just below floor threshold", freeBytes: 699050665},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
probe := func(string) (uint64, error) { return tc.freeBytes, nil }
got, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
if err != nil {
t.Fatalf("ComputeDefaultCacheMaxBytes returned error: %v", err)
}
if got != DefaultCacheMaxBytesFloor {
t.Errorf("ComputeDefaultCacheMaxBytes = %d, want floor %d",
got, DefaultCacheMaxBytesFloor)
}
})
}
}
// TestComputeDefaultCacheMaxBytesPropagatesProbeError verifies that a
// failing free-space probe produces an error naming the config key,
// instead of a silently wrong default.
func TestComputeDefaultCacheMaxBytesPropagatesProbeError(t *testing.T) {
probe := func(string) (uint64, error) { return 0, errors.New("statfs failed") }
_, err := ComputeDefaultCacheMaxBytes(t.TempDir(), probe)
if err == nil {
t.Fatal("probe failure must produce an error, got nil")
}
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "cache_max_bytes") {
t.Errorf("error %q does not name the config key cache_max_bytes", err.Error())
}
}
// TestResolveCacheMaxBytesComputesDefaultWhenOmitted verifies that an
// omitted cache_max_bytes key resolves to the computed default, that
// the probe is pointed at <state_dir>/cache/ (which must be created
// first so statfs measures the right filesystem), and that the result
// lands on the Config.
func TestResolveCacheMaxBytesComputesDefaultWhenOmitted(t *testing.T) {
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
c.StateDir = t.TempDir()
wantCacheDir := filepath.Join(c.StateDir, "cache")
var probedPath string
// 4 GiB free -> 3 GiB default.
probe := func(path string) (uint64, error) {
probedPath = path
return 4294967296, nil
}
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
}
if c.CacheMaxBytes != 3221225472 {
t.Errorf("CacheMaxBytes = %d, want computed default 3221225472", c.CacheMaxBytes)
}
if probedPath != wantCacheDir {
t.Errorf("free space probed at %q, want cache directory %q", probedPath, wantCacheDir)
}
info, err := os.Stat(wantCacheDir)
if err != nil || !info.IsDir() {
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
wantCacheDir, info, err)
}
}
// TestResolveCacheMaxBytesDoesNotOverrideExplicitValue verifies that
// an explicitly configured value survives resolution untouched and
// that the free-space probe is never consulted for it.
func TestResolveCacheMaxBytesDoesNotOverrideExplicitValue(t *testing.T) {
yamlContent := "signing_key: " + validTestSigningKey + "\ncache_max_bytes: 1024\n"
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("explicit cache_max_bytes must be accepted, got error: %v", err)
}
c.StateDir = t.TempDir()
probe := func(string) (uint64, error) {
t.Error("free-space probe must not be consulted for explicit values")
return 0, errors.New("probe must not be called")
}
if err := c.resolveCacheMaxBytes(discardLogger(), probe); err != nil {
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
}
if c.CacheMaxBytes != 1024 {
t.Errorf("CacheMaxBytes = %d, want explicit 1024 (no floor, no recompute)",
c.CacheMaxBytes)
}
}

View File

@@ -0,0 +1,110 @@
package config
import (
"fmt"
"log/slog"
"math"
"os"
"path/filepath"
"syscall"
)
// DefaultCacheMaxBytesFloor is the minimum computed default for the
// cache_max_bytes setting: 500 MiB. The floor applies only to the
// computed default (when the key is omitted from the configuration),
// never to explicitly configured values.
const DefaultCacheMaxBytesFloor int64 = 524288000
// cacheDirPerms is the permission mode for the cache directory created
// before probing free space, matching the state directory permissions.
const cacheDirPerms = 0o750
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
// the 75% share of free space used for the computed default limit as
// integer arithmetic (dividing before multiplying avoids overflow).
const (
freeSpaceFractionNumerator uint64 = 3
freeSpaceFractionDenominator uint64 = 4
)
// FreeSpaceProbeFunc reports the number of free bytes available on the
// filesystem containing path. It is a function type so tests can
// inject a fake probe instead of depending on the host disk.
type FreeSpaceProbeFunc func(path string) (uint64, error)
// defaultFreeSpaceProbe reports free filesystem bytes via statfs on
// the given path, as available to unprivileged processes.
func defaultFreeSpaceProbe(path string) (uint64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, err
}
if stat.Bsize < 0 {
return 0, fmt.Errorf("statfs reported negative block size %d for %q", stat.Bsize, path)
}
blockSize := uint64(stat.Bsize) //nolint:gosec // G115: negative Bsize rejected above
return stat.Bavail * blockSize, nil
}
// ComputeDefaultCacheMaxBytes returns the default cache size limit for
// the filesystem containing cacheDir: 75% of the free bytes reported
// by probe, with a floor of DefaultCacheMaxBytesFloor.
func ComputeDefaultCacheMaxBytes(cacheDir string, probe FreeSpaceProbeFunc) (int64, error) {
freeBytes, err := probe(cacheDir)
if err != nil {
return 0, fmt.Errorf("config key %q: cannot determine free space for %q: %w",
"cache_max_bytes", cacheDir, err)
}
computed := freeBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
if computed > math.MaxInt64 {
computed = math.MaxInt64
}
limit := int64(computed) //nolint:gosec // G115: clamped to MaxInt64 above
if limit < DefaultCacheMaxBytesFloor {
limit = DefaultCacheMaxBytesFloor
}
return limit, nil
}
// resolveCacheMaxBytes finalizes CacheMaxBytes after state_dir
// validation: an explicitly configured value is kept as-is (no floor
// applies), while an omitted key receives the computed default based
// on free space in <state_dir>/cache/. The cache directory is created
// first so statfs measures the filesystem that will actually hold the
// cache. The effective limit is logged either way.
func (c *Config) resolveCacheMaxBytes(log *slog.Logger, probe FreeSpaceProbeFunc) error {
if !c.cacheMaxBytesExplicit {
cacheDir := filepath.Join(c.StateDir, "cache")
if err := os.MkdirAll(cacheDir, cacheDirPerms); err != nil {
return fmt.Errorf("config key %q: cannot create cache directory %q: %w",
"cache_max_bytes", cacheDir, err)
}
limit, err := ComputeDefaultCacheMaxBytes(cacheDir, probe)
if err != nil {
return err
}
c.CacheMaxBytes = limit
log.Info("computed default cache size limit from free space",
"cache_max_bytes", limit,
"cache_dir", cacheDir,
)
}
log.Info("effective cache size limit",
"cache_max_bytes", c.CacheMaxBytes,
"cache_disabled", c.CacheMaxBytes == 0,
)
return nil
}

View File

@@ -4,8 +4,12 @@ package config
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv"
"strings" "strings"
"git.eeqj.de/sneak/smartconfig" "git.eeqj.de/sneak/smartconfig"
@@ -41,9 +45,22 @@ type Config struct {
// Image proxy settings // Image proxy settings
SigningKey string // HMAC signing key for URL signatures SigningKey string // HMAC signing key for URL signatures
WhitelistHosts []string // Hosts that don't require signatures AllowlistHosts []string // Hosts that don't require signatures
AllowHTTP bool // Allow non-TLS upstream (testing only) AllowHTTP bool // Allow non-TLS upstream (testing only)
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
// CacheMaxBytes is the disk cache size limit in bytes. Zero
// disables the disk cache entirely. When cache_max_bytes is
// omitted from the configuration, this holds the computed default
// (75% of free space on the filesystem containing
// <state_dir>/cache/, floored at DefaultCacheMaxBytesFloor).
CacheMaxBytes int64
// cacheMaxBytesExplicit records whether cache_max_bytes was
// explicitly set in the configuration file. Explicit values are
// used exactly as given; only an omitted key gets the computed
// default (and its floor) in resolveCacheMaxBytes.
cacheMaxBytesExplicit bool
} }
// New creates a new Config instance by loading configuration from file. // New creates a new Config instance by loading configuration from file.
@@ -60,31 +77,89 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
log.Info("no config file found, using defaults") log.Info("no config file found, using defaults")
} }
c := &Config{ c, err := newFromSmartConfig(sc)
Debug: getBool(sc, "debug", false), if err != nil {
MaintenanceMode: getBool(sc, "maintenance_mode", false), return nil, err
Port: getInt(sc, "port", DefaultPort),
StateDir: getString(sc, "state_dir", DefaultStateDir),
SentryDSN: getString(sc, "sentry_dsn", ""),
MetricsUsername: getString(sc, "metrics.username", ""),
MetricsPassword: getString(sc, "metrics.password", ""),
SigningKey: getString(sc, "signing_key", ""),
WhitelistHosts: getStringSlice(sc, "whitelist_hosts"),
AllowHTTP: getBool(sc, "allow_http", false),
UpstreamConnectionsPerHost: getInt(sc, "upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
} }
// Build DBURL from StateDir if not explicitly set if err := c.ensureStateDirWritable(); err != nil {
c.DBURL = getString(sc, "db_url", "") return nil, err
if c.DBURL == "" { }
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
if err := c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe); err != nil {
return nil, err
} }
if c.Debug { if c.Debug {
params.Logger.EnableDebugLogging() params.Logger.EnableDebugLogging()
} }
// Validate required configuration return c, nil
}
// newFromSmartConfig constructs a Config from a loaded smartconfig
// instance and validates it. A nil sc means no config file was found,
// in which case every option takes its default value. A key that is
// present but unparseable or invalid is an error: defaults apply only
// to omitted keys, never to invalid explicit values.
func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
if sc != nil {
if err := validateKnownKeys(sc); err != nil {
return nil, err
}
if err := validateAllowlistHostsValue(sc); err != nil {
return nil, err
}
}
loader := &strictLoader{sc: sc}
c := &Config{
Debug: loader.boolVal("debug", false),
MaintenanceMode: loader.boolVal("maintenance_mode", false),
Port: loader.intVal("port", DefaultPort),
StateDir: loader.stringVal("state_dir", DefaultStateDir),
SentryDSN: loader.stringVal("sentry_dsn", ""),
MetricsUsername: loader.stringVal("metrics.username", ""),
MetricsPassword: loader.stringVal("metrics.password", ""),
SigningKey: loader.stringVal("signing_key", ""),
AllowlistHosts: getStringSlice(sc, "allowlist_hosts"),
AllowHTTP: loader.boolVal("allow_http", false),
UpstreamConnectionsPerHost: loader.intVal(
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
CacheMaxBytes: loader.int64Val("cache_max_bytes", 0),
}
// The computed default for cache_max_bytes needs a validated
// state_dir, so it is resolved later (resolveCacheMaxBytes); here
// we only record whether the operator set the key explicitly.
if sc != nil {
if _, present := sc.Get("cache_max_bytes"); present {
c.cacheMaxBytesExplicit = true
}
}
// Build DBURL from StateDir if not explicitly set. The derived URL
// is a default: it applies only when db_url is omitted, never to an
// explicitly empty value.
c.DBURL = loader.stringVal("db_url", "")
if c.DBURL == "" && loader.err == nil {
if sc != nil {
if _, present := sc.Get("db_url"); present {
return nil, fmt.Errorf(
"config key %q: value must not be empty; omit the key to derive it from state_dir",
"db_url")
}
}
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
}
if loader.err != nil {
return nil, loader.err
}
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return nil, err return nil, err
} }
@@ -92,16 +167,202 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
return c, nil return c, nil
} }
// validate checks that all required configuration values are set. // validateKnownKeys rejects configuration files containing keys the
// application does not understand, so typos fail at startup instead of
// being silently ignored, and rejects keys that are explicitly set to
// null: a null is a SET value, never an omission, so it must not
// silently take the default. The env section is permitted because
// smartconfig consumes it for environment variable injection.
func validateKnownKeys(sc *smartconfig.Config) error {
var unknown, nullKeys []string
for key, value := range sc.Data() {
if !isKnownConfigKey(key) {
unknown = append(unknown, key)
continue
}
if value == nil {
nullKeys = append(nullKeys, key)
continue
}
if key == "metrics" {
metricsMap, ok := value.(map[string]interface{})
if !ok {
return fmt.Errorf(
"config key %q: value %v is not a map of metrics settings",
"metrics", value)
}
for subkey, subvalue := range metricsMap {
if subkey != "username" && subkey != "password" {
unknown = append(unknown, "metrics."+subkey)
continue
}
if subvalue == nil {
nullKeys = append(nullKeys, "metrics."+subkey)
}
}
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
}
if len(nullKeys) > 0 {
sort.Strings(nullKeys)
if len(nullKeys) == 1 {
return errNullConfigValue(nullKeys[0])
}
return fmt.Errorf(
"config keys %s: value is null; omit a key entirely to use its default",
strings.Join(nullKeys, ", "))
}
return nil
}
// errNullConfigValue reports a config key that is explicitly set to
// null (including the bare "key:" form and the "~" alias). Silently
// applying the default would mask a truncated or typo'd config entry.
func errNullConfigValue(key string) error {
return fmt.Errorf(
"config key %q: value is null; omit the key entirely to use the default", key)
}
// isKnownConfigKey reports whether key is a permitted top-level
// configuration key.
func isKnownConfigKey(key string) bool {
switch key {
case "debug", "maintenance_mode", "port", "state_dir", "sentry_dsn",
"db_url", "metrics", "signing_key", "allowlist_hosts", "allow_http",
"upstream_connections_per_host", "cache_max_bytes", "env":
return true
}
return false
}
// ensureStateDirWritable verifies at startup that StateDir can be
// created and written to, so a misconfigured path aborts startup
// instead of failing later at first use.
func (c *Config) ensureStateDirWritable() error {
const stateDirPerms = 0o750
if err := os.MkdirAll(c.StateDir, stateDirPerms); err != nil {
return fmt.Errorf("config key %q: cannot create directory %q: %w",
"state_dir", c.StateDir, err)
}
probe, err := os.CreateTemp(c.StateDir, ".startup-write-probe-*")
if err != nil {
return fmt.Errorf("config key %q: directory %q is not writable: %w",
"state_dir", c.StateDir, err)
}
probePath := probe.Name()
if err := probe.Close(); err != nil {
return fmt.Errorf("config key %q: cannot close probe file %q: %w",
"state_dir", probePath, err)
}
//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir
if err := os.Remove(probePath); err != nil {
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
"state_dir", probePath, err)
}
return nil
}
// validate checks that all required configuration values are set and
// that every value is within its valid range.
func (c *Config) validate() error { func (c *Config) validate() error {
// The signing key value is never echoed in error messages.
if c.SigningKey == "" { if c.SigningKey == "" {
return fmt.Errorf("signing_key is required") return fmt.Errorf("config key %q: a value is required", "signing_key")
} }
// Minimum key length for security (32 bytes = 256 bits) // Minimum key length for security (32 bytes = 256 bits)
const minKeyLength = 32 const minKeyLength = 32
if len(c.SigningKey) < minKeyLength { if len(c.SigningKey) < minKeyLength {
return fmt.Errorf("signing_key must be at least %d characters", minKeyLength) return fmt.Errorf("config key %q: value must be at least %d characters, got %d",
"signing_key", minKeyLength, len(c.SigningKey))
}
const maxPort = 65535
if c.Port < 1 || c.Port > maxPort {
return fmt.Errorf("config key %q: value %d is outside the valid port range 1-%d",
"port", c.Port, maxPort)
}
if c.UpstreamConnectionsPerHost < 1 {
return fmt.Errorf("config key %q: value %d must be at least 1",
"upstream_connections_per_host", c.UpstreamConnectionsPerHost)
}
if c.StateDir == "" {
return fmt.Errorf("config key %q: value must not be empty", "state_dir")
}
// Zero is valid (it disables the disk cache); only negative
// values are rejected. No floor applies to explicit values.
if c.CacheMaxBytes < 0 {
return fmt.Errorf("config key %q: value %d must not be negative",
"cache_max_bytes", c.CacheMaxBytes)
}
for _, host := range c.AllowlistHosts {
if err := validateAllowlistHost(host); err != nil {
return err
}
}
if c.SentryDSN != "" {
parsed, err := url.Parse(c.SentryDSN)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("config key %q: value %q is not a valid URL",
"sentry_dsn", c.SentryDSN)
}
}
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
return fmt.Errorf("config keys %q and %q must be set together",
"metrics.username", "metrics.password")
}
return nil
}
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
// hostname, optionally with a leading dot for suffix matching. URLs,
// paths, and whitespace indicate a misconfigured entry. An entry with
// no hostname labels (such as ".") is rejected: the allowlist matcher
// treats a leading dot as a suffix pattern, so a bare "." would match
// any upstream host written in FQDN trailing-dot form and effectively
// disable URL signing.
func validateAllowlistHost(host string) error {
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
return fmt.Errorf(
"config key %q: entry %q must be a bare hostname without scheme, path, or whitespace",
"allowlist_hosts", host)
}
if strings.Trim(host, ".") == "" {
return fmt.Errorf(
"config key %q: entry %q contains no hostname labels",
"allowlist_hosts", host)
} }
return nil return nil
@@ -135,11 +396,11 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
cleanPath := filepath.Clean(path) cleanPath := filepath.Clean(path)
//nolint:gosec // G703: paths are hardcoded config locations //nolint:gosec // G703: paths are hardcoded config locations
if _, statErr := os.Stat(cleanPath); statErr == nil { if _, statErr := os.Stat(cleanPath); statErr == nil {
// A config file that exists but does not parse is a fatal
// startup error, never something to skip over.
sc, err := smartconfig.NewFromConfigPath(path) sc, err := smartconfig.NewFromConfigPath(path)
if err != nil { if err != nil {
log.Warn("failed to parse config file", "path", path, "error", err) return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
continue
} }
log.Info("loaded config file", "path", path) log.Info("loaded config file", "path", path)
@@ -151,45 +412,269 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
return nil, nil //nolint:nilnil // nil config is valid (use defaults) return nil, nil //nolint:nilnil // nil config is valid (use defaults)
} }
func getString(sc *smartconfig.Config, key, defaultVal string) string { // strictLoader accumulates the first error encountered while reading
if sc == nil { // typed values out of a smartconfig instance, so Config construction
return defaultVal // can stay a single struct literal.
type strictLoader struct {
sc *smartconfig.Config
err error
} }
val, err := sc.GetString(key) func (l *strictLoader) stringVal(key, defaultVal string) string {
if l.err != nil {
return ""
}
val, err := getString(l.sc, key, defaultVal)
if err != nil { if err != nil {
return defaultVal l.err = err
} }
return val return val
} }
func getInt(sc *smartconfig.Config, key string, defaultVal int) int { func (l *strictLoader) intVal(key string, defaultVal int) int {
if sc == nil { if l.err != nil {
return defaultVal return 0
} }
val, err := sc.GetInt(key) val, err := getInt(l.sc, key, defaultVal)
if err != nil { if err != nil {
return defaultVal l.err = err
} }
return val return val
} }
func getBool(sc *smartconfig.Config, key string, defaultVal bool) bool { func (l *strictLoader) int64Val(key string, defaultVal int64) int64 {
if sc == nil { if l.err != nil {
return defaultVal return 0
} }
val, err := sc.GetBool(key) val, err := getInt64(l.sc, key, defaultVal)
if err != nil { if err != nil {
return defaultVal l.err = err
} }
return val return val
} }
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
if l.err != nil {
return false
}
val, err := getBool(l.sc, key, defaultVal)
if err != nil {
l.err = err
}
return val
}
// getString returns the string value for key, or defaultVal if the key
// is omitted. A present value that is not a string, or is explicitly
// null, is an error.
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
if sc == nil {
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return "", errNullConfigValue(key)
}
str, ok := raw.(string)
if !ok {
return "", fmt.Errorf("config key %q: value %v (%T) is not a string",
key, raw, raw)
}
return str, nil
}
// getInt returns the integer value for key, or defaultVal if the key is
// omitted. A present value that is not a whole number, or is explicitly
// null, is an error; fractional values are never truncated.
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
if sc == nil {
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return 0, errNullConfigValue(key)
}
switch val := raw.(type) {
case int:
return val, nil
case int64:
return int(val), nil
case float64:
if val != math.Trunc(val) {
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
}
return int(val), nil
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(val))
if err != nil {
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
}
return parsed, nil
default:
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
key, raw, raw)
}
}
// getInt64 returns the 64-bit integer value for key, or defaultVal if
// the key is omitted. A present value that is not a whole number, or
// is explicitly null, is an error; fractional values are never
// truncated and out-of-range values are never clamped.
func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, error) {
if sc == nil {
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return 0, errNullConfigValue(key)
}
switch val := raw.(type) {
case int:
return int64(val), nil
case int64:
return val, nil
case uint64:
if val > math.MaxInt64 {
return 0, fmt.Errorf("config key %q: value %d overflows a 64-bit integer",
key, val)
}
return int64(val), nil //nolint:gosec // G115: bounds checked above
case float64:
if val != math.Trunc(val) {
return 0, fmt.Errorf("config key %q: value %v is not an integer", key, val)
}
return int64(val), nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
if err != nil {
return 0, fmt.Errorf("config key %q: value %q is not an integer", key, val)
}
return parsed, nil
default:
return 0, fmt.Errorf("config key %q: value %v (%T) is not an integer",
key, raw, raw)
}
}
// getBool returns the boolean value for key, or defaultVal if the key
// is omitted. A present value that is not a boolean (or a ParseBool-able
// string), or is explicitly null, is an error; numbers are not accepted
// as booleans.
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
if sc == nil {
return defaultVal, nil
}
raw, ok := sc.Get(key)
if !ok {
return defaultVal, nil
}
if raw == nil {
return false, errNullConfigValue(key)
}
switch val := raw.(type) {
case bool:
return val, nil
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(val))
if err != nil {
return false, fmt.Errorf("config key %q: value %q is not a boolean", key, val)
}
return parsed, nil
default:
return false, fmt.Errorf("config key %q: value %v (%T) is not a boolean",
key, raw, raw)
}
}
// validateAllowlistHostsValue checks the raw shape of the
// allowlist_hosts value before the lenient extraction in getStringSlice
// runs: an explicitly null value, a value that is not a list of strings
// (or a comma-separated string), a non-string entry, or an empty entry
// is an error, never silently skipped.
func validateAllowlistHostsValue(sc *smartconfig.Config) error {
const key = "allowlist_hosts"
raw, ok := sc.Get(key)
if !ok {
return nil
}
if raw == nil {
return errNullConfigValue(key)
}
switch val := raw.(type) {
case []interface{}:
for _, item := range val {
str, ok := item.(string)
if !ok {
return fmt.Errorf(
"config key %q: list entry %v (%T) is not a string", key, item, item)
}
if strings.TrimSpace(str) == "" {
return fmt.Errorf("config key %q: list contains an empty entry", key)
}
}
case string:
if strings.TrimSpace(val) == "" {
return nil
}
for _, part := range strings.Split(val, ",") {
if strings.TrimSpace(part) == "" {
return fmt.Errorf(
"config key %q: value %q contains an empty entry", key, val)
}
}
default:
return fmt.Errorf("config key %q: value %v (%T) is not a list of strings",
key, raw, raw)
}
return nil
}
// getStringSlice returns the list of strings for key, or nil if the key
// is omitted. It accepts a YAML list of strings or a comma-separated
// string (backwards compatibility). Malformed entries are rejected
// beforehand by validateAllowlistHostsValue.
func getStringSlice(sc *smartconfig.Config, key string) []string { func getStringSlice(sc *smartconfig.Config, key string) []string {
if sc == nil { if sc == nil {
return nil return nil

View File

@@ -14,7 +14,7 @@ func TestGetStringSlice_YAMLList(t *testing.T) {
configPath := filepath.Join(tmpDir, "config.yml") configPath := filepath.Join(tmpDir, "config.yml")
yamlContent := ` yamlContent := `
whitelist_hosts: allowlist_hosts:
- static.sneak.cloud - static.sneak.cloud
- sneak.berlin - sneak.berlin
- s3.sneak.cloud - s3.sneak.cloud
@@ -31,7 +31,7 @@ whitelist_hosts:
} }
// Test that getStringSlice correctly parses YAML list // Test that getStringSlice correctly parses YAML list
hosts := getStringSlice(sc, "whitelist_hosts") hosts := getStringSlice(sc, "allowlist_hosts")
if len(hosts) != 3 { if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts) t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
@@ -54,7 +54,7 @@ func TestGetStringSlice_CommaSeparated(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml") configPath := filepath.Join(tmpDir, "config.yml")
yamlContent := `whitelist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"` yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
err := os.WriteFile(configPath, []byte(yamlContent), 0644) err := os.WriteFile(configPath, []byte(yamlContent), 0644)
if err != nil { if err != nil {
@@ -66,7 +66,7 @@ func TestGetStringSlice_CommaSeparated(t *testing.T) {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }
hosts := getStringSlice(sc, "whitelist_hosts") hosts := getStringSlice(sc, "allowlist_hosts")
if len(hosts) != 3 { if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts) t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
@@ -100,7 +100,7 @@ func TestGetStringSlice_Empty(t *testing.T) {
t.Fatalf("failed to load config: %v", err) t.Fatalf("failed to load config: %v", err)
} }
hosts := getStringSlice(sc, "whitelist_hosts") hosts := getStringSlice(sc, "allowlist_hosts")
if hosts != nil && len(hosts) != 0 { if hosts != nil && len(hosts) != 0 {
t.Errorf("expected nil or empty slice, got %v", hosts) t.Errorf("expected nil or empty slice, got %v", hosts)

View File

@@ -0,0 +1,547 @@
package config
import (
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.eeqj.de/sneak/smartconfig"
)
// validTestSigningKey is a 32-character signing key that satisfies the
// minimum length requirement in validate().
const validTestSigningKey = "0123456789abcdef0123456789abcdef"
// configFromYAML writes yamlContent to a temporary config file, loads it
// via smartconfig, and constructs a Config from it using the same code
// path the server uses at startup.
func configFromYAML(t *testing.T, yamlContent string) (*Config, error) {
t.Helper()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
if err := os.WriteFile(configPath, []byte(yamlContent), 0o600); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
sc, err := smartconfig.NewFromConfigPath(configPath)
if err != nil {
t.Fatalf("failed to load test config: %v", err)
}
return newFromSmartConfig(sc)
}
func TestOmittedValuesUseDefaults(t *testing.T) {
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if c.Port != DefaultPort {
t.Errorf("Port = %d, want default %d", c.Port, DefaultPort)
}
if c.StateDir != DefaultStateDir {
t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir)
}
if c.UpstreamConnectionsPerHost != DefaultUpstreamConnectionsPerHost {
t.Errorf("UpstreamConnectionsPerHost = %d, want default %d",
c.UpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost)
}
if c.Debug {
t.Error("Debug = true, want default false")
}
if c.MaintenanceMode {
t.Error("MaintenanceMode = true, want default false")
}
if c.AllowHTTP {
t.Error("AllowHTTP = true, want default false")
}
if len(c.AllowlistHosts) != 0 {
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
}
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
if c.DBURL != wantDBURL {
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
}
}
func TestExplicitValidValuesAreUsed(t *testing.T) {
yamlContent := `
port: 9090
debug: true
maintenance_mode: true
state_dir: /tmp/pixa-test-state
db_url: "file:/tmp/pixa-test-state/other.sqlite3"
signing_key: ` + validTestSigningKey + `
allowlist_hosts:
- s3.sneak.cloud
- .example.com
allow_http: true
upstream_connections_per_host: 5
sentry_dsn: "https://abc123@sentry.example.com/42"
metrics:
username: metricsuser
password: metricspass
`
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("valid config should load, got error: %v", err)
}
if c.Port != 9090 {
t.Errorf("Port = %d, want 9090", c.Port)
}
if !c.Debug || !c.MaintenanceMode || !c.AllowHTTP {
t.Errorf("bool fields = debug %v maintenance %v allow_http %v, want all true",
c.Debug, c.MaintenanceMode, c.AllowHTTP)
}
if c.StateDir != "/tmp/pixa-test-state" {
t.Errorf("StateDir = %q, want /tmp/pixa-test-state", c.StateDir)
}
if c.DBURL != "file:/tmp/pixa-test-state/other.sqlite3" {
t.Errorf("DBURL = %q, want explicit value", c.DBURL)
}
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" ||
c.AllowlistHosts[1] != ".example.com" {
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]", c.AllowlistHosts)
}
if c.UpstreamConnectionsPerHost != 5 {
t.Errorf("UpstreamConnectionsPerHost = %d, want 5", c.UpstreamConnectionsPerHost)
}
if c.SentryDSN != "https://abc123@sentry.example.com/42" {
t.Errorf("SentryDSN = %q, want explicit value", c.SentryDSN)
}
if c.MetricsUsername != "metricsuser" || c.MetricsPassword != "metricspass" {
t.Errorf("metrics = %q/%q, want metricsuser/metricspass",
c.MetricsUsername, c.MetricsPassword)
}
}
func TestCommaSeparatedAllowlistStillSupported(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + `
allowlist_hosts: "s3.sneak.cloud, sneak.berlin"
`
c, err := configFromYAML(t, yamlContent)
if err != nil {
t.Fatalf("comma-separated allowlist should load, got error: %v", err)
}
if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != "s3.sneak.cloud" ||
c.AllowlistHosts[1] != "sneak.berlin" {
t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]", c.AllowlistHosts)
}
}
// TestSetButInvalidValueAbortsStartup verifies the no-silent-fallback
// rule: a key that is explicitly set to an unparseable or out-of-range
// value must produce a startup error naming the offending key, never
// silently fall back to the default.
func TestSetButInvalidValueAbortsStartup(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
cases := []struct {
name string
yaml string
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string
}{
{
name: "port not a number",
yaml: signingKeyLine + "port: banana\n",
wantErrSubstrings: []string{"port", "banana"},
},
{
name: "port zero",
yaml: signingKeyLine + "port: 0\n",
wantErrSubstrings: []string{"port", "0"},
},
{
name: "port above 65535",
yaml: signingKeyLine + "port: 99999\n",
wantErrSubstrings: []string{"port", "99999"},
},
{
name: "port fractional",
yaml: signingKeyLine + "port: 8080.5\n",
wantErrSubstrings: []string{"port", "8080.5"},
},
{
name: "debug not a bool",
yaml: signingKeyLine + "debug: notabool\n",
wantErrSubstrings: []string{"debug", "notabool"},
},
{
name: "maintenance_mode not a bool",
yaml: signingKeyLine + "maintenance_mode: sometimes\n",
wantErrSubstrings: []string{"maintenance_mode", "sometimes"},
},
{
name: "allow_http numeric",
yaml: signingKeyLine + "allow_http: 2\n",
wantErrSubstrings: []string{"allow_http", "2"},
},
{
name: "upstream_connections_per_host zero",
yaml: signingKeyLine + "upstream_connections_per_host: 0\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "0"},
},
{
name: "upstream_connections_per_host negative",
yaml: signingKeyLine + "upstream_connections_per_host: -3\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "-3"},
},
{
name: "upstream_connections_per_host not a number",
yaml: signingKeyLine + "upstream_connections_per_host: many\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "many"},
},
{
name: "allowlist host with scheme",
yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n",
wantErrSubstrings: []string{
"allowlist_hosts", "https://example.com",
},
},
{
name: "allowlist host with path",
yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n",
wantErrSubstrings: []string{
"allowlist_hosts", "example.com/images",
},
},
{
name: "allowlist host with whitespace",
yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n",
wantErrSubstrings: []string{"allowlist_hosts", "exa mple.com"},
},
{
name: "allowlist entry not a string",
yaml: signingKeyLine + "allowlist_hosts:\n - 123\n",
wantErrSubstrings: []string{"allowlist_hosts", "123"},
},
{
name: "allowlist not a list",
yaml: signingKeyLine + "allowlist_hosts:\n key: value\n",
wantErrSubstrings: []string{"allowlist_hosts"},
},
{
name: "signing_key too short",
yaml: "signing_key: short\n",
wantErrSubstrings: []string{"signing_key"},
},
{
name: "signing_key missing",
yaml: "port: 8080\n",
wantErrSubstrings: []string{"signing_key"},
},
{
name: "state_dir explicitly empty",
yaml: signingKeyLine + "state_dir: \"\"\n",
wantErrSubstrings: []string{"state_dir"},
},
{
name: "sentry_dsn not a URL",
yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n",
wantErrSubstrings: []string{"sentry_dsn", "not a url"},
},
{
name: "metrics username without password",
yaml: signingKeyLine + "metrics:\n username: bob\n",
wantErrSubstrings: []string{"metrics"},
},
{
name: "metrics password without username",
yaml: signingKeyLine + "metrics:\n password: hunter2\n",
wantErrSubstrings: []string{"metrics"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
}
}
// TestExplicitNullValueAbortsStartup verifies that a key explicitly
// set to null (including the bare "key:" form and the "~" alias) aborts
// startup naming the key. An explicit null is a SET value: it must
// never silently fall back to the default the way an omitted key does.
func TestExplicitNullValueAbortsStartup(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
cases := []struct {
name string
yaml string
// wantErrSubstrings must all appear in the error message.
wantErrSubstrings []string
}{
{
name: "port explicit null",
yaml: signingKeyLine + "port: null\n",
wantErrSubstrings: []string{"port", "null"},
},
{
name: "port bare key no value",
yaml: signingKeyLine + "port:\n",
wantErrSubstrings: []string{"port", "null"},
},
{
name: "debug tilde null",
yaml: signingKeyLine + "debug: ~\n",
wantErrSubstrings: []string{"debug", "null"},
},
{
name: "maintenance_mode null",
yaml: signingKeyLine + "maintenance_mode: null\n",
wantErrSubstrings: []string{"maintenance_mode", "null"},
},
{
name: "allow_http null",
yaml: signingKeyLine + "allow_http: null\n",
wantErrSubstrings: []string{"allow_http", "null"},
},
{
name: "state_dir null",
yaml: signingKeyLine + "state_dir: null\n",
wantErrSubstrings: []string{"state_dir", "null"},
},
{
name: "db_url null",
yaml: signingKeyLine + "db_url: null\n",
wantErrSubstrings: []string{"db_url", "null"},
},
{
name: "sentry_dsn null",
yaml: signingKeyLine + "sentry_dsn: null\n",
wantErrSubstrings: []string{"sentry_dsn", "null"},
},
{
name: "upstream_connections_per_host null",
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
wantErrSubstrings: []string{"upstream_connections_per_host", "null"},
},
{
name: "allowlist_hosts null",
yaml: signingKeyLine + "allowlist_hosts: null\n",
wantErrSubstrings: []string{"allowlist_hosts", "null"},
},
{
name: "signing_key null",
yaml: "signing_key: null\n",
wantErrSubstrings: []string{"signing_key", "null"},
},
{
name: "metrics null",
yaml: signingKeyLine + "metrics: null\n",
wantErrSubstrings: []string{"metrics", "null"},
},
{
name: "metrics subkeys null",
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
wantErrSubstrings: []string{
"metrics.username", "metrics.password", "null",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, err := configFromYAML(t, tc.yaml)
if err == nil {
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
}
t.Logf("got expected error: %v", err)
for _, want := range tc.wantErrSubstrings {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
})
}
}
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
// empty string aborts startup: the derived file:...state.sqlite3 URL is
// a default, and defaults apply only to omitted keys. This matches
// state_dir, where an explicitly empty value already aborts.
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
yamlContent := "signing_key: " + validTestSigningKey + "\ndb_url: \"\"\n"
c, err := configFromYAML(t, yamlContent)
if err == nil {
t.Fatalf("explicitly empty db_url must abort startup, got config: %+v", c)
}
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "db_url") {
t.Errorf("error %q does not name the offending key db_url", err.Error())
}
}
// TestAllowlistHostsRejectsDotOnlyEntries verifies that entries with no
// hostname labels are rejected. The allowlist matcher treats a leading
// dot as a suffix pattern, so a bare "." entry would match any upstream
// host written in FQDN trailing-dot form (e.g. evil.com.) and
// effectively disable URL signing with a single character.
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
for _, entry := range []string{".", ".."} {
t.Run(entry, func(t *testing.T) {
yamlContent := signingKeyLine +
"allowlist_hosts:\n - \"" + entry + "\"\n"
c, err := configFromYAML(t, yamlContent)
if err == nil {
t.Fatalf("allowlist entry %q must abort startup, got config: %+v",
entry, c)
}
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "allowlist_hosts") {
t.Errorf("error %q does not name the offending key allowlist_hosts",
err.Error())
}
})
}
}
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + `
whitelist_hosts:
- example.com
`
c, err := configFromYAML(t, yamlContent)
if err == nil {
t.Fatalf("config with unknown key must abort startup, got config: %+v", c)
}
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "whitelist_hosts") {
t.Errorf("error %q does not name the unknown key whitelist_hosts", err.Error())
}
}
func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + `
metrics:
username: bob
password: hunter2
port: 9100
`
c, err := configFromYAML(t, yamlContent)
if err == nil {
t.Fatalf("config with unknown metrics subkey must abort startup, got config: %+v", c)
}
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "metrics.port") {
t.Errorf("error %q does not name the unknown key metrics.port", err.Error())
}
}
func TestEnvSectionIsPermitted(t *testing.T) {
yamlContent := `signing_key: ` + validTestSigningKey + `
env:
PIXA_TEST_ENV_INJECTION: injected
`
if _, err := configFromYAML(t, yamlContent); err != nil {
t.Fatalf("env section must be permitted (smartconfig consumes it), got error: %v", err)
}
}
func TestMalformedConfigFileAbortsStartup(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
if err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600); err != nil {
t.Fatalf("failed to write malformed config: %v", err)
}
// loadConfigFile falls through to the relative config.yml candidate;
// the appname is chosen so no /etc or $HOME candidate can exist.
t.Setenv("PIXA_CONFIG_PATH", "")
t.Chdir(tmpDir)
log := slog.New(slog.NewTextHandler(io.Discard, nil))
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
if err == nil {
t.Fatalf("malformed config file must abort startup, got config: %v", sc)
}
t.Logf("got expected error: %v", err)
}
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
stateDir := filepath.Join(t.TempDir(), "nested", "state")
c := &Config{StateDir: stateDir}
if err := c.ensureStateDirWritable(); err != nil {
t.Fatalf("creatable state_dir must validate, got error: %v", err)
}
info, err := os.Stat(stateDir)
if err != nil || !info.IsDir() {
t.Fatalf("state_dir was not created: info=%v err=%v", info, err)
}
}
func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
// A path below /dev/null can never be created, even when running
// as root (as in the Docker build).
c := &Config{StateDir: "/dev/null/pixa-state"}
err := c.ensureStateDirWritable()
if err == nil {
t.Fatal("uncreatable state_dir must abort startup, got nil error")
}
t.Logf("got expected error: %v", err)
if !strings.Contains(err.Error(), "state_dir") {
t.Errorf("error %q does not name the offending key state_dir", err.Error())
}
}

View File

@@ -9,6 +9,7 @@ import (
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"go.uber.org/fx" "go.uber.org/fx"
@@ -21,6 +22,10 @@ import (
//go:embed schema/*.sql //go:embed schema/*.sql
var schemaFS embed.FS var schemaFS embed.FS
// bootstrapVersion is the migration that creates the schema_migrations
// table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0
// Params defines dependencies for Database. // Params defines dependencies for Database.
type Params struct { type Params struct {
fx.In fx.In
@@ -35,6 +40,46 @@ type Database struct {
config *config.Config config *config.Config
} }
// ParseMigrationVersion extracts the numeric version prefix from a migration
// filename. Filenames must follow the pattern "<version>.sql" or
// "<version>_<description>.sql", where version is a zero-padded numeric
// string (e.g. "001", "002"). Returns the version as an integer and an
// error if the filename does not match the expected pattern.
func ParseMigrationVersion(filename string) (int, error) {
name := strings.TrimSuffix(filename, filepath.Ext(filename))
if name == "" {
return 0, fmt.Errorf("invalid migration filename %q: empty name", filename)
}
// Split on underscore to separate version from description.
// If there's no underscore, the entire stem is the version.
versionStr := name
if idx := strings.IndexByte(name, '_'); idx >= 0 {
versionStr = name[:idx]
}
if versionStr == "" {
return 0, fmt.Errorf("invalid migration filename %q: empty version prefix", filename)
}
// Validate the version is purely numeric.
for _, ch := range versionStr {
if ch < '0' || ch > '9' {
return 0, fmt.Errorf(
"invalid migration filename %q: version %q contains non-numeric character %q",
filename, versionStr, string(ch),
)
}
}
version, err := strconv.Atoi(versionStr)
if err != nil {
return 0, fmt.Errorf("invalid migration filename %q: %w", filename, err)
}
return version, nil
}
// New creates a new Database instance. // New creates a new Database instance.
func New(lc fx.Lifecycle, params Params) (*Database, error) { func New(lc fx.Lifecycle, params Params) (*Database, error) {
s := &Database{ s := &Database{
@@ -84,127 +129,86 @@ func (s *Database) connect(ctx context.Context) error {
s.db = db s.db = db
s.log.Info("database connected") s.log.Info("database connected")
return s.runMigrations(ctx) return ApplyMigrations(ctx, s.db, s.log)
} }
func (s *Database) runMigrations(ctx context.Context) error { // collectMigrations reads the embedded schema directory and returns
// Create migrations tracking table // migration filenames sorted lexicographically.
_, err := s.db.ExecContext(ctx, ` func collectMigrations() ([]string, error) {
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get list of migration files
entries, err := schemaFS.ReadDir("schema") entries, err := schemaFS.ReadDir("schema")
if err != nil { if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err) return nil, fmt.Errorf("failed to read schema directory: %w", err)
} }
// Sort migration files by name (001.sql, 002.sql, etc.)
var migrations []string var migrations []string
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name()) migrations = append(migrations, entry.Name())
} }
} }
sort.Strings(migrations) sort.Strings(migrations)
// Apply each migration that hasn't been applied yet return migrations, nil
for _, migration := range migrations { }
version := strings.TrimSuffix(migration, filepath.Ext(migration))
// Check if already applied // bootstrapMigrationsTable ensures the schema_migrations table exists
var count int // by applying 000.sql if the table is missing.
err := s.db.QueryRowContext(ctx, func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", var tableExists int
version,
).Scan(&count) err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableExists)
if err != nil { if err != nil {
return fmt.Errorf("failed to check migration status: %w", err) return fmt.Errorf("failed to check for migrations table: %w", err)
} }
if count > 0 { if tableExists > 0 {
s.log.Debug("migration already applied", "version", version) return nil
continue
} }
// Read and apply migration content, err := schemaFS.ReadFile("schema/000.sql")
content, err := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil { if err != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err) return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
} }
s.log.Info("applying migration", "version", version) if log != nil {
log.Info("applying bootstrap migration", "version", bootstrapVersion)
}
_, err = s.db.ExecContext(ctx, string(content)) _, err = db.ExecContext(ctx, string(content))
if err != nil { if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err) return fmt.Errorf("failed to apply bootstrap migration: %w", err)
}
// Record migration as applied
_, err = s.db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
version,
)
if err != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err)
}
s.log.Info("migration applied successfully", "version", version)
} }
return nil return nil
} }
// DB returns the underlying sql.DB. // ApplyMigrations applies all pending migrations to db. An optional logger
func (s *Database) DB() *sql.DB { // may be provided for informational output; pass nil for silent operation.
return s.db // This is exported so tests can apply the real schema without the full fx
// lifecycle.
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
if err := bootstrapMigrationsTable(ctx, db, log); err != nil {
return err
} }
// ApplyMigrations applies all migrations to the given database. migrations, err := collectMigrations()
// This is useful for testing where you want to use the real schema
// without the full fx lifecycle.
func ApplyMigrations(db *sql.DB) error {
ctx := context.Background()
// Create migrations tracking table
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil { if err != nil {
return fmt.Errorf("failed to create migrations table: %w", err) return err
} }
// Get list of migration files
entries, err := schemaFS.ReadDir("schema")
if err != nil {
return fmt.Errorf("failed to read schema directory: %w", err)
}
// Sort migration files by name (001.sql, 002.sql, etc.)
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
// Apply each migration that hasn't been applied yet
for _, migration := range migrations { for _, migration := range migrations {
version := strings.TrimSuffix(migration, filepath.Ext(migration)) version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil {
return parseErr
}
// Check if already applied // Check if already applied.
var count int var count int
err := db.QueryRowContext(ctx, err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", "SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version, version,
@@ -214,29 +218,46 @@ func ApplyMigrations(db *sql.DB) error {
} }
if count > 0 { if count > 0 {
if log != nil {
log.Debug("migration already applied", "version", version)
}
continue continue
} }
// Read and apply migration // Read and apply migration.
content, err := schemaFS.ReadFile(filepath.Join("schema", migration)) content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
if err != nil { if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, err) return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
} }
_, err = db.ExecContext(ctx, string(content)) if log != nil {
if err != nil { log.Info("applying migration", "version", version)
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
} }
// Record migration as applied _, execErr := db.ExecContext(ctx, string(content))
_, err = db.ExecContext(ctx, if execErr != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
}
// Record migration as applied.
_, recErr := db.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)", "INSERT INTO schema_migrations (version) VALUES (?)",
version, version,
) )
if err != nil { if recErr != nil {
return fmt.Errorf("failed to record migration %s: %w", migration, err) return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
}
if log != nil {
log.Info("migration applied successfully", "version", version)
} }
} }
return nil return nil
} }
// DB returns the underlying sql.DB.
func (s *Database) DB() *sql.DB {
return s.db
}

View File

@@ -0,0 +1,224 @@
package database
import (
"context"
"database/sql"
"testing"
_ "modernc.org/sqlite" // SQLite driver registration
)
// openTestDB returns a fresh in-memory SQLite database.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestParseMigrationVersion(t *testing.T) {
tests := []struct {
name string
filename string
want int
wantErr bool
}{
{
name: "version only",
filename: "001.sql",
want: 1,
},
{
name: "version with description",
filename: "001_initial_schema.sql",
want: 1,
},
{
name: "multi-digit version",
filename: "042_add_indexes.sql",
want: 42,
},
{
name: "long version number",
filename: "00001_long_prefix.sql",
want: 1,
},
{
name: "description with multiple underscores",
filename: "003_add_user_auth_tables.sql",
want: 3,
},
{
name: "empty filename",
filename: ".sql",
wantErr: true,
},
{
name: "leading underscore",
filename: "_description.sql",
wantErr: true,
},
{
name: "non-numeric version",
filename: "abc_migration.sql",
wantErr: true,
},
{
name: "mixed alphanumeric version",
filename: "001a_migration.sql",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseMigrationVersion(tt.filename)
if tt.wantErr {
if err == nil {
t.Errorf("ParseMigrationVersion(%q) expected error, got %d", tt.filename, got)
}
return
}
if err != nil {
t.Errorf("ParseMigrationVersion(%q) unexpected error: %v", tt.filename, err)
return
}
if got != tt.want {
t.Errorf("ParseMigrationVersion(%q) = %d, want %d", tt.filename, got, tt.want)
}
})
}
}
func TestApplyMigrations_CreatesSchemaAndTables(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
if err := ApplyMigrations(ctx, db, nil); err != nil {
t.Fatalf("ApplyMigrations failed: %v", err)
}
// The schema_migrations table must exist and contain at least
// version 0 (the bootstrap) and 1 (the initial schema).
rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version")
if err != nil {
t.Fatalf("failed to query schema_migrations: %v", err)
}
defer rows.Close()
var versions []int
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
t.Fatalf("failed to scan version: %v", err)
}
versions = append(versions, v)
}
if err := rows.Err(); err != nil {
t.Fatalf("row iteration error: %v", err)
}
if len(versions) < 2 {
t.Fatalf("expected at least 2 migrations recorded, got %d: %v", len(versions), versions)
}
if versions[0] != 0 {
t.Errorf("first recorded migration = %d, want %d", versions[0], 0)
}
if versions[1] != 1 {
t.Errorf("second recorded migration = %d, want %d", versions[1], 1)
}
// Verify that the application tables created by 001.sql exist.
for _, table := range []string{"source_content", "source_metadata", "output_content", "request_cache", "negative_cache", "cache_stats"} {
var count int
err := db.QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
table,
).Scan(&count)
if err != nil {
t.Fatalf("failed to check for table %s: %v", table, err)
}
if count != 1 {
t.Errorf("table %s does not exist after migrations", table)
}
}
}
func TestApplyMigrations_Idempotent(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
if err := ApplyMigrations(ctx, db, nil); err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err)
}
// Running a second time must succeed without errors.
if err := ApplyMigrations(ctx, db, nil); err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err)
}
// Verify no duplicate rows in schema_migrations.
var count int
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = 0").Scan(&count)
if err != nil {
t.Fatalf("failed to count version 0 rows: %v", err)
}
if count != 1 {
t.Errorf("expected exactly 1 row for version 0, got %d", count)
}
}
func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
if err := bootstrapMigrationsTable(ctx, db, nil); err != nil {
t.Fatalf("bootstrapMigrationsTable failed: %v", err)
}
// schema_migrations table must exist.
var tableCount int
err := db.QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableCount)
if err != nil {
t.Fatalf("failed to check for table: %v", err)
}
if tableCount != 1 {
t.Fatalf("schema_migrations table not created")
}
// Version 0 must be recorded.
var recorded int
err = db.QueryRow(
"SELECT COUNT(*) FROM schema_migrations WHERE version = 0",
).Scan(&recorded)
if err != nil {
t.Fatalf("failed to check version: %v", err)
}
if recorded != 1 {
t.Errorf("expected version 0 to be recorded, got count %d", recorded)
}
}

View File

@@ -0,0 +1,9 @@
-- Migration 000: Schema migrations tracking table
-- Applied as a bootstrap step before the normal migration loop.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);

View File

@@ -0,0 +1,25 @@
-- Migration 002: cache size accounting and eviction
--
-- Tracks processed variants in the database (source content blobs are
-- already tracked in source_content) so total cache usage can be
-- computed without directory scans, and adds last-access timestamps
-- for LRU eviction ordering.
-- Processed variant blobs
-- Files stored at: cache/variants/<ab>/<cd>/<cache_key> (plus a
-- .meta sidecar with the content type)
CREATE TABLE IF NOT EXISTS variant_content (
cache_key TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL,
content_type TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_variant_content_last_accessed
ON variant_content(last_accessed_at);
-- LRU timestamp for source content blobs. Rows written before this
-- migration have NULL here; eviction falls back to fetched_at.
ALTER TABLE source_content ADD COLUMN last_accessed_at DATETIME;
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
ON source_content(last_accessed_at);

View File

@@ -5,11 +5,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
) )
// Build-time variables populated from main() via ldflags. const appname = "pixad"
var (
Appname string //nolint:gochecknoglobals // set from main // Version is populated from main() via ldflags.
Version string //nolint:gochecknoglobals // set from main var Version string //nolint:gochecknoglobals // set from main
)
// Globals holds application-wide constants. // Globals holds application-wide constants.
type Globals struct { type Globals struct {
@@ -20,7 +19,7 @@ type Globals struct {
// New creates a new Globals instance from build-time variables. // New creates a new Globals instance from build-time variables.
func New(_ fx.Lifecycle) (*Globals, error) { func New(_ fx.Lifecycle) (*Globals, error) {
return &Globals{ return &Globals{
Appname: Appname, Appname: appname,
Version: Version, Version: Version,
}, nil }, nil
} }

View File

@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/healthcheck" "sneak.berlin/go/pixa/internal/healthcheck"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/logger"
"sneak.berlin/go/pixa/internal/session" "sneak.berlin/go/pixa/internal/session"
@@ -52,6 +53,13 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
return s.initImageService() return s.initImageService()
}, },
OnStop: func(_ context.Context) error {
if s.imgCache != nil {
s.imgCache.StopEviction()
}
return nil
},
}) })
return s, nil return s, nil
@@ -59,11 +67,15 @@ func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
// initImageService initializes the image cache and service. // initImageService initializes the image cache and service.
func (s *Handlers) initImageService() error { func (s *Handlers) initImageService() error {
// Create the cache // Create the cache. cache_max_bytes: 0 disables the disk cache
// entirely; any other value is the eviction limit in bytes.
cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{ cache, err := imgcache.NewCache(s.db.DB(), imgcache.CacheConfig{
StateDir: s.config.StateDir, StateDir: s.config.StateDir,
CacheTTL: imgcache.DefaultCacheTTL, CacheTTL: imgcache.DefaultCacheTTL,
NegativeTTL: imgcache.DefaultNegativeTTL, NegativeTTL: imgcache.DefaultNegativeTTL,
MaxBytes: s.config.CacheMaxBytes,
DisableDiskCache: s.config.CacheMaxBytes == 0,
Logger: s.log,
}) })
if err != nil { if err != nil {
return err return err
@@ -71,8 +83,12 @@ func (s *Handlers) initImageService() error {
s.imgCache = cache s.imgCache = cache
// Background eviction: startup reconciliation, then periodic and
// write-pressure passes. No-op when the disk cache is disabled.
cache.StartEviction(imgcache.DefaultEvictionInterval)
// Create the fetcher config // Create the fetcher config
fetcherCfg := imgcache.DefaultFetcherConfig() fetcherCfg := httpfetcher.DefaultConfig()
fetcherCfg.AllowHTTP = s.config.AllowHTTP fetcherCfg.AllowHTTP = s.config.AllowHTTP
if s.config.UpstreamConnectionsPerHost > 0 { if s.config.UpstreamConnectionsPerHost > 0 {
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
@@ -83,7 +99,7 @@ func (s *Handlers) initImageService() error {
Cache: cache, Cache: cache,
FetcherConfig: fetcherCfg, FetcherConfig: fetcherCfg,
SigningKey: s.config.SigningKey, SigningKey: s.config.SigningKey,
Whitelist: s.config.WhitelistHosts, Allowlist: s.config.AllowlistHosts,
Logger: s.log, Logger: s.log,
}) })
if err != nil { if err != nil {
@@ -93,8 +109,9 @@ func (s *Handlers) initImageService() error {
s.imgSvc = svc s.imgSvc = svc
s.log.Info("image service initialized") s.log.Info("image service initialized")
// Initialize session manager (signing key is validated at config load time) // Initialize session manager (signing key is validated at config load
sessMgr, err := session.NewManager(s.config.SigningKey, !s.config.Debug) // time). Session cookies are always Secure/HttpOnly/SameSite=Strict.
sessMgr, err := session.NewManager(s.config.SigningKey)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -18,6 +18,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imgcache" "sneak.berlin/go/pixa/internal/imgcache"
) )
@@ -56,7 +57,7 @@ func setupTestHandler(t *testing.T) *testFixtures {
Cache: cache, Cache: cache,
Fetcher: newMockFetcher(mockFS), Fetcher: newMockFetcher(mockFS),
SigningKey: "test-signing-key-must-be-32-chars", SigningKey: "test-signing-key-must-be-32-chars",
Whitelist: []string{goodHost}, Allowlist: []string{goodHost},
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create service: %v", err) t.Fatalf("failed to create service: %v", err)
@@ -82,7 +83,7 @@ func setupTestDB(t *testing.T) *sql.DB {
t.Fatalf("failed to open test db: %v", err) t.Fatalf("failed to open test db: %v", err)
} }
if err := database.ApplyMigrations(db); err != nil { if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
t.Fatalf("failed to apply migrations: %v", err) t.Fatalf("failed to apply migrations: %v", err)
} }
@@ -116,16 +117,16 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
return &mockFetcher{fs: fs} return &mockFetcher{fs: fs}
} }
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*imgcache.FetchResult, error) { func (f *mockFetcher) Fetch(ctx context.Context, url string) (*httpfetcher.FetchResult, error) {
// Remove https:// prefix // Remove https:// prefix
path := url[8:] // Remove "https://" path := url[8:] // Remove "https://"
data, err := fs.ReadFile(f.fs, path) data, err := fs.ReadFile(f.fs, path)
if err != nil { if err != nil {
return nil, imgcache.ErrUpstreamError return nil, httpfetcher.ErrUpstreamError
} }
return &imgcache.FetchResult{ return &httpfetcher.FetchResult{
Content: io.NopCloser(bytes.NewReader(data)), Content: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)), ContentLength: int64(len(data)),
ContentType: "image/jpeg", ContentType: "image/jpeg",

View File

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

View File

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

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

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

View File

@@ -1,4 +1,5 @@
package imgcache // Package imageprocessor provides image format conversion and resizing using libvips.
package imageprocessor
import ( import (
"bytes" "bytes"
@@ -22,38 +23,133 @@ func initVips() {
}) })
} }
// Format represents supported output image formats.
type Format string
// Supported image output formats.
const (
FormatOriginal Format = "orig"
FormatJPEG Format = "jpeg"
FormatPNG Format = "png"
FormatWebP Format = "webp"
FormatAVIF Format = "avif"
FormatGIF Format = "gif"
)
// FitMode represents how to fit an image into requested dimensions.
type FitMode string
// Supported image fit modes.
const (
FitCover FitMode = "cover"
FitContain FitMode = "contain"
FitFill FitMode = "fill"
FitInside FitMode = "inside"
FitOutside FitMode = "outside"
)
// ErrInvalidFitMode is returned when an invalid fit mode is provided.
var ErrInvalidFitMode = errors.New("invalid fit mode")
// Size represents requested image dimensions.
type Size struct {
Width int
Height int
}
// Request holds the parameters for image processing.
type Request struct {
Size Size
Format Format
Quality int
FitMode FitMode
}
// Result contains the output of image processing.
type Result struct {
// Content is the processed image data.
Content io.ReadCloser
// ContentLength is the size in bytes.
ContentLength int64
// ContentType is the MIME type of the output.
ContentType string
// Width is the output image width.
Width int
// Height is the output image height.
Height int
// InputWidth is the original image width before processing.
InputWidth int
// InputHeight is the original image height before processing.
InputHeight int
// InputFormat is the detected input format (e.g., "jpeg", "png").
InputFormat string
}
// MaxInputDimension is the maximum allowed width or height for input images. // MaxInputDimension is the maximum allowed width or height for input images.
// Images larger than this are rejected to prevent DoS via decompression bombs. // Images larger than this are rejected to prevent DoS via decompression bombs.
const MaxInputDimension = 8192 const MaxInputDimension = 8192
// DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB).
// This matches the default upstream fetcher limit.
const DefaultMaxInputBytes = 50 << 20
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension. // ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum") var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
// ErrInputDataTooLarge is returned when the raw input data exceeds the configured byte limit.
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported. // ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format") var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ImageProcessor implements the Processor interface using libvips via govips. // ImageProcessor implements image transformation using libvips via govips.
type ImageProcessor struct{} type ImageProcessor struct {
maxInputBytes int64
}
// NewImageProcessor creates a new image processor. // Params holds configuration for creating an ImageProcessor.
func NewImageProcessor() *ImageProcessor { // Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes).
type Params struct {
// MaxInputBytes is the maximum allowed input size in bytes.
// If <= 0, DefaultMaxInputBytes is used.
MaxInputBytes int64
}
// New creates a new image processor with the given parameters.
// A zero-value Params{} uses sensible defaults.
func New(params Params) *ImageProcessor {
initVips() initVips()
return &ImageProcessor{} maxInputBytes := params.MaxInputBytes
if maxInputBytes <= 0 {
maxInputBytes = DefaultMaxInputBytes
}
return &ImageProcessor{
maxInputBytes: maxInputBytes,
}
} }
// Process transforms an image according to the request. // Process transforms an image according to the request.
func (p *ImageProcessor) Process( func (p *ImageProcessor) Process(
_ context.Context, _ context.Context,
input io.Reader, input io.Reader,
req *ImageRequest, req *Request,
) (*ProcessResult, error) { ) (*Result, error) {
// Read input // Read input with a size limit to prevent unbounded memory consumption.
data, err := io.ReadAll(input) // We read at most maxInputBytes+1 so we can detect if the input exceeds
// the limit without consuming additional memory.
limited := io.LimitReader(input, p.maxInputBytes+1)
data, err := io.ReadAll(limited)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err) return nil, fmt.Errorf("failed to read input: %w", err)
} }
if int64(len(data)) > p.maxInputBytes {
return nil, ErrInputDataTooLarge
}
// Decode image // Decode image
img, err := vips.NewImageFromBuffer(data) img, err := vips.NewImageFromBuffer(data)
if err != nil { if err != nil {
@@ -109,10 +205,10 @@ func (p *ImageProcessor) Process(
return nil, fmt.Errorf("failed to encode: %w", err) return nil, fmt.Errorf("failed to encode: %w", err)
} }
return &ProcessResult{ return &Result{
Content: io.NopCloser(bytes.NewReader(output)), Content: io.NopCloser(bytes.NewReader(output)),
ContentLength: int64(len(output)), ContentLength: int64(len(output)),
ContentType: ImageFormatToMIME(outputFormat), ContentType: FormatToMIME(outputFormat),
Width: img.Width(), Width: img.Width(),
Height: img.Height(), Height: img.Height(),
InputWidth: origWidth, InputWidth: origWidth,
@@ -124,17 +220,17 @@ func (p *ImageProcessor) Process(
// SupportedInputFormats returns MIME types this processor can read. // SupportedInputFormats returns MIME types this processor can read.
func (p *ImageProcessor) SupportedInputFormats() []string { func (p *ImageProcessor) SupportedInputFormats() []string {
return []string{ return []string{
string(MIMETypeJPEG), "image/jpeg",
string(MIMETypePNG), "image/png",
string(MIMETypeGIF), "image/gif",
string(MIMETypeWebP), "image/webp",
string(MIMETypeAVIF), "image/avif",
} }
} }
// SupportedOutputFormats returns formats this processor can write. // SupportedOutputFormats returns formats this processor can write.
func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat { func (p *ImageProcessor) SupportedOutputFormats() []Format {
return []ImageFormat{ return []Format{
FormatJPEG, FormatJPEG,
FormatPNG, FormatPNG,
FormatGIF, FormatGIF,
@@ -143,6 +239,24 @@ func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat {
} }
} }
// FormatToMIME converts a Format to its MIME type string.
func FormatToMIME(format Format) string {
switch format {
case FormatJPEG:
return "image/jpeg"
case FormatPNG:
return "image/png"
case FormatWebP:
return "image/webp"
case FormatGIF:
return "image/gif"
case FormatAVIF:
return "image/avif"
default:
return "application/octet-stream"
}
}
// detectFormat returns the format string from a vips image. // detectFormat returns the format string from a vips image.
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string { func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
format := img.Format() format := img.Format()
@@ -171,7 +285,6 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
case FitContain: case FitContain:
// Resize to fit within dimensions, maintaining aspect ratio // Resize to fit within dimensions, maintaining aspect ratio
// Calculate target dimensions maintaining aspect ratio
imgW, imgH := img.Width(), img.Height() imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW) scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH) scaleH := float64(height) / float64(imgH)
@@ -182,7 +295,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
return img.Thumbnail(newW, newH, vips.InterestingNone) return img.Thumbnail(newW, newH, vips.InterestingNone)
case FitFill: case FitFill:
// Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force // Resize to exact dimensions (may distort)
return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce)
case FitInside: case FitInside:
@@ -218,7 +331,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
const defaultQuality = 85 const defaultQuality = 85
// encode encodes an image to the specified format. // encode encodes an image to the specified format.
func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) { func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) {
if quality <= 0 { if quality <= 0 {
quality = defaultQuality quality = defaultQuality
} }
@@ -266,8 +379,8 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality
return output, nil return output, nil
} }
// formatFromString converts a format string to ImageFormat. // formatFromString converts a format string to Format.
func (p *ImageProcessor) formatFromString(format string) ImageFormat { func (p *ImageProcessor) formatFromString(format string) Format {
switch format { switch format {
case "jpeg": case "jpeg":
return FormatJPEG return FormatJPEG

View File

@@ -1,4 +1,4 @@
package imgcache package imageprocessor
import ( import (
"bytes" "bytes"
@@ -70,13 +70,36 @@ func createTestPNG(t *testing.T, width, height int) []byte {
return buf.Bytes() return buf.Bytes()
} }
// detectMIME is a minimal magic-byte detector for test assertions.
func detectMIME(data []byte) string {
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
return "image/png"
}
if len(data) >= 4 && string(data[:4]) == "GIF8" {
return "image/gif"
}
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
return "image/webp"
}
if len(data) >= 12 && string(data[4:8]) == "ftyp" {
brand := string(data[8:12])
if brand == "avif" || brand == "avis" {
return "image/avif"
}
}
return ""
}
func TestImageProcessor_ResizeJPEG(t *testing.T) { func TestImageProcessor_ResizeJPEG(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 400, Height: 300}, Size: Size{Width: 400, Height: 300},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -107,23 +130,19 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/jpeg" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/jpeg", mime)
}
if mime != MIMETypeJPEG {
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG)
} }
} }
func TestImageProcessor_ConvertToPNG(t *testing.T) { func TestImageProcessor_ConvertToPNG(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 200, Height: 150}, Size: Size{Width: 200, Height: 150},
Format: FormatPNG, Format: FormatPNG,
FitMode: FitCover, FitMode: FitCover,
@@ -140,23 +159,19 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/png" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/png", mime)
}
if mime != MIMETypePNG {
t.Errorf("Output format = %v, want %v", mime, MIMETypePNG)
} }
} }
func TestImageProcessor_OriginalSize(t *testing.T) { func TestImageProcessor_OriginalSize(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 640, 480) input := createTestJPEG(t, 640, 480)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 0, Height: 0}, // Original size Size: Size{Width: 0, Height: 0}, // Original size
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -179,14 +194,14 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
} }
func TestImageProcessor_FitContain(t *testing.T) { func TestImageProcessor_FitContain(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// 800x400 image (2:1 aspect) into 400x400 box with contain // 800x400 image (2:1 aspect) into 400x400 box with contain
// Should result in 400x200 (maintaining aspect ratio) // Should result in 400x200 (maintaining aspect ratio)
input := createTestJPEG(t, 800, 400) input := createTestJPEG(t, 800, 400)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 400, Height: 400}, Size: Size{Width: 400, Height: 400},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -206,14 +221,14 @@ func TestImageProcessor_FitContain(t *testing.T) {
} }
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// 800x600 image, request width=400 height=0 // 800x600 image, request width=400 height=0
// Should scale proportionally to 400x300 // Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 400, Height: 0}, Size: Size{Width: 400, Height: 0},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -236,14 +251,14 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
} }
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// 800x600 image, request width=0 height=300 // 800x600 image, request width=0 height=300
// Should scale proportionally to 400x300 // Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600) input := createTestJPEG(t, 800, 600)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 0, Height: 300}, Size: Size{Width: 0, Height: 300},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -266,12 +281,12 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
} }
func TestImageProcessor_ProcessPNG(t *testing.T) { func TestImageProcessor_ProcessPNG(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestPNG(t, 400, 300) input := createTestPNG(t, 400, 300)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 200, Height: 150}, Size: Size{Width: 200, Height: 150},
Format: FormatPNG, Format: FormatPNG,
FitMode: FitCover, FitMode: FitCover,
@@ -292,13 +307,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
} }
} }
func TestImageProcessor_ImplementsInterface(t *testing.T) {
// Verify ImageProcessor implements Processor interface
var _ Processor = (*ImageProcessor)(nil)
}
func TestImageProcessor_SupportedFormats(t *testing.T) { func TestImageProcessor_SupportedFormats(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
inputFormats := proc.SupportedInputFormats() inputFormats := proc.SupportedInputFormats()
if len(inputFormats) == 0 { if len(inputFormats) == 0 {
@@ -312,14 +322,14 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
} }
func TestImageProcessor_RejectsOversizedInput(t *testing.T) { func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// Create an image that exceeds MaxInputDimension (e.g., 10000x100) // Create an image that exceeds MaxInputDimension (e.g., 10000x100)
// This should be rejected before processing to prevent DoS // This should be rejected before processing to prevent DoS
input := createTestJPEG(t, 10000, 100) input := createTestJPEG(t, 10000, 100)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -337,13 +347,13 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
} }
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// Create an image with oversized height // Create an image with oversized height
input := createTestJPEG(t, 100, 10000) input := createTestJPEG(t, 100, 10000)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -361,14 +371,13 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
} }
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// Create an image at exactly MaxInputDimension - should be accepted // Create an image at exactly MaxInputDimension - should be accepted
// Using smaller dimensions to keep test fast
input := createTestJPEG(t, MaxInputDimension, 100) input := createTestJPEG(t, MaxInputDimension, 100)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 100}, Size: Size{Width: 100, Height: 100},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -383,12 +392,12 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
} }
func TestImageProcessor_EncodeWebP(t *testing.T) { func TestImageProcessor_EncodeWebP(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 75}, Size: Size{Width: 100, Height: 75},
Format: FormatWebP, Format: FormatWebP,
Quality: 80, Quality: 80,
@@ -407,13 +416,9 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/webp" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/webp", mime)
}
if mime != MIMETypeWebP {
t.Errorf("Output format = %v, want %v", mime, MIMETypeWebP)
} }
// Verify dimensions // Verify dimensions
@@ -426,7 +431,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
} }
func TestImageProcessor_DecodeAVIF(t *testing.T) { func TestImageProcessor_DecodeAVIF(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
// Load test AVIF file // Load test AVIF file
@@ -436,7 +441,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
} }
// Request resize and convert to JPEG // Request resize and convert to JPEG
req := &ImageRequest{ req := &Request{
Size: Size{Width: 2, Height: 2}, Size: Size{Width: 2, Height: 2},
Format: FormatJPEG, Format: FormatJPEG,
Quality: 85, Quality: 85,
@@ -455,23 +460,84 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/jpeg" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/jpeg", mime)
}
} }
if mime != MIMETypeJPEG { func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG) // 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 {
t.Fatalf("Process() error = %v, want nil", err)
}
defer result.Content.Close()
}
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
// Passing 0 should use the default
proc := New(Params{})
if proc.maxInputBytes != DefaultMaxInputBytes {
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
}
// Passing negative should also use the default
proc = New(Params{MaxInputBytes: -1})
if proc.maxInputBytes != DefaultMaxInputBytes {
t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes)
} }
} }
func TestImageProcessor_EncodeAVIF(t *testing.T) { func TestImageProcessor_EncodeAVIF(t *testing.T) {
proc := NewImageProcessor() proc := New(Params{})
ctx := context.Background() ctx := context.Background()
input := createTestJPEG(t, 200, 150) input := createTestJPEG(t, 200, 150)
req := &ImageRequest{ req := &Request{
Size: Size{Width: 100, Height: 75}, Size: Size{Width: 100, Height: 75},
Format: FormatAVIF, Format: FormatAVIF,
Quality: 85, Quality: 85,
@@ -490,13 +556,9 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
t.Fatalf("failed to read result: %v", err) t.Fatalf("failed to read result: %v", err)
} }
mime, err := DetectFormat(data) mime := detectMIME(data)
if err != nil { if mime != "image/avif" {
t.Fatalf("DetectFormat() error = %v", err) t.Errorf("Output format = %v, want image/avif", mime)
}
if mime != MIMETypeAVIF {
t.Errorf("Output format = %v, want %v", mime, MIMETypeAVIF)
} }
// Verify dimensions // Verify dimensions

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -7,8 +7,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"sneak.berlin/go/pixa/internal/httpfetcher"
) )
// Cache errors. // Cache errors.
@@ -25,6 +29,22 @@ type CacheConfig struct {
StateDir string StateDir string
CacheTTL time.Duration CacheTTL time.Duration
NegativeTTL time.Duration NegativeTTL time.Duration
// MaxBytes is the disk cache size limit in bytes that eviction
// enforces. Zero means no limit is enforced (no eviction). The
// config layer supplies the computed default when the operator
// omits cache_max_bytes.
MaxBytes int64
// DisableDiskCache turns the disk cache off entirely: no cache
// directories are created, lookups always miss, stores are
// no-ops, and no eviction machinery runs. The config layer sets
// this when the operator configures cache_max_bytes: 0.
DisableDiskCache bool
// Logger receives accounting and eviction log output. A nil
// Logger means slog.Default().
Logger *slog.Logger
} }
// variantMeta stores content type for fast cache hits without reading .meta file. // variantMeta stores content type for fast cache hits without reading .meta file.
@@ -40,6 +60,19 @@ type Cache struct {
variants *VariantStorage // processed variants by cache key variants *VariantStorage // processed variants by cache key
srcMetadata *MetadataStorage // source metadata by host/path srcMetadata *MetadataStorage // source metadata by host/path
config CacheConfig config CacheConfig
log *slog.Logger
// disabled means the disk cache is turned off entirely: lookups
// always miss, stores are no-ops, and no eviction runs.
disabled bool
// Eviction machinery. The channels are created in NewCache so
// stores can signal write pressure without racing StartEviction.
evictionPressure chan struct{}
evictionStop chan struct{}
evictionDone chan struct{}
evictionStarted bool
evictionStopOnce sync.Once
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files // In-memory cache of variant metadata (content type, size) to avoid reading .meta files
metaCache map[VariantKey]variantMeta metaCache map[VariantKey]variantMeta
@@ -47,6 +80,26 @@ type Cache struct {
// NewCache creates a new cache instance. // NewCache creates a new cache instance.
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) { func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
log := config.Logger
if log == nil {
log = slog.Default()
}
c := &Cache{
db: db,
config: config,
log: log,
disabled: config.DisableDiskCache,
evictionPressure: make(chan struct{}, 1),
evictionStop: make(chan struct{}),
evictionDone: make(chan struct{}),
metaCache: make(map[VariantKey]variantMeta),
}
if c.disabled {
return c, nil
}
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources")) srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create source content storage: %w", err) return nil, fmt.Errorf("failed to create source content storage: %w", err)
@@ -62,14 +115,11 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
return nil, fmt.Errorf("failed to create source metadata storage: %w", err) return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
} }
return &Cache{ c.srcContent = srcContent
db: db, c.variants = variants
srcContent: srcContent, c.srcMetadata = srcMetadata
variants: variants,
srcMetadata: srcMetadata, return c, nil
config: config,
metaCache: make(map[VariantKey]variantMeta),
}, nil
} }
// LookupResult contains the result of a cache lookup. // LookupResult contains the result of a cache lookup.
@@ -81,12 +131,15 @@ type LookupResult struct {
CacheStatus CacheStatus CacheStatus CacheStatus
} }
// Lookup checks if a processed variant exists on disk (no DB access for hits). // Lookup checks if a processed variant exists on disk. Hits touch the
func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, error) { // variant's LRU timestamp; a disabled cache always misses.
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
// Check variant storage directly - no DB needed for cache hits // Check variant storage directly - no DB needed for cache hits
if c.variants.Exists(cacheKey) { if !c.disabled && c.variants.Exists(cacheKey) {
c.touchVariant(ctx, cacheKey)
return &LookupResult{ return &LookupResult{
Hit: true, Hit: true,
CacheKey: cacheKey, CacheKey: cacheKey,
@@ -101,18 +154,53 @@ func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, err
}, nil }, nil
} }
// touchVariant updates the LRU timestamp of a variant, best-effort:
// a failed touch only makes the entry look colder to eviction.
func (c *Cache) touchVariant(ctx context.Context, cacheKey VariantKey) {
_, err := c.db.ExecContext(ctx, `
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
WHERE cache_key = ?
`, string(cacheKey))
if err != nil {
c.log.Debug("failed to touch variant LRU timestamp",
"cache_key", cacheKey, "error", err)
}
}
// touchSourceContent updates the LRU timestamp of a source content
// blob, best-effort: a failed touch only makes the blob look colder.
func (c *Cache) touchSourceContent(ctx context.Context, contentHash ContentHash) {
_, err := c.db.ExecContext(ctx, `
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
WHERE content_hash = ?
`, string(contentHash))
if err != nil {
c.log.Debug("failed to touch source content LRU timestamp",
"content_hash", contentHash, "error", err)
}
}
// GetVariant returns a reader, size, and content type for a cached variant. // GetVariant returns a reader, size, and content type for a cached variant.
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) { func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
if c.disabled {
return nil, 0, "", ErrNotFound
}
return c.variants.LoadWithMeta(cacheKey) return c.variants.LoadWithMeta(cacheKey)
} }
// StoreSource stores fetched source content and metadata. // StoreSource stores fetched source content and metadata. On a
// disabled cache it is a no-op returning an empty hash.
func (c *Cache) StoreSource( func (c *Cache) StoreSource(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *ImageRequest,
content io.Reader, content io.Reader,
result *FetchResult, result *httpfetcher.FetchResult,
) (ContentHash, error) { ) (ContentHash, error) {
if c.disabled {
return "", nil
}
// Store content // Store content
contentHash, size, err := c.srcContent.Store(content) contentHash, size, err := c.srcContent.Store(content)
if err != nil { if err != nil {
@@ -169,19 +257,52 @@ func (c *Cache) StoreSource(
_ = err _ = err
} }
c.notifyWritePressure()
return contentHash, nil return contentHash, nil
} }
// StoreVariant stores a processed variant by its cache key. // StoreVariant stores a processed variant by its cache key and records
// it in the size accounting. On a disabled cache it is a no-op. The
// accounting insert is best-effort (the startup reconciliation pass
// adopts any variant file that misses its accounting row).
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error { func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error {
_, err := c.variants.Store(cacheKey, content, contentType) if c.disabled {
return nil
}
size, err := c.variants.Store(cacheKey, content, contentType)
if err != nil {
return err return err
} }
_, err = c.db.Exec(`
INSERT INTO variant_content (cache_key, size_bytes, content_type)
VALUES (?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
size_bytes = excluded.size_bytes,
content_type = excluded.content_type,
last_accessed_at = CURRENT_TIMESTAMP
`, string(cacheKey), size, contentType)
if err != nil {
c.log.Warn("failed to record variant in size accounting",
"cache_key", cacheKey, "error", err)
}
c.notifyWritePressure()
return nil
}
// LookupSource checks if we have cached source content for a request. // LookupSource checks if we have cached source content for a request.
// Returns the content hash and content type if found, or empty values if not. // Returns the content hash and content type if found, or empty values
// if not. Hits touch the blob's LRU timestamp; a disabled cache always
// reports no cached source.
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) { func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) {
if c.disabled {
return "", "", nil
}
var hashStr, contentType string var hashStr, contentType string
err := c.db.QueryRowContext(ctx, ` err := c.db.QueryRowContext(ctx, `
@@ -204,6 +325,8 @@ func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHas
return "", "", nil return "", "", nil
} }
c.touchSourceContent(ctx, contentHash)
return contentHash, contentType, nil return contentHash, contentType, nil
} }
@@ -276,6 +399,10 @@ func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int
// GetSourceContent returns a reader for cached source content by its hash. // GetSourceContent returns a reader for cached source content by its hash.
func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) { func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) {
if c.disabled {
return nil, ErrNotFound
}
return c.srcContent.Load(contentHash) return c.srcContent.Load(contentHash)
} }

View File

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

View File

@@ -0,0 +1,741 @@
package imgcache
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
// DefaultEvictionInterval is how often the background evictor checks
// cache usage against the configured limit, in addition to the
// write-pressure wakeups triggered by stores.
const DefaultEvictionInterval = 5 * time.Minute
// evictionBatchSize is how many LRU candidates of each class (variants
// and source blobs) one eviction pass fetches from the database.
const evictionBatchSize = 100
// staleTempFileAge is how old an orphaned temp file (left behind by a
// crashed write) must be before reconciliation removes it. Fresh temp
// files may still belong to an in-flight store.
const staleTempFileAge = time.Hour
// sqliteTimestampLayout matches SQLite's CURRENT_TIMESTAMP format, so
// timestamps written by reconciliation order correctly against ones
// written by the hot path.
const sqliteTimestampLayout = "2006-01-02 15:04:05"
// tempFilePrefix is the prefix os.CreateTemp uses for in-flight cache
// writes (".tmp-*" patterns in the storage layer).
const tempFilePrefix = ".tmp-"
// variantMetaSuffix is the sidecar suffix VariantStorage writes next
// to each variant file.
const variantMetaSuffix = ".meta"
// fallbackContentType is recorded when a reconciled variant file has
// no readable .meta sidecar.
const fallbackContentType = "application/octet-stream"
// UsageBytes returns the total number of bytes of cache content
// tracked in the database (source content blobs plus processed
// variants). It never scans the cache directories.
func (c *Cache) UsageBytes(ctx context.Context) (int64, error) {
if c.disabled {
return 0, nil
}
var total int64
err := c.db.QueryRowContext(ctx, `
SELECT (SELECT COALESCE(SUM(size_bytes), 0) FROM source_content)
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM variant_content)
`).Scan(&total)
if err != nil {
return 0, fmt.Errorf("failed to compute cache usage: %w", err)
}
return total, nil
}
// evictionCandidate is one LRU eviction victim candidate: either a
// processed variant (isVariant true, identified by cacheKey) or a
// source content blob (identified by contentHash).
type evictionCandidate struct {
isVariant bool
cacheKey VariantKey
contentHash ContentHash
sizeBytes int64
lastAccessedAt string
}
// EvictToLimit evicts least-recently-used cache entries until total
// tracked usage is at or below the configured MaxBytes limit. It is a
// no-op when the cache is disabled or no limit is configured.
func (c *Cache) EvictToLimit(ctx context.Context) error {
if c.disabled || c.config.MaxBytes <= 0 {
return nil
}
for {
usage, err := c.UsageBytes(ctx)
if err != nil {
return err
}
if usage <= c.config.MaxBytes {
return nil
}
freed, err := c.evictBatch(ctx, usage-c.config.MaxBytes)
if err != nil {
return err
}
if freed == 0 {
c.log.Warn("cache eviction made no progress",
"usage_bytes", usage,
"cache_max_bytes", c.config.MaxBytes,
)
return nil
}
c.log.Info("evicted cache content",
"freed_bytes", freed,
"usage_bytes", usage-freed,
"cache_max_bytes", c.config.MaxBytes,
)
}
}
// evictBatch fetches one batch of LRU candidates across variants and
// source blobs and evicts them oldest-first until excessBytes are
// freed or the batch is exhausted. It returns the bytes freed.
func (c *Cache) evictBatch(ctx context.Context, excessBytes int64) (int64, error) {
candidates, err := c.evictionCandidates(ctx)
if err != nil {
return 0, err
}
var freed int64
for _, candidate := range candidates {
if freed >= excessBytes {
break
}
if err := c.evictCandidate(ctx, candidate); err != nil {
c.log.Warn("failed to evict cache entry",
"cache_key", candidate.cacheKey,
"content_hash", candidate.contentHash,
"error", err,
)
continue
}
freed += candidate.sizeBytes
}
return freed, nil
}
// evictCandidate removes a single eviction victim.
func (c *Cache) evictCandidate(ctx context.Context, candidate evictionCandidate) error {
if candidate.isVariant {
return c.evictVariant(ctx, candidate.cacheKey)
}
return c.evictSourceBlob(ctx, candidate.contentHash)
}
// evictionCandidates returns up to evictionBatchSize variants and
// evictionBatchSize source blobs, merged into a single list ordered by
// last access time (oldest first).
func (c *Cache) evictionCandidates(ctx context.Context) ([]evictionCandidate, error) {
variants, err := c.variantCandidates(ctx)
if err != nil {
return nil, err
}
sources, err := c.sourceCandidates(ctx)
if err != nil {
return nil, err
}
// Merge the two lists, each already sorted oldest-first. SQLite
// CURRENT_TIMESTAMP strings compare correctly lexicographically.
merged := make([]evictionCandidate, 0, len(variants)+len(sources))
for len(variants) > 0 && len(sources) > 0 {
if variants[0].lastAccessedAt <= sources[0].lastAccessedAt {
merged = append(merged, variants[0])
variants = variants[1:]
} else {
merged = append(merged, sources[0])
sources = sources[1:]
}
}
merged = append(merged, variants...)
merged = append(merged, sources...)
return merged, nil
}
// variantCandidates returns the least recently used variants.
func (c *Cache) variantCandidates(ctx context.Context) ([]evictionCandidate, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT cache_key, size_bytes, last_accessed_at
FROM variant_content
ORDER BY last_accessed_at ASC, cache_key ASC
LIMIT ?
`, evictionBatchSize)
if err != nil {
return nil, fmt.Errorf("failed to query variant eviction candidates: %w", err)
}
defer func() { _ = rows.Close() }()
var candidates []evictionCandidate
for rows.Next() {
candidate := evictionCandidate{isVariant: true}
var key string
if err := rows.Scan(&key, &candidate.sizeBytes, &candidate.lastAccessedAt); err != nil {
return nil, fmt.Errorf("failed to scan variant candidate: %w", err)
}
candidate.cacheKey = VariantKey(key)
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("variant candidate iteration failed: %w", err)
}
return candidates, nil
}
// sourceCandidates returns the least recently used source blobs. Rows
// written before the LRU column existed fall back to fetched_at.
func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT content_hash, size_bytes,
COALESCE(last_accessed_at, fetched_at, '1970-01-01 00:00:00') AS lru
FROM source_content
ORDER BY lru ASC, content_hash ASC
LIMIT ?
`, evictionBatchSize)
if err != nil {
return nil, fmt.Errorf("failed to query source eviction candidates: %w", err)
}
defer func() { _ = rows.Close() }()
var candidates []evictionCandidate
for rows.Next() {
var candidate evictionCandidate
var hash string
if err := rows.Scan(&hash, &candidate.sizeBytes, &candidate.lastAccessedAt); err != nil {
return nil, fmt.Errorf("failed to scan source candidate: %w", err)
}
candidate.contentHash = ContentHash(hash)
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("source candidate iteration failed: %w", err)
}
return candidates, nil
}
// evictVariant removes one variant: accounting row first, then the
// content and .meta files, so the database never references a deleted
// file.
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
_, err := c.db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
if err != nil {
return fmt.Errorf("failed to delete variant accounting row: %w", err)
}
if err := c.variants.DeleteWithMeta(cacheKey); err != nil {
return err
}
return nil
}
// sourceReference identifies one source_metadata row's JSON sidecar.
type sourceReference struct {
host string
pathHash PathHash
}
// evictSourceBlob removes one source content blob. All source_metadata
// rows referencing the blob are deleted together with its
// source_content row in a single transaction BEFORE the file is
// unlinked: a blob referenced by multiple source paths is only ever
// removed together with all of its references, and database rows never
// point at deleted files. The JSON metadata sidecars for the removed
// rows are deleted afterwards.
func (c *Cache) evictSourceBlob(ctx context.Context, contentHash ContentHash) error {
references, err := c.sourceReferences(ctx, contentHash)
if err != nil {
return err
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin eviction transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash)); err != nil {
return fmt.Errorf("failed to delete source metadata rows: %w", err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM source_content WHERE content_hash = ?`, string(contentHash)); err != nil {
return fmt.Errorf("failed to delete source content row: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit eviction transaction: %w", err)
}
// Only after the rows are gone may the files be removed.
for _, reference := range references {
if err := c.srcMetadata.Delete(reference.host, reference.pathHash); err != nil {
c.log.Warn("failed to delete metadata sidecar",
"host", reference.host, "path_hash", reference.pathHash, "error", err)
}
}
if err := c.srcContent.Delete(contentHash); err != nil {
return err
}
return nil
}
// sourceReferences lists the metadata sidecar locations of every
// source_metadata row referencing the given blob.
func (c *Cache) sourceReferences(
ctx context.Context, contentHash ContentHash,
) ([]sourceReference, error) {
rows, err := c.db.QueryContext(ctx, `
SELECT source_host, path_hash FROM source_metadata WHERE content_hash = ?
`, string(contentHash))
if err != nil {
return nil, fmt.Errorf("failed to query source references: %w", err)
}
defer func() { _ = rows.Close() }()
var references []sourceReference
for rows.Next() {
var reference sourceReference
var pathHash string
if err := rows.Scan(&reference.host, &pathHash); err != nil {
return nil, fmt.Errorf("failed to scan source reference: %w", err)
}
reference.pathHash = PathHash(pathHash)
references = append(references, reference)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("source reference iteration failed: %w", err)
}
return references, nil
}
// notifyWritePressure wakes the background evictor after a store, so
// eviction under write pressure happens promptly without blocking the
// storing request. The notification channel has capacity one and drops
// when a wakeup is already pending.
func (c *Cache) notifyWritePressure() {
if c.disabled || c.config.MaxBytes <= 0 {
return
}
select {
case c.evictionPressure <- struct{}{}:
default:
}
}
// StartEviction launches the background eviction goroutine, which
// reconciles the database accounting with the cache directories once
// at startup and then evicts to the configured limit on the given
// periodic interval and on write-pressure notifications. It is a
// no-op on a disabled cache or when already started.
func (c *Cache) StartEviction(interval time.Duration) {
if c.disabled || c.evictionStarted {
return
}
c.evictionStarted = true
go c.evictionLoop(interval)
}
// StopEviction stops the background eviction goroutine and waits for
// it to exit. It is safe to call when eviction was never started, and
// safe to call more than once.
func (c *Cache) StopEviction() {
if !c.evictionStarted {
return
}
c.evictionStopOnce.Do(func() {
close(c.evictionStop)
<-c.evictionDone
})
}
// evictionLoop is the body of the background eviction goroutine.
func (c *Cache) evictionLoop(interval time.Duration) {
defer close(c.evictionDone)
ctx := context.Background()
if err := c.reconcileAccounting(ctx); err != nil {
c.log.Warn("cache accounting reconciliation failed", "error", err)
}
c.runEvictionPass(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.evictionStop:
return
case <-ticker.C:
case <-c.evictionPressure:
}
c.runEvictionPass(ctx)
}
}
// runEvictionPass runs one eviction pass, logging failures instead of
// propagating them (the loop must keep running).
func (c *Cache) runEvictionPass(ctx context.Context) {
if err := c.EvictToLimit(ctx); err != nil {
c.log.Warn("cache eviction pass failed", "error", err)
}
}
// reconcileAccounting synchronizes the database size accounting with
// the actual contents of the cache directories. It runs once when the
// background evictor starts, off the request hot path: it adopts
// variant files that predate the accounting table, drops accounting
// rows whose files are missing, removes source blob files the database
// does not know (and rows whose files are gone), and sweeps stale temp
// files left behind by crashed writes.
func (c *Cache) reconcileAccounting(ctx context.Context) error {
if c.disabled {
return nil
}
if err := c.reconcileVariantFiles(ctx); err != nil {
return err
}
if err := c.reconcileVariantRows(ctx); err != nil {
return err
}
if err := c.reconcileSourceFiles(ctx); err != nil {
return err
}
if err := c.reconcileSourceRows(ctx); err != nil {
return err
}
return nil
}
// reconcileVariantFiles walks the variant storage directory, adopting
// files without accounting rows and sweeping stale temp files.
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
return filepath.WalkDir(c.variants.baseDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
name := entry.Name()
if strings.HasPrefix(name, tempFilePrefix) {
c.sweepStaleTempFile(path, entry)
return nil
}
if strings.HasSuffix(name, variantMetaSuffix) {
return nil
}
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
})
}
// adoptVariantFile inserts an accounting row for a variant file that
// has none, using the file's size and modification time.
func (c *Cache) adoptVariantFile(
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
) error {
var rowExists int
err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(cacheKey),
).Scan(&rowExists)
if err != nil {
return fmt.Errorf("failed to check variant accounting row: %w", err)
}
if rowExists > 0 {
return nil
}
info, err := entry.Info()
if err != nil {
return fmt.Errorf("failed to stat variant file: %w", err)
}
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
contentType := c.variantContentTypeFromSidecar(path)
_, err = c.db.ExecContext(ctx, `
INSERT INTO variant_content
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
VALUES (?, ?, ?, ?, ?)
`, string(cacheKey), info.Size(), contentType, modTime, modTime)
if err != nil {
return fmt.Errorf("failed to adopt variant file into accounting: %w", err)
}
c.log.Info("adopted untracked variant file into size accounting",
"cache_key", cacheKey, "size_bytes", info.Size())
return nil
}
// variantContentTypeFromSidecar reads the content type from a variant
// .meta sidecar, falling back to application/octet-stream.
func (c *Cache) variantContentTypeFromSidecar(variantPath string) string {
metaData, err := os.ReadFile(variantPath + variantMetaSuffix) //nolint:gosec // path from cache walk
if err != nil {
return fallbackContentType
}
var meta VariantMeta
if json.Unmarshal(metaData, &meta) != nil || meta.ContentType == "" {
return fallbackContentType
}
return meta.ContentType
}
// reconcileVariantRows drops accounting rows whose variant files are
// missing, so the database never references deleted content.
func (c *Cache) reconcileVariantRows(ctx context.Context) error {
keys, err := c.allVariantKeys(ctx)
if err != nil {
return err
}
for _, key := range keys {
if c.variants.Exists(key) {
continue
}
if _, err := c.db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, string(key)); err != nil {
return fmt.Errorf("failed to drop stale variant accounting row: %w", err)
}
c.log.Info("dropped accounting row for missing variant file", "cache_key", key)
}
return nil
}
// allVariantKeys returns every tracked variant cache key.
func (c *Cache) allVariantKeys(ctx context.Context) ([]VariantKey, error) {
rows, err := c.db.QueryContext(ctx, `SELECT cache_key FROM variant_content`)
if err != nil {
return nil, fmt.Errorf("failed to query variant keys: %w", err)
}
defer func() { _ = rows.Close() }()
var keys []VariantKey
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, fmt.Errorf("failed to scan variant key: %w", err)
}
keys = append(keys, VariantKey(key))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("variant key iteration failed: %w", err)
}
return keys, nil
}
// reconcileSourceFiles walks the source content directory, removing
// blob files the database does not track (they are unreachable: source
// lookups always go through source_metadata) and sweeping stale temp
// files.
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
return filepath.WalkDir(c.srcContent.baseDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
name := entry.Name()
if strings.HasPrefix(name, tempFilePrefix) {
c.sweepStaleTempFile(path, entry)
return nil
}
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
})
}
// removeUntrackedSourceFile deletes a source blob file that has no
// source_content row. Any source_metadata rows referencing the hash
// are removed first so no row ever points at a deleted file.
func (c *Cache) removeUntrackedSourceFile(
ctx context.Context, path string, contentHash ContentHash,
) error {
var rowExists int
err := c.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(contentHash),
).Scan(&rowExists)
if err != nil {
return fmt.Errorf("failed to check source content row: %w", err)
}
if rowExists > 0 {
return nil
}
if _, err := c.db.ExecContext(ctx,
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash)); err != nil {
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
}
//nolint:gosec // G703: path comes from walking our own cache directory
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove untracked source file: %w", err)
}
c.log.Info("removed untracked source content file", "content_hash", contentHash)
return nil
}
// reconcileSourceRows removes source_content rows (and their metadata
// references and sidecars) whose blob files are missing on disk.
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
hashes, err := c.allSourceContentHashes(ctx)
if err != nil {
return err
}
for _, hash := range hashes {
if c.srcContent.Exists(hash) {
continue
}
// The blob file is already gone; evictSourceBlob removes the
// rows and sidecars and tolerates the missing file.
if err := c.evictSourceBlob(ctx, hash); err != nil {
return err
}
c.log.Info("dropped rows for missing source content file", "content_hash", hash)
}
return nil
}
// allSourceContentHashes returns every tracked source content hash.
func (c *Cache) allSourceContentHashes(ctx context.Context) ([]ContentHash, error) {
rows, err := c.db.QueryContext(ctx, `SELECT content_hash FROM source_content`)
if err != nil {
return nil, fmt.Errorf("failed to query source content hashes: %w", err)
}
defer func() { _ = rows.Close() }()
var hashes []ContentHash
for rows.Next() {
var hash string
if err := rows.Scan(&hash); err != nil {
return nil, fmt.Errorf("failed to scan content hash: %w", err)
}
hashes = append(hashes, ContentHash(hash))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("content hash iteration failed: %w", err)
}
return hashes, nil
}
// sweepStaleTempFile removes a temp file left behind by a crashed
// write once it is old enough that no in-flight store can own it.
func (c *Cache) sweepStaleTempFile(path string, entry fs.DirEntry) {
info, err := entry.Info()
if err != nil {
return
}
if time.Since(info.ModTime()) < staleTempFileAge {
return
}
//nolint:gosec // G703: path comes from walking our own cache directory
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
c.log.Warn("failed to remove stale temp file", "path", path, "error", err)
return
}
c.log.Info("removed stale temp file", "path", path)
}

View File

@@ -0,0 +1,669 @@
package imgcache
import (
"bytes"
"context"
"database/sql"
"io/fs"
"os"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
"sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
)
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
// produces, so injected timestamps compare correctly against ones the
// implementation writes.
const sqliteTimestampFormat = "2006-01-02 15:04:05"
// evictionTestDB creates an in-memory SQLite database with the real
// production schema, limited to a single connection so the background
// eviction goroutine shares the same in-memory database as the test.
func evictionTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
db.SetMaxOpenConns(1)
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
t.Fatalf("failed to apply migrations: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// newEvictionTestCache creates a Cache backed by a temp directory and
// an in-memory database, with the given size limit.
func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
t.Helper()
tmpDir := t.TempDir()
db := evictionTestDB(t)
// maxBytes zero mirrors the production mapping of
// cache_max_bytes: 0 (handlers sets DisableDiskCache); at the
// CacheConfig layer itself a zero MaxBytes means "no limit" for
// backwards compatibility with existing fixtures.
cache, err := NewCache(db, CacheConfig{
StateDir: tmpDir,
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
MaxBytes: maxBytes,
DisableDiskCache: maxBytes == 0,
})
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
return cache, tmpDir
}
// storeEvictionTestSource stores content as a fetched source for
// host/path and returns the resulting content hash.
func storeEvictionTestSource(
t *testing.T, cache *Cache, host, path string, content []byte,
) ContentHash {
t.Helper()
req := &ImageRequest{
SourceHost: host,
SourcePath: path,
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result := &httpfetcher.FetchResult{
StatusCode: 200,
ContentType: "image/jpeg",
ContentLength: int64(len(content)),
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
}
hash, err := cache.StoreSource(context.Background(), req, bytes.NewReader(content), result)
if err != nil {
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
}
return hash
}
// storeEvictionTestVariant stores content as a processed variant under
// the given cache key.
func storeEvictionTestVariant(t *testing.T, cache *Cache, key VariantKey, content []byte) {
t.Helper()
if err := cache.StoreVariant(key, bytes.NewReader(content), "image/webp"); err != nil {
t.Fatalf("StoreVariant(%s) failed: %v", key, err)
}
}
// setVariantLastAccessed backdates the last access time of a tracked
// variant, to make LRU ordering deterministic in tests.
func setVariantLastAccessed(t *testing.T, cache *Cache, key VariantKey, when time.Time) {
t.Helper()
res, err := cache.db.Exec(
`UPDATE variant_content SET last_accessed_at = ? WHERE cache_key = ?`,
when.UTC().Format(sqliteTimestampFormat), string(key),
)
if err != nil {
t.Fatalf("failed to set variant last_accessed_at: %v", err)
}
affected, err := res.RowsAffected()
if err != nil {
t.Fatalf("failed to read affected rows: %v", err)
}
if affected != 1 {
t.Fatalf("variant %s has no accounting row (affected=%d); "+
"stores must track variants in the database", key, affected)
}
}
// setSourceLastAccessed backdates the last access time of a tracked
// source content blob.
func setSourceLastAccessed(t *testing.T, cache *Cache, hash ContentHash, when time.Time) {
t.Helper()
res, err := cache.db.Exec(
`UPDATE source_content SET last_accessed_at = ? WHERE content_hash = ?`,
when.UTC().Format(sqliteTimestampFormat), string(hash),
)
if err != nil {
t.Fatalf("failed to set source last_accessed_at: %v", err)
}
affected, err := res.RowsAffected()
if err != nil {
t.Fatalf("failed to read affected rows: %v", err)
}
if affected != 1 {
t.Fatalf("source %s has no accounting row (affected=%d)", hash, affected)
}
}
// countRows returns the number of rows the given query yields.
func countRows(t *testing.T, cache *Cache, query string, args ...interface{}) int {
t.Helper()
var n int
if err := cache.db.QueryRow(query, args...).Scan(&n); err != nil {
t.Fatalf("count query %q failed: %v", query, err)
}
return n
}
// assertNoDanglingReferences verifies the core eviction invariant:
// every database row that references cache content on disk points at a
// file that actually exists.
func assertNoDanglingReferences(t *testing.T, cache *Cache) {
t.Helper()
rows, err := cache.db.Query(
`SELECT content_hash FROM source_metadata
WHERE content_hash IS NOT NULL AND content_hash != ''`,
)
if err != nil {
t.Fatalf("failed to query source_metadata: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var hash string
if err := rows.Scan(&hash); err != nil {
t.Fatalf("failed to scan content_hash: %v", err)
}
if !cache.srcContent.Exists(ContentHash(hash)) {
t.Errorf("source_metadata references content %s but the file is missing", hash)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("source_metadata iteration failed: %v", err)
}
variantRows, err := cache.db.Query(`SELECT cache_key FROM variant_content`)
if err != nil {
t.Fatalf("failed to query variant_content: %v", err)
}
defer func() { _ = variantRows.Close() }()
for variantRows.Next() {
var key string
if err := variantRows.Scan(&key); err != nil {
t.Fatalf("failed to scan cache_key: %v", err)
}
if !cache.variants.Exists(VariantKey(key)) {
t.Errorf("variant_content references key %s but the file is missing", key)
}
}
if err := variantRows.Err(); err != nil {
t.Fatalf("variant_content iteration failed: %v", err)
}
}
// waitForUsageAtOrBelow polls UsageBytes until it reaches limit or the
// timeout expires, returning the last observed usage.
func waitForUsageAtOrBelow(t *testing.T, cache *Cache, limit int64, timeout time.Duration) int64 {
t.Helper()
deadline := time.Now().Add(timeout)
var usage int64
for time.Now().Before(deadline) {
var err error
usage, err = cache.UsageBytes(context.Background())
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage <= limit {
return usage
}
time.Sleep(25 * time.Millisecond)
}
return usage
}
func TestUsageBytesAccountsSourceAndVariantBytes(t *testing.T) {
cache, _ := newEvictionTestCache(t, 1<<30)
storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg",
bytes.Repeat([]byte{0xAA}, 1000))
storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg",
bytes.Repeat([]byte{0xAB}, 2000))
storeEvictionTestVariant(t, cache, "aabbccdd0001", bytes.Repeat([]byte{0xAC}, 500))
storeEvictionTestVariant(t, cache, "aabbccdd0002", bytes.Repeat([]byte{0xAD}, 250))
usage, err := cache.UsageBytes(context.Background())
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage != 3750 {
t.Errorf("UsageBytes = %d, want 3750 (1000+2000+500+250)", usage)
}
}
func TestUsageBytesCountsMultiReferencedBlobOnce(t *testing.T) {
cache, _ := newEvictionTestCache(t, 1<<30)
content := bytes.Repeat([]byte{0xCC}, 1200)
hashOne := storeEvictionTestSource(t, cache, "src.example.com", "/one.jpg", content)
hashTwo := storeEvictionTestSource(t, cache, "src.example.com", "/two.jpg", content)
if hashOne != hashTwo {
t.Fatalf("identical content produced different hashes: %s vs %s", hashOne, hashTwo)
}
usage, err := cache.UsageBytes(context.Background())
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage != 1200 {
t.Errorf("UsageBytes = %d, want 1200 (deduplicated blob counted once)", usage)
}
}
func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
const limit = 3000
cache, _ := newEvictionTestCache(t, limit)
now := time.Now()
keys := []VariantKey{"aabbccdd0001", "aabbccdd0002", "aabbccdd0003", "aabbccdd0004"}
fills := []byte{0x01, 0x02, 0x03, 0x04}
ages := []time.Duration{4 * time.Hour, 3 * time.Hour, 2 * time.Hour, 1 * time.Hour}
for i, key := range keys {
storeEvictionTestVariant(t, cache, key, bytes.Repeat([]byte{fills[i]}, 1000))
setVariantLastAccessed(t, cache, key, now.Add(-ages[i]))
}
if err := cache.EvictToLimit(context.Background()); err != nil {
t.Fatalf("EvictToLimit failed: %v", err)
}
usage, err := cache.UsageBytes(context.Background())
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage > limit {
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
}
if cache.variants.Exists(keys[0]) {
t.Errorf("least recently used variant %s must be evicted", keys[0])
}
if n := countRows(t, cache,
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(keys[0]),
); n != 0 {
t.Errorf("evicted variant %s still has %d accounting rows", keys[0], n)
}
for _, key := range keys[1:] {
if !cache.variants.Exists(key) {
t.Errorf("more recently used variant %s must survive eviction", key)
}
}
assertNoDanglingReferences(t, cache)
}
func TestEvictionRemovesMultiReferencedBlobTogetherWithAllReferences(t *testing.T) {
const limit = 1000
cache, _ := newEvictionTestCache(t, limit)
now := time.Now()
// One 800-byte blob referenced by two source paths.
sharedContent := bytes.Repeat([]byte{0xDD}, 800)
sharedHash := storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", sharedContent)
if h := storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", sharedContent); h != sharedHash {
t.Fatalf("identical content produced different hashes: %s vs %s", h, sharedHash)
}
// A newer 600-byte blob referenced by one source path.
recentHash := storeEvictionTestSource(t, cache, "src.example.com", "/c.jpg",
bytes.Repeat([]byte{0xEE}, 600))
setSourceLastAccessed(t, cache, sharedHash, now.Add(-2*time.Hour))
setSourceLastAccessed(t, cache, recentHash, now.Add(-time.Minute))
if err := cache.EvictToLimit(context.Background()); err != nil {
t.Fatalf("EvictToLimit failed: %v", err)
}
usage, err := cache.UsageBytes(context.Background())
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage > limit {
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
}
// The multi-referenced blob must be gone from disk, from
// source_content, and from BOTH source_metadata rows: references
// are removed together with the blob, never left dangling.
if cache.srcContent.Exists(sharedHash) {
t.Errorf("evicted blob %s still exists on disk", sharedHash)
}
if n := countRows(t, cache,
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(sharedHash),
); n != 0 {
t.Errorf("evicted blob %s still has %d source_content rows", sharedHash, n)
}
if n := countRows(t, cache,
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(sharedHash),
); n != 0 {
t.Errorf("evicted blob %s still has %d source_metadata references", sharedHash, n)
}
// The JSON metadata sidecars for both referencing paths must be
// removed along with the rows.
for _, path := range []string{"/a.jpg", "/b.jpg"} {
pathHash := HashPath(path + "?")
if cache.srcMetadata.Exists("src.example.com", pathHash) {
t.Errorf("metadata sidecar for %s must be removed with its row", path)
}
}
// The more recently used blob survives fully intact.
if !cache.srcContent.Exists(recentHash) {
t.Errorf("recently used blob %s must survive eviction", recentHash)
}
if n := countRows(t, cache,
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(recentHash),
); n != 1 {
t.Errorf("recently used blob %s has %d source_metadata rows, want 1", recentHash, n)
}
assertNoDanglingReferences(t, cache)
}
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
cache, _ := newEvictionTestCache(t, 1<<30)
content := bytes.Repeat([]byte{0xDF}, 800)
hash := storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content)
if h := storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content); h != hash {
t.Fatalf("identical content produced different hashes: %s vs %s", h, hash)
}
storeEvictionTestVariant(t, cache, "aabbccdd0001", bytes.Repeat([]byte{0xE0}, 500))
if err := cache.EvictToLimit(context.Background()); err != nil {
t.Fatalf("EvictToLimit failed: %v", err)
}
if !cache.srcContent.Exists(hash) {
t.Errorf("blob %s must not be evicted while usage is under the limit", hash)
}
if n := countRows(t, cache,
`SELECT COUNT(*) FROM source_metadata WHERE content_hash = ?`, string(hash),
); n != 2 {
t.Errorf("blob %s has %d source_metadata rows, want 2", hash, n)
}
if !cache.variants.Exists("aabbccdd0001") {
t.Error("variant must not be evicted while usage is under the limit")
}
assertNoDanglingReferences(t, cache)
}
func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
cache, tmpDir := newEvictionTestCache(t, 0)
ctx := context.Background()
req := &ImageRequest{
SourceHost: "src.example.com",
SourcePath: "/a.jpg",
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Writes are no-ops that report success.
if err := cache.StoreVariant(CacheKey(req), bytes.NewReader([]byte("data")), "image/webp"); err != nil {
t.Fatalf("StoreVariant on disabled cache must be a no-op, got error: %v", err)
}
result := &httpfetcher.FetchResult{
StatusCode: 200,
ContentType: "image/jpeg",
ContentLength: 4,
Headers: map[string][]string{},
}
hash, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
if err != nil {
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
}
if hash != "" {
t.Errorf("StoreSource on disabled cache returned hash %q, want empty", hash)
}
// Reads always miss.
lookup, err := cache.Lookup(ctx, req)
if err != nil {
t.Fatalf("Lookup on disabled cache failed: %v", err)
}
if lookup.Hit {
t.Error("Lookup on disabled cache must always miss")
}
srcHash, srcType, err := cache.LookupSource(ctx, req)
if err != nil {
t.Fatalf("LookupSource on disabled cache failed: %v", err)
}
if srcHash != "" || srcType != "" {
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty", srcHash, srcType)
}
// Nothing is tracked and nothing is written to disk.
usage, err := cache.UsageBytes(ctx)
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage != 0 {
t.Errorf("UsageBytes on disabled cache = %d, want 0", usage)
}
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 {
t.Errorf("disabled cache wrote %d source_content rows, want 0", n)
}
if n := countRows(t, cache, `SELECT COUNT(*) FROM source_metadata`); n != 0 {
t.Errorf("disabled cache wrote %d source_metadata rows, want 0", n)
}
if _, err := os.Stat(filepath.Join(tmpDir, "cache")); !os.IsNotExist(err) {
t.Errorf("disabled cache must not create the cache directory tree (stat err=%v)", err)
}
var foundFiles []string
walkErr := filepath.WalkDir(tmpDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
foundFiles = append(foundFiles, path)
}
return nil
})
if walkErr != nil {
t.Fatalf("failed to walk state dir: %v", walkErr)
}
if len(foundFiles) != 0 {
t.Errorf("disabled cache wrote files to disk: %v", foundFiles)
}
}
func TestEvictionRunsUnderWritePressure(t *testing.T) {
const limit = 1500
cache, _ := newEvictionTestCache(t, limit)
// An interval far longer than the test ensures only write
// pressure can trigger eviction here.
cache.StartEviction(time.Hour)
defer cache.StopEviction()
keys := []VariantKey{"aabbccdd0001", "aabbccdd0002", "aabbccdd0003"}
fills := []byte{0x11, 0x12, 0x13}
for i, key := range keys {
storeEvictionTestVariant(t, cache, key, bytes.Repeat([]byte{fills[i]}, 1000))
}
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
if usage > limit {
t.Errorf("write pressure did not trigger eviction: usage = %d, want <= %d",
usage, limit)
}
assertNoDanglingReferences(t, cache)
}
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
const limit = 1500
cache, _ := newEvictionTestCache(t, limit)
// Start the evictor while the cache is empty, then create tracked
// over-limit state WITHOUT going through the store methods, so no
// write-pressure notification fires and only the periodic ticker
// can trigger eviction.
cache.StartEviction(100 * time.Millisecond)
defer cache.StopEviction()
keys := []VariantKey{"aabbccdd0001", "aabbccdd0002", "aabbccdd0003"}
fills := []byte{0x21, 0x22, 0x23}
for i, key := range keys {
content := bytes.Repeat([]byte{fills[i]}, 1000)
if _, err := cache.variants.Store(key, bytes.NewReader(content), "image/webp"); err != nil {
t.Fatalf("failed to store variant file: %v", err)
}
if _, err := cache.db.Exec(
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
VALUES (?, ?, ?)`,
string(key), len(content), "image/webp",
); err != nil {
t.Fatalf("failed to insert variant accounting row: %v", err)
}
}
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
if usage > limit {
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
usage, limit)
}
assertNoDanglingReferences(t, cache)
}
func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
cache, _ := newEvictionTestCache(t, 1<<30)
// An untracked variant file on disk (e.g. written before this
// feature existed) must be adopted into the accounting.
untracked := bytes.Repeat([]byte{0x31}, 1000)
if _, err := cache.variants.Store("aabbccdd0001", bytes.NewReader(untracked), "image/webp"); err != nil {
t.Fatalf("failed to store untracked variant file: %v", err)
}
// An accounting row whose file is missing must be dropped.
if _, err := cache.db.Exec(
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
VALUES (?, ?, ?)`,
"deadbeef0001", 700, "image/webp",
); err != nil {
t.Fatalf("failed to insert stale variant accounting row: %v", err)
}
cache.StartEviction(time.Hour)
defer cache.StopEviction()
deadline := time.Now().Add(5 * time.Second)
var usage int64
for time.Now().Before(deadline) {
var err error
usage, err = cache.UsageBytes(context.Background())
if err != nil {
t.Fatalf("UsageBytes failed: %v", err)
}
if usage == 1000 {
break
}
time.Sleep(25 * time.Millisecond)
}
if usage != 1000 {
t.Errorf("usage after reconciliation = %d, want 1000 "+
"(untracked file adopted, stale row dropped)", usage)
}
if n := countRows(t, cache,
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, "aabbccdd0001",
); n != 1 {
t.Errorf("untracked variant file was not adopted into accounting (rows=%d)", n)
}
if n := countRows(t, cache,
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, "deadbeef0001",
); n != 0 {
t.Errorf("stale accounting row without a file was not dropped (rows=%d)", n)
}
}

View File

@@ -75,7 +75,7 @@ type ImageRequest struct {
Quality int Quality int
// FitMode is how to fit the image into requested dimensions // FitMode is how to fit the image into requested dimensions
FitMode FitMode FitMode FitMode
// Signature is the HMAC signature for non-whitelisted hosts // Signature is the HMAC signature for non-allowlisted hosts
Signature string Signature string
// Expires is the signature expiration timestamp // Expires is the signature expiration timestamp
Expires time.Time Expires time.Time
@@ -163,70 +163,10 @@ type SignatureValidator interface {
Generate(req *ImageRequest) string Generate(req *ImageRequest) string
} }
// Whitelist checks if a URL is whitelisted (no signature required) // Allowlist checks if a URL is allowlisted (no signature required)
type Whitelist interface { type Allowlist interface {
// IsWhitelisted returns true if the URL doesn't require a signature // IsAllowlisted returns true if the URL doesn't require a signature
IsWhitelisted(u *url.URL) bool IsAllowlisted(u *url.URL) bool
}
// Fetcher fetches images from upstream origins
type Fetcher interface {
// Fetch retrieves an image from the origin
Fetch(ctx context.Context, url string) (*FetchResult, error)
}
// FetchResult contains the result of fetching from upstream
type FetchResult struct {
// Content is the raw image data
Content io.ReadCloser
// ContentLength is the size in bytes (-1 if unknown)
ContentLength int64
// ContentType is the MIME type from upstream
ContentType string
// Headers contains all response headers from upstream
Headers map[string][]string
// StatusCode is the HTTP status code from upstream
StatusCode int
// FetchDurationMs is how long the fetch took in milliseconds
FetchDurationMs int64
// RemoteAddr is the IP:port of the upstream server
RemoteAddr string
// HTTPVersion is the protocol version (e.g., "1.1", "2.0")
HTTPVersion string
// TLSVersion is the TLS protocol version (e.g., "TLS 1.3")
TLSVersion string
// TLSCipherSuite is the negotiated cipher suite name
TLSCipherSuite string
}
// Processor handles image transformation (resize, format conversion)
type Processor interface {
// Process transforms an image according to the request
Process(ctx context.Context, input io.Reader, req *ImageRequest) (*ProcessResult, error)
// SupportedInputFormats returns MIME types this processor can read
SupportedInputFormats() []string
// SupportedOutputFormats returns formats this processor can write
SupportedOutputFormats() []ImageFormat
}
// ProcessResult contains the result of image processing
type ProcessResult struct {
// Content is the processed image data
Content io.ReadCloser
// ContentLength is the size in bytes
ContentLength int64
// ContentType is the MIME type of the output
ContentType string
// Width is the output image width
Width int
// Height is the output image height
Height int
// InputWidth is the original image width before processing
InputWidth int
// InputHeight is the original image height before processing
InputHeight int
// InputFormat is the detected input format (e.g., "jpeg", "png")
InputFormat string
} }
// Storage handles persistent storage of cached content // Storage handles persistent storage of cached content

View File

@@ -11,17 +11,23 @@ import (
"time" "time"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"sneak.berlin/go/pixa/internal/allowlist"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/magic"
"sneak.berlin/go/pixa/internal/signature"
) )
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor. // Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
type Service struct { type Service struct {
cache *Cache cache *Cache
fetcher Fetcher fetcher httpfetcher.Fetcher
processor Processor processor *imageprocessor.ImageProcessor
signer *Signer signer *signature.Signer
whitelist *HostWhitelist allowlist *allowlist.HostAllowList
log *slog.Logger log *slog.Logger
allowHTTP bool allowHTTP bool
maxResponseSize int64
} }
// ServiceConfig holds configuration for the image service. // ServiceConfig holds configuration for the image service.
@@ -29,13 +35,13 @@ type ServiceConfig struct {
// Cache is the cache instance // Cache is the cache instance
Cache *Cache Cache *Cache
// FetcherConfig configures the upstream fetcher (ignored if Fetcher is set) // FetcherConfig configures the upstream fetcher (ignored if Fetcher is set)
FetcherConfig *FetcherConfig FetcherConfig *httpfetcher.Config
// Fetcher is an optional custom fetcher (for testing) // Fetcher is an optional custom fetcher (for testing)
Fetcher Fetcher Fetcher httpfetcher.Fetcher
// SigningKey is the HMAC signing key (empty disables signing) // SigningKey is the HMAC signing key (empty disables signing)
SigningKey string SigningKey string
// Whitelist is the list of hosts that don't require signatures // Allowlist is the list of hosts that don't require signatures
Whitelist []string Allowlist []string
// Logger for logging // Logger for logging
Logger *slog.Logger Logger *slog.Logger
} }
@@ -50,19 +56,21 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
return nil, errors.New("signing key is required") return nil, errors.New("signing key is required")
} }
// Resolve fetcher config for defaults
fetcherCfg := cfg.FetcherConfig
if fetcherCfg == nil {
fetcherCfg = httpfetcher.DefaultConfig()
}
// Use custom fetcher if provided, otherwise create HTTP fetcher // Use custom fetcher if provided, otherwise create HTTP fetcher
var fetcher Fetcher var fetcher httpfetcher.Fetcher
if cfg.Fetcher != nil { if cfg.Fetcher != nil {
fetcher = cfg.Fetcher fetcher = cfg.Fetcher
} else { } else {
fetcherCfg := cfg.FetcherConfig fetcher = httpfetcher.New(fetcherCfg)
if fetcherCfg == nil {
fetcherCfg = DefaultFetcherConfig()
}
fetcher = NewHTTPFetcher(fetcherCfg)
} }
signer := NewSigner(cfg.SigningKey) signer := signature.New(cfg.SigningKey)
log := cfg.Logger log := cfg.Logger
if log == nil { if log == nil {
@@ -74,14 +82,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
allowHTTP = cfg.FetcherConfig.AllowHTTP allowHTTP = cfg.FetcherConfig.AllowHTTP
} }
maxResponseSize := fetcherCfg.MaxResponseSize
return &Service{ return &Service{
cache: cfg.Cache, cache: cfg.Cache,
fetcher: fetcher, fetcher: fetcher,
processor: NewImageProcessor(), processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
signer: signer, signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist), allowlist: allowlist.New(cfg.Allowlist),
log: log, log: log,
allowHTTP: allowHTTP, allowHTTP: allowHTTP,
maxResponseSize: maxResponseSize,
}, nil }, nil
} }
@@ -104,7 +115,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
"path", req.SourcePath, "path", req.SourcePath,
) )
return nil, fmt.Errorf("%w: %w", ErrUpstreamError, ErrNegativeCached) return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
} }
// Check variant cache first (disk only, no DB) // Check variant cache first (disk only, no DB)
@@ -146,6 +157,40 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
return response, nil return response, nil
} }
// loadCachedSource attempts to load source content from cache, returning nil
// if the cached data is unavailable or exceeds maxResponseSize.
func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
reader, err := s.cache.GetSourceContent(contentHash)
if err != nil {
s.log.Warn("failed to load cached source, fetching", "error", err)
return nil
}
// Bound the read to maxResponseSize to prevent unbounded memory use
// from unexpectedly large cached files.
limited := io.LimitReader(reader, s.maxResponseSize+1)
data, err := io.ReadAll(limited)
_ = reader.Close()
if err != nil {
s.log.Warn("failed to read cached source, fetching", "error", err)
return nil
}
if int64(len(data)) > s.maxResponseSize {
s.log.Warn("cached source exceeds max response size, discarding",
"hash", contentHash,
"max_bytes", s.maxResponseSize,
)
return nil
}
return data
}
// processFromSourceOrFetch processes an image, using cached source content if available. // processFromSourceOrFetch processes an image, using cached source content if available.
func (s *Service) processFromSourceOrFetch( func (s *Service) processFromSourceOrFetch(
ctx context.Context, ctx context.Context,
@@ -162,22 +207,8 @@ func (s *Service) processFromSourceOrFetch(
var fetchBytes int64 var fetchBytes int64
if contentHash != "" { if contentHash != "" {
// We have cached source - load it
s.log.Debug("using cached source", "hash", contentHash) s.log.Debug("using cached source", "hash", contentHash)
sourceData = s.loadCachedSource(contentHash)
reader, err := s.cache.GetSourceContent(contentHash)
if err != nil {
s.log.Warn("failed to load cached source, fetching", "error", err)
// Fall through to fetch
} else {
sourceData, err = io.ReadAll(reader)
_ = reader.Close()
if err != nil {
s.log.Warn("failed to read cached source, fetching", "error", err)
// Fall through to fetch
}
}
} }
// Fetch from upstream if we don't have source data or it's empty // Fetch from upstream if we don't have source data or it's empty
@@ -249,7 +280,7 @@ func (s *Service) fetchAndProcess(
) )
// Validate magic bytes match content type // Validate magic bytes match content type
if err := ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil { if err := magic.ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil {
return nil, fmt.Errorf("content validation failed: %w", err) return nil, fmt.Errorf("content validation failed: %w", err)
} }
@@ -274,7 +305,14 @@ func (s *Service) processAndStore(
// Process the image // Process the image
processStart := time.Now() processStart := time.Now()
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req) processReq := &imageprocessor.Request{
Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height},
Format: imageprocessor.Format(req.Format),
Quality: req.Quality,
FitMode: imageprocessor.FitMode(req.FitMode),
}
processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), processReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("image processing failed: %w", err) return nil, fmt.Errorf("image processing failed: %w", err)
} }
@@ -347,7 +385,7 @@ func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
// ValidateRequest validates the request signature if required. // ValidateRequest validates the request signature if required.
func (s *Service) ValidateRequest(req *ImageRequest) error { func (s *Service) ValidateRequest(req *ImageRequest) error {
// Check if host is whitelisted (no signature required) // Check if host is allowed (no signature required)
sourceURL := req.SourceURL() sourceURL := req.SourceURL()
parsedURL, err := url.Parse(sourceURL) parsedURL, err := url.Parse(sourceURL)
@@ -355,12 +393,12 @@ func (s *Service) ValidateRequest(req *ImageRequest) error {
return fmt.Errorf("invalid source URL: %w", err) return fmt.Errorf("invalid source URL: %w", err)
} }
if s.whitelist.IsWhitelisted(parsedURL) { if s.allowlist.IsAllowed(parsedURL) {
return nil return nil
} }
// Signature required for non-whitelisted hosts // Signature required for non-allowed hosts
return s.signer.Verify(req) return s.signer.Verify(signatureRequest(req))
} }
// GenerateSignedURL generates a signed URL for the given request. // GenerateSignedURL generates a signed URL for the given request.
@@ -369,11 +407,32 @@ func (s *Service) GenerateSignedURL(
req *ImageRequest, req *ImageRequest,
ttl time.Duration, ttl time.Duration,
) (string, error) { ) (string, error) {
path, sig, exp := s.signer.GenerateSignedURL(req, ttl) sigReq := signatureRequest(req)
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
// Propagate the generated signature and expiration back onto the request.
req.Expires = sigReq.Expires
req.Signature = sigReq.Signature
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
} }
// signatureRequest projects an ImageRequest onto the standalone
// signature.Request type used by the signature package. This keeps the
// import edge one-way: imgcache depends on signature, never the reverse.
func signatureRequest(req *ImageRequest) *signature.Request {
return &signature.Request{
SourceHost: req.SourceHost,
SourcePath: req.SourcePath,
SourceQuery: req.SourceQuery,
Width: req.Size.Width,
Height: req.Size.Height,
Format: string(req.Format),
Signature: req.Signature,
Expires: req.Expires,
}
}
// HTTP status codes for error responses. // HTTP status codes for error responses.
const ( const (
httpStatusBadGateway = 502 httpStatusBadGateway = 502
@@ -382,13 +441,13 @@ const (
// isNegativeCacheable returns true if the error should be cached. // isNegativeCacheable returns true if the error should be cached.
func isNegativeCacheable(err error) bool { func isNegativeCacheable(err error) bool {
return errors.Is(err, ErrUpstreamError) return errors.Is(err, httpfetcher.ErrUpstreamError)
} }
// extractStatusCode extracts HTTP status code from error message. // extractStatusCode extracts HTTP status code from error message.
func extractStatusCode(err error) int { func extractStatusCode(err error) int {
// Default to 502 Bad Gateway for upstream errors // Default to 502 Bad Gateway for upstream errors
if errors.Is(err, ErrUpstreamError) { if errors.Is(err, httpfetcher.ErrUpstreamError) {
return httpStatusBadGateway return httpStatusBadGateway
} }

View File

@@ -5,9 +5,12 @@ import (
"io" "io"
"testing" "testing"
"time" "time"
"sneak.berlin/go/pixa/internal/magic"
"sneak.berlin/go/pixa/internal/signature"
) )
func TestService_Get_WhitelistedHost(t *testing.T) { func TestService_Get_AllowlistedHost(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -41,7 +44,7 @@ func TestService_Get_WhitelistedHost(t *testing.T) {
} }
} }
func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
svc, fixtures := SetupTestService(t, WithSigningKey("test-key")) svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
req := &ImageRequest{ req := &ImageRequest{
@@ -53,14 +56,14 @@ func TestService_Get_NonWhitelistedHost_NoSignature(t *testing.T) {
FitMode: FitCover, FitMode: FitCover,
} }
// Should fail validation - not whitelisted and no signature // Should fail validation - not allowlisted and no signature
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
if err == nil { if err == nil {
t.Error("ValidateRequest() expected error for non-whitelisted host without signature") t.Error("ValidateRequest() expected error for non-allowlisted host without signature")
} }
} }
func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
signingKey := "test-signing-key-12345" signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
ctx := context.Background() ctx := context.Background()
@@ -75,9 +78,9 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
} }
// Generate a valid signature // Generate a valid signature
signer := NewSigner(signingKey) signer := signature.New(signingKey)
req.Expires = time.Now().Add(time.Hour) req.Expires = time.Now().Add(time.Hour)
req.Signature = signer.Sign(req) req.Signature = signer.Sign(signatureRequest(req))
// Should pass validation // Should pass validation
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -102,7 +105,7 @@ func TestService_Get_NonWhitelistedHost_ValidSignature(t *testing.T) {
} }
} }
func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
signingKey := "test-signing-key-12345" signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
@@ -116,9 +119,9 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
} }
// Generate an expired signature // Generate an expired signature
signer := NewSigner(signingKey) signer := signature.New(signingKey)
req.Expires = time.Now().Add(-time.Hour) // Already expired req.Expires = time.Now().Add(-time.Hour) // Already expired
req.Signature = signer.Sign(req) req.Signature = signer.Sign(signatureRequest(req))
// Should fail validation // Should fail validation
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
@@ -127,7 +130,7 @@ func TestService_Get_NonWhitelistedHost_ExpiredSignature(t *testing.T) {
} }
} }
func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) { func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
signingKey := "test-signing-key-12345" signingKey := "test-signing-key-12345"
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey)) svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
@@ -151,6 +154,74 @@ func TestService_Get_NonWhitelistedHost_InvalidSignature(t *testing.T) {
} }
} }
// TestService_ValidateRequest_SignatureExactHostMatch verifies that
// ValidateRequest enforces exact host matching for signatures. A
// signature for one host must not verify for a different host, even
// if they share a domain suffix.
func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
signingKey := "test-signing-key-must-be-32-chars"
svc, _ := SetupTestService(t,
WithSigningKey(signingKey),
WithNoAllowlist(),
)
signer := signature.New(signingKey)
// Sign a request for "cdn.example.com"
signedReq := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
Expires: time.Now().Add(time.Hour),
}
signedReq.Signature = signer.Sign(signatureRequest(signedReq))
// The original request should pass validation
t.Run("exact host passes", func(t *testing.T) {
err := svc.ValidateRequest(signedReq)
if err != nil {
t.Errorf("ValidateRequest() exact host failed: %v", err)
}
})
// Try to reuse the signature with different hosts
tests := []struct {
name string
host string
}{
{"parent domain", "example.com"},
{"sibling subdomain", "images.example.com"},
{"deeper subdomain", "a.cdn.example.com"},
{"evil suffix domain", "cdn.example.com.evil.com"},
{"prefixed host", "evilcdn.example.com"},
}
for _, tt := range tests {
t.Run(tt.name+" rejected", func(t *testing.T) {
req := &ImageRequest{
SourceHost: tt.host,
SourcePath: signedReq.SourcePath,
SourceQuery: signedReq.SourceQuery,
Size: signedReq.Size,
Format: signedReq.Format,
Quality: signedReq.Quality,
FitMode: signedReq.FitMode,
Expires: signedReq.Expires,
Signature: signedReq.Signature,
}
err := svc.ValidateRequest(req)
if err == nil {
t.Errorf("ValidateRequest() should reject signature for host %q (signed for %q)",
tt.host, signedReq.SourceHost)
}
})
}
}
func TestService_Get_InvalidFile(t *testing.T) { func TestService_Get_InvalidFile(t *testing.T) {
svc, fixtures := SetupTestService(t) svc, fixtures := SetupTestService(t)
ctx := context.Background() ctx := context.Background()
@@ -247,17 +318,17 @@ func TestService_Get_FormatConversion(t *testing.T) {
t.Fatalf("failed to read response: %v", err) t.Fatalf("failed to read response: %v", err)
} }
detectedMIME, err := DetectFormat(data) detectedMIME, err := magic.DetectFormat(data)
if err != nil { if err != nil {
t.Fatalf("failed to detect format: %v", err) t.Fatalf("failed to detect format: %v", err)
} }
expectedFormat, ok := MIMEToImageFormat(tt.wantMIME) expectedFormat, ok := magic.MIMEToImageFormat(tt.wantMIME)
if !ok { if !ok {
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME) t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
} }
detectedFormat, ok := MIMEToImageFormat(string(detectedMIME)) detectedFormat, ok := magic.MIMEToImageFormat(string(detectedMIME))
if !ok { if !ok {
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME) t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
} }
@@ -367,8 +438,8 @@ func TestService_Get_DifferentSizes(t *testing.T) {
} }
func TestService_ValidateRequest_NoSigningKey(t *testing.T) { func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
// Service with no signing key - all non-whitelisted requests should fail // Service with no signing key - all non-allowlisted requests should fail
svc, fixtures := SetupTestService(t, WithNoWhitelist()) svc, fixtures := SetupTestService(t, WithNoAllowlist())
req := &ImageRequest{ req := &ImageRequest{
SourceHost: fixtures.OtherHost, SourceHost: fixtures.OtherHost,
@@ -381,7 +452,7 @@ func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
err := svc.ValidateRequest(req) err := svc.ValidateRequest(req)
if err == nil { if err == nil {
t.Error("ValidateRequest() expected error when no signing key and host not whitelisted") t.Error("ValidateRequest() expected error when no signing key and host not allowlisted")
} }
} }

View File

@@ -1,142 +0,0 @@
package imgcache
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strconv"
"time"
)
// Signature errors.
var (
ErrSignatureRequired = errors.New("signature required for non-whitelisted host")
ErrSignatureInvalid = errors.New("invalid signature")
ErrSignatureExpired = errors.New("signature has expired")
ErrMissingExpiration = errors.New("signature expiration is required")
)
// Signer handles HMAC-SHA256 signature generation and verification.
type Signer struct {
secretKey []byte
}
// NewSigner creates a new Signer with the given secret key.
func NewSigner(secretKey string) *Signer {
return &Signer{
secretKey: []byte(secretKey),
}
}
// Sign generates an HMAC-SHA256 signature for the given image request.
// The signature covers: host + path + query + width + height + format + expiration.
func (s *Signer) Sign(req *ImageRequest) string {
data := s.buildSignatureData(req)
mac := hmac.New(sha256.New, s.secretKey)
mac.Write([]byte(data))
sig := mac.Sum(nil)
return base64.URLEncoding.EncodeToString(sig)
}
// Verify checks if the signature on the request is valid and not expired.
func (s *Signer) Verify(req *ImageRequest) error {
// Check expiration first
if req.Expires.IsZero() {
return ErrMissingExpiration
}
if time.Now().After(req.Expires) {
return ErrSignatureExpired
}
// Compute expected signature
expected := s.Sign(req)
// Constant-time comparison to prevent timing attacks
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
return ErrSignatureInvalid
}
return nil
}
// buildSignatureData creates the string to be signed.
// Format: "host:path:query:width:height:format:expiration"
func (s *Signer) buildSignatureData(req *ImageRequest) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
req.SourceHost,
req.SourcePath,
req.SourceQuery,
req.Size.Width,
req.Size.Height,
req.Format,
req.Expires.Unix(),
)
}
// GenerateSignedURL creates a complete URL with signature and expiration.
// Returns the path portion that should be appended to the base URL.
func (s *Signer) GenerateSignedURL(req *ImageRequest, ttl time.Duration) (path string, sig string, exp int64) {
// Set expiration
req.Expires = time.Now().Add(ttl)
exp = req.Expires.Unix()
// Generate signature
sig = s.Sign(req)
req.Signature = sig
// Build the size component
var sizeStr string
if req.Size.OriginalSize() {
sizeStr = "orig"
} else {
sizeStr = fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height)
}
// Build the path.
// When a source query is present, it is embedded as a path segment
// (e.g. /host/path?query/size.fmt) so that ParseImagePath can extract
// it from the last-slash split. The "?" inside a path segment is
// percent-encoded by clients but chi delivers it decoded, which is
// exactly what the URL parser expects.
if req.SourceQuery != "" {
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
req.SourceHost,
req.SourcePath,
url.PathEscape(req.SourceQuery),
sizeStr,
req.Format,
)
} else {
path = fmt.Sprintf("/v1/image/%s%s/%s.%s",
req.SourceHost,
req.SourcePath,
sizeStr,
req.Format,
)
}
return path, sig, exp
}
// ParseSignatureParams extracts signature and expiration from query parameters.
func ParseSignatureParams(sig, expStr string) (signature string, expires time.Time, err error) {
signature = sig
if expStr == "" {
return signature, time.Time{}, nil
}
expUnix, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
}
expires = time.Unix(expUnix, 0)
return signature, expires, nil
}

View File

@@ -1,55 +0,0 @@
package imgcache
import (
"strings"
"testing"
"time"
)
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
signer := NewSigner("test-secret-key-for-testing!")
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "token=abc&v=2",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
// The path must NOT contain a bare "?" that would be interpreted as a query string delimiter.
// The size segment must appear as the last path component.
if strings.Contains(path, "?token=abc") {
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
}
// The size segment must be present in the path
if !strings.Contains(path, "/800x600.webp") {
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
}
// Path should end with the size.format, not with query params
if !strings.HasSuffix(path, "/800x600.webp") {
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
}
}
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
signer := NewSigner("test-secret-key-for-testing!")
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
expected := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
if path != expected {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expected)
}
}

View File

@@ -1,295 +0,0 @@
package imgcache
import (
"testing"
"time"
)
func TestSigner_Sign(t *testing.T) {
signer := NewSigner("test-secret-key")
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
}
sig1 := signer.Sign(req)
sig2 := signer.Sign(req)
// Same input should produce same signature
if sig1 != sig2 {
t.Errorf("Sign() produced different signatures for same input: %q vs %q", sig1, sig2)
}
// Signature should be non-empty
if sig1 == "" {
t.Error("Sign() produced empty signature")
}
// Different input should produce different signature
req2 := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/dog.jpg", // Different path
SourceQuery: "",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Expires: time.Unix(1704067200, 0),
}
sig3 := signer.Sign(req2)
if sig1 == sig3 {
t.Error("Sign() produced same signature for different input")
}
}
func TestSigner_Verify(t *testing.T) {
signer := NewSigner("test-secret-key")
tests := []struct {
name string
setup func() *ImageRequest
wantErr error
}{
{
name: "valid signature",
setup: func() *ImageRequest {
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req)
return req
},
wantErr: nil,
},
{
name: "expired signature",
setup: func() *ImageRequest {
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Expires: time.Now().Add(-1 * time.Hour), // Expired
}
req.Signature = signer.Sign(req)
return req
},
wantErr: ErrSignatureExpired,
},
{
name: "invalid signature",
setup: func() *ImageRequest {
return &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Expires: time.Now().Add(1 * time.Hour),
Signature: "invalid-signature",
}
},
wantErr: ErrSignatureInvalid,
},
{
name: "missing expiration",
setup: func() *ImageRequest {
return &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Signature: "some-signature",
// Expires is zero
}
},
wantErr: ErrMissingExpiration,
},
{
name: "tampered request",
setup: func() *ImageRequest {
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req)
// Tamper with the request
req.SourcePath = "/photos/secret.jpg"
return req
},
wantErr: ErrSignatureInvalid,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := tt.setup()
err := signer.Verify(req)
if tt.wantErr == nil {
if err != nil {
t.Errorf("Verify() unexpected error = %v", err)
}
} else {
if err != tt.wantErr {
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
}
}
})
}
}
func TestSigner_DifferentKeys(t *testing.T) {
signer1 := NewSigner("secret-key-1")
signer2 := NewSigner("secret-key-2")
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
Expires: time.Now().Add(1 * time.Hour),
}
// Sign with key 1
req.Signature = signer1.Sign(req)
// Verify with key 1 should succeed
if err := signer1.Verify(req); err != nil {
t.Errorf("Verify() with same key failed: %v", err)
}
// Verify with key 2 should fail
if err := signer2.Verify(req); err != ErrSignatureInvalid {
t.Errorf("Verify() with different key should fail, got: %v", err)
}
}
func TestGenerateSignedURL(t *testing.T) {
signer := NewSigner("test-secret-key")
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Size: Size{Width: 800, Height: 600},
Format: FormatWebP,
}
ttl := 1 * time.Hour
path, sig, exp := signer.GenerateSignedURL(req, ttl)
// Path should be correct format
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
if path != expectedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
}
// Signature should be non-empty
if sig == "" {
t.Error("GenerateSignedURL() produced empty signature")
}
// Expiration should be approximately now + TTL
expTime := time.Unix(exp, 0)
expectedExp := time.Now().Add(ttl)
if expTime.Sub(expectedExp) > time.Second {
t.Errorf("GenerateSignedURL() exp time off by too much")
}
// Request should have been updated with signature and expiration
if req.Signature != sig {
t.Errorf("GenerateSignedURL() didn't update request signature")
}
}
func TestGenerateSignedURL_OrigSize(t *testing.T) {
signer := NewSigner("test-secret-key")
req := &ImageRequest{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Size: Size{Width: 0, Height: 0}, // Original size
Format: FormatPNG,
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
if path != expectedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
}
}
func TestParseSignatureParams(t *testing.T) {
tests := []struct {
name string
sig string
expStr string
wantSig string
wantErr bool
checkTime bool
}{
{
name: "valid params",
sig: "abc123",
expStr: "1704067200",
wantSig: "abc123",
wantErr: false,
},
{
name: "empty expiration",
sig: "abc123",
expStr: "",
wantSig: "abc123",
wantErr: false,
},
{
name: "invalid expiration",
sig: "abc123",
expStr: "not-a-number",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sig, exp, err := ParseSignatureParams(tt.sig, tt.expStr)
if tt.wantErr {
if err == nil {
t.Error("ParseSignatureParams() expected error, got nil")
}
return
}
if err != nil {
t.Errorf("ParseSignatureParams() unexpected error = %v", err)
return
}
if sig != tt.wantSig {
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
}
if tt.expStr != "" && exp.IsZero() {
t.Error("exp should not be zero when expStr is provided")
}
})
}
}

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

View File

@@ -493,6 +493,24 @@ func (s *VariantStorage) Delete(key VariantKey) error {
return nil return nil
} }
// DeleteWithMeta removes the content at the given key together with
// its .meta sidecar file. A missing file is not an error.
func (s *VariantStorage) DeleteWithMeta(key VariantKey) error {
if err := s.Delete(key); err != nil {
return err
}
metaPath := s.keyToPath(key) + ".meta"
//nolint:gosec // G703: path derived from cache key
err := os.Remove(metaPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete variant metadata: %w", err)
}
return nil
}
// keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key> // keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
func (s *VariantStorage) keyToPath(key VariantKey) string { func (s *VariantStorage) keyToPath(key VariantKey) string {
k := string(key) k := string(key)

View File

@@ -2,6 +2,7 @@ package imgcache
import ( import (
"bytes" "bytes"
"context"
"database/sql" "database/sql"
"image" "image"
"image/color" "image/color"
@@ -14,16 +15,17 @@ import (
"time" "time"
"sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
) )
// TestFixtures contains paths to test files in the mock filesystem. // TestFixtures contains paths to test files in the mock filesystem.
type TestFixtures struct { type TestFixtures struct {
// Valid image files // Valid image files
GoodHostJPEG string // whitelisted host, valid JPEG GoodHostJPEG string // allowlisted host, valid JPEG
GoodHostPNG string // whitelisted host, valid PNG GoodHostPNG string // allowlisted host, valid PNG
GoodHostGIF string // whitelisted host, valid GIF GoodHostGIF string // allowlisted host, valid GIF
OtherHostJPEG string // non-whitelisted host, valid JPEG OtherHostJPEG string // non-allowlisted host, valid JPEG
OtherHostPNG string // non-whitelisted host, valid PNG OtherHostPNG string // non-allowlisted host, valid PNG
// Invalid/edge case files // Invalid/edge case files
InvalidFile string // file with wrong magic bytes InvalidFile string // file with wrong magic bytes
@@ -31,8 +33,8 @@ type TestFixtures struct {
TextFile string // text file masquerading as image TextFile string // text file masquerading as image
// Hostnames // Hostnames
GoodHost string // whitelisted hostname GoodHost string // allowlisted hostname
OtherHost string // non-whitelisted hostname OtherHost string // non-allowlisted hostname
} }
// DefaultFixtures returns the standard test fixture paths. // DefaultFixtures returns the standard test fixture paths.
@@ -146,7 +148,7 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
mockFS, fixtures := NewTestFS(t) mockFS, fixtures := NewTestFS(t)
cfg := &testServiceConfig{ cfg := &testServiceConfig{
whitelist: []string{fixtures.GoodHost}, allowlist: []string{fixtures.GoodHost},
signingKey: "test-signing-key-must-be-32-chars", signingKey: "test-signing-key-must-be-32-chars",
} }
@@ -171,9 +173,9 @@ func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestF
svc, err := NewService(&ServiceConfig{ svc, err := NewService(&ServiceConfig{
Cache: cache, Cache: cache,
Fetcher: NewMockFetcher(mockFS), Fetcher: httpfetcher.NewMock(mockFS),
SigningKey: cfg.signingKey, SigningKey: cfg.signingKey,
Whitelist: cfg.whitelist, Allowlist: cfg.allowlist,
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create service: %v", err) t.Fatalf("failed to create service: %v", err)
@@ -193,7 +195,7 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
} }
// Use the real production schema via migrations // Use the real production schema via migrations
if err := database.ApplyMigrations(db); err != nil { if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
t.Fatalf("failed to apply migrations: %v", err) t.Fatalf("failed to apply migrations: %v", err)
} }
@@ -201,17 +203,17 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
} }
type testServiceConfig struct { type testServiceConfig struct {
whitelist []string allowlist []string
signingKey string signingKey string
} }
// TestServiceOption configures the test service. // TestServiceOption configures the test service.
type TestServiceOption func(*testServiceConfig) type TestServiceOption func(*testServiceConfig)
// WithWhitelist sets the whitelist for the test service. // WithAllowlist sets the allowlist for the test service.
func WithWhitelist(hosts ...string) TestServiceOption { func WithAllowlist(hosts ...string) TestServiceOption {
return func(c *testServiceConfig) { return func(c *testServiceConfig) {
c.whitelist = hosts c.allowlist = hosts
} }
} }
@@ -222,9 +224,9 @@ func WithSigningKey(key string) TestServiceOption {
} }
} }
// WithNoWhitelist removes all whitelisted hosts. // WithNoAllowlist removes all allowlisted hosts.
func WithNoWhitelist() TestServiceOption { func WithNoAllowlist() TestServiceOption {
return func(c *testServiceConfig) { return func(c *testServiceConfig) {
c.whitelist = nil c.allowlist = nil
} }
} }

View File

@@ -1,4 +1,6 @@
package imgcache // Package magic detects image formats from magic bytes and validates
// content against declared MIME types.
package magic
import ( import (
"bytes" "bytes"
@@ -27,6 +29,20 @@ const (
MIMETypeSVG = MIMEType("image/svg+xml") MIMETypeSVG = MIMEType("image/svg+xml")
) )
// ImageFormat represents supported output image formats.
// This mirrors the type in imgcache to avoid circular imports.
type ImageFormat string
// Supported image output formats.
const (
FormatOriginal ImageFormat = "orig"
FormatJPEG ImageFormat = "jpeg"
FormatPNG ImageFormat = "png"
FormatWebP ImageFormat = "webp"
FormatAVIF ImageFormat = "avif"
FormatGIF ImageFormat = "gif"
)
// MinMagicBytes is the minimum number of bytes needed to detect format. // MinMagicBytes is the minimum number of bytes needed to detect format.
const MinMagicBytes = 12 const MinMagicBytes = 12
@@ -189,7 +205,7 @@ func PeekAndValidate(r io.Reader, declaredType string) (io.Reader, error) {
return io.MultiReader(bytes.NewReader(buf), r), nil return io.MultiReader(bytes.NewReader(buf), r), nil
} }
// MIMEToImageFormat converts a MIME type to our ImageFormat type. // MIMEToImageFormat converts a MIME type to an ImageFormat.
func MIMEToImageFormat(mimeType string) (ImageFormat, bool) { func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
normalized := normalizeMIMEType(mimeType) normalized := normalizeMIMEType(mimeType)
switch MIMEType(normalized) { switch MIMEType(normalized) {
@@ -208,7 +224,7 @@ func MIMEToImageFormat(mimeType string) (ImageFormat, bool) {
} }
} }
// ImageFormatToMIME converts our ImageFormat to a MIME type string. // ImageFormatToMIME converts an ImageFormat to a MIME type string.
func ImageFormatToMIME(format ImageFormat) string { func ImageFormatToMIME(format ImageFormat) string {
switch format { switch format {
case FormatJPEG: case FormatJPEG:

View File

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

View File

@@ -37,13 +37,15 @@ type Data struct {
// Manager handles session creation and validation using encrypted cookies. // Manager handles session creation and validation using encrypted cookies.
type Manager struct { type Manager struct {
sc *securecookie.SecureCookie sc *securecookie.SecureCookie
secure bool // Set Secure flag on cookies (should be true in production)
sameSite http.SameSite
} }
// NewManager creates a session manager with keys derived from the signing key. // NewManager creates a session manager with keys derived from the signing key.
// Set secure=true in production to require HTTPS for cookies. //
func NewManager(signingKey string, secure bool) (*Manager, error) { // Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
// attributes; this cannot be configured. Browsers treat http://localhost as a
// trustworthy origin and accept Secure cookies there, so local development
// keeps working.
func NewManager(signingKey string) (*Manager, error) {
masterKey := []byte(signingKey) masterKey := []byte(signingKey)
// Derive separate keys for HMAC (hash) and encryption (block) // Derive separate keys for HMAC (hash) and encryption (block)
@@ -62,8 +64,6 @@ func NewManager(signingKey string, secure bool) (*Manager, error) {
return &Manager{ return &Manager{
sc: sc, sc: sc,
secure: secure,
sameSite: http.SameSiteStrictMode,
}, nil }, nil
} }
@@ -87,8 +87,8 @@ func (m *Manager) CreateSession(w http.ResponseWriter) error {
Path: "/", Path: "/",
MaxAge: int(SessionTTL.Seconds()), MaxAge: int(SessionTTL.Seconds()),
HttpOnly: true, HttpOnly: true,
Secure: m.secure, Secure: true,
SameSite: m.sameSite, SameSite: http.SameSiteStrictMode,
}) })
return nil return nil
@@ -131,8 +131,8 @@ func (m *Manager) ClearSession(w http.ResponseWriter) {
Path: "/", Path: "/",
MaxAge: -1, // Delete immediately MaxAge: -1, // Delete immediately
HttpOnly: true, HttpOnly: true,
Secure: m.secure, Secure: true,
SameSite: m.sameSite, SameSite: http.SameSiteStrictMode,
}) })
} }

View File

@@ -0,0 +1,82 @@
package session
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestSessionCookieAttributesAlwaysSecure verifies that every cookie
// emitted by the session manager carries HttpOnly, Secure, and a
// SameSite mode of Lax or stricter. Session cookies contain the
// authentication state and must never be exposed to script (HttpOnly),
// sent over plaintext HTTP (Secure), or attached to cross-site
// requests (SameSite). Nothing may weaken these attributes.
//
// This covers both cookie-writing paths: CreateSession (the login
// set-cookie path) and ClearSession (the logout delete-cookie path).
func TestSessionCookieAttributesAlwaysSecure(t *testing.T) {
mgr, err := NewManager("test-signing-key-12345")
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
writePaths := []struct {
name string
setCookie func(t *testing.T, w http.ResponseWriter)
}{
{
name: "CreateSession",
setCookie: func(t *testing.T, w http.ResponseWriter) {
t.Helper()
if err := mgr.CreateSession(w); err != nil {
t.Fatalf("CreateSession() error = %v", err)
}
},
},
{
name: "ClearSession",
setCookie: func(t *testing.T, w http.ResponseWriter) {
t.Helper()
mgr.ClearSession(w)
},
},
}
for _, writePath := range writePaths {
t.Run(writePath.name, func(t *testing.T) {
w := httptest.NewRecorder()
writePath.setCookie(t, w)
var sessionCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == CookieName {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("no cookie named %q was set", CookieName)
}
t.Logf("cookie attributes: HttpOnly=%v Secure=%v SameSite=%v",
sessionCookie.HttpOnly, sessionCookie.Secure, sessionCookie.SameSite)
if !sessionCookie.HttpOnly {
t.Error("session cookie must have HttpOnly set")
}
if !sessionCookie.Secure {
t.Error("session cookie must have Secure set")
}
if sessionCookie.SameSite != http.SameSiteLaxMode &&
sessionCookie.SameSite != http.SameSiteStrictMode {
t.Errorf("session cookie SameSite = %v, want Lax (%v) or Strict (%v)",
sessionCookie.SameSite, http.SameSiteLaxMode, http.SameSiteStrictMode)
}
})
}
}

View File

@@ -8,7 +8,7 @@ import (
) )
func TestManager_CreateAndValidate(t *testing.T) { func TestManager_CreateAndValidate(t *testing.T) {
mgr, err := NewManager("test-signing-key-12345", false) mgr, err := NewManager("test-signing-key-12345")
if err != nil { if err != nil {
t.Fatalf("NewManager() error = %v", err) t.Fatalf("NewManager() error = %v", err)
} }
@@ -57,7 +57,7 @@ func TestManager_CreateAndValidate(t *testing.T) {
} }
func TestManager_ValidateSession_NoCookie(t *testing.T) { func TestManager_ValidateSession_NoCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false) mgr, _ := NewManager("test-signing-key-12345")
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -72,7 +72,7 @@ func TestManager_ValidateSession_NoCookie(t *testing.T) {
} }
func TestManager_ValidateSession_TamperedCookie(t *testing.T) { func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false) mgr, _ := NewManager("test-signing-key-12345")
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{ req.AddCookie(&http.Cookie{
@@ -91,8 +91,8 @@ func TestManager_ValidateSession_TamperedCookie(t *testing.T) {
} }
func TestManager_ValidateSession_WrongKey(t *testing.T) { func TestManager_ValidateSession_WrongKey(t *testing.T) {
mgr1, _ := NewManager("signing-key-1", false) mgr1, _ := NewManager("signing-key-1")
mgr2, _ := NewManager("signing-key-2", false) mgr2, _ := NewManager("signing-key-2")
// Create session with mgr1 // Create session with mgr1
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -118,7 +118,7 @@ func TestManager_ValidateSession_WrongKey(t *testing.T) {
} }
func TestManager_ClearSession(t *testing.T) { func TestManager_ClearSession(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false) mgr, _ := NewManager("test-signing-key-12345")
w := httptest.NewRecorder() w := httptest.NewRecorder()
mgr.ClearSession(w) mgr.ClearSession(w)
@@ -144,7 +144,7 @@ func TestManager_ClearSession(t *testing.T) {
} }
func TestManager_IsAuthenticated(t *testing.T) { func TestManager_IsAuthenticated(t *testing.T) {
mgr, _ := NewManager("test-signing-key-12345", false) mgr, _ := NewManager("test-signing-key-12345")
// No session - should return false // No session - should return false
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -175,8 +175,7 @@ func TestManager_IsAuthenticated(t *testing.T) {
} }
func TestManager_CookieAttributes(t *testing.T) { func TestManager_CookieAttributes(t *testing.T) {
// Test with secure=true mgr, _ := NewManager("test-key")
mgr, _ := NewManager("test-key", true)
w := httptest.NewRecorder() w := httptest.NewRecorder()
_ = mgr.CreateSession(w) _ = mgr.CreateSession(w)

View File

@@ -0,0 +1,105 @@
package signature
import (
"testing"
"time"
)
// goldenExpiresUnix is the fixed expiration timestamp used by all golden
// vectors: 2024-01-01T00:00:00Z.
const goldenExpiresUnix int64 = 1704067200
// goldenSigningKey is the fixed signing key used by all golden vectors.
const goldenSigningKey = "golden-test-key"
// TestSigner_GoldenVectors pins the exact HMAC-SHA256 signature output and
// the exact generated signed URL path for fully-specified requests with a
// hardcoded signing key. The expected values were computed once and are
// hardcoded here as known answers.
//
// If any of these assertions fail, the signed byte format
// ("host:path:query:width:height:format:expiration"), the base64url
// encoding, or the signed URL layout has changed. Such a change breaks
// every signature already issued to clients, so it must be made
// deliberately: update these constants only as part of an intentional,
// documented signature format migration.
func TestSigner_GoldenVectors(t *testing.T) {
signer := New(goldenSigningKey)
vectors := []struct {
name string
req Request
// wantSignature is the exact base64url (RFC 4648 URL-safe,
// padded) HMAC-SHA256 signature for the request with Expires
// set to goldenExpiresUnix.
wantSignature string
// wantSignedPath is the exact path returned by
// GenerateSignedURL for the request. The signature and
// expiration are returned separately by GenerateSignedURL and
// are not embedded in the path.
wantSignedPath string
}{
{
name: "resized without query",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 800,
Height: 600,
Format: "webp",
},
// Signed data: "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200"
wantSignature: "x5PfPp8QSDo0cJT96od-AEgrQyOVLfqifH5sst61_-w=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
},
{
name: "resized with query string",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "token=abc&v=2",
Width: 800,
Height: 600,
Format: "webp",
},
// Signed data: "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200"
wantSignature: "394_Vf9TdQFkpQ3XKFDQSyxgqKq8N7mApf2S4QaHqyo=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg%3Ftoken=abc&v=2/800x600.webp",
},
{
name: "original size without query",
req: Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 0,
Height: 0,
Format: "png",
},
// Signed data: "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200"
wantSignature: "7Be7oteeQwvnSPU4bchyQ4ZGYGsAGBKpeEtuQ02ox60=",
wantSignedPath: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
},
}
for _, tt := range vectors {
t.Run(tt.name, func(t *testing.T) {
signReq := tt.req
signReq.Expires = time.Unix(goldenExpiresUnix, 0)
gotSignature := signer.Sign(&signReq)
if gotSignature != tt.wantSignature {
t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
gotSignature, tt.wantSignature)
}
urlReq := tt.req
gotPath, _, _ := signer.GenerateSignedURL(&urlReq, time.Hour)
if gotPath != tt.wantSignedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q (signed URL layout changed?)",
gotPath, tt.wantSignedPath)
}
})
}
}

View File

@@ -0,0 +1,173 @@
// Package signature provides HMAC-SHA256 signing and verification of image
// requests.
package signature
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strconv"
"time"
)
// Signature errors.
var (
ErrRequired = errors.New("signature required for non-allowlisted host")
ErrInvalid = errors.New("invalid signature")
ErrExpired = errors.New("signature has expired")
ErrMissingExpiration = errors.New("signature expiration is required")
)
// Request carries the components an image request signature covers. It is a
// standalone type so that this package does not depend on imgcache, keeping
// the import edge one-way (imgcache depends on signature, never the reverse).
type Request struct {
// SourceHost is the origin host (e.g. "cdn.example.com").
SourceHost string
// SourcePath is the path on the origin (e.g. "/photos/cat.jpg").
SourcePath string
// SourceQuery is the optional query string for the origin URL.
SourceQuery string
// Width is the requested output width in pixels.
Width int
// Height is the requested output height in pixels.
Height int
// Format is the requested output format (e.g. "webp").
Format string
// Signature is the HMAC signature to verify.
Signature string
// Expires is the signature expiration timestamp.
Expires time.Time
}
// Signer handles HMAC-SHA256 signature generation and verification.
type Signer struct {
secretKey []byte
}
// New creates a new Signer with the given secret key.
func New(secretKey string) *Signer {
return &Signer{
secretKey: []byte(secretKey),
}
}
// Sign generates an HMAC-SHA256 signature for the given request.
// The signature covers: host + path + query + width + height + format + expiration.
func (s *Signer) Sign(req *Request) string {
data := s.buildSignatureData(req)
mac := hmac.New(sha256.New, s.secretKey)
mac.Write([]byte(data))
sig := mac.Sum(nil)
return base64.URLEncoding.EncodeToString(sig)
}
// Verify checks if the signature on the request is valid and not expired.
// Signatures are exact-match only: every component of the signed data
// (host, path, query, dimensions, format, expiration) must match exactly.
// No suffix matching, wildcard matching, or partial matching is supported.
// A signature for "cdn.example.com" will NOT verify for "example.com" or
// "other.cdn.example.com", and vice versa.
func (s *Signer) Verify(req *Request) error {
// Check expiration first
if req.Expires.IsZero() {
return ErrMissingExpiration
}
if time.Now().After(req.Expires) {
return ErrExpired
}
// Compute expected signature
expected := s.Sign(req)
// Constant-time comparison to prevent timing attacks
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
return ErrInvalid
}
return nil
}
// buildSignatureData creates the string to be signed.
// Format: "host:path:query:width:height:format:expiration"
// All components are used verbatim (exact match). No normalization,
// suffix matching, or wildcard expansion is performed.
func (s *Signer) buildSignatureData(req *Request) string {
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
req.SourceHost,
req.SourcePath,
req.SourceQuery,
req.Width,
req.Height,
req.Format,
req.Expires.Unix(),
)
}
// GenerateSignedURL creates a complete URL with signature and expiration.
// Returns the path portion that should be appended to the base URL.
func (s *Signer) GenerateSignedURL(req *Request, ttl time.Duration) (path string, sig string, exp int64) {
// Set expiration
req.Expires = time.Now().Add(ttl)
exp = req.Expires.Unix()
// Generate signature
sig = s.Sign(req)
req.Signature = sig
// Build the size component
var sizeStr string
if req.Width == 0 && req.Height == 0 {
sizeStr = "orig"
} else {
sizeStr = fmt.Sprintf("%dx%d", req.Width, req.Height)
}
// Build the path.
// When a source query is present, it is embedded as a path segment
// (e.g. /host/path?query/size.fmt) so that the URL parser can extract
// it from the last-slash split. The "?" inside a path segment is
// percent-encoded by clients but chi delivers it decoded, which is
// exactly what the URL parser expects.
if req.SourceQuery != "" {
path = fmt.Sprintf("/v1/image/%s%s%%3F%s/%s.%s",
req.SourceHost,
req.SourcePath,
url.PathEscape(req.SourceQuery),
sizeStr,
req.Format,
)
} else {
path = fmt.Sprintf("/v1/image/%s%s/%s.%s",
req.SourceHost,
req.SourcePath,
sizeStr,
req.Format,
)
}
return path, sig, exp
}
// ParseParams extracts signature and expiration from query parameters.
func ParseParams(sig, expStr string) (parsed string, expires time.Time, err error) {
parsed = sig
if expStr == "" {
return parsed, time.Time{}, nil
}
expUnix, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
}
expires = time.Unix(expUnix, 0)
return parsed, expires, nil
}

View File

@@ -0,0 +1,530 @@
package signature
import (
"strings"
"testing"
"time"
)
func TestSigner_Sign(t *testing.T) {
signer := New("test-secret-key")
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Unix(1704067200, 0), // Fixed timestamp for reproducibility
}
sig1 := signer.Sign(req)
sig2 := signer.Sign(req)
// Same input should produce same signature
if sig1 != sig2 {
t.Errorf("Sign() produced different signatures for same input: %q vs %q", sig1, sig2)
}
// Signature should be non-empty
if sig1 == "" {
t.Error("Sign() produced empty signature")
}
// Different input should produce different signature
req2 := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/dog.jpg", // Different path
SourceQuery: "",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Unix(1704067200, 0),
}
sig3 := signer.Sign(req2)
if sig1 == sig3 {
t.Error("Sign() produced same signature for different input")
}
}
func TestSigner_Verify(t *testing.T) {
signer := New("test-secret-key")
tests := []struct {
name string
setup func() *Request
wantErr error
}{
{
name: "valid signature",
setup: func() *Request {
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req)
return req
},
wantErr: nil,
},
{
name: "expired signature",
setup: func() *Request {
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(-1 * time.Hour), // Expired
}
req.Signature = signer.Sign(req)
return req
},
wantErr: ErrExpired,
},
{
name: "invalid signature",
setup: func() *Request {
return &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour),
Signature: "invalid-signature",
}
},
wantErr: ErrInvalid,
},
{
name: "missing expiration",
setup: func() *Request {
return &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Signature: "some-signature",
// Expires is zero
}
},
wantErr: ErrMissingExpiration,
},
{
name: "tampered request",
setup: func() *Request {
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req)
// Tamper with the request
req.SourcePath = "/photos/secret.jpg"
return req
},
wantErr: ErrInvalid,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := tt.setup()
err := signer.Verify(req)
if tt.wantErr == nil {
if err != nil {
t.Errorf("Verify() unexpected error = %v", err)
}
} else {
if err != tt.wantErr {
t.Errorf("Verify() error = %v, wantErr %v", err, tt.wantErr)
}
}
})
}
}
// TestSigner_Verify_ExactMatchOnly verifies that signatures enforce exact
// matching on every URL component. No suffix matching, wildcard matching,
// or partial matching is supported.
func TestSigner_Verify_ExactMatchOnly(t *testing.T) {
signer := New("test-secret-key")
// Base request that we'll sign, then tamper with individual fields.
baseReq := func() *Request {
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "token=abc",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour),
}
req.Signature = signer.Sign(req)
return req
}
tests := []struct {
name string
tamper func(req *Request)
}{
{
name: "parent domain does not match subdomain",
tamper: func(req *Request) {
// Signed for cdn.example.com, try example.com
req.SourceHost = "example.com"
},
},
{
name: "subdomain does not match parent domain",
tamper: func(req *Request) {
// Signed for cdn.example.com, try images.cdn.example.com
req.SourceHost = "images.cdn.example.com"
},
},
{
name: "sibling subdomain does not match",
tamper: func(req *Request) {
// Signed for cdn.example.com, try images.example.com
req.SourceHost = "images.example.com"
},
},
{
name: "host with suffix appended does not match",
tamper: func(req *Request) {
// Signed for cdn.example.com, try cdn.example.com.evil.com
req.SourceHost = "cdn.example.com.evil.com"
},
},
{
name: "host with prefix does not match",
tamper: func(req *Request) {
// Signed for cdn.example.com, try evilcdn.example.com
req.SourceHost = "evilcdn.example.com"
},
},
{
name: "different path does not match",
tamper: func(req *Request) {
req.SourcePath = "/photos/dog.jpg"
},
},
{
name: "path suffix does not match",
tamper: func(req *Request) {
req.SourcePath = "/photos/cat.jpg/extra"
},
},
{
name: "path prefix does not match",
tamper: func(req *Request) {
req.SourcePath = "/other/photos/cat.jpg"
},
},
{
name: "different query does not match",
tamper: func(req *Request) {
req.SourceQuery = "token=xyz"
},
},
{
name: "added query does not match empty query",
tamper: func(req *Request) {
req.SourceQuery = "extra=1"
},
},
{
name: "removed query does not match",
tamper: func(req *Request) {
req.SourceQuery = ""
},
},
{
name: "different width does not match",
tamper: func(req *Request) {
req.Width = 801
},
},
{
name: "different height does not match",
tamper: func(req *Request) {
req.Height = 601
},
},
{
name: "different format does not match",
tamper: func(req *Request) {
req.Format = "png"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := baseReq()
tt.tamper(req)
err := signer.Verify(req)
if err != ErrInvalid {
t.Errorf("Verify() = %v, want %v", err, ErrInvalid)
}
})
}
// Verify the unmodified base request still passes
t.Run("unmodified request passes", func(t *testing.T) {
req := baseReq()
if err := signer.Verify(req); err != nil {
t.Errorf("Verify() unmodified request failed: %v", err)
}
})
}
// TestSigner_Sign_ExactHostInData verifies that Sign uses the exact host
// string in the signature data, producing different signatures for
// suffix-related hosts.
func TestSigner_Sign_ExactHostInData(t *testing.T) {
signer := New("test-secret-key")
hosts := []string{
"cdn.example.com",
"example.com",
"images.example.com",
"images.cdn.example.com",
"cdn.example.com.evil.com",
}
sigs := make(map[string]string)
for _, host := range hosts {
req := &Request{
SourceHost: host,
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Unix(1704067200, 0),
}
sig := signer.Sign(req)
if existing, ok := sigs[sig]; ok {
t.Errorf("hosts %q and %q produced the same signature", existing, host)
}
sigs[sig] = host
}
}
func TestSigner_DifferentKeys(t *testing.T) {
signer1 := New("secret-key-1")
signer2 := New("secret-key-2")
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
Expires: time.Now().Add(1 * time.Hour),
}
// Sign with key 1
req.Signature = signer1.Sign(req)
// Verify with key 1 should succeed
if err := signer1.Verify(req); err != nil {
t.Errorf("Verify() with same key failed: %v", err)
}
// Verify with key 2 should fail
if err := signer2.Verify(req); err != ErrInvalid {
t.Errorf("Verify() with different key should fail, got: %v", err)
}
}
func TestGenerateSignedURL(t *testing.T) {
signer := New("test-secret-key")
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "",
Width: 800,
Height: 600,
Format: "webp",
}
ttl := 1 * time.Hour
path, sig, exp := signer.GenerateSignedURL(req, ttl)
// Path should be correct format
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
if path != expectedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
}
// Signature should be non-empty
if sig == "" {
t.Error("GenerateSignedURL() produced empty signature")
}
// Expiration should be approximately now + TTL
expTime := time.Unix(exp, 0)
expectedExp := time.Now().Add(ttl)
if expTime.Sub(expectedExp) > time.Second {
t.Errorf("GenerateSignedURL() exp time off by too much")
}
// Request should have been updated with signature and expiration
if req.Signature != sig {
t.Errorf("GenerateSignedURL() didn't update request signature")
}
}
func TestGenerateSignedURL_OrigSize(t *testing.T) {
signer := New("test-secret-key")
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 0, // Original size
Height: 0,
Format: "png",
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
expectedPath := "/v1/image/cdn.example.com/photos/cat.jpg/orig.png"
if path != expectedPath {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expectedPath)
}
}
func TestGenerateSignedURL_WithQueryString(t *testing.T) {
signer := New("test-secret-key-for-testing!")
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
SourceQuery: "token=abc&v=2",
Width: 800,
Height: 600,
Format: "webp",
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
// The path must NOT contain a bare "?" that would be interpreted as a query string delimiter.
// The size segment must appear as the last path component.
if strings.Contains(path, "?token=abc") {
t.Errorf("GenerateSignedURL() produced bare query string in path: %q", path)
}
// The size segment must be present in the path
if !strings.Contains(path, "/800x600.webp") {
t.Errorf("GenerateSignedURL() missing size segment in path: %q", path)
}
// Path should end with the size.format, not with query params
if !strings.HasSuffix(path, "/800x600.webp") {
t.Errorf("GenerateSignedURL() path should end with size.format: %q", path)
}
}
func TestGenerateSignedURL_WithoutQueryString(t *testing.T) {
signer := New("test-secret-key-for-testing!")
req := &Request{
SourceHost: "cdn.example.com",
SourcePath: "/photos/cat.jpg",
Width: 800,
Height: 600,
Format: "webp",
}
path, _, _ := signer.GenerateSignedURL(req, time.Hour)
expected := "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp"
if path != expected {
t.Errorf("GenerateSignedURL() path = %q, want %q", path, expected)
}
}
func TestParseParams(t *testing.T) {
tests := []struct {
name string
sig string
expStr string
wantSig string
wantErr bool
checkTime bool
}{
{
name: "valid params",
sig: "abc123",
expStr: "1704067200",
wantSig: "abc123",
wantErr: false,
},
{
name: "empty expiration",
sig: "abc123",
expStr: "",
wantSig: "abc123",
wantErr: false,
},
{
name: "invalid expiration",
sig: "abc123",
expStr: "not-a-number",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sig, exp, err := ParseParams(tt.sig, tt.expStr)
if tt.wantErr {
if err == nil {
t.Error("ParseParams() expected error, got nil")
}
return
}
if err != nil {
t.Errorf("ParseParams() unexpected error = %v", err)
return
}
if sig != tt.wantSig {
t.Errorf("sig = %q, want %q", sig, tt.wantSig)
}
if tt.expStr != "" && exp.IsZero() {
t.Error("exp should not be zero when expStr is provided")
}
})
}
}

138
script/bootstrap Executable file
View File

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

15
script/check Executable file
View File

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

15
script/cibuild Executable file
View File

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

15
script/docker Executable file
View File

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

14
script/fmt Executable file
View File

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

18
script/fmt-check Executable file
View File

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

16
script/install-precommit Executable file
View File

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

23
script/lint Executable file
View File

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

21
script/precommit Executable file
View File

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

12
script/projectname Executable file
View File

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

14
script/setup Executable file
View File

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

23
script/test Executable file
View File

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

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 "password=wrong-key" -c "$COOKIE_JAR") WRONG_LOGIN=$(curl -sf -X POST "$BASE_URL/" -d "key=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 "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null curl -sf -X POST "$BASE_URL/" -d "key=$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 "source_url=$TEST_IMAGE_URL" \ -d "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_mode=cover" \ -d "fit=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"
@@ -97,8 +97,8 @@ else
fail "No encrypted URL to test" fail "No encrypted URL to test"
fi fi
# Test 7: Fetch image via whitelisted host (direct proxy) # Test 7: Fetch image via allowlisted host (direct proxy)
echo "--- Test 7: Fetch image via direct proxy (whitelisted host) ---" echo "--- Test 7: Fetch image via direct proxy (allowlisted host) ---"
# URL format: /v1/image/<host>/<path>/<WxH>.<format> # URL format: /v1/image/<host>/<path>/<WxH>.<format>
PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg" PROXY_PATH="/v1/image/s3.sneak.cloud/sneak-public/2021/2021-04-18.untitled.a7r4.07723.jpg/400x300.jpeg"
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH") HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" "$BASE_URL$PROXY_PATH")
@@ -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 "password=$SIGNING_KEY" -c "$COOKIE_JAR" -b "$COOKIE_JAR" -L -o /dev/null curl -sf -X POST "$BASE_URL/" -d "key=$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 "source_url=$TEST_IMAGE_URL" \ -d "url=$TEST_IMAGE_URL" \
-d "width=100" \ -d "width=100" \
-d "height=100" \ -d "height=100" \
-d "format=jpeg" \ -d "format=jpeg" \