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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 + `"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user