Wire up image handler endpoint with service orchestration
- Add image proxy config options (signing_key, whitelist_hosts, allow_http) - Create Service to orchestrate cache, fetcher, and processor - Initialize image service in handlers OnStart hook - Implement HandleImage with URL parsing, signature validation, cache - Implement HandleRobotsTxt for search engine prevention - Parse query params for signature, quality, and fit mode
This commit is contained in:
@@ -1,28 +1,145 @@
|
||||
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(_ http.ResponseWriter, _ *http.Request) {
|
||||
// FIXME: Implement image proxy handler
|
||||
// - Parse URL to extract host, path, size, format
|
||||
// - Validate signature and expiration
|
||||
// - Check source host whitelist
|
||||
// - Fetch from upstream (with SSRF protection)
|
||||
// - Process image (resize, convert format)
|
||||
// - Cache and serve result
|
||||
panic("unimplemented")
|
||||
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 the image (from cache or fetch/process)
|
||||
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)
|
||||
|
||||
_, err = io.Copy(w, resp.Content)
|
||||
if err != nil {
|
||||
s.log.Error("failed to write response",
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRobotsTxt serves robots.txt to prevent search engine crawling.
|
||||
func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
||||
return func(_ http.ResponseWriter, _ *http.Request) {
|
||||
// FIXME: Implement robots.txt handler
|
||||
panic("unimplemented")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user