chore: update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 2m3s
All checks were successful
check / check (push) Successful in 2m3s
Replace .golangci.yml with the canonical v2-schema config (default: all minus six disabled linters, lll 88, tests included) and bump every golangci-lint pin to v2.12.2: - Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned) - script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new linux-amd64/arm64 release-archive sha256 pins Fix all 747 findings the stricter config surfaces, with no behavior changes: t.Parallel() throughout the test suite, static sentinel errors and errors.Is comparisons, checked error returns, context propagation (contextcheck/noctx), 88-column wrapping, extracted constants and helpers for goconst/dupl/funlen/cyclop, exhaustive switch cases replicating existing defaults, and white-box test files renamed to *_internal_test.go for testpackage. Three nolint:tagliatelle directives preserve the existing snake_case JSON wire and on-disk metadata formats.
This commit is contained in:
@@ -35,7 +35,8 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
|
||||
|
||||
// handleLoginPost handles login form submission.
|
||||
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
s.renderLogin(w, "Invalid form data")
|
||||
|
||||
return
|
||||
@@ -52,7 +53,8 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Create session
|
||||
if err := s.sessMgr.CreateSession(w); err != nil {
|
||||
err = s.sessMgr.CreateSession(w)
|
||||
if err != nil {
|
||||
s.log.Error("failed to create session", "error", err)
|
||||
s.renderLogin(w, "Failed to create session")
|
||||
|
||||
@@ -83,20 +85,14 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form values
|
||||
sourceURL := r.FormValue("url")
|
||||
widthStr := r.FormValue("width")
|
||||
heightStr := r.FormValue("height")
|
||||
format := r.FormValue("format")
|
||||
qualityStr := r.FormValue("quality")
|
||||
fit := r.FormValue("fit")
|
||||
ttlStr := r.FormValue("ttl")
|
||||
|
||||
// Validate source URL
|
||||
parsed, err := url.Parse(sourceURL)
|
||||
@@ -106,38 +102,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse dimensions
|
||||
width, _ := strconv.Atoi(widthStr)
|
||||
height, _ := strconv.Atoi(heightStr)
|
||||
quality, _ := strconv.Atoi(qualityStr)
|
||||
ttl, _ := strconv.Atoi(ttlStr)
|
||||
|
||||
if quality <= 0 {
|
||||
quality = 85
|
||||
}
|
||||
|
||||
// Create payload
|
||||
// ttl=0 means never expires
|
||||
var expiresAt time.Time
|
||||
var expiresAtUnix int64
|
||||
|
||||
if ttl > 0 {
|
||||
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||
expiresAtUnix = expiresAt.Unix()
|
||||
}
|
||||
// else expiresAtUnix stays 0 (never expires)
|
||||
|
||||
payload := &encurl.Payload{
|
||||
SourceHost: parsed.Host,
|
||||
SourcePath: parsed.Path,
|
||||
SourceQuery: parsed.RawQuery,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Format: imgcache.ImageFormat(format),
|
||||
Quality: quality,
|
||||
FitMode: imgcache.FitMode(fit),
|
||||
ExpiresAt: expiresAtUnix,
|
||||
}
|
||||
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
|
||||
|
||||
// Generate encrypted token
|
||||
token, err := s.encGen.Generate(payload)
|
||||
@@ -148,20 +113,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Build full URL (URL-encode the token for safety)
|
||||
scheme := "https"
|
||||
if s.config.Debug {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// Determine file extension for the trailing filename
|
||||
ext := format
|
||||
if ext == "" || ext == "orig" {
|
||||
ext = "jpg" // Default extension
|
||||
}
|
||||
|
||||
host := r.Host
|
||||
generatedURL := scheme + "://" + host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||
generatedURL := s.buildGeneratedURL(r, token, r.FormValue("format"))
|
||||
|
||||
// Format expiry for display
|
||||
expiresAtStr := "Never"
|
||||
@@ -173,16 +125,55 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
GeneratedURL: generatedURL,
|
||||
ExpiresAt: expiresAtStr,
|
||||
FormURL: sourceURL,
|
||||
FormWidth: widthStr,
|
||||
FormHeight: heightStr,
|
||||
FormFormat: format,
|
||||
FormQuality: qualityStr,
|
||||
FormFit: fit,
|
||||
FormTTL: ttlStr,
|
||||
FormWidth: r.FormValue("width"),
|
||||
FormHeight: r.FormValue("height"),
|
||||
FormFormat: r.FormValue("format"),
|
||||
FormQuality: r.FormValue("quality"),
|
||||
FormFit: r.FormValue("fit"),
|
||||
FormTTL: r.FormValue("ttl"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
|
||||
func buildGeneratePayload(
|
||||
parsed *url.URL, form url.Values,
|
||||
) (*encurl.Payload, time.Time, int) {
|
||||
width, _ := strconv.Atoi(form.Get("width"))
|
||||
height, _ := strconv.Atoi(form.Get("height"))
|
||||
quality, _ := strconv.Atoi(form.Get("quality"))
|
||||
ttl, _ := strconv.Atoi(form.Get("ttl"))
|
||||
|
||||
if quality <= 0 {
|
||||
quality = 85
|
||||
}
|
||||
|
||||
var (
|
||||
expiresAt time.Time
|
||||
expiresAtUnix int64
|
||||
)
|
||||
|
||||
if ttl > 0 {
|
||||
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||
expiresAtUnix = expiresAt.Unix()
|
||||
}
|
||||
|
||||
payload := &encurl.Payload{
|
||||
SourceHost: parsed.Host,
|
||||
SourcePath: parsed.Path,
|
||||
SourceQuery: parsed.RawQuery,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Format: imgcache.ImageFormat(form.Get("format")),
|
||||
Quality: quality,
|
||||
FitMode: imgcache.FitMode(form.Get("fit")),
|
||||
ExpiresAt: expiresAtUnix,
|
||||
}
|
||||
|
||||
return payload, expiresAt, ttl
|
||||
}
|
||||
|
||||
// generatorData holds template data for the generator page.
|
||||
type generatorData struct {
|
||||
GeneratedURL string
|
||||
@@ -206,7 +197,8 @@ func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
|
||||
Error: errorMsg,
|
||||
}
|
||||
|
||||
if err := templates.Render(w, "login.html", data); err != nil {
|
||||
err := templates.Render(w, "login.html", data)
|
||||
if err != nil {
|
||||
s.log.Error("failed to render login template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -219,13 +211,16 @@ func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
|
||||
data = &generatorData{}
|
||||
}
|
||||
|
||||
if err := templates.Render(w, "generator.html", data); err != nil {
|
||||
err := templates.Render(w, "generator.html", data)
|
||||
if err != nil {
|
||||
s.log.Error("failed to render generator template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg string, form url.Values) {
|
||||
func (s *Handlers) renderGeneratorWithForm(
|
||||
w http.ResponseWriter, errorMsg string, form url.Values,
|
||||
) {
|
||||
s.renderGenerator(w, &generatorData{
|
||||
Error: errorMsg,
|
||||
FormURL: form.Get("url"),
|
||||
@@ -237,3 +232,19 @@ func (s *Handlers) renderGeneratorWithForm(w http.ResponseWriter, errorMsg strin
|
||||
FormTTL: form.Get("ttl"),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
||||
// Build full URL (URL-encode the token for safety)
|
||||
scheme := "https"
|
||||
if s.config.Debug {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// Determine file extension for the trailing filename
|
||||
ext := format
|
||||
if ext == "" || ext == "orig" {
|
||||
ext = "jpg" // Default extension
|
||||
}
|
||||
|
||||
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
// Params defines dependencies for Handlers.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Database *database.Database
|
||||
@@ -75,6 +76,7 @@ func (s *Handlers) initImageService() error {
|
||||
// Create the fetcher config
|
||||
fetcherCfg := httpfetcher.DefaultConfig()
|
||||
fetcherCfg.AllowHTTP = s.config.AllowHTTP
|
||||
|
||||
if s.config.UpstreamConnectionsPerHost > 0 {
|
||||
fetcherCfg.MaxConnectionsPerHost = s.config.UpstreamConnectionsPerHost
|
||||
}
|
||||
@@ -100,6 +102,7 @@ func (s *Handlers) initImageService() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.sessMgr = sessMgr
|
||||
|
||||
// Initialize encrypted URL generator
|
||||
@@ -107,6 +110,7 @@ func (s *Handlers) initImageService() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.encGen = encGen
|
||||
|
||||
s.log.Info("session manager and URL generator initialized")
|
||||
@@ -114,9 +118,10 @@ func (s *Handlers) initImageService() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status int) {
|
||||
func (s *Handlers) respondJSON(w http.ResponseWriter, data any, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
if data != nil {
|
||||
err := json.NewEncoder(w).Encode(data)
|
||||
if err != nil {
|
||||
@@ -126,7 +131,7 @@ func (s *Handlers) respondJSON(w http.ResponseWriter, data interface{}, status i
|
||||
}
|
||||
|
||||
func (s *Handlers) respondError(w http.ResponseWriter, message string, status int) {
|
||||
s.respondJSON(w, map[string]interface{}{
|
||||
s.respondJSON(w, map[string]any{
|
||||
"error": message,
|
||||
"status": status,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
|
||||
@@ -83,7 +83,8 @@ func setupTestDB(t *testing.T) *sql.DB {
|
||||
t.Fatalf("failed to open test db: %v", err)
|
||||
}
|
||||
|
||||
if err := database.ApplyMigrations(context.Background(), db, nil); err != nil {
|
||||
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to apply migrations: %v", err)
|
||||
}
|
||||
|
||||
@@ -94,14 +95,16 @@ func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
|
||||
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||
}
|
||||
|
||||
@@ -117,7 +120,9 @@ func newMockFetcher(fs fs.FS) *mockFetcher {
|
||||
return &mockFetcher{fs: fs}
|
||||
}
|
||||
|
||||
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*httpfetcher.FetchResult, error) {
|
||||
func (f *mockFetcher) Fetch(
|
||||
_ context.Context, url string,
|
||||
) (*httpfetcher.FetchResult, error) {
|
||||
// Remove https:// prefix
|
||||
path := url[8:] // Remove "https://"
|
||||
|
||||
@@ -134,13 +139,16 @@ func (f *mockFetcher) Fetch(ctx context.Context, url string) (*httpfetcher.Fetch
|
||||
}
|
||||
|
||||
func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fix := setupTestHandler(t)
|
||||
|
||||
// Create a chi router to properly handle wildcards
|
||||
r := chi.NewRouter()
|
||||
r.Head("/v1/image/*", fix.handler.HandleImage())
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodHead,
|
||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
@@ -167,13 +175,16 @@ func TestHandleImage_HEAD_ReturnsHeadersOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fix := setupTestHandler(t)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||
|
||||
// First request to get the ETag
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
req1 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
rec1 := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec1, req1)
|
||||
@@ -188,15 +199,18 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
||||
}
|
||||
|
||||
// Second request with If-None-Match header
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec2, req2)
|
||||
|
||||
// Should return 304 Not Modified
|
||||
if rec2.Code != http.StatusNotModified {
|
||||
t.Errorf("Conditional request status = %d, want %d", rec2.Code, http.StatusNotModified)
|
||||
t.Errorf("Conditional request status = %d, want %d",
|
||||
rec2.Code, http.StatusNotModified)
|
||||
}
|
||||
|
||||
// Body should be empty for 304 response
|
||||
@@ -206,21 +220,26 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_Returns304(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fix := setupTestHandler(t)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||
|
||||
// Request with non-matching ETag
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
req.Header.Set("If-None-Match", `"different-etag"`)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
// Should return 200 OK with full response
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("Request with non-matching ETag status = %d, want %d", rec.Code, http.StatusOK)
|
||||
t.Errorf("Request with non-matching ETag status = %d, want %d",
|
||||
rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// Body should not be empty
|
||||
@@ -230,12 +249,15 @@ func TestHandleImage_ConditionalRequest_IfNoneMatch_DifferentETag(t *testing.T)
|
||||
}
|
||||
|
||||
func TestHandleImage_ETagHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fix := setupTestHandler(t)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/image/*", fix.handler.HandleImage())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(rec, req)
|
||||
@@ -16,64 +16,14 @@ import (
|
||||
// /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)
|
||||
|
||||
req, ok := s.parseImageRequest(w, r)
|
||||
if !ok {
|
||||
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)
|
||||
if err := imgcache.ValidateFitMode(req.FitMode); err != nil {
|
||||
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
err := s.imgSvc.ValidateRequest(req)
|
||||
if err != nil {
|
||||
s.log.Warn("signature validation failed",
|
||||
"host", req.SourceHost,
|
||||
"path", req.SourcePath,
|
||||
@@ -89,83 +39,17 @@ func (s *Handlers) HandleImage() http.HandlerFunc {
|
||||
|
||||
// Get the image (from cache or fetch/process)
|
||||
startTime := time.Now()
|
||||
resp, err := s.imgSvc.Get(ctx, req)
|
||||
|
||||
resp, err := s.imgSvc.Get(r.Context(), 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, httpfetcher.ErrSSRFBlocked) {
|
||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||
s.respondImageError(w, req, 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 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)
|
||||
|
||||
// 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
|
||||
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,
|
||||
)
|
||||
s.writeImageResponse(w, r, req, resp, cacheKey, startTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,3 +64,156 @@ func (s *Handlers) HandleRobotsTxt() http.HandlerFunc {
|
||||
_, _ = w.Write(robotsTxt)
|
||||
}
|
||||
}
|
||||
|
||||
// parseImageRequest parses the wildcard path and query parameters into
|
||||
// an ImageRequest. On invalid input it writes an error response and
|
||||
// returns false.
|
||||
func (s *Handlers) parseImageRequest(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) (*imgcache.ImageRequest, bool) {
|
||||
// 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 nil, false
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
exp, parseErr := strconv.ParseInt(expStr, 10, 64)
|
||||
if parseErr == nil {
|
||||
req.Expires = time.Unix(exp, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse optional quality and fit params
|
||||
if qStr := query.Get("q"); qStr != "" {
|
||||
q, parseErr := strconv.Atoi(qStr)
|
||||
if parseErr == nil && q > 0 && q <= 100 {
|
||||
req.Quality = q
|
||||
}
|
||||
}
|
||||
|
||||
if fit := query.Get("fit"); fit != "" {
|
||||
req.FitMode = imgcache.FitMode(fit)
|
||||
|
||||
fitErr := imgcache.ValidateFitMode(req.FitMode)
|
||||
if fitErr != nil {
|
||||
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
return req, true
|
||||
}
|
||||
|
||||
// respondImageError maps image retrieval errors to HTTP responses.
|
||||
func (s *Handlers) respondImageError(
|
||||
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
||||
) {
|
||||
s.log.Error("failed to get image",
|
||||
"host", req.SourceHost,
|
||||
"path", req.SourcePath,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
// Check for specific error types
|
||||
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
||||
s.respondError(w, "forbidden", http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
||||
s.respondError(w, "upstream error", http.StatusBadGateway)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.respondError(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// writeImageResponse writes headers and streams the image content,
|
||||
// handling conditional and HEAD requests.
|
||||
func (s *Handlers) writeImageResponse(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
||||
cacheKey imgcache.VariantKey, startTime time.Time,
|
||||
) {
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ import (
|
||||
"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.
|
||||
// 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()
|
||||
@@ -57,7 +58,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||
"format", req.Format,
|
||||
)
|
||||
|
||||
// Fetch and process the image (no signature validation needed - encrypted URL is trusted)
|
||||
// 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)
|
||||
@@ -68,6 +70,7 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||
|
||||
// Set response headers
|
||||
w.Header().Set("Content-Type", resp.ContentType)
|
||||
|
||||
if resp.ContentLength > 0 {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user