feat: image variants engine
This commit is contained in:
1 parent
f0845de643
commit
f1d56ef50e
80 files changed
+7312
-84
No files matched your search
@@ -16,6 +16,9 @@ AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
TRUSTED_PROXIES=
|
||||
UPDATE_FEED_URL=https://git.floriansylvain.fr
|
||||
RENEWCMS_CONTAINER=false
|
||||
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
|
||||
IMAGE_VARIANT_MAX_SOURCE_PIXELS=50000000
|
||||
IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=4
|
||||
DB_DRIVER=sqlite
|
||||
DATA_DIR=
|
||||
DB_HOST=
|
||||
|
||||
@@ -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
|
||||
|
||||
+22
-19
@@ -42,25 +42,28 @@ var requiredEnvVars = []string{
|
||||
}
|
||||
|
||||
var envDefaults = map[string]string{
|
||||
"ENVIRONMENT": "production",
|
||||
"PORT": "8080",
|
||||
"DB_FILE": "./data/renewcms.db",
|
||||
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
|
||||
"DB_TLS": config.DefaultDatabaseTLS,
|
||||
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
|
||||
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
|
||||
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
|
||||
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
|
||||
"UPLOAD_DIR": "./data/uploads",
|
||||
config.EnvStorageDriver: config.DefaultStorageDriver,
|
||||
"S3_REGION": config.DefaultS3Region,
|
||||
"S3_PATH_STYLE": config.DefaultS3PathStyle,
|
||||
"S3_TLS": config.DefaultS3TLS,
|
||||
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
|
||||
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
||||
"TRUSTED_PROXIES": "",
|
||||
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
||||
"RENEWCMS_CONTAINER": "false",
|
||||
"ENVIRONMENT": "production",
|
||||
"PORT": "8080",
|
||||
"DB_FILE": "./data/renewcms.db",
|
||||
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
|
||||
"DB_TLS": config.DefaultDatabaseTLS,
|
||||
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
|
||||
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
|
||||
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
|
||||
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
|
||||
"UPLOAD_DIR": "./data/uploads",
|
||||
config.EnvStorageDriver: config.DefaultStorageDriver,
|
||||
"S3_REGION": config.DefaultS3Region,
|
||||
"S3_PATH_STYLE": config.DefaultS3PathStyle,
|
||||
"S3_TLS": config.DefaultS3TLS,
|
||||
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
|
||||
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
||||
"TRUSTED_PROXIES": "",
|
||||
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
||||
"RENEWCMS_CONTAINER": "false",
|
||||
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
|
||||
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
|
||||
config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders,
|
||||
}
|
||||
|
||||
const versionFlag = "--version"
|
||||
|
||||
+29
-3
@@ -62,12 +62,13 @@ never changes the response or the change itself.
|
||||
| Category | Actions |
|
||||
| --- | --- |
|
||||
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
|
||||
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge` |
|
||||
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge` |
|
||||
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
|
||||
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
|
||||
|
||||
Target types: `article`, `image`, `user`, `invite`, `api_key` and `release` (id and name are the tag).
|
||||
Only the fields `title`, `online`, `body`, `filename` and `display_name` appear in `changes`, with the
|
||||
Target types: `article`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
|
||||
`api_key` and `release` (id and name are the tag).
|
||||
Only the fields `title`, `online`, `body`, `filename`, `display_name`, `width`, `height`, `fit`, `format` and `quality` appear in `changes`, with the
|
||||
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
|
||||
change that leaves a value as it was (same body, same publish state, same file name, same display
|
||||
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
|
||||
@@ -89,6 +90,31 @@ codes, article bodies, file bytes, storage paths and asset urls are never record
|
||||
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
|
||||
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`.
|
||||
|
||||
## Image variants
|
||||
Owners and admins manage named variants of the stored images. These routes are session only (no API
|
||||
key) and sit behind the verified-user check; any other role gets 403 "Only owners and admins can do
|
||||
this." Behaviour, caching and the public `?v=<name>` contract are in [image-variants.md](image-variants.md).
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /v1/image-variants` | `{ "items": [...] }`, ordered by name. |
|
||||
| `POST /v1/image-variants` | Create. 201 with the variant. 409 when the name exists. |
|
||||
| `PUT /v1/image-variants/{name}` | Replace the definition (the name in the body is ignored). 200 with the variant and `purged_files`. |
|
||||
| `PATCH /v1/image-variants/{name}` | `{ "enabled": true }` or `{ "enabled": false }`. Keeps the generated files. |
|
||||
| `DELETE /v1/image-variants/{name}` | 204. Removes the definition and its generated files. |
|
||||
| `POST /v1/image-variants/{name}/purge` | `{ "purged_files", "purged_bytes" }`. Keeps the definition. |
|
||||
|
||||
A variant is `{ name, width, height, fit, format, quality, generate_on_upload, enabled,
|
||||
generated_files, cache_size, created_at, updated_at }`. `width` and `height` are 1 to 8000 or `null`
|
||||
(at least one is required), `fit` is `cover`, `contain` or `inside` (default `cover`), `format` is
|
||||
`original`, `jpeg`, `png` or `webp` (default `original`), `quality` is 1 to 100 (default 82, `null`
|
||||
for `png`), `generate_on_upload` defaults to `false`, `enabled` to `true`, and `cache_size` is in
|
||||
bytes. A `PUT` that omits `generate_on_upload` or `enabled` keeps the stored value.
|
||||
|
||||
Errors are plain text: 400 for a body that is not valid JSON, 404 "Variant not found", 409 "A variant
|
||||
named “thumb” already exists. Choose another name." and 422 with one sentence for the first invalid
|
||||
field (name, width and height, fit, format, quality).
|
||||
|
||||
## Errors
|
||||
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
|
||||
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
|
||||
|
||||
@@ -69,6 +69,12 @@ The use cases `ListActivity`, `GetActivitySummary` and `ExportActivity` turn the
|
||||
|
||||
Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/audit` build the change lists (the body change holds only a signed rune count delta), and `NewTarget` and `ReleaseTarget` build the target snapshots (types `article`, `image`, `user`, `invite`, `api_key`, `release`). The use cases create, edit, publish, unpublish and delete article, upload, rename, delete, merge and purge media, register, verify, update profile, create, cancel, renew and accept invite, check, download and restart take the request context first and an `audit.Recorder` last, and record exactly one event after the change or the refusal (the action catalogue is in `docs/api.md`). Events are never written by handlers, middleware or GORM hooks, and nothing is recorded for reads or for a target that does not exist. `useCases/auditFailures.go` holds the fixed table of failure sentences keyed by domain `Err...` sentinels (matched with `errors.Is`, each at most 255 characters; the restart action overrides `ErrJobInProgress`) with the generic fallback "Something went wrong on our side. Please try again."; raw error text is never stored. The manual purge runs the groups through the non-recording `MergeMediaUseCase.merge`, so only one `media.purge` event is written; `RunPurgeUseCase.Execute` is the non-recording run that `PurgeSchedulerUseCase.Tick(now)` uses to record one `system.media_purge` event with the system actor and `context.Background()`. `StartUpdateDownloadUseCase` hands `context.WithoutCancel(ctx)` to its job goroutine so the `system.update_downloaded` event, written when the job becomes `ready` or `failed`, keeps the origin of the request. `middleware.AuditContext` is mounted on the article, user, media and system routers (after the API key middleware where keys are accepted), so API key calls are attributed to the key.
|
||||
|
||||
### Image variants
|
||||
|
||||
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
|
||||
|
||||
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under a `singleflight` keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
|
||||
|
||||
### Image metadata backfill
|
||||
|
||||
At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
|
||||
|
||||
@@ -34,6 +34,9 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
|
||||
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
|
||||
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
|
||||
| `IMAGE_VARIANT_MAX_SOURCE_BYTES` | `10485760` | Largest stored image, in bytes, that an [image variant](image-variants.md) may be generated from. A request for a bigger source answers 413 with a plain-text body. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
|
||||
| `IMAGE_VARIANT_MAX_SOURCE_PIXELS` | `50000000` | Largest declared width x height, in pixels, that an [image variant](image-variants.md) may be generated from. It is read from the image header before any pixel buffer is allocated; a bigger source answers 413 with the same plain-text body as the byte cap. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
|
||||
| `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` | `4` | Number of [image variant](image-variants.md) renders that may run at once. Other requests wait for a free slot until their client goes away; cached files never wait. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
|
||||
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, in the data directory. |
|
||||
|
||||
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Image variants
|
||||
|
||||
An owner or admin defines named variants of the stored images. A request for
|
||||
`/static/uploadedImages/<storedName>?v=<name>` generates the variant on first use, caches it and
|
||||
serves the cached file afterwards. The original is never changed. Only a variant that exists and is
|
||||
enabled is served. The visual spec is `docs/design/screens/image-variants.md`; the management routes
|
||||
are in [api.md](api.md#image-variants); the layering is in [architecture.md](architecture.md#image-variants).
|
||||
|
||||
## Public contract
|
||||
|
||||
| Request | Result |
|
||||
| --- | --- |
|
||||
| `…/a1b2.png` | The stored file, exactly as before this feature. |
|
||||
| `…/a1b2.png?v=thumb` | 200, the cached variant. Other query parameters (`t=123`) are ignored. |
|
||||
| `?v=` with an unknown, disabled, deleted or badly written name | 400 `{"status":400,"error":"unknown_variant","message":"Unknown variant","detail":"<name>"}`. |
|
||||
| `width`, `height`, `format` or `quality` | 400 `unsupported_parameter` with the parameter as `detail`. It wins over `unknown_variant`. |
|
||||
| A missing source (also `..` and nested paths) | 404, after the parameter checks. |
|
||||
| A blob that has no image record (a generated variant file, an orphan) | 404 for `?v=`, after the parameter checks and before any storage read. Without `?v=` the blob is served as before. |
|
||||
| A source bigger than `IMAGE_VARIANT_MAX_SOURCE_BYTES` | 413 `The source image is too large to generate this variant.` (plain text). The original is never capped. |
|
||||
| A source whose header declares more than `IMAGE_VARIANT_MAX_SOURCE_PIXELS` (width x height) | The same 413 and body. The header is read before any pixel buffer is allocated, so a small file declaring 30000 x 30000 is refused without being decoded. |
|
||||
| A source that cannot be decoded, a header that cannot be read, or a failing render | 500, nothing is cached, the next request tries again. |
|
||||
|
||||
The 400 body is JSON with `Content-Type: application/json`. Every other error is plain text.
|
||||
|
||||
SVG sources and animated GIFs are served as they are, with the variant parameters validated but no
|
||||
generation, even above the size cap. A one-frame GIF is a normal raster source.
|
||||
|
||||
The response carries an `ETag` derived from the variant settings (width, height, fit, format, quality)
|
||||
and the source stored name. It is an opaque quoted value. `If-None-Match` answers 304 before the cache
|
||||
is read. Changing `generate_on_upload` or `enabled` does not change it; changing any rendering
|
||||
setting does, so a client holding an old value gets a fresh 200. `Cache-Control` stays
|
||||
`public, no-cache`.
|
||||
|
||||
## Cache
|
||||
|
||||
Every generated file is a blob in `image.Storage` (local directory or S3) plus a row in
|
||||
`image_variant_files` (variant, source stored name, cached stored name, content type, size, settings
|
||||
fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation
|
||||
(`singleflight` keyed by variant, settings and source); different variants or different images are
|
||||
generated in parallel.
|
||||
|
||||
Only a stored name that has an image record can be a variant source, so generated files and orphan blobs
|
||||
are never rendered. At most `IMAGE_VARIANT_MAX_CONCURRENT_RENDERS` (default 4) renders run at once:
|
||||
the slot is taken inside the shared generation, so identical concurrent requests use one slot, and a
|
||||
request waiting for a slot stops waiting when its client goes away, without rendering. A cached
|
||||
file never takes a slot. The pixel cap defaults to 50000000 pixels.
|
||||
|
||||
- Editing a rendering setting (`width`, `height`, `fit`, `format`, `quality`) purges the variant's
|
||||
files; the next request regenerates. Switching `generate_on_upload` or `enabled` keeps them, so
|
||||
re-enabling a variant serves from its kept cache.
|
||||
- A row whose fingerprint differs from the variant (an edit raced a generation) or whose blob is gone
|
||||
is regenerated and replaced, and the old blob is removed.
|
||||
- Deleting a variant, purging it, deleting a source image or merging copies removes the blobs first
|
||||
and the rows second. A blob that cannot be deleted keeps its row, the call answers 500 and a retry
|
||||
finishes the job. A blob that is already missing does not stop a purge.
|
||||
- `generate_on_upload` is stored and returned only. Generating at upload time is not part of this
|
||||
release.
|
||||
|
||||
## Geometry
|
||||
|
||||
Images are never enlarged.
|
||||
|
||||
- With one dimension, the image keeps its aspect ratio and the output is at most that size.
|
||||
- `inside` fits the whole image in the box, without padding.
|
||||
- `cover` fills the box and crops around the centre. A box larger than the source is first scaled
|
||||
down, keeping its aspect ratio, until it fits inside the source (300 x 200 into 400 x 300 gives
|
||||
267 x 200).
|
||||
- `contain` with both width and height scales the image to fit inside the box, never enlarging it, and
|
||||
centres it on a canvas of exactly width x height: a 300 x 200 image in a 400 x 300 box stays
|
||||
300 x 200 on a 400 x 300 canvas, and a 40 x 20 image in a 40 x 40 box is padded to 40 x 40. With a
|
||||
single dimension there is no box, so no padding and no upscaling.
|
||||
|
||||
### Padding colour of `contain`
|
||||
|
||||
The design does not draw it, so it is decided here: the padding is transparent for `png` and `webp`
|
||||
outputs and white (`#FFFFFF`) for `jpeg` outputs, which have no alpha. A `jpeg` output also flattens
|
||||
any transparency of the source onto white (`#FFFFFF`).
|
||||
|
||||
## Formats
|
||||
|
||||
`original` keeps the source format (a GIF source becomes PNG). `jpeg` and `webp` use `quality`
|
||||
(default 82); `png` ignores it and the API returns `quality: null`. A WebP source always decodes. The
|
||||
renderer reports the formats it can encode; the API refuses a format it cannot encode with
|
||||
`Format must be original, jpeg or png.`.
|
||||
|
||||
## Spike: a CGO-free lossy WebP encoder
|
||||
|
||||
The standard library and `golang.org/x/image` can decode WebP but not encode it, and the build must
|
||||
stay `CGO_ENABLED=0` (six release targets, pure-Go SQLite).
|
||||
|
||||
| Candidate | Lossy | `CGO_ENABLED=0` | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| `golang.org/x/image/webp` | decode only | yes | Cannot encode. |
|
||||
| `github.com/gen2brain/webp` v0.6.4 (libwebp translated to Go with wasm2go; `purego` only for the optional dynamic library) | yes, with alpha | yes | Adopted. |
|
||||
|
||||
The spike built `gen2brain/webp` for linux, darwin and windows on amd64 and arm64 with
|
||||
`CGO_ENABLED=0`, and `go list -deps` shows no package with cgo files. On a noisy 4032 x 3024 image
|
||||
a lossy encode took about 0.75 s at quality 20 and 1.5 s at quality 95 (a smaller, real photograph is
|
||||
faster). Output is `RIFF....WEBP`, and a lower quality gives a smaller file. Decision: `webp` is offered
|
||||
as an output format. Should the dependency ever be dropped, `NewVariantRendererEncoding` already
|
||||
builds a renderer without the WebP encoder: the API then refuses `webp` with 422, and a WebP source
|
||||
with format `original` is served as PNG.
|
||||
@@ -0,0 +1,778 @@
|
||||
Feature: Image variants are generated on first request, cached, and managed by admins
|
||||
|
||||
As an admin or owner of a RenewCMS site
|
||||
I want to define named variants of the original images and have them generated and cached on demand
|
||||
So that articles can ask for /static/uploadedImages/<storedName>?v=<name> and get a right-sized file,
|
||||
the original is never changed, and nothing but a defined, enabled variant can be requested
|
||||
|
||||
Background:
|
||||
Given the spec of record is docs/design/screens/image-variants.md sections 1, 2 and 7, its "Back-office API errors" table and section 8 for the activity rows
|
||||
And this slice is API only: no SPA code, no change to docs/design
|
||||
And it excludes the design's cache screen routes (GET /v1/image-variants/cache, POST /v1/image-variants/cache/purge), the per-image routes (/v1/media/{id}/variants...) and the media library variants filter and sort
|
||||
And internal/domain/image gains the entity Variant with the JSON fields name, width, height, fit, format, quality, generate_on_upload, enabled, created_at and updated_at in snake_case
|
||||
And Variant.Name is a slug of 2 to 32 characters among a-z, 0-9 and "-", unique and immutable after creation
|
||||
And Variant.Width and Variant.Height are 1 to 8000 pixels or absent (JSON null, "auto" in the design), and at least one is present
|
||||
And Variant.Fit is one of cover, contain, inside, and defaults to cover
|
||||
And Variant.Format is one of original, jpeg, png, webp, and defaults to original
|
||||
And Variant.Quality is 1 to 100 and defaults to 82; for format png it is not stored and is returned as null, and a quality sent with png is ignored
|
||||
And generate_on_upload defaults to false and enabled defaults to true
|
||||
And internal/domain/image defines the sentinels ErrVariantNotFound, ErrVariantExists, ErrVariantInvalid, ErrUnknownVariant, ErrUnsupportedParameter and ErrSourceTooLarge, asserted with errors.Is
|
||||
And internal/domain/image defines the port VariantRepository (variants CRUD) and the port VariantFileRepository, an index of generated files holding variant name, source stored name, cached stored name, content type, size in bytes and the settings fingerprint, because Storage.Put chooses the stored name itself
|
||||
And internal/domain/image defines the port VariantRenderer, implemented in internal/infrastructure and kept free of GORM and HTTP, which decodes a source and encodes a variant, and reports which output formats it can encode
|
||||
And generated bytes are stored only through image.Storage, are named by Storage.Put like any stored image, and are never reachable as library images
|
||||
And the use cases (create, update, set enabled, delete, purge, list variants, serve variant) depend only on domain interfaces
|
||||
And the variant routes are mounted at /v1/image-variants behind jwtauth.Verifier, middleware.AuditContext, jwtauth.Authenticator, middleware.RequireCurrentToken, middleware.RequireVerifiedUser and middleware.RequireOwnerOrAdmin, with no API key authentication
|
||||
And request bodies are validated with validate tags and validator.Validate.Struct, never by hand
|
||||
And successful responses are JSON with snake_case fields, and error responses on /v1 are plain text from http.Error, except the public 400 described below
|
||||
And a variant is returned as {"name", "width", "height", "fit", "format", "quality", "generate_on_upload", "enabled", "generated_files", "cache_size", "created_at", "updated_at"}, with cache_size in bytes
|
||||
And the variant list is ordered by name ascending
|
||||
And the contain padding colour is decided here: transparent for png and webp outputs, and white (#FFFFFF) for jpeg outputs, which carry no alpha
|
||||
And a jpeg output flattens any transparency of the source onto white (#FFFFFF)
|
||||
And a new env var IMAGE_VARIANT_MAX_SOURCE_BYTES, default 10485760, is listed in envDefaults in cmd/server/main.go (not in requiredEnvVars), in .env.example and in docs/configuration.md
|
||||
And a new env var IMAGE_VARIANT_MAX_SOURCE_PIXELS, default 50000000, caps the declared width x height of a source, read from the image header before any pixel buffer is allocated, and is listed in the same three places
|
||||
And a new env var IMAGE_VARIANT_MAX_CONCURRENT_RENDERS, default 4, bounds the renders running at once, with waiting requests queued until a slot frees or their context ends, and is listed in the same three places
|
||||
And contain always pads to the box when both width and height are given: the image is scaled to fit inside the box, never enlarged, and centred on a canvas of exactly width x height; with a single dimension there is no box and no padding
|
||||
And the first implementation task is a spike of a CGO-free lossy WebP encoder with CGO_ENABLED=0
|
||||
And the public serving path stays /static/uploadedImages/<storedName>, mounted in api/router.go, and is outside /v1 and outside any auth
|
||||
|
||||
Rule: Only owners and admins reach the variant routes
|
||||
|
||||
Scenario Outline: Every variant route is guarded
|
||||
When the client sends <method> <path> with <credentials>
|
||||
Then the response status is <status>
|
||||
And the plain text body is <body>
|
||||
And no variant, generated file or activity event is read or changed
|
||||
|
||||
Examples:
|
||||
| method | path | credentials | status | body |
|
||||
| GET | /v1/image-variants | no cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
|
||||
| GET | /v1/image-variants | the cookie of an unverified user| 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| GET | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/image-variants | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PUT | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| PATCH | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| DELETE | /v1/image-variants/hero | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| POST | /v1/image-variants/hero/purge | the cookie of a plain user | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
|
||||
| GET | /v1/image-variants | a valid API key | 401 | apperr.UnauthorizedErrorMessage |
|
||||
|
||||
Scenario Outline: Owners and admins are both allowed
|
||||
When <role> sends GET /v1/image-variants with a valid cookie
|
||||
Then the response status is 200
|
||||
|
||||
Examples:
|
||||
| role |
|
||||
| the owner |
|
||||
| an admin |
|
||||
|
||||
Rule: Listing
|
||||
|
||||
Scenario: An empty list is an empty array
|
||||
Given no variant exists
|
||||
When an admin sends GET /v1/image-variants
|
||||
Then the response status is 200
|
||||
And the body is {"items": []}, with items an empty array and not null in the raw JSON
|
||||
|
||||
Scenario: The list carries the generated count and cache size per variant
|
||||
Given the variants "hero" (cover 1200x630 webp 82, enabled) and "card" (inside 800 x auto jpeg 80, disabled)
|
||||
And the cache index holds 3 generated files of 1000, 2000 and 3000 bytes for "hero" and none for "card"
|
||||
When an admin sends GET /v1/image-variants
|
||||
Then "items" is ordered "card", "hero"
|
||||
And "card" has width 800, height null, fit "inside", format "jpeg", quality 80, enabled false, generated_files 0 and cache_size 0
|
||||
And "hero" has width 1200, height 630, fit "cover", format "webp", quality 82, enabled true, generated_files 3 and cache_size 6000
|
||||
|
||||
Scenario: A png variant lists a null quality
|
||||
Given the variant "logo" with format png
|
||||
When an admin sends GET /v1/image-variants
|
||||
Then "logo" has quality null
|
||||
|
||||
Rule: Creating a variant
|
||||
|
||||
Scenario: Creating a variant with every field
|
||||
When an admin sends POST /v1/image-variants with {"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}
|
||||
Then the response status is 201
|
||||
And the body is the variant with generated_files 0 and cache_size 0
|
||||
And exactly one variant "hero" is stored
|
||||
|
||||
Scenario: Omitted optional fields take their defaults
|
||||
When an admin sends POST /v1/image-variants with {"name": "thumb", "width": 400}
|
||||
Then the response status is 201
|
||||
And the body has width 400, height null, fit "cover", format "original", quality 82, generate_on_upload false and enabled true
|
||||
|
||||
Scenario Outline: A valid boundary value is accepted
|
||||
When an admin sends POST /v1/image-variants with <body>
|
||||
Then the response status is 201
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| {"name": "ab", "width": 1} |
|
||||
| {"name": "a-b-c", "height": 8000} |
|
||||
| {"name": "<32 characters a-z0-9->", "width": 8000, "height": 8000} |
|
||||
| {"name": "q1", "width": 10, "quality": 1} |
|
||||
| {"name": "q100", "width": 10, "quality": 100} |
|
||||
|
||||
Scenario Outline: An invalid field answers 422 with the field's sentence and stores nothing
|
||||
When an admin sends POST /v1/image-variants with <body>
|
||||
Then the response status is 422
|
||||
And the plain text body is "<message>"
|
||||
And no variant is stored and no activity event is recorded
|
||||
|
||||
Examples:
|
||||
| body | message |
|
||||
| {"name": "a", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
|
||||
| {"name": "<33 characters>", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
|
||||
| {"name": "Hero", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
|
||||
| {"name": "he ro", "width": 10} | Use lowercase letters, numbers and dashes, 2 to 32 characters. |
|
||||
| {"name": "hero"} | Width and height must be between 1 and 8000, and one of them is required. |
|
||||
| {"name": "hero", "width": 0} | Width and height must be between 1 and 8000, and one of them is required. |
|
||||
| {"name": "hero", "width": 8001} | Width and height must be between 1 and 8000, and one of them is required. |
|
||||
| {"name": "hero", "height": -1} | Width and height must be between 1 and 8000, and one of them is required. |
|
||||
| {"name": "hero", "width": 10, "fit": "stretch"} | Fit must be cover, contain or inside. |
|
||||
| {"name": "hero", "width": 10, "format": "avif"} | Format must be original, jpeg, png or webp. |
|
||||
| {"name": "hero", "width": 10, "quality": 0} | Quality must be between 1 and 100. |
|
||||
| {"name": "hero", "width": 10, "quality": 101} | Quality must be between 1 and 100. |
|
||||
|
||||
Scenario Outline: A malformed request answers 400
|
||||
When an admin sends POST /v1/image-variants with <body>
|
||||
Then the response status is 400
|
||||
And the plain text body is apperr.BodyErrorMessage
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| not json |
|
||||
| {"name": "hero", "width": "wide"} |
|
||||
| an empty body |
|
||||
|
||||
Scenario: A duplicate name answers 409 and keeps the existing variant
|
||||
Given the variant "thumb" 400 x 300 cover jpeg quality 80
|
||||
When an admin sends POST /v1/image-variants with {"name": "thumb", "width": 800, "format": "png"}
|
||||
Then the response status is 409
|
||||
And the plain text body is "A variant named “thumb” already exists. Choose another name."
|
||||
And the stored variant "thumb" is unchanged
|
||||
|
||||
Rule: Updating a variant
|
||||
|
||||
Background:
|
||||
Given the variant "hero" is 1200x630 cover webp quality 82, enabled, with generate_on_upload false
|
||||
And the cache index holds 244 generated files for "hero" (84 MB) and 3 generated files for "thumb"
|
||||
|
||||
Scenario: Changing quality updates the variant and purges its generated files
|
||||
When an admin sends PUT /v1/image-variants/hero with {"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70, "generate_on_upload": false, "enabled": true}
|
||||
Then the response status is 200
|
||||
And the body is the variant with quality 70, generated_files 0, cache_size 0 and "purged_files": 244
|
||||
And the 244 cached blobs of "hero" are deleted from image.Storage and their index rows are gone
|
||||
And the files of "thumb" are untouched
|
||||
|
||||
Scenario Outline: A change to a rendering setting purges
|
||||
When an admin sends PUT /v1/image-variants/hero changing <setting> to <value>, everything else as stored
|
||||
Then the response status is 200
|
||||
And "purged_files" is 244
|
||||
|
||||
Examples:
|
||||
| setting | value |
|
||||
| width | 1000 |
|
||||
| height | null |
|
||||
| fit | inside |
|
||||
| format | jpeg |
|
||||
| quality | 60 |
|
||||
|
||||
Scenario Outline: A change to a switch does not purge
|
||||
When an admin sends PUT /v1/image-variants/hero changing <setting> to <value>, everything else as stored
|
||||
Then the response status is 200
|
||||
And "purged_files" is 0 and generated_files is still 244
|
||||
And no blob is deleted
|
||||
|
||||
Examples:
|
||||
| setting | value |
|
||||
| generate_on_upload | true |
|
||||
| enabled | false |
|
||||
|
||||
Scenario: A PUT identical to the stored variant changes nothing
|
||||
When an admin sends PUT /v1/image-variants/hero with the stored values
|
||||
Then the response status is 200 and "purged_files" is 0
|
||||
And updated_at is unchanged and no activity event is recorded
|
||||
|
||||
Scenario: Switching the format to png drops the quality and purges
|
||||
When an admin sends PUT /v1/image-variants/hero with format "png" and quality 40
|
||||
Then the response status is 200 and quality is null and "purged_files" is 244
|
||||
|
||||
Scenario: Changing the quality of a png variant is a no-op
|
||||
Given the variant "logo" with format png
|
||||
When an admin sends PUT /v1/image-variants/logo with the same values and quality 30
|
||||
Then "purged_files" is 0 and quality is still null
|
||||
|
||||
Scenario: The name is immutable
|
||||
When an admin sends PUT /v1/image-variants/hero with {"name": "banner", "width": 1200, "height": 630}
|
||||
Then the stored variant is still named "hero" and no variant "banner" exists
|
||||
|
||||
Scenario Outline: An invalid PUT answers like a create and changes nothing
|
||||
When an admin sends PUT /v1/image-variants/hero with <body>
|
||||
Then the response status is 422 and the stored variant and its 244 generated files are unchanged
|
||||
|
||||
Examples:
|
||||
| body |
|
||||
| {"quality": 101} |
|
||||
| {"width": null, "height": null} |
|
||||
| {"width": 9000} |
|
||||
| {"fit": "stretch"} |
|
||||
|
||||
Scenario: An unknown variant answers 404
|
||||
When an admin sends PUT /v1/image-variants/missing with a valid body
|
||||
Then the response status is 404 and the plain text body is "Variant not found"
|
||||
|
||||
Rule: Enabling and disabling
|
||||
|
||||
Scenario Outline: PATCH toggles enabled and keeps the cache
|
||||
Given the variant "hero" has enabled <before> and 244 generated files
|
||||
When an admin sends PATCH /v1/image-variants/hero with {"enabled": <after>}
|
||||
Then the response status is 200 and the body has enabled <after> and generated_files 244
|
||||
And no blob is deleted
|
||||
|
||||
Examples:
|
||||
| before | after |
|
||||
| true | false |
|
||||
| false | true |
|
||||
|
||||
Scenario Outline: A PATCH that is not valid or not found
|
||||
When an admin sends PATCH /v1/image-variants/<name> with <body>
|
||||
Then the response status is <status>
|
||||
|
||||
Examples:
|
||||
| name | body | status |
|
||||
| hero | not json | 400 |
|
||||
| hero | {} | 422 |
|
||||
| hero | {"enabled": "no"} | 400 |
|
||||
| missing | {"enabled": true} | 404 |
|
||||
|
||||
Rule: Deleting a variant and purging its cache
|
||||
|
||||
Scenario: Deleting removes the definition and its generated files
|
||||
Given the variant "hero" with 244 generated files and the variant "thumb" with 3
|
||||
When an admin sends DELETE /v1/image-variants/hero
|
||||
Then the response status is 204 with an empty body
|
||||
And "hero" is gone, its 244 blobs are deleted from image.Storage and its index rows are gone
|
||||
And "thumb" and its 3 files are untouched
|
||||
|
||||
Scenario: Purging removes only the generated files
|
||||
Given the variant "hero" with 244 generated files of 84 MB in total
|
||||
When an admin sends POST /v1/image-variants/hero/purge
|
||||
Then the response status is 200 and the body is {"purged_files": 244, "purged_bytes": <total bytes>}
|
||||
And the variant "hero" still exists unchanged and GET /v1/image-variants shows generated_files 0 and cache_size 0
|
||||
|
||||
Scenario: Purging an empty cache answers zero
|
||||
When an admin sends POST /v1/image-variants/hero/purge and "hero" has no generated file
|
||||
Then the response status is 200 and the body is {"purged_files": 0, "purged_bytes": 0}
|
||||
|
||||
Scenario Outline: An unknown variant answers 404
|
||||
When an admin sends <method> /v1/image-variants/missing<suffix>
|
||||
Then the response status is 404 and the plain text body is "Variant not found"
|
||||
|
||||
Examples:
|
||||
| method | suffix |
|
||||
| DELETE | |
|
||||
| POST | /purge |
|
||||
|
||||
Scenario: A blob that cannot be deleted keeps its index row and the call fails
|
||||
Given the variant "hero" with 3 generated files and image.Storage failing to delete the second one
|
||||
When an admin sends POST /v1/image-variants/hero/purge
|
||||
Then the response status is 500 with the body "Internal server error"
|
||||
And the 2 other files are deleted and the failing file keeps its index row so that a retry can finish
|
||||
|
||||
Scenario: A blob already missing from storage does not stop a purge
|
||||
Given the variant "hero" with an index row whose blob no longer exists
|
||||
When an admin sends POST /v1/image-variants/hero/purge
|
||||
Then the response status is 200 and the row is removed
|
||||
|
||||
Scenario: Deleting a source image purges its generated files
|
||||
Given the image "north-wall.jpg" with generated files for "hero" and "thumb"
|
||||
When an admin sends DELETE /v1/media/<its id>
|
||||
Then the response status is 204 and the generated blobs and index rows of that image are gone
|
||||
And the generated files of other images are untouched
|
||||
|
||||
Scenario: Merging copies purges the generated files of the removed copies
|
||||
Given the images 5, 6 and 7 share one hash and 6 and 7 each have generated files
|
||||
When an admin sends POST /v1/media/merge keeping 5 and merging [6, 7]
|
||||
Then the generated blobs and rows of images 6 and 7 are gone
|
||||
|
||||
Rule: Public serving of ?v=<name>
|
||||
|
||||
Background:
|
||||
Given the variant "thumb" is cover 400x300 jpeg quality 80 and enabled
|
||||
And the image "photo.png" is stored as "a1b2.png", a 1000 x 800 PNG
|
||||
And no generated file exists yet
|
||||
|
||||
Scenario: The first request generates and caches
|
||||
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb
|
||||
Then the response status is 200 with the content type "image/jpeg"
|
||||
And the body decodes to a 400 x 300 JPEG
|
||||
And exactly one blob was put in image.Storage for the variant and one index row exists with the same size as the body
|
||||
And the original blob "a1b2.png" is unchanged
|
||||
|
||||
Scenario: The second request serves the cached file without rendering
|
||||
Given GET /static/uploadedImages/a1b2.png?v=thumb was served once
|
||||
When the public client sends the same request again
|
||||
Then the body is byte-identical to the first one
|
||||
And the renderer was called once in total and image.Storage Put was called once in total
|
||||
|
||||
Scenario: A request without ?v= is the unchanged original
|
||||
When the public client sends GET /static/uploadedImages/a1b2.png
|
||||
Then the response is the stored PNG with the same headers as before this slice
|
||||
And the renderer is not called
|
||||
|
||||
Scenario: The ETag derives from the variant settings
|
||||
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb
|
||||
Then the response has an ETag that is a quoted opaque value
|
||||
And requesting it again returns the same ETag
|
||||
And sending If-None-Match with that ETag answers 304 with an empty body
|
||||
|
||||
Scenario Outline: The ETag changes when a setting changes and stays when a switch changes
|
||||
Given the ETag E1 of GET /static/uploadedImages/a1b2.png?v=thumb
|
||||
When an admin changes <setting> of "thumb" to <value>
|
||||
And the public client requests the same URL again
|
||||
Then the new ETag is different from E1: <changes>
|
||||
|
||||
Examples:
|
||||
| setting | value | changes |
|
||||
| quality | 70 | yes |
|
||||
| width | 500 | yes |
|
||||
| height | 200 | yes |
|
||||
| fit | inside | yes |
|
||||
| format | png | yes |
|
||||
| generate_on_upload | true | no |
|
||||
|
||||
Scenario: A stale If-None-Match after an edit gets a fresh 200
|
||||
Given the client holds E1 and the admin changed the quality of "thumb"
|
||||
When the client sends If-None-Match E1
|
||||
Then the response is 200 with the regenerated file and a new ETag
|
||||
|
||||
Scenario: Editing a variant regenerates on the next request
|
||||
Given GET ...?v=thumb was served once
|
||||
When an admin changes the quality of "thumb" from 80 to 70
|
||||
And the public client requests the same URL
|
||||
Then a new blob is generated and the previous blob was deleted from image.Storage
|
||||
|
||||
Scenario: A cached file that vanished from storage is generated again
|
||||
Given the index row for "thumb" and "a1b2.png" exists but image.Storage reports ErrBlobNotFound for it
|
||||
When the public client requests GET ...?v=thumb
|
||||
Then the response is 200 with a freshly generated file and the index row points to the new blob
|
||||
|
||||
Scenario: Concurrent first requests generate once
|
||||
When 20 concurrent clients send GET /static/uploadedImages/a1b2.png?v=thumb
|
||||
Then every response is 200 with identical bytes
|
||||
And the renderer was called once and image.Storage Put was called once (singleflight keyed by variant and image)
|
||||
|
||||
Scenario: Different variants and different images are not serialised together
|
||||
Given the variant "card" and the image "c3d4.png"
|
||||
When the four (variant, image) pairs are requested concurrently while each render blocks
|
||||
Then all four renders are in flight at the same time
|
||||
|
||||
Scenario: A failed generation is not cached and is retried
|
||||
Given the renderer fails for the first call
|
||||
When the public client requests GET ...?v=thumb twice
|
||||
Then the first response is 500 and no blob or row exists
|
||||
And the second response is 200
|
||||
|
||||
Rule: Unknown, disabled and raw parameters
|
||||
|
||||
Scenario Outline: A request outside the contract answers 400 with the JSON error body
|
||||
When the public client sends GET <url>
|
||||
Then the response status is 400 with the content type "application/json"
|
||||
And the body is {"status": 400, "error": "<error>", "message": "<message>", "detail": "<detail>"}
|
||||
And the renderer is not called and nothing is stored
|
||||
|
||||
Examples:
|
||||
| url | error | message | detail |
|
||||
| /static/uploadedImages/a1b2.png?v=banner | unknown_variant | Unknown variant | banner |
|
||||
| /static/uploadedImages/a1b2.png?v=Thumb | unknown_variant | Unknown variant | Thumb |
|
||||
| /static/uploadedImages/a1b2.png?v= | unknown_variant | Unknown variant | |
|
||||
| /static/uploadedImages/a1b2.png?v=hero (disabled) | unknown_variant | Unknown variant | hero |
|
||||
| /static/uploadedImages/a1b2.png?width=400 | unsupported_parameter | Unsupported parameter | width |
|
||||
| /static/uploadedImages/a1b2.png?height=300 | unsupported_parameter | Unsupported parameter | height |
|
||||
| /static/uploadedImages/a1b2.png?format=webp | unsupported_parameter | Unsupported parameter | format |
|
||||
| /static/uploadedImages/a1b2.png?quality=50 | unsupported_parameter | Unsupported parameter | quality |
|
||||
| /static/uploadedImages/a1b2.png?v=thumb&width=400 | unsupported_parameter | Unsupported parameter | width |
|
||||
| /static/uploadedImages/a1b2.png?v=banner&quality=50 | unsupported_parameter | Unsupported parameter | quality |
|
||||
|
||||
Scenario: Other query parameters are ignored
|
||||
When the public client sends GET /static/uploadedImages/a1b2.png?v=thumb&t=123
|
||||
Then the response status is 200 with the thumb variant
|
||||
|
||||
Scenario: A disabled variant serves again once enabled, from its kept cache
|
||||
Given the variant "thumb" was served once, then disabled
|
||||
When the public client requests ?v=thumb
|
||||
Then the response is 400 unknown_variant
|
||||
When an admin enables "thumb" and the client requests again
|
||||
Then the response is 200 and image.Storage Put was called once in total
|
||||
|
||||
Scenario: A deleted variant answers 400
|
||||
Given the variant "thumb" is deleted
|
||||
When the public client requests ?v=thumb
|
||||
Then the response is 400 unknown_variant
|
||||
|
||||
Rule: Missing and unreadable sources
|
||||
|
||||
Scenario Outline: A missing source answers 404
|
||||
When the public client sends GET <url>
|
||||
Then the response status is 404 and nothing is generated
|
||||
|
||||
Examples:
|
||||
| url |
|
||||
| /static/uploadedImages/missing.png?v=thumb |
|
||||
| /static/uploadedImages/../secret.png?v=thumb |
|
||||
| /static/uploadedImages/nested/a1b2.png?v=thumb|
|
||||
|
||||
Scenario: Parameter errors come before the missing source
|
||||
When the public client sends GET /static/uploadedImages/missing.png?v=banner
|
||||
Then the response status is 400 unknown_variant
|
||||
|
||||
Scenario: A source that cannot be decoded answers 500 and caches nothing
|
||||
Given the stored blob "bad.png" holds bytes that are not an image
|
||||
When the public client requests /static/uploadedImages/bad.png?v=thumb
|
||||
Then the response status is 500 and nothing is stored
|
||||
|
||||
Scenario Outline: Only a source that is a known image record can be a variant source
|
||||
Given the blob "<name>" exists in image.Storage
|
||||
And <record>
|
||||
When the public client requests /static/uploadedImages/<name>?v=thumb
|
||||
Then the response status is <status>
|
||||
And nothing is generated and nothing is stored
|
||||
|
||||
Examples:
|
||||
| name | record | status |
|
||||
| a1b2.png | an image record with that stored name exists | 200 |
|
||||
| gen-1.jpg | the blob is a generated variant file, with no image record | 404 |
|
||||
| orphan.png | the blob has no image record | 404 |
|
||||
|
||||
Scenario: The record lookup comes after the parameter checks and before any storage read
|
||||
When the public client requests /static/uploadedImages/orphan.png?v=banner
|
||||
Then the response status is 400 unknown_variant
|
||||
And when requested with ?v=thumb the response is 404 and image.Storage Open is not called
|
||||
|
||||
Scenario: The original of any stored blob is still served without ?v=
|
||||
When the public client requests /static/uploadedImages/gen-1.jpg
|
||||
Then the response is the stored bytes as before this slice
|
||||
|
||||
Rule: Source size cap
|
||||
|
||||
Scenario Outline: A source beyond IMAGE_VARIANT_MAX_SOURCE_BYTES answers 413
|
||||
Given IMAGE_VARIANT_MAX_SOURCE_BYTES is 1000
|
||||
And the stored source "big.png" is <size> bytes
|
||||
When the public client requests /static/uploadedImages/big.png?v=thumb
|
||||
Then the response status is <status>
|
||||
|
||||
Examples:
|
||||
| size | status |
|
||||
| 1000 | 200 |
|
||||
| 1001 | 413 |
|
||||
|
||||
Scenario: The 413 body is plain text and nothing is generated
|
||||
Given a source of 1001 bytes with the cap at 1000
|
||||
Then the body is "The source image is too large to generate this variant." and the renderer is not called
|
||||
|
||||
Scenario: The cap never affects the original
|
||||
Given a source of 1001 bytes with the cap at 1000
|
||||
When the public client requests it without ?v=
|
||||
Then the response is 200 with the stored bytes
|
||||
|
||||
Scenario Outline: The env var rejects invalid values at boot
|
||||
When the settings are loaded with IMAGE_VARIANT_MAX_SOURCE_BYTES=<value>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| value | result |
|
||||
| (unset) | 10485760 |
|
||||
| 2048 | 2048 |
|
||||
| 0 | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
|
||||
| -5 | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
|
||||
| many | an error naming IMAGE_VARIANT_MAX_SOURCE_BYTES |
|
||||
|
||||
Rule: Source pixel cap, checked before decoding
|
||||
|
||||
Scenario Outline: A source beyond IMAGE_VARIANT_MAX_SOURCE_PIXELS answers 413 without decoding it
|
||||
Given IMAGE_VARIANT_MAX_SOURCE_PIXELS is 10000
|
||||
And the stored source "wide.png" is a small file whose header declares <w> x <h> pixels
|
||||
When the public client requests /static/uploadedImages/wide.png?v=thumb
|
||||
Then the response status is <status>
|
||||
And the renderer never allocates the pixel buffer for a refused source
|
||||
|
||||
Examples:
|
||||
| w | h | status |
|
||||
| 100 | 100 | 200 |
|
||||
| 101 | 100 | 413 |
|
||||
| 30000 | 30000 | 413 |
|
||||
|
||||
Scenario: The pixel 413 body is plain text and nothing is generated
|
||||
Given a source declaring more pixels than the cap
|
||||
Then the body is "The source image is too large to generate this variant." and the renderer is not called and nothing is stored
|
||||
|
||||
Scenario: The pixel cap never affects the original or SVG
|
||||
Given a source declaring more pixels than the cap
|
||||
When the public client requests it without ?v=
|
||||
Then the response is 200 with the stored bytes
|
||||
|
||||
Scenario: A header that cannot be read answers 500 and caches nothing
|
||||
Given the stored blob "bad.png" holds bytes that are not an image
|
||||
When the public client requests /static/uploadedImages/bad.png?v=thumb
|
||||
Then the response status is 500 and nothing is stored
|
||||
|
||||
Scenario Outline: The pixel cap env var
|
||||
When the settings are loaded with IMAGE_VARIANT_MAX_SOURCE_PIXELS=<value>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| value | result |
|
||||
| (unset) | 50000000 |
|
||||
| 2048 | 2048 |
|
||||
| 0 | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
|
||||
| -5 | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
|
||||
| many | an error naming IMAGE_VARIANT_MAX_SOURCE_PIXELS |
|
||||
|
||||
Rule: Concurrent renders are bounded
|
||||
|
||||
Scenario: At most IMAGE_VARIANT_MAX_CONCURRENT_RENDERS renders run at once
|
||||
Given IMAGE_VARIANT_MAX_CONCURRENT_RENDERS is 2 and the renderer blocks
|
||||
When 5 requests for 5 distinct uncached (variant, image) pairs arrive
|
||||
Then exactly 2 renders are in flight and the other 3 wait for a slot
|
||||
And once a render is released, a waiting one starts, and every request ends with 200
|
||||
|
||||
Scenario: A cached file never takes a render slot
|
||||
Given every render slot is held
|
||||
When the public client requests a variant file that is already cached
|
||||
Then the response is 200 immediately
|
||||
|
||||
Scenario: A waiting request whose client has gone does not render
|
||||
Given every render slot is held and a request waits for a slot
|
||||
When its context is cancelled
|
||||
Then it stops waiting, no render starts for it and nothing is stored
|
||||
|
||||
Scenario: Identical concurrent requests still share one render and one slot
|
||||
When 20 concurrent clients request the same uncached (variant, image) pair
|
||||
Then the renderer is called once and only one slot is taken
|
||||
|
||||
Scenario Outline: The concurrency env var
|
||||
When the settings are loaded with IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=<value>
|
||||
Then the result is <result>
|
||||
|
||||
Examples:
|
||||
| value | result |
|
||||
| (unset) | 4 |
|
||||
| 1 | 1 |
|
||||
| 0 | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
|
||||
| -1 | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
|
||||
| many | an error naming IMAGE_VARIANT_MAX_CONCURRENT_RENDERS |
|
||||
|
||||
Rule: SVG and animated GIF are served as they are
|
||||
|
||||
Scenario: An SVG with a valid variant is served unchanged
|
||||
Given the image "logo.svg" is stored as "e5f6.svg"
|
||||
When the public client requests /static/uploadedImages/e5f6.svg?v=thumb
|
||||
Then the response is 200 with the stored bytes, the content type "image/svg+xml" and the existing Content-Security-Policy
|
||||
And the renderer is not called and no blob or row is created
|
||||
|
||||
Scenario: An SVG with an unknown variant is still a 400
|
||||
When the public client requests /static/uploadedImages/e5f6.svg?v=banner
|
||||
Then the response is 400 unknown_variant
|
||||
|
||||
Scenario: An SVG larger than the cap is still served
|
||||
Given the cap is lower than the SVG size
|
||||
Then the response is 200 with the stored bytes
|
||||
|
||||
Scenario: An animated GIF is served unchanged
|
||||
Given a stored blob "anim.gif" holding an animated GIF
|
||||
When the public client requests /static/uploadedImages/anim.gif?v=thumb
|
||||
Then the response is 200 with the stored bytes, the content type "image/gif" and no generated file
|
||||
|
||||
Scenario: A single-frame GIF is treated like any raster source
|
||||
Given a stored blob "still.gif" holding a one-frame GIF
|
||||
When the public client requests it with ?v=thumb
|
||||
Then the response is a generated variant
|
||||
|
||||
Rule: Output geometry never upscales
|
||||
|
||||
Scenario Outline: The output size follows the fit and never exceeds the source
|
||||
Given a variant with width <w>, height <h>, fit <fit> and format png
|
||||
And a source of <sw> x <sh> pixels
|
||||
When the variant is requested
|
||||
Then the decoded output is <ow> x <oh>
|
||||
|
||||
Examples:
|
||||
| sw | sh | w | h | fit | ow | oh |
|
||||
| 4032 | 3024 | 400 | 300 | cover | 400 | 300 |
|
||||
| 4032 | 3024 | 800 | | cover | 800 | 600 |
|
||||
| 4032 | 3024 | | 600 | contain | 800 | 600 |
|
||||
| 4032 | 3024 | 1000 | 1000 | inside | 1000 | 750 |
|
||||
| 4032 | 3024 | 1000 | 1000 | cover | 1000 | 1000 |
|
||||
| 4032 | 3024 | 1000 | 1000 | contain | 1000 | 1000 |
|
||||
| 300 | 200 | 800 | | cover | 300 | 200 |
|
||||
| 300 | 200 | | 800 | inside | 300 | 200 |
|
||||
| 300 | 200 | 400 | 300 | cover | 267 | 200 |
|
||||
| 300 | 200 | 400 | 300 | contain | 400 | 300 |
|
||||
| 300 | 200 | 1200 | 1200 | contain | 1200 | 1200 |
|
||||
| 300 | 200 | 1200 | 1200 | inside | 300 | 200 |
|
||||
|
||||
Scenario: Cover crops around the centre
|
||||
Given a 40 x 20 source whose columns 0 to 9 are red, 10 to 29 are green and 30 to 39 are blue
|
||||
When the variant cover 20 x 20 png is requested
|
||||
Then the output is 20 x 20 and every pixel is green
|
||||
|
||||
Scenario: Contain pads transparent on png and webp
|
||||
Given a 40 x 20 opaque red source and the variant contain 40 x 40 png
|
||||
When it is requested
|
||||
Then the output is 40 x 40, the top 10 rows and bottom 10 rows are fully transparent, and the middle 20 rows are red
|
||||
|
||||
Scenario: Contain pads white on jpeg
|
||||
Given the same source and the variant contain 40 x 40 jpeg
|
||||
Then the top 10 rows and bottom 10 rows are white (#FFFFFF) within JPEG tolerance
|
||||
|
||||
Scenario: Inside adds no padding
|
||||
Given the same source and the variant inside 40 x 40 png
|
||||
Then the output is 40 x 20 with no transparent pixel
|
||||
|
||||
Scenario: A transparent PNG source flattened to JPEG becomes white
|
||||
Given a fully transparent 10 x 10 PNG and the variant 10 x 10 jpeg
|
||||
Then every pixel of the output is white within JPEG tolerance
|
||||
|
||||
Rule: Output format
|
||||
|
||||
Scenario Outline: The output format and content type
|
||||
Given the source "<source>" and the variant format <format>
|
||||
Then the response content type is <type> and the body signature is <signature>
|
||||
|
||||
Examples:
|
||||
| source | format | type | signature |
|
||||
| png | original | image/png | PNG |
|
||||
| jpeg | original | image/jpeg | JPEG |
|
||||
| png | jpeg | image/jpeg | JPEG |
|
||||
| jpeg | png | image/png | PNG |
|
||||
|
||||
Scenario: Quality changes the size of a lossy output
|
||||
Given a photographic 600 x 600 source and the variants "lo" quality 20 and "hi" quality 95, both jpeg
|
||||
Then the "lo" output is smaller than the "hi" output
|
||||
|
||||
Scenario: A png output ignores quality
|
||||
Given two png variants stored with quality 30 and 95 submitted at creation
|
||||
Then both outputs are byte-identical
|
||||
|
||||
Rule: WebP is conditional on the spike
|
||||
|
||||
Scenario: When a CGO-free lossy WebP encoder exists, webp is offered
|
||||
Given the spike found an encoder that builds with CGO_ENABLED=0
|
||||
When an admin creates a variant with format "webp"
|
||||
Then the response is 201
|
||||
And requesting it returns "image/webp" with a RIFF/WEBP signature, the right dimensions, and a lower quality giving a smaller file
|
||||
And a webp source with format "original" is served as webp
|
||||
|
||||
Scenario: When no encoder is viable, webp is refused
|
||||
Given the spike found no usable encoder and the renderer reports png, jpeg as encodable
|
||||
When an admin sends POST /v1/image-variants with {"name": "wp", "width": 10, "format": "webp"}
|
||||
Then the response status is 422 and the plain text body is "Format must be original, jpeg or png."
|
||||
And creating with original, jpeg and png still works
|
||||
|
||||
Scenario: A stored GIF is served with the image/gif content type
|
||||
Given a stored blob "anim.gif"
|
||||
When the public client requests /static/uploadedImages/anim.gif
|
||||
Then the response content type is "image/gif"
|
||||
|
||||
Scenario: A webp source still decodes without an encoder
|
||||
Given a webp source and a jpeg variant
|
||||
Then the response is 200 with a JPEG
|
||||
|
||||
Scenario: Without an encoder, a webp source with format original falls back to png
|
||||
Given no webp encoder and a webp source with a variant of format original
|
||||
Then the response content type is image/png
|
||||
|
||||
Scenario: The build stays CGO-free
|
||||
When the module is built with CGO_ENABLED=0 and -tags nofrontend
|
||||
Then it succeeds and go list -deps ./... lists no package that imports "C"
|
||||
|
||||
Rule: Every variant change is recorded in the activity log
|
||||
|
||||
Background:
|
||||
Given recording follows features/activity-log-producers.feature: once, from the use case, after the change, with the request origin
|
||||
And the category is "media" and the target is {"type": "image_variant", "id": "<name>", "name": "<name>"}
|
||||
And the actions are media.variant_create, media.variant_update, media.variant_delete, media.variant_enable, media.variant_disable and media.variant_purge
|
||||
And the change fields allowed in the log are extended with width, height, fit, format and quality, all of kind "text"
|
||||
|
||||
Scenario: Creating a variant is recorded
|
||||
When the owner creates "hero" 1200x630 cover webp quality 82
|
||||
Then one event exists with action "media.variant_create", target {"type": "image_variant", "id": "hero", "name": "hero"}, outcome "success" and params {"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82}
|
||||
|
||||
Scenario: Updating a variant is recorded with the diff and the purge count
|
||||
Given "hero" has quality 82 and 244 generated files
|
||||
When the owner changes its quality to 70
|
||||
Then one event exists with action "media.variant_update", changes [{"field": "quality", "kind": "text", "before": "82", "after": "70"}] and params {"purged_files": 244}
|
||||
|
||||
Scenario: A switch-only update does not claim a purge
|
||||
When the owner sets generate_on_upload to true on "hero"
|
||||
Then no "media.variant_update" event with a rendering change is recorded, and no event exists for that switch
|
||||
|
||||
Scenario: Deleting a variant is recorded with the removed files
|
||||
Given "hero" has 244 generated files totalling 84 MB
|
||||
When the owner deletes it
|
||||
Then one event exists with action "media.variant_delete", target "hero" and params {"purged_files": 244, "purged_bytes": <total>}
|
||||
|
||||
Scenario Outline: Enabling and disabling are recorded
|
||||
Given "hero" has enabled <before>
|
||||
When the owner sends PATCH /v1/image-variants/hero with {"enabled": <after>}
|
||||
Then one event exists with action "<action>" and target "hero"
|
||||
|
||||
Examples:
|
||||
| before | after | action |
|
||||
| false | true | media.variant_enable |
|
||||
| true | false | media.variant_disable |
|
||||
|
||||
Scenario: A PATCH to the current value records nothing
|
||||
When the owner sets enabled to its current value
|
||||
Then the response is 200 and no event is recorded
|
||||
|
||||
Scenario: A refused create is recorded as failed
|
||||
Given "thumb" exists
|
||||
When the owner creates "thumb" again
|
||||
Then one event exists with action "media.variant_create", outcome "failed", failure_reason "A variant with that name already exists." and target "thumb"
|
||||
|
||||
Scenario Outline: Requests that fail validation or find nothing record nothing
|
||||
When <request>
|
||||
Then no event is recorded
|
||||
|
||||
Examples:
|
||||
| request |
|
||||
| POST /v1/image-variants with width 9000 |
|
||||
| PUT /v1/image-variants/missing with a valid body |
|
||||
| DELETE /v1/image-variants/missing |
|
||||
| PATCH /v1/image-variants/missing with {"enabled": true} |
|
||||
| POST /v1/image-variants/missing/purge |
|
||||
| GET /v1/image-variants |
|
||||
| a public GET ...?v=thumb that generates a file |
|
||||
|
||||
Scenario: Purging a variant's cache is recorded
|
||||
Given "hero" has 244 generated files totalling 84 MB
|
||||
When the owner sends POST /v1/image-variants/hero/purge
|
||||
Then one event exists with action "media.variant_purge", target {"type": "image_variant", "id": "hero", "name": "hero"}, outcome "success" and params {"purged_files": 244, "purged_bytes": <total>}
|
||||
|
||||
Scenario: Purging an empty cache records nothing
|
||||
Given "hero" has no generated file
|
||||
When the owner sends POST /v1/image-variants/hero/purge
|
||||
Then the response is 200 with {"purged_files": 0, "purged_bytes": 0} and no event is recorded
|
||||
|
||||
Scenario: A purge that fails on storage is recorded as failed
|
||||
Given "hero" has 3 generated files and image.Storage fails to delete the second
|
||||
When the owner sends POST /v1/image-variants/hero/purge
|
||||
Then the response is 500 and one event exists with action "media.variant_purge", outcome "failed" and failure_reason "Something went wrong on our side. Please try again."
|
||||
|
||||
Scenario: A failing audit write never changes the response
|
||||
Given the audit repository fails on Append
|
||||
When the owner creates "hero"
|
||||
Then the response is 201 and the variant exists
|
||||
|
||||
Scenario: The existing allowlist scenario is updated
|
||||
Then the only change fields any producer emits are title, online, body, filename, display_name, width, height, fit, format and quality
|
||||
|
||||
Rule: Layering and hygiene
|
||||
|
||||
Scenario: The dependency rule holds
|
||||
Then internal/domain/image imports only the stdlib and other internal/domain packages
|
||||
And the variant use cases import no persistence, mailer, models, gorm, net/http, chi or api package
|
||||
And the handler for /v1/image-variants and the public handler import no persistence, mailer, models or gorm package
|
||||
And only api/dependecyInjection.go constructs the variant repositories, the renderer and the use cases
|
||||
And the new GORM models are registered in internal/infrastructure/persistence/database/migrate.go
|
||||
And no comment is added to any Go file, test or doc, except tool directives
|
||||
@@ -0,0 +1,74 @@
|
||||
# QA procedure: image-variants
|
||||
|
||||
Go only, no SPA. Package `test` (black-box). New files: `test/image_variants_admin_http_test.go`, `test/image_variants_serving_http_test.go`, `test/image_variants_geometry_test.go`, `test/image_variants_audit_test.go`, `test/image_variants_config_test.go`. Fake type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no `time.Sleep`.
|
||||
|
||||
## Setup shared by every case
|
||||
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` stands in for `JWT_SECRET`.
|
||||
2. New hand-written in-memory fakes: `mockVariantRepo` (CRUD, stores creation order, unique-name check returning `image.ErrVariantExists`), `mockVariantFileRepo` (index rows with variant, source stored name, cached stored name, content type, size, fingerprint, with `Delete` calls recorded and a per-row failure switch), `mockVariantStorage` (an in-memory `image.Storage` that names blobs `gen-<n>.<ext>`, counts `Put`, `Open` and `Delete` calls, can fail `Delete` for one name, and can block `Put`/render on a channel), `countingRenderer` (wraps the real stdlib-based renderer, counts calls, can fail the first call, can block on a channel, `EncodableFormats()` configurable for the webp-on or webp-off cases). Reuse `mockImageRepo`, the user fake, the audit fakes from `test/activity_support_test.go` (`mockAuditRepo`, `fakeAuditClock`).
|
||||
3. Users: owner 1, admin 2, verified plain user 3, unverified user 4. Real signed `jwt` cookies with `user_id` and `token_version`; a stale cookie with a lower `token_version`. A valid API key in the key fake for the "API key refused" row.
|
||||
4. Admin router: a fresh `chi.NewRouter()` mirroring `api/router.go`: `jwtauth.Verifier`, `middleware.AuditContext(getUser, nil)`, `jwtauth.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, `RequireOwnerOrAdmin`, mounted at `/v1/image-variants`, with no `apiKeyAuth`.
|
||||
5. Public router: mount the real `staticimages.NewHandler(...)` (with the new variant dependency) at `/static/uploadedImages` on the same kind of router, over `mockVariantStorage` holding the originals. The router wraps `chimiddleware.GetHead` like `InitRoutes`.
|
||||
6. Images are generated in the test with `image/png`, `image/jpeg` and `image/draw` (solid or half-and-half colours so crops and padding can be asserted pixel by pixel). Decode responses with `image.Decode`. An animated GIF is built with `image/gif` and two frames. A real SVG string is stored as `e5f6.svg`. Use `x/image/webp` to decode webp fixtures.
|
||||
7. Set the cap by constructing the use case with the value; do not touch the process environment except in the config test.
|
||||
|
||||
## A. Guards (feature "Only owners and admins")
|
||||
8. Table: each method, path and credential gives the status and the exact body constant; assert no variant, index, storage or audit fake was touched. API key gets 401. Owner and admin both get 200.
|
||||
|
||||
## B. List and create
|
||||
9. Empty list: raw JSON has `"items":[]`. Seeded list: order by name, `width`/`height` as JSON `null` when absent (assert on the raw body), `quality` null for png, counts and sizes from the file index.
|
||||
10. Create: full body, defaults body, boundary table (name 2 and 32 chars, `width` 1 and 8000, quality 1 and 100). Invalid table gives 422 with each exact sentence and verifies nothing stored and no audit event. Malformed JSON rows give 400 with `apperr.BodyErrorMessage`. Duplicate gives 409 with the exact sentence and leaves the stored variant unchanged.
|
||||
11. When `EncodableFormats()` lacks webp, the format rejection sentence is `Format must be original, jpeg or png.` and `webp` is refused with 422; run the format rows in both configurations.
|
||||
|
||||
## C. Update, toggle, delete, purge
|
||||
12. PUT table over `width`, `height`, `fit`, `format`, `quality` (purges 244 seeded index rows: assert the blob `Delete` calls equal the row set, the index is empty for the variant, the other variant's rows untouched, `purged_files` equals 244). `generate_on_upload` and `enabled` rows purge nothing. Identical PUT leaves `updated_at` as it was and records no event. A png variant ignores a quality edit. The body `name` is ignored.
|
||||
13. Invalid PUT rows leave variant and rows unchanged. Unknown name gives 404 with `Variant not found`.
|
||||
14. PATCH: both directions keep the index rows and call no storage `Delete`; 400/422/404 rows.
|
||||
15. DELETE: 204 with empty body, variant gone, its blobs and rows gone, others untouched. 404 row.
|
||||
16. Purge: 200 with `purged_files` and `purged_bytes`, variant intact, list shows zero. Empty cache returns zeros. Storage `Delete` failing on the second of three blobs gives 500, two rows removed, one row kept. A blob already absent (storage `Delete` returns nil per `LocalStorage` contract) removes the row.
|
||||
17. Source delete and merge: through the real delete and merge media use cases, assert the generated blobs and rows of the removed image(s) are deleted and those of other images are kept. Assert the 204 status and the existing library behaviour are unchanged.
|
||||
|
||||
## D. Public serving
|
||||
18. First request: 200, content type, decoded size, exactly one `Put` and one index row equal to the body size, original blob untouched. Second request: byte-identical body, renderer count 1, `Put` count 1.
|
||||
19. No `?v=`: the response equals the pre-slice response (headers and bytes) and the renderer count is 0. Other parameters such as `t=123` are ignored.
|
||||
20. ETag: present, stable, `If-None-Match` gives 304 with an empty body and no renderer call on a cached file. Edit table (quality, width, height, fit, format change the ETag; `generate_on_upload` does not). A stale `If-None-Match` after an edit gets 200 with a new ETag. After an edit, the next request puts one new blob and the old blob was deleted.
|
||||
21. Vanished blob: index row present, storage `Open` returns `ErrBlobNotFound`: regenerated, row repointed.
|
||||
22. Concurrency (run with `-race`): 20 goroutines released by a barrier channel against a renderer blocking on a channel; all 200 with identical bytes, renderer count 1, `Put` count 1. Four distinct (variant, image) pairs with blocking renderers: assert all four renders start before any is released (use a `sync.WaitGroup` rendezvous in the fake, no sleeping). A failing first render gives 500 with no row and a successful retry.
|
||||
23. 400 table: every URL of the outline, with the exact JSON body (decode and compare `status`, `error`, `message`, `detail`), `Content-Type` starting with `application/json`, renderer count 0, storage `Put` count 0. Precedence: `unsupported_parameter` beats `unknown_variant`; 400 beats 404 for a missing source.
|
||||
24. Disabled then enabled: 400, then 200 served from the kept row (`Put` count unchanged). Deleted variant: 400.
|
||||
25. 404 table: missing name, `..` traversal, nested path. 500 for undecodable bytes with nothing stored.
|
||||
26. Cap: boundary table 1000 bytes (200) and 1001 bytes (413), the 413 plain text body, the renderer count 0, the original served without `?v=`.
|
||||
27. SVG and GIF: unchanged bytes, content types `image/svg+xml` (with the existing CSP header) and `image/gif`, renderer count 0, nothing stored, unknown variant still 400, SVG larger than the cap still 200. A one-frame GIF is rendered.
|
||||
|
||||
## E. Geometry and format (unit-level, no HTTP, same files)
|
||||
28. Table (feature "Output geometry"): decode each output and assert width and height. Build the source with a solid colour for the size rows.
|
||||
29. Pixel assertions: cover crops around the centre of a 40 x 20 source with red columns 0-9, green 10-29, blue 30-39 and every pixel of the 20 x 20 output is green; contain pads transparent on png (alpha 0 on the padded rows, assert RGBA) and white on jpeg (within a tolerance of 8 per channel); inside has no transparent pixel; a fully transparent PNG source flattened to jpeg is white.
|
||||
30. Format table: signature bytes (`\x89PNG`, `\xff\xd8`, `RIFF....WEBP`) and content types for original, jpeg, png. Lossy size: quality 20 smaller than quality 95 on a noisy fixture. PNG variants stored with quality 30 and 95 give byte-identical outputs.
|
||||
31. WebP, conditional: with an encoder, assert `RIFF/WEBP`, dimensions via `webp.DecodeConfig`, lower quality gives a smaller file, and a webp source with `original` stays webp. Without one, a webp source rendered to jpeg works and `original` falls back to png. Record in the test name which build is under test.
|
||||
|
||||
## F. Config
|
||||
32. Settings load: unset gives 10485760, `2048` gives 2048, `0`, `-5` and `many` give an error naming `IMAGE_VARIANT_MAX_SOURCE_BYTES`. Assert the var is present in `envDefaults` (and absent from `requiredEnvVars`), in `.env.example` and in `docs/configuration.md` (grep-style test on the files).
|
||||
|
||||
## G. Audit
|
||||
33. Create, update (diff plus `purged_files`), delete (`purged_files`, `purged_bytes`), enable, disable and purge events (purge: counts on success, nothing for an empty cache or an unknown name, a failed event with the generic reason when storage fails) with the exact action, category `media`, target `image_variant`, actor and source from the origin. No-op PUT and PATCH record nothing; switch-only PUT records nothing for the rendering diff. Duplicate create records a failed event with its reason; validation errors, 404s, list and public generation record nothing. A failing audit repository leaves the response and the change untouched. Update the allowlist assertion of `test/activity_log_producers*_test.go` to the new field set.
|
||||
|
||||
## H. Mutation guard
|
||||
34. `task mutate` scope is `./internal/infrastructure/useCases`, scoped with `MUTATE_FILES` to the new use case files and `MUTATE_RUN` to the new test names. Each new branch needs a killing assertion: the 1 and 8000 and 2 and 32 boundaries (just inside and just outside), the "at least one dimension" rule, every rendering-setting equality in the purge decision with equal and differing values, the cap comparison (`>` not `>=`, at 1000 and 1001), the never-upscale `min`, the singleflight key (variant and image both), the ETag input set, the check order (parameters, variant, source, as-is types, cap, ETag, cache), and the audit no-op guards. Bare-bool conditions produce no mutant, so cover both branches anyway.
|
||||
|
||||
## I. Layering and hygiene
|
||||
35. Guard test (`go list -deps` or grep): `internal/domain/image` imports no GORM, chi, http or infrastructure; the variant use cases import no `persistence`, `mailer`, `models`, `gorm`, `net/http`, chi or `api`; the handlers import no `persistence`, `mailer`, `models` or `gorm`.
|
||||
36. `CGO_ENABLED=0 go build -tags nofrontend ./...` succeeds, and `go list -deps` shows no package importing `"C"`.
|
||||
37. No comments in new or edited Go files, tests and docs (the hook enforces it). Docs: `docs/api.md` lists the routes and response shapes, `docs/architecture.md` the ports and the cache, `docs/configuration.md` the env var.
|
||||
|
||||
## J. Hardening follow-up
|
||||
44. Pixel cap: a PNG whose IHDR declares 30000 x 30000 (a few hundred bytes of real data) answers 413 with the plain text body and the renderer fake records zero calls; boundary 100 x 100 passes and 101 x 100 fails at a cap of 10000; the original is still served without `?v=`; an unreadable header gives 500. Settings tests for IMAGE_VARIANT_MAX_SOURCE_PIXELS (unset 50000000, 2048, 0, -5, many) and presence in envDefaults, `.env.example`, `docs/configuration.md`.
|
||||
45. Render slots: a renderer fake that blocks on a channel proves at most N renders run at once, the rest wait and then complete with 200; a cached hit does not wait; a cancelled request context stops waiting and renders nothing; identical concurrent requests share one slot. Settings tests for IMAGE_VARIANT_MAX_CONCURRENT_RENDERS (unset 4, 1, 0, -1, many) and the three-place documentation check. Run with `-race`.
|
||||
46. Known source: a blob without an image record (generated cache file or orphan) answers 404 for `?v=` with no storage `Open` call; parameter checks (400) still come first; the original of that blob is still served without `?v=`.
|
||||
47. Contain geometry: 300 x 200 in 400 x 300 gives a 400 x 300 canvas with the image centred and unscaled (transparent padding on png); 300 x 200 in 1200 x 1200 gives 1200 x 1200; single-dimension contain keeps no padding.
|
||||
|
||||
## Manual (not automated)
|
||||
38. Real boot with `task dev:api` over a scratch SQLite file and `UPLOAD_DIR`: create `thumb`, request `/static/uploadedImages/<name>?v=thumb` in a browser, check the file lands in `UPLOAD_DIR`, a second request is served without re-rendering, edit the quality and check the old file disappears. Check `AutoMigrate` created the new tables.
|
||||
39. S3 driver (`STORAGE_DRIVER=s3`, MinIO): generated blobs land in the bucket, purge removes them, serving works without Range or seek support.
|
||||
40. Visual quality of a real photograph in jpeg and, if the spike succeeded, webp; check the contain padding looks right against a white and a dark page.
|
||||
41. WebP spike: record the evaluated encoders, the build result with `CGO_ENABLED=0`, speed and size on a 4032x3024 photo, and the decision. Report to the user if none works.
|
||||
42. Memory and time on a source near `IMAGE_VARIANT_MAX_SOURCE_BYTES` with very large pixel dimensions.
|
||||
43. Read the activity rows in the real Activity screen against section 8 copy.
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.33.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0
|
||||
github.com/gen2brain/webp v0.6.4
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/cors v1.2.2
|
||||
@@ -21,6 +22,7 @@ require (
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/sync v0.23.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.3
|
||||
@@ -46,6 +48,7 @@ require (
|
||||
github.com/aws/smithy-go v1.28.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
@@ -69,7 +72,6 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
|
||||
@@ -47,10 +47,14 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
|
||||
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
|
||||
@@ -2,6 +2,7 @@ package audit
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -28,3 +29,34 @@ func ArticleChanges(before article.Article, after article.Article) []Change {
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func VariantChanges(before image.VariantSettings, after image.VariantSettings) []Change {
|
||||
changes := []Change{}
|
||||
changes = appendChange(changes, FieldWidth, image.DimensionText(before.Width), image.DimensionText(after.Width))
|
||||
changes = appendChange(changes, FieldHeight, image.DimensionText(before.Height), image.DimensionText(after.Height))
|
||||
changes = appendChange(changes, FieldFit, string(before.Fit), string(after.Fit))
|
||||
changes = appendChange(changes, FieldFormat, string(before.Format), string(after.Format))
|
||||
changes = appendChange(changes, FieldQuality, image.QualityText(before.Quality), image.QualityText(after.Quality))
|
||||
return changes
|
||||
}
|
||||
|
||||
func appendChange(changes []Change, field string, before string, after string) []Change {
|
||||
if before == after {
|
||||
return changes
|
||||
}
|
||||
return append(changes, TextChange(field, before, after))
|
||||
}
|
||||
|
||||
func VariantParams(settings image.VariantSettings) map[string]any {
|
||||
params := map[string]any{ParamFit: string(settings.Fit), ParamFormat: string(settings.Format)}
|
||||
if settings.Width != nil {
|
||||
params[ParamWidth] = *settings.Width
|
||||
}
|
||||
if settings.Height != nil {
|
||||
params[ParamHeight] = *settings.Height
|
||||
}
|
||||
if settings.Quality != nil {
|
||||
params[ParamQuality] = *settings.Quality
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -76,6 +76,12 @@ const (
|
||||
ActionMediaDelete = "media.delete"
|
||||
ActionMediaMerge = "media.merge"
|
||||
ActionMediaPurge = "media.purge"
|
||||
ActionVariantCreate = "media.variant_create"
|
||||
ActionVariantUpdate = "media.variant_update"
|
||||
ActionVariantDelete = "media.variant_delete"
|
||||
ActionVariantEnable = "media.variant_enable"
|
||||
ActionVariantDisable = "media.variant_disable"
|
||||
ActionVariantPurge = "media.variant_purge"
|
||||
ActionUserRegister = "user.register"
|
||||
ActionUserVerify = "user.verify"
|
||||
ActionUserUpdateProfile = "user.update_profile"
|
||||
@@ -94,6 +100,7 @@ const (
|
||||
TargetUser = "user"
|
||||
TargetArticle = "article"
|
||||
TargetImage = "image"
|
||||
TargetVariant = "image_variant"
|
||||
TargetInvite = "invite"
|
||||
TargetRelease = "release"
|
||||
)
|
||||
@@ -112,12 +119,24 @@ const (
|
||||
ParamFailedGroups = "failed_groups"
|
||||
ParamUpdateAvailable = "update_available"
|
||||
ParamKind = "kind"
|
||||
ParamPurgedFiles = "purged_files"
|
||||
ParamPurgedBytes = "purged_bytes"
|
||||
ParamWidth = "width"
|
||||
ParamHeight = "height"
|
||||
ParamFit = "fit"
|
||||
ParamFormat = "format"
|
||||
ParamQuality = "quality"
|
||||
ParamTag = "tag"
|
||||
FieldTitle = "title"
|
||||
FieldOnline = "online"
|
||||
FieldBody = "body"
|
||||
FieldFilename = "filename"
|
||||
FieldDisplayName = "display_name"
|
||||
FieldWidth = "width"
|
||||
FieldHeight = "height"
|
||||
FieldFit = "fit"
|
||||
FieldFormat = "format"
|
||||
FieldQuality = "quality"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
|
||||
@@ -9,3 +9,7 @@ func NewTarget(targetType string, id uint32, name string) Target {
|
||||
func ReleaseTarget(tag string) Target {
|
||||
return Target{Type: TargetRelease, ID: tag, Name: tag}
|
||||
}
|
||||
|
||||
func VariantTarget(name string) Target {
|
||||
return Target{Type: TargetVariant, ID: name, Name: name}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var ErrNotAGif = errors.New("not a gif")
|
||||
|
||||
const (
|
||||
gifSignaturePrefix = "GIF"
|
||||
gifHeaderLength = 6
|
||||
gifScreenDescriptorSize = 7
|
||||
gifImageDescriptorSize = 9
|
||||
gifExtensionIntroducer = 0x21
|
||||
gifImageSeparator = 0x2C
|
||||
gifTrailer = 0x3B
|
||||
gifColorTableFlag = 0x80
|
||||
gifColorTableSizeMask = 0x07
|
||||
gifColorTableEntryBytes = 3
|
||||
gifFramesBeforeAnimation = 2
|
||||
)
|
||||
|
||||
func IsAnimatedGIF(source io.Reader) (bool, error) {
|
||||
reader := bufio.NewReader(source)
|
||||
if err := skipGifPreamble(reader); err != nil {
|
||||
return false, err
|
||||
}
|
||||
frames := 0
|
||||
for {
|
||||
marker, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch marker {
|
||||
case gifTrailer:
|
||||
return false, nil
|
||||
case gifExtensionIntroducer:
|
||||
err = skipGifExtension(reader)
|
||||
case gifImageSeparator:
|
||||
frames++
|
||||
if frames >= gifFramesBeforeAnimation {
|
||||
return true, nil
|
||||
}
|
||||
err = skipGifImage(reader)
|
||||
default:
|
||||
return false, ErrNotAGif
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skipGifPreamble(reader *bufio.Reader) error {
|
||||
header := make([]byte, gifHeaderLength)
|
||||
if _, err := io.ReadFull(reader, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(header[:len(gifSignaturePrefix)]) != gifSignaturePrefix {
|
||||
return ErrNotAGif
|
||||
}
|
||||
screen := make([]byte, gifScreenDescriptorSize)
|
||||
if _, err := io.ReadFull(reader, screen); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifColorTable(reader, screen[4])
|
||||
}
|
||||
|
||||
func skipGifColorTable(reader *bufio.Reader, packed byte) error {
|
||||
if packed&gifColorTableFlag == 0 {
|
||||
return nil
|
||||
}
|
||||
entries := 1 << (int(packed&gifColorTableSizeMask) + 1)
|
||||
_, err := reader.Discard(entries * gifColorTableEntryBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
func skipGifExtension(reader *bufio.Reader) error {
|
||||
if _, err := reader.ReadByte(); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifSubBlocks(reader)
|
||||
}
|
||||
|
||||
func skipGifImage(reader *bufio.Reader) error {
|
||||
descriptor := make([]byte, gifImageDescriptorSize)
|
||||
if _, err := io.ReadFull(reader, descriptor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := skipGifColorTable(reader, descriptor[8]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := reader.ReadByte(); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifSubBlocks(reader)
|
||||
}
|
||||
|
||||
func skipGifSubBlocks(reader *bufio.Reader) error {
|
||||
for {
|
||||
size, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := reader.Discard(int(size)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type Repository interface {
|
||||
Rename(id uint32, filename string) (Image, error)
|
||||
ListDuplicates() ([]Image, error)
|
||||
Counts() (Counts, error)
|
||||
ExistsByStoredName(storedName string) (bool, error)
|
||||
ListMissingMetadata() ([]Image, error)
|
||||
UpdateMetadata(id uint32, metadata Metadata) error
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrVariantNotFound = errors.New("variant not found")
|
||||
var ErrVariantExists = errors.New("variant already exists")
|
||||
var ErrVariantInvalid = errors.New("variant is not valid")
|
||||
var ErrVariantFormatUnavailable = errors.New("variant format cannot be encoded")
|
||||
var ErrUnknownVariant = errors.New("unknown variant")
|
||||
var ErrUnsupportedParameter = errors.New("unsupported parameter")
|
||||
var ErrSourceTooLarge = errors.New("source image is too large to generate this variant")
|
||||
|
||||
type VariantFit string
|
||||
|
||||
const (
|
||||
FitCover VariantFit = "cover"
|
||||
FitContain VariantFit = "contain"
|
||||
FitInside VariantFit = "inside"
|
||||
)
|
||||
|
||||
type VariantFormat string
|
||||
|
||||
const (
|
||||
FormatOriginal VariantFormat = "original"
|
||||
FormatJPEG VariantFormat = "jpeg"
|
||||
FormatPNG VariantFormat = "png"
|
||||
FormatWebP VariantFormat = "webp"
|
||||
)
|
||||
|
||||
const (
|
||||
MinVariantNameLength = 2
|
||||
MaxVariantNameLength = 32
|
||||
MinVariantDimension = 1
|
||||
MaxVariantDimension = 8000
|
||||
MinVariantQuality = 1
|
||||
MaxVariantQuality = 100
|
||||
DefaultVariantQuality = 82
|
||||
DefaultVariantFit = FitCover
|
||||
DefaultVariantFormat = FormatOriginal
|
||||
VariantQueryParameter = "v"
|
||||
)
|
||||
|
||||
var variantNamePattern = regexp.MustCompile(`^[a-z0-9-]{2,32}$`)
|
||||
|
||||
var unsupportedQueryParameters = []string{"width", "height", "format", "quality"}
|
||||
|
||||
func VariantFits() []VariantFit {
|
||||
return []VariantFit{FitCover, FitContain, FitInside}
|
||||
}
|
||||
|
||||
func VariantFormats() []VariantFormat {
|
||||
return []VariantFormat{FormatOriginal, FormatJPEG, FormatPNG, FormatWebP}
|
||||
}
|
||||
|
||||
func UnsupportedQueryParameters() []string {
|
||||
return append([]string(nil), unsupportedQueryParameters...)
|
||||
}
|
||||
|
||||
func IsValidVariantName(name string) bool {
|
||||
return variantNamePattern.MatchString(name)
|
||||
}
|
||||
|
||||
type VariantSettings struct {
|
||||
Width *int
|
||||
Height *int
|
||||
Fit VariantFit
|
||||
Format VariantFormat
|
||||
Quality *int
|
||||
}
|
||||
|
||||
func (s VariantSettings) Normalized() (VariantSettings, error) {
|
||||
normalized := s
|
||||
if normalized.Fit == "" {
|
||||
normalized.Fit = DefaultVariantFit
|
||||
}
|
||||
if normalized.Format == "" {
|
||||
normalized.Format = DefaultVariantFormat
|
||||
}
|
||||
if !isKnownFit(normalized.Fit) || !isKnownFormat(normalized.Format) {
|
||||
return VariantSettings{}, ErrVariantInvalid
|
||||
}
|
||||
if !hasValidDimensions(normalized.Width, normalized.Height) {
|
||||
return VariantSettings{}, ErrVariantInvalid
|
||||
}
|
||||
quality, err := normalizedQuality(normalized.Format, normalized.Quality)
|
||||
if err != nil {
|
||||
return VariantSettings{}, err
|
||||
}
|
||||
normalized.Quality = quality
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (s VariantSettings) Fingerprint() string {
|
||||
parts := []string{
|
||||
"w=" + DimensionText(s.Width),
|
||||
"h=" + DimensionText(s.Height),
|
||||
"fit=" + string(s.Fit),
|
||||
"format=" + string(s.Format),
|
||||
"q=" + QualityText(s.Quality),
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, ";")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s VariantSettings) IsSameAs(other VariantSettings) bool {
|
||||
return s.Fingerprint() == other.Fingerprint()
|
||||
}
|
||||
|
||||
func isKnownFit(fit VariantFit) bool {
|
||||
for _, known := range VariantFits() {
|
||||
if fit == known {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isKnownFormat(format VariantFormat) bool {
|
||||
for _, known := range VariantFormats() {
|
||||
if format == known {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasValidDimensions(width *int, height *int) bool {
|
||||
if width == nil && height == nil {
|
||||
return false
|
||||
}
|
||||
return isValidDimension(width) && isValidDimension(height)
|
||||
}
|
||||
|
||||
func isValidDimension(dimension *int) bool {
|
||||
return dimension == nil || (*dimension >= MinVariantDimension && *dimension <= MaxVariantDimension)
|
||||
}
|
||||
|
||||
func normalizedQuality(format VariantFormat, quality *int) (*int, error) {
|
||||
if format == FormatPNG {
|
||||
return nil, nil
|
||||
}
|
||||
if quality == nil {
|
||||
defaultQuality := DefaultVariantQuality
|
||||
return &defaultQuality, nil
|
||||
}
|
||||
if *quality < MinVariantQuality || *quality > MaxVariantQuality {
|
||||
return nil, ErrVariantInvalid
|
||||
}
|
||||
kept := *quality
|
||||
return &kept, nil
|
||||
}
|
||||
|
||||
func DimensionText(dimension *int) string {
|
||||
if dimension == nil {
|
||||
return "auto"
|
||||
}
|
||||
return strconv.Itoa(*dimension)
|
||||
}
|
||||
|
||||
func QualityText(quality *int) string {
|
||||
if quality == nil {
|
||||
return "none"
|
||||
}
|
||||
return strconv.Itoa(*quality)
|
||||
}
|
||||
|
||||
type Variant struct {
|
||||
Name string `json:"name"`
|
||||
Width *int `json:"width"`
|
||||
Height *int `json:"height"`
|
||||
Fit VariantFit `json:"fit"`
|
||||
Format VariantFormat `json:"format"`
|
||||
Quality *int `json:"quality"`
|
||||
GenerateOnUpload bool `json:"generate_on_upload"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func NewVariant(name string, settings VariantSettings, generateOnUpload bool, enabled bool, now time.Time) (Variant, error) {
|
||||
if !IsValidVariantName(name) {
|
||||
return Variant{}, ErrVariantInvalid
|
||||
}
|
||||
normalized, err := settings.Normalized()
|
||||
if err != nil {
|
||||
return Variant{}, err
|
||||
}
|
||||
variant := Variant{Name: name, GenerateOnUpload: generateOnUpload, Enabled: enabled, CreatedAt: now, UpdatedAt: now}
|
||||
return variant.WithSettings(normalized), nil
|
||||
}
|
||||
|
||||
func (v Variant) Settings() VariantSettings {
|
||||
return VariantSettings{Width: v.Width, Height: v.Height, Fit: v.Fit, Format: v.Format, Quality: v.Quality}
|
||||
}
|
||||
|
||||
func (v Variant) WithSettings(settings VariantSettings) Variant {
|
||||
v.Width, v.Height, v.Fit, v.Format, v.Quality = settings.Width, settings.Height, settings.Fit, settings.Format, settings.Quality
|
||||
return v
|
||||
}
|
||||
|
||||
type VariantFile struct {
|
||||
ID uint32
|
||||
VariantName string
|
||||
SourceName string
|
||||
CachedName string
|
||||
ContentType string
|
||||
Size int64
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type VariantFileTotals struct {
|
||||
Files int
|
||||
Bytes int64
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package image
|
||||
|
||||
import "io"
|
||||
|
||||
type VariantRepository interface {
|
||||
List() ([]Variant, error)
|
||||
Get(name string) (Variant, error)
|
||||
Create(variant Variant) (Variant, error)
|
||||
Update(variant Variant) (Variant, error)
|
||||
Delete(name string) error
|
||||
}
|
||||
|
||||
type VariantFileRepository interface {
|
||||
Find(variantName string, sourceName string) (VariantFile, bool, error)
|
||||
Save(file VariantFile) (VariantFile, error)
|
||||
ListByVariant(variantName string) ([]VariantFile, error)
|
||||
ListBySource(sourceName string) ([]VariantFile, error)
|
||||
Delete(id uint32) error
|
||||
Totals() (map[string]VariantFileTotals, error)
|
||||
}
|
||||
|
||||
type RenderedVariant struct {
|
||||
Content []byte
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type VariantRenderer interface {
|
||||
DeclaredSize(source io.Reader) (width int, height int, err error)
|
||||
Render(source io.Reader, settings VariantSettings) (RenderedVariant, error)
|
||||
EncodableFormats() []VariantFormat
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const etagDigestLength = 32
|
||||
|
||||
type VariantRequestError struct {
|
||||
cause error
|
||||
Detail string
|
||||
}
|
||||
|
||||
func NewUnknownVariantError(name string) error {
|
||||
return VariantRequestError{cause: ErrUnknownVariant, Detail: name}
|
||||
}
|
||||
|
||||
func NewUnsupportedParameterError(parameter string) error {
|
||||
return VariantRequestError{cause: ErrUnsupportedParameter, Detail: parameter}
|
||||
}
|
||||
|
||||
func (e VariantRequestError) Error() string {
|
||||
return e.cause.Error() + ": " + e.Detail
|
||||
}
|
||||
|
||||
func (e VariantRequestError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func VariantRequestDetail(err error) string {
|
||||
var requestError VariantRequestError
|
||||
if errors.As(err, &requestError) {
|
||||
return requestError.Detail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func VariantETag(settings VariantSettings, sourceName string) string {
|
||||
digest := sha256.Sum256([]byte(settings.Fingerprint() + "|" + sourceName))
|
||||
return `"` + hex.EncodeToString(digest[:])[:etagDigestLength] + `"`
|
||||
}
|
||||
|
||||
func ETagMatches(ifNoneMatch string, etag string) bool {
|
||||
for _, candidate := range strings.Split(ifNoneMatch, ",") {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(candidate), "W/")
|
||||
if trimmed == "*" || trimmed == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvImageVariantMaxSourceBytes = "IMAGE_VARIANT_MAX_SOURCE_BYTES"
|
||||
DefaultImageVariantMaxSourceBytes = "10485760"
|
||||
|
||||
EnvImageVariantMaxSourcePixels = "IMAGE_VARIANT_MAX_SOURCE_PIXELS"
|
||||
DefaultImageVariantMaxSourcePixels = "50000000"
|
||||
|
||||
EnvImageVariantMaxConcurrentRenders = "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS"
|
||||
DefaultImageVariantMaxConcurrentRenders = "4"
|
||||
)
|
||||
|
||||
func imageVariantMaxSourceBytesFromEnv() (int64, error) {
|
||||
raw := envOrDefault(EnvImageVariantMaxSourceBytes, DefaultImageVariantMaxSourceBytes)
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("%s must be a whole number of bytes greater than zero, got %q", EnvImageVariantMaxSourceBytes, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func imageVariantMaxSourcePixelsFromEnv() (int64, error) {
|
||||
raw := envOrDefault(EnvImageVariantMaxSourcePixels, DefaultImageVariantMaxSourcePixels)
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("%s must be a whole number of pixels greater than zero, got %q", EnvImageVariantMaxSourcePixels, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func imageVariantMaxConcurrentRendersFromEnv() (int, error) {
|
||||
raw := envOrDefault(EnvImageVariantMaxConcurrentRenders, DefaultImageVariantMaxConcurrentRenders)
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("%s must be a whole number of renders greater than zero, got %q", EnvImageVariantMaxConcurrentRenders, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -22,22 +22,25 @@ type AuthRateLimitSettings struct {
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Environment string
|
||||
CorsAllowedOrigins []string
|
||||
PublicURL string
|
||||
UploadDir string
|
||||
JWTSecret string
|
||||
DBFile string
|
||||
Database DatabaseSettings
|
||||
Storage StorageSettings
|
||||
DataDirPath string
|
||||
Port string
|
||||
Smtp SmtpSettings
|
||||
AuthRateLimit AuthRateLimitSettings
|
||||
TrustedProxies []string
|
||||
Version string
|
||||
UpdateFeedURL string
|
||||
Container bool
|
||||
Environment string
|
||||
CorsAllowedOrigins []string
|
||||
PublicURL string
|
||||
UploadDir string
|
||||
JWTSecret string
|
||||
DBFile string
|
||||
Database DatabaseSettings
|
||||
Storage StorageSettings
|
||||
DataDirPath string
|
||||
Port string
|
||||
Smtp SmtpSettings
|
||||
AuthRateLimit AuthRateLimitSettings
|
||||
TrustedProxies []string
|
||||
Version string
|
||||
UpdateFeedURL string
|
||||
Container bool
|
||||
ImageVariantMaxSourceBytes int64
|
||||
ImageVariantMaxSourcePixels int64
|
||||
ImageVariantMaxConcurrentRenders int
|
||||
}
|
||||
|
||||
func (s Settings) DataDir() string {
|
||||
@@ -77,6 +80,21 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
maxSourceBytes, err := imageVariantMaxSourceBytesFromEnv()
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
maxSourcePixels, err := imageVariantMaxSourcePixelsFromEnv()
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
maxConcurrentRenders, err := imageVariantMaxConcurrentRendersFromEnv()
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
return Settings{
|
||||
Environment: os.Getenv("ENVIRONMENT"),
|
||||
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
|
||||
@@ -99,9 +117,12 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
MaxAttempts: authRateLimitMaxAttempts,
|
||||
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
|
||||
},
|
||||
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
||||
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
|
||||
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
|
||||
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
||||
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
|
||||
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
|
||||
ImageVariantMaxSourceBytes: maxSourceBytes,
|
||||
ImageVariantMaxSourcePixels: maxSourcePixels,
|
||||
ImageVariantMaxConcurrentRenders: maxConcurrentRenders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
|
||||
"github.com/gen2brain/webp"
|
||||
)
|
||||
|
||||
type encoder interface {
|
||||
contentType() string
|
||||
flattensTransparency() bool
|
||||
encode(output *bytes.Buffer, source image.Image, quality int) error
|
||||
}
|
||||
|
||||
type jpegEncoder struct{}
|
||||
|
||||
func (jpegEncoder) contentType() string { return "image/jpeg" }
|
||||
|
||||
func (jpegEncoder) flattensTransparency() bool { return true }
|
||||
|
||||
func (jpegEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
|
||||
return jpeg.Encode(output, source, &jpeg.Options{Quality: quality})
|
||||
}
|
||||
|
||||
type pngEncoder struct{}
|
||||
|
||||
func (pngEncoder) contentType() string { return "image/png" }
|
||||
|
||||
func (pngEncoder) flattensTransparency() bool { return false }
|
||||
|
||||
func (pngEncoder) encode(output *bytes.Buffer, source image.Image, _ int) error {
|
||||
return png.Encode(output, source)
|
||||
}
|
||||
|
||||
type webpEncoder struct{}
|
||||
|
||||
func (webpEncoder) contentType() string { return "image/webp" }
|
||||
|
||||
func (webpEncoder) flattensTransparency() bool { return false }
|
||||
|
||||
func (webpEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
|
||||
return webp.Encode(output, source, webp.Options{Quality: quality})
|
||||
}
|
||||
|
||||
var knownEncoders = map[domain.VariantFormat]encoder{
|
||||
domain.FormatJPEG: jpegEncoder{},
|
||||
domain.FormatPNG: pngEncoder{},
|
||||
domain.FormatWebP: webpEncoder{},
|
||||
}
|
||||
|
||||
var sourceFormats = map[string]domain.VariantFormat{
|
||||
"jpeg": domain.FormatJPEG,
|
||||
"png": domain.FormatPNG,
|
||||
"webp": domain.FormatWebP,
|
||||
"gif": domain.FormatPNG,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"image"
|
||||
"math"
|
||||
)
|
||||
|
||||
type layout struct {
|
||||
canvas image.Point
|
||||
source image.Rectangle
|
||||
content image.Rectangle
|
||||
}
|
||||
|
||||
func layoutFor(source image.Point, settings domain.VariantSettings) layout {
|
||||
if settings.Width == nil || settings.Height == nil {
|
||||
return proportionalLayout(source, settings)
|
||||
}
|
||||
box := image.Pt(*settings.Width, *settings.Height)
|
||||
switch settings.Fit {
|
||||
case domain.FitContain:
|
||||
return containLayout(source, box)
|
||||
case domain.FitInside:
|
||||
return insideLayout(source, box)
|
||||
default:
|
||||
return coverLayout(source, box)
|
||||
}
|
||||
}
|
||||
|
||||
func proportionalLayout(source image.Point, settings domain.VariantSettings) layout {
|
||||
scale := 1.0
|
||||
if settings.Width != nil {
|
||||
scale = math.Min(scale, ratio(*settings.Width, source.X))
|
||||
}
|
||||
if settings.Height != nil {
|
||||
scale = math.Min(scale, ratio(*settings.Height, source.Y))
|
||||
}
|
||||
return wholeSourceLayout(source, scaled(source, scale))
|
||||
}
|
||||
|
||||
func insideLayout(source image.Point, box image.Point) layout {
|
||||
scale := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
|
||||
return wholeSourceLayout(source, scaled(source, scale))
|
||||
}
|
||||
|
||||
func wholeSourceLayout(source image.Point, output image.Point) layout {
|
||||
return layout{canvas: output, source: image.Rectangle{Max: source}, content: image.Rectangle{Max: output}}
|
||||
}
|
||||
|
||||
func coverLayout(source image.Point, box image.Point) layout {
|
||||
shrink := math.Min(1, math.Min(ratio(source.X, box.X), ratio(source.Y, box.Y)))
|
||||
output := scaled(box, shrink)
|
||||
return layout{canvas: output, source: centeredCrop(source, output), content: image.Rectangle{Max: output}}
|
||||
}
|
||||
|
||||
func containLayout(source image.Point, box image.Point) layout {
|
||||
fit := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
|
||||
content := scaled(source, fit)
|
||||
content = image.Pt(min(content.X, box.X), min(content.Y, box.Y))
|
||||
origin := image.Pt((box.X-content.X)/2, (box.Y-content.Y)/2)
|
||||
return layout{canvas: box, source: image.Rectangle{Max: source}, content: image.Rectangle{Min: origin, Max: origin.Add(content)}}
|
||||
}
|
||||
|
||||
func centeredCrop(source image.Point, output image.Point) image.Rectangle {
|
||||
crop := source
|
||||
if source.X*output.Y >= source.Y*output.X {
|
||||
crop.X = min(source.X, roundedDivision(source.Y*output.X, output.Y))
|
||||
} else {
|
||||
crop.Y = min(source.Y, roundedDivision(source.X*output.Y, output.X))
|
||||
}
|
||||
origin := image.Pt((source.X-crop.X)/2, (source.Y-crop.Y)/2)
|
||||
return image.Rectangle{Min: origin, Max: origin.Add(crop)}
|
||||
}
|
||||
|
||||
func ratio(numerator int, denominator int) float64 {
|
||||
return float64(numerator) / float64(denominator)
|
||||
}
|
||||
|
||||
func roundedDivision(numerator int, denominator int) int {
|
||||
return int(math.Round(ratio(numerator, denominator)))
|
||||
}
|
||||
|
||||
func scaled(size image.Point, factor float64) image.Point {
|
||||
return image.Pt(scaledLength(size.X, factor), scaledLength(size.Y, factor))
|
||||
}
|
||||
|
||||
func scaledLength(length int, factor float64) int {
|
||||
return max(1, int(math.Round(float64(length)*factor)))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"slices"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type VariantRenderer struct {
|
||||
encoders map[domain.VariantFormat]encoder
|
||||
}
|
||||
|
||||
var _ domain.VariantRenderer = &VariantRenderer{}
|
||||
|
||||
func NewVariantRenderer() *VariantRenderer {
|
||||
return NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG, domain.FormatWebP)
|
||||
}
|
||||
|
||||
func NewVariantRendererEncoding(formats ...domain.VariantFormat) *VariantRenderer {
|
||||
encoders := map[domain.VariantFormat]encoder{}
|
||||
for format, candidate := range knownEncoders {
|
||||
if slices.Contains(formats, format) {
|
||||
encoders[format] = candidate
|
||||
}
|
||||
}
|
||||
return &VariantRenderer{encoders}
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) EncodableFormats() []domain.VariantFormat {
|
||||
formats := []domain.VariantFormat{domain.FormatOriginal}
|
||||
for _, format := range domain.VariantFormats() {
|
||||
if _, encodable := r.encoders[format]; encodable {
|
||||
formats = append(formats, format)
|
||||
}
|
||||
}
|
||||
return formats
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) DeclaredSize(source io.Reader) (int, int, error) {
|
||||
header, _, err := image.DecodeConfig(source)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return header.Width, header.Height, nil
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
|
||||
decoded, sourceFormat, err := image.Decode(source)
|
||||
if err != nil {
|
||||
return domain.RenderedVariant{}, err
|
||||
}
|
||||
chosen := r.encoderFor(settings.Format, sourceFormat)
|
||||
arranged := arrange(decoded, layoutFor(decoded.Bounds().Size(), settings), chosen.flattensTransparency())
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := chosen.encode(&output, arranged, qualityOf(settings)); err != nil {
|
||||
return domain.RenderedVariant{}, err
|
||||
}
|
||||
return domain.RenderedVariant{Content: output.Bytes(), ContentType: chosen.contentType()}, nil
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) encoderFor(format domain.VariantFormat, sourceFormat string) encoder {
|
||||
if format == domain.FormatOriginal {
|
||||
format = sourceFormats[sourceFormat]
|
||||
}
|
||||
if chosen, encodable := r.encoders[format]; encodable {
|
||||
return chosen
|
||||
}
|
||||
return pngEncoder{}
|
||||
}
|
||||
|
||||
func qualityOf(settings domain.VariantSettings) int {
|
||||
if settings.Quality == nil {
|
||||
return domain.DefaultVariantQuality
|
||||
}
|
||||
return *settings.Quality
|
||||
}
|
||||
|
||||
func arrange(decoded image.Image, plan layout, onWhite bool) image.Image {
|
||||
canvas := image.NewRGBA(image.Rectangle{Max: plan.canvas})
|
||||
if onWhite {
|
||||
xdraw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, xdraw.Src)
|
||||
}
|
||||
sourceRegion := plan.source.Add(decoded.Bounds().Min)
|
||||
if plan.source.Size() == plan.content.Size() {
|
||||
xdraw.Draw(canvas, plan.content, decoded, sourceRegion.Min, xdraw.Over)
|
||||
return canvas
|
||||
}
|
||||
xdraw.CatmullRom.Scale(canvas, plan.content, decoded, sourceRegion, xdraw.Over, nil)
|
||||
return canvas
|
||||
}
|
||||
@@ -7,9 +7,29 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB) error {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureImagePathIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return BackfillUserRoles(db)
|
||||
}
|
||||
|
||||
const (
|
||||
imagePathIndexName = "idx_images_path"
|
||||
mysqlDialectorName = "mysql"
|
||||
imagePathIndexStatement = "CREATE INDEX " + imagePathIndexName + " ON images (path)"
|
||||
mysqlImagePathStatement = "CREATE INDEX " + imagePathIndexName + " ON images (path(191))"
|
||||
)
|
||||
|
||||
func ensureImagePathIndex(db *gorm.DB) error {
|
||||
if db.Migrator().HasIndex(&models.Image{}, imagePathIndexName) {
|
||||
return nil
|
||||
}
|
||||
if db.Dialector.Name() == mysqlDialectorName {
|
||||
return db.Exec(mysqlImagePathStatement).Error
|
||||
}
|
||||
return db.Exec(imagePathIndexStatement).Error
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func VariantToDomain(variantModel models.ImageVariant) image.Variant {
|
||||
return image.Variant{
|
||||
Name: variantModel.Name,
|
||||
Width: variantModel.Width,
|
||||
Height: variantModel.Height,
|
||||
Fit: image.VariantFit(variantModel.Fit),
|
||||
Format: image.VariantFormat(variantModel.Format),
|
||||
Quality: variantModel.Quality,
|
||||
GenerateOnUpload: variantModel.GenerateOnUpload,
|
||||
Enabled: variantModel.Enabled,
|
||||
CreatedAt: variantModel.CreatedAt,
|
||||
UpdatedAt: variantModel.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func VariantToModel(variantDomain image.Variant) models.ImageVariant {
|
||||
return models.ImageVariant{
|
||||
Name: variantDomain.Name,
|
||||
Width: variantDomain.Width,
|
||||
Height: variantDomain.Height,
|
||||
Fit: string(variantDomain.Fit),
|
||||
Format: string(variantDomain.Format),
|
||||
Quality: variantDomain.Quality,
|
||||
GenerateOnUpload: variantDomain.GenerateOnUpload,
|
||||
Enabled: variantDomain.Enabled,
|
||||
CreatedAt: variantDomain.CreatedAt,
|
||||
UpdatedAt: variantDomain.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func VariantFileToDomain(fileModel models.ImageVariantFile) image.VariantFile {
|
||||
return image.VariantFile{
|
||||
ID: fileModel.ID,
|
||||
VariantName: fileModel.VariantName,
|
||||
SourceName: fileModel.SourceName,
|
||||
CachedName: fileModel.CachedName,
|
||||
ContentType: fileModel.ContentType,
|
||||
Size: fileModel.Size,
|
||||
Fingerprint: fileModel.Fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
func VariantFileToModel(fileDomain image.VariantFile) models.ImageVariantFile {
|
||||
return models.ImageVariantFile{
|
||||
ID: fileDomain.ID,
|
||||
VariantName: fileDomain.VariantName,
|
||||
SourceName: fileDomain.SourceName,
|
||||
CachedName: fileDomain.CachedName,
|
||||
ContentType: fileDomain.ContentType,
|
||||
Size: fileDomain.Size,
|
||||
Fingerprint: fileDomain.Fingerprint,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ImageVariantFile struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
VariantName string `gorm:"not null;size:32;uniqueIndex:idx_image_variant_files_pair,priority:1"`
|
||||
SourceName string `gorm:"not null;size:191;uniqueIndex:idx_image_variant_files_pair,priority:2;index:idx_image_variant_files_source"`
|
||||
CachedName string `gorm:"not null;size:191"`
|
||||
ContentType string `gorm:"not null;size:64"`
|
||||
Size int64 `gorm:"not null"`
|
||||
Fingerprint string `gorm:"not null;size:64"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ImageVariant struct {
|
||||
Name string `gorm:"primaryKey;size:32"`
|
||||
Width *int
|
||||
Height *int
|
||||
Fit string `gorm:"not null;size:16"`
|
||||
Format string `gorm:"not null;size:16"`
|
||||
Quality *int
|
||||
GenerateOnUpload bool `gorm:"not null"`
|
||||
Enabled bool `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -101,6 +101,12 @@ func (i ImageRepository) Counts() (domain.Counts, error) {
|
||||
return domain.Counts{All: int(all), DuplicateGroups: int(groups)}, nil
|
||||
}
|
||||
|
||||
func (i ImageRepository) ExistsByStoredName(storedName string) (bool, error) {
|
||||
var matches int64
|
||||
err := i.db.Model(&entity.Image{}).Where("path = ?", domain.PublicPath(storedName)).Limit(1).Count(&matches).Error
|
||||
return matches > 0, err
|
||||
}
|
||||
|
||||
func (i ImageRepository) duplicatedHashes() *gorm.DB {
|
||||
return i.db.Model(&entity.Image{}).
|
||||
Select("sha256").
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type VariantFileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var _ domain.VariantFileRepository = &VariantFileRepository{}
|
||||
|
||||
func NewVariantFileRepository(db *gorm.DB) *VariantFileRepository {
|
||||
return &VariantFileRepository{db}
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
|
||||
var model entity.ImageVariantFile
|
||||
err := v.db.Where("variant_name = ? AND source_name = ?", variantName, sourceName).First(&model).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domain.VariantFile{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.VariantFile{}, false, err
|
||||
}
|
||||
return mappers.VariantFileToDomain(model), true, nil
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Save(file domain.VariantFile) (domain.VariantFile, error) {
|
||||
model := mappers.VariantFileToModel(file)
|
||||
model.ID = 0
|
||||
err := v.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "variant_name"}, {Name: "source_name"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"cached_name", "content_type", "size", "fingerprint"}),
|
||||
}).Create(&model).Error
|
||||
if err != nil {
|
||||
return domain.VariantFile{}, err
|
||||
}
|
||||
saved, _, err := v.Find(file.VariantName, file.SourceName)
|
||||
return saved, err
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) ListByVariant(variantName string) ([]domain.VariantFile, error) {
|
||||
return v.list("variant_name = ?", variantName)
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) ListBySource(sourceName string) ([]domain.VariantFile, error) {
|
||||
return v.list("source_name = ?", sourceName)
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) list(condition string, value string) ([]domain.VariantFile, error) {
|
||||
var found []entity.ImageVariantFile
|
||||
if err := v.db.Where(condition, value).Order("id asc").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]domain.VariantFile, 0, len(found))
|
||||
for _, model := range found {
|
||||
files = append(files, mappers.VariantFileToDomain(model))
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Delete(id uint32) error {
|
||||
return v.db.Delete(&entity.ImageVariantFile{}, id).Error
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Totals() (map[string]domain.VariantFileTotals, error) {
|
||||
var rows []struct {
|
||||
VariantName string
|
||||
Files int
|
||||
Bytes int64
|
||||
}
|
||||
err := v.db.Model(&entity.ImageVariantFile{}).
|
||||
Select("variant_name, COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes").
|
||||
Group("variant_name").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totals := make(map[string]domain.VariantFileTotals, len(rows))
|
||||
for _, row := range rows {
|
||||
totals[row.VariantName] = domain.VariantFileTotals{Files: row.Files, Bytes: row.Bytes}
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type VariantRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var _ domain.VariantRepository = &VariantRepository{}
|
||||
|
||||
func NewVariantRepository(db *gorm.DB) *VariantRepository {
|
||||
return &VariantRepository{db}
|
||||
}
|
||||
|
||||
func (v *VariantRepository) List() ([]domain.Variant, error) {
|
||||
var found []entity.ImageVariant
|
||||
if err := v.db.Order("name asc").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
variants := make([]domain.Variant, 0, len(found))
|
||||
for _, model := range found {
|
||||
variants = append(variants, mappers.VariantToDomain(model))
|
||||
}
|
||||
return variants, nil
|
||||
}
|
||||
|
||||
func (v *VariantRepository) Get(name string) (domain.Variant, error) {
|
||||
var model entity.ImageVariant
|
||||
if err := v.db.Where("name = ?", name).First(&model).Error; err != nil {
|
||||
return domain.Variant{}, mapNotFoundErr(err, domain.ErrVariantNotFound)
|
||||
}
|
||||
return mappers.VariantToDomain(model), nil
|
||||
}
|
||||
|
||||
func (v *VariantRepository) Create(variant domain.Variant) (domain.Variant, error) {
|
||||
model := mappers.VariantToModel(variant)
|
||||
result := v.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model)
|
||||
if result.Error != nil {
|
||||
return domain.Variant{}, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return domain.Variant{}, domain.ErrVariantExists
|
||||
}
|
||||
return mappers.VariantToDomain(model), nil
|
||||
}
|
||||
|
||||
func (v *VariantRepository) Update(variant domain.Variant) (domain.Variant, error) {
|
||||
model := mappers.VariantToModel(variant)
|
||||
result := v.db.Model(&entity.ImageVariant{}).Where("name = ?", model.Name).Select("*").Omit("name", "created_at").UpdateColumns(&model)
|
||||
if err := deleteOrNotFound(result, domain.ErrVariantNotFound); err != nil {
|
||||
return domain.Variant{}, err
|
||||
}
|
||||
return v.Get(variant.Name)
|
||||
}
|
||||
|
||||
func (v *VariantRepository) Delete(name string) error {
|
||||
return deleteOrNotFound(v.db.Where("name = ?", name).Delete(&entity.ImageVariant{}), domain.ErrVariantNotFound)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateVariantCommand struct {
|
||||
Name string
|
||||
VariantDefinitionCommand
|
||||
GenerateOnUpload *bool
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
type CreateVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
renderer image.VariantRenderer
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewCreateVariantUseCase(variants image.VariantRepository, renderer image.VariantRenderer, now func() time.Time, recorder audit.Recorder) *CreateVariantUseCase {
|
||||
return &CreateVariantUseCase{variants, renderer, now, recorder}
|
||||
}
|
||||
|
||||
func (u *CreateVariantUseCase) EncodableFormats() []image.VariantFormat {
|
||||
return u.renderer.EncodableFormats()
|
||||
}
|
||||
|
||||
func (u *CreateVariantUseCase) Create(ctx context.Context, command CreateVariantCommand) (VariantSummary, error) {
|
||||
variant, err := u.build(command)
|
||||
if err != nil {
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantCreate, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(variant.Name)
|
||||
created, err := u.variants.Create(variant)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
event.Params = audit.VariantParams(created.Settings())
|
||||
u.recorder.Record(ctx, event)
|
||||
return VariantSummary{Variant: created}, nil
|
||||
}
|
||||
|
||||
func (u *CreateVariantUseCase) build(command CreateVariantCommand) (image.Variant, error) {
|
||||
settings, err := command.settings(u.renderer.EncodableFormats())
|
||||
if err != nil {
|
||||
return image.Variant{}, err
|
||||
}
|
||||
generateOnUpload := command.GenerateOnUpload != nil && *command.GenerateOnUpload
|
||||
enabled := command.Enabled == nil || *command.Enabled
|
||||
return image.NewVariant(command.Name, settings, generateOnUpload, enabled, u.now())
|
||||
}
|
||||
@@ -12,8 +12,8 @@ type DeleteMediaUseCase struct {
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}, recorder}
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
|
||||
}
|
||||
|
||||
func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
type DeleteVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
cache variantCache
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewDeleteVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *DeleteVariantUseCase {
|
||||
return &DeleteVariantUseCase{variants, variantCache{files, storage}, recorder}
|
||||
}
|
||||
|
||||
func (u *DeleteVariantUseCase) Delete(ctx context.Context, name string) error {
|
||||
if _, err := u.variants.Get(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantDelete, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(name)
|
||||
purged, err := u.cache.purgeVariant(name)
|
||||
if err == nil {
|
||||
err = u.variants.Delete(name)
|
||||
}
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
event.Params = variantPurgeParams(purged)
|
||||
u.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func variantPurgeParams(purged VariantPurgeResult) map[string]any {
|
||||
return map[string]any{audit.ParamPurgedFiles: purged.Files, audit.ParamPurgedBytes: purged.Bytes}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
|
||||
type ListVariantsUseCase struct {
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
}
|
||||
|
||||
func NewListVariantsUseCase(variants image.VariantRepository, files image.VariantFileRepository) *ListVariantsUseCase {
|
||||
return &ListVariantsUseCase{variants, files}
|
||||
}
|
||||
|
||||
func (u *ListVariantsUseCase) List() ([]VariantSummary, error) {
|
||||
variants, err := u.variants.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totals, err := u.files.Totals()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries := make([]VariantSummary, 0, len(variants))
|
||||
for _, variant := range variants {
|
||||
summaries = append(summaries, summarize(variant, totals))
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
@@ -24,8 +24,8 @@ type MergeMediaUseCase struct {
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, recorder audit.Recorder) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}, recorder}
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository, articleRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
|
||||
}
|
||||
|
||||
type bodyRewrite struct {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
type PurgeVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
cache variantCache
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewPurgeVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *PurgeVariantUseCase {
|
||||
return &PurgeVariantUseCase{variants, variantCache{files, storage}, recorder}
|
||||
}
|
||||
|
||||
func (u *PurgeVariantUseCase) Purge(ctx context.Context, name string) (VariantPurgeResult, error) {
|
||||
if _, err := u.variants.Get(name); err != nil {
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantPurge, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(name)
|
||||
purged, err := u.cache.purgeVariant(name)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
if purged.Files > 0 {
|
||||
event.Params = variantPurgeParams(purged)
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
return purged, nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
svgExtension = ".svg"
|
||||
gifExtension = ".gif"
|
||||
flightKeySep = "|"
|
||||
)
|
||||
|
||||
type ServeVariantLimits struct {
|
||||
MaxSourceBytes int64
|
||||
MaxSourcePixels int64
|
||||
MaxConcurrentRenders int
|
||||
}
|
||||
|
||||
type ServeVariantRequest struct {
|
||||
Context context.Context
|
||||
StoredName string
|
||||
Variant *string
|
||||
Parameters []string
|
||||
IfNoneMatch string
|
||||
}
|
||||
|
||||
type ServedVariant struct {
|
||||
Original bool
|
||||
NotModified bool
|
||||
ETag string
|
||||
ContentType string
|
||||
Content io.ReadCloser
|
||||
Size int64
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
type ServeVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
images image.Repository
|
||||
storage image.Storage
|
||||
renderer image.VariantRenderer
|
||||
maxSourceBytes int64
|
||||
maxSourcePixels int64
|
||||
renderSlots chan struct{}
|
||||
flights singleflight.Group
|
||||
}
|
||||
|
||||
func NewServeVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, images image.Repository, storage image.Storage, renderer image.VariantRenderer, limits ServeVariantLimits) *ServeVariantUseCase {
|
||||
return &ServeVariantUseCase{
|
||||
variants: variants,
|
||||
files: files,
|
||||
images: images,
|
||||
storage: storage,
|
||||
renderer: renderer,
|
||||
maxSourceBytes: limits.MaxSourceBytes,
|
||||
maxSourcePixels: limits.MaxSourcePixels,
|
||||
renderSlots: make(chan struct{}, max(1, limits.MaxConcurrentRenders)),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) Serve(request ServeVariantRequest) (ServedVariant, error) {
|
||||
if parameter := unsupportedParameter(request.Parameters); parameter != "" {
|
||||
return ServedVariant{}, image.NewUnsupportedParameterError(parameter)
|
||||
}
|
||||
if request.Variant == nil {
|
||||
return ServedVariant{Original: true}, nil
|
||||
}
|
||||
variant, err := u.enabledVariant(*request.Variant)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
return u.serveSource(variant, request)
|
||||
}
|
||||
|
||||
func unsupportedParameter(parameters []string) string {
|
||||
for _, unsupported := range image.UnsupportedQueryParameters() {
|
||||
if slices.Contains(parameters, unsupported) {
|
||||
return unsupported
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) enabledVariant(name string) (image.Variant, error) {
|
||||
if !image.IsValidVariantName(name) {
|
||||
return image.Variant{}, image.NewUnknownVariantError(name)
|
||||
}
|
||||
variant, err := u.variants.Get(name)
|
||||
if errors.Is(err, image.ErrVariantNotFound) {
|
||||
return image.Variant{}, image.NewUnknownVariantError(name)
|
||||
}
|
||||
if err != nil {
|
||||
return image.Variant{}, err
|
||||
}
|
||||
if !variant.Enabled || variant.Name != name {
|
||||
return image.Variant{}, image.NewUnknownVariantError(name)
|
||||
}
|
||||
return variant, nil
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) serveSource(variant image.Variant, request ServeVariantRequest) (ServedVariant, error) {
|
||||
known, err := u.images.ExistsByStoredName(request.StoredName)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
if !known {
|
||||
return ServedVariant{}, image.ErrImageNotFound
|
||||
}
|
||||
source, err := u.storage.Open(request.StoredName)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
defer source.Content.Close()
|
||||
|
||||
if servedAsIs(request.StoredName, source.Content) {
|
||||
return ServedVariant{Original: true}, nil
|
||||
}
|
||||
if source.Size > u.maxSourceBytes {
|
||||
return ServedVariant{}, image.ErrSourceTooLarge
|
||||
}
|
||||
|
||||
settings := variant.Settings()
|
||||
etag := image.VariantETag(settings, request.StoredName)
|
||||
if image.ETagMatches(request.IfNoneMatch, etag) {
|
||||
return ServedVariant{NotModified: true, ETag: etag}, nil
|
||||
}
|
||||
fingerprint := settings.Fingerprint()
|
||||
cached, previous, hit, err := u.lookupCached(variant.Name, request.StoredName, fingerprint, etag)
|
||||
if err != nil || hit {
|
||||
return cached, err
|
||||
}
|
||||
file, err := u.generated(requestContext(request), variant, request.StoredName, fingerprint, previous)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
return u.serveFile(file, etag)
|
||||
}
|
||||
|
||||
func requestContext(request ServeVariantRequest) context.Context {
|
||||
if request.Context == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return request.Context
|
||||
}
|
||||
|
||||
func servedAsIs(storedName string, content io.Reader) bool {
|
||||
switch strings.ToLower(path.Ext(storedName)) {
|
||||
case svgExtension:
|
||||
return true
|
||||
case gifExtension:
|
||||
animated, err := image.IsAnimatedGIF(content)
|
||||
return err == nil && animated
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) lookupCached(variantName string, storedName string, fingerprint string, etag string) (ServedVariant, image.VariantFile, bool, error) {
|
||||
existing, found, err := u.files.Find(variantName, storedName)
|
||||
if err != nil {
|
||||
return ServedVariant{}, image.VariantFile{}, false, err
|
||||
}
|
||||
if !found || existing.Fingerprint != fingerprint {
|
||||
return ServedVariant{}, existing, false, nil
|
||||
}
|
||||
served, err := u.serveFile(existing, etag)
|
||||
if errors.Is(err, image.ErrBlobNotFound) {
|
||||
return ServedVariant{}, existing, false, nil
|
||||
}
|
||||
return served, existing, err == nil, err
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) generated(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
|
||||
key := strings.Join([]string{variant.Name, fingerprint, storedName}, flightKeySep)
|
||||
for {
|
||||
file, err, _ := u.flights.Do(key, func() (any, error) {
|
||||
return u.generate(ctx, variant, storedName, fingerprint, previous)
|
||||
})
|
||||
if err == nil {
|
||||
return file.(image.VariantFile), nil
|
||||
}
|
||||
if ctx.Err() != nil || !errors.Is(err, context.Canceled) {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) generate(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
|
||||
current, found, err := u.files.Find(variant.Name, storedName)
|
||||
if err != nil {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
if found && current.Fingerprint == fingerprint && current.CachedName != previous.CachedName {
|
||||
return current, nil
|
||||
}
|
||||
|
||||
rendered, err := u.render(ctx, variant.Settings(), storedName)
|
||||
if err != nil {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
cachedName, err := u.storage.Put(rendered.ContentType, bytes.NewReader(rendered.Content))
|
||||
if err != nil {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
saved, err := u.files.Save(image.VariantFile{
|
||||
VariantName: variant.Name,
|
||||
SourceName: storedName,
|
||||
CachedName: cachedName,
|
||||
ContentType: rendered.ContentType,
|
||||
Size: int64(len(rendered.Content)),
|
||||
Fingerprint: fingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
_ = u.storage.Delete(cachedName)
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
u.discardReplaced(previous, cachedName)
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) discardReplaced(previous image.VariantFile, replacementName string) {
|
||||
if previous.CachedName != "" && previous.CachedName != replacementName {
|
||||
_ = u.storage.Delete(previous.CachedName)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) render(ctx context.Context, settings image.VariantSettings, storedName string) (image.RenderedVariant, error) {
|
||||
if err := u.acquireRenderSlot(ctx); err != nil {
|
||||
return image.RenderedVariant{}, err
|
||||
}
|
||||
defer u.releaseRenderSlot()
|
||||
|
||||
source, err := u.storage.Open(storedName)
|
||||
if err != nil {
|
||||
return image.RenderedVariant{}, err
|
||||
}
|
||||
defer source.Content.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(source.Content, u.maxSourceBytes+1))
|
||||
if err != nil {
|
||||
return image.RenderedVariant{}, err
|
||||
}
|
||||
if int64(len(data)) > u.maxSourceBytes {
|
||||
return image.RenderedVariant{}, image.ErrSourceTooLarge
|
||||
}
|
||||
if err := u.checkDeclaredPixels(data); err != nil {
|
||||
return image.RenderedVariant{}, err
|
||||
}
|
||||
return u.renderer.Render(bytes.NewReader(data), settings)
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) checkDeclaredPixels(data []byte) error {
|
||||
width, height, err := u.renderer.DeclaredSize(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(width)*int64(height) > u.maxSourcePixels {
|
||||
return image.ErrSourceTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) acquireRenderSlot(ctx context.Context) error {
|
||||
select {
|
||||
case u.renderSlots <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) releaseRenderSlot() {
|
||||
<-u.renderSlots
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) serveFile(file image.VariantFile, etag string) (ServedVariant, error) {
|
||||
blob, err := u.storage.Open(file.CachedName)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
return ServedVariant{ETag: etag, ContentType: file.ContentType, Content: blob.Content, Size: blob.Size, ModTime: blob.ModTime}, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SetVariantEnabledUseCase struct {
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewSetVariantEnabledUseCase(variants image.VariantRepository, files image.VariantFileRepository, now func() time.Time, recorder audit.Recorder) *SetVariantEnabledUseCase {
|
||||
return &SetVariantEnabledUseCase{variants, files, now, recorder}
|
||||
}
|
||||
|
||||
func (u *SetVariantEnabledUseCase) SetEnabled(ctx context.Context, name string, enabled bool) (VariantSummary, error) {
|
||||
current, err := u.variants.Get(name)
|
||||
if err != nil {
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
if current.Enabled == enabled {
|
||||
return summaryOf(current, u.files)
|
||||
}
|
||||
|
||||
next := current
|
||||
next.Enabled = enabled
|
||||
next.UpdatedAt = u.now()
|
||||
updated, err := u.variants.Update(next)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, enabledEvent(next), err)
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
u.recorder.Record(ctx, enabledEvent(updated))
|
||||
return summaryOf(updated, u.files)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdateVariantCommand struct {
|
||||
Name string
|
||||
VariantDefinitionCommand
|
||||
GenerateOnUpload *bool
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
type UpdateVariantResult struct {
|
||||
VariantSummary
|
||||
PurgedFiles int
|
||||
}
|
||||
|
||||
type UpdateVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
cache variantCache
|
||||
renderer image.VariantRenderer
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewUpdateVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, renderer image.VariantRenderer, now func() time.Time, recorder audit.Recorder) *UpdateVariantUseCase {
|
||||
return &UpdateVariantUseCase{variants, files, variantCache{files, storage}, renderer, now, recorder}
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) EncodableFormats() []image.VariantFormat {
|
||||
return u.renderer.EncodableFormats()
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) Update(ctx context.Context, command UpdateVariantCommand) (UpdateVariantResult, error) {
|
||||
current, err := u.variants.Get(command.Name)
|
||||
if err != nil {
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
settings, err := command.settings(u.renderer.EncodableFormats())
|
||||
if err != nil {
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
|
||||
next := withSwitches(current.WithSettings(settings), command)
|
||||
renderingChanged := !settings.IsSameAs(current.Settings())
|
||||
if !renderingChanged && next.Enabled == current.Enabled && next.GenerateOnUpload == current.GenerateOnUpload {
|
||||
return u.result(current, 0)
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantUpdate, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(current.Name)
|
||||
purged, err := u.purgeIfRendering(renderingChanged, current.Name)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
next.UpdatedAt = u.now()
|
||||
updated, err := u.variants.Update(next)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
u.record(ctx, event, current, updated, renderingChanged, purged)
|
||||
return u.result(updated, purged)
|
||||
}
|
||||
|
||||
func withSwitches(variant image.Variant, command UpdateVariantCommand) image.Variant {
|
||||
if command.GenerateOnUpload != nil {
|
||||
variant.GenerateOnUpload = *command.GenerateOnUpload
|
||||
}
|
||||
if command.Enabled != nil {
|
||||
variant.Enabled = *command.Enabled
|
||||
}
|
||||
return variant
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) purgeIfRendering(renderingChanged bool, name string) (int, error) {
|
||||
if !renderingChanged {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := u.cache.purgeVariant(name)
|
||||
return result.Files, err
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) record(ctx context.Context, event audit.Event, before image.Variant, after image.Variant, renderingChanged bool, purged int) {
|
||||
if renderingChanged {
|
||||
event.Changes = audit.VariantChanges(before.Settings(), after.Settings())
|
||||
event.Params = map[string]any{audit.ParamPurgedFiles: purged}
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
if before.Enabled != after.Enabled {
|
||||
u.recorder.Record(ctx, enabledEvent(after))
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) result(variant image.Variant, purged int) (UpdateVariantResult, error) {
|
||||
summary, err := summaryOf(variant, u.files)
|
||||
if err != nil {
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
return UpdateVariantResult{summary, purged}, nil
|
||||
}
|
||||
|
||||
func enabledEvent(variant image.Variant) audit.Event {
|
||||
action := audit.ActionVariantDisable
|
||||
if variant.Enabled {
|
||||
action = audit.ActionVariantEnable
|
||||
}
|
||||
event := audit.NewEvent(action, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(variant.Name)
|
||||
return event
|
||||
}
|
||||
@@ -29,6 +29,7 @@ var failureReasons = []failureReason{
|
||||
{image.ErrInvalidMergeRequest, "The merge request is not valid."},
|
||||
{image.ErrMergeCopyMissing, "One of the copies no longer exists."},
|
||||
{image.ErrMergeHashMismatch, "The images are not identical copies."},
|
||||
{image.ErrVariantExists, "A variant with that name already exists."},
|
||||
{purge.ErrPurgeConflict, "The library changed since the preview. Review it again."},
|
||||
{purge.ErrPurgeBusy, "A purge is already running."},
|
||||
{user.ErrRegistrationClosed, "Registration is closed on this site."},
|
||||
@@ -65,6 +66,7 @@ var downloadFailureReasons = map[string]string{
|
||||
var notFoundCauses = []error{
|
||||
article.ErrArticleNotFound,
|
||||
image.ErrImageNotFound,
|
||||
image.ErrVariantNotFound,
|
||||
invite.ErrInviteNotFound,
|
||||
user.ErrUserNotFound,
|
||||
}
|
||||
|
||||
@@ -5,10 +5,19 @@ import "RenewCMS/internal/domain/image"
|
||||
type mediaRemover struct {
|
||||
imageRepository image.Repository
|
||||
storage image.Storage
|
||||
generated variantCache
|
||||
}
|
||||
|
||||
func newMediaRemover(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository) mediaRemover {
|
||||
return mediaRemover{imageRepository, storage, variantCache{variantFiles, storage}}
|
||||
}
|
||||
|
||||
func (r mediaRemover) remove(img image.Image) error {
|
||||
if err := r.storage.Delete(image.StoredName(img.Path)); err != nil {
|
||||
storedName := image.StoredName(img.Path)
|
||||
if err := r.generated.purgeSource(storedName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.storage.Delete(storedName); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.imageRepository.Delete(img.ID)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
|
||||
type VariantPurgeResult struct {
|
||||
Files int
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
type variantCache struct {
|
||||
files image.VariantFileRepository
|
||||
storage image.Storage
|
||||
}
|
||||
|
||||
func (c variantCache) purgeVariant(name string) (VariantPurgeResult, error) {
|
||||
rows, err := c.files.ListByVariant(name)
|
||||
if err != nil {
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
return c.purge(rows)
|
||||
}
|
||||
|
||||
func (c variantCache) purgeSource(storedName string) error {
|
||||
rows, err := c.files.ListBySource(storedName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.purge(rows)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c variantCache) purge(rows []image.VariantFile) (VariantPurgeResult, error) {
|
||||
var result VariantPurgeResult
|
||||
var failure error
|
||||
for _, row := range rows {
|
||||
if err := c.purgeRow(row); err != nil {
|
||||
failure = err
|
||||
continue
|
||||
}
|
||||
result.Files++
|
||||
result.Bytes += row.Size
|
||||
}
|
||||
return result, failure
|
||||
}
|
||||
|
||||
func (c variantCache) purgeRow(row image.VariantFile) error {
|
||||
if err := c.storage.Delete(row.CachedName); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.files.Delete(row.ID)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type VariantDefinitionCommand struct {
|
||||
Width *int
|
||||
Height *int
|
||||
Fit string
|
||||
Format string
|
||||
Quality *int
|
||||
}
|
||||
|
||||
func (c VariantDefinitionCommand) settings(encodable []image.VariantFormat) (image.VariantSettings, error) {
|
||||
settings, err := image.VariantSettings{
|
||||
Width: c.Width,
|
||||
Height: c.Height,
|
||||
Fit: image.VariantFit(c.Fit),
|
||||
Format: image.VariantFormat(c.Format),
|
||||
Quality: c.Quality,
|
||||
}.Normalized()
|
||||
if err != nil {
|
||||
return image.VariantSettings{}, err
|
||||
}
|
||||
if !slices.Contains(encodable, settings.Format) {
|
||||
return image.VariantSettings{}, image.ErrVariantFormatUnavailable
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
|
||||
type VariantSummary struct {
|
||||
Variant image.Variant
|
||||
GeneratedFiles int
|
||||
CacheSize int64
|
||||
}
|
||||
|
||||
func summarize(variant image.Variant, totals map[string]image.VariantFileTotals) VariantSummary {
|
||||
own := totals[variant.Name]
|
||||
return VariantSummary{Variant: variant, GeneratedFiles: own.Files, CacheSize: own.Bytes}
|
||||
}
|
||||
|
||||
func summaryOf(variant image.Variant, files image.VariantFileRepository) (VariantSummary, error) {
|
||||
totals, err := files.Totals()
|
||||
if err != nil {
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
return summarize(variant, totals), nil
|
||||
}
|
||||
@@ -383,7 +383,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
|
||||
images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
|
||||
articles := newMockArticleRepo()
|
||||
storage := newMockImageStorage()
|
||||
merge := useCases.NewMergeMediaUseCase(images, articles, storage, h.recorder)
|
||||
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder)
|
||||
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder)
|
||||
|
||||
finished := make(chan struct{})
|
||||
|
||||
@@ -100,7 +100,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
mail := newMockMailRepo()
|
||||
env.mail = mail
|
||||
renderer := &fakeBodyRenderer{}
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), recorder)
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder)
|
||||
purgeSettings := newMockPurgeSettingsRepo()
|
||||
restartJobs := &fakeJobStore{}
|
||||
env.container = &api.UseCases{
|
||||
@@ -127,7 +127,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), recorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder),
|
||||
MergeMediaUseCase: mergeMedia,
|
||||
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder),
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
|
||||
|
||||
+13
-1
@@ -245,12 +245,24 @@ func (m *mockImageRepo) Counts() (imageDomain.Counts, error) {
|
||||
return imageDomain.Counts{All: len(m.images), DuplicateGroups: groups}, nil
|
||||
}
|
||||
|
||||
func (m *mockImageRepo) ExistsByStoredName(storedName string) (bool, error) {
|
||||
if err := m.fail.trigger("ExistsByStoredName"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, img := range m.images {
|
||||
if imageDomain.StoredName(img.Path) == storedName {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var _ imageDomain.Repository = &mockImageRepo{}
|
||||
|
||||
func TestImageUseCases(t *testing.T) {
|
||||
repo := newMockImageRepo()
|
||||
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
|
||||
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
|
||||
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
|
||||
|
||||
t.Run("Create Image", func(t *testing.T) {
|
||||
input := imageDomain.ImageInput{
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
nameSentence = "Use lowercase letters, numbers and dashes, 2 to 32 characters."
|
||||
dimensionsSentence = "Width and height must be between 1 and 8000, and one of them is required."
|
||||
fitSentence = "Fit must be cover, contain or inside."
|
||||
formatSentence = "Format must be original, jpeg, png or webp."
|
||||
formatNoWebP = "Format must be original, jpeg or png."
|
||||
qualitySentence = "Quality must be between 1 and 100."
|
||||
thumbTakenSentence = "A variant named “thumb” already exists. Choose another name."
|
||||
variantNotFound = "Variant not found"
|
||||
)
|
||||
|
||||
func assertStatusAndBody(t *testing.T, recorder *httptest.ResponseRecorder, status int, body string) {
|
||||
t.Helper()
|
||||
if recorder.Code != status || strings.TrimSpace(recorder.Body.String()) != body {
|
||||
t.Errorf("response = %d %q, want %d %q", recorder.Code, recorder.Body.String(), status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageVariantsGuards(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
credentials string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{"no cookie", http.MethodGet, "/v1/image-variants", "none", 401, apperr.UnauthorizedErrorMessage},
|
||||
{"stale cookie", http.MethodGet, "/v1/image-variants", "stale", 401, apperr.UnauthorizedErrorMessage},
|
||||
{"unverified user", http.MethodGet, "/v1/image-variants", "unverified", 403, apperr.VerificationRequiredErrorMessage},
|
||||
{"plain user list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user patch", http.MethodPatch, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user delete", http.MethodDelete, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"plain user purge", http.MethodPost, "/v1/image-variants/hero/purge", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
||||
{"api key", http.MethodGet, "/v1/image-variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run("Every variant route is guarded: "+row.name, func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
seedVariantFiles(env.files, env.storage, "hero", "x", 2, 10)
|
||||
callsBefore := env.variants.calls
|
||||
|
||||
request := httptest.NewRequest(row.method, row.path, strings.NewReader(`{"width": 100}`))
|
||||
switch row.credentials {
|
||||
case "stale":
|
||||
request.AddCookie(env.cookie(variantStaleId, 0))
|
||||
case "unverified":
|
||||
request.AddCookie(env.cookie(variantUnverifiedId, 0))
|
||||
case "plain":
|
||||
request.AddCookie(env.cookie(variantPlainUserId, 0))
|
||||
case "apikey":
|
||||
request.Header = bearerKeyHeader()
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
env.admin.ServeHTTP(recorder, request)
|
||||
|
||||
assertStatusAndBody(t, recorder, row.status, row.body)
|
||||
if env.variants.calls != callsBefore || env.files.rowCount() != 2 || len(env.storage.deletedNames()) != 0 || env.events() != 0 {
|
||||
t.Error("a variant, generated file or activity event was touched")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for name, userId := range map[string]uint32{"the owner": variantOwnerId, "an admin": variantAdminId} {
|
||||
t.Run("Owners and admins are both allowed: "+name, func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.send(http.MethodGet, "/v1/image-variants", "", userId)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageVariantsListing(t *testing.T) {
|
||||
t.Run("An empty list is an empty array", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodGet, "/v1/image-variants", "")
|
||||
|
||||
if recorder.Code != 200 || strings.TrimSpace(recorder.Body.String()) != `{"items":[]}` {
|
||||
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The list carries the generated count and cache size per variant", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82)
|
||||
card := seedVariant(env.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
|
||||
card.Enabled = false
|
||||
env.variants.variants["card"] = card
|
||||
for index, size := range []int64{1000, 2000, 3000} {
|
||||
env.files.Save(domain.VariantFile{VariantName: "hero", SourceName: string(rune('a' + index)), CachedName: "g", Size: size})
|
||||
}
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodGet, "/v1/image-variants", "")
|
||||
|
||||
items := decodeBody(t, recorder)["items"].([]any)
|
||||
first, second := items[0].(map[string]any), items[1].(map[string]any)
|
||||
if first["name"] != "card" || second["name"] != "hero" {
|
||||
t.Fatalf("order = %v, %v", first["name"], second["name"])
|
||||
}
|
||||
if first["width"] != 800.0 || first["height"] != nil || first["fit"] != "inside" || first["format"] != "jpeg" || first["quality"] != 80.0 || first["enabled"] != false || first["generated_files"] != 0.0 || first["cache_size"] != 0.0 {
|
||||
t.Errorf("card = %v", first)
|
||||
}
|
||||
if second["width"] != 1200.0 || second["height"] != 630.0 || second["fit"] != "cover" || second["format"] != "webp" || second["quality"] != 82.0 || second["enabled"] != true || second["generated_files"] != 3.0 || second["cache_size"] != 6000.0 {
|
||||
t.Errorf("hero = %v", second)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"height":null`) {
|
||||
t.Errorf("raw body = %q", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A png variant lists a null quality", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "logo", 100, 0, domain.FitCover, domain.FormatPNG, 0)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodGet, "/v1/image-variants", "")
|
||||
|
||||
if !strings.Contains(recorder.Body.String(), `"quality":null`) {
|
||||
t.Errorf("raw body = %q", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsCreate(t *testing.T) {
|
||||
t.Run("Creating a variant with every field", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}`)
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if recorder.Code != 201 || body["name"] != "hero" || body["width"] != 1200.0 || body["height"] != 630.0 || body["format"] != "webp" || body["generate_on_upload"] != true || body["generated_files"] != 0.0 || body["cache_size"] != 0.0 || env.variants.count() != 1 {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Omitted optional fields take their defaults", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "thumb", "width": 400}`)
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if recorder.Code != 201 || body["width"] != 400.0 || body["height"] != nil || body["fit"] != "cover" || body["format"] != "original" || body["quality"] != 82.0 || body["generate_on_upload"] != false || body["enabled"] != true {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A quality sent with png is ignored and returned as null", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "logo", "width": 100, "format": "png", "quality": 30}`)
|
||||
|
||||
if body := decodeBody(t, recorder); recorder.Code != 201 || body["quality"] != nil || env.variants.stored("logo").Quality != nil {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
for _, body := range []string{
|
||||
`{"name": "ab", "width": 1}`,
|
||||
`{"name": "a-b-c", "height": 8000}`,
|
||||
`{"name": "` + strings.Repeat("a", 31) + `-", "width": 8000, "height": 8000}`,
|
||||
`{"name": "q1", "width": 10, "quality": 1}`,
|
||||
`{"name": "q100", "width": 10, "quality": 100}`,
|
||||
} {
|
||||
t.Run("A valid boundary value is accepted: "+body, func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", body)
|
||||
|
||||
if recorder.Code != 201 {
|
||||
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []struct{ body, message string }{
|
||||
{`{"name": "a", "width": 10}`, nameSentence},
|
||||
{`{"name": "` + strings.Repeat("a", 33) + `", "width": 10}`, nameSentence},
|
||||
{`{"name": "Hero", "width": 10}`, nameSentence},
|
||||
{`{"name": "he ro", "width": 10}`, nameSentence},
|
||||
{`{"name": "hero"}`, dimensionsSentence},
|
||||
{`{"name": "hero", "width": 0}`, dimensionsSentence},
|
||||
{`{"name": "hero", "width": 8001}`, dimensionsSentence},
|
||||
{`{"name": "hero", "height": -1}`, dimensionsSentence},
|
||||
{`{"name": "hero", "width": 10, "fit": "stretch"}`, fitSentence},
|
||||
{`{"name": "hero", "width": 10, "format": "avif"}`, formatSentence},
|
||||
{`{"name": "hero", "width": 10, "quality": 0}`, qualitySentence},
|
||||
{`{"name": "hero", "width": 10, "quality": 101}`, qualitySentence},
|
||||
}
|
||||
for _, row := range invalid {
|
||||
t.Run("An invalid field answers 422 with the field's sentence and stores nothing: "+row.body, func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", row.body)
|
||||
|
||||
assertStatusAndBody(t, recorder, 422, row.message)
|
||||
if env.variants.count() != 0 || env.events() != 0 {
|
||||
t.Error("something was stored or recorded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, body := range []string{"not json", `{"name": "hero", "width": "wide"}`, ""} {
|
||||
t.Run("A malformed request answers 400: "+body, func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", body)
|
||||
|
||||
assertStatusAndBody(t, recorder, 400, apperr.BodyErrorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A duplicate name answers 409 and keeps the existing variant", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
before := seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "thumb", "width": 800, "format": "png"}`)
|
||||
|
||||
assertStatusAndBody(t, recorder, 409, thumbTakenSentence)
|
||||
after := env.variants.stored("thumb")
|
||||
if *after.Width != 400 || after.Format != domain.FormatJPEG || !after.CreatedAt.Equal(before.CreatedAt) {
|
||||
t.Errorf("stored = %+v", after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsWebPAvailability(t *testing.T) {
|
||||
t.Run("When no encoder is viable, webp is refused", func(t *testing.T) {
|
||||
env := newVariantEnvWith(t, newCountingRendererWithoutWebP(), variantDefaultCap)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "wp", "width": 10, "format": "webp"}`)
|
||||
|
||||
assertStatusAndBody(t, recorder, 422, formatNoWebP)
|
||||
if env.variants.count() != 0 || env.events() != 0 {
|
||||
t.Error("something was stored or recorded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Without an encoder an unknown format uses the shorter sentence", func(t *testing.T) {
|
||||
env := newVariantEnvWith(t, newCountingRendererWithoutWebP(), variantDefaultCap)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "wp", "width": 10, "format": "avif"}`)
|
||||
|
||||
assertStatusAndBody(t, recorder, 422, formatNoWebP)
|
||||
})
|
||||
|
||||
t.Run("Without an encoder original, jpeg and png still work", func(t *testing.T) {
|
||||
env := newVariantEnvWith(t, newCountingRendererWithoutWebP(), variantDefaultCap)
|
||||
|
||||
for _, format := range []string{"original", "jpeg", "png"} {
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "v-`+format+`", "width": 10, "format": "`+format+`"}`)
|
||||
if recorder.Code != 201 {
|
||||
t.Errorf("%s: status = %d", format, recorder.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Without an encoder an update to webp is refused", func(t *testing.T) {
|
||||
env := newVariantEnvWith(t, newCountingRendererWithoutWebP(), variantDefaultCap)
|
||||
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"width": 100, "format": "webp"}`)
|
||||
|
||||
assertStatusAndBody(t, recorder, 422, formatNoWebP)
|
||||
})
|
||||
|
||||
t.Run("When an encoder exists, webp is offered", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "wp", "width": 10, "format": "webp"}`)
|
||||
|
||||
if recorder.Code != 201 {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type updateFixture struct {
|
||||
env *variantEnv
|
||||
}
|
||||
|
||||
func newUpdateFixture(t *testing.T) updateFixture {
|
||||
t.Helper()
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82)
|
||||
seedVariant(env.variants, "thumb", 400, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
seedVariantFiles(env.files, env.storage, "hero", "src", 244, 344000)
|
||||
seedVariantFiles(env.files, env.storage, "thumb", "src", 3, 10)
|
||||
return updateFixture{env}
|
||||
}
|
||||
|
||||
const storedHeroBody = `"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": false, "enabled": true`
|
||||
|
||||
func TestImageVariantsUpdate(t *testing.T) {
|
||||
t.Run("Changing quality updates the variant and purges its generated files", func(t *testing.T) {
|
||||
fixture := newUpdateFixture(t)
|
||||
env := fixture.env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70, "generate_on_upload": false, "enabled": true}`)
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if recorder.Code != 200 || body["quality"] != 70.0 || body["generated_files"] != 0.0 || body["cache_size"] != 0.0 || body["purged_files"] != 244.0 {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
if len(env.storage.deletedNames()) != 244 || len(env.files.rowsOf("hero")) != 0 || len(env.files.rowsOf("thumb")) != 3 {
|
||||
t.Errorf("deleted = %d, hero rows = %d, thumb rows = %d", len(env.storage.deletedNames()), len(env.files.rowsOf("hero")), len(env.files.rowsOf("thumb")))
|
||||
}
|
||||
if env.variants.stored("hero").UpdatedAt.Equal(env.variants.stored("hero").CreatedAt) {
|
||||
t.Error("updated_at did not move")
|
||||
}
|
||||
})
|
||||
|
||||
settings := []struct{ setting, body string }{
|
||||
{"width", `{"width": 1000, "height": 630, "fit": "cover", "format": "webp", "quality": 82}`},
|
||||
{"height", `{"width": 1200, "height": null, "fit": "cover", "format": "webp", "quality": 82}`},
|
||||
{"fit", `{"width": 1200, "height": 630, "fit": "inside", "format": "webp", "quality": 82}`},
|
||||
{"format", `{"width": 1200, "height": 630, "fit": "cover", "format": "jpeg", "quality": 82}`},
|
||||
{"quality", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 60}`},
|
||||
}
|
||||
for _, row := range settings {
|
||||
t.Run("A change to a rendering setting purges: "+row.setting, func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", row.body)
|
||||
|
||||
if body := decodeBody(t, recorder); recorder.Code != 200 || body["purged_files"] != 244.0 {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
switches := []struct{ setting, body string }{
|
||||
{"generate_on_upload", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}`},
|
||||
{"enabled", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": false, "enabled": false}`},
|
||||
}
|
||||
for _, row := range switches {
|
||||
t.Run("A change to a switch does not purge: "+row.setting, func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", row.body)
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if recorder.Code != 200 || body["purged_files"] != 0.0 || body["generated_files"] != 244.0 || len(env.storage.deletedNames()) != 0 {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A PUT identical to the stored variant changes nothing", func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
before := env.variants.stored("hero")
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{`+storedHeroBody+`}`)
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if recorder.Code != 200 || body["purged_files"] != 0.0 || !env.variants.stored("hero").UpdatedAt.Equal(before.UpdatedAt) || env.events() != 0 {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Switching the format to png drops the quality and purges", func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "png", "quality": 40}`)
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if recorder.Code != 200 || body["quality"] != nil || body["purged_files"] != 244.0 {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Changing the quality of a png variant is a no-op", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "logo", 100, 0, domain.FitCover, domain.FormatPNG, 0)
|
||||
seedVariantFiles(env.files, env.storage, "logo", "src", 2, 10)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/logo", `{"width": 100, "fit": "cover", "format": "png", "quality": 30}`)
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if body["purged_files"] != 0.0 || body["quality"] != nil || env.events() != 0 {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The name is immutable", func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"name": "banner", "width": 1200, "height": 630}`)
|
||||
|
||||
if env.variants.stored("hero").Name != "hero" || env.variants.stored("banner").Name != "" {
|
||||
t.Error("the variant was renamed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Omitted switches keep their stored values", func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
hero := env.variants.stored("hero")
|
||||
hero.Enabled, hero.GenerateOnUpload = false, true
|
||||
env.variants.variants["hero"] = hero
|
||||
|
||||
env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "format": "webp", "quality": 70}`)
|
||||
|
||||
if stored := env.variants.stored("hero"); stored.Enabled || !stored.GenerateOnUpload {
|
||||
t.Errorf("stored = %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
invalid := []string{`{"quality": 101}`, `{"width": null, "height": null}`, `{"width": 9000}`, `{"fit": "stretch"}`}
|
||||
for _, body := range invalid {
|
||||
t.Run("An invalid PUT answers like a create and changes nothing: "+body, func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
before := env.variants.stored("hero")
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", body)
|
||||
|
||||
if recorder.Code != 422 || env.variants.stored("hero").UpdatedAt != before.UpdatedAt || len(env.files.rowsOf("hero")) != 244 || len(env.storage.deletedNames()) != 0 {
|
||||
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A malformed PUT answers 400", func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", "not json")
|
||||
|
||||
assertStatusAndBody(t, recorder, 400, apperr.BodyErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("An unknown variant answers 404", func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/missing", `{"width": 100}`)
|
||||
|
||||
assertStatusAndBody(t, recorder, 404, variantNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsToggle(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
before, after bool
|
||||
}{{true, false}, {false, true}} {
|
||||
name := "PATCH toggles enabled and keeps the cache"
|
||||
t.Run(name, func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
hero := env.variants.stored("hero")
|
||||
hero.Enabled = row.before
|
||||
env.variants.variants["hero"] = hero
|
||||
payload := `{"enabled": false}`
|
||||
if row.after {
|
||||
payload = `{"enabled": true}`
|
||||
}
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/hero", payload)
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if recorder.Code != 200 || body["enabled"] != row.after || body["generated_files"] != 244.0 || len(env.storage.deletedNames()) != 0 {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rows := []struct {
|
||||
name, path, body string
|
||||
status int
|
||||
}{
|
||||
{"not json", "hero", "not json", 400},
|
||||
{"empty object", "hero", `{}`, 422},
|
||||
{"wrong type", "hero", `{"enabled": "no"}`, 400},
|
||||
{"unknown variant", "missing", `{"enabled": true}`, 404},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run("A PATCH that is not valid or not found: "+row.name, func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/"+row.path, row.body)
|
||||
|
||||
if recorder.Code != row.status {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageVariantsDeleteAndPurge(t *testing.T) {
|
||||
t.Run("Deleting removes the definition and its generated files", func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/image-variants/hero", "")
|
||||
|
||||
if recorder.Code != 204 || recorder.Body.Len() != 0 {
|
||||
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if env.variants.stored("hero").Name != "" || len(env.storage.deletedNames()) != 244 || len(env.files.rowsOf("hero")) != 0 || len(env.files.rowsOf("thumb")) != 3 || env.variants.stored("thumb").Name == "" {
|
||||
t.Error("unexpected state after delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Purging removes only the generated files", func(t *testing.T) {
|
||||
env := newUpdateFixture(t).env
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants/hero/purge", "")
|
||||
|
||||
body := decodeBody(t, recorder)
|
||||
if recorder.Code != 200 || body["purged_files"] != 244.0 || body["purged_bytes"] != float64(244*344000) {
|
||||
t.Errorf("response = %d %v", recorder.Code, body)
|
||||
}
|
||||
if env.variants.stored("hero").Name == "" || len(env.files.rowsOf("hero")) != 0 {
|
||||
t.Error("unexpected state after purge")
|
||||
}
|
||||
listed := decodeBody(t, env.sendAsAdmin(http.MethodGet, "/v1/image-variants", ""))["items"].([]any)[0].(map[string]any)
|
||||
if listed["name"] != "hero" || listed["generated_files"] != 0.0 || listed["cache_size"] != 0.0 {
|
||||
t.Errorf("listed = %v", listed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Purging an empty cache answers zero", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants/hero/purge", "")
|
||||
|
||||
assertStatusAndBody(t, recorder, 200, `{"purged_files":0,"purged_bytes":0}`)
|
||||
})
|
||||
|
||||
for _, row := range []struct{ method, suffix string }{{http.MethodDelete, ""}, {http.MethodPost, "/purge"}} {
|
||||
t.Run("An unknown variant answers 404: "+row.method, func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
recorder := env.sendAsAdmin(row.method, "/v1/image-variants/missing"+row.suffix, "")
|
||||
|
||||
assertStatusAndBody(t, recorder, 404, variantNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A blob that cannot be deleted keeps its index row and the call fails", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
rows := seedVariantFiles(env.files, env.storage, "hero", "src", 3, 10)
|
||||
env.storage.failDelete[rows[1].CachedName] = errVariantStorageFailure
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants/hero/purge", "")
|
||||
|
||||
assertStatusAndBody(t, recorder, 500, "Internal server error")
|
||||
remaining := env.files.rowsOf("hero")
|
||||
if len(remaining) != 1 || remaining[0].ID != rows[1].ID || env.storage.has(rows[0].CachedName) || env.storage.has(rows[2].CachedName) {
|
||||
t.Errorf("remaining = %+v", remaining)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A blob already missing from storage does not stop a purge", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
env.files.Save(domain.VariantFile{VariantName: "hero", SourceName: "x", CachedName: "gone.jpeg", Size: 5})
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants/hero/purge", "")
|
||||
|
||||
if recorder.Code != 200 || len(env.files.rowsOf("hero")) != 0 {
|
||||
t.Errorf("response = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A delete whose purge fails keeps the variant", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
rows := seedVariantFiles(env.files, env.storage, "hero", "src", 2, 10)
|
||||
env.storage.failDelete[rows[0].CachedName] = errVariantStorageFailure
|
||||
|
||||
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/image-variants/hero", "")
|
||||
|
||||
if recorder.Code != 500 || env.variants.stored("hero").Name == "" {
|
||||
t.Errorf("response = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsSourceRemoval(t *testing.T) {
|
||||
t.Run("Deleting a source image purges its generated files", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
env.seedImage(1, "north-wall.png")
|
||||
env.seedImage(2, "other.png")
|
||||
env.seedSource("north-wall.png", []byte("original"))
|
||||
env.seedSource("other.png", []byte("original"))
|
||||
hero := seedCachedFile(env, "hero", "north-wall.png", "g-hero.jpeg")
|
||||
thumb := seedCachedFile(env, "thumb", "north-wall.png", "g-thumb.jpeg")
|
||||
kept := seedCachedFile(env, "hero", "other.png", "g-other.jpeg")
|
||||
|
||||
recorder := env.send(http.MethodDelete, "/v1/media/1", "", variantAdminId)
|
||||
|
||||
if recorder.Code != 204 {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
if env.storage.has(hero.CachedName) || env.storage.has(thumb.CachedName) || !env.storage.has(kept.CachedName) || env.files.rowCount() != 1 {
|
||||
t.Error("unexpected generated files after the delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Merging copies purges the generated files of the removed copies", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
for id := uint32(5); id <= 7; id++ {
|
||||
env.seedImage(id, "copy-"+string(rune('0'+id))+".png")
|
||||
env.seedSource("copy-"+string(rune('0'+id))+".png", []byte("original"))
|
||||
}
|
||||
keptFile := seedCachedFile(env, "hero", "copy-5.png", "g5.jpeg")
|
||||
six := seedCachedFile(env, "hero", "copy-6.png", "g6.jpeg")
|
||||
seven := seedCachedFile(env, "thumb", "copy-7.png", "g7.jpeg")
|
||||
|
||||
recorder := env.send(http.MethodPost, "/v1/media/merge", `{"keep_id": 5, "merge_ids": [6, 7]}`, variantAdminId)
|
||||
|
||||
if recorder.Code != 200 {
|
||||
t.Fatalf("status = %d %q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if env.storage.has(six.CachedName) || env.storage.has(seven.CachedName) || !env.storage.has(keptFile.CachedName) || env.files.rowCount() != 1 {
|
||||
t.Error("unexpected generated files after the merge")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func seedCachedFile(env *variantEnv, variantName string, sourceName string, cachedName string) domain.VariantFile {
|
||||
env.storage.seed(cachedName, []byte("generated"))
|
||||
saved, _ := env.files.Save(domain.VariantFile{VariantName: variantName, SourceName: sourceName, CachedName: cachedName, ContentType: "image/jpeg", Size: 9, Fingerprint: "f"})
|
||||
return saved
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func eventsOf(env *variantEnv, action string) []audit.Event {
|
||||
matching := []audit.Event{}
|
||||
for _, event := range env.audit.all(env.t) {
|
||||
if event.Action == action {
|
||||
matching = append(matching, event)
|
||||
}
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
func onlyEventOf(t *testing.T, env *variantEnv, action string) audit.Event {
|
||||
t.Helper()
|
||||
matching := eventsOf(env, action)
|
||||
if len(matching) != 1 || env.events() != 1 {
|
||||
t.Fatalf("%d %s events among %d", len(matching), action, env.events())
|
||||
}
|
||||
return matching[0]
|
||||
}
|
||||
|
||||
func marshalled(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func assertVariantTarget(t *testing.T, event audit.Event, name string) {
|
||||
t.Helper()
|
||||
if got := marshalled(t, event.Target); got != `{"type":"image_variant","id":"`+name+`","name":"`+name+`"}` {
|
||||
t.Errorf("target = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func ownerHeroEnv(t *testing.T) *variantEnv {
|
||||
t.Helper()
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82)
|
||||
seedVariantFiles(env.files, env.storage, "hero", "src", 244, 344000)
|
||||
return env
|
||||
}
|
||||
|
||||
func TestImageVariantsActivityLog(t *testing.T) {
|
||||
t.Run("Creating a variant is recorded", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
env.send(http.MethodPost, "/v1/image-variants", `{"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82}`, variantOwnerId)
|
||||
|
||||
event := onlyEventOf(t, env, "media.variant_create")
|
||||
assertVariantTarget(t, event, "hero")
|
||||
if event.Outcome != audit.OutcomeSuccess || event.Category != audit.CategoryMedia || event.Source != audit.SourceWeb || event.Actor.Name != "Marta Alves" || *event.Actor.ID != variantOwnerId {
|
||||
t.Errorf("event = %+v", event)
|
||||
}
|
||||
if got := marshalled(t, event.Params); got != `{"fit":"cover","format":"webp","height":630,"quality":82,"width":1200}` {
|
||||
t.Errorf("params = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Creating a width-only png variant records no quality and no height", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
|
||||
env.send(http.MethodPost, "/v1/image-variants", `{"name": "logo", "width": 100, "format": "png"}`, variantOwnerId)
|
||||
|
||||
event := onlyEventOf(t, env, "media.variant_create")
|
||||
if got := marshalled(t, event.Params); got != `{"fit":"cover","format":"png","width":100}` {
|
||||
t.Errorf("params = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Updating a variant is recorded with the diff and the purge count", func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
|
||||
env.send(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70}`, variantOwnerId)
|
||||
|
||||
event := onlyEventOf(t, env, "media.variant_update")
|
||||
assertVariantTarget(t, event, "hero")
|
||||
if got := marshalled(t, event.Changes); got != `[{"field":"quality","kind":"text","before":"82","after":"70"}]` {
|
||||
t.Errorf("changes = %s", got)
|
||||
}
|
||||
if got := marshalled(t, event.Params); got != `{"purged_files":244}` {
|
||||
t.Errorf("params = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A removed dimension is recorded as auto", func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
|
||||
env.send(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "fit": "cover", "format": "webp", "quality": 82}`, variantOwnerId)
|
||||
|
||||
event := onlyEventOf(t, env, "media.variant_update")
|
||||
if got := marshalled(t, event.Changes); got != `[{"field":"height","kind":"text","before":"630","after":"auto"}]` {
|
||||
t.Errorf("changes = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A switch-only update does not claim a purge", func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
|
||||
env.send(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true}`, variantOwnerId)
|
||||
|
||||
if env.events() != 0 {
|
||||
t.Errorf("events = %d", env.events())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Disabling through a PUT is recorded as a disable and not as an update", func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
|
||||
env.send(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "enabled": false}`, variantOwnerId)
|
||||
|
||||
assertVariantTarget(t, onlyEventOf(t, env, "media.variant_disable"), "hero")
|
||||
})
|
||||
|
||||
t.Run("Deleting a variant is recorded with the removed files", func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
|
||||
env.send(http.MethodDelete, "/v1/image-variants/hero", "", variantOwnerId)
|
||||
|
||||
event := onlyEventOf(t, env, "media.variant_delete")
|
||||
assertVariantTarget(t, event, "hero")
|
||||
if got := marshalled(t, event.Params); got != `{"purged_bytes":83936000,"purged_files":244}` {
|
||||
t.Errorf("params = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
for _, row := range []struct {
|
||||
before, after bool
|
||||
action string
|
||||
}{{false, true, "media.variant_enable"}, {true, false, "media.variant_disable"}} {
|
||||
t.Run("Enabling and disabling are recorded: "+row.action, func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
hero := env.variants.stored("hero")
|
||||
hero.Enabled = row.before
|
||||
env.variants.variants["hero"] = hero
|
||||
payload := `{"enabled": false}`
|
||||
if row.after {
|
||||
payload = `{"enabled": true}`
|
||||
}
|
||||
|
||||
env.send(http.MethodPatch, "/v1/image-variants/hero", payload, variantOwnerId)
|
||||
|
||||
assertVariantTarget(t, onlyEventOf(t, env, row.action), "hero")
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A PATCH to the current value records nothing", func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
|
||||
recorder := env.send(http.MethodPatch, "/v1/image-variants/hero", `{"enabled": true}`, variantOwnerId)
|
||||
|
||||
if recorder.Code != 200 || env.events() != 0 {
|
||||
t.Errorf("status = %d, events = %d", recorder.Code, env.events())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A refused create is recorded as failed", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
env.seedThumb()
|
||||
|
||||
env.send(http.MethodPost, "/v1/image-variants", `{"name": "thumb", "width": 10}`, variantOwnerId)
|
||||
|
||||
event := onlyEventOf(t, env, "media.variant_create")
|
||||
assertVariantTarget(t, event, "thumb")
|
||||
if event.Outcome != audit.OutcomeFailed || event.FailureReason == nil || *event.FailureReason != "A variant with that name already exists." {
|
||||
t.Errorf("event = %+v", event)
|
||||
}
|
||||
})
|
||||
|
||||
nothing := []struct{ name, method, path, body string }{
|
||||
{"validation failure", http.MethodPost, "/v1/image-variants", `{"name": "hero", "width": 9000}`},
|
||||
{"unknown PUT", http.MethodPut, "/v1/image-variants/missing", `{"width": 100}`},
|
||||
{"unknown DELETE", http.MethodDelete, "/v1/image-variants/missing", ""},
|
||||
{"unknown PATCH", http.MethodPatch, "/v1/image-variants/missing", `{"enabled": true}`},
|
||||
{"unknown purge", http.MethodPost, "/v1/image-variants/missing/purge", ""},
|
||||
{"list", http.MethodGet, "/v1/image-variants", ""},
|
||||
}
|
||||
for _, row := range nothing {
|
||||
t.Run("Requests that fail validation or find nothing record nothing: "+row.name, func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
|
||||
env.send(row.method, row.path, row.body, variantOwnerId)
|
||||
|
||||
if env.events() != 0 {
|
||||
t.Errorf("events = %d", env.events())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A public GET that generates a file records nothing", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
|
||||
env.get(thumbURL, nil)
|
||||
|
||||
if env.events() != 0 {
|
||||
t.Errorf("events = %d", env.events())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Purging a variant's cache is recorded", func(t *testing.T) {
|
||||
env := ownerHeroEnv(t)
|
||||
|
||||
env.send(http.MethodPost, "/v1/image-variants/hero/purge", "", variantOwnerId)
|
||||
|
||||
event := onlyEventOf(t, env, "media.variant_purge")
|
||||
assertVariantTarget(t, event, "hero")
|
||||
if got := marshalled(t, event.Params); event.Outcome != audit.OutcomeSuccess || got != `{"purged_bytes":83936000,"purged_files":244}` {
|
||||
t.Errorf("params = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Purging an empty cache records nothing", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
|
||||
recorder := env.send(http.MethodPost, "/v1/image-variants/hero/purge", "", variantOwnerId)
|
||||
|
||||
if recorder.Code != 200 || env.events() != 0 {
|
||||
t.Errorf("status = %d, events = %d", recorder.Code, env.events())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A purge that fails on storage is recorded as failed", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
rows := seedVariantFiles(env.files, env.storage, "hero", "src", 3, 10)
|
||||
env.storage.failDelete[rows[1].CachedName] = errVariantStorageFailure
|
||||
|
||||
recorder := env.send(http.MethodPost, "/v1/image-variants/hero/purge", "", variantOwnerId)
|
||||
|
||||
event := onlyEventOf(t, env, "media.variant_purge")
|
||||
if recorder.Code != 500 || event.Outcome != audit.OutcomeFailed || event.FailureReason == nil || *event.FailureReason != "Something went wrong on our side. Please try again." {
|
||||
t.Errorf("status = %d, event = %+v", recorder.Code, event)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing audit write never changes the response", func(t *testing.T) {
|
||||
env := newVariantEnv(t)
|
||||
env.audit.probe.appendErr = errors.New("audit store down")
|
||||
|
||||
recorder := env.send(http.MethodPost, "/v1/image-variants", `{"name": "hero", "width": 100}`, variantOwnerId)
|
||||
|
||||
if recorder.Code != 201 || env.variants.stored("hero").Name != "hero" {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The existing allowlist scenario is updated", func(t *testing.T) {
|
||||
allowed := []string{"title", "online", "body", "filename", "display_name", "width", "height", "fit", "format", "quality"}
|
||||
emitted := []audit.Change{}
|
||||
emitted = append(emitted, audit.ArticleChanges(article.Article{Title: "a", Body: "x"}, article.Article{Title: "b", Body: "y", IsOnline: true})...)
|
||||
before := domain.VariantSettings{Width: intPointer(1), Height: intPointer(2), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)}
|
||||
after := domain.VariantSettings{Width: intPointer(3), Fit: domain.FitInside, Format: domain.FormatPNG}
|
||||
emitted = append(emitted, audit.VariantChanges(before, after)...)
|
||||
|
||||
for _, change := range emitted {
|
||||
if !slices.Contains(allowed, change.Field) || change.Kind != audit.ChangeText && change.Kind != audit.ChangeBody {
|
||||
t.Errorf("unexpected change %+v", change)
|
||||
}
|
||||
}
|
||||
if len(emitted) != 8 {
|
||||
t.Errorf("emitted %d changes", len(emitted))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const maxSourceEnv = "IMAGE_VARIANT_MAX_SOURCE_BYTES"
|
||||
|
||||
func readRepositoryFile(t *testing.T, relative string) string {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile("../" + relative)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot read %s: %v", relative, err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func between(source string, opening string, closing string) string {
|
||||
start := strings.Index(source, opening)
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := source[start+len(opening):]
|
||||
end := strings.Index(rest, closing)
|
||||
if end < 0 {
|
||||
return rest
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
func TestImageVariantMaxSourceBytesSetting(t *testing.T) {
|
||||
rows := []struct {
|
||||
value string
|
||||
unset bool
|
||||
want int64
|
||||
failure bool
|
||||
}{
|
||||
{unset: true, want: 10485760},
|
||||
{value: "2048", want: 2048},
|
||||
{value: "1", want: 1},
|
||||
{value: "0", failure: true},
|
||||
{value: "-5", failure: true},
|
||||
{value: "many", failure: true},
|
||||
{value: "1.5", failure: true},
|
||||
}
|
||||
for _, row := range rows {
|
||||
name := row.value
|
||||
if row.unset {
|
||||
name = "unset"
|
||||
}
|
||||
t.Run("The env var is loaded: "+name, func(t *testing.T) {
|
||||
setBaseSettingsEnv(t)
|
||||
if row.unset {
|
||||
t.Setenv(maxSourceEnv, "")
|
||||
} else {
|
||||
t.Setenv(maxSourceEnv, row.value)
|
||||
}
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
if row.failure {
|
||||
if err == nil || !strings.Contains(err.Error(), maxSourceEnv) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || settings.ImageVariantMaxSourceBytes != row.want {
|
||||
t.Errorf("value = %d, err = %v", settings.ImageVariantMaxSourceBytes, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageVariantMaxSourceBytesDocumentation(t *testing.T) {
|
||||
t.Run("The default is listed in envDefaults and not in requiredEnvVars", func(t *testing.T) {
|
||||
main := readRepositoryFile(t, "cmd/server/main.go")
|
||||
|
||||
defaults := between(main, "var envDefaults = map[string]string{", "\n}")
|
||||
required := between(main, "var requiredEnvVars = []string{", "\n}")
|
||||
|
||||
if !strings.Contains(defaults, "config.EnvImageVariantMaxSourceBytes") || strings.Contains(required, "ImageVariant") || strings.Contains(required, maxSourceEnv) {
|
||||
t.Error("the default is misplaced")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The constant name and default are the documented ones", func(t *testing.T) {
|
||||
if config.EnvImageVariantMaxSourceBytes != maxSourceEnv || config.DefaultImageVariantMaxSourceBytes != "10485760" {
|
||||
t.Errorf("constants = %q %q", config.EnvImageVariantMaxSourceBytes, config.DefaultImageVariantMaxSourceBytes)
|
||||
}
|
||||
})
|
||||
|
||||
for _, document := range []string{".env.example", "docs/configuration.md"} {
|
||||
t.Run("The env var is documented in "+document, func(t *testing.T) {
|
||||
if !strings.Contains(readRepositoryFile(t, document), maxSourceEnv) {
|
||||
t.Errorf("%s does not mention %s", document, maxSourceEnv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVariantNames(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
valid bool
|
||||
}{
|
||||
{"ab", true},
|
||||
{"a-b-c", true},
|
||||
{"q1", true},
|
||||
{strings.Repeat("a", 32), true},
|
||||
{strings.Repeat("a", 33), false},
|
||||
{"a", false},
|
||||
{"", false},
|
||||
{"Hero", false},
|
||||
{"he ro", false},
|
||||
{"he_ro", false},
|
||||
{"hé", false},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run("name "+row.name, func(t *testing.T) {
|
||||
if got := domain.IsValidVariantName(row.name); got != row.valid {
|
||||
t.Errorf("valid = %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantSettingsNormalization(t *testing.T) {
|
||||
settings := func(width *int, height *int, quality *int, format domain.VariantFormat) domain.VariantSettings {
|
||||
return domain.VariantSettings{Width: width, Height: height, Quality: quality, Format: format}
|
||||
}
|
||||
valid := []struct {
|
||||
name string
|
||||
in domain.VariantSettings
|
||||
}{
|
||||
{"width 1", settings(intPointer(1), nil, nil, "")},
|
||||
{"width 8000", settings(intPointer(8000), nil, nil, "")},
|
||||
{"height 1", settings(nil, intPointer(1), nil, "")},
|
||||
{"height 8000", settings(nil, intPointer(8000), nil, "")},
|
||||
{"quality 1", settings(intPointer(5), nil, intPointer(1), "")},
|
||||
{"quality 100", settings(intPointer(5), nil, intPointer(100), "")},
|
||||
}
|
||||
for _, row := range valid {
|
||||
t.Run("accepts "+row.name, func(t *testing.T) {
|
||||
if _, err := row.in.Normalized(); err != nil {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
invalid := []struct {
|
||||
name string
|
||||
in domain.VariantSettings
|
||||
}{
|
||||
{"no dimension", settings(nil, nil, nil, "")},
|
||||
{"width 0", settings(intPointer(0), nil, nil, "")},
|
||||
{"width 8001", settings(intPointer(8001), nil, nil, "")},
|
||||
{"height 0", settings(nil, intPointer(0), nil, "")},
|
||||
{"height 8001", settings(nil, intPointer(8001), nil, "")},
|
||||
{"height negative", settings(intPointer(10), intPointer(-1), nil, "")},
|
||||
{"quality 0", settings(intPointer(5), nil, intPointer(0), "")},
|
||||
{"quality 101", settings(intPointer(5), nil, intPointer(101), "")},
|
||||
{"unknown format", settings(intPointer(5), nil, nil, "avif")},
|
||||
{"unknown fit", domain.VariantSettings{Width: intPointer(5), Fit: "stretch"}},
|
||||
}
|
||||
for _, row := range invalid {
|
||||
t.Run("refuses "+row.name, func(t *testing.T) {
|
||||
if _, err := row.in.Normalized(); !errors.Is(err, domain.ErrVariantInvalid) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Defaults are cover, original and quality 82", func(t *testing.T) {
|
||||
normalized, err := settings(intPointer(400), nil, nil, "").Normalized()
|
||||
|
||||
if err != nil || normalized.Fit != domain.FitCover || normalized.Format != domain.FormatOriginal || *normalized.Quality != 82 {
|
||||
t.Errorf("normalized = %+v, err = %v", normalized, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A png variant stores no quality", func(t *testing.T) {
|
||||
normalized, _ := settings(intPointer(400), nil, intPointer(30), domain.FormatPNG).Normalized()
|
||||
|
||||
if normalized.Quality != nil {
|
||||
t.Errorf("quality = %v", *normalized.Quality)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Normalizing does not alter the input", func(t *testing.T) {
|
||||
quality := 40
|
||||
input := settings(intPointer(400), nil, &quality, domain.FormatPNG)
|
||||
|
||||
_, _ = input.Normalized()
|
||||
|
||||
if input.Quality == nil || *input.Quality != 40 {
|
||||
t.Error("the input quality was cleared")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVariantFingerprint(t *testing.T) {
|
||||
base := domain.VariantSettings{Width: intPointer(400), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)}
|
||||
variations := map[string]domain.VariantSettings{
|
||||
"width": {Width: intPointer(401), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)},
|
||||
"height": {Width: intPointer(400), Height: nil, Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)},
|
||||
"fit": {Width: intPointer(400), Height: intPointer(300), Fit: domain.FitInside, Format: domain.FormatJPEG, Quality: intPointer(80)},
|
||||
"format": {Width: intPointer(400), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatWebP, Quality: intPointer(80)},
|
||||
"quality": {Width: intPointer(400), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(79)},
|
||||
}
|
||||
|
||||
t.Run("Equal settings share a fingerprint", func(t *testing.T) {
|
||||
same := domain.VariantSettings{Width: intPointer(400), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)}
|
||||
|
||||
if !base.IsSameAs(same) {
|
||||
t.Error("fingerprints differ")
|
||||
}
|
||||
})
|
||||
|
||||
for field, changed := range variations {
|
||||
t.Run("A different "+field+" changes the fingerprint", func(t *testing.T) {
|
||||
if base.IsSameAs(changed) {
|
||||
t.Error("fingerprints match")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("An absent dimension differs from a present one", func(t *testing.T) {
|
||||
widthOnly := domain.VariantSettings{Width: intPointer(300)}
|
||||
heightOnly := domain.VariantSettings{Height: intPointer(300)}
|
||||
|
||||
if widthOnly.IsSameAs(heightOnly) {
|
||||
t.Error("fingerprints match")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVariantConstruction(t *testing.T) {
|
||||
now := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("A new variant carries the normalized settings and timestamps", func(t *testing.T) {
|
||||
variant, err := domain.NewVariant("hero", domain.VariantSettings{Width: intPointer(100), Format: domain.FormatPNG, Quality: intPointer(5)}, true, false, now)
|
||||
|
||||
if err != nil || variant.Quality != nil || variant.Fit != domain.FitCover || !variant.GenerateOnUpload || variant.Enabled || !variant.CreatedAt.Equal(now) || !variant.UpdatedAt.Equal(now) {
|
||||
t.Errorf("variant = %+v, err = %v", variant, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A new variant refuses an invalid name", func(t *testing.T) {
|
||||
_, err := domain.NewVariant("A", domain.VariantSettings{Width: intPointer(100)}, false, true, now)
|
||||
|
||||
if !errors.Is(err, domain.ErrVariantInvalid) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVariantETagAndRequestErrors(t *testing.T) {
|
||||
settings := domain.VariantSettings{Width: intPointer(400), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)}
|
||||
|
||||
t.Run("The ETag is quoted and depends on the source", func(t *testing.T) {
|
||||
first, second := domain.VariantETag(settings, "a.png"), domain.VariantETag(settings, "b.png")
|
||||
|
||||
if !strings.HasPrefix(first, `"`) || !strings.HasSuffix(first, `"`) || first == second || first != domain.VariantETag(settings, "a.png") {
|
||||
t.Errorf("etags = %q %q", first, second)
|
||||
}
|
||||
})
|
||||
|
||||
matches := []struct {
|
||||
header string
|
||||
want bool
|
||||
}{
|
||||
{`"abc"`, true},
|
||||
{`W/"abc"`, true},
|
||||
{`"x", "abc"`, true},
|
||||
{`*`, true},
|
||||
{`"abd"`, false},
|
||||
{``, false},
|
||||
}
|
||||
for _, row := range matches {
|
||||
t.Run("If-None-Match "+row.header, func(t *testing.T) {
|
||||
if got := domain.ETagMatches(row.header, `"abc"`); got != row.want {
|
||||
t.Errorf("matches = %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Request errors unwrap to their sentinel and carry the detail", func(t *testing.T) {
|
||||
unknown := domain.NewUnknownVariantError("banner")
|
||||
unsupported := domain.NewUnsupportedParameterError("width")
|
||||
|
||||
if !errors.Is(unknown, domain.ErrUnknownVariant) || errors.Is(unknown, domain.ErrUnsupportedParameter) || domain.VariantRequestDetail(unknown) != "banner" {
|
||||
t.Errorf("unknown = %v", unknown)
|
||||
}
|
||||
if !errors.Is(unsupported, domain.ErrUnsupportedParameter) || domain.VariantRequestDetail(unsupported) != "width" || domain.VariantRequestDetail(errors.New("other")) != "" {
|
||||
t.Errorf("unsupported = %v", unsupported)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnimatedGIFDetection(t *testing.T) {
|
||||
t.Run("A two-frame GIF is animated", func(t *testing.T) {
|
||||
animated, err := domain.IsAnimatedGIF(bytes.NewReader(animatedGIF(t)))
|
||||
|
||||
if err != nil || !animated {
|
||||
t.Errorf("animated = %v, err = %v", animated, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A one-frame GIF is not animated", func(t *testing.T) {
|
||||
animated, err := domain.IsAnimatedGIF(bytes.NewReader(stillGIF(t)))
|
||||
|
||||
if err != nil || animated {
|
||||
t.Errorf("animated = %v, err = %v", animated, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bytes that are not a GIF are refused", func(t *testing.T) {
|
||||
_, err := domain.IsAnimatedGIF(bytes.NewReader(solidPNG(t, 4, 4)))
|
||||
|
||||
if !errors.Is(err, domain.ErrNotAGif) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A truncated GIF reports an error", func(t *testing.T) {
|
||||
truncated := animatedGIF(t)[:20]
|
||||
|
||||
if _, err := domain.IsAnimatedGIF(bytes.NewReader(truncated)); err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/api/handlers/staticimages"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
variantOwnerId uint32 = 1
|
||||
variantAdminId uint32 = 2
|
||||
variantPlainUserId uint32 = 3
|
||||
variantUnverifiedId uint32 = 4
|
||||
variantStaleId uint32 = 5
|
||||
variantDefaultCap int64 = 10485760
|
||||
variantDefaultPixels int64 = 50000000
|
||||
variantDefaultRenders = 4
|
||||
variantSourceName = "a1b2.png"
|
||||
)
|
||||
|
||||
type variantEnv struct {
|
||||
t *testing.T
|
||||
users *mockUserRepo
|
||||
images *mockImageRepo
|
||||
variants *mockVariantRepo
|
||||
files *mockVariantFileRepo
|
||||
storage *mockVariantStorage
|
||||
renderer *countingRenderer
|
||||
audit *activityHarness
|
||||
token *jwtauth.JWTAuth
|
||||
now time.Time
|
||||
admin http.Handler
|
||||
public http.Handler
|
||||
}
|
||||
|
||||
func newVariantEnv(t *testing.T) *variantEnv {
|
||||
t.Helper()
|
||||
return newVariantEnvWith(t, newCountingRenderer(), variantDefaultCap)
|
||||
}
|
||||
|
||||
func newVariantEnvWith(t *testing.T, renderer *countingRenderer, maxSourceBytes int64) *variantEnv {
|
||||
t.Helper()
|
||||
return newVariantEnvLimits(t, renderer, useCases.ServeVariantLimits{MaxSourceBytes: maxSourceBytes, MaxSourcePixels: variantDefaultPixels, MaxConcurrentRenders: variantDefaultRenders})
|
||||
}
|
||||
|
||||
func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCases.ServeVariantLimits) *variantEnv {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
env := &variantEnv{
|
||||
t: t,
|
||||
users: newMockUserRepo(),
|
||||
images: newMockImageRepo(),
|
||||
variants: newMockVariantRepo(),
|
||||
files: newMockVariantFileRepo(),
|
||||
storage: newMockVariantStorage(),
|
||||
renderer: renderer,
|
||||
audit: newActivityHarness(t),
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC),
|
||||
}
|
||||
env.users.users[variantOwnerId] = user.User{ID: variantOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner}
|
||||
env.users.users[variantAdminId] = user.User{ID: variantAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
|
||||
env.users.users[variantPlainUserId] = user.User{ID: variantPlainUserId, Username: "pat", IsVerified: true}
|
||||
env.users.users[variantUnverifiedId] = user.User{ID: variantUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
|
||||
env.users.users[variantStaleId] = user.User{ID: variantStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1}
|
||||
|
||||
container := env.container(limits)
|
||||
admin := chi.NewRouter()
|
||||
admin.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
|
||||
env.admin = admin
|
||||
|
||||
public := chi.NewRouter()
|
||||
public.Use(chimiddleware.GetHead)
|
||||
public.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
|
||||
env.public = public
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases {
|
||||
clock := func() time.Time { return e.now }
|
||||
recorder := e.audit.recorder
|
||||
return &api.UseCases{
|
||||
Token: e.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
|
||||
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, newMockArticleRepo(), e.storage, e.files, recorder),
|
||||
ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
|
||||
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
|
||||
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder),
|
||||
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(e.variants, e.files, clock, recorder),
|
||||
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(e.variants, e.files, e.storage, recorder),
|
||||
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(e.variants, e.files, e.storage, recorder),
|
||||
ServeVariantUseCase: useCases.NewServeVariantUseCase(e.variants, e.files, e.images, e.storage, e.renderer, limits),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *variantEnv) cookie(userId uint32, tokenVersion uint32) *http.Cookie {
|
||||
e.t.Helper()
|
||||
_, signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
|
||||
if err != nil {
|
||||
e.t.Fatalf("failed to encode jwt: %v", err)
|
||||
}
|
||||
return &http.Cookie{Name: "jwt", Value: signed}
|
||||
}
|
||||
|
||||
func (e *variantEnv) send(method string, path string, body string, userId uint32) *httptest.ResponseRecorder {
|
||||
e.t.Helper()
|
||||
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if userId != 0 {
|
||||
request.AddCookie(e.cookie(userId, 0))
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
e.admin.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func (e *variantEnv) sendAsAdmin(method string, path string, body string) *httptest.ResponseRecorder {
|
||||
e.t.Helper()
|
||||
return e.send(method, path, body, variantAdminId)
|
||||
}
|
||||
|
||||
func (e *variantEnv) get(path string, header http.Header) *httptest.ResponseRecorder {
|
||||
e.t.Helper()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for name, values := range header {
|
||||
request.Header[name] = values
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
e.public.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func (e *variantEnv) seedSource(storedName string, content []byte) {
|
||||
e.storage.seed(storedName, content)
|
||||
if known, _ := e.images.ExistsByStoredName(storedName); !known {
|
||||
e.seedImage(e.images.nextID+1, storedName)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *variantEnv) seedImage(id uint32, storedName string) domain.Image {
|
||||
image := domain.Image{ID: id, Path: domain.PublicPath(storedName), Filename: storedName, ContentType: "image/png", SHA256: "same"}
|
||||
e.images.images[id] = image
|
||||
if id > e.images.nextID {
|
||||
e.images.nextID = id
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
func (e *variantEnv) seedThumb() domain.Variant {
|
||||
return seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
|
||||
}
|
||||
|
||||
func (e *variantEnv) seedPhoto() {
|
||||
e.seedSource(variantSourceName, encodePNG(e.t, variantSolidImage(1000, 800, variantRed)))
|
||||
}
|
||||
|
||||
func (e *variantEnv) events() int {
|
||||
e.t.Helper()
|
||||
return len(e.audit.all(e.t))
|
||||
}
|
||||
|
||||
func (e *variantEnv) lastEvent() (string, map[string]any) {
|
||||
e.t.Helper()
|
||||
events := e.audit.all(e.t)
|
||||
if len(events) == 0 {
|
||||
e.t.Fatal("no event recorded")
|
||||
}
|
||||
encoded, _ := json.Marshal(events[0])
|
||||
var decoded map[string]any
|
||||
_ = json.Unmarshal(encoded, &decoded)
|
||||
return events[0].Action, decoded
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("body is not a json object: %q", recorder.Body.String())
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func bearerKeyHeader() http.Header {
|
||||
return http.Header{"Authorization": []string{"Bearer " + apikey.RawKeyPrefix + "validkey"}}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/imaging"
|
||||
"bytes"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func variantSettings(width int, height int, fit domain.VariantFit, format domain.VariantFormat) domain.VariantSettings {
|
||||
settings := domain.VariantSettings{Fit: fit, Format: format}
|
||||
if width > 0 {
|
||||
settings.Width = &width
|
||||
}
|
||||
if height > 0 {
|
||||
settings.Height = &height
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func renderVariant(t *testing.T, source []byte, settings domain.VariantSettings) domain.RenderedVariant {
|
||||
t.Helper()
|
||||
rendered, err := imaging.NewVariantRenderer().Render(bytes.NewReader(source), settings)
|
||||
if err != nil {
|
||||
t.Fatalf("render failed: %v", err)
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func TestVariantOutputGeometry(t *testing.T) {
|
||||
rows := []struct {
|
||||
sourceWidth, sourceHeight, width, height int
|
||||
fit domain.VariantFit
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{4032, 3024, 400, 300, domain.FitCover, 400, 300},
|
||||
{4032, 3024, 800, 0, domain.FitCover, 800, 600},
|
||||
{4032, 3024, 0, 600, domain.FitContain, 800, 600},
|
||||
{4032, 3024, 1000, 1000, domain.FitInside, 1000, 750},
|
||||
{4032, 3024, 1000, 1000, domain.FitCover, 1000, 1000},
|
||||
{4032, 3024, 1000, 1000, domain.FitContain, 1000, 1000},
|
||||
{300, 200, 800, 0, domain.FitCover, 300, 200},
|
||||
{300, 200, 0, 800, domain.FitInside, 300, 200},
|
||||
{300, 200, 400, 300, domain.FitCover, 267, 200},
|
||||
{300, 200, 400, 300, domain.FitContain, 400, 300},
|
||||
{300, 200, 1200, 1200, domain.FitContain, 1200, 1200},
|
||||
{300, 200, 1200, 1200, domain.FitInside, 300, 200},
|
||||
}
|
||||
for _, row := range rows {
|
||||
name := strconv.Itoa(row.sourceWidth) + "x" + strconv.Itoa(row.sourceHeight) + " to " + strconv.Itoa(row.width) + "x" + strconv.Itoa(row.height) + " " + string(row.fit)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
source := solidPNG(t, row.sourceWidth, row.sourceHeight)
|
||||
rendered := renderVariant(t, source, variantSettings(row.width, row.height, row.fit, domain.FormatPNG))
|
||||
|
||||
width, height := decodedSize(t, rendered.Content)
|
||||
if width != row.wantWidth || height != row.wantHeight {
|
||||
t.Errorf("output = %dx%d, want %dx%d", width, height, row.wantWidth, row.wantHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantFitPixels(t *testing.T) {
|
||||
t.Run("Cover crops around the centre", func(t *testing.T) {
|
||||
rendered := renderVariant(t, encodePNG(t, stripedImage(t)), variantSettings(20, 20, domain.FitCover, domain.FormatPNG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
if decoded.Bounds().Dx() != 20 || decoded.Bounds().Dy() != 20 {
|
||||
t.Fatalf("output = %v", decoded.Bounds())
|
||||
}
|
||||
for y := 0; y < 20; y++ {
|
||||
for x := 0; x < 20; x++ {
|
||||
if got := pixelAt(decoded, x, y); got != variantGreen {
|
||||
t.Fatalf("pixel %d,%d = %v, want green", x, y, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Contain pads transparent on png", func(t *testing.T) {
|
||||
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
|
||||
rendered := renderVariant(t, source, variantSettings(40, 40, domain.FitContain, domain.FormatPNG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
if decoded.Bounds().Dx() != 40 || decoded.Bounds().Dy() != 40 {
|
||||
t.Fatalf("output = %v", decoded.Bounds())
|
||||
}
|
||||
for x := 0; x < 40; x++ {
|
||||
for _, y := range []int{0, 9, 30, 39} {
|
||||
if got := pixelAt(decoded, x, y); got.A != 0 {
|
||||
t.Fatalf("pixel %d,%d = %v, want transparent", x, y, got)
|
||||
}
|
||||
}
|
||||
for _, y := range []int{10, 19, 29} {
|
||||
if got := pixelAt(decoded, x, y); got != variantRed {
|
||||
t.Fatalf("pixel %d,%d = %v, want red", x, y, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Contain never enlarges a small source and centres it on the box", func(t *testing.T) {
|
||||
source := encodePNG(t, variantSolidImage(30, 20, variantRed))
|
||||
rendered := renderVariant(t, source, variantSettings(40, 30, domain.FitContain, domain.FormatPNG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
if decoded.Bounds().Dx() != 40 || decoded.Bounds().Dy() != 30 {
|
||||
t.Fatalf("output = %v", decoded.Bounds())
|
||||
}
|
||||
for _, inside := range [][2]int{{5, 5}, {34, 24}, {20, 15}} {
|
||||
if got := pixelAt(decoded, inside[0], inside[1]); got != variantRed {
|
||||
t.Errorf("pixel %v = %v, want red", inside, got)
|
||||
}
|
||||
}
|
||||
for _, outside := range [][2]int{{4, 15}, {35, 15}, {20, 4}, {20, 25}} {
|
||||
if got := pixelAt(decoded, outside[0], outside[1]); got.A != 0 {
|
||||
t.Errorf("pixel %v = %v, want transparent", outside, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Contain with a single dimension adds no padding", func(t *testing.T) {
|
||||
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
|
||||
rendered := renderVariant(t, source, variantSettings(0, 10, domain.FitContain, domain.FormatPNG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
if decoded.Bounds().Dx() != 20 || decoded.Bounds().Dy() != 10 {
|
||||
t.Fatalf("output = %v", decoded.Bounds())
|
||||
}
|
||||
for y := 0; y < 10; y++ {
|
||||
if got := pixelAt(decoded, 0, y); got.A == 0 {
|
||||
t.Fatalf("pixel 0,%d is transparent", y)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Contain pads transparent on webp", func(t *testing.T) {
|
||||
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
|
||||
rendered := renderVariant(t, source, variantSettings(40, 40, domain.FitContain, domain.FormatWebP))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
if got := pixelAt(decoded, 20, 2); got.A != 0 {
|
||||
t.Errorf("padded pixel = %v, want transparent", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Contain pads white on jpeg", func(t *testing.T) {
|
||||
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
|
||||
rendered := renderVariant(t, source, variantSettings(40, 40, domain.FitContain, domain.FormatJPEG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
for _, y := range []int{0, 5, 9, 30, 35, 39} {
|
||||
assertNearColor(t, pixelAt(decoded, 20, y), color.NRGBA{R: 255, G: 255, B: 255, A: 255})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Inside adds no padding", func(t *testing.T) {
|
||||
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
|
||||
rendered := renderVariant(t, source, variantSettings(40, 40, domain.FitInside, domain.FormatPNG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
if decoded.Bounds().Dx() != 40 || decoded.Bounds().Dy() != 20 {
|
||||
t.Fatalf("output = %v", decoded.Bounds())
|
||||
}
|
||||
for y := 0; y < 20; y++ {
|
||||
if got := pixelAt(decoded, 3, y); got.A != 255 {
|
||||
t.Fatalf("pixel 3,%d = %v, want opaque", y, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A transparent png source flattened to jpeg becomes white", func(t *testing.T) {
|
||||
source := encodePNG(t, variantSolidImage(10, 10, color.NRGBA{}))
|
||||
rendered := renderVariant(t, source, variantSettings(10, 10, domain.FitCover, domain.FormatJPEG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
for y := 0; y < 10; y++ {
|
||||
for x := 0; x < 10; x++ {
|
||||
assertNearColor(t, pixelAt(decoded, x, y), color.NRGBA{R: 255, G: 255, B: 255, A: 255})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertNearColor(t *testing.T, got color.NRGBA, want color.NRGBA) {
|
||||
t.Helper()
|
||||
const tolerance = 8
|
||||
for _, pair := range [][2]uint8{{got.R, want.R}, {got.G, want.G}, {got.B, want.B}} {
|
||||
difference := int(pair[0]) - int(pair[1])
|
||||
if difference > tolerance || difference < -tolerance {
|
||||
t.Fatalf("color = %v, want near %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantOutputFormat(t *testing.T) {
|
||||
photo := encodePNG(t, noisyImage(64, 64))
|
||||
rows := []struct {
|
||||
name string
|
||||
source []byte
|
||||
format domain.VariantFormat
|
||||
contentType string
|
||||
signature string
|
||||
}{
|
||||
{"png original", photo, domain.FormatOriginal, "image/png", "\x89PNG"},
|
||||
{"jpeg original", encodeJPEG(t, noisyImage(64, 64)), domain.FormatOriginal, "image/jpeg", "\xff\xd8"},
|
||||
{"png to jpeg", photo, domain.FormatJPEG, "image/jpeg", "\xff\xd8"},
|
||||
{"jpeg to png", encodeJPEG(t, noisyImage(64, 64)), domain.FormatPNG, "image/png", "\x89PNG"},
|
||||
{"png to webp", photo, domain.FormatWebP, "image/webp", "RIFF"},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
rendered := renderVariant(t, row.source, variantSettings(32, 0, domain.FitCover, row.format))
|
||||
|
||||
if rendered.ContentType != row.contentType {
|
||||
t.Errorf("content type = %q, want %q", rendered.ContentType, row.contentType)
|
||||
}
|
||||
if !bytes.HasPrefix(rendered.Content, []byte(row.signature)) {
|
||||
t.Errorf("signature = %q", rendered.Content[:4])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A webp output carries the WEBP marker and the right dimensions", func(t *testing.T) {
|
||||
rendered := renderVariant(t, photo, variantSettings(32, 0, domain.FitCover, domain.FormatWebP))
|
||||
|
||||
width, height := webpSize(t, rendered.Content)
|
||||
if string(rendered.Content[8:12]) != "WEBP" || width != 32 || height != 32 {
|
||||
t.Errorf("webp = %q %dx%d", rendered.Content[8:12], width, height)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVariantQuality(t *testing.T) {
|
||||
photo := encodePNG(t, noisyImage(300, 300))
|
||||
|
||||
t.Run("Quality changes the size of a lossy output", func(t *testing.T) {
|
||||
for _, format := range []domain.VariantFormat{domain.FormatJPEG, domain.FormatWebP} {
|
||||
low, high := 20, 95
|
||||
lowSettings := variantSettings(300, 0, domain.FitCover, format)
|
||||
highSettings := lowSettings
|
||||
lowSettings.Quality, highSettings.Quality = &low, &high
|
||||
|
||||
lowSize := len(renderVariant(t, photo, lowSettings).Content)
|
||||
highSize := len(renderVariant(t, photo, highSettings).Content)
|
||||
|
||||
if lowSize >= highSize {
|
||||
t.Errorf("%s: quality 20 gave %d bytes, quality 95 gave %d", format, lowSize, highSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A png output ignores quality", func(t *testing.T) {
|
||||
low, high := 30, 95
|
||||
settings := variantSettings(100, 0, domain.FitCover, domain.FormatPNG)
|
||||
first, second := settings, settings
|
||||
first.Quality, second.Quality = &low, &high
|
||||
|
||||
if !bytes.Equal(renderVariant(t, photo, first).Content, renderVariant(t, photo, second).Content) {
|
||||
t.Error("png outputs differ")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVariantSourceHandling(t *testing.T) {
|
||||
t.Run("A webp source still decodes and renders to jpeg", func(t *testing.T) {
|
||||
webpSource := renderVariant(t, solidPNG(t, 40, 40), variantSettings(40, 0, domain.FitCover, domain.FormatWebP)).Content
|
||||
|
||||
rendered := renderVariant(t, webpSource, variantSettings(20, 0, domain.FitCover, domain.FormatJPEG))
|
||||
|
||||
if rendered.ContentType != "image/jpeg" {
|
||||
t.Errorf("content type = %q", rendered.ContentType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A webp source with format original stays webp when an encoder exists", func(t *testing.T) {
|
||||
webpSource := renderVariant(t, solidPNG(t, 40, 40), variantSettings(40, 0, domain.FitCover, domain.FormatWebP)).Content
|
||||
|
||||
rendered := renderVariant(t, webpSource, variantSettings(20, 0, domain.FitCover, domain.FormatOriginal))
|
||||
|
||||
if rendered.ContentType != "image/webp" {
|
||||
t.Errorf("content type = %q", rendered.ContentType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Without a webp encoder a webp source with format original falls back to png", func(t *testing.T) {
|
||||
webpSource := renderVariant(t, solidPNG(t, 40, 40), variantSettings(40, 0, domain.FitCover, domain.FormatWebP)).Content
|
||||
renderer := imaging.NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG)
|
||||
|
||||
rendered, err := renderer.Render(bytes.NewReader(webpSource), variantSettings(20, 0, domain.FitCover, domain.FormatOriginal))
|
||||
|
||||
if err != nil || rendered.ContentType != "image/png" {
|
||||
t.Errorf("rendered = %q, err = %v", rendered.ContentType, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The renderer reports the formats it can encode", func(t *testing.T) {
|
||||
all := imaging.NewVariantRenderer().EncodableFormats()
|
||||
without := imaging.NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG).EncodableFormats()
|
||||
|
||||
if len(all) != 4 || len(without) != 3 {
|
||||
t.Errorf("formats = %v and %v", all, without)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A one-frame gif renders like any raster source", func(t *testing.T) {
|
||||
rendered := renderVariant(t, stillGIF(t), variantSettings(4, 0, domain.FitCover, domain.FormatPNG))
|
||||
|
||||
if rendered.ContentType != "image/png" {
|
||||
t.Errorf("content type = %q", rendered.ContentType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Bytes that are not an image fail to render", func(t *testing.T) {
|
||||
_, err := imaging.NewVariantRenderer().Render(bytes.NewReader([]byte("not an image")), variantSettings(4, 0, domain.FitCover, domain.FormatPNG))
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var variantUseCaseFiles = []string{
|
||||
"ListVariants.go", "CreateVariant.go", "UpdateVariant.go", "SetVariantEnabled.go",
|
||||
"DeleteVariant.go", "PurgeVariant.go", "ServeVariant.go", "variantCache.go",
|
||||
"variantSettings.go", "variantSummary.go", "mediaRemover.go", "DeleteMedia.go", "MergeMedia.go",
|
||||
}
|
||||
|
||||
var newVariantGoFiles = []string{
|
||||
"internal/domain/image/variantDomain.go",
|
||||
"internal/domain/image/variantRepositoryInterface.go",
|
||||
"internal/domain/image/variantRequest.go",
|
||||
"internal/domain/image/animatedGif.go",
|
||||
"internal/infrastructure/imaging/variantEncoders.go",
|
||||
"internal/infrastructure/imaging/variantGeometry.go",
|
||||
"internal/infrastructure/imaging/variantRenderer.go",
|
||||
"internal/infrastructure/config/imageVariantSettings.go",
|
||||
"internal/infrastructure/persistence/models/imageVariantModel.go",
|
||||
"internal/infrastructure/persistence/models/imageVariantFileModel.go",
|
||||
"internal/infrastructure/persistence/mappers/imageVariantMapper.go",
|
||||
"internal/infrastructure/persistence/repositories/imageVariantRepository.go",
|
||||
"internal/infrastructure/persistence/repositories/imageVariantFileRepository.go",
|
||||
"api/handlers/imagevariant/imageVariantHandler.go",
|
||||
"api/handlers/staticimages/staticImagesHandler.go",
|
||||
}
|
||||
|
||||
func importsOf(t *testing.T, relative string) []string {
|
||||
t.Helper()
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), "../"+relative, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot parse %s: %v", relative, err)
|
||||
}
|
||||
imports := []string{}
|
||||
for _, spec := range parsed.Imports {
|
||||
imports = append(imports, strings.Trim(spec.Path.Value, `"`))
|
||||
}
|
||||
return imports
|
||||
}
|
||||
|
||||
func isStandardLibrary(path string) bool {
|
||||
return !strings.Contains(strings.Split(path, "/")[0], ".")
|
||||
}
|
||||
|
||||
func TestImageVariantLayering(t *testing.T) {
|
||||
t.Run("internal/domain/image imports only the stdlib and other domain packages", func(t *testing.T) {
|
||||
files, _ := filepath.Glob("../internal/domain/image/*.go")
|
||||
for _, file := range files {
|
||||
for _, path := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
|
||||
if !isStandardLibrary(path) && !strings.HasPrefix(path, "RenewCMS/internal/domain/") {
|
||||
t.Errorf("%s imports %s", file, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("internal/domain/audit still imports only the stdlib and domain packages", func(t *testing.T) {
|
||||
files, _ := filepath.Glob("../internal/domain/audit/*.go")
|
||||
for _, file := range files {
|
||||
for _, path := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
|
||||
if !isStandardLibrary(path) && !strings.HasPrefix(path, "RenewCMS/internal/domain/") {
|
||||
t.Errorf("%s imports %s", file, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The variant use cases import no persistence, mailer, models, gorm, net/http, chi or api package", func(t *testing.T) {
|
||||
forbidden := []string{"persistence", "mailer", "models", "gorm", "net/http", "go-chi", "RenewCMS/api"}
|
||||
for _, name := range variantUseCaseFiles {
|
||||
for _, path := range importsOf(t, "internal/infrastructure/useCases/"+name) {
|
||||
for _, fragment := range forbidden {
|
||||
if strings.Contains(path, fragment) {
|
||||
t.Errorf("%s imports %s", name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The variant handlers import no persistence, mailer, models or gorm package", func(t *testing.T) {
|
||||
for _, file := range []string{"api/handlers/imagevariant/imageVariantHandler.go", "api/handlers/staticimages/staticImagesHandler.go"} {
|
||||
for _, path := range importsOf(t, file) {
|
||||
for _, fragment := range []string{"persistence", "mailer", "models", "gorm"} {
|
||||
if strings.Contains(path, fragment) {
|
||||
t.Errorf("%s imports %s", file, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The renderer is free of gorm and http", func(t *testing.T) {
|
||||
for _, name := range []string{"variantEncoders.go", "variantGeometry.go", "variantRenderer.go"} {
|
||||
for _, path := range importsOf(t, "internal/infrastructure/imaging/"+name) {
|
||||
if strings.Contains(path, "gorm") || strings.Contains(path, "net/http") {
|
||||
t.Errorf("%s imports %s", name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Only api/dependecyInjection.go constructs the variant repositories, the renderer and the use cases", func(t *testing.T) {
|
||||
constructors := []string{"NewVariantRepository(", "NewVariantFileRepository(", "NewVariantRenderer(", "NewListVariantsUseCase(", "NewCreateVariantUseCase(", "NewUpdateVariantUseCase(", "NewSetVariantEnabledUseCase(", "NewDeleteVariantUseCase(", "NewPurgeVariantUseCase(", "NewServeVariantUseCase("}
|
||||
for _, root := range []string{"../api", "../internal", "../cmd"} {
|
||||
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || filepath.Base(path) == "dependecyInjection.go" {
|
||||
return err
|
||||
}
|
||||
content, _ := os.ReadFile(path)
|
||||
for _, constructor := range constructors {
|
||||
if bytes.Contains(content, []byte(constructor)) && !definesConstructor(content, constructor) {
|
||||
t.Errorf("%s calls %s", path, constructor)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The new GORM models are registered in migrate.go", func(t *testing.T) {
|
||||
migrate := readRepositoryFile(t, "internal/infrastructure/persistence/database/migrate.go")
|
||||
|
||||
if !strings.Contains(migrate, "&models.ImageVariant{}") || !strings.Contains(migrate, "&models.ImageVariantFile{}") {
|
||||
t.Error("a variant model is missing from AutoMigrate")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func definesConstructor(content []byte, constructor string) bool {
|
||||
return bytes.Contains(content, []byte("func "+constructor))
|
||||
}
|
||||
|
||||
func TestImageVariantFilesHaveNoComments(t *testing.T) {
|
||||
for _, relative := range newVariantGoFiles {
|
||||
t.Run(relative, func(t *testing.T) {
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), "../"+relative, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, group := range parsed.Comments {
|
||||
for _, comment := range group.List {
|
||||
if !strings.HasPrefix(comment.Text, "//go:") {
|
||||
t.Errorf("comment %q", comment.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageVariantBuildStaysCGOFree(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("builds the module")
|
||||
}
|
||||
environment := append(os.Environ(), "CGO_ENABLED=0")
|
||||
|
||||
t.Run("The module builds with CGO_ENABLED=0 and the nofrontend tag", func(t *testing.T) {
|
||||
build := exec.Command("go", "build", "-tags", "nofrontend", "./...")
|
||||
build.Dir, build.Env = "..", environment
|
||||
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build failed: %v\n%s", err, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No dependency compiles cgo files", func(t *testing.T) {
|
||||
list := exec.Command("go", "list", "-tags", "nofrontend", "-deps", "-f", "{{if .CgoFiles}}{{.ImportPath}}{{end}}", "./...")
|
||||
list.Dir, list.Env = "..", environment
|
||||
|
||||
output, err := list.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("go list failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(output)) != "" {
|
||||
t.Errorf("packages with cgo files: %s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var (
|
||||
variantRed = color.NRGBA{R: 255, A: 255}
|
||||
variantGreen = color.NRGBA{G: 255, A: 255}
|
||||
variantBlue = color.NRGBA{B: 255, A: 255}
|
||||
)
|
||||
|
||||
func variantSolidImage(width int, height int, fill color.NRGBA) *image.NRGBA {
|
||||
canvas := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(fill), image.Point{}, draw.Src)
|
||||
return canvas
|
||||
}
|
||||
|
||||
func encodePNG(t *testing.T, source image.Image) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
if err := png.Encode(&buffer, source); err != nil {
|
||||
t.Fatalf("png encode failed: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func encodeJPEG(t *testing.T, source image.Image) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
if err := jpeg.Encode(&buffer, source, &jpeg.Options{Quality: 90}); err != nil {
|
||||
t.Fatalf("jpeg encode failed: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func solidPNG(t *testing.T, width int, height int) []byte {
|
||||
t.Helper()
|
||||
return encodePNG(t, variantSolidImage(width, height, variantRed))
|
||||
}
|
||||
|
||||
func stripedImage(t *testing.T) *image.NRGBA {
|
||||
t.Helper()
|
||||
canvas := image.NewNRGBA(image.Rect(0, 0, 40, 20))
|
||||
for x := 0; x < 40; x++ {
|
||||
fill := variantGreen
|
||||
if x < 10 {
|
||||
fill = variantRed
|
||||
}
|
||||
if x >= 30 {
|
||||
fill = variantBlue
|
||||
}
|
||||
for y := 0; y < 20; y++ {
|
||||
canvas.SetNRGBA(x, y, fill)
|
||||
}
|
||||
}
|
||||
return canvas
|
||||
}
|
||||
|
||||
func noisyImage(width int, height int) *image.NRGBA {
|
||||
generator := rand.New(rand.NewSource(7))
|
||||
canvas := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for offset := 0; offset < len(canvas.Pix); offset += 4 {
|
||||
canvas.Pix[offset] = uint8(generator.Intn(256))
|
||||
canvas.Pix[offset+1] = uint8(generator.Intn(256))
|
||||
canvas.Pix[offset+2] = uint8(generator.Intn(256))
|
||||
canvas.Pix[offset+3] = 255
|
||||
}
|
||||
return canvas
|
||||
}
|
||||
|
||||
func animatedGIF(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return encodeGIF(t, 2)
|
||||
}
|
||||
|
||||
func stillGIF(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
return encodeGIF(t, 1)
|
||||
}
|
||||
|
||||
func encodeGIF(t *testing.T, frames int) []byte {
|
||||
t.Helper()
|
||||
palette := color.Palette{color.White, color.Black}
|
||||
animation := &gif.GIF{}
|
||||
for frame := 0; frame < frames; frame++ {
|
||||
paletted := image.NewPaletted(image.Rect(0, 0, 8, 8), palette)
|
||||
paletted.SetColorIndex(frame, frame, 1)
|
||||
animation.Image = append(animation.Image, paletted)
|
||||
animation.Delay = append(animation.Delay, 10)
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
if err := gif.EncodeAll(&buffer, animation); err != nil {
|
||||
t.Fatalf("gif encode failed: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func decodeImage(t *testing.T, content []byte) (image.Image, string) {
|
||||
t.Helper()
|
||||
decoded, format, err := image.Decode(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
return decoded, format
|
||||
}
|
||||
|
||||
func decodedSize(t *testing.T, content []byte) (int, int) {
|
||||
t.Helper()
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("decode config failed: %v", err)
|
||||
}
|
||||
return config.Width, config.Height
|
||||
}
|
||||
|
||||
func webpSize(t *testing.T, content []byte) (int, int) {
|
||||
t.Helper()
|
||||
config, err := webp.DecodeConfig(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("webp decode config failed: %v", err)
|
||||
}
|
||||
return config.Width, config.Height
|
||||
}
|
||||
|
||||
func pixelAt(decoded image.Image, x int, y int) color.NRGBA {
|
||||
return color.NRGBAModel.Convert(decoded.At(x, y)).(color.NRGBA)
|
||||
}
|
||||
|
||||
func padSizedPNG(t *testing.T, size int) []byte {
|
||||
t.Helper()
|
||||
content := solidPNG(t, 8, 8)
|
||||
if len(content) > size {
|
||||
t.Fatalf("base png is larger than %d bytes", size)
|
||||
}
|
||||
return append(content, make([]byte, size-len(content))...)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tooLargeBody = "The source image is too large to generate this variant."
|
||||
pngSignatureLength = 8
|
||||
pngChunkTypeLength = 4
|
||||
pngHeaderLength = 13
|
||||
gateTimeout = 5 * time.Second
|
||||
settleDelay = 150 * time.Millisecond
|
||||
)
|
||||
|
||||
func declaringPNG(t *testing.T, width int, height int) []byte {
|
||||
t.Helper()
|
||||
content := solidPNG(t, 1, 1)
|
||||
headerStart := pngSignatureLength + pngChunkTypeLength
|
||||
binary.BigEndian.PutUint32(content[headerStart+pngChunkTypeLength:], uint32(width))
|
||||
binary.BigEndian.PutUint32(content[headerStart+pngChunkTypeLength+4:], uint32(height))
|
||||
checksumStart := headerStart + pngChunkTypeLength + pngHeaderLength
|
||||
binary.BigEndian.PutUint32(content[checksumStart:], crc32.ChecksumIEEE(content[headerStart:checksumStart]))
|
||||
return content
|
||||
}
|
||||
|
||||
func pixelLimitedEnv(t *testing.T, pixels int64) *variantEnv {
|
||||
t.Helper()
|
||||
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: pixels, MaxConcurrentRenders: variantDefaultRenders})
|
||||
env.seedThumb()
|
||||
return env
|
||||
}
|
||||
|
||||
func TestImageVariantsSourcePixelCap(t *testing.T) {
|
||||
rows := []struct {
|
||||
width, height, status int
|
||||
real bool
|
||||
}{
|
||||
{100, 100, 200, true},
|
||||
{101, 100, 413, true},
|
||||
{30000, 30000, 413, false},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run("A source beyond IMAGE_VARIANT_MAX_SOURCE_PIXELS answers 413 without decoding it: "+strconv.Itoa(row.width)+"x"+strconv.Itoa(row.height), func(t *testing.T) {
|
||||
env := pixelLimitedEnv(t, 10000)
|
||||
content := declaringPNG(t, row.width, row.height)
|
||||
if row.real {
|
||||
content = solidPNG(t, row.width, row.height)
|
||||
}
|
||||
env.seedSource("wide.png", content)
|
||||
|
||||
recorder := env.get("/static/uploadedImages/wide.png?v=thumb", nil)
|
||||
|
||||
if recorder.Code != row.status {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
if row.status == 413 && (env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0) {
|
||||
t.Errorf("renders = %d, puts = %d, rows = %d", env.renderer.renderCount(), env.storage.putCount(), env.files.rowCount())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("The pixel 413 body is plain text and nothing is generated", func(t *testing.T) {
|
||||
env := pixelLimitedEnv(t, 10000)
|
||||
env.seedSource("wide.png", declaringPNG(t, 30000, 30000))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/wide.png?v=thumb", nil)
|
||||
|
||||
assertStatusAndBody(t, recorder, 413, tooLargeBody)
|
||||
if !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/plain") || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
|
||||
t.Errorf("content type = %q, renders = %d", recorder.Header().Get("Content-Type"), env.renderer.renderCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The pixel cap never affects the original", func(t *testing.T) {
|
||||
env := pixelLimitedEnv(t, 10000)
|
||||
content := declaringPNG(t, 30000, 30000)
|
||||
env.seedSource("wide.png", content)
|
||||
|
||||
recorder := env.get("/static/uploadedImages/wide.png", nil)
|
||||
|
||||
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), content) {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The pixel cap never affects an SVG", func(t *testing.T) {
|
||||
env := pixelLimitedEnv(t, 1)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="9000" height="9000"></svg>`
|
||||
env.seedSource("e5f6.svg", []byte(svg))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil)
|
||||
|
||||
if recorder.Code != 200 || recorder.Body.String() != svg || env.renderer.renderCount() != 0 {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A header that cannot be read answers 500 and caches nothing", func(t *testing.T) {
|
||||
env := pixelLimitedEnv(t, 10000)
|
||||
env.seedSource("bad.png", []byte("these bytes are not an image"))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/bad.png?v=thumb", nil)
|
||||
|
||||
if recorder.Code != 500 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
|
||||
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsKnownSource(t *testing.T) {
|
||||
t.Run("An image record with that stored name is a source", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
|
||||
if recorder := env.get(thumbURL, nil); recorder.Code != 200 {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
for _, name := range []string{"gen-1.jpg", "orphan.png"} {
|
||||
t.Run("A blob without an image record answers 404 and nothing is read: "+name, func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.storage.seed(name, encodePNG(t, variantSolidImage(10, 10, variantRed)))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/"+name+"?v=thumb", nil)
|
||||
|
||||
if recorder.Code != 404 || env.storage.openCountOf(name) != 0 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
|
||||
t.Errorf("status = %d, opens = %d", recorder.Code, env.storage.openCountOf(name))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("The record lookup comes after the parameter checks and before any storage read", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.storage.seed("orphan.png", []byte("orphan"))
|
||||
|
||||
unknown := env.get("/static/uploadedImages/orphan.png?v=banner", nil)
|
||||
orphan := env.get("/static/uploadedImages/orphan.png?v=thumb", nil)
|
||||
|
||||
assertRequestError(t, unknown, "unknown_variant", unknownMessage, "banner")
|
||||
if orphan.Code != 404 || env.storage.openCountOf("orphan.png") != 0 {
|
||||
t.Errorf("status = %d, opens = %d", orphan.Code, env.storage.openCountOf("orphan.png"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The original of any stored blob is still served without a variant", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.storage.seed("gen-1.jpg", []byte("generated bytes"))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/gen-1.jpg", nil)
|
||||
|
||||
if recorder.Code != 200 || recorder.Body.String() != "generated bytes" {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing record lookup answers 500 and reads nothing", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.images.fail.failNext("ExistsByStoredName")
|
||||
|
||||
recorder := env.get(thumbURL, nil)
|
||||
|
||||
if recorder.Code != 500 || env.storage.openCountOf(variantSourceName) != 0 || env.renderer.renderCount() != 0 {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageRepositoryExistsByStoredName(t *testing.T) {
|
||||
repo := persistence.NewImageRepository(openActivityDatabase(t))
|
||||
if _, err := repo.Create(domain.Image{Path: domain.PublicPath("a1b2.png"), Filename: "photo.png"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, want := range map[string]bool{"a1b2.png": true, "a1b2": false, "b2.png": false, "gen-1.jpg": false, "": false} {
|
||||
found, err := repo.ExistsByStoredName(name)
|
||||
if err != nil || found != want {
|
||||
t.Errorf("%q: found = %v, err = %v", name, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type renderGate struct {
|
||||
mutex sync.Mutex
|
||||
inFlight int
|
||||
peak int
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func newRenderGate() *renderGate {
|
||||
return &renderGate{started: make(chan struct{}, 64), release: make(chan struct{}, 64)}
|
||||
}
|
||||
|
||||
func (g *renderGate) hold() {
|
||||
g.mutex.Lock()
|
||||
g.inFlight++
|
||||
g.peak = max(g.peak, g.inFlight)
|
||||
g.mutex.Unlock()
|
||||
g.started <- struct{}{}
|
||||
<-g.release
|
||||
g.mutex.Lock()
|
||||
g.inFlight--
|
||||
g.mutex.Unlock()
|
||||
}
|
||||
|
||||
func (g *renderGate) running() int {
|
||||
g.mutex.Lock()
|
||||
defer g.mutex.Unlock()
|
||||
return g.inFlight
|
||||
}
|
||||
|
||||
func (g *renderGate) peakRunning() int {
|
||||
g.mutex.Lock()
|
||||
defer g.mutex.Unlock()
|
||||
return g.peak
|
||||
}
|
||||
|
||||
func (g *renderGate) awaitStarts(t *testing.T, count int) {
|
||||
t.Helper()
|
||||
for index := 0; index < count; index++ {
|
||||
select {
|
||||
case <-g.started:
|
||||
case <-time.After(gateTimeout):
|
||||
t.Fatalf("only %d renders started, want %d", index, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *renderGate) letRun(count int) {
|
||||
for index := 0; index < count; index++ {
|
||||
g.release <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func concurrencyEnv(t *testing.T, slots int, sources int) (*variantEnv, *renderGate, []string) {
|
||||
t.Helper()
|
||||
renderer := newCountingRenderer()
|
||||
env := newVariantEnvLimits(t, renderer, useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: variantDefaultPixels, MaxConcurrentRenders: slots})
|
||||
env.seedThumb()
|
||||
gate := newRenderGate()
|
||||
urls := make([]string, 0, sources)
|
||||
for index := 0; index < sources; index++ {
|
||||
name := "src" + strconv.Itoa(index) + ".png"
|
||||
env.seedSource(name, encodePNG(t, variantSolidImage(60, 40, variantRed)))
|
||||
urls = append(urls, "/static/uploadedImages/"+name+"?v=thumb")
|
||||
}
|
||||
renderer.onRender = gate.hold
|
||||
return env, gate, urls
|
||||
}
|
||||
|
||||
func getAsync(env *variantEnv, ctx context.Context, url string, codes chan<- int) {
|
||||
go func() {
|
||||
request := httptest.NewRequest(http.MethodGet, url, nil).WithContext(ctx)
|
||||
recorder := httptest.NewRecorder()
|
||||
env.public.ServeHTTP(recorder, request)
|
||||
codes <- recorder.Code
|
||||
}()
|
||||
}
|
||||
|
||||
func TestImageVariantsBoundedRenders(t *testing.T) {
|
||||
t.Run("At most IMAGE_VARIANT_MAX_CONCURRENT_RENDERS renders run at once", func(t *testing.T) {
|
||||
env, gate, urls := concurrencyEnv(t, 2, 5)
|
||||
codes := make(chan int, len(urls))
|
||||
for _, url := range urls {
|
||||
getAsync(env, context.Background(), url, codes)
|
||||
}
|
||||
|
||||
gate.awaitStarts(t, 2)
|
||||
time.Sleep(settleDelay)
|
||||
if gate.running() != 2 || env.renderer.renderCount() != 2 {
|
||||
t.Fatalf("in flight = %d, renders = %d", gate.running(), env.renderer.renderCount())
|
||||
}
|
||||
gate.letRun(len(urls))
|
||||
for range urls {
|
||||
select {
|
||||
case code := <-codes:
|
||||
if code != 200 {
|
||||
t.Errorf("status = %d", code)
|
||||
}
|
||||
case <-time.After(gateTimeout):
|
||||
t.Fatal("a request never finished")
|
||||
}
|
||||
}
|
||||
if gate.peakRunning() != 2 || env.renderer.renderCount() != 5 {
|
||||
t.Errorf("peak = %d, renders = %d", gate.peakRunning(), env.renderer.renderCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A cached file never takes a render slot", func(t *testing.T) {
|
||||
env, gate, urls := concurrencyEnv(t, 1, 2)
|
||||
gate.letRun(1)
|
||||
warm := make(chan int, 1)
|
||||
getAsync(env, context.Background(), urls[0], warm)
|
||||
gate.awaitStarts(t, 1)
|
||||
if code := <-warm; code != 200 {
|
||||
t.Fatalf("warm-up status = %d", code)
|
||||
}
|
||||
blocked := make(chan int, 1)
|
||||
getAsync(env, context.Background(), urls[1], blocked)
|
||||
gate.awaitStarts(t, 1)
|
||||
|
||||
cached := make(chan int, 1)
|
||||
getAsync(env, context.Background(), urls[0], cached)
|
||||
|
||||
select {
|
||||
case code := <-cached:
|
||||
if code != 200 {
|
||||
t.Errorf("status = %d", code)
|
||||
}
|
||||
case <-time.After(gateTimeout):
|
||||
t.Fatal("the cached request waited for a slot")
|
||||
}
|
||||
gate.letRun(1)
|
||||
<-blocked
|
||||
})
|
||||
|
||||
t.Run("A waiting request whose client has gone does not render", func(t *testing.T) {
|
||||
env, gate, urls := concurrencyEnv(t, 1, 2)
|
||||
holder := make(chan int, 1)
|
||||
getAsync(env, context.Background(), urls[0], holder)
|
||||
gate.awaitStarts(t, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
waiter := make(chan int, 1)
|
||||
getAsync(env, ctx, urls[1], waiter)
|
||||
time.Sleep(settleDelay)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-waiter:
|
||||
case <-time.After(gateTimeout):
|
||||
t.Fatal("the waiting request kept waiting")
|
||||
}
|
||||
gate.letRun(1)
|
||||
<-holder
|
||||
if env.renderer.renderCount() != 1 || env.storage.putCount() != 1 || env.files.rowCount() != 1 {
|
||||
t.Errorf("renders = %d, puts = %d, rows = %d", env.renderer.renderCount(), env.storage.putCount(), env.files.rowCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Identical concurrent requests still share one render and one slot", func(t *testing.T) {
|
||||
env, gate, urls := concurrencyEnv(t, 1, 1)
|
||||
codes := make(chan int, 20)
|
||||
for index := 0; index < 20; index++ {
|
||||
getAsync(env, context.Background(), urls[0], codes)
|
||||
}
|
||||
|
||||
gate.awaitStarts(t, 1)
|
||||
time.Sleep(settleDelay)
|
||||
gate.letRun(20)
|
||||
var served atomic.Int32
|
||||
for index := 0; index < 20; index++ {
|
||||
if <-codes == 200 {
|
||||
served.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
if served.Load() != 20 || env.renderer.renderCount() != 1 || gate.peakRunning() != 1 {
|
||||
t.Errorf("served = %d, renders = %d, peak = %d", served.Load(), env.renderer.renderCount(), gate.peakRunning())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantPixelAndConcurrencySettings(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
env string
|
||||
value string
|
||||
unset bool
|
||||
want int64
|
||||
failure bool
|
||||
}{
|
||||
{"pixels unset", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "", true, 50000000, false},
|
||||
{"pixels 2048", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "2048", false, 2048, false},
|
||||
{"pixels 0", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "0", false, 0, true},
|
||||
{"pixels -5", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "-5", false, 0, true},
|
||||
{"pixels many", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "many", false, 0, true},
|
||||
{"renders unset", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "", true, 4, false},
|
||||
{"renders 1", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "1", false, 1, false},
|
||||
{"renders 0", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "0", false, 0, true},
|
||||
{"renders -1", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "-1", false, 0, true},
|
||||
{"renders many", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "many", false, 0, true},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run("The env var is loaded: "+row.name, func(t *testing.T) {
|
||||
setBaseSettingsEnv(t)
|
||||
if row.unset {
|
||||
t.Setenv(row.env, "")
|
||||
} else {
|
||||
t.Setenv(row.env, row.value)
|
||||
}
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
if row.failure {
|
||||
if err == nil || !strings.Contains(err.Error(), row.env) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
got := settings.ImageVariantMaxSourcePixels
|
||||
if row.env == "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS" {
|
||||
got = int64(settings.ImageVariantMaxConcurrentRenders)
|
||||
}
|
||||
if err != nil || got != row.want {
|
||||
t.Errorf("value = %d, err = %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Both defaults are listed in envDefaults, .env.example and docs/configuration.md", func(t *testing.T) {
|
||||
defaults := between(readRepositoryFile(t, "cmd/server/main.go"), "var envDefaults = map[string]string{", "\n}")
|
||||
required := between(readRepositoryFile(t, "cmd/server/main.go"), "var requiredEnvVars = []string{", "\n}")
|
||||
for _, name := range []string{"EnvImageVariantMaxSourcePixels", "EnvImageVariantMaxConcurrentRenders"} {
|
||||
if !strings.Contains(defaults, "config."+name) || strings.Contains(required, name) {
|
||||
t.Errorf("%s is misplaced", name)
|
||||
}
|
||||
}
|
||||
for _, document := range []string{".env.example", "docs/configuration.md"} {
|
||||
content := readRepositoryFile(t, document)
|
||||
for _, name := range []string{"IMAGE_VARIANT_MAX_SOURCE_PIXELS", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS"} {
|
||||
if !strings.Contains(content, name) {
|
||||
t.Errorf("%s does not mention %s", document, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func bandedTallImage() *image.NRGBA {
|
||||
canvas := image.NewNRGBA(image.Rect(0, 0, 20, 60))
|
||||
for y := 0; y < 60; y++ {
|
||||
fill := variantGreen
|
||||
if y < 15 {
|
||||
fill = variantRed
|
||||
}
|
||||
if y >= 45 {
|
||||
fill = variantBlue
|
||||
}
|
||||
for x := 0; x < 20; x++ {
|
||||
canvas.SetNRGBA(x, y, fill)
|
||||
}
|
||||
}
|
||||
return canvas
|
||||
}
|
||||
|
||||
func globalPaletteGIF(t *testing.T, frames int) []byte {
|
||||
t.Helper()
|
||||
palette := color.Palette{color.White, color.Black, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255}}
|
||||
animation := &gif.GIF{Config: image.Config{ColorModel: palette, Width: 8, Height: 8}}
|
||||
for frame := 0; frame < frames; frame++ {
|
||||
paletted := image.NewPaletted(image.Rect(0, 0, 8, 8), palette)
|
||||
paletted.SetColorIndex(frame, frame, 1)
|
||||
animation.Image = append(animation.Image, paletted)
|
||||
animation.Delay = append(animation.Delay, 10)
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
if err := gif.EncodeAll(&buffer, animation); err != nil {
|
||||
t.Fatalf("gif encode failed: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func TestVariantGeometryPixelGaps(t *testing.T) {
|
||||
t.Run("Cover on a tall source keeps the middle band", func(t *testing.T) {
|
||||
rendered := renderVariant(t, encodePNG(t, bandedTallImage()), variantSettings(20, 20, domain.FitCover, domain.FormatPNG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
if decoded.Bounds().Dx() != 20 || decoded.Bounds().Dy() != 20 {
|
||||
t.Fatalf("output = %v", decoded.Bounds())
|
||||
}
|
||||
for _, y := range []int{0, 10, 19} {
|
||||
if got := pixelAt(decoded, 10, y); got != variantGreen {
|
||||
t.Errorf("pixel 10,%d = %v, want green", y, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Inside on a tall source scales the whole picture", func(t *testing.T) {
|
||||
rendered := renderVariant(t, encodePNG(t, bandedTallImage()), variantSettings(10, 30, domain.FitInside, domain.FormatPNG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
if decoded.Bounds().Dx() != 10 || decoded.Bounds().Dy() != 30 {
|
||||
t.Fatalf("output = %v", decoded.Bounds())
|
||||
}
|
||||
want := map[int]color.NRGBA{1: variantRed, 15: variantGreen, 28: variantBlue}
|
||||
for y, expected := range want {
|
||||
assertNearColor(t, pixelAt(decoded, 5, y), expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Contain pads horizontally around a narrow source", func(t *testing.T) {
|
||||
rendered := renderVariant(t, encodePNG(t, variantSolidImage(20, 40, variantRed)), variantSettings(40, 40, domain.FitContain, domain.FormatPNG))
|
||||
|
||||
decoded, _ := decodeImage(t, rendered.Content)
|
||||
for y := 0; y < 40; y += 13 {
|
||||
for _, x := range []int{0, 9, 30, 39} {
|
||||
if got := pixelAt(decoded, x, y); got.A != 0 {
|
||||
t.Fatalf("pixel %d,%d = %v, want transparent", x, y, got)
|
||||
}
|
||||
}
|
||||
for _, x := range []int{10, 19, 29} {
|
||||
if got := pixelAt(decoded, x, y); got != variantRed {
|
||||
t.Fatalf("pixel %d,%d = %v, want red", x, y, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Contain keeps a box that is larger in one direction only", func(t *testing.T) {
|
||||
for _, row := range []struct{ width, height, wantWidth, wantHeight int }{
|
||||
{200, 100, 200, 100},
|
||||
{100, 200, 100, 200},
|
||||
} {
|
||||
rendered := renderVariant(t, solidPNG(t, 100, 100), variantSettings(row.width, row.height, domain.FitContain, domain.FormatPNG))
|
||||
|
||||
width, height := decodedSize(t, rendered.Content)
|
||||
if width != row.wantWidth || height != row.wantHeight {
|
||||
t.Errorf("output = %dx%d, want %dx%d", width, height, row.wantWidth, row.wantHeight)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnimatedGIFDetectionWithGlobalPalette(t *testing.T) {
|
||||
animated, err := domain.IsAnimatedGIF(bytes.NewReader(globalPaletteGIF(t, 2)))
|
||||
if err != nil || !animated {
|
||||
t.Errorf("two frames: animated = %v, err = %v", animated, err)
|
||||
}
|
||||
|
||||
animated, err = domain.IsAnimatedGIF(bytes.NewReader(globalPaletteGIF(t, 1)))
|
||||
if err != nil || animated {
|
||||
t.Errorf("one frame: animated = %v, err = %v", animated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRequestErrorMessages(t *testing.T) {
|
||||
unknown := domain.NewUnknownVariantError("thumb")
|
||||
if unknown.Error() != "unknown variant: thumb" || !errors.Is(unknown, domain.ErrUnknownVariant) {
|
||||
t.Errorf("unknown = %q", unknown.Error())
|
||||
}
|
||||
unsupported := domain.NewUnsupportedParameterError("w")
|
||||
if unsupported.Error() != "unsupported parameter: w" || !errors.Is(unsupported, domain.ErrUnsupportedParameter) {
|
||||
t.Errorf("unsupported = %q", unsupported.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func persistedVariant(name string, width *int, height *int) domain.Variant {
|
||||
quality := 80
|
||||
return domain.Variant{Name: name, Width: width, Height: height, Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: &quality, Enabled: true}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int { return &value }
|
||||
|
||||
func TestVariantRepository(t *testing.T) {
|
||||
t.Run("A created variant is read back with absent dimensions as nil", func(t *testing.T) {
|
||||
repo := persistence.NewVariantRepository(openActivityDatabase(t))
|
||||
|
||||
if _, err := repo.Create(persistedVariant("card", intPointer(800), nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stored, err := repo.Get("card")
|
||||
if err != nil || stored.Width == nil || *stored.Width != 800 || stored.Height != nil || *stored.Quality != 80 || !stored.Enabled {
|
||||
t.Errorf("stored = %+v, err = %v", stored, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A png variant keeps a nil quality and a disabled variant stays disabled", func(t *testing.T) {
|
||||
repo := persistence.NewVariantRepository(openActivityDatabase(t))
|
||||
logo := persistedVariant("logo", intPointer(100), nil)
|
||||
logo.Format, logo.Quality, logo.Enabled = domain.FormatPNG, nil, false
|
||||
if _, err := repo.Create(logo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stored, _ := repo.Get("logo")
|
||||
|
||||
if stored.Quality != nil || stored.Enabled || stored.GenerateOnUpload {
|
||||
t.Errorf("stored = %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A duplicate name is refused with ErrVariantExists", func(t *testing.T) {
|
||||
repo := persistence.NewVariantRepository(openActivityDatabase(t))
|
||||
_, _ = repo.Create(persistedVariant("thumb", intPointer(400), nil))
|
||||
|
||||
_, err := repo.Create(persistedVariant("thumb", intPointer(800), nil))
|
||||
|
||||
if !errors.Is(err, domain.ErrVariantExists) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The list is ordered by name", func(t *testing.T) {
|
||||
repo := persistence.NewVariantRepository(openActivityDatabase(t))
|
||||
for _, name := range []string{"hero", "card", "thumb"} {
|
||||
_, _ = repo.Create(persistedVariant(name, intPointer(100), nil))
|
||||
}
|
||||
|
||||
listed, err := repo.List()
|
||||
|
||||
if err != nil || len(listed) != 3 || listed[0].Name != "card" || listed[1].Name != "hero" || listed[2].Name != "thumb" {
|
||||
t.Errorf("listed = %+v, err = %v", listed, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An update replaces the rendering settings and nulls a removed dimension", func(t *testing.T) {
|
||||
repo := persistence.NewVariantRepository(openActivityDatabase(t))
|
||||
_, _ = repo.Create(persistedVariant("hero", intPointer(1200), intPointer(630)))
|
||||
changed := persistedVariant("hero", intPointer(1000), nil)
|
||||
changed.UpdatedAt = time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
if _, err := repo.Update(changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stored, _ := repo.Get("hero")
|
||||
if *stored.Width != 1000 || stored.Height != nil || !stored.UpdatedAt.Equal(changed.UpdatedAt) {
|
||||
t.Errorf("stored = %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown variant is reported as ErrVariantNotFound on get, update and delete", func(t *testing.T) {
|
||||
repo := persistence.NewVariantRepository(openActivityDatabase(t))
|
||||
|
||||
_, getErr := repo.Get("missing")
|
||||
_, updateErr := repo.Update(persistedVariant("missing", intPointer(1), nil))
|
||||
deleteErr := repo.Delete("missing")
|
||||
|
||||
for _, err := range []error{getErr, updateErr, deleteErr} {
|
||||
if !errors.Is(err, domain.ErrVariantNotFound) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A deleted variant is gone", func(t *testing.T) {
|
||||
repo := persistence.NewVariantRepository(openActivityDatabase(t))
|
||||
_, _ = repo.Create(persistedVariant("hero", intPointer(100), nil))
|
||||
|
||||
if err := repo.Delete("hero"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := repo.Get("hero"); !errors.Is(err, domain.ErrVariantNotFound) {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVariantFileRepository(t *testing.T) {
|
||||
file := func(variant string, source string, cached string, size int64) domain.VariantFile {
|
||||
return domain.VariantFile{VariantName: variant, SourceName: source, CachedName: cached, ContentType: "image/jpeg", Size: size, Fingerprint: "f1"}
|
||||
}
|
||||
|
||||
t.Run("A saved file is found by variant and source", func(t *testing.T) {
|
||||
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
|
||||
_, _ = repo.Save(file("hero", "a.png", "g1.jpeg", 1000))
|
||||
|
||||
found, ok, err := repo.Find("hero", "a.png")
|
||||
|
||||
if err != nil || !ok || found.CachedName != "g1.jpeg" || found.Size != 1000 || found.Fingerprint != "f1" || found.ContentType != "image/jpeg" || found.ID == 0 {
|
||||
t.Errorf("found = %+v, ok = %v, err = %v", found, ok, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Finding nothing is not an error", func(t *testing.T) {
|
||||
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
|
||||
|
||||
_, ok, err := repo.Find("hero", "a.png")
|
||||
|
||||
if ok || err != nil {
|
||||
t.Errorf("ok = %v, err = %v", ok, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Saving again for the same variant and source replaces the row", func(t *testing.T) {
|
||||
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
|
||||
_, _ = repo.Save(file("hero", "a.png", "g1.jpeg", 1000))
|
||||
|
||||
if _, err := repo.Save(file("hero", "a.png", "g2.jpeg", 500)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, _ := repo.ListByVariant("hero")
|
||||
if len(rows) != 1 || rows[0].CachedName != "g2.jpeg" || rows[0].Size != 500 {
|
||||
t.Errorf("rows = %+v", rows)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Rows are listed by variant and by source", func(t *testing.T) {
|
||||
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
|
||||
_, _ = repo.Save(file("hero", "a.png", "g1.jpeg", 1))
|
||||
_, _ = repo.Save(file("hero", "b.png", "g2.jpeg", 2))
|
||||
_, _ = repo.Save(file("thumb", "a.png", "g3.jpeg", 3))
|
||||
|
||||
byVariant, _ := repo.ListByVariant("hero")
|
||||
bySource, _ := repo.ListBySource("a.png")
|
||||
|
||||
if len(byVariant) != 2 || len(bySource) != 2 {
|
||||
t.Errorf("by variant = %+v, by source = %+v", byVariant, bySource)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Totals count files and bytes per variant", func(t *testing.T) {
|
||||
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
|
||||
_, _ = repo.Save(file("hero", "a.png", "g1.jpeg", 1000))
|
||||
_, _ = repo.Save(file("hero", "b.png", "g2.jpeg", 2000))
|
||||
_, _ = repo.Save(file("hero", "c.png", "g3.jpeg", 3000))
|
||||
|
||||
totals, err := repo.Totals()
|
||||
|
||||
if err != nil || totals["hero"].Files != 3 || totals["hero"].Bytes != 6000 || len(totals) != 1 {
|
||||
t.Errorf("totals = %+v, err = %v", totals, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting a row removes only that row and tolerates a missing one", func(t *testing.T) {
|
||||
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
|
||||
first, _ := repo.Save(file("hero", "a.png", "g1.jpeg", 1))
|
||||
_, _ = repo.Save(file("hero", "b.png", "g2.jpeg", 2))
|
||||
|
||||
firstErr := repo.Delete(first.ID)
|
||||
secondErr := repo.Delete(first.ID)
|
||||
|
||||
rows, _ := repo.ListByVariant("hero")
|
||||
if firstErr != nil || secondErr != nil || len(rows) != 1 {
|
||||
t.Errorf("rows = %+v, errs = %v %v", rows, firstErr, secondErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type understatedSizeVariantStorage struct {
|
||||
*mockVariantStorage
|
||||
}
|
||||
|
||||
func (s understatedSizeVariantStorage) Open(storedName string) (domain.Blob, error) {
|
||||
blob, err := s.mockVariantStorage.Open(storedName)
|
||||
blob.Size = 1
|
||||
return blob, err
|
||||
}
|
||||
|
||||
type sizingVariantRenderer struct {
|
||||
received int
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *sizingVariantRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
|
||||
data, err := io.ReadAll(source)
|
||||
r.received = len(data)
|
||||
r.calls++
|
||||
return domain.RenderedVariant{Content: []byte("rendered"), ContentType: "image/png"}, err
|
||||
}
|
||||
|
||||
func (r *sizingVariantRenderer) DeclaredSize(source io.Reader) (int, int, error) {
|
||||
return 1, 1, nil
|
||||
}
|
||||
|
||||
func (r *sizingVariantRenderer) EncodableFormats() []domain.VariantFormat {
|
||||
return []domain.VariantFormat{domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG}
|
||||
}
|
||||
|
||||
type lateStaleVariantFileRepo struct {
|
||||
*mockVariantFileRepo
|
||||
finds int
|
||||
stale domain.VariantFile
|
||||
}
|
||||
|
||||
func (r *lateStaleVariantFileRepo) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
|
||||
r.finds++
|
||||
if r.finds == 1 {
|
||||
return domain.VariantFile{}, false, nil
|
||||
}
|
||||
return r.stale, true, nil
|
||||
}
|
||||
|
||||
func TestImageVariantsServeInternals(t *testing.T) {
|
||||
const cap = 1000
|
||||
|
||||
serve := func(storage domain.Storage, files domain.VariantFileRepository, renderer domain.VariantRenderer, variants *mockVariantRepo) (useCases.ServedVariant, error) {
|
||||
name := "thumb"
|
||||
images := newMockImageRepo()
|
||||
images.images[1] = domain.Image{ID: 1, Path: domain.PublicPath("a.png")}
|
||||
return useCases.NewServeVariantUseCase(variants, files, images, storage, renderer, defaultServeLimits(cap)).Serve(useCases.ServeVariantRequest{StoredName: "a.png", Variant: &name})
|
||||
}
|
||||
|
||||
t.Run("A streamed source of exactly the cap is rendered whole", func(t *testing.T) {
|
||||
variants := newMockVariantRepo()
|
||||
seedVariant(variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
|
||||
storage := newMockVariantStorage()
|
||||
storage.seed("a.png", make([]byte, cap))
|
||||
renderer := &sizingVariantRenderer{}
|
||||
|
||||
_, err := serve(understatedSizeVariantStorage{storage}, newMockVariantFileRepo(), renderer, variants)
|
||||
|
||||
if err != nil || renderer.calls != 1 || renderer.received != cap {
|
||||
t.Errorf("err = %v, calls = %d, received = %d", err, renderer.calls, renderer.received)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A streamed source beyond the cap is rejected even when its reported size is small", func(t *testing.T) {
|
||||
variants := newMockVariantRepo()
|
||||
seedVariant(variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
|
||||
storage := newMockVariantStorage()
|
||||
storage.seed("a.png", make([]byte, cap+1))
|
||||
renderer := &sizingVariantRenderer{}
|
||||
|
||||
_, err := serve(understatedSizeVariantStorage{storage}, newMockVariantFileRepo(), renderer, variants)
|
||||
|
||||
if !errors.Is(err, domain.ErrSourceTooLarge) || renderer.calls != 0 {
|
||||
t.Errorf("err = %v, calls = %d", err, renderer.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A stale row that appears during generation is regenerated, not served", func(t *testing.T) {
|
||||
variants := newMockVariantRepo()
|
||||
seedVariant(variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
|
||||
storage := newMockVariantStorage()
|
||||
storage.seed("a.png", []byte("source"))
|
||||
storage.seed("old.png", []byte("old"))
|
||||
files := &lateStaleVariantFileRepo{
|
||||
mockVariantFileRepo: newMockVariantFileRepo(),
|
||||
stale: domain.VariantFile{VariantName: "thumb", SourceName: "a.png", CachedName: "old.png", ContentType: "image/png", Size: 3, Fingerprint: "stale"},
|
||||
}
|
||||
renderer := &sizingVariantRenderer{}
|
||||
|
||||
served, err := serve(storage, files, renderer, variants)
|
||||
|
||||
if err != nil || renderer.calls != 1 {
|
||||
t.Fatalf("err = %v, calls = %d", err, renderer.calls)
|
||||
}
|
||||
defer served.Content.Close()
|
||||
body, _ := io.ReadAll(served.Content)
|
||||
if string(body) != "rendered" {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
thumbURL = "/static/uploadedImages/a1b2.png?v=thumb"
|
||||
unknownMessage = "Unknown variant"
|
||||
unsupportedText = "Unsupported parameter"
|
||||
cacheHeader = "public, no-cache"
|
||||
privateBlobName = "a1b2.png"
|
||||
)
|
||||
|
||||
func servingEnv(t *testing.T) *variantEnv {
|
||||
t.Helper()
|
||||
env := newVariantEnv(t)
|
||||
env.seedThumb()
|
||||
env.seedPhoto()
|
||||
return env
|
||||
}
|
||||
|
||||
func assertRequestError(t *testing.T, recorder *httptest.ResponseRecorder, code string, message string, detail string) {
|
||||
t.Helper()
|
||||
if recorder.Code != 400 || !strings.HasPrefix(recorder.Header().Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
var body struct {
|
||||
Status int `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body = %q", recorder.Body.String())
|
||||
}
|
||||
if body.Status != 400 || body.Error != code || body.Message != message || body.Detail != detail {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageVariantsFirstAndCachedRequests(t *testing.T) {
|
||||
t.Run("The first request generates and caches", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
original := append([]byte(nil), env.storage.blobs[privateBlobName]...)
|
||||
|
||||
recorder := env.get(thumbURL, nil)
|
||||
|
||||
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Fatalf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
width, height := decodedSize(t, recorder.Body.Bytes())
|
||||
_, format := decodeImage(t, recorder.Body.Bytes())
|
||||
if width != 400 || height != 300 || format != "jpeg" {
|
||||
t.Errorf("output = %dx%d %s", width, height, format)
|
||||
}
|
||||
rows := env.files.rowsOf("thumb")
|
||||
if env.storage.putCount() != 1 || len(rows) != 1 || rows[0].Size != int64(recorder.Body.Len()) {
|
||||
t.Errorf("puts = %d, rows = %+v", env.storage.putCount(), rows)
|
||||
}
|
||||
if !bytes.Equal(env.storage.blobs[privateBlobName], original) {
|
||||
t.Error("the original blob changed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The second request serves the cached file without rendering", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
first := env.get(thumbURL, nil)
|
||||
|
||||
second := env.get(thumbURL, nil)
|
||||
|
||||
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
|
||||
t.Errorf("renders = %d, puts = %d", env.renderer.renderCount(), env.storage.putCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A request without ?v= is the unchanged original", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
|
||||
recorder := env.get("/static/uploadedImages/a1b2.png", nil)
|
||||
|
||||
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/png" || recorder.Header().Get("Cache-Control") != cacheHeader || recorder.Header().Get("X-Content-Type-Options") != "nosniff" || recorder.Header().Get("ETag") != "" {
|
||||
t.Errorf("headers = %v", recorder.Header())
|
||||
}
|
||||
if !bytes.Equal(recorder.Body.Bytes(), env.storage.blobs[privateBlobName]) || env.renderer.renderCount() != 0 {
|
||||
t.Error("the original was altered or the renderer ran")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Other query parameters are ignored", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
|
||||
recorder := env.get(thumbURL+"&t=123", nil)
|
||||
|
||||
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Errorf("response = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A HEAD request answers the headers of the variant", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
request := httptest.NewRequest(http.MethodHead, thumbURL, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
env.public.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" || recorder.Body.Len() != 0 {
|
||||
t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsETag(t *testing.T) {
|
||||
t.Run("The ETag derives from the variant settings", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
first := env.get(thumbURL, nil)
|
||||
etag := first.Header().Get("ETag")
|
||||
|
||||
again := env.get(thumbURL, nil)
|
||||
conditional := env.get(thumbURL, http.Header{"If-None-Match": []string{etag}})
|
||||
|
||||
if len(etag) < 3 || !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) || again.Header().Get("ETag") != etag {
|
||||
t.Errorf("etag = %q then %q", etag, again.Header().Get("ETag"))
|
||||
}
|
||||
if conditional.Code != 304 || conditional.Body.Len() != 0 || env.renderer.renderCount() != 1 {
|
||||
t.Errorf("conditional = %d %q, renders = %d", conditional.Code, conditional.Body.String(), env.renderer.renderCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A 304 does not touch the cache", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
etag := env.get(thumbURL, nil).Header().Get("ETag")
|
||||
callsBefore := env.files.calls
|
||||
|
||||
env.get(thumbURL, http.Header{"If-None-Match": []string{etag}})
|
||||
|
||||
if env.files.calls != callsBefore {
|
||||
t.Error("the file index was read")
|
||||
}
|
||||
})
|
||||
|
||||
changes := []struct {
|
||||
setting string
|
||||
body string
|
||||
changes bool
|
||||
}{
|
||||
{"quality", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`, true},
|
||||
{"width", `{"width": 500, "height": 300, "fit": "cover", "format": "jpeg", "quality": 80}`, true},
|
||||
{"height", `{"width": 400, "height": 200, "fit": "cover", "format": "jpeg", "quality": 80}`, true},
|
||||
{"fit", `{"width": 400, "height": 300, "fit": "inside", "format": "jpeg", "quality": 80}`, true},
|
||||
{"format", `{"width": 400, "height": 300, "fit": "cover", "format": "png"}`, true},
|
||||
{"generate_on_upload", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 80, "generate_on_upload": true}`, false},
|
||||
}
|
||||
for _, row := range changes {
|
||||
t.Run("The ETag changes when a setting changes and stays when a switch changes: "+row.setting, func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
before := env.get(thumbURL, nil).Header().Get("ETag")
|
||||
|
||||
update := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", row.body)
|
||||
after := env.get(thumbURL, nil).Header().Get("ETag")
|
||||
|
||||
if update.Code != 200 || (before != after) != row.changes {
|
||||
t.Errorf("update = %d, before = %q, after = %q", update.Code, before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A stale If-None-Match after an edit gets a fresh 200", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
stale := env.get(thumbURL, nil).Header().Get("ETag")
|
||||
env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`)
|
||||
|
||||
recorder := env.get(thumbURL, http.Header{"If-None-Match": []string{stale}})
|
||||
|
||||
if recorder.Code != 200 || recorder.Header().Get("ETag") == stale || recorder.Body.Len() == 0 {
|
||||
t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("ETag"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Different images get different ETags for the same variant", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.seedSource("c3d4.png", encodePNG(t, variantSolidImage(900, 700, variantBlue)))
|
||||
|
||||
first := env.get(thumbURL, nil).Header().Get("ETag")
|
||||
second := env.get("/static/uploadedImages/c3d4.png?v=thumb", nil).Header().Get("ETag")
|
||||
|
||||
if first == second {
|
||||
t.Error("both images share one ETag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Editing a variant regenerates on the next request", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.get(thumbURL, nil)
|
||||
oldName := env.files.rowsOf("thumb")[0].CachedName
|
||||
|
||||
env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`)
|
||||
env.get(thumbURL, nil)
|
||||
|
||||
rows := env.files.rowsOf("thumb")
|
||||
if env.storage.has(oldName) || len(rows) != 1 || rows[0].CachedName == oldName || env.storage.putCount() != 2 {
|
||||
t.Errorf("rows = %+v, puts = %d", rows, env.storage.putCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A row with an old fingerprint is replaced and its blob removed", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.storage.seed("old.jpeg", []byte("old"))
|
||||
env.files.Save(domain.VariantFile{VariantName: "thumb", SourceName: privateBlobName, CachedName: "old.jpeg", ContentType: "image/jpeg", Size: 3, Fingerprint: "outdated"})
|
||||
|
||||
recorder := env.get(thumbURL, nil)
|
||||
|
||||
if recorder.Code != 200 || env.storage.has("old.jpeg") || env.files.rowsOf("thumb")[0].CachedName == "old.jpeg" {
|
||||
t.Error("the stale row was served or kept")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A cached file that vanished from storage is generated again", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.get(thumbURL, nil)
|
||||
vanished := env.files.rowsOf("thumb")[0].CachedName
|
||||
delete(env.storage.blobs, vanished)
|
||||
|
||||
recorder := env.get(thumbURL, nil)
|
||||
|
||||
rows := env.files.rowsOf("thumb")
|
||||
if recorder.Code != 200 || recorder.Body.Len() == 0 || len(rows) != 1 || rows[0].CachedName == vanished || !env.storage.has(rows[0].CachedName) {
|
||||
t.Errorf("response = %d, rows = %+v", recorder.Code, rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsConcurrency(t *testing.T) {
|
||||
t.Run("Concurrent first requests generate once", func(t *testing.T) {
|
||||
const clients = 20
|
||||
env := servingEnv(t)
|
||||
env.renderer.onRender = func() {
|
||||
for env.storage.openCountOf(privateBlobName) < clients {
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
bodies := make([][]byte, clients)
|
||||
codes := make([]int, clients)
|
||||
start := make(chan struct{})
|
||||
var finished sync.WaitGroup
|
||||
for index := 0; index < clients; index++ {
|
||||
finished.Add(1)
|
||||
go func() {
|
||||
defer finished.Done()
|
||||
<-start
|
||||
recorder := env.get(thumbURL, nil)
|
||||
codes[index], bodies[index] = recorder.Code, recorder.Body.Bytes()
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
finished.Wait()
|
||||
|
||||
for index := range bodies {
|
||||
if codes[index] != 200 || !bytes.Equal(bodies[index], bodies[0]) {
|
||||
t.Fatalf("client %d got %d", index, codes[index])
|
||||
}
|
||||
}
|
||||
if env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
|
||||
t.Errorf("renders = %d, puts = %d", env.renderer.renderCount(), env.storage.putCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Different variants and different images are not serialised together", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
seedVariant(env.variants, "card", 200, 0, domain.FitInside, domain.FormatJPEG, 80)
|
||||
env.seedSource("c3d4.png", encodePNG(t, variantSolidImage(900, 700, variantBlue)))
|
||||
urls := []string{
|
||||
"/static/uploadedImages/a1b2.png?v=thumb",
|
||||
"/static/uploadedImages/a1b2.png?v=card",
|
||||
"/static/uploadedImages/c3d4.png?v=thumb",
|
||||
"/static/uploadedImages/c3d4.png?v=card",
|
||||
}
|
||||
rendezvous := newRendezvous(len(urls))
|
||||
env.renderer.onRender = rendezvous.arrive
|
||||
|
||||
var finished sync.WaitGroup
|
||||
codes := make([]int, len(urls))
|
||||
for index, url := range urls {
|
||||
finished.Add(1)
|
||||
go func() {
|
||||
defer finished.Done()
|
||||
codes[index] = env.get(url, nil).Code
|
||||
}()
|
||||
}
|
||||
finished.Wait()
|
||||
|
||||
if !rendezvous.allInFlight() {
|
||||
t.Error("the renders were serialised")
|
||||
}
|
||||
for _, code := range codes {
|
||||
if code != 200 {
|
||||
t.Errorf("codes = %v", codes)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed generation is not cached and is retried", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.renderer.failFirst = true
|
||||
|
||||
first := env.get(thumbURL, nil)
|
||||
blobsAfterFailure, rowsAfterFailure := env.storage.putCount(), env.files.rowCount()
|
||||
second := env.get(thumbURL, nil)
|
||||
|
||||
if first.Code != 500 || blobsAfterFailure != 0 || rowsAfterFailure != 0 || second.Code != 200 {
|
||||
t.Errorf("statuses = %d then %d, puts = %d, rows = %d", first.Code, second.Code, blobsAfterFailure, rowsAfterFailure)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type rendezvous struct {
|
||||
expected int
|
||||
mutex sync.Mutex
|
||||
arrived int
|
||||
all chan struct{}
|
||||
timedOut bool
|
||||
}
|
||||
|
||||
func newRendezvous(expected int) *rendezvous {
|
||||
return &rendezvous{expected: expected, all: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (r *rendezvous) arrive() {
|
||||
r.mutex.Lock()
|
||||
r.arrived++
|
||||
if r.arrived == r.expected {
|
||||
close(r.all)
|
||||
}
|
||||
r.mutex.Unlock()
|
||||
select {
|
||||
case <-r.all:
|
||||
case <-time.After(10 * time.Second):
|
||||
r.mutex.Lock()
|
||||
r.timedOut = true
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rendezvous) allInFlight() bool {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
return r.arrived == r.expected && !r.timedOut
|
||||
}
|
||||
|
||||
func TestImageVariantsRequestContract(t *testing.T) {
|
||||
rows := []struct {
|
||||
name, url, code, message, detail string
|
||||
}{
|
||||
{"unknown name", "/static/uploadedImages/a1b2.png?v=banner", "unknown_variant", unknownMessage, "banner"},
|
||||
{"uppercase name", "/static/uploadedImages/a1b2.png?v=Thumb", "unknown_variant", unknownMessage, "Thumb"},
|
||||
{"empty name", "/static/uploadedImages/a1b2.png?v=", "unknown_variant", unknownMessage, ""},
|
||||
{"disabled variant", "/static/uploadedImages/a1b2.png?v=hero", "unknown_variant", unknownMessage, "hero"},
|
||||
{"width", "/static/uploadedImages/a1b2.png?width=400", "unsupported_parameter", unsupportedText, "width"},
|
||||
{"height", "/static/uploadedImages/a1b2.png?height=300", "unsupported_parameter", unsupportedText, "height"},
|
||||
{"format", "/static/uploadedImages/a1b2.png?format=webp", "unsupported_parameter", unsupportedText, "format"},
|
||||
{"quality", "/static/uploadedImages/a1b2.png?quality=50", "unsupported_parameter", unsupportedText, "quality"},
|
||||
{"variant and width", "/static/uploadedImages/a1b2.png?v=thumb&width=400", "unsupported_parameter", unsupportedText, "width"},
|
||||
{"unknown variant and quality", "/static/uploadedImages/a1b2.png?v=banner&quality=50", "unsupported_parameter", unsupportedText, "quality"},
|
||||
{"parameter before missing source", "/static/uploadedImages/missing.png?width=400", "unsupported_parameter", unsupportedText, "width"},
|
||||
{"unknown variant before missing source", "/static/uploadedImages/missing.png?v=banner", "unknown_variant", unknownMessage, "banner"},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run("A request outside the contract answers 400 with the JSON error body: "+row.name, func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
hero := seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
|
||||
hero.Enabled = false
|
||||
env.variants.variants["hero"] = hero
|
||||
|
||||
recorder := env.get(row.url, nil)
|
||||
|
||||
assertRequestError(t, recorder, row.code, row.message, row.detail)
|
||||
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
|
||||
t.Error("something was generated")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A disabled variant serves again once enabled, from its kept cache", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.get(thumbURL, nil)
|
||||
env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/thumb", `{"enabled": false}`)
|
||||
|
||||
disabled := env.get(thumbURL, nil)
|
||||
env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/thumb", `{"enabled": true}`)
|
||||
enabled := env.get(thumbURL, nil)
|
||||
|
||||
assertRequestError(t, disabled, "unknown_variant", unknownMessage, "thumb")
|
||||
if enabled.Code != 200 || env.storage.putCount() != 1 {
|
||||
t.Errorf("response = %d, puts = %d", enabled.Code, env.storage.putCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A deleted variant answers 400", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.get(thumbURL, nil)
|
||||
env.sendAsAdmin(http.MethodDelete, "/v1/image-variants/thumb", "")
|
||||
|
||||
recorder := env.get(thumbURL, nil)
|
||||
|
||||
assertRequestError(t, recorder, "unknown_variant", unknownMessage, "thumb")
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsMissingAndUnreadableSources(t *testing.T) {
|
||||
urls := []string{
|
||||
"/static/uploadedImages/missing.png?v=thumb",
|
||||
"/static/uploadedImages/../secret.png?v=thumb",
|
||||
"/static/uploadedImages/nested/a1b2.png?v=thumb",
|
||||
}
|
||||
for _, url := range urls {
|
||||
t.Run("A missing source answers 404: "+url, func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
|
||||
recorder := env.get(url, nil)
|
||||
|
||||
if recorder.Code != 404 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
|
||||
t.Errorf("response = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("A source that cannot be decoded answers 500 and caches nothing", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.seedSource("bad.png", []byte("these bytes are not an image"))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/bad.png?v=thumb", nil)
|
||||
|
||||
if recorder.Code != 500 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
|
||||
t.Errorf("response = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsSourceCap(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
size int
|
||||
status int
|
||||
}{{1000, 200}, {1001, 413}} {
|
||||
t.Run("A source beyond IMAGE_VARIANT_MAX_SOURCE_BYTES answers 413: "+http.StatusText(row.status), func(t *testing.T) {
|
||||
env := newVariantEnvWith(t, newCountingRenderer(), 1000)
|
||||
env.seedThumb()
|
||||
env.seedSource("big.png", padSizedPNG(t, row.size))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/big.png?v=thumb", nil)
|
||||
|
||||
if recorder.Code != row.status {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("The 413 body is plain text and nothing is generated", func(t *testing.T) {
|
||||
env := newVariantEnvWith(t, newCountingRenderer(), 1000)
|
||||
env.seedThumb()
|
||||
env.seedSource("big.png", padSizedPNG(t, 1001))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/big.png?v=thumb", nil)
|
||||
|
||||
assertStatusAndBody(t, recorder, 413, "The source image is too large to generate this variant.")
|
||||
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
|
||||
t.Error("something was generated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The cap never affects the original", func(t *testing.T) {
|
||||
env := newVariantEnvWith(t, newCountingRenderer(), 1000)
|
||||
content := padSizedPNG(t, 1001)
|
||||
env.seedSource("big.png", content)
|
||||
|
||||
recorder := env.get("/static/uploadedImages/big.png", nil)
|
||||
|
||||
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), content) {
|
||||
t.Errorf("status = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsAsIsSources(t *testing.T) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>`
|
||||
|
||||
t.Run("An SVG with a valid variant is served unchanged", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.seedSource("e5f6.svg", []byte(svg))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil)
|
||||
|
||||
if recorder.Code != 200 || recorder.Body.String() != svg || recorder.Header().Get("Content-Type") != "image/svg+xml" || recorder.Header().Get("Content-Security-Policy") != "default-src 'none'; style-src 'unsafe-inline'; sandbox" {
|
||||
t.Errorf("response = %d %v", recorder.Code, recorder.Header())
|
||||
}
|
||||
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
|
||||
t.Error("something was generated")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An SVG with an unknown variant is still a 400", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.seedSource("e5f6.svg", []byte(svg))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/e5f6.svg?v=banner", nil)
|
||||
|
||||
assertRequestError(t, recorder, "unknown_variant", unknownMessage, "banner")
|
||||
})
|
||||
|
||||
t.Run("An SVG larger than the cap is still served", func(t *testing.T) {
|
||||
env := newVariantEnvWith(t, newCountingRenderer(), 10)
|
||||
env.seedThumb()
|
||||
env.seedSource("e5f6.svg", []byte(svg))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil)
|
||||
|
||||
if recorder.Code != 200 || recorder.Body.String() != svg {
|
||||
t.Errorf("response = %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An animated GIF is served unchanged", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
animation := animatedGIF(t)
|
||||
env.seedSource("anim.gif", animation)
|
||||
|
||||
recorder := env.get("/static/uploadedImages/anim.gif?v=thumb", nil)
|
||||
|
||||
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), animation) || recorder.Header().Get("Content-Type") != "image/gif" || env.files.rowCount() != 0 || env.renderer.renderCount() != 0 {
|
||||
t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A single-frame GIF is treated like any raster source", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.seedSource("still.gif", stillGIF(t))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/still.gif?v=thumb", nil)
|
||||
|
||||
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" || env.renderer.renderCount() != 1 {
|
||||
t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A stored GIF is served with the image/gif content type", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
env.seedSource("anim.gif", animatedGIF(t))
|
||||
|
||||
recorder := env.get("/static/uploadedImages/anim.gif", nil)
|
||||
|
||||
if recorder.Header().Get("Content-Type") != "image/gif" {
|
||||
t.Errorf("content type = %q", recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestImageVariantsServeFormats(t *testing.T) {
|
||||
t.Run("A webp variant is served as image/webp with the right dimensions", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
seedVariant(env.variants, "wp", 200, 0, domain.FitCover, domain.FormatWebP, 70)
|
||||
|
||||
recorder := env.get("/static/uploadedImages/a1b2.png?v=wp", nil)
|
||||
|
||||
width, height := webpSize(t, recorder.Body.Bytes())
|
||||
if recorder.Header().Get("Content-Type") != "image/webp" || width != 200 || height != 160 {
|
||||
t.Errorf("response = %q %dx%d", recorder.Header().Get("Content-Type"), width, height)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The content type follows the generated file, not the source", func(t *testing.T) {
|
||||
env := servingEnv(t)
|
||||
seedVariant(env.variants, "logo", 100, 0, domain.FitCover, domain.FormatPNG, 0)
|
||||
|
||||
recorder := env.get("/static/uploadedImages/a1b2.png?v=logo", nil)
|
||||
|
||||
if recorder.Header().Get("Content-Type") != "image/png" || !bytes.HasPrefix(recorder.Body.Bytes(), []byte("\x89PNG")) {
|
||||
t.Errorf("content type = %q", recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/imaging"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errVariantStorageFailure = errors.New("variant storage failure")
|
||||
|
||||
var variantStorageExtensions = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpeg",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
type mockVariantRepo struct {
|
||||
mutex sync.Mutex
|
||||
variants map[string]domain.Variant
|
||||
failGet error
|
||||
calls int
|
||||
}
|
||||
|
||||
var _ domain.VariantRepository = &mockVariantRepo{}
|
||||
|
||||
func newMockVariantRepo() *mockVariantRepo {
|
||||
return &mockVariantRepo{variants: map[string]domain.Variant{}}
|
||||
}
|
||||
|
||||
func (m *mockVariantRepo) List() ([]domain.Variant, error) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
listed := make([]domain.Variant, 0, len(m.variants))
|
||||
for _, variant := range m.variants {
|
||||
listed = append(listed, variant)
|
||||
}
|
||||
sort.Slice(listed, func(i, j int) bool { return listed[i].Name < listed[j].Name })
|
||||
return listed, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantRepo) Get(name string) (domain.Variant, error) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
if m.failGet != nil {
|
||||
return domain.Variant{}, m.failGet
|
||||
}
|
||||
variant, found := m.variants[name]
|
||||
if !found {
|
||||
return domain.Variant{}, domain.ErrVariantNotFound
|
||||
}
|
||||
return variant, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantRepo) Create(variant domain.Variant) (domain.Variant, error) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
if _, found := m.variants[variant.Name]; found {
|
||||
return domain.Variant{}, domain.ErrVariantExists
|
||||
}
|
||||
m.variants[variant.Name] = variant
|
||||
return variant, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantRepo) Update(variant domain.Variant) (domain.Variant, error) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
if _, found := m.variants[variant.Name]; !found {
|
||||
return domain.Variant{}, domain.ErrVariantNotFound
|
||||
}
|
||||
m.variants[variant.Name] = variant
|
||||
return variant, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantRepo) Delete(name string) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
if _, found := m.variants[name]; !found {
|
||||
return domain.ErrVariantNotFound
|
||||
}
|
||||
delete(m.variants, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVariantRepo) stored(name string) domain.Variant {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
return m.variants[name]
|
||||
}
|
||||
|
||||
func (m *mockVariantRepo) count() int {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
return len(m.variants)
|
||||
}
|
||||
|
||||
type mockVariantFileRepo struct {
|
||||
mutex sync.Mutex
|
||||
rows map[uint32]domain.VariantFile
|
||||
nextID uint32
|
||||
deleted []uint32
|
||||
saves int
|
||||
calls int
|
||||
}
|
||||
|
||||
var _ domain.VariantFileRepository = &mockVariantFileRepo{}
|
||||
|
||||
func newMockVariantFileRepo() *mockVariantFileRepo {
|
||||
return &mockVariantFileRepo{rows: map[uint32]domain.VariantFile{}}
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
for _, row := range m.rows {
|
||||
if row.VariantName == variantName && row.SourceName == sourceName {
|
||||
return row, true, nil
|
||||
}
|
||||
}
|
||||
return domain.VariantFile{}, false, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) Save(file domain.VariantFile) (domain.VariantFile, error) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
m.saves++
|
||||
for id, row := range m.rows {
|
||||
if row.VariantName == file.VariantName && row.SourceName == file.SourceName {
|
||||
file.ID = id
|
||||
m.rows[id] = file
|
||||
return file, nil
|
||||
}
|
||||
}
|
||||
m.nextID++
|
||||
file.ID = m.nextID
|
||||
m.rows[file.ID] = file
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) ListByVariant(variantName string) ([]domain.VariantFile, error) {
|
||||
return m.filtered(func(row domain.VariantFile) bool { return row.VariantName == variantName }), nil
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) ListBySource(sourceName string) ([]domain.VariantFile, error) {
|
||||
return m.filtered(func(row domain.VariantFile) bool { return row.SourceName == sourceName }), nil
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) filtered(keep func(domain.VariantFile) bool) []domain.VariantFile {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
kept := []domain.VariantFile{}
|
||||
for _, row := range m.rows {
|
||||
if keep(row) {
|
||||
kept = append(kept, row)
|
||||
}
|
||||
}
|
||||
sort.Slice(kept, func(i, j int) bool { return kept[i].ID < kept[j].ID })
|
||||
return kept
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) Delete(id uint32) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
m.deleted = append(m.deleted, id)
|
||||
delete(m.rows, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) Totals() (map[string]domain.VariantFileTotals, error) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.calls++
|
||||
totals := map[string]domain.VariantFileTotals{}
|
||||
for _, row := range m.rows {
|
||||
current := totals[row.VariantName]
|
||||
totals[row.VariantName] = domain.VariantFileTotals{Files: current.Files + 1, Bytes: current.Bytes + row.Size}
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) rowsOf(variantName string) []domain.VariantFile {
|
||||
rows, _ := m.ListByVariant(variantName)
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m *mockVariantFileRepo) rowCount() int {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
return len(m.rows)
|
||||
}
|
||||
|
||||
type mockVariantStorage struct {
|
||||
mutex sync.Mutex
|
||||
blobs map[string][]byte
|
||||
puts int
|
||||
opens int
|
||||
openCounts map[string]int
|
||||
nextName int
|
||||
deleted []string
|
||||
failDelete map[string]error
|
||||
}
|
||||
|
||||
var _ domain.Storage = &mockVariantStorage{}
|
||||
|
||||
func newMockVariantStorage() *mockVariantStorage {
|
||||
return &mockVariantStorage{blobs: map[string][]byte{}, failDelete: map[string]error{}, openCounts: map[string]int{}}
|
||||
}
|
||||
|
||||
func (m *mockVariantStorage) Put(contentType string, content io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.puts++
|
||||
m.nextName++
|
||||
name := "gen-" + strconv.Itoa(m.nextName) + variantStorageExtensions[contentType]
|
||||
m.blobs[name] = data
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantStorage) Open(storedName string) (domain.Blob, error) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.opens++
|
||||
m.openCounts[storedName]++
|
||||
data, found := m.blobs[storedName]
|
||||
if !found || strings.Contains(storedName, "/") {
|
||||
return domain.Blob{}, domain.ErrBlobNotFound
|
||||
}
|
||||
return domain.Blob{Content: io.NopCloser(bytes.NewReader(data)), Size: int64(len(data)), ModTime: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)}, nil
|
||||
}
|
||||
|
||||
func (m *mockVariantStorage) Delete(storedName string) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.deleted = append(m.deleted, storedName)
|
||||
if err := m.failDelete[storedName]; err != nil {
|
||||
return err
|
||||
}
|
||||
delete(m.blobs, storedName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVariantStorage) seed(storedName string, content []byte) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.blobs[storedName] = content
|
||||
}
|
||||
|
||||
func (m *mockVariantStorage) has(storedName string) bool {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
_, found := m.blobs[storedName]
|
||||
return found
|
||||
}
|
||||
|
||||
func (m *mockVariantStorage) putCount() int {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
return m.puts
|
||||
}
|
||||
|
||||
func (m *mockVariantStorage) deletedNames() []string {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
return append([]string(nil), m.deleted...)
|
||||
}
|
||||
|
||||
type countingRenderer struct {
|
||||
inner domain.VariantRenderer
|
||||
calls atomic.Int32
|
||||
failFirst bool
|
||||
onRender func()
|
||||
}
|
||||
|
||||
var _ domain.VariantRenderer = &countingRenderer{}
|
||||
|
||||
func newCountingRenderer() *countingRenderer {
|
||||
return &countingRenderer{inner: imaging.NewVariantRenderer()}
|
||||
}
|
||||
|
||||
func newCountingRendererWithoutWebP() *countingRenderer {
|
||||
return &countingRenderer{inner: imaging.NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG)}
|
||||
}
|
||||
|
||||
func (c *countingRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
|
||||
call := c.calls.Add(1)
|
||||
if c.onRender != nil {
|
||||
c.onRender()
|
||||
}
|
||||
if c.failFirst && call == 1 {
|
||||
return domain.RenderedVariant{}, errors.New("render failure")
|
||||
}
|
||||
return c.inner.Render(source, settings)
|
||||
}
|
||||
|
||||
func (c *countingRenderer) DeclaredSize(source io.Reader) (int, int, error) {
|
||||
return c.inner.DeclaredSize(source)
|
||||
}
|
||||
|
||||
func defaultServeLimits(maxSourceBytes int64) useCases.ServeVariantLimits {
|
||||
return useCases.ServeVariantLimits{MaxSourceBytes: maxSourceBytes, MaxSourcePixels: variantDefaultPixels, MaxConcurrentRenders: variantDefaultRenders}
|
||||
}
|
||||
|
||||
func (c *countingRenderer) EncodableFormats() []domain.VariantFormat {
|
||||
return c.inner.EncodableFormats()
|
||||
}
|
||||
|
||||
func (c *countingRenderer) renderCount() int {
|
||||
return int(c.calls.Load())
|
||||
}
|
||||
|
||||
func seedVariant(repo *mockVariantRepo, name string, width int, height int, fit domain.VariantFit, format domain.VariantFormat, quality int) domain.Variant {
|
||||
settings := domain.VariantSettings{Fit: fit, Format: format}
|
||||
if width > 0 {
|
||||
settings.Width = &width
|
||||
}
|
||||
if height > 0 {
|
||||
settings.Height = &height
|
||||
}
|
||||
if format != domain.FormatPNG {
|
||||
settings.Quality = &quality
|
||||
}
|
||||
stamp := time.Date(2026, 10, 7, 8, 0, 0, 0, time.UTC)
|
||||
variant, err := domain.NewVariant(name, settings, false, true, stamp)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
repo.variants[name] = variant
|
||||
return variant
|
||||
}
|
||||
|
||||
func seedVariantFiles(files *mockVariantFileRepo, storage *mockVariantStorage, variantName string, sourceName string, count int, size int64) []domain.VariantFile {
|
||||
seeded := []domain.VariantFile{}
|
||||
for index := 0; index < count; index++ {
|
||||
cached := "seed-" + variantName + "-" + sourceName + "-" + strconv.Itoa(index) + ".jpeg"
|
||||
storage.seed(cached, []byte("generated"))
|
||||
saved, _ := files.Save(domain.VariantFile{
|
||||
VariantName: variantName,
|
||||
SourceName: sourceName + "-" + strconv.Itoa(index),
|
||||
CachedName: cached,
|
||||
ContentType: "image/jpeg",
|
||||
Size: size,
|
||||
Fingerprint: "seeded",
|
||||
})
|
||||
seeded = append(seeded, saved)
|
||||
}
|
||||
return seeded
|
||||
}
|
||||
|
||||
func newInertServeVariantUseCase(storage domain.Storage) *useCases.ServeVariantUseCase {
|
||||
return useCases.NewServeVariantUseCase(newMockVariantRepo(), newMockVariantFileRepo(), newMockImageRepo(), storage, newCountingRenderer(), defaultServeLimits(variantDefaultCap))
|
||||
}
|
||||
|
||||
func (m *mockVariantStorage) openCountOf(storedName string) int {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
return m.openCounts[storedName]
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
|
||||
env.seedArticle(id, "Article", true, env.urlOf(3))
|
||||
}
|
||||
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, discardRecorder)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
|
||||
if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -60,7 +60,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
|
||||
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
|
||||
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
now := func() time.Time { return env.clock }
|
||||
container := &api.UseCases{
|
||||
Token: env.token,
|
||||
@@ -73,7 +73,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
|
||||
MergeMediaUseCase: merge,
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
|
||||
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
|
||||
|
||||
@@ -947,7 +947,7 @@ func TestMediaMerging(t *testing.T) {
|
||||
|
||||
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
|
||||
_, err := uc.Merge(testCtx, 2, []uint32{3, 3})
|
||||
|
||||
@@ -1042,7 +1042,7 @@ func TestMediaSentinels(t *testing.T) {
|
||||
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
|
||||
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
|
||||
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
|
||||
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
|
||||
if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
|
||||
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
|
||||
|
||||
@@ -31,7 +31,7 @@ type schedulerEnv struct {
|
||||
func newSchedulerEnv(t *testing.T) *schedulerEnv {
|
||||
t.Helper()
|
||||
env := newPurgeEnv(t)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
now := func() time.Time { return env.clock }
|
||||
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder)
|
||||
return &schedulerEnv{
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
|
||||
|
||||
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
|
||||
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
|
||||
env.useImages(1, 2)
|
||||
env.useImages(2, 1, 2)
|
||||
env.useImages(3, 1)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
|
||||
result, err := merge.Merge(testCtx, 1, []uint32{2})
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
|
||||
env.useImages(1, 2)
|
||||
env.useImages(2, 2)
|
||||
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
|
||||
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
|
||||
|
||||
result, err := merge.Merge(testCtx, 1, []uint32{2})
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
|
||||
repo, store := newMockImageRepo(), newMockImageStorage()
|
||||
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
|
||||
|
||||
if err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, created.ID); err != nil {
|
||||
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, created.ID); err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
if len(store.blobs) != 0 || len(repo.images) != 0 {
|
||||
@@ -87,7 +87,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
|
||||
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
|
||||
repo, store := newMockImageRepo(), newMockImageStorage()
|
||||
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, 7)
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, 7)
|
||||
|
||||
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
|
||||
t.Errorf("got %v, deleted %v", err, store.deleted)
|
||||
@@ -99,7 +99,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
|
||||
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
|
||||
store.failDelete = errRepositoryFailure
|
||||
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, created.ID)
|
||||
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, created.ID)
|
||||
|
||||
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
|
||||
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
|
||||
@@ -112,7 +112,7 @@ func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
|
||||
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
|
||||
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
|
||||
|
||||
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, discardRecorder).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
|
||||
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
|
||||
|
||||
if err != nil || result.Merged != 2 {
|
||||
t.Fatalf("merge returned %+v, %v", result, err)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateTwiceKeepsSingleImagePathIndex(t *testing.T) {
|
||||
db := openActivityDatabase(t)
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("second migrate failed: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("third migrate failed: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?", "idx_images_path").Scan(&count).Error; err != nil {
|
||||
t.Fatalf("index lookup failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected idx_images_path exactly once, got %d", count)
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,8 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
|
||||
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
|
||||
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), discardRecorder),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), discardRecorder),
|
||||
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const s3ObjectBody = "0123456789"
|
||||
|
||||
func newS3StorageRouter(store domainImage.Storage) *chi.Mux {
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(store)))
|
||||
router.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(store), newInertServeVariantUseCase(store)))
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const spaIndexMarker = "SPA-INDEX-MARKER"
|
||||
func newServeUploadedImagesRouter(uploadDir string) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimiddleware.GetHead)
|
||||
r.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(storage.NewLocalStorage(uploadDir))))
|
||||
r.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(storage.NewLocalStorage(uploadDir)), newInertServeVariantUseCase(storage.NewLocalStorage(uploadDir))))
|
||||
r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(spaIndexMarker))
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
after := takeSqliteSnapshot(t, db)
|
||||
|
||||
for name := range after.schema {
|
||||
if strings.HasPrefix(name, "audit_") || strings.HasPrefix(name, "idx_audit_") {
|
||||
if isNewTableObject(name) {
|
||||
delete(after.schema, name)
|
||||
}
|
||||
}
|
||||
@@ -181,3 +181,12 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isNewTableObject(name string) bool {
|
||||
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path"} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func (failingStorage) Open(string) (domainImage.Blob, error) {
|
||||
|
||||
func serveFrom(t *testing.T, store domainImage.Storage, method, target string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store))
|
||||
handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store), newInertServeVariantUseCase(store))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
|
||||
return rec
|
||||
|
||||
@@ -385,7 +385,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
|
||||
t.Run("id_1", func(t *testing.T) {
|
||||
repo := newMockImageRepo()
|
||||
repo.images[1] = imageDomain.Image{ID: 1}
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
|
||||
|
||||
err := uc.Delete(testCtx, 1)
|
||||
if err != nil {
|
||||
@@ -399,7 +399,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
|
||||
t.Run("id_9", func(t *testing.T) {
|
||||
repo := newMockImageRepo()
|
||||
repo.images[1] = imageDomain.Image{ID: 1}
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
|
||||
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
|
||||
|
||||
err := uc.Delete(testCtx, 9)
|
||||
if !errors.Is(err, imageDomain.ErrImageNotFound) {
|
||||
|
||||
Reference in new issue
Block a user