From 1f809a6fc9f9e7562afe4b295a6231466aecaf6b Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 8 Jan 2026 10:08:38 -0800 Subject: [PATCH] Implement ETag, HEAD requests, and conditional requests - Add ETag generation based on output content hash (first 16 chars) - Add ContentLength to ImageResponse from cache - Add LoadWithSize method to ContentStorage - Add GetOutputWithSize method to Cache - Handle HEAD requests returning headers only - Handle If-None-Match conditional requests returning 304 - Register HEAD route for image proxy endpoint --- internal/handlers/image.go | 16 ++++++++++++++++ internal/imgcache/cache.go | 5 +++++ internal/imgcache/service.go | 33 +++++++++++++++++++++++++-------- internal/imgcache/storage.go | 23 +++++++++++++++++++++++ internal/server/routes.go | 1 + 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/internal/handlers/image.go b/internal/handlers/image.go index 5dd9dcd..4f71217 100644 --- a/internal/handlers/image.go +++ b/internal/handlers/image.go @@ -122,6 +122,22 @@ func (s *Handlers) HandleImage() http.HandlerFunc { if resp.ETag != "" { w.Header().Set("ETag", resp.ETag) + + // Check for conditional request (If-None-Match) + if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" { + if ifNoneMatch == resp.ETag { + w.WriteHeader(http.StatusNotModified) + + return + } + } + } + + // Handle HEAD request - return headers only + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + + return } // Stream the response diff --git a/internal/imgcache/cache.go b/internal/imgcache/cache.go index f6b9d82..665a517 100644 --- a/internal/imgcache/cache.go +++ b/internal/imgcache/cache.go @@ -168,6 +168,11 @@ func (c *Cache) GetOutput(outputHash string) (io.ReadCloser, error) { return c.dstContent.Load(outputHash) } +// GetOutputWithSize returns a reader and size for cached output content. +func (c *Cache) GetOutputWithSize(outputHash string) (io.ReadCloser, int64, error) { + return c.dstContent.LoadWithSize(outputHash) +} + // StoreSource stores fetched source content and metadata. func (c *Cache) StoreSource( ctx context.Context, diff --git a/internal/imgcache/service.go b/internal/imgcache/service.go index d57011c..a5d712e 100644 --- a/internal/imgcache/service.go +++ b/internal/imgcache/service.go @@ -91,15 +91,17 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e if result != nil && result.Hit { s.cache.IncrementStats(ctx, true, 0) - reader, err := s.cache.GetOutput(result.OutputHash) + reader, size, err := s.cache.GetOutputWithSize(result.OutputHash) if err != nil { s.log.Error("failed to get cached output", "hash", result.OutputHash, "error", err) // Fall through to re-fetch } else { return &ImageResponse{ - Content: reader, - ContentType: result.ContentType, - CacheStatus: CacheHit, + Content: reader, + ContentLength: size, + ContentType: result.ContentType, + CacheStatus: CacheHit, + ETag: formatETag(result.OutputHash), }, nil } } @@ -173,15 +175,17 @@ func (s *Service) fetchAndProcess(ctx context.Context, req *ImageRequest) (*Imag } // Serve from the cached file on disk (same path as cache hits) - reader, err := s.cache.GetOutput(outputHash) + reader, size, err := s.cache.GetOutputWithSize(outputHash) if err != nil { return nil, fmt.Errorf("failed to read cached output: %w", err) } return &ImageResponse{ - Content: reader, - ContentType: processResult.ContentType, - FetchedBytes: int64(len(sourceData)), + Content: reader, + ContentLength: size, + ContentType: processResult.ContentType, + FetchedBytes: int64(len(sourceData)), + ETag: formatETag(outputHash), }, nil } @@ -260,3 +264,16 @@ func extractStatusCode(err error) int { return httpStatusInternalError } + +// etagHashLength is the number of hash characters to use for ETags. +const etagHashLength = 16 + +// formatETag formats a hash as a quoted ETag value. +func formatETag(hash string) string { + // Use first 16 characters of hash for a shorter but still unique ETag + if len(hash) > etagHashLength { + hash = hash[:etagHashLength] + } + + return `"` + hash + `"` +} diff --git a/internal/imgcache/storage.go b/internal/imgcache/storage.go index 0c95023..f057977 100644 --- a/internal/imgcache/storage.go +++ b/internal/imgcache/storage.go @@ -114,6 +114,29 @@ func (s *ContentStorage) Load(hash string) (io.ReadCloser, error) { return f, nil } +// LoadWithSize returns a reader and file size for the content with the given hash. +func (s *ContentStorage) LoadWithSize(hash string) (io.ReadCloser, int64, error) { + path := s.hashToPath(hash) + + f, err := os.Open(path) //nolint:gosec // content-addressable path from hash + if err != nil { + if os.IsNotExist(err) { + return nil, 0, ErrNotFound + } + + return nil, 0, fmt.Errorf("failed to open content: %w", err) + } + + stat, err := f.Stat() + if err != nil { + _ = f.Close() + + return nil, 0, fmt.Errorf("failed to stat content: %w", err) + } + + return f, stat.Size(), nil +} + // Delete removes content with the given hash. func (s *ContentStorage) Delete(hash string) error { path := s.hashToPath(hash) diff --git a/internal/server/routes.go b/internal/server/routes.go index cbc376a..af87680 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -53,6 +53,7 @@ func (s *Server) SetupRoutes() { // Main image proxy route // /v1/image///x. s.router.Get("/v1/image/*", s.h.HandleImage()) + s.router.Head("/v1/image/*", s.h.HandleImage()) // Encrypted image URL route s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())