- StoreOutput now returns output hash for immediate retrieval - Cache misses now serve from disk file after storing (same as hits) - Log served_bytes from actual io.Copy result (avoids stat calls) - Remove ContentLength field usage for cache hits (stream from file) - Fix tests to properly check all return values
161 lines
3.8 KiB
Go
161 lines
3.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
)
|
|
|
|
// HandleImage handles the main image proxy route:
|
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Get the wildcard path from chi
|
|
pathParam := chi.URLParam(r, "*")
|
|
|
|
// Parse the URL path
|
|
parsed, err := imgcache.ParseImagePath(pathParam)
|
|
if err != nil {
|
|
s.log.Warn("failed to parse image URL",
|
|
"path", pathParam,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Convert to ImageRequest
|
|
req := parsed.ToImageRequest()
|
|
|
|
// Parse signature params from query string
|
|
query := r.URL.Query()
|
|
req.Signature = query.Get("sig")
|
|
|
|
if expStr := query.Get("exp"); expStr != "" {
|
|
if exp, err := strconv.ParseInt(expStr, 10, 64); err == nil {
|
|
req.Expires = time.Unix(exp, 0)
|
|
}
|
|
}
|
|
|
|
// Parse optional quality and fit params
|
|
if qStr := query.Get("q"); qStr != "" {
|
|
if q, err := strconv.Atoi(qStr); err == nil && q > 0 && q <= 100 {
|
|
req.Quality = q
|
|
}
|
|
}
|
|
|
|
if fit := query.Get("fit"); fit != "" {
|
|
req.FitMode = imgcache.FitMode(fit)
|
|
}
|
|
|
|
// Default quality if not set
|
|
if req.Quality == 0 {
|
|
req.Quality = 85
|
|
}
|
|
|
|
// Default fit mode if not set
|
|
if req.FitMode == "" {
|
|
req.FitMode = imgcache.FitCover
|
|
}
|
|
|
|
// Validate signature if required
|
|
if err := s.imgSvc.ValidateRequest(req); err != nil {
|
|
s.log.Warn("signature validation failed",
|
|
"host", req.SourceHost,
|
|
"path", req.SourcePath,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
// Get cache key for logging
|
|
cacheKey := imgcache.CacheKey(req)
|
|
|
|
// Get the image (from cache or fetch/process)
|
|
startTime := time.Now()
|
|
resp, err := s.imgSvc.Get(ctx, req)
|
|
if err != nil {
|
|
s.log.Error("failed to get image",
|
|
"host", req.SourceHost,
|
|
"path", req.SourcePath,
|
|
"error", err,
|
|
)
|
|
|
|
// Check for specific error types
|
|
if errors.Is(err, imgcache.ErrSSRFBlocked) {
|
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
|
|
|
return
|
|
}
|
|
|
|
if errors.Is(err, imgcache.ErrUpstreamError) {
|
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
|
|
|
return
|
|
}
|
|
|
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
// Set response headers
|
|
w.Header().Set("Content-Type", resp.ContentType)
|
|
if resp.ContentLength > 0 {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
|
}
|
|
|
|
// Cache control headers
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
|
|
|
if resp.ETag != "" {
|
|
w.Header().Set("ETag", resp.ETag)
|
|
}
|
|
|
|
// Stream the response
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
servedBytes, err := io.Copy(w, resp.Content)
|
|
if err != nil {
|
|
s.log.Error("failed to write response",
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
// Log cache status and timing after serving
|
|
duration := time.Since(startTime)
|
|
s.log.Info("image served",
|
|
"cache_key", cacheKey,
|
|
"cache_status", resp.CacheStatus,
|
|
"duration_ms", duration.Milliseconds(),
|
|
"format", req.Format,
|
|
"served_bytes", servedBytes,
|
|
"fetched_bytes", resp.FetchedBytes,
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleRobotsTxt serves robots.txt to prevent search engine crawling.
|
|
func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
|
robotsTxt := []byte("User-agent: *\nDisallow: /\n")
|
|
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(robotsTxt)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(robotsTxt)
|
|
}
|
|
}
|