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 != "" {
|
if resp.ETag != "" {
|
||||||
w.Header().Set("ETag", 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
|
// Stream the response
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ func (c *Cache) GetOutput(outputHash string) (io.ReadCloser, error) {
|
|||||||
return c.dstContent.Load(outputHash)
|
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.
|
// StoreSource stores fetched source content and metadata.
|
||||||
func (c *Cache) StoreSource(
|
func (c *Cache) StoreSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -91,15 +91,17 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e
|
|||||||
if result != nil && result.Hit {
|
if result != nil && result.Hit {
|
||||||
s.cache.IncrementStats(ctx, true, 0)
|
s.cache.IncrementStats(ctx, true, 0)
|
||||||
|
|
||||||
reader, err := s.cache.GetOutput(result.OutputHash)
|
reader, size, err := s.cache.GetOutputWithSize(result.OutputHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to get cached output", "hash", result.OutputHash, "error", err)
|
s.log.Error("failed to get cached output", "hash", result.OutputHash, "error", err)
|
||||||
// Fall through to re-fetch
|
// Fall through to re-fetch
|
||||||
} else {
|
} else {
|
||||||
return &ImageResponse{
|
return &ImageResponse{
|
||||||
Content: reader,
|
Content: reader,
|
||||||
|
ContentLength: size,
|
||||||
ContentType: result.ContentType,
|
ContentType: result.ContentType,
|
||||||
CacheStatus: CacheHit,
|
CacheStatus: CacheHit,
|
||||||
|
ETag: formatETag(result.OutputHash),
|
||||||
}, nil
|
}, 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)
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read cached output: %w", err)
|
return nil, fmt.Errorf("failed to read cached output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ImageResponse{
|
return &ImageResponse{
|
||||||
Content: reader,
|
Content: reader,
|
||||||
|
ContentLength: size,
|
||||||
ContentType: processResult.ContentType,
|
ContentType: processResult.ContentType,
|
||||||
FetchedBytes: int64(len(sourceData)),
|
FetchedBytes: int64(len(sourceData)),
|
||||||
|
ETag: formatETag(outputHash),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,3 +264,16 @@ func extractStatusCode(err error) int {
|
|||||||
|
|
||||||
return httpStatusInternalError
|
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
|
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.
|
// Delete removes content with the given hash.
|
||||||
func (s *ContentStorage) Delete(hash string) error {
|
func (s *ContentStorage) Delete(hash string) error {
|
||||||
path := s.hashToPath(hash)
|
path := s.hashToPath(hash)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Main image proxy route
|
// Main image proxy route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
s.router.Get("/v1/image/*", s.h.HandleImage())
|
s.router.Get("/v1/image/*", s.h.HandleImage())
|
||||||
|
s.router.Head("/v1/image/*", s.h.HandleImage())
|
||||||
|
|
||||||
// Encrypted image URL route
|
// Encrypted image URL route
|
||||||
s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())
|
s.router.Get("/v1/e/{token}", s.h.HandleImageEnc())
|
||||||
|
|||||||
Reference in New Issue
Block a user