183 lines
5.3 KiB
Go
183 lines
5.3 KiB
Go
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
|
|
}
|