feat: image variants engine
This commit is contained in:
1 parent
f0845de643
commit
f1d56ef50e
80 files changed
+7312
-84
No files matched your search
@@ -13,6 +13,7 @@ import (
|
||||
"RenewCMS/internal/infrastructure/auditing"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/hashing"
|
||||
"RenewCMS/internal/infrastructure/imaging"
|
||||
"RenewCMS/internal/infrastructure/mailer"
|
||||
"RenewCMS/internal/infrastructure/markdown"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
@@ -87,6 +88,13 @@ type UseCases struct {
|
||||
ExportActivityUseCase *useCases.ExportActivityUseCase
|
||||
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
|
||||
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
|
||||
ListVariantsUseCase *useCases.ListVariantsUseCase
|
||||
CreateVariantUseCase *useCases.CreateVariantUseCase
|
||||
UpdateVariantUseCase *useCases.UpdateVariantUseCase
|
||||
SetVariantEnabledUseCase *useCases.SetVariantEnabledUseCase
|
||||
DeleteVariantUseCase *useCases.DeleteVariantUseCase
|
||||
PurgeVariantUseCase *useCases.PurgeVariantUseCase
|
||||
ServeVariantUseCase *useCases.ServeVariantUseCase
|
||||
}
|
||||
|
||||
func (c *UseCases) RunSchedulers(ctx context.Context) {
|
||||
@@ -108,6 +116,9 @@ type Repositories struct {
|
||||
ArticleRepo domainArticle.Repository
|
||||
UserRepo domainUser.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
VariantRepo domainImage.VariantRepository
|
||||
VariantFileRepo domainImage.VariantFileRepository
|
||||
VariantRenderer domainImage.VariantRenderer
|
||||
InviteRepo domainInvite.Repository
|
||||
APIKeyRepo domainAPIKey.Repository
|
||||
PurgeSettingsRepo domainPurge.Repository
|
||||
@@ -240,6 +251,9 @@ func getRepositories(connection *database.Connection, settings config.Settings)
|
||||
ArticleRepo: persistence.NewArticleRepository(db),
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(db),
|
||||
VariantRepo: persistence.NewVariantRepository(db),
|
||||
VariantFileRepo: persistence.NewVariantFileRepository(db),
|
||||
VariantRenderer: imaging.NewVariantRenderer(),
|
||||
InviteRepo: persistence.NewInviteRepository(db),
|
||||
APIKeyRepo: persistence.NewAPIKeyRepository(db),
|
||||
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
|
||||
@@ -265,7 +279,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, recorder)
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder)
|
||||
|
||||
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
|
||||
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
|
||||
@@ -300,7 +314,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
|
||||
@@ -322,6 +336,13 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
|
||||
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
|
||||
ListVariantsUseCase: useCases.NewListVariantsUseCase(repos.VariantRepo, repos.VariantFileRepo),
|
||||
CreateVariantUseCase: useCases.NewCreateVariantUseCase(repos.VariantRepo, repos.VariantRenderer, time.Now, recorder),
|
||||
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, repos.VariantRenderer, time.Now, recorder),
|
||||
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(repos.VariantRepo, repos.VariantFileRepo, time.Now, recorder),
|
||||
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
|
||||
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, imageStorage, recorder),
|
||||
ServeVariantUseCase: useCases.NewServeVariantUseCase(repos.VariantRepo, repos.VariantFileRepo, repos.ImageRepo, imageStorage, repos.VariantRenderer, useCases.ServeVariantLimits{MaxSourceBytes: settings.ImageVariantMaxSourceBytes, MaxSourcePixels: settings.ImageVariantMaxSourcePixels, MaxConcurrentRenders: settings.ImageVariantMaxConcurrentRenders}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package imagevariant
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validatorlib "github.com/go-playground/validator/v10"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
list *useCases.ListVariantsUseCase
|
||||
create *useCases.CreateVariantUseCase
|
||||
update *useCases.UpdateVariantUseCase
|
||||
setEnabled *useCases.SetVariantEnabledUseCase
|
||||
remove *useCases.DeleteVariantUseCase
|
||||
purge *useCases.PurgeVariantUseCase
|
||||
}
|
||||
|
||||
func NewHandler(list *useCases.ListVariantsUseCase, create *useCases.CreateVariantUseCase, update *useCases.UpdateVariantUseCase, setEnabled *useCases.SetVariantEnabledUseCase, remove *useCases.DeleteVariantUseCase, purge *useCases.PurgeVariantUseCase) *Handler {
|
||||
return &Handler{list, create, update, setEnabled, remove, purge}
|
||||
}
|
||||
|
||||
type definitionRequest struct {
|
||||
Width *int `json:"width" validate:"required_without=Height,omitempty,min=1,max=8000"`
|
||||
Height *int `json:"height" validate:"required_without=Width,omitempty,min=1,max=8000"`
|
||||
Fit string `json:"fit" validate:"omitempty,oneof=cover contain inside"`
|
||||
Format string `json:"format" validate:"omitempty,oneof=original jpeg png webp"`
|
||||
Quality *int `json:"quality" validate:"omitempty,min=1,max=100"`
|
||||
}
|
||||
|
||||
type switchesRequest struct {
|
||||
GenerateOnUpload *bool `json:"generate_on_upload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
Name string `json:"name" validate:"required,variantname"`
|
||||
definitionRequest
|
||||
switchesRequest
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
definitionRequest
|
||||
switchesRequest
|
||||
}
|
||||
|
||||
type enabledRequest struct {
|
||||
Enabled *bool `json:"enabled" validate:"required"`
|
||||
}
|
||||
|
||||
type variantResponse struct {
|
||||
Name string `json:"name"`
|
||||
Width *int `json:"width"`
|
||||
Height *int `json:"height"`
|
||||
Fit image.VariantFit `json:"fit"`
|
||||
Format image.VariantFormat `json:"format"`
|
||||
Quality *int `json:"quality"`
|
||||
GenerateOnUpload bool `json:"generate_on_upload"`
|
||||
Enabled bool `json:"enabled"`
|
||||
GeneratedFiles int `json:"generated_files"`
|
||||
CacheSize int64 `json:"cache_size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type updatedVariantResponse struct {
|
||||
variantResponse
|
||||
PurgedFiles int `json:"purged_files"`
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
Items []variantResponse `json:"items"`
|
||||
}
|
||||
|
||||
type purgeResponse struct {
|
||||
PurgedFiles int `json:"purged_files"`
|
||||
PurgedBytes int64 `json:"purged_bytes"`
|
||||
}
|
||||
|
||||
func responseOf(summary useCases.VariantSummary) variantResponse {
|
||||
variant := summary.Variant
|
||||
return variantResponse{
|
||||
Name: variant.Name,
|
||||
Width: variant.Width,
|
||||
Height: variant.Height,
|
||||
Fit: variant.Fit,
|
||||
Format: variant.Format,
|
||||
Quality: variant.Quality,
|
||||
GenerateOnUpload: variant.GenerateOnUpload,
|
||||
Enabled: variant.Enabled,
|
||||
GeneratedFiles: summary.GeneratedFiles,
|
||||
CacheSize: summary.CacheSize,
|
||||
CreatedAt: variant.CreatedAt,
|
||||
UpdatedAt: variant.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
|
||||
summaries, err := h.list.List()
|
||||
if err != nil {
|
||||
h.writeError(w, "", err)
|
||||
return
|
||||
}
|
||||
items := make([]variantResponse, 0, len(summaries))
|
||||
for _, summary := range summaries {
|
||||
items = append(items, responseOf(summary))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listResponse{items})
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var request createRequest
|
||||
if !h.decode(w, r, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.create.Create(r.Context(), useCases.CreateVariantCommand{
|
||||
Name: request.Name,
|
||||
VariantDefinitionCommand: request.definitionRequest.command(),
|
||||
GenerateOnUpload: request.GenerateOnUpload,
|
||||
Enabled: request.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeError(w, request.Name, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, responseOf(summary))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var request updateRequest
|
||||
if !h.decode(w, r, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
name := chi.URLParam(r, "name")
|
||||
result, err := h.update.Update(r.Context(), useCases.UpdateVariantCommand{
|
||||
Name: name,
|
||||
VariantDefinitionCommand: request.definitionRequest.command(),
|
||||
GenerateOnUpload: request.GenerateOnUpload,
|
||||
Enabled: request.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeError(w, name, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updatedVariantResponse{responseOf(result.VariantSummary), result.PurgedFiles})
|
||||
}
|
||||
|
||||
func (h *Handler) SetEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var request enabledRequest
|
||||
if !h.decode(w, r, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
name := chi.URLParam(r, "name")
|
||||
summary, err := h.setEnabled.SetEnabled(r.Context(), name, *request.Enabled)
|
||||
if err != nil {
|
||||
h.writeError(w, name, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, responseOf(summary))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if err := h.remove.Delete(r.Context(), name); err != nil {
|
||||
h.writeError(w, name, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Purge(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
purged, err := h.purge.Purge(r.Context(), name)
|
||||
if err != nil {
|
||||
h.writeError(w, name, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, purgeResponse{purged.Files, purged.Bytes})
|
||||
}
|
||||
|
||||
func (d definitionRequest) command() useCases.VariantDefinitionCommand {
|
||||
return useCases.VariantDefinitionCommand{Width: d.Width, Height: d.Height, Fit: d.Fit, Format: d.Format, Quality: d.Quality}
|
||||
}
|
||||
|
||||
func (h *Handler) decode(w http.ResponseWriter, r *http.Request, request any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if err := validator.Validate.Struct(request); err != nil {
|
||||
http.Error(w, h.validationMessage(err), http.StatusUnprocessableEntity)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) validationMessage(err error) string {
|
||||
var failures validatorlib.ValidationErrors
|
||||
if !errors.As(err, &failures) || len(failures) == 0 {
|
||||
return apperr.VariantInvalidErrorMessage
|
||||
}
|
||||
switch failures[0].StructField() {
|
||||
case "Name":
|
||||
return apperr.VariantNameErrorMessage
|
||||
case "Width", "Height":
|
||||
return apperr.VariantDimensionsErrorMessage
|
||||
case "Fit":
|
||||
return apperr.VariantFitErrorMessage
|
||||
case "Format":
|
||||
return h.formatMessage()
|
||||
case "Quality":
|
||||
return apperr.VariantQualityErrorMessage
|
||||
default:
|
||||
return apperr.VariantInvalidErrorMessage
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) formatMessage() string {
|
||||
encodable := h.create.EncodableFormats()
|
||||
names := make([]string, 0, len(encodable))
|
||||
for _, format := range encodable {
|
||||
names = append(names, string(format))
|
||||
}
|
||||
last := len(names) - 1
|
||||
return fmt.Sprintf(apperr.VariantFormatErrorMessageFormat, strings.Join(names[:last], ", ")+" or "+names[last])
|
||||
}
|
||||
|
||||
func (h *Handler) writeError(w http.ResponseWriter, name string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, image.ErrVariantNotFound):
|
||||
http.Error(w, apperr.VariantNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, image.ErrVariantExists):
|
||||
http.Error(w, fmt.Sprintf(apperr.VariantExistsErrorMessageFormat, name), http.StatusConflict)
|
||||
case errors.Is(err, image.ErrVariantFormatUnavailable):
|
||||
http.Error(w, h.formatMessage(), http.StatusUnprocessableEntity)
|
||||
case errors.Is(err, image.ErrVariantInvalid):
|
||||
http.Error(w, apperr.VariantInvalidErrorMessage, http.StatusUnprocessableEntity)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package staticimages
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -17,31 +19,115 @@ import (
|
||||
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
|
||||
modTime time.Time
|
||||
openMedia *useCases.OpenMediaUseCase
|
||||
serveVariant *useCases.ServeVariantUseCase
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler {
|
||||
func NewHandler(openMedia *useCases.OpenMediaUseCase, serveVariant *useCases.ServeVariantUseCase) *Handler {
|
||||
return &Handler{
|
||||
openMedia: openMedia,
|
||||
modTime: time.Now(),
|
||||
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))
|
||||
blob, err := h.openMedia.Open(cleaned)
|
||||
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
|
||||
@@ -52,7 +138,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer blob.Content.Close()
|
||||
|
||||
contentType := contentTypeFor(cleaned)
|
||||
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" {
|
||||
@@ -60,7 +149,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if content, ok := blob.Content.(io.ReadSeeker); ok {
|
||||
http.ServeContent(w, r, cleaned, blob.ModTime, content)
|
||||
http.ServeContent(w, r, name, blob.ModTime, content)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
|
||||
|
||||
@@ -42,3 +42,14 @@ const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
|
||||
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
const PurgeBusyErrorMessage = "A purge is already running."
|
||||
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
|
||||
const VariantNotFoundErrorMessage = "Variant not found"
|
||||
const VariantNameErrorMessage = "Use lowercase letters, numbers and dashes, 2 to 32 characters."
|
||||
const VariantDimensionsErrorMessage = "Width and height must be between 1 and 8000, and one of them is required."
|
||||
const VariantFitErrorMessage = "Fit must be cover, contain or inside."
|
||||
const VariantQualityErrorMessage = "Quality must be between 1 and 100."
|
||||
const VariantInvalidErrorMessage = "The variant is not valid."
|
||||
const VariantExistsErrorMessageFormat = "A variant named “%s” already exists. Choose another name."
|
||||
const VariantFormatErrorMessageFormat = "Format must be %s."
|
||||
const VariantSourceTooLargeErrorMessage = "The source image is too large to generate this variant."
|
||||
const VariantUnknownMessage = "Unknown variant"
|
||||
const VariantUnsupportedParameterMessage = "Unsupported parameter"
|
||||
@@ -1,12 +1,14 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
const inviteEmailTag = "inviteemail"
|
||||
const variantNameTag = "variantname"
|
||||
|
||||
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
|
||||
|
||||
@@ -15,6 +17,11 @@ var Validate *validator.Validate
|
||||
func InitValidator() {
|
||||
Validate = validator.New()
|
||||
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail)
|
||||
_ = Validate.RegisterValidation(variantNameTag, isVariantName)
|
||||
}
|
||||
|
||||
func isVariantName(field validator.FieldLevel) bool {
|
||||
return image.IsValidVariantName(field.Field().String())
|
||||
}
|
||||
|
||||
func isInviteEmail(field validator.FieldLevel) bool {
|
||||
|
||||
+24
-1
@@ -6,6 +6,7 @@ import (
|
||||
"RenewCMS/api/handlers/article"
|
||||
"RenewCMS/api/handlers/auth"
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
"RenewCMS/api/handlers/imagevariant"
|
||||
"RenewCMS/api/handlers/media"
|
||||
"RenewCMS/api/handlers/settings"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
@@ -273,6 +274,27 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase)
|
||||
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
|
||||
r.Use(middleware.RequireSignedIn)
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
|
||||
r.Get("/", variantHandler.List)
|
||||
r.Post("/", variantHandler.Create)
|
||||
r.Put("/{name}", variantHandler.Update)
|
||||
r.Patch("/{name}", variantHandler.SetEnabled)
|
||||
r.Delete("/{name}", variantHandler.Delete)
|
||||
r.Post("/{name}/purge", variantHandler.Purge)
|
||||
return r
|
||||
}
|
||||
|
||||
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -289,6 +311,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
|
||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
@@ -335,7 +358,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
apiRouter.Use(chimiddleware.GetHead)
|
||||
apiRouter.Get("/healthz", GetHealth)
|
||||
apiRouter.Mount("/v1", backend)
|
||||
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase))
|
||||
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
|
||||
apiRouter.Mount("/", frontend)
|
||||
|
||||
return apiRouter
|
||||
|
||||
Reference in new issue
Block a user