Commit Graph

21 Commits

Author SHA1 Message Date
851a65b594 Merge branch 'main' into golangci-v2.12.2
Absorbs the cache size management and LRU eviction work (#55). All four
textual conflicts resolved in favor of main's implementation, with this
branch's mechanical lint conformance re-applied on top:

- internal/config/config.go: took main's cache_max_bytes wiring
  (CacheMaxBytes, cacheMaxBytesExplicit) verbatim and expressed the key
  through this branch's constant convention as keyCacheMaxBytes.
- internal/imgcache/cache.go: took main's disabled-cache guards, LRU
  touch on lookup, and variant_content size accounting verbatim;
  re-applied this branch's signature wrapping for lll.
- internal/imgcache/storage.go: took main's new writeIfAbsent helper and
  its temp-file cleanup defer verbatim. The auto-merge had silently
  dropped that defer in favor of this branch's inline cleanup form;
  restored so the cleanup semantics that arrived from main are intact.
- TODO.md: kept both sides' Completed Steps entries.

.golangci.yml resolves to this branch's canonical version
(sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb).

make build and make test are green; #55's code is not yet conformant
with the canonical lint config, which the following commits address.
2026-08-09 13:16:14 +00:00
63fbc98e63 feat: cache size management and LRU eviction (closes #51) (#55)
Some checks failed
check / check (push) Has been cancelled
Implements #51 per the issue DoD and the owner direction comment (issuecomment-44068).

## Behavior

**Config: `cache_max_bytes`** (integrates with the #52/#53 validation framework)

- Strict int64 parsing via a new `getInt64`/`int64Val` getter in the existing strict-loader pattern; the key is registered in the known-keys list. A SET but invalid value — negative, float, null, non-numeric string, boolean, list — aborts startup with exit 1 naming the key and the offending value.
- Explicit values are used exactly as given, any non-negative amount, no floor. `cache_max_bytes: 0` is a valid value that disables the disk cache entirely.
- Omitted: after `state_dir` validation, the default resolves to `max(75% of free bytes on the filesystem containing <state_dir>/cache/, 500 MiB)`. The cache directory is created first and statfs runs on that actual path, so the measurement hits the right filesystem. The probe is injectable (`FreeSpaceProbeFunc`) so tests do not depend on the host disk. The effective limit (and disabled state) is logged at startup.

**Size accounting** (no directory scans on the hot path)

- Migration `002` adds a `variant_content` table — processed variants were previously untracked anywhere — and a `last_accessed_at` column on `source_content`, both indexed. Total usage is two SUM queries.
- Stores record accounting rows; cache hits touch the LRU timestamps (same cost class as the existing per-request stats UPDATEs). The variant accounting insert is best-effort with a warning: the reconciliation pass (below) adopts any file that missed its row, and this keeps the pre-migration inline test schema working.

**Eviction policy: global LRU across both content classes**

- Candidates are the least-recently-used entries from `variant_content` and `source_content` (batched, 100 per class per pass, merged oldest-first by `COALESCE(last_accessed_at, fetched_at)`), evicted until usage is at or below the limit.
- Why global LRU: recency of actual use is the best cheap predictor of future use for a CDN-style cache, and treating both classes in one ordering avoids pathologies of class-priority schemes (e.g. evicting every variant before any cold source blob, which would tank hit rate, or the reverse, which would hoard stale sources). Byte-for-byte, the coldest data goes first regardless of what kind it is. LFU-style schemes need more bookkeeping for marginal gain at this scale.
- Reference safety (the multi-reference DoD case): evicting a source blob deletes ALL `source_metadata` rows referencing it plus 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 DB rows never point at deleted files (the crash window leaves at worst an orphaned file, which reconciliation sweeps). The JSON metadata sidecars for removed rows are deleted as well.

**Triggers, off the request path**

- A background goroutine (started in the handlers OnStart hook, stopped in OnStop) runs an eviction pass on a periodic ticker (5 min) and on write pressure: every store sends a non-blocking notification on a capacity-1 channel. Requests never wait on eviction.
- On startup the goroutine first reconciles accounting with the disk (off the hot path): adopts untracked variant files (size/mtime from disk, content type from the `.meta` sidecar), drops accounting rows whose files are missing, removes source blob files the DB does not know (unreachable, since lookups go through `source_metadata`), removes rows whose files are gone, and sweeps `.tmp-*` files older than an hour.

**`cache_max_bytes: 0` disables the disk cache**

- No cache directories are created, lookups always miss, `StoreSource`/`StoreVariant` are no-ops, no evictor runs; every request fetches and processes uncached. Verified end-to-end (below).

## Notes for review

- At the `imgcache.CacheConfig` layer, disabling is an explicit `DisableDiskCache` flag rather than `MaxBytes == 0`, because existing test fixtures construct `CacheConfig` without `MaxBytes` and rely on the legacy "no limit" behavior; per repo rules those tests were not touched. The config layer maps `cache_max_bytes: 0` to the flag in `handlers`. `MaxBytes == 0` at that layer means "no limit enforced" and is unreachable from production config (the computed default is always at least 500 MiB).
- The negative cache stays active in disabled mode: it is DB-backed (TTL-expired rows in SQLite), not part of the disk cache this issue bounds, and it protects against hammering failing upstreams. Flagging explicitly since the direction said "no cache reads, no cache writes" — I read that as the disk cache; happy to disable it too if intended.
- One deviation from pure red/green: after the red commit I extended the new-test fixture helper (`newEvictionTestCache`) to pass `DisableDiskCache: maxBytes == 0`, mirroring the production mapping, when the flag design emerged. Assertions were not touched; no pre-existing tests were modified.
- Sidecar files (`.meta`, metadata JSON) are not counted in usage; they are bounded by entry counts and small (tens of bytes to ~1 KiB per entry) while content bytes dominate. Documented here for transparency.
- Discovered while working: `Cache.Stats` reads the never-populated `output_content`/`request_cache` tables, so `TotalItems`/`TotalSizeBytes` are always 0. Out of scope here; filing as a separate issue.

## Verification

- TDD: commit `3963ec3` adds the failing tests first (18 new tests covering strict parsing, default computation with injected probe including floor and 75% branches, explicit-no-floor, zero-disables, size accounting, dedup accounting, LRU order, multi-reference blob eviction with the no-dangling-references invariant, under-limit no-op, write-pressure trigger, periodic trigger, reconciliation); implementation follows in `8cb09b6`/`bdd86a4` until green.
- `make check` green (all tests, lint 0 issues, fmt-check) at HEAD.
- Pinned CI lint gate: `docker build --target lint .` green (golangci-lint v2.10.1).
- End-to-end with the built binary:
  - omitted key: startup logs `computed default cache size limit from free space` and `effective cache size limit` (75% of the test host's free space);
  - `cache_max_bytes: banana`: exit 1 with `config key "cache_max_bytes": value "banana" is not an integer`;
  - `cache_max_bytes: 0`: `cache_disabled=true` logged, two identical requests both fetch upstream (2 upstream fetches logged), 200 `image/jpeg` responses, no `cache/` directory created, only `state.sqlite3` in the state dir;
  - enabled: second request served from cache (1 upstream fetch), `variant_content` and `source_content` rows match the on-disk file sizes.

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #55
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-09 13:22:51 +02:00
4f43725705 fix: remove dead nolint:gosec suppressions added for a stale toolchain
All checks were successful
check / check (push) Successful in 1m42s
The 15 //nolint:gosec G703/G704 directives added in 13e9f2c suppressed
findings that only a stale local golangci-lint (v2.10.1) reports; the
repo's pinned golangci-lint:v2.12.2-alpine never raises gosec G703/G704
on these lines, so nolintlint correctly flagged every directive as an
unused suppression and failed the pinned lint build.

Verified with the authoritative docker build --target lint . using the
pinned image, and separately with a locally installed pinned v2.12.2
binary: both report 0 issues after removal.
2026-08-09 00:50:49 +00:00
13e9f2c072 style: suppress gosec G703/G704 taint false positives with justification
Some checks failed
check / check (push) Failing after 42s
The v2.12.2 gosec ruleset's new path-traversal (G703) and SSRF (G704)
taint checks flag os.Stat/os.Remove/os.Rename calls on paths that are
never attacker-controlled: our own temp files created immediately
before in the same function, content-hash- or cache-key-derived
storage paths, the operator-supplied config search path, and the
already SSRF-guarded upstream fetch (protected by ssrfSafeDialer at
the transport layer). Each suppression carries the rule ID and a
one-line justification, matching this repo's existing gosec nolint
convention in internal/imgcache/storage.go. No behavior change.
2026-08-09 00:37:30 +00:00
ce06170604 chore: conform post-merge config validation code to v2.12.2 lint config
All checks were successful
check / check (push) Successful in 1m44s
The stricter canonical .golangci.yml surfaced 81 findings in the
config validation code merged from main (#53). Fix them all with no
behavior change: static sentinel errors wrapped with %w preserving the
existing messages (err113), config key name constants (goconst),
t.Parallel() throughout except the Setenv/Chdir test (paralleltest),
white-box test renamed to config_validation_internal_test.go
(testpackage), case tables extracted into builder functions plus a
shared runAbortCases helper (funlen/dupl/gochecknoglobals), plain
error assignments (noinlineerr), any instead of interface{} and
strings.SplitSeq (modernize), slog.DiscardHandler (sloglint), 88-col
wrapping (lll), and removal of two stale nolint:gosec directives
(nolintlint).
2026-08-07 21:01:03 +00:00
1a15b88971 Merge branch 'main' into golangci-v2.12.2
Resolves conflicts with the startup config validation from #53:
internal/config/config.go takes main's validation implementation
wholesale, with getStringSlice mechanically adapted to this branch's
keyless signature; TODO.md keeps both Completed Steps entries.
2026-08-07 20:49:30 +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
23506df609 chore: update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 2m3s
Replace .golangci.yml with the canonical v2-schema config
(default: all minus six disabled linters, lll 88, tests included)
and bump every golangci-lint pin to v2.12.2:

- Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned)
- script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new
  linux-amd64/arm64 release-archive sha256 pins

Fix all 747 findings the stricter config surfaces, with no behavior
changes: t.Parallel() throughout the test suite, static sentinel
errors and errors.Is comparisons, checked error returns, context
propagation (contextcheck/noctx), 88-column wrapping, extracted
constants and helpers for goconst/dupl/funlen/cyclop, exhaustive
switch cases replicating existing defaults, and white-box test files
renamed to *_internal_test.go for testpackage. Three
nolint:tagliatelle directives preserve the existing snake_case JSON
wire and on-disk metadata formats.
2026-08-07 17:10:27 +00: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
clawbot
85729d9181 fix: update Dockerfile to Go 1.25.4 and resolve gosec lint findings
All checks were successful
check / check (push) Successful in 1m41s
- Update Dockerfile base image from golang:1.24-alpine to golang:1.25.4-alpine
  (pinned by sha256 digest) to match go.mod requirement of go >= 1.25.4
- Fix gosec G703 (path traversal) false positives by adding filepath.Clean()
  at call sites with nolint annotations for internally-constructed paths
- Fix gosec G704 (SSRF) false positive with nolint annotation; URL is already
  validated by validateURL() which checks scheme, resolves DNS, and blocks
  private IPs
- All make check passes clean (lint + tests)
2026-02-25 05:44:49 -08:00
02dedd433b Require signing_key at startup, add default config
- Add config validation: signing_key required, minimum 32 characters
- Server now fails to start without valid signing_key (no more runtime errors)
- Add config.example.yml with default whitelist hosts
- Copy config to /etc/pixa/config.yml in Docker image
- Update entrypoint to use --config /etc/pixa/config.yml
- Add config.dev.yml for local Docker development
- Mount dev config in make devserver
2026-01-08 15:48:37 -08:00
d2e2e319be Create /var/lib/pixa directory in Docker image for database/cache
- Change default StateDir from ./data to /var/lib/pixa (proper Unix convention)
- Create directory owned by pixad user in Dockerfile
- Set WORKDIR to /var/lib/pixa
2026-01-08 15:37:25 -08:00
f244d9c7e0 Add per-host connection limits for upstream fetching
- Add upstream_connections_per_host config option (default: 20)
- Implement per-host semaphores to limit concurrent connections
- Semaphore released when response body is closed
- Prevents overwhelming origin servers with parallel requests
2026-01-08 05:19:20 -08:00
6a20406b0f Add -config flag using cobra to specify config file path 2026-01-08 04:58:05 -08:00
9647829ac9 Support YAML list format for whitelist_hosts config 2026-01-08 04:08:51 -08:00
3fcf9d9146 Add failing test for YAML list format in whitelist_hosts config 2026-01-08 04:08:11 -08:00
fd2d108f9c Wire up image handler endpoint with service orchestration
- Add image proxy config options (signing_key, whitelist_hosts, allow_http)
- Create Service to orchestrate cache, fetcher, and processor
- Initialize image service in handlers OnStart hook
- Implement HandleImage with URL parsing, signature validation, cache
- Implement HandleRobotsTxt for search engine prevention
- Parse query params for signature, quality, and fit mode
2026-01-08 04:01:53 -08:00
29adb6ee47 Rename unused parameters to _ to satisfy linter 2026-01-08 02:27:45 -08:00
d95d31df25 Rename ConfigParams to Params to avoid stuttering 2026-01-08 02:24:20 -08:00
ce25ea8f6f Define DefaultPort and DefaultStateDir constants 2026-01-08 02:21:00 -08:00
516853626d Add basic webserver skeleton with healthcheck 2026-01-08 02:20:23 -08:00