Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca512fdb4f | ||
|
|
52f69d3082 |
@@ -191,10 +191,11 @@ path under `/v1/` answers 200, in maintenance mode too.
|
|||||||
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`); 429 over
|
||||||
`image_requests_per_minute` (below); 415 when the upstream answers with a
|
`image_requests_per_minute` (below); 415 when the upstream answers with a
|
||||||
`Content-Type` that is not one of the source formats (below), or for `orig` of
|
`Content-Type` that is not one of the source formats (below), or with an image
|
||||||
a source pixa cannot save in its own format; 502 when the upstream answered
|
whose first bytes libvips reads as another format; 502 when the upstream
|
||||||
with an error status, and for 5 minutes after that for the same source URL;
|
answered with an error status, and for 5 minutes after that for the same
|
||||||
503 when pixa is busy or in maintenance mode; 500 for any other failure.
|
source URL; 503 when pixa is busy or in maintenance mode; 500 for any other
|
||||||
|
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
|
||||||
@@ -265,7 +266,8 @@ once, is refused with 400.
|
|||||||
The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's
|
The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's
|
||||||
`Content-Type` must name its format, and the image's first bytes must match it.
|
`Content-Type` must name its format, and the image's first bytes must match it.
|
||||||
An upstream answering with any other `Content-Type`, SVG included, is refused
|
An upstream answering with any other `Content-Type`, SVG included, is refused
|
||||||
with 415.
|
with 415, and so is an image whose first bytes libvips reads as another format,
|
||||||
|
such as an SVG whose first bytes pass the check for AVIF.
|
||||||
|
|
||||||
With the format `auto`, pixa chooses the format for each request from its
|
With the format `auto`, pixa chooses the format for each request from its
|
||||||
`Accept` header, in this order:
|
`Accept` header, in this order:
|
||||||
|
|||||||
@@ -30,16 +30,27 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-13 the unused cache lifetime is removed (closes #69):
|
||||||
|
`CacheConfig.CacheTTL`, which pixad set to `DefaultCacheTTL` (24 hours), was
|
||||||
|
never read, so cached images never expired. The field and the constant are
|
||||||
|
removed. Cached source and transformed images stay until eviction removes them
|
||||||
|
to keep the cache within `cache_max_bytes`.
|
||||||
- 2026-10-13 SVG sources are refused, and `orig` is never saved as JPEG in place
|
- 2026-10-13 SVG sources are refused, and `orig` is never saved as JPEG in place
|
||||||
of the source's own format (closes #68): the upstream fetch no longer accepts
|
of the source's own format (closes #68): the upstream fetch no longer accepts
|
||||||
`image/svg+xml`, which libvips would render. Both image routes answer 415 for
|
`image/svg+xml`, which libvips would render. Both image routes answer 415 for
|
||||||
an upstream `Content-Type` that is not one of the source formats, SVG
|
an upstream `Content-Type` that is not one of the source formats, SVG
|
||||||
included, where they answered 500. `orig` of a source in a format the image
|
included, where they answered 500. Before decoding, the image processor
|
||||||
processor cannot name is `ErrUnsupportedInputFormat`, also answered 415, where
|
refuses with `ErrUnsupportedInputFormat`, also answered 415, any input that
|
||||||
it was saved as JPEG. `README.md` no longer lists SVG. The SVG detection in
|
govips' `DetermineImageType`, which picks the libvips loader from the first
|
||||||
`internal/magic` stays: the request path does not call it, and it goes with
|
bytes, does not name as a source format. That catches an SVG whose first bytes
|
||||||
the unused code of https://git.eeqj.de/sneak/pixa/issues/168. An SVG sent
|
pass the first-bytes check for another type, as an XML comment can make them
|
||||||
under another image type still fails the first-bytes check and answers 500.
|
do for AVIF; an SVG whose first bytes do not pass that check answers 500, as
|
||||||
|
before, and so does a damaged image in a source format, whose first bytes
|
||||||
|
`DetermineImageType` names. `orig` is always the source's own format, where a
|
||||||
|
source the processor could not name was saved as JPEG. `README.md` no longer
|
||||||
|
lists SVG. The SVG detection in `internal/magic` stays: the request path does
|
||||||
|
not call it, and it goes with the unused code of
|
||||||
|
https://git.eeqj.de/sneak/pixa/issues/168.
|
||||||
- 2026-10-12 per-client limit on the image routes (closes #236): the new setting
|
- 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,
|
`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
|
is how many `GET` and `HEAD` requests one client may send to `/v1/image/` and
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ func (s *Handlers) WaitForProcessing(ctx context.Context) int {
|
|||||||
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
func newCacheConfig(cfg *config.Config, log *slog.Logger) imgcache.CacheConfig {
|
||||||
return imgcache.CacheConfig{
|
return imgcache.CacheConfig{
|
||||||
StateDir: cfg.StateDir,
|
StateDir: cfg.StateDir,
|
||||||
CacheTTL: imgcache.DefaultCacheTTL,
|
|
||||||
NegativeTTL: imgcache.DefaultNegativeTTL,
|
NegativeTTL: imgcache.DefaultNegativeTTL,
|
||||||
MaxBytes: cfg.CacheMaxBytes,
|
MaxBytes: cfg.CacheMaxBytes,
|
||||||
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
UseDefaultMaxBytes: !cfg.CacheMaxBytesExplicit,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ func setupTestHandler(t *testing.T) *testFixtures {
|
|||||||
|
|
||||||
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(db, imgcache.CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func newSignedHostServer(
|
|||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ func newImageRoute(t *testing.T, fetcher httpfetcher.Fetcher) http.Handler {
|
|||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ func newRefererRoutes(t *testing.T) (http.Handler, *Handlers, *countingFetcher)
|
|||||||
|
|
||||||
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
cache, err := imgcache.NewCache(setupTestDB(t), imgcache.CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -137,9 +137,10 @@ var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
|||||||
// not supported.
|
// not supported.
|
||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
// ErrUnsupportedInputFormat is returned when the requested format is orig and
|
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
||||||
// the input is in a format the processor cannot name, so it cannot be saved
|
// libvips reads the input's first bytes as another format than the source
|
||||||
// in that format.
|
// formats, or as none: for example an SVG whose first bytes pass the
|
||||||
|
// first-bytes check for AVIF.
|
||||||
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
||||||
|
|
||||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||||
@@ -224,6 +225,17 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, ErrInputDataTooLarge
|
return nil, ErrInputDataTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// govips picks the libvips loader from the input's first bytes with
|
||||||
|
// DetermineImageType. Input whose first bytes it reads as no source
|
||||||
|
// format, such as SVG, which librsvg would render, is refused before
|
||||||
|
// libvips reads it.
|
||||||
|
inputFormat := p.detectFormat(vips.DetermineImageType(data))
|
||||||
|
|
||||||
|
sourceFormat, err := p.formatFromString(inputFormat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Decode image
|
// Decode image
|
||||||
img, err := vips.NewImageFromBuffer(data)
|
img, err := vips.NewImageFromBuffer(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -242,9 +254,6 @@ func (p *ImageProcessor) Process(
|
|||||||
origWidth := img.Width()
|
origWidth := img.Width()
|
||||||
origHeight := img.Height()
|
origHeight := img.Height()
|
||||||
|
|
||||||
// Detect input format
|
|
||||||
inputFormat := p.detectFormat(img)
|
|
||||||
|
|
||||||
// Validate input dimensions to prevent DoS via decompression bombs
|
// Validate input dimensions to prevent DoS via decompression bombs
|
||||||
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||||
return nil, ErrInputTooLarge
|
return nil, ErrInputTooLarge
|
||||||
@@ -261,14 +270,10 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// orig is the source's own format, and an error when the processor
|
// orig is the source's own format; encode refuses an empty format
|
||||||
// cannot name it; encode refuses an empty format
|
|
||||||
outputFormat := req.Format
|
outputFormat := req.Format
|
||||||
if outputFormat == FormatOriginal {
|
if outputFormat == FormatOriginal {
|
||||||
outputFormat, err = p.formatFromString(inputFormat)
|
outputFormat = sourceFormat
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode to target format
|
// Encode to target format
|
||||||
@@ -412,10 +417,9 @@ func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectFormat returns the format string from a vips image.
|
// detectFormat returns the format string for a govips image type.
|
||||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
||||||
format := img.Format()
|
switch imageType {
|
||||||
switch format {
|
|
||||||
case vips.ImageTypeJPEG:
|
case vips.ImageTypeJPEG:
|
||||||
return "jpeg"
|
return "jpeg"
|
||||||
case vips.ImageTypePNG:
|
case vips.ImageTypePNG:
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of an
|
// TestImageProcessor_OrigOfUnnamedFormatRefused verifies that orig of a TIFF
|
||||||
// input in a format the processor decodes but cannot name, here TIFF, is
|
// input, which is not a source format, is refused with
|
||||||
// an error, and not the image saved as JPEG.
|
// ErrUnsupportedInputFormat before it is decoded, and not saved as JPEG.
|
||||||
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
func TestImageProcessor_OrigOfUnnamedFormatRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const metaCacheSize = 10000
|
|||||||
// CacheConfig holds cache configuration.
|
// CacheConfig holds cache configuration.
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
StateDir string
|
StateDir string
|
||||||
CacheTTL time.Duration
|
|
||||||
NegativeTTL time.Duration
|
NegativeTTL time.Duration
|
||||||
|
|
||||||
// MaxBytes is the disk cache size limit in bytes that eviction
|
// MaxBytes is the disk cache size limit in bytes that eviction
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ func setupTestCache(t *testing.T) *Cache {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,7 +240,6 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
|||||||
// Very short negative TTL for testing
|
// Very short negative TTL for testing
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 1 * time.Millisecond,
|
NegativeTTL: 1 * time.Millisecond,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
|
|||||||
// backwards compatibility with existing fixtures.
|
// backwards compatibility with existing fixtures.
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
MaxBytes: maxBytes,
|
MaxBytes: maxBytes,
|
||||||
DisableDiskCache: maxBytes == 0,
|
DisableDiskCache: maxBytes == 0,
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ import "time"
|
|||||||
|
|
||||||
// CacheConfig defaults.
|
// CacheConfig defaults.
|
||||||
const (
|
const (
|
||||||
DefaultCacheTTL = 24 * time.Hour
|
|
||||||
DefaultNegativeTTL = 5 * time.Minute
|
DefaultNegativeTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +62,6 @@ func TestNegativeCache_Expired(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -430,6 +432,172 @@ func TestService_Get_Caching(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
|
||||||
|
// a variant of it and sets back two days every time pixa stored for them.
|
||||||
|
// For the source: fetched_at in source_metadata, fetched_at and
|
||||||
|
// last_accessed_at in source_content, the fetched_at in its metadata file,
|
||||||
|
// and the access and modification times of its file and of its metadata
|
||||||
|
// file. For the variant: created_at and last_accessed_at in variant_content,
|
||||||
|
// the created_at in its .meta file, and the access and modification times of
|
||||||
|
// its file and of its .meta file. It then checks that the variant is still
|
||||||
|
// served from the cache and that a new variant is made from the cached source
|
||||||
|
// without fetching it again.
|
||||||
|
//
|
||||||
|
// Left at the present: the files' status change and birth times, which a
|
||||||
|
// program cannot set, and the times of the directories holding the files,
|
||||||
|
// which other entries share. The source's stored response headers are empty,
|
||||||
|
// as the mock fetcher sends none. expires_at, etag and last_modified in
|
||||||
|
// source_metadata and in the metadata file are never written, nor are the
|
||||||
|
// request_cache and output_content tables.
|
||||||
|
func TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
variant := photoVariant(fixtures, 85, FitCover)
|
||||||
|
|
||||||
|
resp, err := svc.Get(t.Context(), variant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() first request error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
twoDaysAgo := time.Now().AddDate(0, 0, -2)
|
||||||
|
backdateSource(t, svc.cache, variant, twoDaysAgo)
|
||||||
|
backdateVariant(t, svc.cache, variant, twoDaysAgo)
|
||||||
|
|
||||||
|
resp, err = svc.Get(t.Context(), variant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of the cached variant error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
if resp.CacheStatus != CacheHit {
|
||||||
|
t.Errorf("cached variant CacheStatus = %q, want %q",
|
||||||
|
resp.CacheStatus, CacheHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another quality is a new variant, made from the cached source
|
||||||
|
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, FitCover))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of a new variant error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
|
if fetches != 1 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backdateSource sets every time pixa stored for req's cached source to when,
|
||||||
|
// as listed on TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
||||||
|
func backdateSource(
|
||||||
|
t *testing.T, cache *Cache, req *ImageRequest, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
||||||
|
|
||||||
|
meta, err := cache.srcMetadata.Load(req.SourceHost, pathHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the source's metadata file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.FetchedAt = when.Unix()
|
||||||
|
|
||||||
|
err = cache.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing the source's metadata file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbTime := when.UTC().Format(sqliteTimestampFormat)
|
||||||
|
|
||||||
|
updateOneRow(t, cache, `
|
||||||
|
UPDATE source_metadata SET fetched_at = ?
|
||||||
|
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||||
|
`, dbTime, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||||
|
updateOneRow(t, cache,
|
||||||
|
`UPDATE source_content SET fetched_at = ? WHERE content_hash = ?`,
|
||||||
|
dbTime, meta.ContentHash)
|
||||||
|
setSourceLastAccessed(t, cache, ContentHash(meta.ContentHash), when)
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
cache.srcContent.hashToPath(ContentHash(meta.ContentHash)),
|
||||||
|
cache.srcMetadata.metaPath(req.SourceHost, pathHash),
|
||||||
|
} {
|
||||||
|
err = os.Chtimes(path, when, when)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setting back the times of %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backdateVariant sets every time pixa stored for the cached variant req asks
|
||||||
|
// for to when, as listed on
|
||||||
|
// TestService_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
||||||
|
func backdateVariant(
|
||||||
|
t *testing.T, cache *Cache, req *ImageRequest, when time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key := CacheKey(req)
|
||||||
|
|
||||||
|
content, size, contentType, err := cache.variants.LoadWithMeta(key)
|
||||||
|
if err != nil || contentType == "" {
|
||||||
|
t.Fatalf("reading the variant's .meta file: content type %q, error %v",
|
||||||
|
contentType, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = content.Close()
|
||||||
|
|
||||||
|
meta, err := json.Marshal(VariantMeta{
|
||||||
|
ContentType: contentType,
|
||||||
|
Size: size,
|
||||||
|
CreatedAt: when.Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encoding the variant's .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := cache.variants.keyToPath(key)
|
||||||
|
|
||||||
|
err = os.WriteFile(path+variantMetaSuffix, meta, StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing the variant's .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOneRow(t, cache,
|
||||||
|
`UPDATE variant_content SET created_at = ? WHERE cache_key = ?`,
|
||||||
|
when.UTC().Format(sqliteTimestampFormat), string(key))
|
||||||
|
setVariantLastAccessed(t, cache, key, when)
|
||||||
|
|
||||||
|
for _, file := range []string{path, path + variantMetaSuffix} {
|
||||||
|
err = os.Chtimes(file, when, when)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("setting back the times of %s: %v", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateOneRow runs the UPDATE statement query and fails the test unless it
|
||||||
|
// changed exactly one row.
|
||||||
|
func updateOneRow(t *testing.T, cache *Cache, query string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
result, err := cache.db.ExecContext(t.Context(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", query, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil || rows != 1 {
|
||||||
|
t.Fatalf("%s: rows = %d, error = %v, want 1 row", query, rows, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestService_Get_DifferentSizes(t *testing.T) {
|
func TestService_Get_DifferentSizes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,7 +90,6 @@ func TestStats_ZeroCounts(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,7 +118,6 @@ func TestStats_LogsFailedCountQueries(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||||
})
|
})
|
||||||
@@ -162,7 +159,6 @@ func TestIncrementStats_LogsFailedUpdates(t *testing.T) {
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
Logger: slog.New(slog.NewJSONHandler(&logBuf, nil)),
|
||||||
})
|
})
|
||||||
@@ -232,7 +228,6 @@ func TestStats_DisabledCacheReportsNoItems(t *testing.T) {
|
|||||||
|
|
||||||
disabled, err := NewCache(enabled.db, CacheConfig{
|
disabled, err := NewCache(enabled.db, CacheConfig{
|
||||||
StateDir: t.TempDir(),
|
StateDir: t.TempDir(),
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
DisableDiskCache: true,
|
DisableDiskCache: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ func SetupTestService(
|
|||||||
|
|
||||||
cache, err := NewCache(db, CacheConfig{
|
cache, err := NewCache(db, CacheConfig{
|
||||||
StateDir: tmpDir,
|
StateDir: tmpDir,
|
||||||
CacheTTL: time.Hour,
|
|
||||||
NegativeTTL: 5 * time.Minute,
|
NegativeTTL: 5 * time.Minute,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -38,14 +38,7 @@ func TestImageProxyRefusesSVGServedAsAVIF(t *testing.T) {
|
|||||||
t.Fatalf("encurl.NewGenerator: %v", err)
|
t.Fatalf("encurl.NewGenerator: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
targets := []string{
|
encryptedURL := func(format imgcache.ImageFormat) string {
|
||||||
"/v1/image/" + upstreamHost + "/drawing.avif/32x24.png",
|
|
||||||
"/v1/image/" + upstreamHost + "/drawing.avif/64x48.orig",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, format := range []imgcache.ImageFormat{
|
|
||||||
imgcache.FormatPNG, imgcache.FormatOriginal,
|
|
||||||
} {
|
|
||||||
token, err := generator.Generate(&encurl.Payload{
|
token, err := generator.Generate(&encurl.Payload{
|
||||||
SourceHost: upstreamHost, SourcePath: "/drawing.avif",
|
SourceHost: upstreamHost, SourcePath: "/drawing.avif",
|
||||||
Width: 32, Height: 24, Format: format,
|
Width: 32, Height: 24, Format: format,
|
||||||
@@ -54,10 +47,15 @@ func TestImageProxyRefusesSVGServedAsAVIF(t *testing.T) {
|
|||||||
t.Fatalf("encurl Generate: %v", err)
|
t.Fatalf("encurl Generate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
targets = append(targets, "/v1/e/"+token+"/drawing."+string(format))
|
return "/v1/e/" + token + "/drawing." + string(format)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, target := range targets {
|
for _, target := range []string{
|
||||||
|
"/v1/image/" + upstreamHost + "/drawing.avif/32x24.png",
|
||||||
|
"/v1/image/" + upstreamHost + "/drawing.avif/64x48.orig",
|
||||||
|
encryptedURL(imgcache.FormatPNG),
|
||||||
|
encryptedURL(imgcache.FormatOriginal),
|
||||||
|
} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
t.Context(), http.MethodGet, target, nil))
|
t.Context(), http.MethodGet, target, nil))
|
||||||
|
|||||||
Reference in New Issue
Block a user