Compare commits
3
Commits
next
..
4637026bc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4637026bc4 | ||
|
|
3aab234a73 | ||
|
|
d4ffcbdf9e |
@@ -1,4 +0,0 @@
|
||||
# Every PR adds an entry at the top of TODO.md's Completed Steps; union keeps
|
||||
# both sides instead of conflicting. Git never reports a conflict here: read
|
||||
# the merged entries after every merge or rebase.
|
||||
TODO.md merge=union
|
||||
+2
-66
@@ -10,20 +10,14 @@ run:
|
||||
|
||||
linters:
|
||||
default: all
|
||||
enable:
|
||||
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||
# left to `default: all`, because it carries the module policy below.
|
||||
- gomodguard_v2
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- depguard # Dependency allow/block lists
|
||||
- godot # Requires comments to end with periods
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
# Deprecated: the warning is attached to the old name, so it is
|
||||
# silenced by disabling that name, not by enabling the successor.
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
@@ -34,64 +28,6 @@ linters:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
depguard:
|
||||
# Test-support code must not be compiled into the shipped binary. A
|
||||
# test-support package exists to hand a test privileges the program
|
||||
# itself must never have, so a file that is not a test must not import
|
||||
# one. Test files, and the files inside a package whose directory name
|
||||
# ends in `test`, are where that code belongs, and are exempt.
|
||||
#
|
||||
# The deny list below is the one part of this file a repository is
|
||||
# expected to extend, and the only part it may. depguard matches an
|
||||
# import path against a list of prefixes, so it cannot be told "any path
|
||||
# whose last segment ends in test"; a repository's own test-support
|
||||
# packages have to be named here one at a time, by full import path,
|
||||
# under a module path that differs from repository to repository. Add
|
||||
# them; change nothing else.
|
||||
rules:
|
||||
test-support:
|
||||
list-mode: lax
|
||||
files:
|
||||
- "$all"
|
||||
- "!$test"
|
||||
- "!**/*test/**"
|
||||
deny:
|
||||
- pkg: net/http/httptest
|
||||
desc: >-
|
||||
Test-support code belongs in test files and in packages whose
|
||||
directory name ends in test, not in the shipped binary.
|
||||
# Only decisions already recorded in the Go package defaults are
|
||||
# listed here. Every entry matches the module path exactly.
|
||||
gomodguard_v2:
|
||||
blocked:
|
||||
- module: github.com/rs/zerolog
|
||||
recommendations:
|
||||
- log/slog
|
||||
reason: "Structured logging is stdlib log/slog."
|
||||
# One entry per pre-fork module path, because the later releases
|
||||
# are separate paths. A prefix match would be shorter but would
|
||||
# also reach github.com/go-redis/redismock, the test double for
|
||||
# the successor these entries recommend.
|
||||
- module: github.com/go-redis/redis
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v7
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v8
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/sergi/go-diff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "No unified diff output; use go-udiff."
|
||||
- module: github.com/hexops/gotextdiff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "Unmaintained fork; use go-udiff."
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
|
||||
@@ -34,66 +34,6 @@ else has a built-in default. A config file mounted at `/etc/pixa/config.yml`
|
||||
is optional: it is read when present, and an environment variable wins over
|
||||
the same setting in it.
|
||||
|
||||
## Deployment
|
||||
|
||||
pixa listens on plain HTTP and runs behind a reverse proxy that terminates TLS.
|
||||
[`configs/Caddyfile`](configs/Caddyfile) is an example for Caddy, chosen because
|
||||
it is the smallest correct one: Caddy gets the TLS certificate itself and does
|
||||
everything in this list without further settings. The reverse proxy must:
|
||||
|
||||
- terminate TLS, as the login and generator pages work only over HTTPS (see
|
||||
Routes);
|
||||
- pass the `Host`, `Origin` and `Referer` headers on unchanged, as pixa refuses
|
||||
a form from those pages unless `Origin` or `Referer` names the host in `Host`,
|
||||
and builds encrypted URLs from `Host`;
|
||||
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
|
||||
the address pixa sees the proxy's requests come from, so the login limit
|
||||
counts each client by its own address (see `trusted_proxies` under
|
||||
Configuration);
|
||||
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
||||
longest pixa takes to fetch, convert and send an image.
|
||||
|
||||
It may also refuse `/metrics`, as the example does, so that only a scraper that
|
||||
reaches pixa directly can read it; pixa itself asks for the metrics username and
|
||||
password there.
|
||||
|
||||
pixa does the rest itself: it checks signatures and encrypted URLs, applies the
|
||||
allowlist, refuses upstream hosts with private or local addresses, limits login
|
||||
attempts, upstream response size and image dimensions, and sends the security
|
||||
headers, `Strict-Transport-Security` included, with every response.
|
||||
|
||||
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
||||
database and the disk cache:
|
||||
|
||||
- It needs a persistent volume: without one, every restart starts with an empty
|
||||
cache. In the container, the startup script gives the directory to the user
|
||||
pixa runs as (uid 65532) and sets its mode to `750`; outside it, that user
|
||||
must be able to write the directory.
|
||||
- `cache_max_bytes` limits the source and transformed images together. The
|
||||
database, the metadata files, the `.meta` file beside each transformed image
|
||||
and files still being written come on top, and eviction runs in the
|
||||
background, so the cache can pass the limit for a while: leave room on the
|
||||
volume beyond it.
|
||||
- Set `cache_max_bytes` for a lasting deployment. Its default, worked out each
|
||||
time pixa starts, is 75% of the sum of the space free on the volume and the
|
||||
space the cached images already take, so a restart keeps the limit the cache
|
||||
had, but anything else that fills or frees space on the volume moves it.
|
||||
|
||||
A load balancer's health check can request `/.well-known/healthcheck.json`,
|
||||
which answers 200 whenever pixa is running, in maintenance mode too (see
|
||||
`maintenance_mode`).
|
||||
|
||||
On SIGTERM or SIGINT pixa stops accepting connections, gives the requests in
|
||||
progress and the images being processed 5 seconds to finish, and exits: with 0,
|
||||
or with 1 when images were still being processed after those 5 seconds or
|
||||
another part of pixa failed to stop. A request not finished by then is cut off.
|
||||
`docker stop` waits 10 seconds before it kills the container.
|
||||
|
||||
Outside Docker, pixa needs libvips (the image has 8.15) and libheif to run, as
|
||||
it uses libvips through CGO; building it also needs their development files,
|
||||
`pkg-config` and a C compiler. `script/bootstrap` installs all of these with
|
||||
nix, apt, brew or apk.
|
||||
|
||||
## Running under upaas
|
||||
|
||||
What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||
@@ -109,7 +49,7 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||
- `PIXA_ALLOWLIST_HOSTS`: upstream hosts served without a signature,
|
||||
comma-separated
|
||||
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
||||
default 75% of (free space + what the cache holds)
|
||||
default 75% of free space
|
||||
- the rest are in the table under Configuration below
|
||||
- **Health check:** the image's `HEALTHCHECK` requests
|
||||
`/.well-known/healthcheck.json`. upaas reads the container's health 60
|
||||
@@ -192,9 +132,8 @@ path under `/v1/` answers 200, in maintenance mode too.
|
||||
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
||||
`uptime_seconds`, `uptime_human`, `version`, `appname` and
|
||||
`maintenance_mode`. Needs: nothing. Answers: 200, always.
|
||||
- `GET /static/<file>` — the stylesheet and script the login and generator
|
||||
pages load. Needs: nothing. Answers: 200, or 404 for a file that does not
|
||||
exist.
|
||||
- `GET /static/<file>` — the script the login and generator pages load. Needs:
|
||||
nothing. Answers: 200, or 404 for a file that does not exist.
|
||||
- `GET /metrics` — Prometheus metrics (see Architecture). Needs: HTTP basic
|
||||
authentication with `metrics.username` and `metrics.password`. Answers: 200;
|
||||
401 without them; 404 when they are not set, as the route then does not exist.
|
||||
@@ -426,7 +365,7 @@ tell. With no file, pixa uses the environment and the defaults.
|
||||
| `PORT` | `port` | Port to listen on; default `8080` |
|
||||
| `PIXA_STATE_DIR` | `state_dir` | Directory for the database and the disk cache; default `/var/lib/pixa` |
|
||||
| `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory |
|
||||
| `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of (free + cached) |
|
||||
| `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of free space |
|
||||
| `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature |
|
||||
| `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones |
|
||||
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
|
||||
@@ -490,17 +429,9 @@ Key settings in more detail:
|
||||
waits for an upstream connection and for a processing slot (up to 10 seconds
|
||||
each), so keep it longer than `upstream_fetch_timeout` plus 20 seconds
|
||||
- `signing_key` — HMAC secret for URL signatures
|
||||
- `db_url` — the SQLite database to open; omitted, it is
|
||||
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
||||
database in WAL mode. pixa adds `_pragma=busy_timeout(5000)` to any `db_url`,
|
||||
so a write that finds another in progress waits up to five seconds for it
|
||||
instead of failing. WAL mode comes only from the URL: keep
|
||||
`_pragma=journal_mode(WAL)` in one you set
|
||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
||||
disk cache entirely; omitted defaults to 75% of the sum of the free space on
|
||||
the filesystem containing `<state_dir>/cache/` and the bytes of source and
|
||||
transformed images the cache already holds, worked out at startup (minimum
|
||||
500 MiB)
|
||||
disk cache entirely; omitted defaults to 75% of the free space on
|
||||
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
|
||||
- `upstream_connections` — the most connections to upstream hosts at once, all
|
||||
hosts together, on top of `upstream_connections_per_host`; default `64`. A
|
||||
fetch holds its connection until its image has been processed. A fetch that
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* branch per issue from `next`
|
||||
* do the work in Next Step
|
||||
* move Next Step to the top of Completed Steps
|
||||
* `TODO.md` merges with git's union merge (`.gitattributes`), which never
|
||||
reports a conflict: read the merged entries after every merge or rebase
|
||||
* move the top item of Future Steps into Next Step
|
||||
* commit (`TODO.md` changes in the same commit as the work)
|
||||
* open a PR based on `next`
|
||||
@@ -31,84 +29,11 @@ P2: security: referer blacklist
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-04 SQLite writes no longer fail with "database is locked" (closes
|
||||
#198): pixa adds `_pragma=busy_timeout(5000)` to every `db_url`, so a write
|
||||
that finds another in progress on another connection waits up to five seconds
|
||||
for it, and the default `db_url` turns on WAL mode with
|
||||
`_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a
|
||||
parameter the driver reads, so the database was never in WAL mode.
|
||||
- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup`
|
||||
only passes through a periodic pass (closes #189): it slept for three
|
||||
eviction intervals before writing its file, and a startup pass still running
|
||||
then could adopt the file itself. It now holds the test database's only
|
||||
connection until the startup pass waits for it after walking the empty
|
||||
variant directory, writes the file and lets the connection go, as
|
||||
`TestEvictionRunsOnPeriodicSchedule` does, so only a periodic reconciliation
|
||||
pass can adopt the file. Test only.
|
||||
- 2026-10-04 logging in, logging out, the URL generator and `/v1/e/` have
|
||||
handler tests (closes #77): new tests in `internal/handlers`, with no
|
||||
network, check that `GET /` without a login session shows the login form; a
|
||||
wrong key shows it again with an error and sets no session cookie; the right
|
||||
key answers 303 to `/` with a session cookie marked `Secure`, `HttpOnly` and
|
||||
`SameSite=Strict`, with which `GET /` shows the generator page; `GET /logout`
|
||||
answers 303 to `/` with an empty session cookie sent with `Max-Age=0`;
|
||||
`POST /generate` without a login session answers 303 to `/`; `/v1/e/` serves
|
||||
the image for a valid token, answers 410 for an expired one and 400 for one
|
||||
with a character changed, cut short or made with another signing key; and a
|
||||
URL made on the generator page is served by `/v1/e/`. No code changes.
|
||||
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root
|
||||
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two
|
||||
branches that each add an entry at the top of Completed Steps merge without a
|
||||
conflict and keep both entries. Git now never reports a conflict in
|
||||
`TODO.md`: a real one keeps both versions of the lines, and two entries that
|
||||
share an identical line can end up one inside the other, which a rebase can
|
||||
do to an entry already on `next`. The Workflow above says to read the merged
|
||||
entries after every merge or rebase.
|
||||
- 2026-10-04 the default `cache_max_bytes` no longer shrinks as the cache fills
|
||||
(closes #184): for an omitted key, the cache works out the limit when it
|
||||
opens, after the database is open, as 75% of the sum of the free space on the
|
||||
filesystem containing `<state_dir>/cache/` and what the cache already holds by
|
||||
its own size accounting, at least 500 MiB, so a cache filled to its limit
|
||||
keeps that limit across a restart. The computation and its tests moved from
|
||||
`internal/config` to `internal/imgcache`; the config only records whether the
|
||||
key was set.
|
||||
- 2026-10-04 `TestEvictionRunsOnPeriodicSchedule` no longer races the evictor
|
||||
(closes #183): it wrote each variant file and then inserted its accounting row
|
||||
by hand, and a reconciliation pass between the two adopted the file first, so
|
||||
the insert failed. It now writes the files only, while holding the test
|
||||
database's only connection so the evictor's startup pass waits after walking
|
||||
the empty variant directory; a periodic reconciliation pass then adopts the
|
||||
files and the eviction pass after it evicts them. No other test in
|
||||
`internal/imgcache` inserts a row by hand after starting the evictor. Test
|
||||
only.
|
||||
- 2026-10-04 a config file pixa cannot read aborts startup (closes #176): of the
|
||||
places pixa looks for its config file on its own, only one where the file does
|
||||
not exist is passed over; any other error, such as a directory on the path
|
||||
that pixa may not enter, aborts startup naming the file, as a file that does
|
||||
not parse already did.
|
||||
- 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57):
|
||||
the deprecated `gomodguard` is switched off, so lint runs print no
|
||||
deprecation warning; its successor `gomodguard_v2` runs with the shared
|
||||
module block list, and `depguard` keeps `net/http/httptest` out of files that
|
||||
are not tests. The tree needed no code changes.
|
||||
- 2026-10-04 the Content-Security-Policy allows no inline script or style
|
||||
(closes #125): `script-src` and `style-src` are `'self'` only. The generator
|
||||
page's two inline `onclick` handlers moved into
|
||||
`internal/static/generator.js`, attached with `addEventListener`; the bundled
|
||||
Tailwind script, which built styles in the browser, is replaced by a small
|
||||
hand-written `internal/static/style.css` with only the rules the login and
|
||||
generator pages use, the templates carrying a few plain class names in place
|
||||
of Tailwind's. No build step. The pages keep their layout, not every pixel of
|
||||
it.
|
||||
- 2026-10-04 deployment guide and example Caddy config (closes #89):
|
||||
"Deployment" in `README.md` says what the reverse proxy in front of pixa must
|
||||
do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set
|
||||
`X-Forwarded-For`, with `trusted_proxies` to match; wait at least
|
||||
`downstream_timeout`; optionally refuse `/metrics`) and what pixa does itself,
|
||||
that the state directory needs a persistent volume and what `cache_max_bytes`
|
||||
counts, the health check for a load balancer, what a stop does and its exit
|
||||
codes, and what running outside Docker needs; `configs/Caddyfile` is the
|
||||
example, checked with `caddy validate`.
|
||||
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and
|
||||
metrics recording have tests (closes #79): `MetricsAuth` on its own answers
|
||||
401 with a challenge without credentials or with a wrong username or password
|
||||
@@ -565,3 +490,6 @@ P2: security: referer blacklist
|
||||
- Prometheus performance metrics
|
||||
- integration tests for the image proxy flow
|
||||
- load tests to verify the 1k to 5k req/s target
|
||||
- P2: documentation
|
||||
- deployment guide
|
||||
- example nginx or caddy reverse proxy config
|
||||
|
||||
+5
-7
@@ -34,10 +34,9 @@ maintenance_mode: false
|
||||
state_dir: ./data
|
||||
|
||||
# SQLite database URL (default:
|
||||
# file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)). pixa adds
|
||||
# _pragma=busy_timeout(5000) to it. An empty value aborts startup; leave the
|
||||
# key out to use the default.
|
||||
# db_url: "file:./data/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||
# file:<state_dir>/state.sqlite3?_journal_mode=WAL). An empty value aborts
|
||||
# startup; leave the key out to use the default.
|
||||
# db_url: "file:./data/state.sqlite3?_journal_mode=WAL"
|
||||
|
||||
# Image proxy settings
|
||||
# HMAC signing key for URL signatures (required, at least 32 characters)
|
||||
@@ -129,9 +128,8 @@ access_control_allow_origin: "*"
|
||||
|
||||
# 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 sum of
|
||||
# the free space on the filesystem containing <state_dir>/cache/ and
|
||||
# the bytes of images the cache already holds, worked out at startup,
|
||||
# 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
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Example Caddy config for running pixa behind Caddy; see "Deployment" in
|
||||
# README.md. Replace images.example.com with pixa's public host name, and
|
||||
# 127.0.0.1:8080 with the address Caddy reaches pixa on.
|
||||
#
|
||||
# Caddy gets and renews the TLS certificate for the host name, passes the
|
||||
# Host, Origin and Referer headers on unchanged, sets X-Forwarded-For to the
|
||||
# client's address, and waits for pixa's answer with no time limit of its
|
||||
# own, so pixa's downstream_timeout is what ends a slow request.
|
||||
|
||||
images.example.com
|
||||
|
||||
# pixa asks for metrics.username and metrics.password on /metrics. This
|
||||
# line also keeps it off the public address, for a scraper that reaches
|
||||
# pixa directly; remove it to read /metrics through Caddy.
|
||||
respond /metrics 404
|
||||
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
@@ -1,10 +1,26 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Static errors returned by the stub free-space probes below.
|
||||
var (
|
||||
errTestStatfsFailed = errors.New("statfs failed")
|
||||
errTestProbeNotExpected = errors.New("probe must not be called")
|
||||
)
|
||||
|
||||
// discardLogger returns a logger that swallows all output, for tests
|
||||
// that exercise code paths which log.
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.DiscardHandler)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -135,30 +151,155 @@ func TestCacheMaxBytesInvalidValuesAbortStartup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheMaxBytesExplicitIsRecorded verifies that an omitted
|
||||
// cache_max_bytes is recorded as not explicit, so the cache works out
|
||||
// the default when it opens, and that an explicit zero is recorded as
|
||||
// explicit, so it disables the disk cache instead.
|
||||
func TestCacheMaxBytesExplicitIsRecorded(t *testing.T) {
|
||||
// TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace verifies the
|
||||
// computed default is 75% of the probed free space when that exceeds
|
||||
// the floor.
|
||||
func TestComputeDefaultCacheMaxBytesUses75PercentOfFreeSpace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||
// 4 GiB free -> 3 GiB default.
|
||||
probe := func(string) (uint64, error) { return 4294967296, nil }
|
||||
|
||||
omitted, err := configFromYAML(t, signingKeyLine)
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
probe := func(string) (uint64, error) { return 0, errTestStatfsFailed }
|
||||
|
||||
_, 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(), keyCacheMaxBytes) {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, "signing_key: "+validTestSigningKey+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||
}
|
||||
|
||||
if omitted.CacheMaxBytesExplicit {
|
||||
t.Error("omitted cache_max_bytes recorded as explicit")
|
||||
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
|
||||
}
|
||||
|
||||
zero, err := configFromYAML(t, signingKeyLine+"cache_max_bytes: 0\n")
|
||||
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
||||
if err != nil {
|
||||
t.Fatalf("cache_max_bytes: 0 must be accepted, got error: %v", err)
|
||||
t.Fatalf("resolveCacheMaxBytes returned error: %v", err)
|
||||
}
|
||||
|
||||
if !zero.CacheMaxBytesExplicit {
|
||||
t.Error("cache_max_bytes: 0 not recorded as explicit")
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
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, errTestProbeNotExpected
|
||||
}
|
||||
|
||||
err = c.resolveCacheMaxBytes(discardLogger(), probe)
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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
|
||||
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if stat.Bsize < 0 {
|
||||
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
|
||||
}
|
||||
|
||||
blockSize := uint64(stat.Bsize)
|
||||
|
||||
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
|
||||
computed = min(computed, math.MaxInt64)
|
||||
|
||||
// gosec cannot see that min() above bounds computed, so it reads
|
||||
// this conversion as potentially overflowing. It cannot: computed is
|
||||
// at most math.MaxInt64 on every path here.
|
||||
//nolint:gosec // G115: clamped to MaxInt64 by min above
|
||||
limit := int64(computed)
|
||||
limit = max(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")
|
||||
|
||||
err := os.MkdirAll(cacheDir, cacheDirPerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config key %q: cannot create cache directory %q: %w",
|
||||
keyCacheMaxBytes, 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
|
||||
}
|
||||
+22
-29
@@ -15,7 +15,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
@@ -93,6 +92,8 @@ var (
|
||||
errMustBeSetTogether = errors.New("must be set together")
|
||||
errMustNotBeNegative = errors.New("must not be negative")
|
||||
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
|
||||
errNegativeBlockSize = errors.New(
|
||||
"statfs reported negative block size")
|
||||
errValueNull = errors.New(
|
||||
"value is null; omit the key entirely to use the default")
|
||||
errValuesNull = errors.New(
|
||||
@@ -169,19 +170,18 @@ type Config struct {
|
||||
// address, and an explicit list replaces the default.
|
||||
TrustedProxies []netip.Prefix
|
||||
|
||||
// CacheMaxBytes is the disk cache size limit in bytes. Only an
|
||||
// explicit zero (CacheMaxBytesExplicit true) disables the disk
|
||||
// cache. Zero with CacheMaxBytesExplicit false means
|
||||
// cache_max_bytes was omitted, and the cache works out the default
|
||||
// limit when it opens.
|
||||
// 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
|
||||
// cacheMaxBytesExplicit records whether cache_max_bytes was
|
||||
// explicitly set, in the environment or the configuration file.
|
||||
// Explicit values are used exactly as given; for an omitted key the
|
||||
// cache works out the default limit when it opens (see
|
||||
// imgcache.CacheConfig.UseDefaultMaxBytes).
|
||||
CacheMaxBytesExplicit bool
|
||||
// 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 from the environment and the
|
||||
@@ -218,13 +218,9 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// An omitted cache_max_bytes is worked out and logged when the
|
||||
// cache opens.
|
||||
if c.CacheMaxBytesExplicit {
|
||||
log.Info("effective cache size limit",
|
||||
"cache_max_bytes", c.CacheMaxBytes,
|
||||
"cache_disabled", c.CacheMaxBytes == 0,
|
||||
)
|
||||
err = c.resolveCacheMaxBytes(log, defaultFreeSpaceProbe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
@@ -303,11 +299,11 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
TrustedProxies: trustedProxies,
|
||||
}
|
||||
|
||||
// The default for an omitted cache_max_bytes is worked out when
|
||||
// the cache opens; here we only record whether the operator set
|
||||
// the key explicitly.
|
||||
// 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 _, present := lookupValue(sc, keyCacheMaxBytes); present {
|
||||
c.CacheMaxBytesExplicit = true
|
||||
c.cacheMaxBytesExplicit = true
|
||||
}
|
||||
|
||||
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||
@@ -320,8 +316,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
settingName(keyDBURL), errValueEmpty)
|
||||
}
|
||||
|
||||
// The driver sets the journal mode only through a _pragma parameter.
|
||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_pragma=journal_mode(WAL)", c.StateDir)
|
||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
||||
}
|
||||
|
||||
if loader.err != nil {
|
||||
@@ -784,12 +779,10 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
||||
for _, path := range configPaths {
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// Only a config file that does not exist is skipped, including
|
||||
// one whose path runs through a file, such as under a HOME of
|
||||
// /dev/null. One that cannot be read or does not parse is a
|
||||
// fatal startup error.
|
||||
// Only a config file that does not exist is skipped. One that
|
||||
// cannot be read or does not parse is a fatal startup error.
|
||||
_, statErr := os.Stat(cleanPath)
|
||||
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
||||
if errors.Is(statErr, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,8 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
|
||||
_ "modernc.org/sqlite" // SQLite driver registration
|
||||
)
|
||||
|
||||
// validTestSigningKey is a 32-character signing key that satisfies the
|
||||
@@ -97,43 +94,12 @@ func TestOmittedValuesUseDefaults(t *testing.T) {
|
||||
t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts)
|
||||
}
|
||||
|
||||
wantDBURL := "file:" + DefaultStateDir +
|
||||
"/state.sqlite3?_pragma=journal_mode(WAL)"
|
||||
wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL"
|
||||
if c.DBURL != wantDBURL {
|
||||
t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultDBURLOpensTheDatabaseInWALMode opens the db_url derived from
|
||||
// state_dir with the SQLite driver pixad uses and checks that the database
|
||||
// is in WAL mode: the driver ignores any parameter it does not know.
|
||||
func TestDefaultDBURLOpensTheDatabaseInWALMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine+"state_dir: "+t.TempDir()+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("config with only state_dir set should be valid, got: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", c.DBURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open %q: %v", c.DBURL, err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
var journalMode string
|
||||
|
||||
err = db.QueryRowContext(t.Context(), "PRAGMA journal_mode").Scan(&journalMode)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read the journal mode of %q: %v", c.DBURL, err)
|
||||
}
|
||||
|
||||
if journalMode != "wal" {
|
||||
t.Errorf("journal mode of %q = %q, want wal", c.DBURL, journalMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
||||
UpstreamConnections: 10,
|
||||
MaxConcurrentProcessing: 3,
|
||||
CacheMaxBytes: 1024,
|
||||
CacheMaxBytesExplicit: true,
|
||||
cacheMaxBytesExplicit: true,
|
||||
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
|
||||
AccessControlAllowOrigin: "https://app.example.com",
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// TestConcurrentWritesAllSucceed opens a database the way pixad does and
|
||||
// writes to it from several goroutines at once, so the writes run on
|
||||
// separate connections, as one request's writes and the background eviction
|
||||
// pass do. Every write must succeed, none failing with "database is locked",
|
||||
// whether or not db_url already has parameters, and the parameters it has
|
||||
// must still apply.
|
||||
func TestConcurrentWritesAllSucceed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantJournalMode string
|
||||
}{
|
||||
{
|
||||
name: "db_url without parameters",
|
||||
query: "",
|
||||
wantJournalMode: "delete",
|
||||
},
|
||||
{
|
||||
name: "db_url with the WAL parameter",
|
||||
query: "?_pragma=journal_mode(WAL)",
|
||||
wantJournalMode: "wal",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbURL := "file:" + filepath.Join(t.TempDir(), "state.sqlite3") + tt.query
|
||||
|
||||
d := &Database{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
config: &config.Config{DBURL: dbURL},
|
||||
}
|
||||
|
||||
err := d.connect(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to %q: %v", dbURL, err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = d.db.Close() })
|
||||
|
||||
writeConcurrently(t, d.db)
|
||||
|
||||
var journalMode string
|
||||
|
||||
err = d.db.QueryRowContext(t.Context(), "PRAGMA journal_mode").
|
||||
Scan(&journalMode)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read the journal mode: %v", err)
|
||||
}
|
||||
|
||||
if journalMode != tt.wantJournalMode {
|
||||
t.Errorf("journal mode = %q, want %q", journalMode, tt.wantJournalMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeConcurrently runs writeLikeOneRequest from several goroutines at once
|
||||
// and checks that every write was made.
|
||||
func writeConcurrently(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
writers = 4
|
||||
requestsEach = 20
|
||||
totalRequests = writers * requestsEach
|
||||
)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for writer := range writers {
|
||||
wg.Go(func() {
|
||||
for request := range requestsEach {
|
||||
key := fmt.Sprintf("%d-%d", writer, request)
|
||||
|
||||
err := writeLikeOneRequest(ctx, db, key)
|
||||
if err != nil {
|
||||
t.Errorf("writer %d: %v", writer, err)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
var hits, sources int
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT hit_count, (SELECT COUNT(*) FROM source_content)
|
||||
FROM cache_stats WHERE id = 1
|
||||
`).Scan(&hits, &sources)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count the writes: %v", err)
|
||||
}
|
||||
|
||||
if hits != totalRequests || sources != totalRequests {
|
||||
t.Errorf("hit_count = %d and %d source_content rows, want %d of each",
|
||||
hits, sources, totalRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLikeOneRequest makes the writes one request and the eviction pass
|
||||
// make: it counts a cache hit, stores a source, records a transformed image
|
||||
// and deletes that record again.
|
||||
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE cache_stats SET hit_count = hit_count + 1 WHERE id = 1`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting a cache hit: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, `INSERT INTO source_content
|
||||
(content_hash, content_type, size_bytes) VALUES (?, 'image/png', 1)`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storing a source: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, `INSERT INTO variant_content
|
||||
(cache_key, size_bytes, content_type) VALUES (?, 1, 'image/png')`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording a transformed image: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx,
|
||||
`DELETE FROM variant_content WHERE cache_key = ?`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evicting a transformed image: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -243,17 +243,7 @@ func (s *Database) DB() *sql.DB {
|
||||
}
|
||||
|
||||
func (s *Database) connect(ctx context.Context) error {
|
||||
// Requests and the eviction pass write on separate connections. With
|
||||
// a busy timeout, a write that finds another one in progress waits up
|
||||
// to five seconds for it instead of failing at once with "database is
|
||||
// locked". The driver runs each _pragma parameter on every connection
|
||||
// it opens.
|
||||
separator := "?"
|
||||
if strings.Contains(s.config.DBURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
dbURL := s.config.DBURL + separator + "_pragma=busy_timeout(5000)"
|
||||
dbURL := s.config.DBURL
|
||||
|
||||
s.log.Info("connecting to database", "url", dbURL)
|
||||
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
"sneak.berlin/go/pixa/internal/session"
|
||||
)
|
||||
|
||||
// formatField is the generator form's format field name.
|
||||
const formatField = "format"
|
||||
|
||||
// Markers telling the login page from the generator page.
|
||||
const (
|
||||
loginForm = `action="/"`
|
||||
loginKeyInput = `name="key"`
|
||||
generatorForm = `action="/generate"`
|
||||
)
|
||||
|
||||
// generatedURLPattern extracts the path of the URL the generator page shows.
|
||||
// The test router runs with debug on, so the URL starts with http, and its
|
||||
// host is httptest's default request host.
|
||||
var generatedURLPattern = regexp.MustCompile(
|
||||
`value="http://example\.com(/v1/e/[^"]+)"`)
|
||||
|
||||
// findSessionCookie returns the session cookie rec sets, or nil if it sets
|
||||
// none.
|
||||
func findSessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == session.CookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestHandleRoot_NoSession_ShowsLoginForm verifies that GET / without a
|
||||
// login session shows the login form.
|
||||
func TestHandleRoot_NoSession_ShowsLoginForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, srv := newCSRFTestRouter(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) {
|
||||
t.Errorf("page is not the login form: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginPost_WrongKey_ShowsErrorWithoutSession verifies that a wrong key
|
||||
// shows the login form again with an error, and sets no session cookie.
|
||||
func TestLoginPost_WrongKey_ShowsErrorWithoutSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, srv := newCSRFTestRouter(t)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, nil)
|
||||
|
||||
rec := postForm(srv, "/", cookies, url.Values{
|
||||
loginKeyField: {"wrong-signing-key-fedcba9876543210"},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, loginForm) || !strings.Contains(body, loginKeyInput) {
|
||||
t.Errorf("page is not the login form: %s", body)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "Invalid signing key") {
|
||||
t.Error("login form does not show the error")
|
||||
}
|
||||
|
||||
if c := findSessionCookie(rec); c != nil {
|
||||
t.Errorf("wrong key set a session cookie: %s", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginPost_RightKey_SetsSessionCookie verifies that the right key answers
|
||||
// 303 to / with a session cookie marked Secure, HttpOnly and SameSite=Strict,
|
||||
// and that GET / with that cookie shows the generator page.
|
||||
func TestLoginPost_RightKey_SetsSessionCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, srv := newCSRFTestRouter(t)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, nil)
|
||||
|
||||
rec := postForm(srv, "/", cookies, url.Values{
|
||||
loginKeyField: {testSigningKey},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
sessionCookie := findSessionCookie(rec)
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("right key set no session cookie")
|
||||
}
|
||||
|
||||
t.Logf("Set-Cookie: %s", sessionCookie)
|
||||
|
||||
if !sessionCookie.Secure {
|
||||
t.Error("session cookie is not Secure")
|
||||
}
|
||||
|
||||
if !sessionCookie.HttpOnly {
|
||||
t.Error("session cookie is not HttpOnly")
|
||||
}
|
||||
|
||||
if sessionCookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Errorf("session cookie SameSite = %v, want Strict", sessionCookie.SameSite)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
req.AddCookie(sessionCookie)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), generatorForm) {
|
||||
t.Errorf("GET / with the session cookie: status = %d, "+
|
||||
"want %d and the generator page", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLogout_ClearsSessionCookie verifies that GET /logout answers 303
|
||||
// to / and replaces the session cookie with an empty one sent with
|
||||
// Max-Age=0, which makes the browser delete it.
|
||||
func TestHandleLogout_ClearsSessionCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, _ := newCSRFTestRouter(t)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/logout", nil)
|
||||
req.AddCookie(newSessionCookie(t, h))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleLogout().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
t.Logf("Set-Cookie: %s", rec.Header().Get("Set-Cookie"))
|
||||
|
||||
sessionCookie := findSessionCookie(rec)
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("logout did not set the session cookie")
|
||||
}
|
||||
|
||||
if sessionCookie.Value != "" {
|
||||
t.Errorf("session cookie value = %q, want empty", sessionCookie.Value)
|
||||
}
|
||||
|
||||
// net/http reads a Max-Age=0 attribute back as MaxAge -1.
|
||||
if sessionCookie.MaxAge != -1 {
|
||||
t.Errorf("session cookie MaxAge = %d, want -1 (Max-Age=0)",
|
||||
sessionCookie.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratePost_NoSession_RedirectsToLogin verifies that POST /generate
|
||||
// with a valid CSRF token but no login session answers 303 to / and makes no
|
||||
// URL.
|
||||
func TestGeneratePost_NoSession_RedirectsToLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, srv := newCSRFTestRouter(t)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, nil)
|
||||
|
||||
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||
sourceURLField: {testSourceURL},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("status = %d, Location = %q, want %d to /",
|
||||
rec.Code, rec.Header().Get("Location"), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||
t.Error("a URL was made without a login session")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratePost_URLServesImage verifies that the URL the generator page
|
||||
// makes is served by /v1/e/. The image route runs on handlers of its own,
|
||||
// made with the same signing key.
|
||||
func TestGeneratePost_URLServesImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, imageSrv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||
|
||||
rec := generatePost(t, url.Values{
|
||||
sourceURLField: {"https://" + signedHost + photoPath},
|
||||
widthField: {"50"},
|
||||
heightField: {"50"},
|
||||
formatField: {string(imgcache.FormatJPEG)},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /generate status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
|
||||
if match == nil {
|
||||
t.Fatalf("generator page shows no URL: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
t.Logf("generated URL path: %s", match[1])
|
||||
|
||||
imageRec := httptest.NewRecorder()
|
||||
imageSrv.ServeHTTP(imageRec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, match[1], nil))
|
||||
|
||||
requireServedPhoto(t, imageRec)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/database"
|
||||
"sneak.berlin/go/pixa/internal/globals"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
)
|
||||
|
||||
// TestNewCacheConfigFromCacheMaxBytes checks the cache configuration
|
||||
// built from cache_max_bytes: omitted, the cache works out the default
|
||||
// limit; 0 turns the disk cache off; a positive value is the limit,
|
||||
// unchanged.
|
||||
func TestNewCacheConfigFromCacheMaxBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const oneGiB = 1 << 30
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cacheMaxBytes int64
|
||||
cacheMaxBytesExplicit bool
|
||||
wantMaxBytes int64
|
||||
wantUseDefaultMaxBytes bool
|
||||
wantDisableDiskCache bool
|
||||
}{
|
||||
{
|
||||
name: "cache_max_bytes omitted",
|
||||
cacheMaxBytes: 0,
|
||||
cacheMaxBytesExplicit: false,
|
||||
wantMaxBytes: 0,
|
||||
wantUseDefaultMaxBytes: true,
|
||||
wantDisableDiskCache: false,
|
||||
},
|
||||
{
|
||||
name: "cache_max_bytes: 0",
|
||||
cacheMaxBytes: 0,
|
||||
cacheMaxBytesExplicit: true,
|
||||
wantMaxBytes: 0,
|
||||
wantUseDefaultMaxBytes: false,
|
||||
wantDisableDiskCache: true,
|
||||
},
|
||||
{
|
||||
name: "cache_max_bytes: 1 GiB",
|
||||
cacheMaxBytes: oneGiB,
|
||||
cacheMaxBytesExplicit: true,
|
||||
wantMaxBytes: oneGiB,
|
||||
wantUseDefaultMaxBytes: false,
|
||||
wantDisableDiskCache: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.Config{
|
||||
CacheMaxBytes: tc.cacheMaxBytes,
|
||||
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
||||
}
|
||||
|
||||
got := newCacheConfig(cfg, nil)
|
||||
t.Logf("MaxBytes = %d, UseDefaultMaxBytes = %v, DisableDiskCache = %v",
|
||||
got.MaxBytes, got.UseDefaultMaxBytes, got.DisableDiskCache)
|
||||
|
||||
if got.MaxBytes != tc.wantMaxBytes {
|
||||
t.Errorf("MaxBytes = %d, want %d", got.MaxBytes, tc.wantMaxBytes)
|
||||
}
|
||||
|
||||
if got.UseDefaultMaxBytes != tc.wantUseDefaultMaxBytes {
|
||||
t.Errorf("UseDefaultMaxBytes = %v, want %v",
|
||||
got.UseDefaultMaxBytes, tc.wantUseDefaultMaxBytes)
|
||||
}
|
||||
|
||||
if got.DisableDiskCache != tc.wantDisableDiskCache {
|
||||
t.Errorf("DisableDiskCache = %v, want %v",
|
||||
got.DisableDiskCache, tc.wantDisableDiskCache)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes starts the handlers
|
||||
// once with cache_max_bytes omitted and once with cache_max_bytes: 0,
|
||||
// and checks by whether the cache directories were created that the
|
||||
// disk cache is on in the first case and off in the second.
|
||||
func TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cacheMaxBytesExplicit bool
|
||||
wantDiskCache bool
|
||||
}{
|
||||
{name: "cache_max_bytes omitted", cacheMaxBytesExplicit: false, wantDiskCache: true},
|
||||
{name: "cache_max_bytes: 0", cacheMaxBytesExplicit: true, wantDiskCache: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
SigningKey: testSigningKey,
|
||||
StateDir: stateDir,
|
||||
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||
CacheMaxBytes: 0,
|
||||
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
||||
}
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New() error = %v", err)
|
||||
}
|
||||
|
||||
db, err := database.New(lc, database.Params{Logger: log, Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = New(lc, Params{Logger: log, Database: db, Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
lc.RequireStart()
|
||||
t.Cleanup(lc.RequireStop)
|
||||
|
||||
_, err = os.Stat(filepath.Join(stateDir, "cache", "variants"))
|
||||
gotDiskCache := err == nil
|
||||
|
||||
if gotDiskCache != tc.wantDiskCache {
|
||||
t.Errorf("cache directories created = %v, want %v",
|
||||
gotDiskCache, tc.wantDiskCache)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,25 +80,18 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
||||
return s.imgSvc.WaitForProcessing(ctx)
|
||||
}
|
||||
|
||||
// newCacheConfig builds the image cache's configuration from cfg.
|
||||
// cache_max_bytes: 0 disables the disk cache entirely; any other value
|
||||
// is the eviction limit in bytes; when it is omitted, the cache works
|
||||
// out the default limit itself.
|
||||
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
||||
return imgcache.CacheConfig{
|
||||
StateDir: cfg.StateDir,
|
||||
CacheTTL: imgcache.DefaultCacheTTL,
|
||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||
MaxBytes: cfg.CacheMaxBytes,
|
||||
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
||||
DisableDiskCache: cfg.CacheMaxBytesExplicit && cfg.CacheMaxBytes == 0,
|
||||
Logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
// initImageService initializes the image cache and service.
|
||||
func (s *Handlers) initImageService() error {
|
||||
cache, err := imgcache.NewCache(s.db.DB(), newCacheConfig(s.config, s.log))
|
||||
// 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{
|
||||
StateDir: s.config.StateDir,
|
||||
CacheTTL: imgcache.DefaultCacheTTL,
|
||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||
MaxBytes: s.config.CacheMaxBytes,
|
||||
DisableDiskCache: s.config.CacheMaxBytes == 0,
|
||||
Logger: s.log,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"image/jpeg"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
)
|
||||
|
||||
// requireServedPhoto requires that rec answers 200 with the JPEG at photoPath
|
||||
// on signedHost at the 50x50 that encPhotoURL and the generator tests ask for.
|
||||
func requireServedPhoto(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body %q",
|
||||
rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if contentType != "image/jpeg" {
|
||||
t.Errorf("Content-Type = %q, want image/jpeg", contentType)
|
||||
}
|
||||
|
||||
img, err := jpeg.DecodeConfig(rec.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("body is not a JPEG: %v", err)
|
||||
}
|
||||
|
||||
if img.Width != 50 || img.Height != 50 {
|
||||
t.Errorf("image is %dx%d, want 50x50", img.Width, img.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleImageEnc_ValidToken_ServesImage verifies that a token made with
|
||||
// the signing key serves the image it asks for.
|
||||
func TestHandleImageEnc_ValidToken_ServesImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, encPhotoURL(t, h), nil))
|
||||
|
||||
requireServedPhoto(t, rec)
|
||||
}
|
||||
|
||||
// TestHandleImageEnc_RejectedToken verifies that a token that has expired
|
||||
// answers 410, and that a token with one character changed, a token cut
|
||||
// short, and a token made with another signing key answer 400. The server
|
||||
// would serve the photo for a token it accepted.
|
||||
func TestHandleImageEnc_RejectedToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler))
|
||||
|
||||
photo := encurl.Payload{
|
||||
SourceHost: signedHost,
|
||||
SourcePath: photoPath,
|
||||
Width: 50,
|
||||
Height: 50,
|
||||
}
|
||||
|
||||
valid, err := h.encGen.Generate(&photo)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
|
||||
expiredPhoto := photo
|
||||
expiredPhoto.ExpiresAt = time.Now().Add(-time.Minute).Unix()
|
||||
|
||||
expired, err := h.encGen.Generate(&expiredPhoto)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
|
||||
otherGen, err := encurl.NewGenerator("another-signing-key-fedcba9876543210")
|
||||
if err != nil {
|
||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||
}
|
||||
|
||||
otherKey, err := otherGen.Generate(&photo)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
|
||||
// Changing a character in the middle always changes the decoded bytes;
|
||||
// the last character of unpadded base64 can carry unused bits.
|
||||
middle := len(valid) / 2
|
||||
|
||||
replacement := "A"
|
||||
if valid[middle] == 'A' {
|
||||
replacement = "B"
|
||||
}
|
||||
|
||||
changed := valid[:middle] + replacement + valid[middle+1:]
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
wantStatus int
|
||||
}{
|
||||
{"expired", expired, http.StatusGone},
|
||||
{"one character changed", changed, http.StatusBadRequest},
|
||||
{"cut short", valid[:middle], http.StatusBadRequest},
|
||||
{"another signing key", otherKey, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := getEncToken(srv, tt.token)
|
||||
t.Logf("GET /v1/e/%s/img.jpg: %d %s", tt.token, rec.Code, rec.Body)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,16 +37,11 @@ type CacheConfig struct {
|
||||
NegativeTTL time.Duration
|
||||
|
||||
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||
// enforces. Zero means no limit is enforced (no 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
|
||||
|
||||
// UseDefaultMaxBytes makes NewCache replace MaxBytes with the
|
||||
// default limit: 75% of the sum of the space free on the filesystem
|
||||
// holding the cache and the bytes the cache already holds, at least
|
||||
// DefaultCacheMaxBytesFloor. The config layer sets this when the
|
||||
// operator omits cache_max_bytes.
|
||||
UseDefaultMaxBytes bool
|
||||
|
||||
// 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
|
||||
@@ -100,14 +95,6 @@ type Cache struct {
|
||||
|
||||
// NewCache creates a new cache instance.
|
||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||
return newCache(db, config, defaultFreeSpaceProbe)
|
||||
}
|
||||
|
||||
// newCache is NewCache with the free-space probe passed in, so tests
|
||||
// can fake the free space the default limit is worked out from.
|
||||
func newCache(
|
||||
db *sql.DB, config CacheConfig, probe FreeSpaceProbeFunc,
|
||||
) (*Cache, error) {
|
||||
log := config.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
@@ -158,20 +145,6 @@ func newCache(
|
||||
c.variants = variants
|
||||
c.srcMetadata = srcMetadata
|
||||
|
||||
if config.UseDefaultMaxBytes {
|
||||
limit, err := c.computeDefaultMaxBytes(context.Background(), probe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.config.MaxBytes = limit
|
||||
|
||||
log.Info("computed default cache size limit from free space and cache contents",
|
||||
"cache_max_bytes", limit,
|
||||
"cache_dir", filepath.Join(config.StateDir, "cache"),
|
||||
)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"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
|
||||
|
||||
// freeSpaceFractionNumerator and freeSpaceFractionDenominator express
|
||||
// the 75% share used for the computed default limit as integer
|
||||
// arithmetic (dividing before multiplying avoids overflow).
|
||||
const (
|
||||
freeSpaceFractionNumerator uint64 = 3
|
||||
freeSpaceFractionDenominator uint64 = 4
|
||||
)
|
||||
|
||||
var errNegativeBlockSize = errors.New("statfs reported negative block size")
|
||||
|
||||
// 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
|
||||
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if stat.Bsize < 0 {
|
||||
return 0, fmt.Errorf("%w %d for %q", errNegativeBlockSize, stat.Bsize, path)
|
||||
}
|
||||
|
||||
blockSize := uint64(stat.Bsize)
|
||||
|
||||
return stat.Bavail * blockSize, nil
|
||||
}
|
||||
|
||||
// computeDefaultMaxBytes returns the default cache size limit: 75% of
|
||||
// the sum of the free bytes probe reports for <state_dir>/cache/ and
|
||||
// the bytes the cache already holds, with a floor of
|
||||
// DefaultCacheMaxBytesFloor. Counting what the cache holds keeps the
|
||||
// limit from shrinking as the cache fills.
|
||||
func (c *Cache) computeDefaultMaxBytes(
|
||||
ctx context.Context, probe FreeSpaceProbeFunc,
|
||||
) (int64, error) {
|
||||
cacheDir := filepath.Join(c.config.StateDir, "cache")
|
||||
|
||||
freeBytes, err := probe(cacheDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"default cache_max_bytes: cannot determine free space for %q: %w",
|
||||
cacheDir, err)
|
||||
}
|
||||
|
||||
usedBytes, err := c.UsageBytes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Both terms are at most math.MaxInt64, so the sum cannot overflow.
|
||||
//nolint:gosec // G115: UsageBytes sums file sizes, never negative
|
||||
spaceBytes := min(freeBytes, math.MaxInt64) + uint64(usedBytes)
|
||||
|
||||
computed := spaceBytes / freeSpaceFractionDenominator * freeSpaceFractionNumerator
|
||||
computed = min(computed, math.MaxInt64)
|
||||
|
||||
// gosec cannot see that min() above bounds computed, so it reads
|
||||
// this conversion as potentially overflowing. It cannot: computed is
|
||||
// at most math.MaxInt64 on every path here.
|
||||
//nolint:gosec // G115: clamped to MaxInt64 by min above
|
||||
limit := int64(computed)
|
||||
limit = max(limit, DefaultCacheMaxBytesFloor)
|
||||
|
||||
return limit, nil
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Static errors returned by the stub free-space probes below.
|
||||
var (
|
||||
errTestStatfsFailed = errors.New("statfs failed")
|
||||
errTestProbeNotExpected = errors.New("probe must not be called")
|
||||
)
|
||||
|
||||
// TestComputeDefaultMaxBytesCountsWhatTheCacheHolds verifies that the
|
||||
// default limit is 75% of the free space plus what the cache already
|
||||
// holds, so a cache filled to its limit keeps that limit across a
|
||||
// restart instead of shrinking to 75% of the space left free.
|
||||
func TestComputeDefaultMaxBytesCountsWhatTheCacheHolds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
// Empty cache, 4 GiB free -> 3 GiB default.
|
||||
got, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||
func(string) (uint64, error) { return 4294967296, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("default for an empty cache with 4 GiB free: %d", got)
|
||||
|
||||
if got != 3221225472 {
|
||||
t.Errorf("default for an empty cache = %d, want 3221225472 (75%% of 4 GiB)",
|
||||
got)
|
||||
}
|
||||
|
||||
// The cache now holds those 3 GiB, which leaves 1 GiB free.
|
||||
_, err = cache.db.ExecContext(t.Context(),
|
||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||
VALUES (?, ?, ?)`,
|
||||
string(testVariantKeyOne), 3221225472, testContentTypeWebP,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||
}
|
||||
|
||||
got, err = cache.computeDefaultMaxBytes(t.Context(),
|
||||
func(string) (uint64, error) { return 1073741824, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("default for a cache holding 3 GiB with 1 GiB free: %d", got)
|
||||
|
||||
if got != 3221225472 {
|
||||
t.Errorf("default for a cache holding 3 GiB with 1 GiB free = %d, "+
|
||||
"want 3221225472 (75%% of 1 GiB + 3 GiB)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeDefaultMaxBytesAppliesFloor verifies that when 75% of the
|
||||
// free space plus what the cache holds is below 500 MiB, the default
|
||||
// is floored at DefaultCacheMaxBytesFloor.
|
||||
func TestComputeDefaultMaxBytesAppliesFloor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
got, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||
func(string) (uint64, error) { return tc.freeBytes, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("computeDefaultMaxBytes returned error: %v", err)
|
||||
}
|
||||
|
||||
if got != DefaultCacheMaxBytesFloor {
|
||||
t.Errorf("computeDefaultMaxBytes = %d, want floor %d",
|
||||
got, DefaultCacheMaxBytesFloor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeDefaultMaxBytesPropagatesProbeError verifies that a
|
||||
// failing free-space probe produces an error naming cache_max_bytes,
|
||||
// instead of a silently wrong default.
|
||||
func TestComputeDefaultMaxBytesPropagatesProbeError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
_, err := cache.computeDefaultMaxBytes(t.Context(),
|
||||
func(string) (uint64, error) { return 0, errTestStatfsFailed })
|
||||
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 cache_max_bytes", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCacheComputesDefaultMaxBytesWhenAsked verifies that with
|
||||
// UseDefaultMaxBytes set, the cache's limit becomes the computed
|
||||
// default, and that the probe is pointed at <state_dir>/cache/, which
|
||||
// must be created first so statfs measures the right filesystem.
|
||||
func TestNewCacheComputesDefaultMaxBytesWhenAsked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
wantCacheDir := filepath.Join(stateDir, "cache")
|
||||
|
||||
var probedPath string
|
||||
|
||||
// 4 GiB free -> 3 GiB default.
|
||||
probe := func(path string) (uint64, error) {
|
||||
probedPath = path
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Errorf("cache directory %q was not created before probing: info=%v err=%v",
|
||||
path, info, err)
|
||||
}
|
||||
|
||||
return 4294967296, nil
|
||||
}
|
||||
|
||||
cache, err := newCache(evictionTestDB(t), CacheConfig{
|
||||
StateDir: stateDir,
|
||||
UseDefaultMaxBytes: true,
|
||||
}, probe)
|
||||
if err != nil {
|
||||
t.Fatalf("newCache returned error: %v", err)
|
||||
}
|
||||
|
||||
if cache.config.MaxBytes != 3221225472 {
|
||||
t.Errorf("MaxBytes = %d, want computed default 3221225472",
|
||||
cache.config.MaxBytes)
|
||||
}
|
||||
|
||||
if probedPath != wantCacheDir {
|
||||
t.Errorf("free space probed at %q, want cache directory %q",
|
||||
probedPath, wantCacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCacheKeepsExplicitMaxBytes verifies that without
|
||||
// UseDefaultMaxBytes the cache keeps MaxBytes exactly as given and
|
||||
// never consults the free-space probe.
|
||||
func TestNewCacheKeepsExplicitMaxBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
probe := func(string) (uint64, error) {
|
||||
t.Error("free-space probe must not be consulted for explicit values")
|
||||
|
||||
return 0, errTestProbeNotExpected
|
||||
}
|
||||
|
||||
cache, err := newCache(evictionTestDB(t), CacheConfig{
|
||||
StateDir: t.TempDir(),
|
||||
MaxBytes: 1024,
|
||||
}, probe)
|
||||
if err != nil {
|
||||
t.Fatalf("newCache returned error: %v", err)
|
||||
}
|
||||
|
||||
if cache.config.MaxBytes != 1024 {
|
||||
t.Errorf("MaxBytes = %d, want explicit 1024 (no floor, no recompute)",
|
||||
cache.config.MaxBytes)
|
||||
}
|
||||
}
|
||||
@@ -681,11 +681,6 @@ func TestEvictionRunsUnderWritePressure(t *testing.T) {
|
||||
assertNoDanglingReferences(t, cache)
|
||||
}
|
||||
|
||||
// TestEvictionRunsOnPeriodicSchedule writes three variant files straight
|
||||
// to disk, bypassing StoreVariant, so they have no accounting rows and no
|
||||
// write-pressure notification fires. Only a periodic reconciliation pass
|
||||
// can then adopt them, and only the eviction pass that follows it can
|
||||
// evict them.
|
||||
func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -693,29 +688,13 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||
|
||||
cache, _ := newEvictionTestCache(t, limit)
|
||||
|
||||
// Hold the test database's only connection, so the startup pass
|
||||
// waits for it after walking the still empty variant directory: the
|
||||
// files written while it waits are first seen by a periodic pass.
|
||||
conn, err := cache.db.Conn(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to take the database connection: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// 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 func() { _ = cache.StopEviction(t.Context()) }()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
for cache.db.Stats().WaitCount == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the startup pass never waited for the database")
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
keys := []VariantKey{
|
||||
testVariantKeyOne, testVariantKeyTwo, testVariantKeyThree,
|
||||
}
|
||||
@@ -724,43 +703,25 @@ func TestEvictionRunsOnPeriodicSchedule(t *testing.T) {
|
||||
for i, key := range keys {
|
||||
content := bytes.Repeat([]byte{fills[i]}, 1000)
|
||||
|
||||
_, err = cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
||||
_, err := cache.variants.Store(key, bytes.NewReader(content), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to store variant file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
// Only one of the 1000-byte files fits under the limit: wait until
|
||||
// the evictor has removed the other two.
|
||||
stored := len(keys)
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
|
||||
for stored > 1 && time.Now().Before(deadline) {
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
|
||||
stored = 0
|
||||
|
||||
for _, key := range keys {
|
||||
if cache.variants.Exists(key) {
|
||||
stored++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stored > 1 {
|
||||
t.Fatalf("periodic schedule did not trigger eviction: %d of %d "+
|
||||
"variant files still on disk, want at most 1", stored, len(keys))
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(t.Context())
|
||||
_, err = cache.db.ExecContext(t.Context(),
|
||||
`INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||
VALUES (?, ?, ?)`,
|
||||
string(key), len(content), "image/webp",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes failed: %v", err)
|
||||
t.Fatalf("failed to insert variant accounting row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
usage := waitForUsageAtOrBelow(t, cache, limit, 5*time.Second)
|
||||
if usage > limit {
|
||||
t.Errorf("usage after eviction = %d, want <= %d", usage, limit)
|
||||
t.Errorf("periodic schedule did not trigger eviction: usage = %d, want <= %d",
|
||||
usage, limit)
|
||||
}
|
||||
|
||||
assertNoDanglingReferences(t, cache)
|
||||
@@ -847,28 +808,15 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
// Hold the test database's only connection, so the startup pass
|
||||
// waits for it after walking the still empty variant directory: the
|
||||
// file written while it waits is first seen by a periodic pass.
|
||||
conn, err := cache.db.Conn(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to take the database connection: %v", err)
|
||||
}
|
||||
const interval = 100 * time.Millisecond
|
||||
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
cache.StartEviction(100 * time.Millisecond)
|
||||
cache.StartEviction(interval)
|
||||
defer func() { _ = cache.StopEviction(t.Context()) }()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
for cache.db.Stats().WaitCount == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the startup pass never waited for the database")
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
// Let startup reconciliation run and settle on an empty cache
|
||||
// before introducing the untracked file, so the adoption we assert
|
||||
// below can only be the work of a later, periodic pass.
|
||||
time.Sleep(3 * interval)
|
||||
|
||||
// Simulate a variant whose accounting insert failed after the
|
||||
// process was already running and serving requests: the content
|
||||
@@ -877,16 +825,14 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) {
|
||||
// insert had failed and only the file write had succeeded.
|
||||
untracked := bytes.Repeat([]byte{0x41}, 900)
|
||||
|
||||
_, err = cache.variants.Store(
|
||||
_, err := cache.variants.Store(
|
||||
"aabbccdd0099", bytes.NewReader(untracked), "image/webp",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to store untracked variant file: %v", err)
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
var usage int64
|
||||
|
||||
|
||||
@@ -35,10 +35,13 @@ const HSTSValue = "max-age=31536000; includeSubDomains"
|
||||
|
||||
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
|
||||
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
|
||||
// clickjacking control.
|
||||
// clickjacking control. 'unsafe-inline' is required in script-src and style-src
|
||||
// because the served templates carry inline onclick handlers (generator page)
|
||||
// and the bundled Tailwind asset injects a runtime <style> element; dropping it
|
||||
// needs template changes outside this issue's scope.
|
||||
const ContentSecurityPolicyValue = "default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
|
||||
@@ -325,13 +325,6 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// The login and generator pages load their script and stylesheet from
|
||||
// /static, so the policy allows no inline script or style.
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
if strings.Contains(csp, "unsafe-inline") {
|
||||
t.Errorf("Content-Security-Policy allows unsafe-inline: %q", csp)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
header string
|
||||
want string
|
||||
@@ -340,8 +333,8 @@ func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
||||
{
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self'; " +
|
||||
"style-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
|
||||
@@ -53,7 +53,7 @@ func (s *Server) SetupRoutes() {
|
||||
// Robots.txt
|
||||
s.router.Get("/robots.txt", s.h.HandleRobotsTxt())
|
||||
|
||||
// The login and generator pages' stylesheet and script
|
||||
// Static files (Tailwind CSS, etc.)
|
||||
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
||||
|
||||
// Login/generator UI. The form routes carry CSRF protection; the
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// Generator page: a click on the generated URL selects it, and the Copy
|
||||
// button copies it. Both are on the page only once a URL has been generated.
|
||||
const generatedURL = document.getElementById("generated-url");
|
||||
|
||||
if (generatedURL) {
|
||||
generatedURL.addEventListener("click", () => generatedURL.select());
|
||||
document.getElementById("copy-url").addEventListener("click", () => {
|
||||
navigator.clipboard.writeText(generatedURL.value);
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed *.css *.js
|
||||
//go:embed *.js
|
||||
var files embed.FS
|
||||
|
||||
// FS returns the embedded filesystem containing static files.
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/* The login and generator pages. */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f3f4f6;
|
||||
font-family: system-ui, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
form > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 10%),
|
||||
0 2px 4px -2px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #f87171;
|
||||
border-radius: 0.25rem;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Login page: the card centred on the screen. */
|
||||
|
||||
.login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login .card {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Generator page. */
|
||||
|
||||
.page {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
header a {
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
header a:hover {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 0.5rem;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.result h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.result div {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result input {
|
||||
flex: 1;
|
||||
border-color: #86efac;
|
||||
box-shadow: none;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.result button {
|
||||
width: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #16a34a;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.result button:hover {
|
||||
background: #15803d;
|
||||
}
|
||||
|
||||
.result p {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -4,47 +4,52 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pixa - URL Generator</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/tailwind.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header>
|
||||
<h1>Pixa URL Generator</h1>
|
||||
<a href="/logout">
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<div class="max-w-2xl mx-auto py-8 px-4">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800">Pixa URL Generator</h1>
|
||||
<a href="/logout" class="text-sm text-gray-600 hover:text-gray-800 underline">
|
||||
Logout
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{{if .GeneratedURL}}
|
||||
<div class="result">
|
||||
<h2>Generated URL</h2>
|
||||
<div>
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<h2 class="text-sm font-medium text-green-800 mb-2">Generated URL</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value="{{.GeneratedURL}}"
|
||||
id="generated-url"
|
||||
class="flex-1 px-3 py-2 bg-white border border-green-300 rounded-md text-sm font-mono"
|
||||
onclick="this.select()"
|
||||
>
|
||||
<button
|
||||
onclick="navigator.clipboard.writeText(document.getElementById('generated-url').value)"
|
||||
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||
>
|
||||
<button id="copy-url">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<p class="text-xs text-green-600 mt-2">
|
||||
Expires: {{.ExpiresAt}}
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error">
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/generate" class="card">
|
||||
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
|
||||
{{ .CSRFField }}
|
||||
<div>
|
||||
<label for="url">
|
||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Source URL
|
||||
</label>
|
||||
<input
|
||||
@@ -54,12 +59,13 @@
|
||||
required
|
||||
placeholder="https://example.com/image.jpg"
|
||||
value="{{.FormURL}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="width">
|
||||
<label for="width" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Width
|
||||
</label>
|
||||
<input
|
||||
@@ -70,10 +76,11 @@
|
||||
max="8192"
|
||||
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
|
||||
placeholder="0 = original"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="height">
|
||||
<label for="height" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Height
|
||||
</label>
|
||||
<input
|
||||
@@ -84,16 +91,21 @@
|
||||
max="8192"
|
||||
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
|
||||
placeholder="0 = original"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="format">
|
||||
<label for="format" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Format
|
||||
</label>
|
||||
<select id="format" name="format">
|
||||
<select
|
||||
id="format"
|
||||
name="format"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
|
||||
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
|
||||
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
|
||||
@@ -103,10 +115,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="quality">
|
||||
<label for="quality" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Quality
|
||||
</label>
|
||||
<select id="quality" name="quality">
|
||||
<select
|
||||
id="quality"
|
||||
name="quality"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="25" {{if eq .FormQuality "25"}}selected{{end}}>Potato</option>
|
||||
<option value="50" {{if eq .FormQuality "50"}}selected{{end}}>Low</option>
|
||||
<option value="70" {{if eq .FormQuality "70"}}selected{{end}}>Medium</option>
|
||||
@@ -116,12 +132,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="fit">
|
||||
<label for="fit" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fit Mode
|
||||
</label>
|
||||
<select id="fit" name="fit">
|
||||
<select
|
||||
id="fit"
|
||||
name="fit"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="cover" {{if eq .FormFit "cover"}}selected{{end}}>Cover</option>
|
||||
<option value="contain" {{if eq .FormFit "contain"}}selected{{end}}>Contain</option>
|
||||
<option value="fill" {{if eq .FormFit "fill"}}selected{{end}}>Fill</option>
|
||||
@@ -130,10 +150,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="ttl">
|
||||
<label for="ttl" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Expires In
|
||||
</label>
|
||||
<select id="ttl" name="ttl">
|
||||
<select
|
||||
id="ttl"
|
||||
name="ttl"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="0" {{if or (eq .FormTTL "0") (eq .FormTTL "")}}selected{{end}}>Never</option>
|
||||
<option value="60" {{if eq .FormTTL "60"}}selected{{end}}>1 minute</option>
|
||||
<option value="3600" {{if eq .FormTTL "3600"}}selected{{end}}>1 hour</option>
|
||||
@@ -145,15 +169,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
Generate Encrypted URL
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="note">
|
||||
<p class="text-xs text-gray-500 mt-4 text-center">
|
||||
Generated URLs are encrypted and cannot be modified. They will expire at the specified time.
|
||||
</p>
|
||||
</div>
|
||||
<script src="/static/generator.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,22 +4,22 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pixa - Login</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/tailwind.js"></script>
|
||||
</head>
|
||||
<body class="login">
|
||||
<div class="card">
|
||||
<h1>Pixa Image Proxy</h1>
|
||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">Pixa Image Proxy</h1>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error">
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/">
|
||||
<form method="POST" action="/" class="space-y-4">
|
||||
{{ .CSRFField }}
|
||||
<div>
|
||||
<label for="key">
|
||||
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Signing Key
|
||||
</label>
|
||||
<input
|
||||
@@ -28,11 +28,15 @@
|
||||
name="key"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter your signing key"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user