Compare commits
11 Commits
fix/issue-
...
9e2e3fe9e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e2e3fe9e9 | ||
|
|
a94795fac6 | ||
|
|
8866ec8fd9 | ||
| f702e64139 | |||
|
|
c4368b1541 | ||
| 2fb36c5ccb | |||
|
|
40c4b53b01 | ||
| b800ef86d8 | |||
| 3d857da237 | |||
| 46a92c3514 | |||
|
|
e651e672aa |
23
.gitea/workflows/check.yml
Normal file
23
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Install golangci-lint
|
||||||
|
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
|
||||||
|
|
||||||
|
- name: Run make check
|
||||||
|
run: make check
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -18,3 +18,7 @@ vendor/
|
|||||||
# Data
|
# Data
|
||||||
/data/
|
/data/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
# Local dev configs
|
||||||
|
config.yaml
|
||||||
|
config.dev.yml
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Development config for local Docker testing
|
|
||||||
signing_key: "dev-signing-key-minimum-32-chars!"
|
|
||||||
debug: true
|
|
||||||
allow_http: true
|
|
||||||
whitelist_hosts:
|
|
||||||
- localhost
|
|
||||||
- s3.sneak.cloud
|
|
||||||
- static.sneak.cloud
|
|
||||||
- sneak.berlin
|
|
||||||
- github.com
|
|
||||||
- user-images.githubusercontent.com
|
|
||||||
10
config.yaml
10
config.yaml
@@ -1,10 +0,0 @@
|
|||||||
debug: true
|
|
||||||
port: 8080
|
|
||||||
state_dir: ./data
|
|
||||||
signing_key: "test-signing-key-for-development-only"
|
|
||||||
whitelist_hosts:
|
|
||||||
- "*.example.com"
|
|
||||||
- "images.unsplash.com"
|
|
||||||
- "picsum.photos"
|
|
||||||
- "s3.sneak.cloud"
|
|
||||||
allow_http: false
|
|
||||||
@@ -79,11 +79,18 @@ type ImageRequest struct {
|
|||||||
Signature string
|
Signature string
|
||||||
// Expires is the signature expiration timestamp
|
// Expires is the signature expiration timestamp
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
|
// AllowHTTP indicates whether HTTP (non-TLS) is allowed for this request
|
||||||
|
AllowHTTP bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceURL returns the full upstream URL to fetch
|
// SourceURL returns the full upstream URL to fetch.
|
||||||
|
// Uses http:// scheme when AllowHTTP is true, otherwise https://.
|
||||||
func (r *ImageRequest) SourceURL() string {
|
func (r *ImageRequest) SourceURL() string {
|
||||||
url := "https://" + r.SourceHost + r.SourcePath
|
scheme := "https"
|
||||||
|
if r.AllowHTTP {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
url := scheme + "://" + r.SourceHost + r.SourcePath
|
||||||
if r.SourceQuery != "" {
|
if r.SourceQuery != "" {
|
||||||
url += "?" + r.SourceQuery
|
url += "?" + r.SourceQuery
|
||||||
}
|
}
|
||||||
|
|||||||
100
internal/imgcache/negative_cache_test.go
Normal file
100
internal/imgcache/negative_cache_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNegativeCache_StoreAndCheck(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: dir,
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 5 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "example.com",
|
||||||
|
SourcePath: "/missing.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initially should not be in negative cache
|
||||||
|
hit, err := cache.checkNegativeCache(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
|
t.Error("expected no negative cache hit initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store a negative entry
|
||||||
|
err = cache.StoreNegative(ctx, req, 404, "not found")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now should be in negative cache
|
||||||
|
hit, err = cache.checkNegativeCache(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hit {
|
||||||
|
t.Error("expected negative cache hit after storing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNegativeCache_Expired(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
cache, err := NewCache(db, CacheConfig{
|
||||||
|
StateDir: dir,
|
||||||
|
CacheTTL: time.Hour,
|
||||||
|
NegativeTTL: 1 * time.Millisecond, // very short TTL
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "example.com",
|
||||||
|
SourcePath: "/expired.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store a negative entry with very short TTL
|
||||||
|
err = cache.StoreNegative(ctx, req, 500, "server error")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for expiry
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Should no longer be in negative cache
|
||||||
|
hit, err := cache.checkNegativeCache(ctx, req)
|
||||||
|
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) {
|
||||||
|
// 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
|
||||||
|
err := ErrNegativeCached
|
||||||
|
if !errors.Is(err, ErrNegativeCached) {
|
||||||
|
t.Error("ErrNegativeCached should be identifiable with errors.Is")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type Service struct {
|
|||||||
signer *Signer
|
signer *Signer
|
||||||
whitelist *HostWhitelist
|
whitelist *HostWhitelist
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
allowHTTP bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceConfig holds configuration for the image service.
|
// ServiceConfig holds configuration for the image service.
|
||||||
@@ -68,6 +69,11 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowHTTP := false
|
||||||
|
if cfg.FetcherConfig != nil {
|
||||||
|
allowHTTP = cfg.FetcherConfig.AllowHTTP
|
||||||
|
}
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
cache: cfg.Cache,
|
cache: cfg.Cache,
|
||||||
fetcher: fetcher,
|
fetcher: fetcher,
|
||||||
@@ -75,11 +81,31 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
|
|||||||
signer: signer,
|
signer: signer,
|
||||||
whitelist: NewHostWhitelist(cfg.Whitelist),
|
whitelist: NewHostWhitelist(cfg.Whitelist),
|
||||||
log: log,
|
log: log,
|
||||||
|
allowHTTP: allowHTTP,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrNegativeCached is returned when a URL is in the negative cache (recently failed).
|
||||||
|
var ErrNegativeCached = errors.New("request is in negative cache (recently failed)")
|
||||||
|
|
||||||
// Get retrieves a processed image, fetching and processing if necessary.
|
// Get retrieves a processed image, fetching and processing if necessary.
|
||||||
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
|
func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, error) {
|
||||||
|
// Propagate AllowHTTP setting to the request
|
||||||
|
req.AllowHTTP = s.allowHTTP
|
||||||
|
|
||||||
|
// Check negative cache first - skip fetching for recently-failed URLs
|
||||||
|
negHit, err := s.cache.checkNegativeCache(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("negative cache check failed", "error", err)
|
||||||
|
}
|
||||||
|
if negHit {
|
||||||
|
s.log.Debug("negative cache hit",
|
||||||
|
"host", req.SourceHost,
|
||||||
|
"path", req.SourcePath,
|
||||||
|
)
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrUpstreamError, ErrNegativeCached)
|
||||||
|
}
|
||||||
|
|
||||||
// Check variant cache first (disk only, no DB)
|
// Check variant cache first (disk only, no DB)
|
||||||
result, err := s.cache.Lookup(ctx, req)
|
result, err := s.cache.Lookup(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
44
internal/imgcache/sourceurl_test.go
Normal file
44
internal/imgcache/sourceurl_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestImageRequest_SourceURL_DefaultHTTPS(t *testing.T) {
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageRequest_SourceURL_AllowHTTP(t *testing.T) {
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "localhost:8080",
|
||||||
|
SourcePath: "/photos/cat.jpg",
|
||||||
|
AllowHTTP: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := req.SourceURL()
|
||||||
|
want := "http://localhost:8080/photos/cat.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SourceURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageRequest_SourceURL_AllowHTTPFalse(t *testing.T) {
|
||||||
|
req := &ImageRequest{
|
||||||
|
SourceHost: "cdn.example.com",
|
||||||
|
SourcePath: "/img.jpg",
|
||||||
|
AllowHTTP: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := req.SourceURL()
|
||||||
|
if got != "https://cdn.example.com/img.jpg" {
|
||||||
|
t.Errorf("SourceURL() = %q, want https scheme", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user