Add trailing filename to encrypted URLs for better browser compatibility. The filename is ignored by the server but helps browsers identify content type.
114 lines
3.0 KiB
Go
114 lines
3.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
)
|
|
|
|
// HandleImageEnc handles requests to /v1/e/{token}/* for encrypted image URLs.
|
|
// The trailing path (e.g., /img.jpg) is ignored but helps browsers identify the content type.
|
|
func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
start := time.Now()
|
|
|
|
// Extract token from URL
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
s.respondError(w, "missing token", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Decrypt and validate the payload
|
|
payload, err := s.encGen.Parse(token)
|
|
if err != nil {
|
|
if errors.Is(err, encurl.ErrExpired) {
|
|
s.log.Debug("encrypted URL expired", "error", err)
|
|
s.respondError(w, "URL has expired", http.StatusGone)
|
|
|
|
return
|
|
}
|
|
|
|
s.log.Debug("failed to decrypt URL", "error", err)
|
|
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Convert payload to ImageRequest
|
|
req := payload.ToImageRequest()
|
|
|
|
// Log the request
|
|
s.log.Debug("encrypted image request",
|
|
"host", req.SourceHost,
|
|
"path", req.SourcePath,
|
|
"dimensions", fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height),
|
|
"format", req.Format,
|
|
)
|
|
|
|
// Fetch and process the image (no signature validation needed - encrypted URL is trusted)
|
|
resp, err := s.imgSvc.Get(ctx, req)
|
|
if err != nil {
|
|
s.handleImageError(w, err)
|
|
|
|
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 headers - encrypted URLs can be cached since they're immutable
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
|
|
|
// Stream the response
|
|
written, err := io.Copy(w, resp.Content)
|
|
if err != nil {
|
|
s.log.Error("failed to write response", "error", err)
|
|
|
|
return
|
|
}
|
|
|
|
// Log completion
|
|
duration := time.Since(start)
|
|
s.log.Info("image served",
|
|
"cache_key", imgcache.CacheKey(req),
|
|
"host", req.SourceHost,
|
|
"path", req.SourcePath,
|
|
"format", req.Format,
|
|
"cache_status", resp.CacheStatus,
|
|
"served_bytes", written,
|
|
"duration_ms", duration.Milliseconds(),
|
|
)
|
|
}
|
|
}
|
|
|
|
// handleImageError converts image service errors to HTTP responses.
|
|
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, imgcache.ErrSSRFBlocked):
|
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
|
case errors.Is(err, imgcache.ErrUpstreamError):
|
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
|
case errors.Is(err, imgcache.ErrUpstreamTimeout):
|
|
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
|
default:
|
|
s.log.Error("image request failed", "error", err)
|
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|