package staticimages import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/image" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" "io" "net/http" "path" "strconv" "strings" "time" "github.com/go-chi/chi/v5" ) const cacheControl = "public, no-cache" const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox" const fallbackContentType = "application/octet-stream" const unknownVariantCode = "unknown_variant" const unsupportedParameterCode = "unsupported_parameter" var contentTypeExtensions = map[string]string{ ".png": "image/png", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml", ".gif": "image/gif", } type Handler struct { openMedia *useCases.OpenMediaUseCase serveVariant *useCases.ServeVariantUseCase modTime time.Time } func NewHandler(openMedia *useCases.OpenMediaUseCase, serveVariant *useCases.ServeVariantUseCase) *Handler { return &Handler{ openMedia: openMedia, serveVariant: serveVariant, modTime: time.Now(), } } type requestErrorBody struct { Status int `json:"status"` Error string `json:"error"` Message string `json:"message"` Detail string `json:"detail"` } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") cleaned := cleanPath(requestedPath(r)) served, err := h.serveVariant.Serve(variantRequestFrom(r, cleaned)) if err != nil { writeServeError(w, r, err) return } switch { case served.NotModified: writeNotModified(w, served) case served.Original: h.serveOriginal(w, r, cleaned) default: serveVariantFile(w, r, cleaned, served) } } func variantRequestFrom(r *http.Request, storedName string) useCases.ServeVariantRequest { query := r.URL.Query() request := useCases.ServeVariantRequest{Context: r.Context(), StoredName: storedName, IfNoneMatch: r.Header.Get("If-None-Match")} for parameter := range query { request.Parameters = append(request.Parameters, parameter) } if query.Has(image.VariantQueryParameter) { name := query.Get(image.VariantQueryParameter) request.Variant = &name } return request } func writeServeError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, image.ErrUnknownVariant): writeRequestError(w, unknownVariantCode, apperr.VariantUnknownMessage, err) case errors.Is(err, image.ErrUnsupportedParameter): writeRequestError(w, unsupportedParameterCode, apperr.VariantUnsupportedParameterMessage, err) case errors.Is(err, image.ErrBlobNotFound), errors.Is(err, image.ErrImageNotFound): http.NotFound(w, r) case errors.Is(err, image.ErrSourceTooLarge): http.Error(w, apperr.VariantSourceTooLargeErrorMessage, http.StatusRequestEntityTooLarge) default: http.Error(w, "Unable to read the file", http.StatusInternalServerError) } } func writeRequestError(w http.ResponseWriter, code string, message string, err error) { body, marshalErr := json.Marshal(requestErrorBody{ Status: http.StatusBadRequest, Error: code, Message: message, Detail: image.VariantRequestDetail(err), }) if marshalErr != nil { http.Error(w, "Unable to read the file", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write(body) } func writeNotModified(w http.ResponseWriter, served useCases.ServedVariant) { w.Header().Set("ETag", served.ETag) w.Header().Set("Cache-Control", cacheControl) w.WriteHeader(http.StatusNotModified) } func serveVariantFile(w http.ResponseWriter, r *http.Request, name string, served useCases.ServedVariant) { defer served.Content.Close() w.Header().Set("ETag", served.ETag) serveContent(w, r, name, served.ContentType, image.Blob{Content: served.Content, Size: served.Size, ModTime: served.ModTime}) } func (h *Handler) serveOriginal(w http.ResponseWriter, r *http.Request, name string) { blob, err := h.openMedia.Open(name) if errors.Is(err, image.ErrBlobNotFound) { http.NotFound(w, r) return } if err != nil { http.Error(w, "Unable to read the file", http.StatusInternalServerError) return } defer blob.Content.Close() serveContent(w, r, name, contentTypeFor(name), blob) } func serveContent(w http.ResponseWriter, r *http.Request, name string, contentType string, blob image.Blob) { w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", cacheControl) if contentType == "image/svg+xml" { w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy) } if content, ok := blob.Content.(io.ReadSeeker); ok { http.ServeContent(w, r, name, blob.ModTime, content) return } w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10)) if !blob.ModTime.IsZero() { w.Header().Set("Last-Modified", blob.ModTime.UTC().Format(http.TimeFormat)) } w.WriteHeader(http.StatusOK) if r.Method != http.MethodHead { _, _ = io.Copy(w, blob.Content) } } func requestedPath(r *http.Request) string { if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { return rctx.RoutePath } return r.URL.Path } func cleanPath(p string) string { return strings.TrimPrefix(path.Clean("/"+p), "/") } func contentTypeFor(p string) string { ext := path.Ext(p) if contentType, ok := contentTypeExtensions[ext]; ok { return contentType } return fallbackContentType }