Commit Graph

25 Commits

Author SHA1 Message Date
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
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
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
be293906bc Add type-safe hash types for cache storage
Define ContentHash, VariantKey, and PathHash types to replace
raw strings, providing compile-time type safety for storage
operations. Update storage layer to use typed parameters,
refactor cache to use variant storage keyed by VariantKey,
and implement source content reuse on cache misses.
2026-01-08 16:55:20 -08:00
87a8393537 Change encrypted URL format to /v1/e/{token}/img.{ext}
Add trailing filename to encrypted URLs for better browser compatibility.
The filename is ignored by the server but helps browsers identify content type.
2026-01-08 16:14:01 -08:00
3849128c45 Remove runtime nil checks for always-initialized components
Since signing_key is now required at config load time, sessMgr, encGen,
and signer are always initialized. Remove unnecessary nil checks that
were runtime failure paths that can no longer be reached.

- handlers.go: Remove conditional init, always create sessMgr/encGen
- auth.go: Remove nil checks for sessMgr
- imageenc.go: Remove nil check for encGen
- service.go: Require signing_key in NewService, remove signer nil checks
- Update tests to provide signing_key
2026-01-08 15:58:44 -08:00
9bfae69ccf Fix logging: add response_bytes to middleware, cache_key to handler
- Middleware now tracks and logs bytes written via response_bytes
- Handler logs cache_key for cache hit debugging
- Changed "served encrypted image" to "image served" (only URL is encrypted)
2026-01-08 13:05:10 -08:00
77c6744383 Add upstream connection info and download metrics to logging
- Capture TLS version, cipher suite, HTTP version, and remote addr
- Add download bitrate using go-humanize SI formatting
- Use consistent WxH format for dimensions (not struct notation)
- Rename input/output to src/dst for consistency
- Add separate "upstream fetched" log with connection details
2026-01-08 12:47:31 -08:00
b55b75cbe7 Fix silent fallbacks for unsupported formats and fit modes
- Return ErrUnsupportedOutputFormat for WebP/AVIF encoding
- Return ErrInvalidFitMode for unknown fit mode values
- Add ValidateFitMode() for input validation
- Validate fit mode at handler level before processing

Silent fallbacks violate the principle of least surprise and mask bugs.
When a user explicitly specifies a value, we should either honor it or
return an error - never silently substitute a different value.
2026-01-08 11:08:22 -08:00
014c144d73 Add 'Never' expiry option for encrypted URLs
- Make ExpiresAt optional in CBOR (omitempty) for smaller tokens
- Treat ExpiresAt=0 as 'never expires' in parser
- URL-encode token with url.PathEscape() for safety
- Add 'Never' as default TTL option in generator form
2026-01-08 11:08:11 -08:00
1f809a6fc9 Implement ETag, HEAD requests, and conditional requests
- Add ETag generation based on output content hash (first 16 chars)
- Add ContentLength to ImageResponse from cache
- Add LoadWithSize method to ContentStorage
- Add GetOutputWithSize method to Cache
- Handle HEAD requests returning headers only
- Handle If-None-Match conditional requests returning 304
- Register HEAD route for image proxy endpoint
2026-01-08 10:08:38 -08:00
4df3e44eff Add failing tests for ETag, HEAD requests, and conditional requests
TDD: Write tests first before implementation for:
- ETag generation and consistency in service layer
- HEAD request support (headers only, no body)
- Conditional requests with If-None-Match header (304 responses)
2026-01-08 10:06:18 -08:00
1f0ec59eb5 Wire up auth routes and encrypted URL endpoint
Add session manager and encurl generator to handlers.
Register /, /logout, /generate, /v1/e/{token}, /static/* routes.
2026-01-08 07:38:44 -08:00
08d6e264ed Add auth and encrypted image handlers
Login page, logout, URL generator form, and /v1/e/{token}
endpoint for serving images from encrypted URLs.
2026-01-08 07:38:15 -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
6304556837 Refactor to serve all responses from cached files on disk
- StoreOutput now returns output hash for immediate retrieval
- Cache misses now serve from disk file after storing (same as hits)
- Log served_bytes from actual io.Copy result (avoids stat calls)
- Remove ContentLength field usage for cache hits (stream from file)
- Fix tests to properly check all return values
2026-01-08 05:11:55 -08:00
1a97f42cd8 Add detailed logging for image requests with cache status and timing 2026-01-08 05:04:08 -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
1ac16bcfb4 Remove unused respondError function 2026-01-08 02:30:13 -08:00
453fd22ce2 Add doc comment to HandleHealthCheck method 2026-01-08 02:28:30 -08:00
29adb6ee47 Rename unused parameters to _ to satisfy linter 2026-01-08 02:27:45 -08:00
43e4172fb3 Rename HandlersParams to Params to avoid stuttering 2026-01-08 02:25:00 -08:00
516853626d Add basic webserver skeleton with healthcheck 2026-01-08 02:20:23 -08:00