12 Commits
Author SHA1 Message Date
clawbot 2a8ce05335 Give the login rate limit tests the default request timeout (closes #61)
check / check (push) Successful in 3m14s
newTestServer left downstream_timeout at zero, so every request in those
tests ran with an already expired per-request timeout. It now uses
config.DefaultDownstreamTimeout, as a real config would. Approved by the
owner on the issue.

Model: opus-5-5
2026-09-29 06:10:58 +00:00
clawbot d46e42b747 State the origin rule in README.md and config.example.yml (closes #61)
check / check (push) Successful in 2m47s
Both now say what access_control_allow_origin accepts, instead of
"exactly as the browser sends it", and that another scheme, such as a
browser extension's, aborts startup.

Model: opus-5-5
2026-09-29 00:19:28 +00:00
clawbot 2055b557e3 Test that an origin with an upper case scheme aborts startup (closes #61)
Model: opus-5-5
2026-09-29 00:19:28 +00:00
clawbot facf84299c Accept only an origin written exactly as a browser sends it (closes #61)
access_control_allow_origin is now "*", or http or https, a host that is
an IP address as net/netip writes it (IPv6 in brackets) or a lowercase
host name whose last part contains a letter, and an optional port 1-65535
with no leading zero that is not the scheme's default. The value must
equal the origin rebuilt from those parts; anything else aborts startup
naming the key, its variable and the value. README.md and
config.example.yml say an origin is scheme, host and optional port,
exactly as the browser sends it.

Model: opus-5-5
2026-09-29 00:19:28 +00:00
clawbot af5f98b867 Test that origins a browser does not send abort startup (closes #61)
Adds the review's examples to invalidSizeAndOriginCases: a scheme's
default port, a port with a leading zero, hosts a browser reads as an
IPv4 address or refuses, an IPv6 address not in its shortest form,
plus a scheme other than http or https and a host name in upper case.

Model: opus-5-5
2026-09-29 00:19:18 +00:00
clawbot 84ec9a6080 Check the origin's host and port; cap the size at 1 GiB (closes #61)
access_control_allow_origin now needs a host name (ASCII letters,
digits, hyphens, dots) or an IP address, and a port, when given, from 1
to 65535, read with net/url, net/netip and strconv. Two hosts, an empty
port, no host, a bad port or a non-ASCII host name abort startup, as a
* inside the value already did.

upstream_max_response_size above 1 GiB aborts startup: the image
processor reads one byte past the limit, which wrapped negative at the
largest 64-bit value, and a response is held whole in memory.
config.example.yml states the maximum.

Model: opus-5-5
2026-09-29 00:19:18 +00:00
clawbot 256fc71511 Test origin host and port checks and a 1 GiB size maximum (closes #61)
Failing cases: access_control_allow_origin with two hosts, an empty
port, no host, port 0, port 99999 or a non-ASCII host name, and
upstream_max_response_size above 1 GiB, up to the largest 64-bit
integer, where the image processor's limit wraps negative. Each must
abort startup naming the key and the value. The bad origins are now one
list so the table fits the linter's function length limit; the size
test uses 1 GiB to show the maximum itself is accepted, and IPv4 and
IPv6 origins are shown to be accepted.

Model: opus-5-5
2026-09-29 00:19:07 +00:00
clawbot 44b3885f41 Reword the comment on TestCORSAnswersWithConfiguredOrigin (closes #61)
The comment was not a readable sentence; it is now one plain sentence
saying what the test checks.

Model: opus-5-5
2026-09-29 00:19:07 +00:00
clawbot 3fdb7faab1 Refuse an origin with a * inside at startup (closes #61)
access_control_allow_origin accepted values such as https://* or
https://*example.com, which the CORS middleware reads as a pattern that
lets other sites read responses. Any value that contains * and is not
exactly * now aborts startup naming the key, its variable and the value.

Model: opus-5-5
2026-09-29 00:19:07 +00:00
clawbot 7a969bb225 Test that an origin with a * inside aborts startup (closes #61)
Failing cases for access_control_allow_origin set to https://*,
https://*.example.com and https://*example.com. The CORS middleware
reads a * inside an origin as a pattern, so these would let other sites
read responses; each must abort startup naming the key and the value.

Model: opus-5-5
2026-09-29 00:19:07 +00:00
clawbot 2cd328d2da Add the four settings the README documented but pixa lacked (closes #61)
access_control_allow_origin, upstream_fetch_timeout,
upstream_max_response_size and downstream_timeout were in the README
but unknown to pixa, so a config that followed it aborted startup. Each
is now a setting with its PIXA_ variable, defaulting to the value that
was fixed in the code: *, 30s, 50 MiB and 60s. Durations are positive Go
duration strings; the size is a positive whole number of bytes; the
origin is * or one scheme and host. An invalid value aborts startup
naming the key and value. downstream_timeout replaces HTTPWriteTimeout
for the server's write timeout and the per-request timeout.

Model: opus-5-5
2026-09-29 00:19:07 +00:00
clawbot 5fcbad8515 Test four README settings pixa does not have yet (closes #61)
Failing tests for access_control_allow_origin, upstream_fetch_timeout,
upstream_max_response_size and downstream_timeout: their defaults,
valid values from the file and the environment, invalid values aborting
startup naming the key or variable and the value, the CORS middleware
answering with the configured origin, and the server's write timeout
coming from downstream_timeout. They do not compile until the settings
exist.

Model: opus-5-5
2026-09-29 00:18:54 +00:00
18 changed files with 87 additions and 799 deletions
+2 -6
View File
@@ -68,12 +68,8 @@ RUN apk add --no-cache \
COPY --from=builder /pixad /usr/local/bin/pixad COPY --from=builder /pixad /usr/local/bin/pixad
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Create non-root user, config directory, and data directory. pixad # Create non-root user, config directory, and data directory
# gets uid and gid 65532, which host login and system accounts do not RUN adduser -D -H -s /sbin/nologin pixad && \
# use: a bind-mounted /var/lib/pixa is given to pixad, and on the host
# it must not belong to a person's account.
RUN addgroup -g 65532 pixad && \
adduser -D -H -s /sbin/nologin -u 65532 -G pixad pixad && \
mkdir -p /var/lib/pixa /etc/pixa && \ mkdir -p /var/lib/pixa /etc/pixa && \
chown pixad:pixad /var/lib/pixa chown pixad:pixad /var/lib/pixa
+18 -48
View File
@@ -58,10 +58,8 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
`healthy`. The probe uses the port from `PORT` (default `8080`), so a `healthy`. The probe uses the port from `PORT` (default `8080`), so a
port changed only in a mounted config file is not seen by it: change port changed only in a mounted config file is not seen by it: change
the port with `PORT`. the port with `PORT`.
- **First run:** create the host directory, owned by root or by uid - **First run:** create the host directory. It may be owned by root: the
`65532` and gid `65532`. The server runs as the container's `pixad` container gives it to its `pixad` user when it starts.
user, which has that uid and gid, and the container gives the
directory to `pixad` when it starts.
## Rationale ## Rationale
@@ -102,29 +100,16 @@ than once, is refused with 400.
- `<format>`: one of `orig`, `png`, `jpeg`, `webp` - `<format>`: one of `orig`, `png`, `jpeg`, `webp`
- `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`) - `<size>`: `orig` or `<width>x<height>` (e.g. `800x600`)
An image is served with `Cache-Control: public, max-age=<seconds>, immutable`.
When the URL has an expiry (an `exp`, or the TTL of an encrypted URL),
`max-age` is the whole seconds left until then, at most one year, so no browser
or proxy cache keeps the image after pixa would refuse the URL. A URL with no
expiry gets one year. `immutable` only stops a client revalidating while its
copy is fresh.
The login form (`POST /`) is limited to 5 attempts per minute per client The login form (`POST /`) is limited to 5 attempts per minute per client
address, counting an IPv6 client by its /64; an attempt over the limit is address, counting an IPv6 client by its /64; an attempt over the limit is
refused with 429 and a `Retry-After` header. Behind a reverse proxy the client refused with 429 and a `Retry-After` header. Behind a reverse proxy the client
address comes from `X-Forwarded-For` only when the address pixa sees for address comes from `X-Forwarded-For` only when the proxy's address is in
requests that come through the proxy is in `trusted_proxies`; otherwise all `trusted_proxies`; otherwise all users behind the proxy are counted as one
users behind the proxy are counted as one client. That address is not always client. With the default `trusted_proxies` (the RFC 1918 ranges), a client
the proxy's own: a proxy on the Docker host that connects to pixa over with a private address can choose the address it is counted by through its own
`127.0.0.1` is seen as the gateway of the container's Docker network, such as `X-Forwarded-For`, whether it connects directly or through the proxy, because
`172.17.0.1` on the default bridge, and one that connects through another of the its own address is trusted too. Setting `trusted_proxies` to the proxy's own
host's addresses is seen with that address. To be sure, read it as `remoteIP` in address closes this.
pixa's request log while it is not in `trusted_proxies` (see `trusted_proxies`
under Configuration). With the default `trusted_proxies` (the RFC 1918 ranges),
a client with a private address can choose the address it is counted by through
its own `X-Forwarded-For`, whether it connects directly or through the proxy,
because its own address is trusted too. Setting `trusted_proxies` to only the
address pixa sees for requests that come through the proxy closes this.
### Image Metadata ### Image Metadata
@@ -178,27 +163,19 @@ Where:
outside), or `cover` when the URL has no `fit`; a request whose `fit` is outside), or `cover` when the URL has no `fit`; a request whose `fit` is
anything else, an empty `fit=` included, is refused with 400 anything else, an empty `fit=` included, is refused with 400
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet **Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600
of RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa WebP with expiration 1704067200, default quality and fit:
compares it exactly, so a signature encoded without padding, as Node's
`base64url` and Go's `base64.RawURLEncoding` do, is refused with 401.
**Example:** with the signing key `example-signing-key-for-documentation`,
resize `https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
expiration 1704067200, default quality and fit:
1. Build input: 1. Build input:
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover` `cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
2. Compute HMAC-SHA256 of it with the signing key 2. Compute HMAC-SHA256 with your secret key
3. Base64URL-encode the result, keeping the `=` padding: 3. Base64URL-encode the result
`-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=`
4. URL: 4. URL:
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=&exp=1704067200` `/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200`
For the same image at quality 40 with fit `contain`, the input ends in For the same image at quality 40 with fit `contain`, the input ends in
`:40:contain`, the signature is `5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=`, `:40:contain` and the URL is
and the URL is `/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=<base64url>&exp=1704067200&q=40&fit=contain`.
`/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=&exp=1704067200&q=40&fit=contain`.
**Allowlist patterns:** **Allowlist patterns:**
@@ -271,15 +248,8 @@ Key settings in more detail:
`172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a `172.16.0.0/12`, `192.168.0.0/16`), since pixa is deployed behind a
proxy on a private network; an explicitly empty list (`[]`) trusts no proxy on a private network; an explicitly empty list (`[]`) trusts no
one, and an explicit list replaces the default. An invalid CIDR aborts one, and an explicit list replaces the default. An invalid CIDR aborts
startup. Set this to the address pixa sees for requests that come through startup. Set this to your proxy's address range if it is not already
your proxy, such as `172.17.0.1/32`, when the defaults do not cover it, or covered by the defaults
to trust nothing else (see the login limit under Routes). For a proxy on
the Docker host that connects to pixa over `127.0.0.1`, that address is the
gateway of the container's Docker network (`172.17.0.1` on the default
bridge), not the proxy's own address; a proxy that connects through another of
the host's addresses is seen with that address. To be sure which address it
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
through the proxy, and read `remoteIP` in pixa's request log line for it
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream - `upstream_fetch_timeout` — time allowed for one fetch from an upstream
host, as a duration such as `30s` (the default) or `2m` host, as a duration such as `30s` (the default) or `2m`
- `upstream_max_response_size` — largest upstream response accepted, in - `upstream_max_response_size` — largest upstream response accepted, in
-39
View File
@@ -30,33 +30,6 @@ exhaustion
# Completed Steps # Completed Steps
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
migration files moved, contents unchanged, from `internal/database/schema/`
to `internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
`internal/db/migrations` package embeds them and `internal/database` reads
them through its `FS()`; the `internal/database` package itself stays; the
version still comes from the filename prefix, so a database that has recorded
versions 0 and 1 runs neither again.
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
requests that come through the proxy, which the request log shows as
`remoteIP` while it is not trusted; for a proxy on the Docker host that
connects over `127.0.0.1` that is the Docker network's gateway, not the
proxy's own address; the signature section says `sig` is base64url with the
`=` padding kept, and gives the example's `sig` for a stated signing key.
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad`
is not owned on the host by a person's login account; the first-run step of
"Running under upaas" in `README.md` names the uid and gid.
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
routes build `Cache-Control` from the request's `Expires`, which an encrypted
URL's expiry now fills too; `max-age` is one year, or the whole seconds left
until the `exp` of a `/v1/image/` URL or the expiry of an encrypted URL when
that is sooner, never negative; an allowlisted host's URL that has an `exp`
follows it too; `immutable` stays, as freshness now ends at the expiry;
documented in `README.md`.
- 2026-09-28 add the four settings `README.md` documented but pixa did not - 2026-09-28 add the four settings `README.md` documented but pixa did not
have, which aborted startup as unknown keys (closes #61): have, which aborted startup as unknown keys (closes #61):
`access_control_allow_origin` (default `*`, the CORS origin), `access_control_allow_origin` (default `*`, the CORS origin),
@@ -68,18 +41,6 @@ exhaustion
`https` origin as `README.md` describes it; an invalid value `https` origin as `README.md` describes it; an invalid value
aborts startup naming the key and the value; documented in aborts startup naming the key and the value; documented in
`config.example.yml` and `README.md`. `config.example.yml` and `README.md`.
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats`
counts the cached source images and processed variants (`source_content`
plus `variant_content`) and takes their size from `Cache.UsageBytes`,
instead of reading `request_cache` and `output_content`, which nothing
writes; those two tables are left in the schema; a disabled disk cache
reports no items and no size. A hit is counted even when the request
context has ended. A miss is counted after it is served or fails, also
when the request context has ended by then, with the bytes it read from
upstream, so `upstream_fetch_count` and `upstream_fetch_bytes` move,
including for an upstream body that fails partway or a fetched source
that then fails the magic byte check; `transform_count` counts each image
the image processor transcodes.
- 2026-09-28 strip metadata from processed images (closes #82): every output is - 2026-09-28 strip metadata from processed images (closes #82): every output is
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
profile; the image is first turned upright with `AutoRotate` (before sizes are profile; the image is first turned upright with `AutoRotate` (before sizes are
+1 -7
View File
@@ -54,13 +54,7 @@ allowlist_hosts:
# 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on # 172.16.0.0/12, 192.168.0.0/16), since pixa is deployed behind a proxy on
# a private network. An explicitly empty list ([]) trusts no one; an # a private network. An explicitly empty list ([]) trusts no one; an
# explicit list replaces the default. An invalid CIDR aborts startup. # explicit list replaces the default. An invalid CIDR aborts startup.
# Uncomment to override the defaults with the address pixa sees for # Uncomment to override the defaults with your proxy's address range.
# requests that come through your proxy. That is not always the proxy's own
# address: a proxy on the Docker host that connects over 127.0.0.1 is seen
# as the gateway of the container's Docker network (172.17.0.1 on the
# default bridge), and one that connects through another host address is
# seen with that address. To be sure, look it up in the request log as the
# trusted_proxies entry in README.md describes.
# trusted_proxies: # trusted_proxies:
# - 10.0.0.0/8 # - 10.0.0.0/8
# - 2001:db8::/32 # - 2001:db8::/32
+17 -15
View File
@@ -4,9 +4,9 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"embed"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -15,12 +15,14 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/config"
"sneak.berlin/go/pixa/internal/db/migrations"
"sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/logger"
_ "modernc.org/sqlite" // SQLite driver registration _ "modernc.org/sqlite" // SQLite driver registration
) )
//go:embed schema/*.sql
var schemaFS embed.FS
// bootstrapVersion is the migration that creates the schema_migrations // bootstrapVersion is the migration that creates the schema_migrations
// table itself. It is applied before the normal migration loop. // table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0 const bootstrapVersion = 0
@@ -111,29 +113,29 @@ func New(lc fx.Lifecycle, params Params) (*Database, error) {
return s, nil return s, nil
} }
// collectMigrations reads the embedded migrations directory and returns // collectMigrations reads the embedded schema directory and returns
// migration filenames sorted lexicographically. // migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) { func collectMigrations() ([]string, error) {
entries, err := fs.ReadDir(migrations.FS(), ".") entries, err := schemaFS.ReadDir("schema")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read migrations directory: %w", err) return nil, fmt.Errorf("failed to read schema directory: %w", err)
} }
var filenames []string var migrations []string
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
filenames = append(filenames, entry.Name()) migrations = append(migrations, entry.Name())
} }
} }
sort.Strings(filenames) sort.Strings(migrations)
return filenames, nil return migrations, nil
} }
// bootstrapMigrationsTable ensures the schema_migrations table exists // bootstrapMigrationsTable ensures the schema_migrations table exists
// by applying 000_migration.sql if the table is missing. // by applying 000.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error { func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var tableExists int var tableExists int
@@ -148,9 +150,9 @@ func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger)
return nil return nil
} }
content, err := fs.ReadFile(migrations.FS(), "000_migration.sql") content, err := schemaFS.ReadFile("schema/000.sql")
if err != nil { if err != nil {
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err) return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
} }
if log != nil { if log != nil {
@@ -175,12 +177,12 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
return err return err
} }
filenames, err := collectMigrations() migrations, err := collectMigrations()
if err != nil { if err != nil {
return err return err
} }
for _, migration := range filenames { for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration) version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil { if parseErr != nil {
return parseErr return parseErr
@@ -206,7 +208,7 @@ func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
} }
// Read and apply migration. // Read and apply migration.
content, readErr := fs.ReadFile(migrations.FS(), migration) content, readErr := schemaFS.ReadFile(filepath.Join("schema", migration))
if readErr != nil { if readErr != nil {
return fmt.Errorf("failed to read migration %s: %w", migration, readErr) return fmt.Errorf("failed to read migration %s: %w", migration, readErr)
} }
@@ -1,54 +0,0 @@
package database
import (
"bytes"
"database/sql"
"log/slog"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite" // SQLite driver registration
)
// TestApplyMigrations_SecondRunAppliesNothing applies the migrations twice
// to one database file, as happens when pixad starts again on the database
// it created, and checks that the second run applies none of them.
// ApplyMigrations logs a message starting with "applying" before it runs
// any migration, the bootstrap one included.
func TestApplyMigrations_SecondRunAppliesNothing(t *testing.T) {
t.Parallel()
ctx := t.Context()
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "state.sqlite3"))
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
var firstLog bytes.Buffer
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&firstLog, nil)))
if err != nil {
t.Fatalf("first ApplyMigrations failed: %v", err)
}
if !strings.Contains(firstLog.String(), "applying") {
t.Fatalf("first ApplyMigrations logged no applied migration:\n%s",
firstLog.String())
}
var secondLog bytes.Buffer
err = ApplyMigrations(ctx, db, slog.New(slog.NewTextHandler(&secondLog, nil)))
if err != nil {
t.Fatalf("second ApplyMigrations failed: %v", err)
}
if strings.Contains(secondLog.String(), "applying") {
t.Errorf("second ApplyMigrations ran a migration again:\n%s",
secondLog.String())
}
}
-15
View File
@@ -1,15 +0,0 @@
// Package migrations provides the embedded SQL migration files.
package migrations
import (
"embed"
"io/fs"
)
//go:embed *.sql
var files embed.FS
// FS returns the embedded filesystem containing the migration files.
func FS() fs.FS {
return files
}
+1 -8
View File
@@ -103,8 +103,7 @@ func (g *Generator) Parse(token string) (*Payload, error) {
} }
// ToImageRequest converts the payload to an ImageRequest. // ToImageRequest converts the payload to an ImageRequest.
// Applies default values for omitted optional fields. An ExpiresAt of 0, a URL // Applies default values for omitted optional fields.
// that never expires, gives the zero Expires.
func (p *Payload) ToImageRequest() *imgcache.ImageRequest { func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
format := p.Format format := p.Format
if format == "" { if format == "" {
@@ -121,11 +120,6 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
fitMode = DefaultFitMode fitMode = DefaultFitMode
} }
var expires time.Time
if p.ExpiresAt != 0 {
expires = time.Unix(p.ExpiresAt, 0)
}
return &imgcache.ImageRequest{ return &imgcache.ImageRequest{
SourceHost: p.SourceHost, SourceHost: p.SourceHost,
SourcePath: p.SourcePath, SourcePath: p.SourcePath,
@@ -137,7 +131,6 @@ func (p *Payload) ToImageRequest() *imgcache.ImageRequest {
Format: format, Format: format,
Quality: quality, Quality: quality,
FitMode: fitMode, FitMode: fitMode,
Expires: expires,
} }
} }
+1 -19
View File
@@ -220,24 +220,6 @@ func (s *Handlers) respondImageError(
s.respondError(w, "internal error", http.StatusInternalServerError) s.respondError(w, "internal error", http.StatusInternalServerError)
} }
// cacheControl returns the Cache-Control header for an image served through a
// URL that expires at expires, or never when expires is the zero time. A cache
// may keep the image for a year, but not past the URL's expiry, after which
// pixa refuses the URL. The seconds left are rounded down and never negative.
// immutable only stops revalidation while the image is fresh, so it also ends
// at the expiry.
func cacheControl(expires time.Time) string {
const oneYear = 365 * 24 * time.Hour
maxAge := oneYear
if !expires.IsZero() {
maxAge = min(maxAge, max(time.Until(expires), 0))
}
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
}
// writeImageResponse writes headers and streams the image content, // writeImageResponse writes headers and streams the image content,
// handling conditional and HEAD requests. // handling conditional and HEAD requests.
func (s *Handlers) writeImageResponse( func (s *Handlers) writeImageResponse(
@@ -253,7 +235,7 @@ func (s *Handlers) writeImageResponse(
} }
// Cache control headers // Cache control headers
w.Header().Set("Cache-Control", cacheControl(req.Expires)) w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
if resp.ETag != "" { if resp.ETag != "" {
@@ -1,203 +0,0 @@
package handlers
import (
"image/color"
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// photoPath is the path of the JPEG that newSignedHostServer serves.
const photoPath = "/images/photo.jpg"
// newSignedHostServer returns a router for both image routes, and the Handlers
// behind it, whose fetcher serves a JPEG at photoPath on signedHost. signedHost
// is not on the allowlist, so a /v1/image/ URL for it is served only with a
// valid signature.
func newSignedHostServer(t *testing.T) (*Handlers, http.Handler) {
t.Helper()
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
})
if err != nil {
t.Fatalf("imgcache.NewCache() error = %v", err)
}
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
svc, err := imgcache.NewService(&imgcache.ServiceConfig{
Cache: cache,
Fetcher: newMockFetcher(fstest.MapFS{
signedHost + photoPath: &fstest.MapFile{Data: jpegData},
}),
SigningKey: testSigningKey,
})
if err != nil {
t.Fatalf("imgcache.NewService() error = %v", err)
}
encGen, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator() error = %v", err)
}
h := &Handlers{
log: slog.New(slog.DiscardHandler),
imgSvc: svc,
encGen: encGen,
}
r := chi.NewRouter()
r.Get("/v1/image/*", h.HandleImage())
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
return h, r
}
// getMaxAge sends a GET for target to srv, requires a 200, and returns the
// max-age of the response's Cache-Control header, which must read
// "public, max-age=<seconds>, immutable".
func getMaxAge(t *testing.T, srv http.Handler, target string) int {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
header := rec.Header().Get("Cache-Control")
t.Logf("GET %s: %d, Cache-Control: %s", target, rec.Code, header)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
value, hasPrefix := strings.CutPrefix(header, "public, max-age=")
value, hasSuffix := strings.CutSuffix(value, ", immutable")
maxAge, err := strconv.Atoi(value)
if !hasPrefix || !hasSuffix || err != nil {
t.Fatalf("Cache-Control = %q, want public, max-age=<seconds>, immutable",
header)
}
return maxAge
}
// TestHandleImage_SignedURL_MaxAgeEndsAtExp verifies that an image served
// through a signed URL expiring in 60 seconds may be cached for at most those
// 60 seconds. The lower bound of 50 shows the max-age is the time left, not 0.
func TestHandleImage_SignedURL_MaxAgeEndsAtExp(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t)
signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{
SourceHost: signedHost,
SourcePath: photoPath,
Size: imgcache.Size{Width: 50, Height: 50},
Format: imgcache.FormatJPEG,
}, time.Minute)
if err != nil {
t.Fatalf("GenerateSignedURL() error = %v", err)
}
maxAge := getMaxAge(t, srv, signedURL)
if maxAge < 50 || maxAge > 60 {
t.Errorf("max-age = %d, want 50 to 60", maxAge)
}
}
// TestHandleImage_AllowlistedHost_MaxAge verifies the max-age of an image from
// an allowlisted host, which is served without checking sig or exp. A URL with
// no exp may be cached for a year. A URL whose exp has passed is the one request
// that reaches the header after its expiry, and must get 0, never less.
func TestHandleImage_AllowlistedHost_MaxAge(t *testing.T) {
t.Parallel()
pastExp := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10)
tests := []struct {
name string
query string
wantMaxAge int
}{
{"no exp", "", 31536000},
{"exp already past", "?exp=" + pastExp, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fix := setupTestHandler(t)
r := chi.NewRouter()
r.Get("/v1/image/*", fix.handler.HandleImage())
maxAge := getMaxAge(t, r,
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg"+tt.query)
if maxAge != tt.wantMaxAge {
t.Errorf("max-age = %d, want %d", maxAge, tt.wantMaxAge)
}
})
}
}
// TestHandleImageEnc_MaxAge verifies that an image served through an encrypted
// URL with a 60 second TTL may be cached for at most those 60 seconds, that one
// with a two-year TTL may be cached for a year, and that one made without a
// TTL, which never expires, may be cached for a year.
func TestHandleImageEnc_MaxAge(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expiresAt int64
wantAtLeast int
wantAtMost int
}{
{"60 second TTL", time.Now().Add(time.Minute).Unix(), 50, 60},
{"two-year TTL", time.Now().Add(2 * 365 * 24 * time.Hour).Unix(), 31536000, 31536000},
{"no TTL", 0, 31536000, 31536000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h, srv := newSignedHostServer(t)
token, err := h.encGen.Generate(&encurl.Payload{
SourceHost: signedHost,
SourcePath: photoPath,
Width: 50,
Height: 50,
Format: imgcache.FormatJPEG,
ExpiresAt: tt.expiresAt,
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
maxAge := getMaxAge(t, srv, "/v1/e/"+token+"/img.jpg")
if maxAge < tt.wantAtLeast || maxAge > tt.wantAtMost {
t.Errorf("max-age = %d, want %d to %d",
maxAge, tt.wantAtLeast, tt.wantAtMost)
}
})
}
}
@@ -14,9 +14,9 @@ import (
) )
// signedHost is not on the allowlist setupTestHandler builds, so a request // signedHost is not on the allowlist setupTestHandler builds, so a request
// for it needs a valid signature. setupTestHandler serves no image for it: a // for it needs a valid signature. No image is served for it: a request that
// request that passes the signature check gets 502 from the failed fetch, and // passes the signature check gets 502 from the failed fetch, and one that
// one that fails the check gets 401. // fails the check gets 401.
const signedHost = "signed.example.com" const signedHost = "signed.example.com"
// getImage sends a GET for target to the image route of fix and returns the // getImage sends a GET for target to the image route of fix and returns the
+2 -2
View File
@@ -89,8 +89,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
} }
// Cache headers: max-age ends at the URL's expiry // Cache headers - encrypted URLs can be cached since they're immutable
w.Header().Set("Cache-Control", cacheControl(req.Expires)) w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
// Stream the response // Stream the response
+12 -27
View File
@@ -419,21 +419,19 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
return nil, fmt.Errorf("failed to get cache stats: %w", err) return nil, fmt.Errorf("failed to get cache stats: %w", err)
} }
// Count and size the cached source images and processed variants. A // Get actual item count and total size from content tables
// disabled cache holds none, whatever rows an earlier run left. err = c.db.QueryRowContext(ctx,
if !c.disabled { `SELECT COUNT(*) FROM request_cache`,
err = c.db.QueryRowContext(ctx, ` ).Scan(&stats.TotalItems)
SELECT (SELECT COUNT(*) FROM source_content) if err != nil {
+ (SELECT COUNT(*) FROM variant_content) c.log.Warn("failed to count cache items for stats", "error", err)
`).Scan(&stats.TotalItems) }
if err != nil {
c.log.Warn("failed to count cache items for stats", "error", err)
}
stats.TotalSizeBytes, err = c.UsageBytes(ctx) err = c.db.QueryRowContext(ctx,
if err != nil { `SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`,
c.log.Warn("failed to sum cache size for stats", "error", err) ).Scan(&stats.TotalSizeBytes)
} if err != nil {
c.log.Warn("failed to sum cache size for stats", "error", err)
} }
// Compute hit rate as a ratio // Compute hit rate as a ratio
@@ -483,19 +481,6 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
} }
} }
// IncrementTransformCount counts one image transcoded by the image processor.
func (c *Cache) IncrementTransformCount(ctx context.Context) {
_, err := c.db.ExecContext(ctx, `
UPDATE cache_stats
SET transform_count = transform_count + 1,
last_updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`)
if err != nil {
c.log.Warn("failed to count transform", "error", err)
}
}
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source. // writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
// A failure is logged and is otherwise non-fatal; the metadata is in the // A failure is logged and is otherwise non-fatal; the metadata is in the
// database. // database.
+3 -6
View File
@@ -95,8 +95,7 @@ type ImageRequest struct {
FitMode FitMode FitMode FitMode
// Signature is the HMAC signature for non-allowlisted hosts // Signature is the HMAC signature for non-allowlisted hosts
Signature string Signature string
// Expires is when the URL expires: the exp of a signed URL, or the expiry // Expires is the signature expiration timestamp
// of an encrypted URL; the zero time if it has none
Expires time.Time Expires time.Time
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request // AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
AllowHTTP bool AllowHTTP bool
@@ -163,11 +162,9 @@ type ImageCache interface {
// CacheStats contains cache statistics // CacheStats contains cache statistics
type CacheStats struct { type CacheStats struct {
// TotalItems is the number of cached source images plus processed // TotalItems is the number of cached items
// variants
TotalItems int64 TotalItems int64
// TotalSizeBytes is the total size of cached source images and // TotalSizeBytes is the total size of cached content
// processed variants
TotalSizeBytes int64 TotalSizeBytes int64
// HitCount is the number of cache hits // HitCount is the number of cache hits
HitCount int64 HitCount int64
+26 -32
View File
@@ -143,8 +143,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
s.log.Error("failed to get cached variant", "key", result.CacheKey, "error", err) s.log.Error("failed to get cached variant", "key", result.CacheKey, "error", err)
// Fall through to re-process // Fall through to re-process
} else { } else {
// Counted also when the request context has ended meanwhile s.cache.IncrementStats(ctx, true, 0)
s.cache.IncrementStats(context.WithoutCancel(ctx), true, 0)
return &ImageResponse{ return &ImageResponse{
Content: reader, Content: reader,
@@ -156,15 +155,12 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
} }
} }
// Cache miss - process the cached source or fetch it, then count the // Cache miss - check if we have source content cached
// miss with the bytes it fetched from upstream, also when it failed or
// the request context has ended meanwhile
cacheKey := CacheKey(req) cacheKey := CacheKey(req)
response, fetchedBytes, err := s.processFromSourceOrFetch(ctx, req, cacheKey) s.cache.IncrementStats(ctx, false, 0)
s.cache.IncrementStats(context.WithoutCancel(ctx), false, fetchedBytes)
response, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -272,20 +268,22 @@ func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
} }
// processFromSourceOrFetch processes an image, using cached source content // processFromSourceOrFetch processes an image, using cached source content
// if available. It also returns the number of bytes fetched from upstream, // if available.
// as fetchAndProcess does, or 0 when the cached source was used.
func (s *Service) processFromSourceOrFetch( func (s *Service) processFromSourceOrFetch(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *ImageRequest,
cacheKey VariantKey, cacheKey VariantKey,
) (*ImageResponse, int64, error) { ) (*ImageResponse, error) {
// Check if we have cached source content // Check if we have cached source content
contentHash, _, err := s.cache.LookupSource(ctx, req) contentHash, _, err := s.cache.LookupSource(ctx, req)
if err != nil { if err != nil {
s.log.Warn("source lookup failed", "error", err) s.log.Warn("source lookup failed", "error", err)
} }
var sourceData []byte var (
sourceData []byte
fetchBytes int64
)
if contentHash != "" { if contentHash != "" {
s.log.Debug("using cached source", "hash", contentHash) s.log.Debug("using cached source", "hash", contentHash)
@@ -294,25 +292,26 @@ func (s *Service) processFromSourceOrFetch(
// Fetch from upstream if we don't have source data or it's empty // Fetch from upstream if we don't have source data or it's empty
if len(sourceData) == 0 { if len(sourceData) == 0 {
return s.fetchAndProcess(ctx, req, cacheKey) resp, err := s.fetchAndProcess(ctx, req, cacheKey)
if err != nil {
return nil, err
}
return resp, nil
} }
// Process using cached source; nothing was fetched from upstream // Process using cached source
resp, err := s.processAndStore( fetchBytes = int64(len(sourceData))
ctx, req, cacheKey, sourceData, int64(len(sourceData)),
)
return resp, 0, err return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
} }
// fetchAndProcess fetches from upstream, processes, and caches the result. // fetchAndProcess fetches from upstream, processes, and caches the result.
// It also returns the number of bytes read from upstream, including when
// reading the response or a later step fails.
func (s *Service) fetchAndProcess( func (s *Service) fetchAndProcess(
ctx context.Context, ctx context.Context,
req *ImageRequest, req *ImageRequest,
cacheKey VariantKey, cacheKey VariantKey,
) (*ImageResponse, int64, error) { ) (*ImageResponse, error) {
// Fetch from upstream // Fetch from upstream
sourceURL := req.SourceURL() sourceURL := req.SourceURL()
@@ -331,20 +330,20 @@ func (s *Service) fetchAndProcess(
} }
} }
return nil, 0, fmt.Errorf("upstream fetch failed: %w", err) return nil, fmt.Errorf("upstream fetch failed: %w", err)
} }
defer func() { _ = fetchResult.Content.Close() }() defer func() { _ = fetchResult.Content.Close() }()
// Read and validate the source content // Read and validate the source content
sourceData, err := io.ReadAll(fetchResult.Content) sourceData, err := io.ReadAll(fetchResult.Content)
fetchBytes := int64(len(sourceData))
if err != nil { if err != nil {
return nil, fetchBytes, fmt.Errorf("failed to read upstream response: %w", err) return nil, fmt.Errorf("failed to read upstream response: %w", err)
} }
// Calculate download bitrate // Calculate download bitrate
fetchBytes := int64(len(sourceData))
var downloadRate string var downloadRate string
if fetchResult.FetchDurationMs > 0 { if fetchResult.FetchDurationMs > 0 {
@@ -369,7 +368,7 @@ func (s *Service) fetchAndProcess(
// Validate magic bytes match content type // Validate magic bytes match content type
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType) err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
if err != nil { if err != nil {
return nil, fetchBytes, fmt.Errorf("content validation failed: %w", err) return nil, fmt.Errorf("content validation failed: %w", err)
} }
// Store source content // Store source content
@@ -379,9 +378,7 @@ func (s *Service) fetchAndProcess(
// Continue even if caching fails // Continue even if caching fails
} }
resp, err := s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes) return s.processAndStore(ctx, req, cacheKey, sourceData, fetchBytes)
return resp, fetchBytes, err
} }
// processAndStore processes an image and stores the result. // processAndStore processes an image and stores the result.
@@ -409,9 +406,6 @@ func (s *Service) processAndStore(
processDuration := time.Since(processStart) processDuration := time.Since(processStart)
// Counted also when the request context has ended meanwhile
s.cache.IncrementTransformCount(context.WithoutCancel(ctx))
// Read processed content // Read processed content
processedData, err := io.ReadAll(processResult.Content) processedData, err := io.ReadAll(processResult.Content)
_ = processResult.Content.Close() _ = processResult.Content.Close()
+1 -315
View File
@@ -4,9 +4,6 @@ import (
"bytes" "bytes"
"context" "context"
"database/sql" "database/sql"
"image/color"
"io"
"io/fs"
"log/slog" "log/slog"
"math" "math"
"strings" "strings"
@@ -14,7 +11,6 @@ import (
"time" "time"
"sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/database"
"sneak.berlin/go/pixa/internal/httpfetcher"
) )
func setupStatsTestDB(t *testing.T) *sql.DB { func setupStatsTestDB(t *testing.T) *sql.DB {
@@ -129,7 +125,7 @@ func TestStats_LogsFailedCountQueries(t *testing.T) {
} }
_, err = db.ExecContext(t.Context(), _, err = db.ExecContext(t.Context(),
`DROP TABLE source_content; DROP TABLE variant_content`) `DROP TABLE request_cache; DROP TABLE output_content`)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -187,313 +183,3 @@ func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
} }
} }
} }
// TestStats_TotalsCountSourcesAndVariants verifies that TotalItems and
// TotalSizeBytes cover the stored source images and processed variants.
func TestStats_TotalsCountSourcesAndVariants(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
storeEvictionTestSource(t, cache, testHostCDN, testPathCat,
bytes.Repeat([]byte{0xAA}, 1000))
storeEvictionTestVariant(t, cache, testVariantKeyOne,
bytes.Repeat([]byte{0xAB}, 500))
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
bytes.Repeat([]byte{0xAC}, 250))
stats, err := cache.Stats(t.Context())
if err != nil {
t.Fatalf("Stats() error = %v", err)
}
if stats.TotalItems != 3 {
t.Errorf("TotalItems = %d, want 3 (1 source, 2 variants)", stats.TotalItems)
}
if stats.TotalSizeBytes != 1750 {
t.Errorf("TotalSizeBytes = %d, want 1750 (1000+500+250)",
stats.TotalSizeBytes)
}
}
// TestStats_DisabledCacheReportsNoItems verifies that a disabled disk cache
// reports no items and no size, even when its database still holds the
// rows of an earlier run with the disk cache enabled.
func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
t.Parallel()
enabled, _ := newEvictionTestCache(t, 1<<30)
storeEvictionTestSource(t, enabled, testHostCDN, testPathCat,
bytes.Repeat([]byte{0xAA}, 1000))
storeEvictionTestVariant(t, enabled, testVariantKeyOne,
bytes.Repeat([]byte{0xAB}, 500))
disabled, err := NewCache(enabled.db, CacheConfig{
StateDir: t.TempDir(),
CacheTTL: time.Hour,
NegativeTTL: 5 * time.Minute,
DisableDiskCache: true,
})
if err != nil {
t.Fatal(err)
}
stats, err := disabled.Stats(t.Context())
if err != nil {
t.Fatalf("Stats() error = %v", err)
}
if stats.TotalItems != 0 || stats.TotalSizeBytes != 0 {
t.Errorf("TotalItems = %d, TotalSizeBytes = %d, want 0 and 0",
stats.TotalItems, stats.TotalSizeBytes)
}
}
// cacheStatsCounters holds the counters of the cache_stats row, in column
// order.
type cacheStatsCounters struct {
hitCount int64
missCount int64
upstreamFetchCount int64
upstreamFetchBytes int64
transformCount int64
}
// readCacheStatsCounters reads the counters of the cache_stats row.
func readCacheStatsCounters(t *testing.T, cache *Cache) cacheStatsCounters {
t.Helper()
var got cacheStatsCounters
err := cache.db.QueryRowContext(t.Context(), `
SELECT hit_count, miss_count, upstream_fetch_count,
upstream_fetch_bytes, transform_count
FROM cache_stats WHERE id = 1
`).Scan(&got.hitCount, &got.missCount, &got.upstreamFetchCount,
&got.upstreamFetchBytes, &got.transformCount)
if err != nil {
t.Fatalf("failed to read cache_stats: %v", err)
}
return got
}
// TestService_Get_CountsStats walks Get through a miss that fetches the
// source, a hit, a miss that reuses the cached source, and two misses whose
// source cannot be used, checking every cache_stats counter after each.
func TestService_Get_CountsStats(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
// NewTestFS builds the same files the test service's fetcher serves.
testFS, _ := NewTestFS(t)
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
if err != nil {
t.Fatal(err)
}
fake, err := fs.ReadFile(testFS, fixtures.InvalidFile)
if err != nil {
t.Fatal(err)
}
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
// want is hits, misses, upstream fetches, upstream bytes, transforms.
steps := []struct {
name string
path string
size int
wantErr bool
want cacheStatsCounters
}{
{"miss that fetches the source", testPathPhoto, 50, false,
cacheStatsCounters{0, 1, 1, photoBytes, 1}},
{"hit", testPathPhoto, 50, false,
cacheStatsCounters{1, 1, 1, photoBytes, 1}},
{"miss that reuses the cached source", testPathPhoto, 25, false,
cacheStatsCounters{1, 2, 1, photoBytes, 2}},
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
cacheStatsCounters{1, 3, 2, photoBytes + fakeBytes, 2}},
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
cacheStatsCounters{1, 4, 2, photoBytes + fakeBytes, 2}},
}
for _, step := range steps {
resp, err := svc.Get(t.Context(), &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: step.path,
Size: Size{Width: step.size, Height: step.size},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
})
if (err != nil) != step.wantErr {
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
}
if err == nil {
_ = resp.Content.Close()
}
got := readCacheStatsCounters(t, svc.cache)
if got != step.want {
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
}
}
}
// fakeUpstream answers every fetch with itself as a JPEG body. The body
// serves data, then calls cancel, when set, and returns err; io.EOF ends
// the body normally.
type fakeUpstream struct {
data *bytes.Reader
cancel context.CancelFunc
err error
}
func (u *fakeUpstream) Fetch(
context.Context, string,
) (*httpfetcher.FetchResult, error) {
return &httpfetcher.FetchResult{
Content: io.NopCloser(u),
ContentLength: -1,
ContentType: testContentTypeJPEG,
}, nil
}
func (u *fakeUpstream) Read(p []byte) (int, error) {
if u.data.Len() > 0 {
return u.data.Read(p)
}
if u.cancel != nil {
u.cancel()
}
return 0, u.err
}
// TestService_Get_CountsInterruptedMisses checks every cache_stats counter
// after a miss whose request context ends during or after the upstream
// fetch, and after a miss whose upstream body is over the size limit.
func TestService_Get_CountsInterruptedMisses(t *testing.T) {
t.Parallel()
photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
half := len(photo) / 2
// want is hits, misses, upstream fetches, upstream bytes, transforms.
tests := []struct {
name string
served int // bytes of the photo the upstream body serves
cancel bool // whether the body then ends the request context
readErr error // what the body then returns
wantErr bool
want cacheStatsCounters
}{
{"request context ends during the fetch", half, true, context.Canceled, true,
cacheStatsCounters{0, 1, 1, int64(half), 0}},
{"request context ends after the fetch", len(photo), true, io.EOF, false,
cacheStatsCounters{0, 1, 1, int64(len(photo)), 1}},
{"upstream body over the size limit", half, false,
httpfetcher.ErrResponseTooLarge, true,
cacheStatsCounters{0, 1, 1, int64(half), 0}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
upstream := &fakeUpstream{
data: bytes.NewReader(photo[:tc.served]),
err: tc.readErr,
}
if tc.cancel {
upstream.cancel = cancel
}
svc.fetcher = upstream
resp, err := svc.Get(ctx, &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
})
t.Logf("Get() error = %v", err)
if (err != nil) != tc.wantErr {
t.Fatalf("Get() error = %v, want error %t", err, tc.wantErr)
}
if err == nil {
_ = resp.Content.Close()
}
got := readCacheStatsCounters(t, svc.cache)
if got != tc.want {
t.Errorf("counters = %+v, want %+v", got, tc.want)
}
})
}
}
// TestService_Get_CountsHitAfterRequestEnds checks every cache_stats counter
// after a hit served with a request context that has already ended: only
// the hit count moves.
func TestService_Get_CountsHitAfterRequestEnds(t *testing.T) {
t.Parallel()
svc, fixtures := SetupTestService(t)
req := &ImageRequest{
SourceHost: fixtures.GoodHost,
SourcePath: testPathPhoto,
Size: Size{Width: 50, Height: 50},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// A first request caches the variant.
resp, err := svc.Get(t.Context(), req)
if err != nil {
t.Fatalf("first Get() error = %v", err)
}
_ = resp.Content.Close()
want := readCacheStatsCounters(t, svc.cache)
want.hitCount++
ctx, cancel := context.WithCancel(t.Context())
cancel()
resp, err = svc.Get(ctx, req)
if err != nil {
t.Fatalf("Get() with an ended request context: error = %v", err)
}
_ = resp.Content.Close()
if resp.CacheStatus != CacheHit {
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
}
got := readCacheStatsCounters(t, svc.cache)
if got != want {
t.Errorf("counters = %+v, want %+v", got, want)
}
}