package handlers import ( "errors" "fmt" "mime" "net/http" "strconv" "strings" "sneak.berlin/go/pixa/internal/imgcache" ) // Errors for an Accept header that an auto URL cannot be served for. var ( errInvalidAccept = errors.New("invalid Accept header") errNotAcceptable = errors.New( "not acceptable: auto serves image/avif, image/webp or image/jpeg") ) // chooseAutoFormat replaces the format auto in req with the format // formatForAccept chooses from r's Accept header, and adds Vary: Accept to the // response, which then depends on that header. It answers 400 for an Accept // header that is not valid and 406 for one that allows none of the formats, // and reports whether req can be served. Any other format is left as it is. func (s *Handlers) chooseAutoFormat( w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest, ) bool { if req.Format != imgcache.FormatAuto { return true } w.Header().Add("Vary", "Accept") format, err := formatForAccept(strings.Join(r.Header.Values("Accept"), ",")) if errors.Is(err, errNotAcceptable) { s.respondError(w, err.Error(), http.StatusNotAcceptable) return false } if err != nil { s.respondError(w, err.Error(), http.StatusBadRequest) return false } req.Format = format return true } // formatForAccept returns the format an auto URL is served in for the Accept // header accept: AVIF when it names image/avif, else WebP when it names // image/webp, else JPEG when its most specific entry of image/jpeg, image/* // and */* allows it, or when it names nothing. A q of 0 refuses a format. // AVIF and WebP must be named, as clients that cannot show them send image/* // and */* too. func formatForAccept(accept string) (imgcache.ImageFormat, error) { qualities, err := parseAccept(accept) if err != nil { return "", err } if len(qualities) == 0 { return imgcache.FormatJPEG, nil } if qualities["image/avif"] > 0 { return imgcache.FormatAVIF, nil } if qualities["image/webp"] > 0 { return imgcache.FormatWebP, nil } // For JPEG, the most specific entry the header has decides quality, named := qualities["image/jpeg"] if !named { quality, named = qualities["image/*"] } if !named { quality = qualities["*/*"] } if quality > 0 { return imgcache.FormatJPEG, nil } return "", errNotAcceptable } // parseAccept returns the q of each media range the Accept header accept // names, 1 where it gives none. A media range named more than once keeps its // lowest q, so a refusal is never overridden. A media range that does not // parse, or a q that is not a number from 0 to 1, is an error. func parseAccept(accept string) (map[string]float64, error) { qualities := make(map[string]float64) for entry := range strings.SplitSeq(accept, ",") { // A header field list may hold empty entries if strings.TrimSpace(entry) == "" { continue } mediaRange, params, err := mime.ParseMediaType(entry) if err != nil { return nil, fmt.Errorf("%w: %q: %w", errInvalidAccept, entry, err) } quality := 1.0 if qParam, given := params["q"]; given { quality, err = strconv.ParseFloat(qParam, 64) inRange := quality >= 0 && quality <= 1 if err != nil || !inRange { return nil, fmt.Errorf("%w: %q: q is not a number from 0 to 1", errInvalidAccept, entry) } } previous, named := qualities[mediaRange] if !named || quality < previous { qualities[mediaRange] = quality } } return qualities, nil }