Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85161353ea | ||
|
|
13c06f5ad1 |
+17
-16
@@ -3,14 +3,13 @@
|
||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60 AS lint
|
||||
|
||||
RUN apk add --no-cache make build-base vips-dev libheif-dev pkgconfig
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# script/bootstrap installs the build dependencies and downloads the Go
|
||||
# modules. Only script/, go.mod and go.sum are copied first, so this
|
||||
# layer is reused until one of them changes.
|
||||
COPY script/ ./script/
|
||||
# Copy go mod files first for better layer caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN script/bootstrap
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
@@ -29,12 +28,20 @@ FROM golang:1.25.4-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66
|
||||
# Depend on lint stage passing
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
# Install build dependencies for CGO image libraries
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
vips-dev \
|
||||
libheif-dev \
|
||||
pkgconfig
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Build dependencies and Go modules, as in the lint stage
|
||||
COPY script/ ./script/
|
||||
# Copy go mod files first for better layer caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN script/bootstrap
|
||||
RUN GOTOOLCHAIN=auto go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
@@ -42,14 +49,8 @@ COPY . .
|
||||
# Run tests
|
||||
RUN make test
|
||||
|
||||
# VERSION is declared here, not earlier: a new value reruns only the
|
||||
# build, not script/bootstrap or the tests. CGO stays enabled for
|
||||
# govips; -trimpath keeps build paths out of the binary, and -s -w
|
||||
# leave out the symbol table and debug information.
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -trimpath \
|
||||
-ldflags "-s -w -X main.Version=${VERSION}" \
|
||||
-o /pixad ./cmd/pixad
|
||||
# Build with CGO enabled
|
||||
RUN CGO_ENABLED=1 GOTOOLCHAIN=auto go build -ldflags "-X main.Version=${VERSION}" -o /pixad ./cmd/pixad
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-02-25
|
||||
|
||||
+7
-7
@@ -6,16 +6,16 @@
|
||||
# golangci/golangci-lint:v2.12.2-alpine, 2026-08-07
|
||||
FROM golangci/golangci-lint:v2.12.2-alpine@sha256:91b27804074a0bacea298707f016911e60cf0cdbc6c7bf5ccacb5f0606d18d60
|
||||
|
||||
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
||||
# this image needs the same C libraries the build does.
|
||||
RUN apk add --no-cache build-base vips-dev libheif-dev pkgconfig
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# pixa is CGO/libvips: the type-aware linters compile every package, so
|
||||
# this image needs the same C libraries the build does. script/bootstrap
|
||||
# installs them and downloads the Go modules. Only script/, go.mod and
|
||||
# go.sum are copied first; they settle this layer's result, so it may
|
||||
# safely be reused between runs.
|
||||
COPY script/ ./script/
|
||||
# Modules first for layer caching; go.mod/go.sum settle this layer's
|
||||
# result, so it may safely be reused between runs.
|
||||
COPY go.mod go.sum ./
|
||||
RUN script/bootstrap
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -86,7 +86,10 @@ prevent abuse, and allowlisted source hosts for open access.
|
||||
|
||||
Multiple source paths may reference the same content blob; the
|
||||
database tracks references rather than using filesystem refcounting.
|
||||
In-process caching of request-to-output mappings targets 1-5k r/s.
|
||||
Toward a target of 1-5k r/s, pixa keeps in memory the content types of
|
||||
the 10,000 transformed images most recently cached or served, so a
|
||||
cache hit on one of them reads only the image file from disk and not
|
||||
the metadata file stored beside it.
|
||||
|
||||
### Routes
|
||||
|
||||
@@ -235,10 +238,6 @@ variables set by the file's `env:` section are checked the same way.
|
||||
| `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
|
||||
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
|
||||
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
||||
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
||||
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
||||
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
|
||||
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
|
||||
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
||||
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
||||
@@ -247,14 +246,7 @@ variables set by the file's `env:` section are checked the same way.
|
||||
|
||||
Key settings in more detail:
|
||||
|
||||
- `access_control_allow_origin` — the origin a browser lets read pixa's
|
||||
responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the
|
||||
default, is any site; otherwise one `http` or `https` origin such as
|
||||
`https://example.com`, whose host is a lowercase host name (letters,
|
||||
digits, hyphens and dots, with a letter in its last part) or an IP address
|
||||
(IPv6 in brackets, in its shortest form), with an optional port 1-65535
|
||||
that has no leading zero and is not the scheme's default. Any other value,
|
||||
including another scheme such as a browser extension's, aborts startup
|
||||
- `access_control_allow_origin` — CORS origin
|
||||
- `allowlist_hosts` — list of allowed upstream hosts
|
||||
- `blocked_networks` — list of CIDR ranges to refuse for SSRF protection,
|
||||
added to the always-enforced built-in ranges (loopback, private,
|
||||
@@ -280,14 +272,9 @@ Key settings in more detail:
|
||||
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
|
||||
host, as a duration such as `30s` (the default) or `2m`
|
||||
- `upstream_max_response_size` — largest upstream response accepted, in
|
||||
bytes; default `52428800` (50 MiB). It also limits the image data pixa
|
||||
decodes
|
||||
- `downstream_timeout` — time allowed for answering one client request, as a
|
||||
duration; default `60s`. The upstream fetch counts toward it, so keep it
|
||||
longer than `upstream_fetch_timeout`
|
||||
- `upstream_fetch_timeout` — timeout for origin requests
|
||||
- `upstream_max_response_size` — max origin response size
|
||||
- `downstream_timeout` — client response timeout
|
||||
- `signing_key` — HMAC secret for URL signatures
|
||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
||||
disk cache entirely; omitted defaults to 75% of the free space on
|
||||
|
||||
@@ -30,14 +30,15 @@ exhaustion
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-29 Dockerfiles install through `script/bootstrap` (closes #95): the
|
||||
`Dockerfile` lint and build stages and `Dockerfile.lint` copy `script/`,
|
||||
`go.mod` and `go.sum`, then run `script/bootstrap` in place of their own
|
||||
`apk add` lines, so the build dependencies are listed in one place;
|
||||
`script/bootstrap` now also installs a C compiler when `gcc` is missing; the
|
||||
build uses `-trimpath` and `-s -w` and keeps `CGO_ENABLED=1` for govips;
|
||||
`ARG VERSION` sits just above the build, so a new version reruns neither
|
||||
`script/bootstrap` nor the tests.
|
||||
- 2026-09-29 variant content types kept in memory (closes #70):
|
||||
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
|
||||
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
|
||||
`GetVariant` after it reads a `.meta` file; for a variant it holds, `Lookup`
|
||||
skips the check of the disk and `GetVariant` skips the `.meta` read, still
|
||||
opening the variant file and taking the size from it; eviction removes the
|
||||
entry before deleting the files, and `GetVariant` removes it when the file
|
||||
will not open; the cap is a constant, not a setting; the unused `variantMeta`
|
||||
type is gone; `README.md` describes it.
|
||||
- 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
|
||||
@@ -65,17 +66,6 @@ exhaustion
|
||||
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
|
||||
have, which aborted startup as unknown keys (closes #61):
|
||||
`access_control_allow_origin` (default `*`, the CORS origin),
|
||||
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
|
||||
(default 50 MiB) and `downstream_timeout` (default `60s`, both the
|
||||
server's write timeout and the per-request timeout); each has a
|
||||
`PIXA_` variable; durations are positive Go duration strings, the size a
|
||||
whole number of bytes up to 1 GiB, the origin `*` or one `http` or
|
||||
`https` origin as `README.md` describes it; an invalid value
|
||||
aborts startup naming the key and the value; documented in
|
||||
`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`,
|
||||
@@ -325,6 +315,7 @@ exhaustion
|
||||
- X-Request-ID propagation
|
||||
- P2: auto format selection (format=auto based on Accept header)
|
||||
- P2: configuration
|
||||
- add all configuration options from README
|
||||
- YAML config file support
|
||||
- P2: operational
|
||||
- optional Sentry error reporting
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
# this file's env: section is set while the file loads, so it overrides
|
||||
# both the environment the process was started with and this file's own
|
||||
# key.
|
||||
#
|
||||
# Durations are Go duration strings such as 30s or 2m and must be
|
||||
# positive; a bare number has no unit and aborts startup. Sizes are a
|
||||
# whole number of bytes.
|
||||
|
||||
# Server settings
|
||||
port: 8080
|
||||
@@ -71,27 +67,6 @@ allow_http: false
|
||||
# Maximum concurrent connections per upstream host (default: 20)
|
||||
upstream_connections_per_host: 20
|
||||
|
||||
# Time allowed for one fetch from an upstream host (default: 30s)
|
||||
upstream_fetch_timeout: 30s
|
||||
|
||||
# Largest upstream response accepted, in bytes, at most 1073741824
|
||||
# (1 GiB) (default: 52428800, 50 MiB)
|
||||
upstream_max_response_size: 52428800
|
||||
|
||||
# Time allowed for answering one client request, the upstream fetch
|
||||
# included, so keep it longer than upstream_fetch_timeout (default: 60s)
|
||||
downstream_timeout: 60s
|
||||
|
||||
# The origin a browser lets read pixa's responses, sent as the CORS
|
||||
# Access-Control-Allow-Origin header: "*" (the default) is any site;
|
||||
# otherwise one http or https origin such as https://example.com, whose
|
||||
# host is a lowercase host name (letters, digits, hyphens and dots, with a
|
||||
# letter in its last part) or an IP address (IPv6 in brackets, in its
|
||||
# shortest form), with an optional port 1-65535 that has no leading zero
|
||||
# and is not the scheme's default. Any other value, including another
|
||||
# scheme such as a browser extension's, aborts startup.
|
||||
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 free
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
github.com/go-chi/httprate v0.16.0
|
||||
github.com/gorilla/csrf v1.7.3
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/slok/go-http-metrics v0.13.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
|
||||
@@ -228,6 +228,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
|
||||
+1
-175
@@ -13,7 +13,6 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
"go.uber.org/fx"
|
||||
@@ -25,10 +24,6 @@ const (
|
||||
DefaultPort = 8080
|
||||
DefaultStateDir = "/var/lib/pixa"
|
||||
DefaultUpstreamConnectionsPerHost = 20
|
||||
DefaultAccessControlAllowOrigin = "*"
|
||||
DefaultUpstreamFetchTimeout = 30 * time.Second
|
||||
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
||||
DefaultDownstreamTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
// Configuration key names.
|
||||
@@ -49,10 +44,6 @@ const (
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
keyBlockedNetworks = "blocked_networks"
|
||||
keyTrustedProxies = "trusted_proxies"
|
||||
keyAccessControlAllowOrigin = "access_control_allow_origin"
|
||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
||||
keyDownstreamTimeout = "downstream_timeout"
|
||||
)
|
||||
|
||||
// placeholderSigningKey is the dummy signing_key shipped in
|
||||
@@ -78,7 +69,6 @@ var (
|
||||
errEmptyEntry = errors.New("contains an empty entry")
|
||||
errNotAValidURL = errors.New("not a valid URL")
|
||||
errPortOutOfRange = errors.New("outside the valid port range")
|
||||
errSizeOutOfRange = errors.New("outside the accepted range")
|
||||
errTooFewConnections = errors.New("must be at least 1")
|
||||
errValueTooShort = errors.New("value too short")
|
||||
errPlaceholderKey = errors.New(
|
||||
@@ -96,10 +86,6 @@ var (
|
||||
errNotBareHostname = errors.New(
|
||||
"must be a bare hostname without scheme, path, or whitespace")
|
||||
errNoHostnameLabels = errors.New("contains no hostname labels")
|
||||
errNotADuration = errors.New("not a duration such as 30s or 2m")
|
||||
errMustBePositive = errors.New("must be positive")
|
||||
errNotAnOrigin = errors.New(
|
||||
`not "*" or an origin such as https://example.com`)
|
||||
)
|
||||
|
||||
// Params defines dependencies for Config.
|
||||
@@ -126,23 +112,6 @@ type Config struct {
|
||||
AllowHTTP bool // Allow non-TLS upstream (testing only)
|
||||
UpstreamConnectionsPerHost int // Max concurrent connections per upstream host
|
||||
|
||||
// UpstreamFetchTimeout is the time allowed for one fetch from an
|
||||
// upstream host. UpstreamMaxResponseSize is the largest upstream
|
||||
// response accepted, in bytes, and also the image processor's input
|
||||
// limit.
|
||||
UpstreamFetchTimeout time.Duration
|
||||
UpstreamMaxResponseSize int64
|
||||
|
||||
// AccessControlAllowOrigin is the origin the CORS middleware allows
|
||||
// to read responses: "*" for any, or one origin such as
|
||||
// https://example.com.
|
||||
AccessControlAllowOrigin string
|
||||
|
||||
// DownstreamTimeout bounds how long answering one client request may
|
||||
// take. It is both the HTTP server's write timeout and the deadline
|
||||
// of the per-request timeout middleware.
|
||||
DownstreamTimeout time.Duration
|
||||
|
||||
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
||||
// fetcher's dialer; the built-in ranges always apply.
|
||||
@@ -271,14 +240,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
AllowHTTP: loader.boolVal(keyAllowHTTP, false),
|
||||
UpstreamConnectionsPerHost: loader.intVal(
|
||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||
UpstreamFetchTimeout: loader.durationVal(
|
||||
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
|
||||
UpstreamMaxResponseSize: loader.int64Val(
|
||||
keyUpstreamMaxResponseSize, DefaultUpstreamMaxResponseSize),
|
||||
AccessControlAllowOrigin: loader.stringVal(
|
||||
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
||||
DownstreamTimeout: loader.durationVal(
|
||||
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
BlockedNetworks: blockedNetworks,
|
||||
TrustedProxies: trustedProxies,
|
||||
@@ -393,8 +354,7 @@ func isKnownConfigKey(key string) bool {
|
||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
|
||||
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
||||
keyUpstreamMaxResponseSize, keyDownstreamTimeout, "env":
|
||||
keyTrustedProxies, "env":
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -422,10 +382,6 @@ func envVarNames() map[string]string {
|
||||
keyCacheMaxBytes: "PIXA_CACHE_MAX_BYTES",
|
||||
keyBlockedNetworks: "PIXA_BLOCKED_NETWORKS",
|
||||
keyTrustedProxies: "PIXA_TRUSTED_PROXIES",
|
||||
keyAccessControlAllowOrigin: "PIXA_ACCESS_CONTROL_ALLOW_ORIGIN",
|
||||
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
||||
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
||||
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,11 +535,6 @@ func (c *Config) validate() error {
|
||||
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
||||
}
|
||||
|
||||
err = c.validateUpstreamMaxResponseSize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, host := range c.AllowlistHosts {
|
||||
err := validateAllowlistHost(host)
|
||||
if err != nil {
|
||||
@@ -605,82 +556,6 @@ func (c *Config) validate() error {
|
||||
errMustBeSetTogether)
|
||||
}
|
||||
|
||||
return c.validateAccessControlAllowOrigin()
|
||||
}
|
||||
|
||||
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
||||
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
||||
// memory, and the image processor reads one byte past this limit, which
|
||||
// must not overflow.
|
||||
func (c *Config) validateUpstreamMaxResponseSize() error {
|
||||
const maxUpstreamMaxResponseSize = 1 << 30 // 1 GiB
|
||||
if c.UpstreamMaxResponseSize < 1 ||
|
||||
c.UpstreamMaxResponseSize > maxUpstreamMaxResponseSize {
|
||||
return fmt.Errorf("%s: value %d is %w 1-%d",
|
||||
settingName(keyUpstreamMaxResponseSize), c.UpstreamMaxResponseSize,
|
||||
errSizeOutOfRange, maxUpstreamMaxResponseSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAccessControlAllowOrigin accepts "*" or an origin exactly as a browser
|
||||
// sends it: http or https, an IP address as netip writes it or a lowercase name
|
||||
// with a letter in its last part, and an optional port 1-65535, not the default.
|
||||
func (c *Config) validateAccessControlAllowOrigin() error {
|
||||
origin := c.AccessControlAllowOrigin
|
||||
if origin == "*" {
|
||||
return nil
|
||||
}
|
||||
|
||||
errOrigin := fmt.Errorf("%s: value %q is %w",
|
||||
settingName(keyAccessControlAllowOrigin), origin, errNotAnOrigin)
|
||||
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return errOrigin
|
||||
}
|
||||
|
||||
defaultPort := map[string]string{"http": "80", "https": "443"}[parsed.Scheme]
|
||||
if defaultPort == "" {
|
||||
return errOrigin
|
||||
}
|
||||
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
host := parsed.Hostname()
|
||||
lastPart := host[strings.LastIndex(host, ".")+1:]
|
||||
|
||||
addr, err := netip.ParseAddr(host)
|
||||
|
||||
switch {
|
||||
case err == nil && addr.Is6():
|
||||
host = "[" + addr.String() + "]"
|
||||
case err == nil:
|
||||
host = addr.String()
|
||||
case strings.Trim(host, letters+"0123456789-.") != "": // a character other than these
|
||||
return errOrigin
|
||||
case !strings.ContainsAny(lastPart, letters):
|
||||
return errOrigin
|
||||
}
|
||||
|
||||
// The value must be exactly the origin rebuilt from its parts.
|
||||
rebuilt := parsed.Scheme + "://" + host
|
||||
|
||||
port := parsed.Port()
|
||||
if port != "" {
|
||||
_, err := strconv.ParseUint(port, 10, 16)
|
||||
if err != nil || port[0] == '0' || port == defaultPort {
|
||||
return errOrigin
|
||||
}
|
||||
|
||||
rebuilt += ":" + port
|
||||
}
|
||||
|
||||
if rebuilt != origin {
|
||||
return errOrigin
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -798,19 +673,6 @@ func (l *strictLoader) int64Val(key string, defaultVal int64) int64 {
|
||||
return val
|
||||
}
|
||||
|
||||
func (l *strictLoader) durationVal(key string, defaultVal time.Duration) time.Duration {
|
||||
if l.err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
val, err := getDuration(l.sc, key, defaultVal)
|
||||
if err != nil {
|
||||
l.err = err
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
||||
if l.err != nil {
|
||||
return false
|
||||
@@ -932,42 +794,6 @@ func getInt64(sc *smartconfig.Config, key string, defaultVal int64) (int64, erro
|
||||
}
|
||||
}
|
||||
|
||||
// getDuration returns the duration value for key, or defaultVal if the
|
||||
// key is omitted. A present value must be a positive Go duration string
|
||||
// such as "30s" or "2m", read with time.ParseDuration; a bare number has
|
||||
// no unit and is an error, as is an explicit null.
|
||||
func getDuration(
|
||||
sc *smartconfig.Config, key string, defaultVal time.Duration,
|
||||
) (time.Duration, error) {
|
||||
raw, ok := lookupValue(sc, key)
|
||||
if !ok {
|
||||
return defaultVal, nil
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
return 0, errNullConfigValue(key)
|
||||
}
|
||||
|
||||
str, ok := raw.(string)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
key, raw, raw, errNotADuration)
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(str))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: value %q is %w",
|
||||
settingName(key), str, errNotADuration)
|
||||
}
|
||||
|
||||
if parsed <= 0 {
|
||||
return 0, fmt.Errorf("%s: value %q %w",
|
||||
settingName(key), str, errMustBePositive)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// getBool returns the boolean value for key, or defaultVal if the key
|
||||
// is omitted. A present value that is not a boolean (or a ParseBool-able
|
||||
// string), or is explicitly null, is an error; numbers are not accepted
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
)
|
||||
@@ -600,244 +599,3 @@ func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) {
|
||||
t.Errorf("error %q does not name the offending key state_dir", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmittedOriginTimeoutsAndSizeUseDefaults checks that the CORS
|
||||
// origin, the upstream fetch timeout, the upstream response size limit
|
||||
// and the downstream timeout default to the values pixa used before they
|
||||
// could be configured.
|
||||
func TestOmittedOriginTimeoutsAndSizeUseDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine)
|
||||
if err != nil {
|
||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||
}
|
||||
|
||||
if c.AccessControlAllowOrigin != "*" {
|
||||
t.Errorf("AccessControlAllowOrigin = %q, want *", c.AccessControlAllowOrigin)
|
||||
}
|
||||
|
||||
if c.UpstreamFetchTimeout != 30*time.Second {
|
||||
t.Errorf("UpstreamFetchTimeout = %v, want 30s", c.UpstreamFetchTimeout)
|
||||
}
|
||||
|
||||
if c.UpstreamMaxResponseSize != 50<<20 {
|
||||
t.Errorf("UpstreamMaxResponseSize = %d, want %d (50 MiB)",
|
||||
c.UpstreamMaxResponseSize, 50<<20)
|
||||
}
|
||||
|
||||
if c.DownstreamTimeout != 60*time.Second {
|
||||
t.Errorf("DownstreamTimeout = %v, want 60s", c.DownstreamTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExplicitOriginTimeoutsAndSizeAreUsed checks that valid values for
|
||||
// the CORS origin, the two timeouts and the response size limit are used
|
||||
// as given. The size is the largest accepted, 1 GiB.
|
||||
func TestExplicitOriginTimeoutsAndSizeAreUsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine+`
|
||||
access_control_allow_origin: https://app.example.com
|
||||
upstream_fetch_timeout: 10s
|
||||
upstream_max_response_size: 1073741824
|
||||
downstream_timeout: 2m
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("valid config should load, got error: %v", err)
|
||||
}
|
||||
|
||||
if c.AccessControlAllowOrigin != "https://app.example.com" {
|
||||
t.Errorf("AccessControlAllowOrigin = %q, want https://app.example.com",
|
||||
c.AccessControlAllowOrigin)
|
||||
}
|
||||
|
||||
if c.UpstreamFetchTimeout != 10*time.Second {
|
||||
t.Errorf("UpstreamFetchTimeout = %v, want 10s", c.UpstreamFetchTimeout)
|
||||
}
|
||||
|
||||
if c.UpstreamMaxResponseSize != 1073741824 {
|
||||
t.Errorf("UpstreamMaxResponseSize = %d, want 1073741824",
|
||||
c.UpstreamMaxResponseSize)
|
||||
}
|
||||
|
||||
if c.DownstreamTimeout != 2*time.Minute {
|
||||
t.Errorf("DownstreamTimeout = %v, want 2m", c.DownstreamTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOriginWithPortOrAnyOriginIsAccepted checks the other accepted forms
|
||||
// of access_control_allow_origin: "*", an origin with a port, and origins
|
||||
// whose host is an IPv4 or IPv6 address.
|
||||
func TestOriginWithPortOrAnyOriginIsAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, origin := range []string{
|
||||
"*", "http://localhost:3000", "http://192.0.2.1", "http://[2001:db8::1]:8080",
|
||||
} {
|
||||
c, err := configFromYAML(t, signingKeyLine+
|
||||
"access_control_allow_origin: \""+origin+"\"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("origin %q should be accepted, got error: %v", origin, err)
|
||||
}
|
||||
|
||||
if c.AccessControlAllowOrigin != origin {
|
||||
t.Errorf("AccessControlAllowOrigin = %q, want %q",
|
||||
c.AccessControlAllowOrigin, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// invalidTimeoutCases are configs where upstream_fetch_timeout or
|
||||
// downstream_timeout is not a positive Go duration string; each must
|
||||
// abort startup naming the key and the value.
|
||||
func invalidTimeoutCases() []abortCase {
|
||||
return []abortCase{
|
||||
{
|
||||
name: "upstream_fetch_timeout not a duration",
|
||||
yaml: signingKeyLine + "upstream_fetch_timeout: soon\n",
|
||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "soon"},
|
||||
},
|
||||
{
|
||||
name: "upstream_fetch_timeout number without a unit",
|
||||
yaml: signingKeyLine + "upstream_fetch_timeout: 45\n",
|
||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "45"},
|
||||
},
|
||||
{
|
||||
name: "upstream_fetch_timeout zero",
|
||||
yaml: signingKeyLine + "upstream_fetch_timeout: 0s\n",
|
||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "0s"},
|
||||
},
|
||||
{
|
||||
name: "upstream_fetch_timeout negative",
|
||||
yaml: signingKeyLine + "upstream_fetch_timeout: -5s\n",
|
||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, "-5s"},
|
||||
},
|
||||
{
|
||||
name: "upstream_fetch_timeout null",
|
||||
yaml: signingKeyLine + "upstream_fetch_timeout: null\n",
|
||||
wantErrSubstrings: []string{keyUpstreamFetchTimeout, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "downstream_timeout not a duration",
|
||||
yaml: signingKeyLine + "downstream_timeout: 1 minute\n",
|
||||
wantErrSubstrings: []string{keyDownstreamTimeout, "1 minute"},
|
||||
},
|
||||
{
|
||||
name: "downstream_timeout zero",
|
||||
yaml: signingKeyLine + "downstream_timeout: 0s\n",
|
||||
wantErrSubstrings: []string{keyDownstreamTimeout, "0s"},
|
||||
},
|
||||
{
|
||||
name: "downstream_timeout negative",
|
||||
yaml: signingKeyLine + "downstream_timeout: -1m\n",
|
||||
wantErrSubstrings: []string{keyDownstreamTimeout, "-1m"},
|
||||
},
|
||||
{
|
||||
name: "downstream_timeout null",
|
||||
yaml: signingKeyLine + "downstream_timeout:\n",
|
||||
wantErrSubstrings: []string{keyDownstreamTimeout, nullValueText},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// invalidSizeAndOriginCases are configs where upstream_max_response_size
|
||||
// is not a whole number of bytes from 1 to 1 GiB, or
|
||||
// access_control_allow_origin is neither "*" nor an origin; each must
|
||||
// abort startup naming the key and the value.
|
||||
func invalidSizeAndOriginCases() []abortCase {
|
||||
badOrigins := []string{
|
||||
"", // empty
|
||||
"example.com", // no scheme
|
||||
"https://example.com/images", // a path
|
||||
"https://example.com/", // a trailing slash
|
||||
// The CORS middleware reads a * inside an origin as a pattern
|
||||
// that lets other sites read responses.
|
||||
"https://*",
|
||||
"https://*.example.com",
|
||||
"https://*example.com",
|
||||
"https://a.com,b.com", // two hosts
|
||||
"https://example.com:", // an empty port
|
||||
"https://:8443", // no host
|
||||
"https://example.com:0", // a port below 1
|
||||
"https://example.com:99999", // a port above 65535
|
||||
"https://exämple.com", // a host name that is not ASCII
|
||||
"https://example.com:443", // the default port for https
|
||||
"http://example.com:80", // the default port for http
|
||||
"https://example.com:08080", // a port with a leading zero
|
||||
"https://01.2.3.4", // an IPv4 address with a leading zero
|
||||
"https://10.0.0", // an IPv4 address with three parts
|
||||
"https://192.168.1.256", // an IPv4 address part above 255
|
||||
"https://example.123", // a host name whose last part is a number
|
||||
"https://[0:0:0:0:0:0:0:1]", // an IPv6 address not in its shortest form
|
||||
"file://example.com", // a scheme other than http or https
|
||||
"HTTPS://example.com", // a scheme in upper case
|
||||
"https://Example.com", // a host name in upper case
|
||||
}
|
||||
|
||||
cases := make([]abortCase, 0, len(badOrigins))
|
||||
for _, origin := range badOrigins {
|
||||
cases = append(cases, abortCase{
|
||||
name: "access_control_allow_origin " + origin,
|
||||
yaml: signingKeyLine +
|
||||
"access_control_allow_origin: \"" + origin + "\"\n",
|
||||
wantErrSubstrings: []string{keyAccessControlAllowOrigin, origin},
|
||||
})
|
||||
}
|
||||
|
||||
return append(cases, []abortCase{
|
||||
{
|
||||
name: "access_control_allow_origin null",
|
||||
yaml: signingKeyLine + "access_control_allow_origin: null\n",
|
||||
wantErrSubstrings: []string{keyAccessControlAllowOrigin, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "upstream_max_response_size with a unit",
|
||||
yaml: signingKeyLine + "upstream_max_response_size: 50MB\n",
|
||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "50MB"},
|
||||
},
|
||||
{
|
||||
name: "upstream_max_response_size fractional",
|
||||
yaml: signingKeyLine + "upstream_max_response_size: 1.5\n",
|
||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1.5"},
|
||||
},
|
||||
{
|
||||
name: "upstream_max_response_size zero",
|
||||
yaml: signingKeyLine + "upstream_max_response_size: 0\n",
|
||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "0"},
|
||||
},
|
||||
{
|
||||
name: "upstream_max_response_size negative",
|
||||
yaml: signingKeyLine + "upstream_max_response_size: -1\n",
|
||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "-1"},
|
||||
},
|
||||
{
|
||||
name: "upstream_max_response_size null",
|
||||
yaml: signingKeyLine + "upstream_max_response_size: null\n",
|
||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, nullValueText},
|
||||
},
|
||||
{
|
||||
name: "upstream_max_response_size above 1 GiB",
|
||||
yaml: signingKeyLine + "upstream_max_response_size: 1073741825\n",
|
||||
wantErrSubstrings: []string{keyUpstreamMaxResponseSize, "1073741825"},
|
||||
},
|
||||
{
|
||||
name: "upstream_max_response_size largest 64-bit integer",
|
||||
yaml: signingKeyLine +
|
||||
"upstream_max_response_size: 9223372036854775807\n",
|
||||
wantErrSubstrings: []string{
|
||||
keyUpstreamMaxResponseSize, "9223372036854775807",
|
||||
},
|
||||
},
|
||||
}...)
|
||||
}
|
||||
|
||||
// TestInvalidOriginTimeoutOrSizeAbortsStartup verifies the
|
||||
// no-silent-fallback rule for the CORS origin, the two timeouts and the
|
||||
// response size limit: a value that does not parse or is out of range
|
||||
// aborts startup naming the key and the value.
|
||||
func TestInvalidOriginTimeoutOrSizeAbortsStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runAbortCases(t, append(invalidTimeoutCases(), invalidSizeAndOriginCases()...))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/globals"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
@@ -70,10 +69,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
||||
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
||||
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
||||
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
||||
t.Setenv("PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "https://app.example.com")
|
||||
t.Setenv("PIXA_UPSTREAM_FETCH_TIMEOUT", "10s")
|
||||
t.Setenv("PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "1048576")
|
||||
t.Setenv("PIXA_DOWNSTREAM_TIMEOUT", "2m")
|
||||
|
||||
c, err := newFromSmartConfig(nil)
|
||||
if err != nil {
|
||||
@@ -97,10 +92,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
||||
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",
|
||||
UpstreamFetchTimeout: 10 * time.Second,
|
||||
UpstreamMaxResponseSize: 1048576,
|
||||
DownstreamTimeout: 2 * time.Minute,
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(*c, want) {
|
||||
@@ -289,30 +280,6 @@ func TestInvalidDebugFromEnvironmentAbortsStartup(t *testing.T) {
|
||||
wantStartupError(t, err, "PIXA_DEBUG", "maybe")
|
||||
}
|
||||
|
||||
// TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup checks that
|
||||
// an invalid CORS origin, timeout or response size limit in its variable
|
||||
// aborts startup naming the variable and the value.
|
||||
func TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup(t *testing.T) {
|
||||
cases := []struct {
|
||||
variable string
|
||||
value string
|
||||
}{
|
||||
{"PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "example.com"},
|
||||
{"PIXA_UPSTREAM_FETCH_TIMEOUT", "soon"},
|
||||
{"PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "50MB"},
|
||||
{"PIXA_DOWNSTREAM_TIMEOUT", "0s"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.variable, func(t *testing.T) {
|
||||
t.Setenv(tc.variable, tc.value)
|
||||
|
||||
_, err := configFromYAML(t, signingKeyLine)
|
||||
wantStartupError(t, err, tc.variable, tc.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFileAloneBehavesAsBefore checks that with no variables set
|
||||
// (TestMain unsets them) the config file's values are used and omitted
|
||||
// keys take their defaults.
|
||||
|
||||
@@ -106,8 +106,6 @@ func (s *Handlers) initImageService() error {
|
||||
// Create the fetcher config
|
||||
fetcherCfg := httpfetcher.DefaultConfig()
|
||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||
fetcherCfg.Timeout = s.config.UpstreamFetchTimeout
|
||||
fetcherCfg.MaxResponseSize = s.config.UpstreamMaxResponseSize
|
||||
|
||||
if s.config.UpstreamConnectionsPerHost > 0 {
|
||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||
|
||||
+47
-16
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
)
|
||||
|
||||
@@ -26,6 +27,10 @@ var (
|
||||
// HTTP status code for successful fetch.
|
||||
const httpStatusOK = 200
|
||||
|
||||
// metaCacheSize is how many variants' content types metaCache holds. A
|
||||
// variant not among them is served as before, reading its .meta file.
|
||||
const metaCacheSize = 10000
|
||||
|
||||
// CacheConfig holds cache configuration.
|
||||
type CacheConfig struct {
|
||||
StateDir string
|
||||
@@ -49,12 +54,6 @@ type CacheConfig struct {
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// variantMeta stores content type for fast cache hits without reading .meta file.
|
||||
type variantMeta struct {
|
||||
ContentType string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Cache implements the caching layer for the image proxy.
|
||||
type Cache struct {
|
||||
db *sql.DB
|
||||
@@ -76,9 +75,10 @@ type Cache struct {
|
||||
evictionStarted bool
|
||||
evictionStopOnce sync.Once
|
||||
|
||||
// In-memory cache of variant metadata (content type, size) to avoid
|
||||
// reading .meta files
|
||||
metaCache map[VariantKey]variantMeta
|
||||
// metaCache holds the content types of the variants most recently
|
||||
// stored or served, so a hit does not read the variant's .meta file.
|
||||
// It never stands in for the variant file, which is always opened.
|
||||
metaCache *lru.Cache[VariantKey, string]
|
||||
|
||||
// contentLocks serializes StoreSource and evictSourceBlob per
|
||||
// content hash, closing the race window between an eviction's row
|
||||
@@ -101,6 +101,11 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
metaCache, err := lru.New[VariantKey, string](metaCacheSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
|
||||
}
|
||||
|
||||
c := &Cache{
|
||||
db: db,
|
||||
config: config,
|
||||
@@ -109,7 +114,7 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||
evictionPressure: make(chan struct{}, 1),
|
||||
evictionStop: make(chan struct{}),
|
||||
evictionDone: make(chan struct{}),
|
||||
metaCache: make(map[VariantKey]variantMeta),
|
||||
metaCache: metaCache,
|
||||
contentLocks: newContentLock(),
|
||||
}
|
||||
|
||||
@@ -154,13 +159,15 @@ type LookupResult struct {
|
||||
CacheStatus CacheStatus
|
||||
}
|
||||
|
||||
// Lookup checks if a processed variant exists on disk. Hits touch the
|
||||
// variant's LRU timestamp; a disabled cache always misses.
|
||||
// Lookup checks if a processed variant exists on disk: a variant held
|
||||
// in metaCache counts as present without a check of the disk. Hits
|
||||
// touch the variant's LRU timestamp; a disabled cache always misses.
|
||||
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
|
||||
cacheKey := CacheKey(req)
|
||||
|
||||
// Check variant storage directly - no DB needed for cache hits
|
||||
if !c.disabled && c.variants.Exists(cacheKey) {
|
||||
// Check memory, then variant storage - no DB needed for cache hits
|
||||
if !c.disabled &&
|
||||
(c.metaCache.Contains(cacheKey) || c.variants.Exists(cacheKey)) {
|
||||
c.touchVariant(ctx, cacheKey)
|
||||
|
||||
return &LookupResult{
|
||||
@@ -177,13 +184,35 @@ func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, e
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetVariant returns a reader, size, and content type for a cached variant.
|
||||
// GetVariant returns a reader, size, and content type for a cached
|
||||
// variant. The content type comes from metaCache, or else from the
|
||||
// variant's .meta file and is then kept in metaCache.
|
||||
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
||||
if c.disabled {
|
||||
return nil, 0, "", ErrNotFound
|
||||
}
|
||||
|
||||
return c.variants.LoadWithMeta(cacheKey)
|
||||
contentType, known := c.metaCache.Get(cacheKey)
|
||||
if known {
|
||||
reader, size, err := c.variants.LoadWithSize(cacheKey)
|
||||
if err != nil {
|
||||
// The file is gone, e.g. deleted outside pixa
|
||||
c.metaCache.Remove(cacheKey)
|
||||
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
return reader, size, contentType, nil
|
||||
}
|
||||
|
||||
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
c.metaCache.Add(cacheKey, contentType)
|
||||
|
||||
return reader, size, contentType, nil
|
||||
}
|
||||
|
||||
// StoreSource stores fetched source content and metadata. On a
|
||||
@@ -286,6 +315,8 @@ func (c *Cache) StoreVariant(
|
||||
return err
|
||||
}
|
||||
|
||||
c.metaCache.Add(cacheKey, contentType)
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||
VALUES (?, ?, ?)
|
||||
|
||||
@@ -271,7 +271,9 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
||||
|
||||
// evictVariant removes one variant: accounting row first, then the
|
||||
// content and .meta files, so the database never references a deleted
|
||||
// file.
|
||||
// file. The metaCache entry goes before the files; a GetVariant that
|
||||
// read them just before may put it back, and the next GetVariant then
|
||||
// fails to open the file and removes it again.
|
||||
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||
@@ -279,6 +281,8 @@ func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
||||
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||
}
|
||||
|
||||
c.metaCache.Remove(cacheKey)
|
||||
|
||||
err = c.variants.DeleteWithMeta(cacheKey)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// webpRequest returns a request for a 100x100 WebP variant of path.
|
||||
func webpRequest(path string) *ImageRequest {
|
||||
return &ImageRequest{
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: path,
|
||||
Size: Size{Width: 100, Height: 100},
|
||||
Format: FormatWebP,
|
||||
Quality: 85,
|
||||
FitMode: FitCover,
|
||||
}
|
||||
}
|
||||
|
||||
// assertVariantServed checks that GetVariant serves key with the given
|
||||
// content and the image/webp content type storeEvictionTestVariant stores.
|
||||
func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) {
|
||||
t.Helper()
|
||||
|
||||
reader, size, contentType, err := cache.GetVariant(key)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVariant(%s) error = %v", key, err)
|
||||
}
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
got, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("reading variant %s: %v", key, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content)
|
||||
}
|
||||
|
||||
if size != int64(len(content)) {
|
||||
t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content))
|
||||
}
|
||||
|
||||
if contentType != testContentTypeWebP {
|
||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
||||
key, contentType, testContentTypeWebP)
|
||||
}
|
||||
}
|
||||
|
||||
// assertVariantNotFound checks that GetVariant refuses key with
|
||||
// ErrNotFound.
|
||||
func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) {
|
||||
t.Helper()
|
||||
|
||||
reader, _, _, err := cache.GetVariant(key)
|
||||
if err == nil {
|
||||
_ = reader.Close()
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertLookupMisses checks that Lookup reports request as a miss.
|
||||
func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) {
|
||||
t.Helper()
|
||||
|
||||
lookup, err := cache.Lookup(t.Context(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err)
|
||||
}
|
||||
|
||||
if lookup.Hit {
|
||||
t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecondHitDoesNotReadMetaFile checks that once a variant has been
|
||||
// stored or read, a hit takes its content type from memory: with the
|
||||
// .meta file deleted, GetVariant must still return the stored content
|
||||
// type rather than the application/octet-stream it uses without one.
|
||||
func TestSecondHitDoesNotReadMetaFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
content := []byte("webp variant bytes")
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||
|
||||
// A second Cache on the same state directory starts with nothing in
|
||||
// memory, as pixad does after a restart, so its first read uses the
|
||||
// .meta file.
|
||||
restarted, err := NewCache(cache.db, cache.config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCache() error = %v", err)
|
||||
}
|
||||
|
||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
||||
|
||||
err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
||||
if err != nil {
|
||||
t.Fatalf("removing .meta file: %v", err)
|
||||
}
|
||||
|
||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
||||
}
|
||||
|
||||
// TestEvictedVariantIsNotServed checks that a variant the evictor
|
||||
// removed is a miss and cannot be read, although it had been stored
|
||||
// and served before.
|
||||
func TestEvictedVariantIsNotServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1500)
|
||||
|
||||
oldRequest := webpRequest("/old.jpg")
|
||||
newRequest := webpRequest("/new.jpg")
|
||||
oldKey := CacheKey(oldRequest)
|
||||
newKey := CacheKey(newRequest)
|
||||
oldContent := bytes.Repeat([]byte{0x01}, 1000)
|
||||
newContent := bytes.Repeat([]byte{0x02}, 1000)
|
||||
|
||||
storeEvictionTestVariant(t, cache, oldKey, oldContent)
|
||||
storeEvictionTestVariant(t, cache, newKey, newContent)
|
||||
assertVariantServed(t, cache, oldKey, oldContent)
|
||||
setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour))
|
||||
|
||||
err := cache.EvictToLimit(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("EvictToLimit() error = %v", err)
|
||||
}
|
||||
|
||||
assertLookupMisses(t, cache, oldRequest)
|
||||
assertVariantNotFound(t, cache, oldKey)
|
||||
assertVariantServed(t, cache, newKey, newContent)
|
||||
}
|
||||
|
||||
// TestVariantDeletedFromDiskIsNotServed checks that a variant whose
|
||||
// file was deleted by something other than the evictor cannot be read,
|
||||
// and is a miss afterwards.
|
||||
func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
|
||||
request := webpRequest("/deleted.jpg")
|
||||
key := CacheKey(request)
|
||||
content := []byte("webp variant bytes")
|
||||
|
||||
storeEvictionTestVariant(t, cache, key, content)
|
||||
assertVariantServed(t, cache, key, content)
|
||||
|
||||
err := os.Remove(cache.variants.keyToPath(key))
|
||||
if err != nil {
|
||||
t.Fatalf("removing variant file: %v", err)
|
||||
}
|
||||
|
||||
assertVariantNotFound(t, cache, key)
|
||||
assertLookupMisses(t, cache, request)
|
||||
}
|
||||
|
||||
// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts
|
||||
// variants from several goroutines at once, for the race detector.
|
||||
func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
ctx := t.Context()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for goroutine := range 8 {
|
||||
wg.Go(func() {
|
||||
key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
|
||||
content := []byte(key)
|
||||
|
||||
for range 20 {
|
||||
err := cache.StoreVariant(
|
||||
ctx, key, bytes.NewReader(content), testContentTypeWebP)
|
||||
if err != nil {
|
||||
t.Errorf("StoreVariant(%s) error = %v", key, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reader, _, contentType, err := cache.GetVariant(key)
|
||||
if err != nil {
|
||||
t.Errorf("GetVariant(%s) error = %v", key, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
if contentType != testContentTypeWebP {
|
||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
||||
key, contentType, testContentTypeWebP)
|
||||
}
|
||||
|
||||
err = cache.evictVariant(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("evictVariant(%s) error = %v", key, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assertVariantNotFound(t, cache, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -506,32 +506,43 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||
// the given key.
|
||||
func (s *VariantStorage) LoadWithMeta(
|
||||
key VariantKey,
|
||||
) (io.ReadCloser, int64, string, error) {
|
||||
// LoadWithSize returns a reader and file size for the content at the
|
||||
// given key.
|
||||
func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) {
|
||||
path := s.keyToPath(key)
|
||||
metaPath := path + ".meta"
|
||||
|
||||
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, 0, "", ErrNotFound
|
||||
return nil, 0, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, 0, "", fmt.Errorf("failed to open content: %w", err)
|
||||
return nil, 0, fmt.Errorf("failed to open content: %w", err)
|
||||
}
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return nil, 0, "", fmt.Errorf("failed to stat content: %w", err)
|
||||
return nil, 0, fmt.Errorf("failed to stat content: %w", err)
|
||||
}
|
||||
|
||||
return f, stat.Size(), nil
|
||||
}
|
||||
|
||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||
// the given key.
|
||||
func (s *VariantStorage) LoadWithMeta(
|
||||
key VariantKey,
|
||||
) (io.ReadCloser, int64, string, error) {
|
||||
f, size, err := s.LoadWithSize(key)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
// Load metadata for content type
|
||||
contentType := "application/octet-stream" // fallback
|
||||
metaPath := s.keyToPath(key) + ".meta"
|
||||
|
||||
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
||||
if err == nil {
|
||||
@@ -541,7 +552,7 @@ func (s *VariantStorage) LoadWithMeta(
|
||||
}
|
||||
}
|
||||
|
||||
return f, stat.Size(), contentType, nil
|
||||
return f, size, contentType, nil
|
||||
}
|
||||
|
||||
// Exists checks if content exists at the given key.
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
testHostExample = "example.com"
|
||||
testPathCat = "/photos/cat.jpg"
|
||||
testContentTypeJPEG = "image/jpeg"
|
||||
testContentTypeWebP = "image/webp"
|
||||
testHeaderContentType = "Content-Type"
|
||||
)
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s *Middleware) Logging() func(http.Handler) http.Handler {
|
||||
// CORS returns a CORS middleware.
|
||||
func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{s.config.AccessControlAllowOrigin},
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
|
||||
@@ -9,53 +9,6 @@ import (
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// TestCORSAnswersWithConfiguredOrigin checks that the CORS middleware
|
||||
// answers with access_control_allow_origin, where "*" lets any origin read
|
||||
// responses and a single origin lets only that origin read them.
|
||||
func TestCORSAnswersWithConfiguredOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const appOrigin = "https://app.example.com"
|
||||
|
||||
cases := []struct {
|
||||
configured string
|
||||
requestOrigin string
|
||||
want string
|
||||
}{
|
||||
{"*", "https://any.example.com", "*"},
|
||||
{appOrigin, appOrigin, appOrigin},
|
||||
{appOrigin, "https://other.example.com", ""},
|
||||
}
|
||||
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
for _, tc := range cases {
|
||||
mw := &Middleware{
|
||||
log: slog.Default(),
|
||||
config: &config.Config{AccessControlAllowOrigin: tc.configured},
|
||||
}
|
||||
|
||||
handler := mw.CORS()(testHandler)
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/v1/image/example.com/a.jpg/1x1.png", nil)
|
||||
req.Header.Set("Origin", tc.requestOrigin)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
||||
if got != tc.want {
|
||||
t.Errorf("configured %q, request from %q: "+
|
||||
"Access-Control-Allow-Origin = %q, want %q",
|
||||
tc.configured, tc.requestOrigin, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
// short, so a slowloris client dribbling headers is dropped well
|
||||
// before it ties up a connection for the whole ReadTimeout window.
|
||||
HTTPReadHeaderTimeout = 10 * time.Second
|
||||
HTTPWriteTimeout = 60 * time.Second
|
||||
// HTTPIdleTimeout bounds how long an idle keep-alive connection is
|
||||
// held open, so idle connections cannot accumulate without limit on a
|
||||
// service targeting high concurrency.
|
||||
@@ -29,7 +30,7 @@ func (s *Server) newHTTPServer() *http.Server {
|
||||
Addr: fmt.Sprintf(":%d", s.config.Port),
|
||||
ReadTimeout: HTTPReadTimeout,
|
||||
ReadHeaderTimeout: HTTPReadHeaderTimeout,
|
||||
WriteTimeout: s.config.DownstreamTimeout,
|
||||
WriteTimeout: HTTPWriteTimeout,
|
||||
IdleTimeout: HTTPIdleTimeout,
|
||||
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
||||
Handler: s,
|
||||
|
||||
@@ -11,15 +11,11 @@ import (
|
||||
// carries every hardening timeout wired onto it, including the slowloris
|
||||
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
|
||||
// guards against a field being defined but never set on the server, so
|
||||
// each assertion compares the server field to its constant, or, for
|
||||
// WriteTimeout, to downstream_timeout from the config.
|
||||
// each assertion compares the server field to its constant.
|
||||
func TestNewHTTPServerTimeouts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{config: &config.Config{
|
||||
Port: 8080,
|
||||
DownstreamTimeout: 45 * time.Second,
|
||||
}}
|
||||
s := &Server{config: &config.Config{Port: 8080}}
|
||||
|
||||
srv := s.newHTTPServer()
|
||||
|
||||
@@ -30,7 +26,7 @@ func TestNewHTTPServerTimeouts(t *testing.T) {
|
||||
}{
|
||||
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
|
||||
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
|
||||
{"WriteTimeout", srv.WriteTimeout, 45 * time.Second},
|
||||
{"WriteTimeout", srv.WriteTimeout, HTTPWriteTimeout},
|
||||
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
|
||||
}
|
||||
|
||||
|
||||
@@ -51,12 +51,11 @@ func newTestServer(t *testing.T) *Server {
|
||||
|
||||
stateDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Debug: true,
|
||||
SigningKey: testSigningKey,
|
||||
StateDir: stateDir,
|
||||
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
DownstreamTimeout: config.DefaultDownstreamTimeout,
|
||||
Debug: true,
|
||||
SigningKey: testSigningKey,
|
||||
StateDir: stateDir,
|
||||
DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"),
|
||||
TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
}
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
@@ -33,7 +33,7 @@ func (s *Server) SetupRoutes() {
|
||||
}
|
||||
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
|
||||
s.router.Use(middleware.Timeout(HTTPWriteTimeout))
|
||||
|
||||
if s.sentryEnabled {
|
||||
sentryHandler := sentryhttp.New(sentryhttp.Options{
|
||||
|
||||
+2
-9
@@ -5,10 +5,8 @@
|
||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||
# make, or go). The linter is never installed on the host: golangci-lint
|
||||
# runs only inside a container, Dockerfile.lint or the Dockerfile lint
|
||||
# stage (see script/lint). A C compiler and the CGO image libraries
|
||||
# (pkg-config, vips, libheif) are installed for the govips bindings.
|
||||
# Both Dockerfiles run this script too, so their build dependencies are
|
||||
# the ones listed here.
|
||||
# stage (see script/lint). CGO image libraries (pkg-config, vips,
|
||||
# libheif) are installed for the govips bindings.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -55,11 +53,6 @@ missing() {
|
||||
|
||||
# CGO dependencies for govips (image processing)
|
||||
ensure_cgo_deps() {
|
||||
# cgo compiles with gcc on Linux; build-base and build-essential
|
||||
# also bring the C library headers.
|
||||
if missing gcc; then
|
||||
pkg_install gcc build-essential gcc build-base
|
||||
fi
|
||||
if missing pkg-config; then
|
||||
pkg_install pkg-config pkg-config pkg-config pkgconfig
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user