Compare commits
1
Commits
next
..
99fbbe10f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99fbbe10f7 |
@@ -46,9 +46,9 @@ everything in this list without further settings. The reverse proxy must:
|
|||||||
builds encrypted URLs from `Host`, and checks `Referer` against
|
builds encrypted URLs from `Host`, and checks `Referer` against
|
||||||
`referer_blocklist`;
|
`referer_blocklist`;
|
||||||
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
|
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
|
||||||
the address pixa sees the proxy's requests come from, so the login limit and
|
the address pixa sees the proxy's requests come from, so the login limit
|
||||||
`image_requests_per_minute` count each client by its own address (see
|
counts each client by its own address (see `trusted_proxies` under
|
||||||
`trusted_proxies` under Configuration);
|
Configuration);
|
||||||
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
|
||||||
longest pixa takes to fetch, convert and send an image.
|
longest pixa takes to fetch, convert and send an image.
|
||||||
|
|
||||||
@@ -58,9 +58,8 @@ password there.
|
|||||||
|
|
||||||
pixa does the rest itself: it checks signatures and encrypted URLs, applies the
|
pixa does the rest itself: it checks signatures and encrypted URLs, applies the
|
||||||
allowlist, refuses upstream hosts with private or local addresses, limits login
|
allowlist, refuses upstream hosts with private or local addresses, limits login
|
||||||
attempts, each client's image requests, upstream response size and image
|
attempts, upstream response size and image dimensions, and sends the security
|
||||||
dimensions, and sends the security headers, `Strict-Transport-Security`
|
headers, `Strict-Transport-Security` included, with every response.
|
||||||
included, with every response.
|
|
||||||
|
|
||||||
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
|
||||||
database and the disk cache:
|
database and the disk cache:
|
||||||
@@ -189,10 +188,10 @@ path under `/v1/` answers 200, in maintenance mode too.
|
|||||||
`Referer` names a host in `referer_blocklist`, checked before the signature,
|
`Referer` names a host in `referer_blocklist`, checked before the signature,
|
||||||
the cache and the upstream fetch; 403 when the upstream host, or a host it
|
the cache and the upstream fetch; 403 when the upstream host, or a host it
|
||||||
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
|
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
|
||||||
address in a blocked network (see `blocked_networks`); 429 over
|
address in a blocked network (see `blocked_networks`); 502 when the upstream
|
||||||
`image_requests_per_minute` (below); 502 when the upstream answered with an
|
answered with an error status, and for 5 minutes after that for the same
|
||||||
error status, and for 5 minutes after that for the same source URL; 503 when
|
source URL; 503 when pixa is busy or in maintenance mode; 500 for any other
|
||||||
pixa is busy or in maintenance mode; 500 for any other failure.
|
failure.
|
||||||
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
||||||
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
|
||||||
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
|
||||||
@@ -200,7 +199,7 @@ path under `/v1/` answers 200, in maintenance mode too.
|
|||||||
expired; 504 when the upstream has not sent its response headers within
|
expired; 504 when the upstream has not sent its response headers within
|
||||||
`upstream_fetch_timeout`, but 500 when that time runs out while the image
|
`upstream_fetch_timeout`, but 500 when that time runs out while the image
|
||||||
itself is still arriving; 400 for an `Accept` header that is not valid, and
|
itself is still arriving; 400 for an `Accept` header that is not valid, and
|
||||||
406, 403, 429, 502, 503 and 500, as for `/v1/image/`.
|
406, 403, 502, 503 and 500, as for `/v1/image/`.
|
||||||
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
|
||||||
nothing. Answers: 200.
|
nothing. Answers: 200.
|
||||||
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
|
||||||
@@ -316,15 +315,6 @@ 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
|
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.
|
address pixa sees for requests that come through the proxy closes this.
|
||||||
|
|
||||||
One client may send `image_requests_per_minute` requests per minute
|
|
||||||
(default 600) to the image routes, `/v1/image/` and `/v1/e/` together, `GET` and
|
|
||||||
`HEAD`, cache hits included; a request over the limit is refused with 429 and a
|
|
||||||
`Retry-After` header. Clients are counted as for the login limit. A browser's
|
|
||||||
CORS preflight is not counted. Behind a CDN or proxy whose addresses are not in
|
|
||||||
`trusted_proxies`, all requests arriving from one proxy or CDN address count as
|
|
||||||
one client, so such a deployment needs a higher limit, or `0`, which turns the
|
|
||||||
limit off.
|
|
||||||
|
|
||||||
### Encrypted URLs
|
### Encrypted URLs
|
||||||
|
|
||||||
An encrypted URL is an image URL made on pixa's own web page by someone who
|
An encrypted URL is an image URL made on pixa's own web page by someone who
|
||||||
@@ -494,7 +484,6 @@ tell. With no file, pixa uses the environment and the defaults.
|
|||||||
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
||||||
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
||||||
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
||||||
| `PIXA_IMAGE_REQUESTS_PER_MINUTE` | `image_requests_per_minute` | Image requests per client per minute; `0` turns it off; default `600` |
|
|
||||||
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
| `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_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_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||||
@@ -544,13 +533,13 @@ Key settings in more detail:
|
|||||||
explicit list replaces the default. An invalid CIDR aborts startup. Set this
|
explicit list replaces the default. An invalid CIDR aborts startup. Set this
|
||||||
to the address pixa sees for requests that come through your proxy, such as
|
to the address pixa sees for requests that come through your proxy, such as
|
||||||
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
|
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
|
||||||
(see the login limit and `image_requests_per_minute` under Routes). For a
|
(see the login limit under Routes). For a proxy on the Docker host that
|
||||||
proxy on the Docker host that connects to pixa over `127.0.0.1`, that address
|
connects to pixa over `127.0.0.1`, that address is the gateway of the
|
||||||
is the gateway of the container's Docker network (`172.17.0.1` on the default
|
container's Docker network (`172.17.0.1` on the default bridge), not the
|
||||||
bridge), not the proxy's own address; a proxy that connects through another of
|
proxy's own address; a proxy that connects through another of the host's
|
||||||
the host's addresses is seen with that address. To be sure which address it
|
addresses is seen with that address. To be sure which address it is, set this
|
||||||
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request
|
to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request through the
|
||||||
through the proxy, and read `remoteIP` in pixa's request log line for it
|
proxy, and read `remoteIP` in pixa's request log line for it
|
||||||
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
|
||||||
as a duration such as `30s` (the default) or `2m`
|
as a duration such as `30s` (the default) or `2m`
|
||||||
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
- `upstream_max_response_size` — largest upstream response accepted, in bytes;
|
||||||
@@ -583,12 +572,6 @@ Key settings in more detail:
|
|||||||
container's CPU limit. A request that finds all of them in use waits up to 10
|
container's CPU limit. A request that finds all of them in use waits up to 10
|
||||||
seconds for one to free up; if none does, and `downstream_timeout` has not
|
seconds for one to free up; if none does, and `downstream_timeout` has not
|
||||||
ended first, it is answered 503 the same way
|
ended first, it is answered 503 the same way
|
||||||
- `image_requests_per_minute` — how many requests one client may send to the
|
|
||||||
image routes per minute (see the limit on them under Routes); default `600`.
|
|
||||||
`0` turns the limit off, and a negative or non-integer value aborts startup.
|
|
||||||
Behind a CDN or proxy whose addresses are not in `trusted_proxies`, all
|
|
||||||
requests arriving from one proxy or CDN address count as one client, so raise
|
|
||||||
the limit there, or set `0`
|
|
||||||
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
||||||
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header
|
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header
|
||||||
and a JSON error body. The health check (`/.well-known/healthcheck.json`)
|
and a JSON error body. The health check (`/.well-known/healthcheck.json`)
|
||||||
|
|||||||
@@ -26,18 +26,10 @@ resolved. The disk cache is now size-bounded with LRU eviction
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
P2: security: per-origin rate limiting
|
P2: security: per-IP rate limiting on the image routes
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-12 per-client limit on the image routes (closes #236): the new setting
|
|
||||||
`image_requests_per_minute` (`PIXA_IMAGE_REQUESTS_PER_MINUTE`), default 600,
|
|
||||||
is how many `GET` and `HEAD` requests one client may send to `/v1/image/` and
|
|
||||||
`/v1/e/` together per minute, cache hits included; the next is answered 429
|
|
||||||
with `Retry-After`. It uses the login limit's `RateLimit` middleware, so
|
|
||||||
clients are told apart the same way, and runs after the CORS middleware, so a
|
|
||||||
preflight is not counted. `0` turns the limit off; a negative or non-integer
|
|
||||||
value aborts startup.
|
|
||||||
- 2026-10-08 a request past its deadline no longer waits for the database to
|
- 2026-10-08 a request past its deadline no longer waits for the database to
|
||||||
count it (closes #224). On a busy host, `TestService_Get_ReturnsByItsDeadline`
|
count it (closes #224). On a busy host, `TestService_Get_ReturnsByItsDeadline`
|
||||||
failed because its request, past its deadline, still waited for its miss count
|
failed because its request, past its deadline, still waited for its miss count
|
||||||
@@ -757,6 +749,8 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
|
- P2: security
|
||||||
|
- per-origin rate limiting
|
||||||
- P2: HTTP response handling
|
- P2: HTTP response handling
|
||||||
- Last-Modified headers
|
- Last-Modified headers
|
||||||
- P2: configuration
|
- P2: configuration
|
||||||
|
|||||||
@@ -115,14 +115,6 @@ upstream_connections: 64
|
|||||||
# frees up it is answered 503, unless downstream_timeout has ended first.
|
# frees up it is answered 503, unless downstream_timeout has ended first.
|
||||||
# max_concurrent_processing: 4
|
# max_concurrent_processing: 4
|
||||||
|
|
||||||
# How many requests to the image routes (/v1/image/ and /v1/e/) one client
|
|
||||||
# may send per minute; a request over the limit is answered 429 with a
|
|
||||||
# Retry-After header. 0 turns the limit off. Clients are told apart as for
|
|
||||||
# the login limit, so behind a CDN or proxy whose addresses are not in
|
|
||||||
# trusted_proxies all requests arriving from one proxy or CDN address count
|
|
||||||
# as one client: raise the limit there, or set 0. (default: 600)
|
|
||||||
image_requests_per_minute: 600
|
|
||||||
|
|
||||||
# Time allowed for one fetch from an upstream host (default: 30s)
|
# Time allowed for one fetch from an upstream host (default: 30s)
|
||||||
upstream_fetch_timeout: 30s
|
upstream_fetch_timeout: 30s
|
||||||
|
|
||||||
|
|||||||
+10
-39
@@ -34,7 +34,6 @@ const (
|
|||||||
DefaultUpstreamFetchTimeout = 30 * time.Second
|
DefaultUpstreamFetchTimeout = 30 * time.Second
|
||||||
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
|
||||||
DefaultDownstreamTimeout = 60 * time.Second
|
DefaultDownstreamTimeout = 60 * time.Second
|
||||||
DefaultImageRequestsPerMinute = 600
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configuration key names.
|
// Configuration key names.
|
||||||
@@ -62,7 +61,6 @@ const (
|
|||||||
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
|
||||||
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
keyUpstreamMaxResponseSize = "upstream_max_response_size"
|
||||||
keyDownstreamTimeout = "downstream_timeout"
|
keyDownstreamTimeout = "downstream_timeout"
|
||||||
keyImageRequestsPerMinute = "image_requests_per_minute"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// placeholderSigningKey is the dummy signing_key shipped in
|
// placeholderSigningKey is the dummy signing_key shipped in
|
||||||
@@ -160,11 +158,6 @@ type Config struct {
|
|||||||
// of the per-request timeout middleware.
|
// of the per-request timeout middleware.
|
||||||
DownstreamTimeout time.Duration
|
DownstreamTimeout time.Duration
|
||||||
|
|
||||||
// ImageRequestsPerMinute is how many requests one client may make to
|
|
||||||
// the image routes per minute; the next is refused with 429 Too Many
|
|
||||||
// Requests. 0 turns the limit off.
|
|
||||||
ImageRequestsPerMinute int
|
|
||||||
|
|
||||||
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
// BlockedNetworks are operator-supplied CIDR ranges to refuse in
|
||||||
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
// addition to the built-in SSRF blocklist. Enforced by the upstream
|
||||||
// fetcher's dialer; the built-in ranges always apply.
|
// fetcher's dialer; the built-in ranges always apply.
|
||||||
@@ -309,8 +302,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
|
||||||
DownstreamTimeout: loader.durationVal(
|
DownstreamTimeout: loader.durationVal(
|
||||||
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
keyDownstreamTimeout, DefaultDownstreamTimeout),
|
||||||
ImageRequestsPerMinute: loader.intVal(
|
|
||||||
keyImageRequestsPerMinute, DefaultImageRequestsPerMinute),
|
|
||||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||||
BlockedNetworks: blockedNetworks,
|
BlockedNetworks: blockedNetworks,
|
||||||
TrustedProxies: trustedProxies,
|
TrustedProxies: trustedProxies,
|
||||||
@@ -320,7 +311,9 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
// The default for an omitted cache_max_bytes is worked out when
|
// The default for an omitted cache_max_bytes is worked out when
|
||||||
// the cache opens; here we only record whether the operator set
|
// the cache opens; here we only record whether the operator set
|
||||||
// the key explicitly.
|
// the key explicitly.
|
||||||
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes)
|
if _, present := lookupValue(sc, keyCacheMaxBytes); present {
|
||||||
|
c.CacheMaxBytesExplicit = true
|
||||||
|
}
|
||||||
|
|
||||||
// Build DBURL from StateDir if not explicitly set. The derived URL
|
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||||
// is a default: it applies only when db_url is omitted, never to an
|
// is a default: it applies only when db_url is omitted, never to an
|
||||||
@@ -433,7 +426,7 @@ func isKnownConfigKey(key string) bool {
|
|||||||
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
|
||||||
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
|
||||||
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
|
||||||
keyImageRequestsPerMinute, "env":
|
"env":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +461,6 @@ func envVarNames() map[string]string {
|
|||||||
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
|
||||||
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
|
||||||
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
|
||||||
keyImageRequestsPerMinute: "PIXA_IMAGE_REQUESTS_PER_MINUTE",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,14 +613,6 @@ func (c *Config) validate() error {
|
|||||||
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zero is valid (it turns the limit off); only negative values are
|
|
||||||
// rejected.
|
|
||||||
if c.ImageRequestsPerMinute < 0 {
|
|
||||||
return fmt.Errorf("%s: value %d %w",
|
|
||||||
settingName(keyImageRequestsPerMinute), c.ImageRequestsPerMinute,
|
|
||||||
errMustNotBeNegative)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = c.validateUpstreamMaxResponseSize()
|
err = c.validateUpstreamMaxResponseSize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -641,9 +625,12 @@ func (c *Config) validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.validateSentryDSN()
|
if c.SentryDSN != "" {
|
||||||
if err != nil {
|
parsed, err := url.Parse(c.SentryDSN)
|
||||||
return err
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("%s: value %q is %w",
|
||||||
|
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
|
||||||
@@ -655,22 +642,6 @@ func (c *Config) validate() error {
|
|||||||
return c.validateAccessControlAllowOrigin()
|
return c.validateAccessControlAllowOrigin()
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSentryDSN checks that sentry_dsn, when set, is a URL with a scheme
|
|
||||||
// and a host.
|
|
||||||
func (c *Config) validateSentryDSN() error {
|
|
||||||
if c.SentryDSN == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(c.SentryDSN)
|
|
||||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
||||||
return fmt.Errorf("%s: value %q is %w",
|
|
||||||
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
// validateUpstreamMaxResponseSize checks that upstream_max_response_size
|
||||||
// is from 1 byte to 1 GiB. An upstream response is read whole into
|
// 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
|
// memory, and the image processor reads one byte past this limit, which
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
|
||||||
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
|
||||||
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
|
||||||
t.Setenv("PIXA_IMAGE_REQUESTS_PER_MINUTE", "30")
|
|
||||||
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
|
||||||
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
|
||||||
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
|
||||||
@@ -100,7 +99,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
|
|||||||
UpstreamConnectionsPerHost: 5,
|
UpstreamConnectionsPerHost: 5,
|
||||||
UpstreamConnections: 10,
|
UpstreamConnections: 10,
|
||||||
MaxConcurrentProcessing: 3,
|
MaxConcurrentProcessing: 3,
|
||||||
ImageRequestsPerMinute: 30,
|
|
||||||
CacheMaxBytes: 1024,
|
CacheMaxBytes: 1024,
|
||||||
CacheMaxBytesExplicit: true,
|
CacheMaxBytesExplicit: true,
|
||||||
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// testImageRequestsPerMinuteVar is the variable that sets
|
|
||||||
// image_requests_per_minute.
|
|
||||||
const testImageRequestsPerMinuteVar = "PIXA_IMAGE_REQUESTS_PER_MINUTE"
|
|
||||||
|
|
||||||
// TestImageRequestsPerMinuteValues checks that an omitted
|
|
||||||
// image_requests_per_minute is 600, and that 0, which turns the limit off,
|
|
||||||
// and any larger whole number are used as given.
|
|
||||||
func TestImageRequestsPerMinuteValues(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.ImageRequestsPerMinute != 600 {
|
|
||||||
t.Errorf("ImageRequestsPerMinute = %d, want 600", c.ImageRequestsPerMinute)
|
|
||||||
}
|
|
||||||
|
|
||||||
explicit := []struct {
|
|
||||||
value string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{"0", 0},
|
|
||||||
{"30", 30},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range explicit {
|
|
||||||
c, err := configFromYAML(t,
|
|
||||||
signingKeyLine+"image_requests_per_minute: "+tc.value+"\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("image_requests_per_minute: %s should load, got error: %v",
|
|
||||||
tc.value, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.ImageRequestsPerMinute != tc.want {
|
|
||||||
t.Errorf("ImageRequestsPerMinute = %d, want %d",
|
|
||||||
c.ImageRequestsPerMinute, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInvalidImageRequestsPerMinuteAbortsStartup checks that a value that is
|
|
||||||
// negative, not a whole number, or null aborts startup naming the key and
|
|
||||||
// the value, and the variable too where the value could have come from it.
|
|
||||||
func TestInvalidImageRequestsPerMinuteAbortsStartup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
key := keyImageRequestsPerMinute
|
|
||||||
|
|
||||||
runAbortCases(t, []abortCase{
|
|
||||||
{
|
|
||||||
name: "negative",
|
|
||||||
yaml: signingKeyLine + key + ": -1\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
key, testImageRequestsPerMinuteVar, "value -1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "not a number",
|
|
||||||
yaml: signingKeyLine + key + ": plenty\n",
|
|
||||||
wantErrSubstrings: []string{
|
|
||||||
key, testImageRequestsPerMinuteVar, "plenty",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fractional",
|
|
||||||
yaml: signingKeyLine + key + ": 2.5\n",
|
|
||||||
wantErrSubstrings: []string{key, "2.5"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit null",
|
|
||||||
yaml: signingKeyLine + key + ": null\n",
|
|
||||||
wantErrSubstrings: []string{key, nullValueText},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageRequestsPerMinuteFromEnvironment checks that the variable sets the
|
|
||||||
// limit over the config file, and that an invalid value in it aborts startup
|
|
||||||
// naming the variable and the value.
|
|
||||||
func TestImageRequestsPerMinuteFromEnvironment(t *testing.T) {
|
|
||||||
t.Setenv(testImageRequestsPerMinuteVar, "0")
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, signingKeyLine+"image_requests_per_minute: 30\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("limit from the environment should load: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.ImageRequestsPerMinute != 0 {
|
|
||||||
t.Errorf("ImageRequestsPerMinute = %d, want 0 from the environment",
|
|
||||||
c.ImageRequestsPerMinute)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, value := range []string{"-5", "ten"} {
|
|
||||||
t.Run(value, func(t *testing.T) {
|
|
||||||
t.Setenv(testImageRequestsPerMinuteVar, value)
|
|
||||||
|
|
||||||
_, err := configFromYAML(t, signingKeyLine)
|
|
||||||
wantStartupError(t, err, testImageRequestsPerMinuteVar, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -206,16 +206,6 @@ func parseExpires(query url.Values) (time.Time, error) {
|
|||||||
return time.Unix(exp, 0), nil
|
return time.Unix(exp, 0), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleTooManyRequests answers 429 Too Many Requests with the JSON error
|
|
||||||
// body the image routes send for their other errors. The image routes' rate
|
|
||||||
// limit calls it for a request over the limit.
|
|
||||||
func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
s.respondError(w, "too many requests, try again later",
|
|
||||||
http.StatusTooManyRequests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// respondImageError maps image retrieval errors to HTTP responses.
|
// respondImageError maps image retrieval errors to HTTP responses.
|
||||||
func (s *Handlers) respondImageError(
|
func (s *Handlers) respondImageError(
|
||||||
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||||
|
|||||||
+11
-27
@@ -11,7 +11,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -87,8 +86,7 @@ type Cache struct {
|
|||||||
// StartPendingCountWrites starts. As for eviction, the channels are
|
// StartPendingCountWrites starts. As for eviction, the channels are
|
||||||
// created in NewCache: pendingCountsAdded wakes that goroutine, and
|
// created in NewCache: pendingCountsAdded wakes that goroutine, and
|
||||||
// pendingCountsDone is closed when it returns. pendingCountsCancel,
|
// pendingCountsDone is closed when it returns. pendingCountsCancel,
|
||||||
// set by StartPendingCountWrites, cancels its context, which tells it
|
// set by StartPendingCountWrites, cancels its context.
|
||||||
// to finish.
|
|
||||||
pendingHits atomic.Int64
|
pendingHits atomic.Int64
|
||||||
pendingMisses atomic.Int64
|
pendingMisses atomic.Int64
|
||||||
pendingUpstreamFetches atomic.Int64
|
pendingUpstreamFetches atomic.Int64
|
||||||
@@ -98,10 +96,6 @@ type Cache struct {
|
|||||||
pendingCountsDone chan struct{}
|
pendingCountsDone chan struct{}
|
||||||
pendingCountsCancel context.CancelFunc
|
pendingCountsCancel context.CancelFunc
|
||||||
|
|
||||||
// Held through each write of the pending counts, and by
|
|
||||||
// readHitAndMissCounts to read them
|
|
||||||
pendingCountsWriteMutex sync.Mutex
|
|
||||||
|
|
||||||
// metaCache holds the content types of the variants most recently
|
// metaCache holds the content types of the variants most recently
|
||||||
// stored or served, so a hit does not read the variant's .meta file.
|
// 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.
|
// It never stands in for the variant file, which is always opened.
|
||||||
@@ -500,11 +494,20 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
|
|||||||
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||||
var stats CacheStats
|
var stats CacheStats
|
||||||
|
|
||||||
err := c.readHitAndMissCounts(ctx, &stats)
|
// Fetch hit/miss counts from the stats table
|
||||||
|
err := c.db.QueryRowContext(ctx, `
|
||||||
|
SELECT hit_count, miss_count
|
||||||
|
FROM cache_stats WHERE id = 1
|
||||||
|
`).Scan(&stats.HitCount, &stats.MissCount)
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
return nil, fmt.Errorf("failed to get cache stats: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hits and misses not yet written count too (see StartPendingCountWrites)
|
||||||
|
stats.HitCount += c.pendingHits.Load()
|
||||||
|
stats.MissCount += c.pendingMisses.Load()
|
||||||
|
|
||||||
// Count and size the cached source images and processed variants from
|
// Count and size the cached source images and processed variants from
|
||||||
// their tables. A disabled cache holds none, whatever rows an earlier
|
// their tables. A disabled cache holds none, whatever rows an earlier
|
||||||
// run left.
|
// run left.
|
||||||
@@ -621,25 +624,6 @@ func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
|
||||||
// cache_stats row plus the pending hits and misses (see
|
|
||||||
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
|
|
||||||
// write of the pending counts falls between the two reads.
|
|
||||||
func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) error {
|
|
||||||
c.pendingCountsWriteMutex.Lock()
|
|
||||||
defer c.pendingCountsWriteMutex.Unlock()
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx, `
|
|
||||||
SELECT hit_count, miss_count
|
|
||||||
FROM cache_stats WHERE id = 1
|
|
||||||
`).Scan(&stats.HitCount, &stats.MissCount)
|
|
||||||
|
|
||||||
stats.HitCount += c.pendingHits.Load()
|
|
||||||
stats.MissCount += c.pendingMisses.Load()
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
||||||
// hold: it reads the content type from the variant's .meta file and
|
// hold: it reads the content type from the variant's .meta file and
|
||||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
// keeps it in metaCache, unless a StoreVariant has put one there
|
||||||
|
|||||||
+60
-124
@@ -8,25 +8,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pendingCountsWait is how long a test waits for the database's connection,
|
// pendingCountsWait is how long a test waits for a count to reach the
|
||||||
// for a count to reach the database, for a write to start waiting for the
|
// database, or for a write to start waiting for the database's connection.
|
||||||
// connection, for a request to return past its deadline, or for
|
|
||||||
// StopPendingCountWrites to return.
|
|
||||||
const pendingCountsWait = 5 * time.Second
|
const pendingCountsWait = 5 * time.Second
|
||||||
|
|
||||||
// otherWritesWait is how long a test gives other writes, once one write waits
|
|
||||||
// for the database's connection, to start waiting for it too.
|
|
||||||
const otherWritesWait = 100 * time.Millisecond
|
|
||||||
|
|
||||||
// holdDatabase takes the one connection of cache's database, so that every
|
// holdDatabase takes the one connection of cache's database, so that every
|
||||||
// other query waits for it, and returns the func that frees it.
|
// other query waits for it, and returns the func that frees it.
|
||||||
func holdDatabase(t *testing.T, cache *Cache) func() {
|
func holdDatabase(t *testing.T, cache *Cache) func() {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), pendingCountsWait)
|
conn, err := cache.db.Conn(t.Context())
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
conn, err := cache.db.Conn(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to take the database connection: %v", err)
|
t.Fatalf("failed to take the database connection: %v", err)
|
||||||
}
|
}
|
||||||
@@ -73,9 +64,9 @@ func waitForCounters(t *testing.T, cache *Cache, want cacheStatsCounters) {
|
|||||||
|
|
||||||
// TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
|
// TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
|
||||||
// database's one connection while a request whose fetch is held reaches its
|
// database's one connection while a request whose fetch is held reaches its
|
||||||
// deadline. The request must return with the deadline's error while the
|
// deadline. The request must still return by its deadline with the
|
||||||
// connection is still held, as it waits for no database write past its
|
// deadline's error, and its miss must reach the database once the
|
||||||
// deadline, and its miss must reach the database once the connection is free.
|
// connection is free.
|
||||||
func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -84,36 +75,30 @@ func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
|||||||
svc.cache.StartPendingCountWrites()
|
svc.cache.StartPendingCountWrites()
|
||||||
defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }()
|
defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }()
|
||||||
|
|
||||||
// Room for the request to reach its fetch on a busy host
|
const timeout = 200 * time.Millisecond
|
||||||
const timeout = 2 * time.Second
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
deadline, _ := ctx.Deadline()
|
|
||||||
|
|
||||||
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
|
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
// The request has made its database reads by the time it fetches.
|
// The request has made its database reads by the time it fetches.
|
||||||
select {
|
<-fetcher.started
|
||||||
case <-fetcher.started:
|
|
||||||
case <-time.After(timeout):
|
|
||||||
t.Fatal("request did not reach its fetch by its deadline")
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseDatabase := holdDatabase(t, svc.cache)
|
releaseDatabase := holdDatabase(t, svc.cache)
|
||||||
defer releaseDatabase()
|
defer releaseDatabase()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case got := <-results:
|
case got := <-results:
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
t.Logf("Get() returned %v after its deadline, error = %v",
|
t.Logf("Get() returned %v after its deadline, error = %v",
|
||||||
time.Since(deadline), got.err)
|
time.Since(deadline), got.err)
|
||||||
|
|
||||||
if !errors.Is(got.err, context.DeadlineExceeded) {
|
if !errors.Is(got.err, context.DeadlineExceeded) {
|
||||||
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
|
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
case <-time.After(time.Until(deadline) + pendingCountsWait):
|
case <-time.After(timeout + time.Second):
|
||||||
t.Fatal("request did not return while the database was busy")
|
t.Fatal("request did not return by its deadline while the database was busy")
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseDatabase()
|
releaseDatabase()
|
||||||
@@ -121,47 +106,67 @@ func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
|||||||
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1})
|
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting holds the
|
// TestService_Get_ManyRequestsPastTheirDeadlineLeaveOneWriteWaiting holds the
|
||||||
// database's one connection while many misses are counted past their
|
// database's one connection while many requests whose fetches are held reach
|
||||||
// deadline. At most one write may then wait for the connection, and every
|
// their deadline. At most one write may then wait for the connection, and
|
||||||
// miss must reach the database once it is free.
|
// every miss must reach the database once it is free.
|
||||||
func TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) {
|
func TestService_Get_ManyRequestsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||||
|
|
||||||
cache.StartPendingCountWrites()
|
svc.cache.StartPendingCountWrites()
|
||||||
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
|
defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }()
|
||||||
|
|
||||||
releaseDatabase := holdDatabase(t, cache)
|
const (
|
||||||
defer releaseDatabase()
|
requests = 20
|
||||||
|
timeout = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
waitsBefore := cache.db.Stats().WaitCount
|
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||||
|
|
||||||
// Writes given a context past its deadline fail at once.
|
|
||||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
const misses = 20
|
// Each request asks for a variant of its own, so each fetches.
|
||||||
|
results := make([]<-chan getResult, requests)
|
||||||
for range misses {
|
for i := range results {
|
||||||
cache.IncrementStats(ended, false, 0)
|
results[i] = startGet(ctx, svc, photoVariant(fixtures, i+1, FitCover))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once one write waits for the connection, any others start waiting too.
|
// Each request has made its database reads by the time it fetches.
|
||||||
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
for range requests {
|
||||||
time.Sleep(otherWritesWait)
|
<-fetcher.started
|
||||||
|
}
|
||||||
|
|
||||||
waits := cache.db.Stats().WaitCount - waitsBefore
|
releaseDatabase := holdDatabase(t, svc.cache)
|
||||||
|
defer releaseDatabase()
|
||||||
|
|
||||||
|
waitsBefore := svc.cache.db.Stats().WaitCount
|
||||||
|
|
||||||
|
// Once one write waits for the connection, any others start waiting too.
|
||||||
|
waitForConnectionWaits(t, svc.cache.db, waitsBefore+1)
|
||||||
|
time.Sleep(arrivalWait)
|
||||||
|
|
||||||
|
waits := svc.cache.db.Stats().WaitCount - waitsBefore
|
||||||
t.Logf("%d writes waited for the database", waits)
|
t.Logf("%d writes waited for the database", waits)
|
||||||
|
|
||||||
if waits > 1 {
|
if waits > 1 {
|
||||||
t.Errorf("%d writes waited for the database, want at most 1", waits)
|
t.Errorf("%d writes waited for the database, want at most 1", waits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
select {
|
||||||
|
case got := <-result:
|
||||||
|
if !errors.Is(got.err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("request did not return by its deadline while the database was busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
releaseDatabase()
|
releaseDatabase()
|
||||||
|
|
||||||
waitForCounters(t, cache, cacheStatsCounters{missCount: misses})
|
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: requests})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStats_IncludesPendingCounts counts hits and misses whose writes miss
|
// TestStats_IncludesPendingCounts counts hits and misses whose writes miss
|
||||||
@@ -204,9 +209,8 @@ func TestStats_IncludesPendingCounts(t *testing.T) {
|
|||||||
|
|
||||||
// TestStopPendingCountWrites_WritesPendingCounts holds the database's one
|
// TestStopPendingCountWrites_WritesPendingCounts holds the database's one
|
||||||
// connection while the goroutine that writes the pending counts waits for it,
|
// connection while the goroutine that writes the pending counts waits for it,
|
||||||
// counts more, then stops that goroutine. The stop must leave the write under
|
// then stops that goroutine. The stop must write the counts once the
|
||||||
// way to finish rather than cut it off, and once the connection is free it
|
// connection is free.
|
||||||
// must write the counts that write left.
|
|
||||||
func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
|
func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -225,20 +229,12 @@ func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cache.IncrementStats(ended, true, 0)
|
cache.IncrementStats(ended, true, 0)
|
||||||
|
|
||||||
// The goroutine's write waits for the connection.
|
|
||||||
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
|
||||||
|
|
||||||
// Counted after that write read the pending counts
|
|
||||||
cache.IncrementStats(ended, false, 0)
|
cache.IncrementStats(ended, false, 0)
|
||||||
cache.IncrementUpstreamFetch(ended, 1024)
|
cache.IncrementUpstreamFetch(ended, 1024)
|
||||||
cache.IncrementTransformCount(ended)
|
cache.IncrementTransformCount(ended)
|
||||||
|
|
||||||
// The goroutine is told to finish here as well as by the stop, so that it
|
// The goroutine's write waits for the connection.
|
||||||
// is told before its write under way ends, however late the stop runs.
|
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
||||||
// The counts above then reach the database only through the stop's own
|
|
||||||
// write.
|
|
||||||
cache.pendingCountsCancel()
|
|
||||||
|
|
||||||
stopped := make(chan error, 1)
|
stopped := make(chan error, 1)
|
||||||
|
|
||||||
@@ -246,17 +242,8 @@ func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
|
|||||||
stopped <- cache.StopPendingCountWrites(t.Context())
|
stopped <- cache.StopPendingCountWrites(t.Context())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Had the stop cut off the write under way, its own write would wait for
|
// The stop's own write waits for it once the goroutine has returned.
|
||||||
// the connection too.
|
waitForConnectionWaits(t, cache.db, waitsBefore+2)
|
||||||
time.Sleep(otherWritesWait)
|
|
||||||
|
|
||||||
waits := cache.db.Stats().WaitCount - waitsBefore
|
|
||||||
t.Logf("%d writes waited for the database", waits)
|
|
||||||
|
|
||||||
if waits > 1 {
|
|
||||||
t.Errorf("%d writes waited for the database, want 1: "+
|
|
||||||
"the stop cut off the write under way", waits)
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseDatabase()
|
releaseDatabase()
|
||||||
|
|
||||||
@@ -274,54 +261,3 @@ func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
|
|||||||
t.Errorf("counters = %+v, want %+v", got, want)
|
t.Errorf("counters = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStopPendingCountWrites_ReturnsWhenItsContextEnds holds the database's
|
|
||||||
// one connection while the goroutine that writes the pending counts waits for
|
|
||||||
// it, then stops that goroutine with a context that ends first. The stop must
|
|
||||||
// return the context's error, and the write under way must still reach the
|
|
||||||
// database once the connection is free.
|
|
||||||
func TestStopPendingCountWrites_ReturnsWhenItsContextEnds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
cache.StartPendingCountWrites()
|
|
||||||
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
|
|
||||||
|
|
||||||
releaseDatabase := holdDatabase(t, cache)
|
|
||||||
defer releaseDatabase()
|
|
||||||
|
|
||||||
waitsBefore := cache.db.Stats().WaitCount
|
|
||||||
|
|
||||||
// Writes given a context past its deadline fail at once.
|
|
||||||
ended, cancel := context.WithDeadline(t.Context(), time.Now())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cache.IncrementStats(ended, true, 0)
|
|
||||||
|
|
||||||
// The goroutine's write waits for the connection.
|
|
||||||
waitForConnectionWaits(t, cache.db, waitsBefore+1)
|
|
||||||
|
|
||||||
stopCtx, cancelStop := context.WithCancel(t.Context())
|
|
||||||
cancelStop()
|
|
||||||
|
|
||||||
stopped := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
stopped <- cache.StopPendingCountWrites(stopCtx)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-stopped:
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Errorf("StopPendingCountWrites() error = %v, want %v",
|
|
||||||
err, context.Canceled)
|
|
||||||
}
|
|
||||||
case <-time.After(pendingCountsWait):
|
|
||||||
t.Fatal("StopPendingCountWrites() did not return once its context ended")
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseDatabase()
|
|
||||||
|
|
||||||
waitForCounters(t, cache, cacheStatsCounters{hitCount: 1})
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,7 @@ import (
|
|||||||
// however many requests pass their deadline before their counts are
|
// however many requests pass their deadline before their counts are
|
||||||
// written, at most this one write waits for the database. It is a no-op
|
// written, at most this one write waits for the database. It is a no-op
|
||||||
// when already started. The goroutine outlives the caller, so it runs with
|
// when already started. The goroutine outlives the caller, so it runs with
|
||||||
// its own context, which StopPendingCountWrites cancels to tell it to
|
// its own context, which StopPendingCountWrites cancels.
|
||||||
// finish.
|
|
||||||
func (c *Cache) StartPendingCountWrites() {
|
func (c *Cache) StartPendingCountWrites() {
|
||||||
if c.pendingCountsCancel != nil {
|
if c.pendingCountsCancel != nil {
|
||||||
return
|
return
|
||||||
@@ -26,35 +25,24 @@ func (c *Cache) StartPendingCountWrites() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopPendingCountWrites tells the goroutine StartPendingCountWrites
|
// StopPendingCountWrites stops the goroutine StartPendingCountWrites
|
||||||
// started, if it did, to finish, and waits for its write under way to end
|
// started, if it did, then writes the pending counts once more, so that
|
||||||
// and for it to return. It then writes the pending counts left, so that
|
// they are written before the database closes. It waits for both at most
|
||||||
// they are written before the database closes. It waits for all of this at
|
// until ctx ends. It logs the counts still unwritten then, which are lost,
|
||||||
// most until ctx ends, then logs the counts not written and returns an
|
// and returns an error.
|
||||||
// error.
|
|
||||||
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
||||||
var err error
|
|
||||||
|
|
||||||
if c.pendingCountsCancel != nil {
|
if c.pendingCountsCancel != nil {
|
||||||
c.pendingCountsCancel()
|
c.pendingCountsCancel()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-c.pendingCountsDone:
|
case <-c.pendingCountsDone:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// The write given up on here may not reach the database:
|
|
||||||
// database/sql closes without waiting for a query, and a
|
|
||||||
// write still waiting for the connection fails when the
|
|
||||||
// database closes.
|
|
||||||
err = fmt.Errorf("pending counts still being written: %w", ctx.Err())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
err := c.writePendingCounts(ctx)
|
||||||
err = c.writePendingCounts(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Error("counts not written at shutdown",
|
c.log.Error("counts lost at shutdown",
|
||||||
"hits", c.pendingHits.Load(),
|
"hits", c.pendingHits.Load(),
|
||||||
"misses", c.pendingMisses.Load(),
|
"misses", c.pendingMisses.Load(),
|
||||||
"upstream_fetches", c.pendingUpstreamFetches.Load(),
|
"upstream_fetches", c.pendingUpstreamFetches.Load(),
|
||||||
@@ -70,9 +58,8 @@ func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pendingCountWriteLoop is the body of the goroutine
|
// pendingCountWriteLoop is the body of the goroutine
|
||||||
// StartPendingCountWrites starts. Once ctx is cancelled it starts no write
|
// StartPendingCountWrites starts. It returns when ctx is cancelled. A
|
||||||
// and returns, but does not cut off a write under way then. A write that
|
// write that fails leaves the counts pending, for the next write.
|
||||||
// fails leaves the counts pending, for the next write.
|
|
||||||
func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
|
func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
|
||||||
defer close(c.pendingCountsDone)
|
defer close(c.pendingCountsDone)
|
||||||
|
|
||||||
@@ -83,14 +70,8 @@ func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
|
|||||||
case <-c.pendingCountsAdded:
|
case <-c.pendingCountsAdded:
|
||||||
}
|
}
|
||||||
|
|
||||||
// select picks at random when ctx is cancelled and a wakeup
|
err := c.writePendingCounts(ctx)
|
||||||
// waits too.
|
if err != nil && ctx.Err() == nil {
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := c.writePendingCounts(context.WithoutCancel(ctx))
|
|
||||||
if err != nil {
|
|
||||||
c.log.Warn("failed to write pending counts", "error", err)
|
c.log.Warn("failed to write pending counts", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,9 +93,6 @@ func (c *Cache) addPendingCount(pendingCount *atomic.Int64, n int64) {
|
|||||||
// UPDATE, then takes what it wrote out of them, leaving any added
|
// UPDATE, then takes what it wrote out of them, leaving any added
|
||||||
// meanwhile. When the UPDATE fails, they all stay pending.
|
// meanwhile. When the UPDATE fails, they all stay pending.
|
||||||
func (c *Cache) writePendingCounts(ctx context.Context) error {
|
func (c *Cache) writePendingCounts(ctx context.Context) error {
|
||||||
c.pendingCountsWriteMutex.Lock()
|
|
||||||
defer c.pendingCountsWriteMutex.Unlock()
|
|
||||||
|
|
||||||
hits := c.pendingHits.Load()
|
hits := c.pendingHits.Load()
|
||||||
misses := c.pendingMisses.Load()
|
misses := c.pendingMisses.Load()
|
||||||
upstreamFetches := c.pendingUpstreamFetches.Load()
|
upstreamFetches := c.pendingUpstreamFetches.Load()
|
||||||
|
|||||||
@@ -98,10 +98,9 @@ func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
|
|||||||
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
|
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
|
||||||
// IPv4 address it carries, since every such address falls in the same /64.
|
// IPv4 address it carries, since every such address falls in the same /64.
|
||||||
// Counts are kept only for the current and the previous window, so memory
|
// Counts are kept only for the current and the previous window, so memory
|
||||||
// stays bounded. limitHandler writes the 429 after Retry-After is set; nil
|
// stays bounded.
|
||||||
// keeps the rate limit library's plain-text "Too Many Requests".
|
|
||||||
func (s *Middleware) RateLimit(
|
func (s *Middleware) RateLimit(
|
||||||
requestLimit int, window time.Duration, limitHandler http.HandlerFunc,
|
requestLimit int, window time.Duration,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
return httprate.LimitBy(requestLimit, window,
|
return httprate.LimitBy(requestLimit, window,
|
||||||
func(r *http.Request) (string, error) {
|
func(r *http.Request) (string, error) {
|
||||||
@@ -113,8 +112,7 @@ func (s *Middleware) RateLimit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return httprate.CanonicalizeIP(ip), nil
|
return httprate.CanonicalizeIP(ip), nil
|
||||||
},
|
})
|
||||||
httprate.WithLimitHandler(limitHandler))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestIDPattern is what a request's own X-Request-Id must look like to be
|
// requestIDPattern is what a request's own X-Request-Id must look like to be
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// requestImageFrom sends method path to s from remoteAddr and returns the
|
|
||||||
// response. The image handlers refuse an unsigned image URL with 401 and a
|
|
||||||
// token they cannot decrypt with 400, so either status shows the limit let
|
|
||||||
// the request through.
|
|
||||||
func requestImageFrom(
|
|
||||||
t *testing.T, s *Server, method, path, remoteAddr string,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), method, path, nil)
|
|
||||||
req.RemoteAddr = remoteAddr
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageRequestLimitRefusesRequestOverLimit verifies that GET and HEAD on
|
|
||||||
// both image routes count toward one limit per client, and that the request
|
|
||||||
// after image_requests_per_minute of them is refused with 429 and a
|
|
||||||
// Retry-After header.
|
|
||||||
func TestImageRequestLimitRefusesRequestOverLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
requests := []struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
|
||||||
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
|
||||||
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
|
|
||||||
}
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.ImageRequestsPerMinute = len(requests)
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
for _, tc := range requests {
|
|
||||||
rec := requestImageFrom(t, s, tc.method, tc.path, firstClient)
|
|
||||||
if rec.Code != tc.want {
|
|
||||||
t.Fatalf("%s %s within the limit status = %d, want %d",
|
|
||||||
tc.method, tc.path, rec.Code, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := requestImageFrom(t, s, http.MethodHead, encryptedImagePath, firstClient)
|
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("request over the limit status = %d, want %d",
|
|
||||||
rec.Code, http.StatusTooManyRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
retryAfter := rec.Header().Get("Retry-After")
|
|
||||||
|
|
||||||
seconds, err := strconv.Atoi(retryAfter)
|
|
||||||
if err != nil || seconds <= 0 {
|
|
||||||
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
|
||||||
retryAfter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageRequestLimitAnswersWithJSONError verifies that the 429 for a
|
|
||||||
// request over the limit carries Retry-After and the JSON error body the
|
|
||||||
// image routes send for their other errors.
|
|
||||||
func TestImageRequestLimitAnswersWithJSONError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.ImageRequestsPerMinute = 1
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
||||||
|
|
||||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
||||||
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
|
|
||||||
|
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("request over the limit status = %d, want %d",
|
|
||||||
rec.Code, http.StatusTooManyRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
retryAfter := rec.Header().Get("Retry-After")
|
|
||||||
|
|
||||||
seconds, err := strconv.Atoi(retryAfter)
|
|
||||||
if err != nil || seconds <= 0 {
|
|
||||||
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
|
||||||
retryAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := rec.Header().Get("Content-Type")
|
|
||||||
if contentType != "application/json" {
|
|
||||||
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
Status int `json:"status"`
|
|
||||||
Timestamp string `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.NewDecoder(rec.Body).Decode(&body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("body is not JSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if body.Error == "" || body.Status != http.StatusTooManyRequests ||
|
|
||||||
body.Timestamp == "" {
|
|
||||||
t.Errorf("body = %+v, want an error, status %d and a timestamp",
|
|
||||||
body, http.StatusTooManyRequests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageRequestLimitCountsClientsSeparately verifies that one client going
|
|
||||||
// over the limit does not limit another.
|
|
||||||
func TestImageRequestLimitCountsClientsSeparately(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.ImageRequestsPerMinute = 1
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("first client's first request status = %d, want %d",
|
|
||||||
rec.Code, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("first client's second request status = %d, want %d",
|
|
||||||
rec.Code, http.StatusTooManyRequests)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, secondClient)
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("second client's first request status = %d, want %d",
|
|
||||||
rec.Code, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageRequestLimitZeroLetsEveryRequestThrough verifies that with
|
|
||||||
// image_requests_per_minute set to 0 no image request is refused with 429,
|
|
||||||
// not even past the default limit.
|
|
||||||
func TestImageRequestLimitZeroLetsEveryRequestThrough(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.ImageRequestsPerMinute = 0
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
for request := range config.DefaultImageRequestsPerMinute + 1 {
|
|
||||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("request %d status = %d, want %d",
|
|
||||||
request+1, rec.Code, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestImageRequestLimitDoesNotCountPreflight verifies that a browser's CORS
|
|
||||||
// preflight request to an image route is answered without being counted, so
|
|
||||||
// a client that has sent several still gets its image request through.
|
|
||||||
func TestImageRequestLimitDoesNotCountPreflight(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.AccessControlAllowOrigin = config.DefaultAccessControlAllowOrigin
|
|
||||||
s.config.ImageRequestsPerMinute = 1
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
for range 3 {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodOptions, unsignedImagePath, nil)
|
|
||||||
req.RemoteAddr = firstClient
|
|
||||||
req.Header.Set("Origin", "https://app.example.com")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("preflight status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
|
||||||
t.Errorf("image request after preflights status = %d, want %d",
|
|
||||||
rec.Code, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
||||||
r.Use(s.h.CSRF())
|
r.Use(s.h.CSRF())
|
||||||
r.Get("/", s.h.HandleRoot())
|
r.Get("/", s.h.HandleRoot())
|
||||||
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)).
|
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute)).
|
||||||
Post("/", s.h.HandleRoot())
|
Post("/", s.h.HandleRoot())
|
||||||
r.Post("/generate", s.h.HandleGenerateURL())
|
r.Post("/generate", s.h.HandleGenerateURL())
|
||||||
})
|
})
|
||||||
@@ -88,15 +88,6 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.refuseDuringMaintenance)
|
r.Use(s.refuseDuringMaintenance)
|
||||||
|
|
||||||
// Limited per client, as the login POST is, but the 429 has
|
|
||||||
// the image routes' JSON error body; 0 turns the limit off.
|
|
||||||
// The CORS middleware answers a preflight before it gets
|
|
||||||
// here, so a preflight is not counted.
|
|
||||||
if s.config.ImageRequestsPerMinute > 0 {
|
|
||||||
r.Use(s.mw.RateLimit(s.config.ImageRequestsPerMinute,
|
|
||||||
time.Minute, s.h.HandleTooManyRequests()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main image proxy route
|
// Main image proxy route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no
|
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no
|
||||||
// format /v1/image/<host>/<path>/<width>x<height>
|
// format /v1/image/<host>/<path>/<width>x<height>
|
||||||
|
|||||||
+1
-3
@@ -94,8 +94,7 @@ main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# start_containers starts a new origin and a new pixad, and waits up to 30
|
# start_containers starts a new origin and a new pixad, and waits up to 30
|
||||||
# seconds for pixad's health check to pass. pixad sees all of vegeta's
|
# seconds for pixad's health check to pass.
|
||||||
# requests come from one address, so its image request limit is off.
|
|
||||||
start_containers() {
|
start_containers() {
|
||||||
docker run -d --name "$run-origin" \
|
docker run -d --name "$run-origin" \
|
||||||
--network "$run" --network-alias origin \
|
--network "$run" --network-alias origin \
|
||||||
@@ -105,7 +104,6 @@ start_containers() {
|
|||||||
--network "$run" -p 127.0.0.1::8080 --health-interval=1s \
|
--network "$run" -p 127.0.0.1::8080 --health-interval=1s \
|
||||||
-e PIXA_SIGNING_KEY="$(head -c 32 /dev/urandom | base64)" \
|
-e PIXA_SIGNING_KEY="$(head -c 32 /dev/urandom | base64)" \
|
||||||
-e PIXA_ALLOWLIST_HOSTS=origin -e PIXA_ALLOW_HTTP=true \
|
-e PIXA_ALLOWLIST_HOSTS=origin -e PIXA_ALLOW_HTTP=true \
|
||||||
-e PIXA_IMAGE_REQUESTS_PER_MINUTE=0 \
|
|
||||||
"$image" >/dev/null
|
"$image" >/dev/null
|
||||||
|
|
||||||
waited=0
|
waited=0
|
||||||
|
|||||||
Reference in New Issue
Block a user