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
This commit is contained in:
2026-01-08 10:08:38 -08:00
parent 4df3e44eff
commit 1f809a6fc9
5 changed files with 70 additions and 8 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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 + `"`
}

View File

@@ -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)

View File

@@ -53,6 +53,7 @@ func (s *Server) SetupRoutes() {
// Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format>
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())