chore: update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 2m3s
All checks were successful
check / check (push) Successful in 2m3s
Replace .golangci.yml with the canonical v2-schema config (default: all minus six disabled linters, lll 88, tests included) and bump every golangci-lint pin to v2.12.2: - Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned) - script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new linux-amd64/arm64 release-archive sha256 pins Fix all 747 findings the stricter config surfaces, with no behavior changes: t.Parallel() throughout the test suite, static sentinel errors and errors.Is comparisons, checked error returns, context propagation (contextcheck/noctx), 88-column wrapping, extracted constants and helpers for goconst/dupl/funlen/cyclop, exhaustive switch cases replicating existing defaults, and white-box test files renamed to *_internal_test.go for testpackage. Three nolint:tagliatelle directives preserve the existing snake_case JSON wire and on-disk metadata formats.
This commit is contained in:
@@ -43,23 +43,30 @@ type Cache struct {
|
||||
srcMetadata *MetadataStorage // source metadata by host/path
|
||||
config CacheConfig
|
||||
|
||||
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files
|
||||
// In-memory cache of variant metadata (content type, size) to avoid
|
||||
// reading .meta files
|
||||
metaCache map[VariantKey]variantMeta
|
||||
}
|
||||
|
||||
// NewCache creates a new cache instance.
|
||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources"))
|
||||
srcContent, err := NewContentStorage(
|
||||
filepath.Join(config.StateDir, "cache", "sources"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
||||
}
|
||||
|
||||
variants, err := NewVariantStorage(filepath.Join(config.StateDir, "cache", "variants"))
|
||||
variants, err := NewVariantStorage(
|
||||
filepath.Join(config.StateDir, "cache", "variants"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||
}
|
||||
|
||||
srcMetadata, err := NewMetadataStorage(filepath.Join(config.StateDir, "cache", "metadata"))
|
||||
srcMetadata, err := NewMetadataStorage(
|
||||
filepath.Join(config.StateDir, "cache", "metadata"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
||||
}
|
||||
@@ -123,7 +130,11 @@ func (c *Cache) StoreSource(
|
||||
|
||||
// Store in database
|
||||
pathHash := HashPath(req.SourcePath + "?" + req.SourceQuery)
|
||||
headersJSON, _ := json.Marshal(result.Headers)
|
||||
|
||||
headersJSON, err := json.Marshal(result.Headers)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal response headers: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO source_content (content_hash, content_type, size_bytes)
|
||||
@@ -166,16 +177,16 @@ func (c *Cache) StoreSource(
|
||||
RemoteAddr: result.RemoteAddr,
|
||||
}
|
||||
|
||||
if err := c.srcMetadata.Store(req.SourceHost, pathHash, meta); err != nil {
|
||||
// Non-fatal, we have it in the database
|
||||
_ = err
|
||||
}
|
||||
// A failure here is non-fatal; the metadata is in the database.
|
||||
_ = c.srcMetadata.Store(req.SourceHost, pathHash, meta)
|
||||
|
||||
return contentHash, nil
|
||||
}
|
||||
|
||||
// StoreVariant stores a processed variant by its cache key.
|
||||
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error {
|
||||
func (c *Cache) StoreVariant(
|
||||
cacheKey VariantKey, content io.Reader, contentType string,
|
||||
) error {
|
||||
_, err := c.variants.Store(cacheKey, content, contentType)
|
||||
|
||||
return err
|
||||
@@ -183,7 +194,9 @@ func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType
|
||||
|
||||
// LookupSource checks if we have cached source content for a request.
|
||||
// Returns the content hash and content type if found, or empty values if not.
|
||||
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) {
|
||||
func (c *Cache) LookupSource(
|
||||
ctx context.Context, req *ImageRequest,
|
||||
) (ContentHash, string, error) {
|
||||
var hashStr, contentType string
|
||||
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
@@ -210,11 +223,15 @@ func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHas
|
||||
}
|
||||
|
||||
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||
func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode int, errMsg string) error {
|
||||
func (c *Cache) StoreNegative(
|
||||
ctx context.Context, req *ImageRequest, statusCode int, errMsg string,
|
||||
) error {
|
||||
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
||||
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, error_message, expires_at)
|
||||
INSERT INTO negative_cache
|
||||
(source_host, source_path, source_query, status_code,
|
||||
error_message, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(source_host, source_path, source_query) DO UPDATE SET
|
||||
status_code = excluded.status_code,
|
||||
@@ -229,46 +246,16 @@ func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNegativeCache checks if a request is in the negative cache.
|
||||
func (c *Cache) checkNegativeCache(ctx context.Context, req *ImageRequest) (bool, error) {
|
||||
var expiresAt time.Time
|
||||
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT expires_at FROM negative_cache
|
||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(expiresAt) {
|
||||
// Clean up expired entry
|
||||
_, _ = c.db.ExecContext(ctx, `
|
||||
DELETE FROM negative_cache
|
||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetSourceMetadataID returns the source metadata ID for a request.
|
||||
func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int64, error) {
|
||||
func (c *Cache) GetSourceMetadataID(
|
||||
ctx context.Context, req *ImageRequest,
|
||||
) (int64, error) {
|
||||
var id int64
|
||||
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT id FROM source_metadata
|
||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get source metadata ID: %w", err)
|
||||
}
|
||||
@@ -309,8 +296,12 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
}
|
||||
|
||||
// Get actual item count and total size from content tables
|
||||
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_cache`).Scan(&stats.TotalItems)
|
||||
_ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`).Scan(&stats.TotalSizeBytes)
|
||||
_ = c.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM request_cache`,
|
||||
).Scan(&stats.TotalItems)
|
||||
_ = c.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(size_bytes), 0) FROM output_content`,
|
||||
).Scan(&stats.TotalSizeBytes)
|
||||
|
||||
// Compute hit rate as a ratio
|
||||
if stats.HitCount+stats.MissCount > 0 {
|
||||
@@ -324,11 +315,17 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
||||
if hit {
|
||||
_, _ = c.db.ExecContext(ctx, `
|
||||
UPDATE cache_stats SET hit_count = hit_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1
|
||||
UPDATE cache_stats
|
||||
SET hit_count = hit_count + 1,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`)
|
||||
} else {
|
||||
_, _ = c.db.ExecContext(ctx, `
|
||||
UPDATE cache_stats SET miss_count = miss_count + 1, last_updated_at = CURRENT_TIMESTAMP WHERE id = 1
|
||||
UPDATE cache_stats
|
||||
SET miss_count = miss_count + 1,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -342,3 +339,36 @@ func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64)
|
||||
`, fetchBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNegativeCache checks if a request is in the negative cache.
|
||||
func (c *Cache) checkNegativeCache(
|
||||
ctx context.Context, req *ImageRequest,
|
||||
) (bool, error) {
|
||||
var expiresAt time.Time
|
||||
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT expires_at FROM negative_cache
|
||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&expiresAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(expiresAt) {
|
||||
// Clean up expired entry
|
||||
_, _ = c.db.ExecContext(ctx, `
|
||||
DELETE FROM negative_cache
|
||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||
`, req.SourceHost, req.SourcePath, req.SourceQuery)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -86,14 +86,15 @@ func setupTestDB(t *testing.T) *sql.DB {
|
||||
INSERT INTO cache_stats (id) VALUES (1);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
_, err = db.ExecContext(t.Context(), schema)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create schema: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func setupTestCache(t *testing.T) (*Cache, string) {
|
||||
func setupTestCache(t *testing.T) *Cache {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
@@ -108,16 +109,18 @@ func setupTestCache(t *testing.T) (*Cache, string) {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
}
|
||||
|
||||
return cache, tmpDir
|
||||
return cache
|
||||
}
|
||||
|
||||
func TestCache_LookupMiss(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
t.Parallel()
|
||||
|
||||
cache := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
Quality: 85,
|
||||
@@ -139,12 +142,14 @@ func TestCache_LookupMiss(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCache_StoreAndLookup(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
t.Parallel()
|
||||
|
||||
cache := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
Quality: 85,
|
||||
@@ -154,11 +159,12 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
||||
// Store source content
|
||||
sourceContent := []byte("fake jpeg data")
|
||||
fetchResult := &httpfetcher.FetchResult{
|
||||
ContentType: "image/jpeg",
|
||||
Headers: map[string][]string{"Content-Type": {"image/jpeg"}},
|
||||
ContentType: testContentTypeJPEG,
|
||||
Headers: map[string][]string{"Content-Type": {testContentTypeJPEG}},
|
||||
}
|
||||
|
||||
contentHash, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||
contentHash, err := cache.StoreSource(
|
||||
ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||
if err != nil {
|
||||
t.Fatalf("StoreSource() error = %v", err)
|
||||
}
|
||||
@@ -170,6 +176,7 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
||||
// Store variant
|
||||
cacheKey := CacheKey(req)
|
||||
outputContent := []byte("fake webp data")
|
||||
|
||||
err = cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreVariant() error = %v", err)
|
||||
@@ -195,11 +202,13 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCache_NegativeCache(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
t.Parallel()
|
||||
|
||||
cache := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: "/photos/notfound.jpg",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -223,6 +232,8 @@ func TestCache_NegativeCache(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
db := setupTestDB(t)
|
||||
|
||||
@@ -239,7 +250,7 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: "/photos/expired.jpg",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -266,11 +277,13 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCache_VariantLookup(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
t.Parallel()
|
||||
|
||||
cache := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: "/photos/variant.jpg",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -281,6 +294,7 @@ func TestCache_VariantLookup(t *testing.T) {
|
||||
// Store variant
|
||||
cacheKey := CacheKey(req)
|
||||
outputContent := []byte("output data")
|
||||
|
||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreVariant() error = %v", err)
|
||||
@@ -312,11 +326,13 @@ func TestCache_VariantLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
t.Parallel()
|
||||
|
||||
cache := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: "/photos/variantct.jpg",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -327,6 +343,7 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
||||
// Store variant
|
||||
cacheKey := CacheKey(req)
|
||||
outputContent := []byte("output webp data")
|
||||
|
||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreVariant() error = %v", err)
|
||||
@@ -347,7 +364,8 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetVariant() error = %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
if contentType != "image/webp" {
|
||||
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
||||
@@ -359,11 +377,13 @@ func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCache_GetVariant(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
t.Parallel()
|
||||
|
||||
cache := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: "/photos/output.jpg",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -374,6 +394,7 @@ func TestCache_GetVariant(t *testing.T) {
|
||||
// Store variant
|
||||
cacheKey := CacheKey(req)
|
||||
outputContent := []byte("the actual output content")
|
||||
|
||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreVariant() error = %v", err)
|
||||
@@ -390,7 +411,8 @@ func TestCache_GetVariant(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetVariant() error = %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
buf := make([]byte, 100)
|
||||
n, _ := reader.Read(buf)
|
||||
@@ -401,7 +423,9 @@ func TestCache_GetVariant(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCache_Stats(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
t.Parallel()
|
||||
|
||||
cache := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Increment some stats
|
||||
@@ -424,6 +448,8 @@ func TestCache_Stats(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCache_CleanExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
db := setupTestDB(t)
|
||||
|
||||
@@ -436,7 +462,8 @@ func TestCache_CleanExpired(t *testing.T) {
|
||||
|
||||
// Insert expired negative cache entry directly
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, expires_at)
|
||||
INSERT INTO negative_cache
|
||||
(source_host, source_path, source_query, status_code, expires_at)
|
||||
VALUES ('example.com', '/old.jpg', '', 404, datetime('now', '-1 hour'))
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -445,7 +472,12 @@ func TestCache_CleanExpired(t *testing.T) {
|
||||
|
||||
// Verify it exists
|
||||
var count int
|
||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||
|
||||
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count negative cache entries: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 negative cache entry, got %d", count)
|
||||
}
|
||||
@@ -457,13 +489,19 @@ func TestCache_CleanExpired(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify it's gone
|
||||
db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||
err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM negative_cache`).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count negative cache entries: %v", err)
|
||||
}
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 negative cache entries after clean, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
db := setupTestDB(t)
|
||||
|
||||
@@ -483,7 +521,9 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
||||
|
||||
for _, dir := range dirs {
|
||||
path := tmpDir + "/" + dir
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("directory %s was not created", dir)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
)
|
||||
|
||||
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Simulate the calculation from processAndStore
|
||||
fetchBytes := int64(0)
|
||||
outputSize := int64(100)
|
||||
@@ -29,6 +31,8 @@ func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSizePercentNormalCase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fetchBytes := int64(1000)
|
||||
outputSize := int64(500)
|
||||
|
||||
@@ -90,6 +90,7 @@ func (r *ImageRequest) SourceURL() string {
|
||||
if r.AllowHTTP {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
url := scheme + "://" + r.SourceHost + r.SourcePath
|
||||
if r.SourceQuery != "" {
|
||||
url += "?" + r.SourceQuery
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := setupTestDB(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -22,7 +24,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
req := &ImageRequest{
|
||||
SourceHost: "example.com",
|
||||
SourceHost: testHostExample,
|
||||
SourcePath: "/missing.jpg",
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if hit {
|
||||
t.Error("expected no negative cache hit initially")
|
||||
}
|
||||
@@ -46,12 +49,15 @@ func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !hit {
|
||||
t.Error("expected negative cache hit after storing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeCache_Expired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := setupTestDB(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -66,7 +72,7 @@ func TestNegativeCache_Expired(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
req := &ImageRequest{
|
||||
SourceHost: "example.com",
|
||||
SourceHost: testHostExample,
|
||||
SourcePath: "/expired.jpg",
|
||||
}
|
||||
|
||||
@@ -84,12 +90,15 @@ func TestNegativeCache_Expired(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if hit {
|
||||
t.Error("expected expired negative cache entry to be a miss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test verifies that Service.Get() checks the negative cache
|
||||
// We can't easily test the full pipeline without vips, but we can
|
||||
// verify the error type
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
"sneak.berlin/go/pixa/internal/signature"
|
||||
)
|
||||
|
||||
// Service implements the ImageCache interface, orchestrating cache, fetcher, and processor.
|
||||
// Service implements the ImageCache interface, orchestrating cache,
|
||||
// fetcher, and processor.
|
||||
type Service struct {
|
||||
cache *Cache
|
||||
fetcher httpfetcher.Fetcher
|
||||
@@ -46,14 +47,21 @@ type ServiceConfig struct {
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Static errors for service construction and unimplemented operations.
|
||||
var (
|
||||
errCacheRequired = errors.New("cache is required")
|
||||
errSigningKeyRequired = errors.New("signing key is required")
|
||||
errPurgeNotImplemented = errors.New("purge not implemented")
|
||||
)
|
||||
|
||||
// NewService creates a new image service.
|
||||
func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||
if cfg.Cache == nil {
|
||||
return nil, errors.New("cache is required")
|
||||
return nil, errCacheRequired
|
||||
}
|
||||
|
||||
if cfg.SigningKey == "" {
|
||||
return nil, errors.New("signing key is required")
|
||||
return nil, errSigningKeyRequired
|
||||
}
|
||||
|
||||
// Resolve fetcher config for defaults
|
||||
@@ -83,11 +91,14 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
||||
}
|
||||
|
||||
maxResponseSize := fetcherCfg.MaxResponseSize
|
||||
processor := imageprocessor.New(
|
||||
imageprocessor.Params{MaxInputBytes: maxResponseSize},
|
||||
)
|
||||
|
||||
return &Service{
|
||||
cache: cfg.Cache,
|
||||
fetcher: fetcher,
|
||||
processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
|
||||
processor: processor,
|
||||
signer: signer,
|
||||
allowlist: allowlist.New(cfg.Allowlist),
|
||||
log: log,
|
||||
@@ -109,6 +120,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
||||
if err != nil {
|
||||
s.log.Warn("negative cache check failed", "error", err)
|
||||
}
|
||||
|
||||
if negHit {
|
||||
s.log.Debug("negative cache hit",
|
||||
"host", req.SourceHost,
|
||||
@@ -145,6 +157,7 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
||||
|
||||
// Cache miss - check if we have source content cached
|
||||
cacheKey := CacheKey(req)
|
||||
|
||||
s.cache.IncrementStats(ctx, false, 0)
|
||||
|
||||
response, err := s.processFromSourceOrFetch(ctx, req, cacheKey)
|
||||
@@ -157,6 +170,57 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Warm pre-fetches and caches an image without returning it.
|
||||
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
|
||||
_, err := s.Get(ctx, req)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Purge removes a cached image. Purging is not implemented yet.
|
||||
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
|
||||
return errPurgeNotImplemented
|
||||
}
|
||||
|
||||
// Stats returns cache statistics.
|
||||
func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
return s.cache.Stats(ctx)
|
||||
}
|
||||
|
||||
// ValidateRequest validates the request signature if required.
|
||||
func (s *Service) ValidateRequest(req *ImageRequest) error {
|
||||
// Check if host is allowed (no signature required)
|
||||
sourceURL := req.SourceURL()
|
||||
|
||||
parsedURL, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid source URL: %w", err)
|
||||
}
|
||||
|
||||
if s.allowlist.IsAllowed(parsedURL) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Signature required for non-allowed hosts
|
||||
return s.signer.Verify(signatureRequest(req))
|
||||
}
|
||||
|
||||
// GenerateSignedURL generates a signed URL for the given request.
|
||||
func (s *Service) GenerateSignedURL(
|
||||
baseURL string,
|
||||
req *ImageRequest,
|
||||
ttl time.Duration,
|
||||
) (string, error) {
|
||||
sigReq := signatureRequest(req)
|
||||
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
||||
|
||||
// Propagate the generated signature and expiration back onto the request.
|
||||
req.Expires = sigReq.Expires
|
||||
req.Signature = sigReq.Signature
|
||||
|
||||
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
||||
}
|
||||
|
||||
// loadCachedSource attempts to load source content from cache, returning nil
|
||||
// if the cached data is unavailable or exceeds maxResponseSize.
|
||||
func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
|
||||
@@ -191,7 +255,8 @@ func (s *Service) loadCachedSource(contentHash ContentHash) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
// processFromSourceOrFetch processes an image, using cached source content if available.
|
||||
// processFromSourceOrFetch processes an image, using cached source content
|
||||
// if available.
|
||||
func (s *Service) processFromSourceOrFetch(
|
||||
ctx context.Context,
|
||||
req *ImageRequest,
|
||||
@@ -203,8 +268,10 @@ func (s *Service) processFromSourceOrFetch(
|
||||
s.log.Warn("source lookup failed", "error", err)
|
||||
}
|
||||
|
||||
var sourceData []byte
|
||||
var fetchBytes int64
|
||||
var (
|
||||
sourceData []byte
|
||||
fetchBytes int64
|
||||
)
|
||||
|
||||
if contentHash != "" {
|
||||
s.log.Debug("using cached source", "hash", contentHash)
|
||||
@@ -258,6 +325,7 @@ func (s *Service) fetchAndProcess(
|
||||
|
||||
// Calculate download bitrate
|
||||
fetchBytes := int64(len(sourceData))
|
||||
|
||||
var downloadRate string
|
||||
|
||||
if fetchResult.FetchDurationMs > 0 {
|
||||
@@ -280,7 +348,8 @@ func (s *Service) fetchAndProcess(
|
||||
)
|
||||
|
||||
// Validate magic bytes match content type
|
||||
if err := magic.ValidateMagicBytes(sourceData, fetchResult.ContentType); err != nil {
|
||||
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("content validation failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -332,7 +401,8 @@ func (s *Service) processAndStore(
|
||||
|
||||
var sizePercent float64
|
||||
if fetchBytes > 0 {
|
||||
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0 //nolint:mnd // percentage calculation
|
||||
//nolint:mnd // percentage calculation
|
||||
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0
|
||||
}
|
||||
|
||||
s.log.Info("image converted",
|
||||
@@ -342,8 +412,10 @@ func (s *Service) processAndStore(
|
||||
"dst_format", req.Format,
|
||||
"src_bytes", fetchBytes,
|
||||
"dst_bytes", outputSize,
|
||||
"src_dimensions", fmt.Sprintf("%dx%d", processResult.InputWidth, processResult.InputHeight),
|
||||
"dst_dimensions", fmt.Sprintf("%dx%d", processResult.Width, processResult.Height),
|
||||
"src_dimensions", fmt.Sprintf("%dx%d",
|
||||
processResult.InputWidth, processResult.InputHeight),
|
||||
"dst_dimensions", fmt.Sprintf("%dx%d",
|
||||
processResult.Width, processResult.Height),
|
||||
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
|
||||
"convert_ms", processDuration.Milliseconds(),
|
||||
"quality", req.Quality,
|
||||
@@ -351,7 +423,10 @@ func (s *Service) processAndStore(
|
||||
)
|
||||
|
||||
// Store variant to cache
|
||||
if err := s.cache.StoreVariant(cacheKey, bytes.NewReader(processedData), processResult.ContentType); err != nil {
|
||||
err = s.cache.StoreVariant(
|
||||
cacheKey, bytes.NewReader(processedData), processResult.ContentType,
|
||||
)
|
||||
if err != nil {
|
||||
s.log.Warn("failed to store variant", "error", err)
|
||||
// Continue even if caching fails
|
||||
}
|
||||
@@ -365,58 +440,6 @@ func (s *Service) processAndStore(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Warm pre-fetches and caches an image without returning it.
|
||||
func (s *Service) Warm(ctx context.Context, req *ImageRequest) error {
|
||||
_, err := s.Get(ctx, req)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Purge removes a cached image.
|
||||
func (s *Service) Purge(_ context.Context, _ *ImageRequest) error {
|
||||
// TODO: Implement purge
|
||||
return errors.New("purge not implemented")
|
||||
}
|
||||
|
||||
// Stats returns cache statistics.
|
||||
func (s *Service) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
return s.cache.Stats(ctx)
|
||||
}
|
||||
|
||||
// ValidateRequest validates the request signature if required.
|
||||
func (s *Service) ValidateRequest(req *ImageRequest) error {
|
||||
// Check if host is allowed (no signature required)
|
||||
sourceURL := req.SourceURL()
|
||||
|
||||
parsedURL, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid source URL: %w", err)
|
||||
}
|
||||
|
||||
if s.allowlist.IsAllowed(parsedURL) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Signature required for non-allowed hosts
|
||||
return s.signer.Verify(signatureRequest(req))
|
||||
}
|
||||
|
||||
// GenerateSignedURL generates a signed URL for the given request.
|
||||
func (s *Service) GenerateSignedURL(
|
||||
baseURL string,
|
||||
req *ImageRequest,
|
||||
ttl time.Duration,
|
||||
) (string, error) {
|
||||
sigReq := signatureRequest(req)
|
||||
path, sig, exp := s.signer.GenerateSignedURL(sigReq, ttl)
|
||||
|
||||
// Propagate the generated signature and expiration back onto the request.
|
||||
req.Expires = sigReq.Expires
|
||||
req.Signature = sigReq.Signature
|
||||
|
||||
return fmt.Sprintf("%s%s?sig=%s&exp=%d", baseURL, path, sig, exp), nil
|
||||
}
|
||||
|
||||
// signatureRequest projects an ImageRequest onto the standalone
|
||||
// signature.Request type used by the signature package. This keeps the
|
||||
// import edge one-way: imgcache depends on signature, never the reverse.
|
||||
|
||||
@@ -10,13 +10,22 @@ import (
|
||||
"sneak.berlin/go/pixa/internal/signature"
|
||||
)
|
||||
|
||||
// Test data literals used repeatedly in this file (goconst).
|
||||
const (
|
||||
testPathPhoto = "/images/photo.jpg"
|
||||
testPathUpload = "/uploads/image.jpg"
|
||||
testSigningKey = "test-signing-key-12345"
|
||||
)
|
||||
|
||||
func TestService_Get_AllowlistedHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: "/images/photo.jpg",
|
||||
SourcePath: testPathPhoto,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -27,7 +36,8 @@ func TestService_Get_AllowlistedHost(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
defer resp.Content.Close()
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
// Verify we got content
|
||||
data, err := io.ReadAll(resp.Content)
|
||||
@@ -39,17 +49,19 @@ func TestService_Get_AllowlistedHost(t *testing.T) {
|
||||
t.Error("expected non-empty response")
|
||||
}
|
||||
|
||||
if resp.ContentType != "image/jpeg" {
|
||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, "image/jpeg")
|
||||
if resp.ContentType != testContentTypeJPEG {
|
||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t, WithSigningKey("test-key"))
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.OtherHost,
|
||||
SourcePath: "/uploads/image.jpg",
|
||||
SourcePath: testPathUpload,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -64,13 +76,15 @@ func TestService_Get_NonAllowlistedHost_NoSignature(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
||||
signingKey := "test-signing-key-12345"
|
||||
t.Parallel()
|
||||
|
||||
signingKey := testSigningKey
|
||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.OtherHost,
|
||||
SourcePath: "/uploads/image.jpg",
|
||||
SourcePath: testPathUpload,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -93,7 +107,8 @@ func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
defer resp.Content.Close()
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Content)
|
||||
if err != nil {
|
||||
@@ -106,12 +121,14 @@ func TestService_Get_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
|
||||
signingKey := "test-signing-key-12345"
|
||||
t.Parallel()
|
||||
|
||||
signingKey := testSigningKey
|
||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.OtherHost,
|
||||
SourcePath: "/uploads/image.jpg",
|
||||
SourcePath: testPathUpload,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -131,12 +148,14 @@ func TestService_Get_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
|
||||
signingKey := "test-signing-key-12345"
|
||||
t.Parallel()
|
||||
|
||||
signingKey := testSigningKey
|
||||
svc, fixtures := SetupTestService(t, WithSigningKey(signingKey))
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.OtherHost,
|
||||
SourcePath: "/uploads/image.jpg",
|
||||
SourcePath: testPathUpload,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -159,6 +178,8 @@ func TestService_Get_NonAllowlistedHost_InvalidSignature(t *testing.T) {
|
||||
// signature for one host must not verify for a different host, even
|
||||
// if they share a domain suffix.
|
||||
func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
signingKey := "test-signing-key-must-be-32-chars"
|
||||
svc, _ := SetupTestService(t,
|
||||
WithSigningKey(signingKey),
|
||||
@@ -169,8 +190,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||
|
||||
// Sign a request for "cdn.example.com"
|
||||
signedReq := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -181,6 +202,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||
|
||||
// The original request should pass validation
|
||||
t.Run("exact host passes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := svc.ValidateRequest(signedReq)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateRequest() exact host failed: %v", err)
|
||||
@@ -192,7 +215,7 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||
name string
|
||||
host string
|
||||
}{
|
||||
{"parent domain", "example.com"},
|
||||
{"parent domain", testHostExample},
|
||||
{"sibling subdomain", "images.example.com"},
|
||||
{"deeper subdomain", "a.cdn.example.com"},
|
||||
{"evil suffix domain", "cdn.example.com.evil.com"},
|
||||
@@ -201,6 +224,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+" rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: tt.host,
|
||||
SourcePath: signedReq.SourcePath,
|
||||
@@ -215,7 +240,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||
|
||||
err := svc.ValidateRequest(req)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateRequest() should reject signature for host %q (signed for %q)",
|
||||
t.Errorf(
|
||||
"ValidateRequest() should reject signature for host %q (signed for %q)",
|
||||
tt.host, signedReq.SourceHost)
|
||||
}
|
||||
})
|
||||
@@ -223,6 +249,8 @@ func TestService_ValidateRequest_SignatureExactHostMatch(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_InvalidFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -243,6 +271,8 @@ func TestService_Get_InvalidFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -262,6 +292,8 @@ func TestService_Get_NotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_FormatConversion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -273,7 +305,7 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "JPEG to PNG",
|
||||
sourcePath: "/images/photo.jpg",
|
||||
sourcePath: testPathPhoto,
|
||||
outFormat: FormatPNG,
|
||||
wantMIME: "image/png",
|
||||
},
|
||||
@@ -281,7 +313,7 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
||||
name: "PNG to JPEG",
|
||||
sourcePath: "/images/logo.png",
|
||||
outFormat: FormatJPEG,
|
||||
wantMIME: "image/jpeg",
|
||||
wantMIME: testContentTypeJPEG,
|
||||
},
|
||||
{
|
||||
name: "GIF to PNG",
|
||||
@@ -293,6 +325,8 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: tt.sourcePath,
|
||||
@@ -306,7 +340,8 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
defer resp.Content.Close()
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
if resp.ContentType != tt.wantMIME {
|
||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
||||
@@ -341,12 +376,14 @@ func TestService_Get_FormatConversion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_Caching(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: "/images/photo.jpg",
|
||||
SourcePath: testPathPhoto,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -367,7 +404,8 @@ func TestService_Get_Caching(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read first response: %v", err)
|
||||
}
|
||||
resp1.Content.Close()
|
||||
|
||||
_ = resp1.Content.Close()
|
||||
|
||||
// Second request - should be a cache hit
|
||||
resp2, err := svc.Get(ctx, req)
|
||||
@@ -383,7 +421,8 @@ func TestService_Get_Caching(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read second response: %v", err)
|
||||
}
|
||||
resp2.Content.Close()
|
||||
|
||||
_ = resp2.Content.Close()
|
||||
|
||||
// Content should be identical
|
||||
if len(data1) != len(data2) {
|
||||
@@ -392,6 +431,8 @@ func TestService_Get_Caching(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_DifferentSizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -402,12 +443,12 @@ func TestService_Get_DifferentSizes(t *testing.T) {
|
||||
{Width: 75, Height: 75},
|
||||
}
|
||||
|
||||
var responses [][]byte
|
||||
responses := make([][]byte, 0, len(sizes))
|
||||
|
||||
for _, size := range sizes {
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: "/images/photo.jpg",
|
||||
SourcePath: testPathPhoto,
|
||||
Size: size,
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -423,27 +464,31 @@ func TestService_Get_DifferentSizes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
}
|
||||
resp.Content.Close()
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
responses = append(responses, data)
|
||||
}
|
||||
|
||||
// All responses should be different sizes (different cache entries)
|
||||
for i := 0; i < len(responses)-1; i++ {
|
||||
for i := range len(responses) - 1 {
|
||||
if len(responses[i]) == len(responses[i+1]) {
|
||||
// Not necessarily an error, but worth noting
|
||||
t.Logf("responses %d and %d have same size: %d bytes", i, i+1, len(responses[i]))
|
||||
t.Logf("responses %d and %d have same size: %d bytes",
|
||||
i, i+1, len(responses[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Service with no signing key - all non-allowlisted requests should fail
|
||||
svc, fixtures := SetupTestService(t, WithNoAllowlist())
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.OtherHost,
|
||||
SourcePath: "/uploads/image.jpg",
|
||||
SourcePath: testPathUpload,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -452,11 +497,15 @@ func TestService_ValidateRequest_NoSigningKey(t *testing.T) {
|
||||
|
||||
err := svc.ValidateRequest(req)
|
||||
if err == nil {
|
||||
t.Error("ValidateRequest() expected error when no signing key and host not allowlisted")
|
||||
t.Error(
|
||||
"ValidateRequest() expected error when no signing key and host not allowlisted",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Get_ContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -464,7 +513,7 @@ func TestService_Get_ContextCancellation(t *testing.T) {
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: "/images/photo.jpg",
|
||||
SourcePath: testPathPhoto,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -478,12 +527,14 @@ func TestService_Get_ContextCancellation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_ReturnsETag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: "/images/photo.jpg",
|
||||
SourcePath: testPathPhoto,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -494,7 +545,8 @@ func TestService_Get_ReturnsETag(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
defer resp.Content.Close()
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
// ETag should be set
|
||||
if resp.ETag == "" {
|
||||
@@ -508,12 +560,14 @@ func TestService_Get_ReturnsETag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_ETagConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: "/images/photo.jpg",
|
||||
SourcePath: testPathPhoto,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -525,16 +579,20 @@ func TestService_Get_ETagConsistency(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Get() first request error = %v", err)
|
||||
}
|
||||
|
||||
etag1 := resp1.ETag
|
||||
resp1.Content.Close()
|
||||
|
||||
_ = resp1.Content.Close()
|
||||
|
||||
// Second request (from cache)
|
||||
resp2, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() second request error = %v", err)
|
||||
}
|
||||
|
||||
etag2 := resp2.ETag
|
||||
resp2.Content.Close()
|
||||
|
||||
_ = resp2.Content.Close()
|
||||
|
||||
// ETags should be identical for the same content
|
||||
if etag1 != etag2 {
|
||||
@@ -543,13 +601,15 @@ func TestService_Get_ETagConsistency(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Request same image at different sizes - should get different ETags
|
||||
req1 := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: "/images/photo.jpg",
|
||||
SourcePath: testPathPhoto,
|
||||
Size: Size{Width: 25, Height: 25},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -558,7 +618,7 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
||||
|
||||
req2 := &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: "/images/photo.jpg",
|
||||
SourcePath: testPathPhoto,
|
||||
Size: Size{Width: 50, Height: 50},
|
||||
Format: FormatJPEG,
|
||||
Quality: 85,
|
||||
@@ -569,15 +629,19 @@ func TestService_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Get() first request error = %v", err)
|
||||
}
|
||||
|
||||
etag1 := resp1.ETag
|
||||
resp1.Content.Close()
|
||||
|
||||
_ = resp1.Content.Close()
|
||||
|
||||
resp2, err := svc.Get(ctx, req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() second request error = %v", err)
|
||||
}
|
||||
|
||||
etag2 := resp2.ETag
|
||||
resp2.Content.Close()
|
||||
|
||||
_ = resp2.Content.Close()
|
||||
|
||||
// ETags should be different for different content
|
||||
if etag1 == etag2 {
|
||||
@@ -3,13 +3,16 @@ package imgcache
|
||||
import "testing"
|
||||
|
||||
func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
SourceQuery: "v=2",
|
||||
}
|
||||
|
||||
got := req.SourceURL()
|
||||
|
||||
want := "https://cdn.example.com/photos/cat.jpg?v=2"
|
||||
if got != want {
|
||||
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||
@@ -17,13 +20,16 @@ func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "localhost:8080",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourcePath: testPathCat,
|
||||
AllowHTTP: true,
|
||||
}
|
||||
|
||||
got := req.SourceURL()
|
||||
|
||||
want := "http://localhost:8080/photos/cat.jpg"
|
||||
if got != want {
|
||||
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||
@@ -31,8 +37,10 @@ func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: "/img.jpg",
|
||||
AllowHTTP: false,
|
||||
}
|
||||
@@ -12,18 +12,25 @@ import (
|
||||
|
||||
func setupStatsTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
||||
|
||||
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func TestStats_HitRateIsRatio(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := setupStatsTestDB(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -40,7 +47,9 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
||||
|
||||
// Set some hit/miss counts and a transform_count
|
||||
_, err = db.ExecContext(ctx, `
|
||||
UPDATE cache_stats SET hit_count = 75, miss_count = 25, transform_count = 9999 WHERE id = 1
|
||||
UPDATE cache_stats
|
||||
SET hit_count = 75, miss_count = 25, transform_count = 9999
|
||||
WHERE id = 1
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -54,6 +63,7 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
||||
if stats.HitCount != 75 {
|
||||
t.Errorf("HitCount = %d, want 75", stats.HitCount)
|
||||
}
|
||||
|
||||
if stats.MissCount != 25 {
|
||||
t.Errorf("MissCount = %d, want 25", stats.MissCount)
|
||||
}
|
||||
@@ -61,11 +71,14 @@ func TestStats_HitRateIsRatio(t *testing.T) {
|
||||
// HitRate should be 0.75, NOT 9999 (transform_count)
|
||||
expectedRate := 0.75
|
||||
if math.Abs(stats.HitRate-expectedRate) > 0.001 {
|
||||
t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)", stats.HitRate, expectedRate)
|
||||
t.Errorf("HitRate = %f, want %f (was it scanning transform_count?)",
|
||||
stats.HitRate, expectedRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_ZeroCounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := setupStatsTestDB(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -44,7 +44,8 @@ type ContentStorage struct {
|
||||
|
||||
// NewContentStorage creates a new content storage at the given base directory.
|
||||
func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
||||
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ func NewContentStorage(baseDir string) (*ContentStorage, error) {
|
||||
|
||||
// Store writes content to storage and returns its SHA256 hash.
|
||||
// The content is read fully into memory to compute the hash before writing.
|
||||
func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err error) {
|
||||
func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
|
||||
// Read all content to compute hash
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -62,20 +63,23 @@ func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err e
|
||||
|
||||
// Compute hash
|
||||
h := sha256.Sum256(data)
|
||||
hash = ContentHash(hex.EncodeToString(h[:]))
|
||||
size = int64(len(data))
|
||||
hash := ContentHash(hex.EncodeToString(h[:]))
|
||||
size := int64(len(data))
|
||||
|
||||
// Build path: <basedir>/<ab>/<cd>/<hash>
|
||||
path := s.hashToPath(hash)
|
||||
|
||||
// Check if already exists
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
_, err = os.Stat(path)
|
||||
if err == nil {
|
||||
return hash, size, nil
|
||||
}
|
||||
|
||||
// Create directory structure
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
||||
|
||||
err = os.MkdirAll(dir, StorageDirPerm)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -84,27 +88,29 @@ func (s *ContentStorage) Store(r io.Reader) (hash ContentHash, size int64, err e
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
_, err = tmpFile.Write(data)
|
||||
if err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return "", 0, fmt.Errorf("failed to write content: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
err = tmpFile.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return "", 0, fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
//nolint:gosec // G703: paths from internal SHA256 hashes
|
||||
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
||||
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
@@ -188,7 +194,8 @@ type MetadataStorage struct {
|
||||
|
||||
// NewMetadataStorage creates a new metadata storage at the given base directory.
|
||||
func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
||||
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create metadata directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -196,6 +203,8 @@ func NewMetadataStorage(baseDir string) (*MetadataStorage, error) {
|
||||
}
|
||||
|
||||
// SourceMetadata represents cached metadata about a source URL.
|
||||
//
|
||||
//nolint:tagliatelle // stored metadata format uses snake_case
|
||||
type SourceMetadata struct {
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
@@ -214,12 +223,16 @@ type SourceMetadata struct {
|
||||
}
|
||||
|
||||
// Store writes metadata to storage.
|
||||
func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMetadata) error {
|
||||
func (s *MetadataStorage) Store(
|
||||
host string, pathHash PathHash, meta *SourceMetadata,
|
||||
) error {
|
||||
path := s.metaPath(host, pathHash)
|
||||
|
||||
// Create directory structure
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
||||
|
||||
err := os.MkdirAll(dir, StorageDirPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -234,27 +247,29 @@ func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMeta
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
_, err = tmpFile.Write(data)
|
||||
if err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
err = tmpFile.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
//nolint:gosec // G703: paths from internal SHA256 hashes
|
||||
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
||||
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
@@ -262,7 +277,9 @@ func (s *MetadataStorage) Store(host string, pathHash PathHash, meta *SourceMeta
|
||||
}
|
||||
|
||||
// Load reads metadata from storage.
|
||||
func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata, error) {
|
||||
func (s *MetadataStorage) Load(
|
||||
host string, pathHash PathHash,
|
||||
) (*SourceMetadata, error) {
|
||||
path := s.metaPath(host, pathHash)
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // path derived from host+hash
|
||||
@@ -275,7 +292,9 @@ func (s *MetadataStorage) Load(host string, pathHash PathHash) (*SourceMetadata,
|
||||
}
|
||||
|
||||
var meta SourceMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
|
||||
err = json.Unmarshal(data, &meta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
||||
}
|
||||
|
||||
@@ -341,6 +360,8 @@ type VariantStorage struct {
|
||||
}
|
||||
|
||||
// VariantMeta contains metadata about a cached variant.
|
||||
//
|
||||
//nolint:tagliatelle // stored metadata format uses snake_case
|
||||
type VariantMeta struct {
|
||||
ContentType string `json:"content_type"`
|
||||
Size int64 `json:"size"`
|
||||
@@ -349,7 +370,8 @@ type VariantMeta struct {
|
||||
|
||||
// NewVariantStorage creates a new variant storage at the given base directory.
|
||||
func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
||||
if err := os.MkdirAll(baseDir, StorageDirPerm); err != nil {
|
||||
err := os.MkdirAll(baseDir, StorageDirPerm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create variant storage directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -357,19 +379,23 @@ func NewVariantStorage(baseDir string) (*VariantStorage, error) {
|
||||
}
|
||||
|
||||
// Store writes content and metadata to storage at the given key.
|
||||
func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string) (size int64, err error) {
|
||||
func (s *VariantStorage) Store(
|
||||
key VariantKey, r io.Reader, contentType string,
|
||||
) (int64, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read content: %w", err)
|
||||
}
|
||||
|
||||
size = int64(len(data))
|
||||
size := int64(len(data))
|
||||
path := s.keyToPath(key)
|
||||
metaPath := path + ".meta"
|
||||
|
||||
// Create directory structure
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, StorageDirPerm); err != nil {
|
||||
|
||||
err = os.MkdirAll(dir, StorageDirPerm)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -378,27 +404,29 @@ func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
_, err = tmpFile.Write(data)
|
||||
if err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return 0, fmt.Errorf("failed to write content: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
err = tmpFile.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename content
|
||||
//nolint:gosec // G703: paths from internal SHA256 hashes
|
||||
if err := os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)); err != nil {
|
||||
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
@@ -414,10 +442,8 @@ func (s *VariantStorage) Store(key VariantKey, r io.Reader, contentType string)
|
||||
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(metaPath, metaData, StorageFilePerm); err != nil {
|
||||
// Non-fatal, content is stored
|
||||
_ = err
|
||||
}
|
||||
// Metadata write failure is non-fatal; content is already stored.
|
||||
_ = os.WriteFile(metaPath, metaData, StorageFilePerm)
|
||||
|
||||
return size, nil
|
||||
}
|
||||
@@ -438,8 +464,11 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// LoadWithMeta returns a reader, size, and content type for the content at the given key.
|
||||
func (s *VariantStorage) LoadWithMeta(key VariantKey) (io.ReadCloser, int64, string, error) {
|
||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||
// the given key.
|
||||
func (s *VariantStorage) LoadWithMeta(
|
||||
key VariantKey,
|
||||
) (io.ReadCloser, int64, string, error) {
|
||||
path := s.keyToPath(key)
|
||||
metaPath := path + ".meta"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,13 +10,17 @@ import (
|
||||
)
|
||||
|
||||
func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewContentStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewContentStorage() error = %v", err)
|
||||
}
|
||||
|
||||
content := []byte("hello world")
|
||||
|
||||
hash, size, err := storage.Store(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
@@ -31,8 +36,11 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||
|
||||
// Verify file exists at expected path
|
||||
hashStr := string(hash)
|
||||
|
||||
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
||||
if _, err := os.Stat(expectedPath); err != nil {
|
||||
|
||||
_, err = os.Stat(expectedPath)
|
||||
if err != nil {
|
||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||
}
|
||||
|
||||
@@ -41,7 +49,8 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
loaded, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -54,7 +63,10 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContentStorage_StoreIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewContentStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewContentStorage() error = %v", err)
|
||||
@@ -78,26 +90,33 @@ func TestContentStorage_StoreIdempotent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContentStorage_LoadNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewContentStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewContentStorage() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = storage.Load(ContentHash("nonexistent"))
|
||||
if err != ErrNotFound {
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentStorage_Delete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewContentStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewContentStorage() error = %v", err)
|
||||
}
|
||||
|
||||
content := []byte("to be deleted")
|
||||
|
||||
hash, _, err := storage.Store(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
@@ -107,7 +126,8 @@ func TestContentStorage_Delete(t *testing.T) {
|
||||
t.Error("Exists() = false, want true")
|
||||
}
|
||||
|
||||
if err := storage.Delete(hash); err != nil {
|
||||
err = storage.Delete(hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -117,20 +137,27 @@ func TestContentStorage_Delete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContentStorage_DeleteNonexistent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewContentStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewContentStorage() error = %v", err)
|
||||
}
|
||||
|
||||
// Should not error
|
||||
if err := storage.Delete(ContentHash("nonexistent")); err != nil {
|
||||
err = storage.Delete(ContentHash("nonexistent"))
|
||||
if err != nil {
|
||||
t.Errorf("Delete() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentStorage_HashToPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewContentStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewContentStorage() error = %v", err)
|
||||
@@ -138,50 +165,59 @@ func TestContentStorage_HashToPath(t *testing.T) {
|
||||
|
||||
// Test by storing and verifying the resulting path structure
|
||||
content := []byte("test content for path verification")
|
||||
|
||||
hash, _, err := storage.Store(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
hashStr := string(hash)
|
||||
|
||||
expectedPath := filepath.Join(tmpDir, hashStr[0:2], hashStr[2:4], hashStr)
|
||||
if _, err := os.Stat(expectedPath); err != nil {
|
||||
|
||||
_, err = os.Stat(expectedPath)
|
||||
if err != nil {
|
||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewMetadataStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||
}
|
||||
|
||||
meta := &SourceMetadata{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/photos/cat.jpg",
|
||||
Host: testHostCDN,
|
||||
Path: testPathCat,
|
||||
ContentHash: "abc123",
|
||||
StatusCode: 200,
|
||||
ContentType: "image/jpeg",
|
||||
ContentType: testContentTypeJPEG,
|
||||
FetchedAt: 1704067200,
|
||||
ETag: `"etag123"`,
|
||||
}
|
||||
|
||||
pathHash := HashPath("/photos/cat.jpg")
|
||||
pathHash := HashPath(testPathCat)
|
||||
|
||||
err = storage.Store("cdn.example.com", pathHash, meta)
|
||||
err = storage.Store(testHostCDN, pathHash, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists at expected path
|
||||
expectedPath := filepath.Join(tmpDir, "cdn.example.com", string(pathHash)+".json")
|
||||
if _, err := os.Stat(expectedPath); err != nil {
|
||||
expectedPath := filepath.Join(tmpDir, testHostCDN, string(pathHash)+".json")
|
||||
|
||||
_, err = os.Stat(expectedPath)
|
||||
if err != nil {
|
||||
t.Errorf("File not at expected path %s: %v", expectedPath, err)
|
||||
}
|
||||
|
||||
// Load and verify
|
||||
loaded, err := storage.Load("cdn.example.com", pathHash)
|
||||
loaded, err := storage.Load(testHostCDN, pathHash)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
@@ -208,55 +244,64 @@ func TestMetadataStorage_StoreAndLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMetadataStorage_LoadNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewMetadataStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = storage.Load("example.com", PathHash("nonexistent"))
|
||||
if err != ErrNotFound {
|
||||
_, err = storage.Load(testHostExample, PathHash("nonexistent"))
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataStorage_Delete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
storage, err := NewMetadataStorage(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMetadataStorage() error = %v", err)
|
||||
}
|
||||
|
||||
meta := &SourceMetadata{
|
||||
Host: "example.com",
|
||||
Host: testHostExample,
|
||||
Path: "/test.jpg",
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
pathHash := HashPath("/test.jpg")
|
||||
|
||||
err = storage.Store("example.com", pathHash, meta)
|
||||
err = storage.Store(testHostExample, pathHash, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
if !storage.Exists("example.com", pathHash) {
|
||||
if !storage.Exists(testHostExample, pathHash) {
|
||||
t.Error("Exists() = false, want true")
|
||||
}
|
||||
|
||||
if err := storage.Delete("example.com", pathHash); err != nil {
|
||||
err = storage.Delete(testHostExample, pathHash)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
|
||||
if storage.Exists("example.com", pathHash) {
|
||||
if storage.Exists(testHostExample, pathHash) {
|
||||
t.Error("Exists() = true after delete, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Same input should produce same hash
|
||||
hash1 := HashPath("/photos/cat.jpg")
|
||||
hash2 := HashPath("/photos/cat.jpg")
|
||||
hash1 := HashPath(testPathCat)
|
||||
hash2 := HashPath(testPathCat)
|
||||
|
||||
if hash1 != hash2 {
|
||||
t.Errorf("HashPath() not deterministic: %s vs %s", hash1, hash2)
|
||||
@@ -276,9 +321,11 @@ func TestHashPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCacheKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req1 := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
SourceQuery: "",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -287,8 +334,8 @@ func TestCacheKey(t *testing.T) {
|
||||
}
|
||||
|
||||
req2 := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
SourceQuery: "",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -311,8 +358,8 @@ func TestCacheKey(t *testing.T) {
|
||||
|
||||
// Different size should produce different key
|
||||
req3 := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
SourceQuery: "",
|
||||
Size: Size{Width: 400, Height: 300}, // Different size
|
||||
Format: FormatWebP,
|
||||
@@ -327,8 +374,8 @@ func TestCacheKey(t *testing.T) {
|
||||
|
||||
// Different format should produce different key
|
||||
req4 := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
SourceQuery: "",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatPNG, // Different format
|
||||
@@ -343,8 +390,8 @@ func TestCacheKey(t *testing.T) {
|
||||
|
||||
// Different quality should produce different key
|
||||
req5 := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/cat.jpg",
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: testPathCat,
|
||||
SourceQuery: "",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -18,6 +18,14 @@ import (
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
)
|
||||
|
||||
// Shared test data literals, extracted as constants for goconst.
|
||||
const (
|
||||
testHostCDN = "cdn.example.com"
|
||||
testHostExample = "example.com"
|
||||
testPathCat = "/photos/cat.jpg"
|
||||
testContentTypeJPEG = "image/jpeg"
|
||||
)
|
||||
|
||||
// TestFixtures contains paths to test files in the mock filesystem.
|
||||
type TestFixtures struct {
|
||||
// Valid image files
|
||||
@@ -89,14 +97,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
|
||||
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||
}
|
||||
|
||||
@@ -108,14 +118,16 @@ func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
|
||||
err := png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test PNG: %v", err)
|
||||
}
|
||||
|
||||
@@ -126,15 +138,20 @@ func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
|
||||
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewPaletted(image.Rect(0, 0, width, height), []color.Color{c, color.White})
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
img := image.NewPaletted(
|
||||
image.Rect(0, 0, width, height),
|
||||
[]color.Color{c, color.White},
|
||||
)
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
img.SetColorIndex(x, y, 0)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := gif.Encode(&buf, img, nil); err != nil {
|
||||
|
||||
err := gif.Encode(&buf, img, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test GIF: %v", err)
|
||||
}
|
||||
|
||||
@@ -142,7 +159,9 @@ func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
||||
}
|
||||
|
||||
// SetupTestService creates a Service with mock fetcher for testing.
|
||||
func SetupTestService(t *testing.T, opts ...TestServiceOption) (*Service, *TestFixtures) {
|
||||
func SetupTestService(
|
||||
t *testing.T, opts ...TestServiceOption,
|
||||
) (*Service, *TestFixtures) {
|
||||
t.Helper()
|
||||
|
||||
mockFS, fixtures := NewTestFS(t)
|
||||
@@ -195,7 +214,8 @@ func setupServiceTestDB(t *testing.T) *sql.DB {
|
||||
}
|
||||
|
||||
// Use the real production schema via migrations
|
||||
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
||||
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to apply migrations: %v", err)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ type ParsedURL struct {
|
||||
Format ImageFormat
|
||||
}
|
||||
|
||||
// ParseImagePath parses the path captured by chi's wildcard: <host>/<path>/<size>.<format>
|
||||
// ParseImagePath parses the path captured by chi's wildcard:
|
||||
// <host>/<path>/<size>.<format>
|
||||
// This is the primary entry point when using chi routing.
|
||||
// Examples:
|
||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||
@@ -76,7 +77,8 @@ func ParseImageURL(urlPath string) (*ParsedURL, error) {
|
||||
// parseImageComponents parses <host>/<path>/<size>.<format> structure.
|
||||
func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||
// Check for path traversal before any other processing
|
||||
if err := checkPathTraversal(remainder); err != nil {
|
||||
err := checkPathTraversal(remainder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -102,6 +104,7 @@ func parseImageComponents(remainder string) (*ParsedURL, error) {
|
||||
// Split host from path
|
||||
// The first segment is the host, everything after is the path
|
||||
firstSlash := strings.Index(hostAndPath, "/")
|
||||
|
||||
var host, path, query string
|
||||
|
||||
if firstSlash == -1 {
|
||||
@@ -181,8 +184,7 @@ func checkPathTraversal(path string) error {
|
||||
|
||||
// Also check for ".." as a path segment in the original path
|
||||
// This catches cases where the path hasn't been normalized
|
||||
segments := strings.Split(path, "/")
|
||||
for _, seg := range segments {
|
||||
for seg := range strings.SplitSeq(path, "/") {
|
||||
// URL decode the segment
|
||||
decodedSeg, _ := url.PathUnescape(seg)
|
||||
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
|
||||
@@ -202,8 +204,10 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
||||
return Size{}, "", ErrInvalidSize
|
||||
}
|
||||
|
||||
var size Size
|
||||
var formatStr string
|
||||
var (
|
||||
size Size
|
||||
formatStr string
|
||||
)
|
||||
|
||||
if matches[4] == "orig" {
|
||||
// "orig.format" pattern
|
||||
|
||||
@@ -1,93 +1,124 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// assertParsedURL compares all fields of a parsed URL against the
|
||||
// expected value.
|
||||
func assertParsedURL(t *testing.T, got, want *ParsedURL) {
|
||||
t.Helper()
|
||||
|
||||
if got.Host != want.Host {
|
||||
t.Errorf("Host = %q, want %q", got.Host, want.Host)
|
||||
}
|
||||
|
||||
if got.Path != want.Path {
|
||||
t.Errorf("Path = %q, want %q", got.Path, want.Path)
|
||||
}
|
||||
|
||||
if got.Query != want.Query {
|
||||
t.Errorf("Query = %q, want %q", got.Query, want.Query)
|
||||
}
|
||||
|
||||
if got.Size != want.Size {
|
||||
t.Errorf("Size = %v, want %v", got.Size, want.Size)
|
||||
}
|
||||
|
||||
if got.Format != want.Format {
|
||||
t.Errorf("Format = %q, want %q", got.Format, want.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *ParsedURL
|
||||
wantErr error
|
||||
name string
|
||||
input string
|
||||
want *ParsedURL
|
||||
}{
|
||||
{
|
||||
name: "basic path with size",
|
||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
want: &ParsedURL{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/photos/cat.jpg",
|
||||
Query: "",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
Host: testHostCDN, Path: testPathCat,
|
||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "original size with 0x0",
|
||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
||||
want: &ParsedURL{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/photos/cat.jpg",
|
||||
Query: "",
|
||||
Size: Size{Width: 0, Height: 0},
|
||||
Format: FormatJPEG,
|
||||
Host: testHostCDN, Path: testPathCat,
|
||||
Size: Size{Width: 0, Height: 0}, Format: FormatJPEG,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "original size with orig keyword",
|
||||
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
||||
want: &ParsedURL{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/photos/cat.jpg",
|
||||
Query: "",
|
||||
Size: Size{Width: 0, Height: 0},
|
||||
Format: FormatPNG,
|
||||
Host: testHostCDN, Path: testPathCat,
|
||||
Size: Size{Width: 0, Height: 0}, Format: FormatPNG,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with query string",
|
||||
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
||||
want: &ParsedURL{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/photos/cat.jpg",
|
||||
Query: "arg1=val1&arg2=val2",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
||||
Size: Size{Width: 800, Height: 600}, Format: FormatWebP,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deep nested path",
|
||||
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
||||
want: &ParsedURL{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/a/b/c/d/image.jpg",
|
||||
Query: "",
|
||||
Size: Size{Width: 1920, Height: 1080},
|
||||
Format: FormatAVIF,
|
||||
Host: testHostCDN, Path: "/a/b/c/d/image.jpg",
|
||||
Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "jpg alias for jpeg",
|
||||
input: "/v1/image/example.com/img.png/100x100.jpg",
|
||||
want: &ParsedURL{
|
||||
Host: "example.com",
|
||||
Path: "/img.png",
|
||||
Query: "",
|
||||
Size: Size{Width: 100, Height: 100},
|
||||
Format: FormatJPEG,
|
||||
Host: testHostExample, Path: "/img.png",
|
||||
Size: Size{Width: 100, Height: 100}, Format: FormatJPEG,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gif format",
|
||||
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
||||
want: &ParsedURL{
|
||||
Host: "example.com",
|
||||
Path: "/animated.gif",
|
||||
Query: "",
|
||||
Size: Size{Width: 200, Height: 200},
|
||||
Format: FormatGIF,
|
||||
Host: testHostExample, Path: "/animated.gif",
|
||||
Size: Size{Width: 200, Height: 200}, Format: FormatGIF,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseImageURL(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
assertParsedURL(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageURL_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "missing prefix",
|
||||
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
||||
@@ -122,47 +153,23 @@ func TestParseImageURL(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseImageURL(tt.input)
|
||||
t.Parallel()
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if err == nil {
|
||||
t.Errorf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
if !errorIs(err, tt.wantErr) {
|
||||
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
return
|
||||
_, err := ParseImageURL(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseImageURL() unexpected error = %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if got.Host != tt.want.Host {
|
||||
t.Errorf("Host = %q, want %q", got.Host, tt.want.Host)
|
||||
}
|
||||
if got.Path != tt.want.Path {
|
||||
t.Errorf("Path = %q, want %q", got.Path, tt.want.Path)
|
||||
}
|
||||
if got.Query != tt.want.Query {
|
||||
t.Errorf("Query = %q, want %q", got.Query, tt.want.Query)
|
||||
}
|
||||
if got.Size != tt.want.Size {
|
||||
t.Errorf("Size = %v, want %v", got.Size, tt.want.Size)
|
||||
}
|
||||
if got.Format != tt.want.Format {
|
||||
t.Errorf("Format = %q, want %q", got.Format, tt.want.Format)
|
||||
if !errorIs(err, tt.wantErr) {
|
||||
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImagePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -174,8 +181,8 @@ func TestParseImagePath(t *testing.T) {
|
||||
name: "chi wildcard capture",
|
||||
input: "cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
want: &ParsedURL{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/photos/cat.jpg",
|
||||
Host: testHostCDN,
|
||||
Path: testPathCat,
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
},
|
||||
@@ -184,8 +191,8 @@ func TestParseImagePath(t *testing.T) {
|
||||
name: "with leading slash from chi",
|
||||
input: "/cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
want: &ParsedURL{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/photos/cat.jpg",
|
||||
Host: testHostCDN,
|
||||
Path: testPathCat,
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
},
|
||||
@@ -194,35 +201,30 @@ func TestParseImagePath(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseImagePath(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if got.Host != tt.want.Host {
|
||||
t.Errorf("Host = %q, want %q", got.Host, tt.want.Host)
|
||||
}
|
||||
if got.Path != tt.want.Path {
|
||||
t.Errorf("Path = %q, want %q", got.Path, tt.want.Path)
|
||||
}
|
||||
if got.Size != tt.want.Size {
|
||||
t.Errorf("Size = %v, want %v", got.Size, tt.want.Size)
|
||||
}
|
||||
if got.Format != tt.want.Format {
|
||||
t.Errorf("Format = %q, want %q", got.Format, tt.want.Format)
|
||||
}
|
||||
|
||||
assertParsedURL(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedURL_ToImageRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parsed := &ParsedURL{
|
||||
Host: "cdn.example.com",
|
||||
Path: "/photos/cat.jpg",
|
||||
Host: testHostCDN,
|
||||
Path: testPathCat,
|
||||
Query: "version=2",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
@@ -233,21 +235,27 @@ func TestParsedURL_ToImageRequest(t *testing.T) {
|
||||
if req.SourceHost != parsed.Host {
|
||||
t.Errorf("SourceHost = %q, want %q", req.SourceHost, parsed.Host)
|
||||
}
|
||||
|
||||
if req.SourcePath != parsed.Path {
|
||||
t.Errorf("SourcePath = %q, want %q", req.SourcePath, parsed.Path)
|
||||
}
|
||||
|
||||
if req.SourceQuery != parsed.Query {
|
||||
t.Errorf("SourceQuery = %q, want %q", req.SourceQuery, parsed.Query)
|
||||
}
|
||||
|
||||
if req.Size != parsed.Size {
|
||||
t.Errorf("Size = %v, want %v", req.Size, parsed.Size)
|
||||
}
|
||||
|
||||
if req.Format != parsed.Format {
|
||||
t.Errorf("Format = %q, want %q", req.Format, parsed.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// All path traversal attempts should be rejected
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -293,12 +301,14 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseImageURL(tt.input)
|
||||
if err == nil {
|
||||
t.Error("ParseImageURL() should reject path traversal attempts")
|
||||
}
|
||||
|
||||
if err != ErrPathTraversal {
|
||||
if !errors.Is(err, ErrPathTraversal) {
|
||||
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
||||
}
|
||||
})
|
||||
@@ -306,6 +316,8 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseImagePath_PathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test path traversal via ParseImagePath (chi wildcard)
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -323,12 +335,14 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseImagePath(tt.input)
|
||||
if err == nil {
|
||||
t.Error("ParseImagePath() should reject path traversal attempts")
|
||||
}
|
||||
|
||||
if err != ErrPathTraversal {
|
||||
if !errors.Is(err, ErrPathTraversal) {
|
||||
t.Errorf("ParseImagePath() error = %v, want ErrPathTraversal", err)
|
||||
}
|
||||
})
|
||||
@@ -337,7 +351,7 @@ func TestParseImagePath_PathTraversal(t *testing.T) {
|
||||
|
||||
// errorIs checks if err matches target (handles wrapped errors).
|
||||
func errorIs(err, target error) bool {
|
||||
if err == target {
|
||||
if errors.Is(err, target) {
|
||||
return true
|
||||
}
|
||||
// Check if error message contains target message for wrapped errors
|
||||
Reference in New Issue
Block a user