package handlers import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/go-chi/chi/v5/middleware" "sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/httpfetcher" "sneak.berlin/go/pixa/internal/imageprocessor" "sneak.berlin/go/pixa/internal/imageproxy" "sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/magic" ) // statusClientClosedRequest is the status nginx calls "client closed // request". The image routes answer it when the client goes away while // waiting for an image another request is making. Nobody reads it; it is for // logs and metrics. const statusClientClosedRequest = 499 // HandleImage handles the main image proxy route: // /v1/image///x., or with no format // /v1/image///x func (s *Handlers) HandleImage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.refuseBlockedReferer(w, r) { return } req, sig, expires, ok := s.parseImageRequest(w, r) if !ok { return } // Validate signature if required err := s.validateSignature(req, sig, expires) if err != nil { s.log.Warn("signature validation failed", "host", req.Source.Host, "path", req.Source.Path, "error", err, ) s.respondError(w, "unauthorized", http.StatusUnauthorized) return } s.serveImage(w, r, req, expires) } } // 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) } } // parseImageRequest parses the image URL's path and query parameters into // an image request, and returns it with the URL's signature and expiry. On // invalid input it writes an error response and returns false. func (s *Handlers) parseImageRequest( w http.ResponseWriter, r *http.Request, ) (*imagerequest.Request, string, time.Time, bool) { // The path is parsed decoded once, as r.URL.Path holds it, and not from // chi's wildcard: chi routes on the path as the client wrote it whenever // that is not Go's own encoding of it, so its wildcard would still hold // the %3F before a source query when, say, a "/" in the query is written // as %2F req, err := imagerequest.ParseImageURL(r.URL.Path) if err != nil { s.log.Warn("failed to parse image URL", "path", r.URL.Path, "error", err, ) s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest) return nil, "", time.Time{}, false } // Parse signature params from query string. r.URL.Query() would silently // drop a pair it cannot decode, such as q=80%, so that q would be served // at 85; a query string that cannot be decoded is refused instead. A // parameter given more than once is refused too, as only its first value // would be read. query, err := url.ParseQuery(r.URL.RawQuery) if err != nil { s.respondError(w, fmt.Sprintf("invalid query string %q: %v", r.URL.RawQuery, err), http.StatusBadRequest) return nil, "", time.Time{}, false } for name, values := range query { if len(values) > 1 { s.respondError(w, fmt.Sprintf("invalid %s: given more than once", name), http.StatusBadRequest) return nil, "", time.Time{}, false } } expires, err := parseExpires(query) if err != nil { s.respondError(w, err.Error(), http.StatusBadRequest) return nil, "", time.Time{}, false } // Parse optional quality and fit params. Only a q missing from the URL is // 85. A q in the URL that is not a whole number from 1 to 100, an empty // one included, is refused, checked as the generator checks its quality // field; that check alone would take an empty q as missing. qStr := query.Get("q") if query.Has("q") && qStr == "" { s.respondError(w, `invalid q: not a number, got ""`, http.StatusBadRequest) return nil, "", time.Time{}, false } req.Transform.Quality, err = parseFormInt(query, "q", encurl.DefaultQuality, minQuality, maxQuality) if err != nil { s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr), http.StatusBadRequest) return nil, "", time.Time{}, false } // Only a fit missing from the URL is cover. A fit in the URL that is not a // fit mode is refused by the fit-mode check below; that check would take an // empty fit as missing, so an empty one is refused here. req.Transform.FitMode = imagerequest.Fit(query.Get("fit")) if query.Has("fit") && req.Transform.FitMode == "" { s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest) return nil, "", time.Time{}, false } // Default fit mode if not set if req.Transform.FitMode == "" { req.Transform.FitMode = imagerequest.FitCover } // Enforce dimension and fit-mode bounds, shared with the encrypted-URL // route. Dimensions are already bounded by the path parser above; this // also rejects an unrecognized fit mode with 400 instead of letting it // reach the processor as a 500. err = imagerequest.ValidateImageRequest(req) if err != nil { s.respondError(w, "invalid image request: "+err.Error(), http.StatusBadRequest) return nil, "", time.Time{}, false } return req, query.Get("sig"), expires, true } // validateSignature returns nil when the source host of req is on // allowlist_hosts and names no port. Otherwise it verifies sig, the URL's // signature of req, and expires, the URL's expiry. func (s *Handlers) validateSignature( req *imagerequest.Request, sig string, expires time.Time, ) error { // An allowlist entry names no port, so a host followed by ":", with or // without a port, needs a signature. Go's URL type reads the port as the // fetcher does: in an IPv6 address written without brackets, such as // 2001:db8::1:8443, it is what follows the last ":". This is checked here // and not in allowlist.IsAllowed, which also matches referer_blocklist, // where a Referer must be blocked whatever its port. Port and IsAllowed // read only the host of a URL. sourceURL := &url.URL{Host: req.Source.Host} hostHasPort := sourceURL.Port() != "" || strings.HasSuffix(sourceURL.Host, ":") if !hostHasPort && s.allowlist.IsAllowed(sourceURL) { return nil } // Signature required for non-allowed hosts return s.signer.Verify(req, sig, expires) } // parseExpires reads the exp query parameter, a Unix time in seconds. An exp // missing from the URL gives the zero time, which the signature check refuses // as a missing exp. An exp in the URL that is not a whole number, an empty // one included, is an error naming exp and the value. func parseExpires(query url.Values) (time.Time, error) { if !query.Has("exp") { return time.Time{}, nil } expStr := query.Get("exp") exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil { return time.Time{}, fmt.Errorf("%w exp: not a number, got %q", errInvalidFormField, expStr) } return time.Unix(exp, 0), nil } // HandleTooManyRequests answers 429 Too Many Requests with the JSON error // body the image routes send for their other errors. The image routes' rate // limit calls it for a request over the limit. func (s *Handlers) HandleTooManyRequests() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { s.respondError(w, "too many requests, try again later", http.StatusTooManyRequests) } } // respondImageError answers err, the image service's error for req, on both // image routes. It logs a 500 at error level, as pixa's own failure. It logs // the other answers at warn level, since the client, the upstream or a full // limit caused them. The 415 for a source refused for its first bytes is not // logged here, because the image service logs it. func (s *Handlers) respondImageError( w http.ResponseWriter, req *imagerequest.Request, err error, ) { // An upstream body whose first bytes are not those of the format its // Content-Type names is the upstream's error, not pixa's: the image // service has logged it as a refused source. if errors.Is(err, magic.ErrMagicByteMismatch) || errors.Is(err, magic.ErrUnknownFormat) || errors.Is(err, magic.ErrNotEnoughData) { s.respondError(w, "unsupported source format", http.StatusUnsupportedMediaType) return } var ( status int message string ) switch { case errors.Is(err, httpfetcher.ErrSSRFBlocked): status, message = http.StatusForbidden, "forbidden" case errors.Is(err, httpfetcher.ErrUpstreamError): status, message = http.StatusBadGateway, "upstream error" case errors.Is(err, httpfetcher.ErrUpstreamTimeout): status, message = http.StatusGatewayTimeout, "upstream timeout" case errors.Is(err, httpfetcher.ErrTooManyConnections), errors.Is(err, imageprocessor.ErrTooManyImages), errors.Is(err, imageproxy.ErrProcessingTimedOut): status = http.StatusServiceUnavailable message = "server busy, try again later" case errors.Is(err, httpfetcher.ErrInvalidContentType), errors.Is(err, imageprocessor.ErrUnsupportedInputFormat): status = http.StatusUnsupportedMediaType message = "unsupported source format" case errors.Is(err, imageprocessor.ErrOutputTooLarge): status = http.StatusBadRequest message = fmt.Sprintf("size too large for the source: the side "+ "computed from its proportions is above %d", imageprocessor.MaxOutputDimension) case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge): status = http.StatusBadRequest message = fmt.Sprintf("size too large for AVIF: the output "+ "would be above %d pixels", imageprocessor.MaxAVIFOutputPixels) default: s.log.Error("failed to get image", "host", req.Source.Host, "path", req.Source.Path, "error", err, ) s.respondError(w, "internal error", http.StatusInternalServerError) return } s.log.Warn("failed to get image", "host", req.Source.Host, "path", req.Source.Path, "error", err, ) s.respondError(w, message, status) } // cacheControl returns the Cache-Control header for an image served through a // URL that expires at expires, or never when expires is the zero time. A cache // may keep the image for a year, but not past the URL's expiry, after which // pixa refuses the URL. The seconds left are rounded down and never negative. // immutable only stops revalidation while the image is fresh, so it also ends // at the expiry. func cacheControl(expires time.Time) string { const oneYear = 365 * 24 * time.Hour maxAge := oneYear if !expires.IsZero() { maxAge = min(maxAge, max(time.Until(expires), 0)) } return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second)) } // refuseBlockedReferer answers 403 with a JSON error when the request's Referer // names a host on referer_blocklist, and reports whether it answered. A request // with no Referer, or one that does not parse as a URL with a host, is not // refused. func (s *Handlers) refuseBlockedReferer( w http.ResponseWriter, r *http.Request, ) bool { referer, err := url.Parse(r.Referer()) if err != nil || !s.refererBlocklist.IsAllowed(referer) { return false } s.respondError(w, "referer blocked", http.StatusForbidden) return true } // notModified sets the ETag header to etag and, when the request's // If-None-Match is that ETag, answers 304 Not Modified. It reports whether it // answered. An empty etag sets no header and never answers. func notModified(w http.ResponseWriter, r *http.Request, etag string) bool { if etag == "" { return false } w.Header().Set("ETag", etag) if r.Header.Get("If-None-Match") != etag { return false } w.WriteHeader(http.StatusNotModified) return true } // serveImage serves the image that req asks for. Both image routes call it // once they have the request and its URL's expiry, which ends the // Cache-Control max-age. It chooses the format for the format auto, answers // If-None-Match with 304 and HEAD with the headers only. It answers 499 when // the client goes away while waiting for an image another request is making. // It answers any other error of the image service with respondImageError. func (s *Handlers) serveImage( w http.ResponseWriter, r *http.Request, req *imagerequest.Request, expires time.Time, ) { // The signature or the token covers the format auto, not the format chosen format, ok := s.chooseAutoFormat(w, r, req.Transform.Format) if !ok { return } req.Transform.Format = format startTime := time.Now() resp, err := s.imgSvc.Get(r.Context(), req) // The client went away before its image was ready, which is not pixa's // failure. A cancellation while the request's context is live is pixa's, // and respondImageError answers it 500. if errors.Is(err, context.Canceled) && r.Context().Err() != nil { s.log.Info("client went away before the image was ready", "request_id", middleware.GetReqID(r.Context()), "host", req.Source.Host, "path", req.Source.Path, ) w.WriteHeader(statusClientClosedRequest) return } if err != nil { 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", cacheControl(expires)) w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) if notModified(w, r, resp.ETag) { return } // Handle HEAD request - return headers only if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) return } // Stream the response w.WriteHeader(http.StatusOK) // A failed write is most often a client that has gone away, not pixa's // failure, so it is logged at warn level servedBytes, err := io.Copy(w, resp.Content) if err != nil { s.log.Warn("failed to write response", "request_id", middleware.GetReqID(r.Context()), "host", req.Source.Host, "path", req.Source.Path, "served_bytes", servedBytes, "error", err, ) return } // Log cache status and timing after serving duration := time.Since(startTime) s.log.Info("image served", "request_id", middleware.GetReqID(r.Context()), "cache_key", imagerequest.CacheKey(req), "host", req.Source.Host, "path", req.Source.Path, "cache_status", resp.CacheStatus, "duration_ms", duration.Milliseconds(), "format", req.Transform.Format, "served_bytes", servedBytes, "fetched_bytes", resp.FetchedBytes, ) }