feat: image variants engine

This commit is contained in:
fsylvain committed 2026-10-08 02:33:32 +02:00
1 parent f0845de643
commit f1d56ef50e
80 files changed
+7312 -84

No files matched your search

+3
View File
@@ -16,6 +16,9 @@ AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES= TRUSTED_PROXIES=
UPDATE_FEED_URL=https://git.floriansylvain.fr UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false RENEWCMS_CONTAINER=false
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
IMAGE_VARIANT_MAX_SOURCE_PIXELS=50000000
IMAGE_VARIANT_MAX_CONCURRENT_RENDERS=4
DB_DRIVER=sqlite DB_DRIVER=sqlite
DATA_DIR= DATA_DIR=
DB_HOST= DB_HOST=
+23 -2
View File
@@ -13,6 +13,7 @@ import (
"RenewCMS/internal/infrastructure/auditing" "RenewCMS/internal/infrastructure/auditing"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown" "RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/database" "RenewCMS/internal/infrastructure/persistence/database"
@@ -87,6 +88,13 @@ type UseCases struct {
ExportActivityUseCase *useCases.ExportActivityUseCase ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase 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) { func (c *UseCases) RunSchedulers(ctx context.Context) {
@@ -108,6 +116,9 @@ type Repositories struct {
ArticleRepo domainArticle.Repository ArticleRepo domainArticle.Repository
UserRepo domainUser.Repository UserRepo domainUser.Repository
ImageRepo domainImage.Repository ImageRepo domainImage.Repository
VariantRepo domainImage.VariantRepository
VariantFileRepo domainImage.VariantFileRepository
VariantRenderer domainImage.VariantRenderer
InviteRepo domainInvite.Repository InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository APIKeyRepo domainAPIKey.Repository
PurgeSettingsRepo domainPurge.Repository PurgeSettingsRepo domainPurge.Repository
@@ -240,6 +251,9 @@ func getRepositories(connection *database.Connection, settings config.Settings)
ArticleRepo: persistence.NewArticleRepository(db), ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db), UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db), ImageRepo: persistence.NewImageRepository(db),
VariantRepo: persistence.NewVariantRepository(db),
VariantFileRepo: persistence.NewVariantFileRepository(db),
VariantRenderer: imaging.NewVariantRenderer(),
InviteRepo: persistence.NewInviteRepository(db), InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db), APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db), PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
@@ -265,7 +279,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
renderer := markdown.NewConverter() renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator() keyGenerator := hashing.NewSHA256KeyGenerator()
recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default()) 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) runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder) purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
@@ -300,7 +314,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo), ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, recorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder),
MergeMediaUseCase: mergeMedia, MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
@@ -322,6 +336,13 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now), ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now), GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, 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 package staticimages
import ( import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/image" "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors" "errors"
"io" "io"
"net/http" "net/http"
@@ -17,31 +19,115 @@ import (
const cacheControl = "public, no-cache" const cacheControl = "public, no-cache"
const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox" const svgContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
const fallbackContentType = "application/octet-stream" const fallbackContentType = "application/octet-stream"
const unknownVariantCode = "unknown_variant"
const unsupportedParameterCode = "unsupported_parameter"
var contentTypeExtensions = map[string]string{ var contentTypeExtensions = map[string]string{
".png": "image/png", ".png": "image/png",
".jpeg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp", ".webp": "image/webp",
".svg": "image/svg+xml", ".svg": "image/svg+xml",
".gif": "image/gif",
} }
type Handler struct { type Handler struct {
openMedia *useCases.OpenMediaUseCase openMedia *useCases.OpenMediaUseCase
modTime time.Time serveVariant *useCases.ServeVariantUseCase
modTime time.Time
} }
func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler { func NewHandler(openMedia *useCases.OpenMediaUseCase, serveVariant *useCases.ServeVariantUseCase) *Handler {
return &Handler{ return &Handler{
openMedia: openMedia, openMedia: openMedia,
modTime: time.Now(), 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) { func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r)) 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) { if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -52,7 +138,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
defer blob.Content.Close() 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("Content-Type", contentType)
w.Header().Set("Cache-Control", cacheControl) w.Header().Set("Cache-Control", cacheControl)
if contentType == "image/svg+xml" { 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 { 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 return
} }
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10)) w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
+11
View File
@@ -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 PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running." const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365." 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"
+7
View File
@@ -1,12 +1,14 @@
package validator package validator
import ( import (
"RenewCMS/internal/domain/image"
"regexp" "regexp"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
const inviteEmailTag = "inviteemail" const inviteEmailTag = "inviteemail"
const variantNameTag = "variantname"
var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`) var inviteEmailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$`)
@@ -15,6 +17,11 @@ var Validate *validator.Validate
func InitValidator() { func InitValidator() {
Validate = validator.New() Validate = validator.New()
_ = Validate.RegisterValidation(inviteEmailTag, isInviteEmail) _ = 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 { func isInviteEmail(field validator.FieldLevel) bool {
+24 -1
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/api/handlers/article" "RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/frontend" "RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/media" "RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/staticimages" "RenewCMS/api/handlers/staticimages"
@@ -273,6 +274,27 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
return r 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 { func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r := chi.NewRouter() 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("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies)) r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(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("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies)) r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, 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.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth) apiRouter.Get("/healthz", GetHealth)
apiRouter.Mount("/v1", backend) 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) apiRouter.Mount("/", frontend)
return apiRouter return apiRouter
+22 -19
View File
@@ -42,25 +42,28 @@ var requiredEnvVars = []string{
} }
var envDefaults = map[string]string{ var envDefaults = map[string]string{
"ENVIRONMENT": "production", "ENVIRONMENT": "production",
"PORT": "8080", "PORT": "8080",
"DB_FILE": "./data/renewcms.db", "DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver, config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS, "DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode, "DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns, "DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns, "DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds, "DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads", "UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver, config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region, "S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle, "S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS, "S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5", "AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60", "AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "", "TRUSTED_PROXIES": "",
"UPDATE_FEED_URL": "https://git.floriansylvain.fr", "UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false", "RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders,
} }
const versionFlag = "--version" const versionFlag = "--version"
+29 -3
View File
@@ -62,12 +62,13 @@ never changes the response or the change itself.
| Category | Actions | | Category | Actions |
| --- | --- | | --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | | `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` | | `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` | | `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). Target types: `article`, `image`, `image_variant` (id and name are the variant name), `user`, `invite`,
Only the fields `title`, `online`, `body`, `filename` and `display_name` appear in `changes`, with the `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 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 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 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 Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`. `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 ## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The 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. statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
+6
View File
@@ -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. 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 ### 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. 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.
+3
View File
@@ -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. | | `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). | | `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. | | `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. | | `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. 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.
+102
View File
@@ -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.
+778
View File
@@ -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
+74
View File
@@ -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.
+3 -1
View File
@@ -8,6 +8,7 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.33.6 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/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 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/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
@@ -21,6 +22,7 @@ require (
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/net v0.59.0 golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.3 gorm.io/driver/postgres v1.6.3
@@ -46,6 +48,7 @@ require (
github.com/aws/smithy-go v1.28.1 // indirect github.com/aws/smithy-go v1.28.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/glebarez/go-sqlite v1.22.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // 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/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+4
View File
@@ -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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= 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= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
+32
View File
@@ -2,6 +2,7 @@ package audit
import ( import (
"RenewCMS/internal/domain/article" "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"strconv" "strconv"
"unicode/utf8" "unicode/utf8"
) )
@@ -28,3 +29,34 @@ func ArticleChanges(before article.Article, after article.Article) []Change {
} }
return changes 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
}
+19
View File
@@ -76,6 +76,12 @@ const (
ActionMediaDelete = "media.delete" ActionMediaDelete = "media.delete"
ActionMediaMerge = "media.merge" ActionMediaMerge = "media.merge"
ActionMediaPurge = "media.purge" 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" ActionUserRegister = "user.register"
ActionUserVerify = "user.verify" ActionUserVerify = "user.verify"
ActionUserUpdateProfile = "user.update_profile" ActionUserUpdateProfile = "user.update_profile"
@@ -94,6 +100,7 @@ const (
TargetUser = "user" TargetUser = "user"
TargetArticle = "article" TargetArticle = "article"
TargetImage = "image" TargetImage = "image"
TargetVariant = "image_variant"
TargetInvite = "invite" TargetInvite = "invite"
TargetRelease = "release" TargetRelease = "release"
) )
@@ -112,12 +119,24 @@ const (
ParamFailedGroups = "failed_groups" ParamFailedGroups = "failed_groups"
ParamUpdateAvailable = "update_available" ParamUpdateAvailable = "update_available"
ParamKind = "kind" ParamKind = "kind"
ParamPurgedFiles = "purged_files"
ParamPurgedBytes = "purged_bytes"
ParamWidth = "width"
ParamHeight = "height"
ParamFit = "fit"
ParamFormat = "format"
ParamQuality = "quality"
ParamTag = "tag" ParamTag = "tag"
FieldTitle = "title" FieldTitle = "title"
FieldOnline = "online" FieldOnline = "online"
FieldBody = "body" FieldBody = "body"
FieldFilename = "filename" FieldFilename = "filename"
FieldDisplayName = "display_name" FieldDisplayName = "display_name"
FieldWidth = "width"
FieldHeight = "height"
FieldFit = "fit"
FieldFormat = "format"
FieldQuality = "quality"
maxNameLength = 255 maxNameLength = 255
maxActionLength = 64 maxActionLength = 64
maxFailureReasonSize = 255 maxFailureReasonSize = 255
+4
View File
@@ -9,3 +9,7 @@ func NewTarget(targetType string, id uint32, name string) Target {
func ReleaseTarget(tag string) Target { func ReleaseTarget(tag string) Target {
return Target{Type: TargetRelease, ID: tag, Name: tag} return Target{Type: TargetRelease, ID: tag, Name: tag}
} }
func VariantTarget(name string) Target {
return Target{Type: TargetVariant, ID: name, Name: name}
}
+114
View File
@@ -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) Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error) ListDuplicates() ([]Image, error)
Counts() (Counts, error) Counts() (Counts, error)
ExistsByStoredName(storedName string) (bool, error)
ListMissingMetadata() ([]Image, error) ListMissingMetadata() ([]Image, error)
UpdateMetadata(id uint32, metadata Metadata) error UpdateMetadata(id uint32, metadata Metadata) error
} }
+222
View File
@@ -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
}
+54
View File
@@ -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
}
+40 -19
View File
@@ -22,22 +22,25 @@ type AuthRateLimitSettings struct {
} }
type Settings struct { type Settings struct {
Environment string Environment string
CorsAllowedOrigins []string CorsAllowedOrigins []string
PublicURL string PublicURL string
UploadDir string UploadDir string
JWTSecret string JWTSecret string
DBFile string DBFile string
Database DatabaseSettings Database DatabaseSettings
Storage StorageSettings Storage StorageSettings
DataDirPath string DataDirPath string
Port string Port string
Smtp SmtpSettings Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings AuthRateLimit AuthRateLimitSettings
TrustedProxies []string TrustedProxies []string
Version string Version string
UpdateFeedURL string UpdateFeedURL string
Container bool Container bool
ImageVariantMaxSourceBytes int64
ImageVariantMaxSourcePixels int64
ImageVariantMaxConcurrentRenders int
} }
func (s Settings) DataDir() string { func (s Settings) DataDir() string {
@@ -77,6 +80,21 @@ func NewSettingsFromEnv() (Settings, error) {
return Settings{}, err 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{ return Settings{
Environment: os.Getenv("ENVIRONMENT"), Environment: os.Getenv("ENVIRONMENT"),
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")), CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
@@ -99,9 +117,12 @@ func NewSettingsFromEnv() (Settings, error) {
MaxAttempts: authRateLimitMaxAttempts, MaxAttempts: authRateLimitMaxAttempts,
Window: time.Duration(authRateLimitWindowSeconds) * time.Second, Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
}, },
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")), TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"), UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"), Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
ImageVariantMaxSourceBytes: maxSourceBytes,
ImageVariantMaxSourcePixels: maxSourcePixels,
ImageVariantMaxConcurrentRenders: maxConcurrentRenders,
}, nil }, 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 { 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 { if err != nil {
return err return err
} }
if err := ensureImagePathIndex(db); err != nil {
return err
}
return BackfillUserRoles(db) 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 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 { func (i ImageRepository) duplicatedHashes() *gorm.DB {
return i.db.Model(&entity.Image{}). return i.db.Model(&entity.Image{}).
Select("sha256"). 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 recorder audit.Recorder
} }
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *DeleteMediaUseCase { func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}, recorder} return &DeleteMediaUseCase{imageRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
} }
func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error { 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 recorder audit.Recorder
} }
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, recorder audit.Recorder) *MergeMediaUseCase { func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}, recorder} return &MergeMediaUseCase{imageRepository, articleRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
} }
type bodyRewrite struct { 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.ErrInvalidMergeRequest, "The merge request is not valid."},
{image.ErrMergeCopyMissing, "One of the copies no longer exists."}, {image.ErrMergeCopyMissing, "One of the copies no longer exists."},
{image.ErrMergeHashMismatch, "The images are not identical copies."}, {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.ErrPurgeConflict, "The library changed since the preview. Review it again."},
{purge.ErrPurgeBusy, "A purge is already running."}, {purge.ErrPurgeBusy, "A purge is already running."},
{user.ErrRegistrationClosed, "Registration is closed on this site."}, {user.ErrRegistrationClosed, "Registration is closed on this site."},
@@ -65,6 +66,7 @@ var downloadFailureReasons = map[string]string{
var notFoundCauses = []error{ var notFoundCauses = []error{
article.ErrArticleNotFound, article.ErrArticleNotFound,
image.ErrImageNotFound, image.ErrImageNotFound,
image.ErrVariantNotFound,
invite.ErrInviteNotFound, invite.ErrInviteNotFound,
user.ErrUserNotFound, user.ErrUserNotFound,
} }
@@ -5,10 +5,19 @@ import "RenewCMS/internal/domain/image"
type mediaRemover struct { type mediaRemover struct {
imageRepository image.Repository imageRepository image.Repository
storage image.Storage 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 { 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 err
} }
return r.imageRepository.Delete(img.ID) 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
}
+1 -1
View File
@@ -383,7 +383,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})} images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
articles := newMockArticleRepo() articles := newMockArticleRepo()
storage := newMockImageStorage() 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) run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder)
finished := make(chan struct{}) finished := make(chan struct{})
+2 -2
View File
@@ -100,7 +100,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
mail := newMockMailRepo() mail := newMockMailRepo()
env.mail = mail env.mail = mail
renderer := &fakeBodyRenderer{} renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), recorder) mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder)
purgeSettings := newMockPurgeSettingsRepo() purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{} restartJobs := &fakeJobStore{}
env.container = &api.UseCases{ env.container = &api.UseCases{
@@ -127,7 +127,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), recorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder),
MergeMediaUseCase: mergeMedia, MergeMediaUseCase: mergeMedia,
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder), 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), RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
+13 -1
View File
@@ -245,12 +245,24 @@ func (m *mockImageRepo) Counts() (imageDomain.Counts, error) {
return imageDomain.Counts{All: len(m.images), DuplicateGroups: groups}, nil 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{} var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) { func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder) 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) { t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{ input := imageDomain.ImageInput{
+640
View File
@@ -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
}
+278
View File
@@ -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))
}
})
}
+102
View File
@@ -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)
}
})
}
}
+242
View File
@@ -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")
}
})
}
+204
View File
@@ -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"}}
}
+323
View File
@@ -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")
}
})
}
+189
View File
@@ -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)
}
})
}
+147
View File
@@ -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))...)
}
+444
View File
@@ -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)
}
}
}
})
}
+129
View File
@@ -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())
}
}
+195
View File
@@ -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)
}
})
}
+116
View File
@@ -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)
}
})
}
+588
View File
@@ -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"))
}
})
}
+378
View File
@@ -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]
}
+1 -1
View File
@@ -60,7 +60,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3)) env.seedArticle(id, "Article", true, env.urlOf(3))
} }
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles} 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 { if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
+2 -2
View File
@@ -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[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1} 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 } now := func() time.Time { return env.clock }
container := &api.UseCases{ container := &api.UseCases{
Token: env.token, Token: env.token,
@@ -73,7 +73,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder), 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, MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
+2 -2
View File
@@ -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) { t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
env := newEnv(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}) _, 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(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1) env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
env.seedImage(4, "d.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) { if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err) t.Errorf("expected ErrMergeCopyMissing, got %v", err)
+1 -1
View File
@@ -31,7 +31,7 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv { func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper() t.Helper()
env := newPurgeEnv(t) 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 } now := func() time.Time { return env.clock }
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder) runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder)
return &schedulerEnv{ return &schedulerEnv{
+3 -3
View File
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) { func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase { 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) 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(1, 2)
env.useImages(2, 1, 2) env.useImages(2, 1, 2)
env.useImages(3, 1) 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}) result, err := merge.Merge(testCtx, 1, []uint32{2})
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2) env.useImages(1, 2)
env.useImages(2, 2) env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure} 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}) result, err := merge.Merge(testCtx, 1, []uint32{2})
+4 -4
View File
@@ -76,7 +76,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage() repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder)) 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) t.Fatalf("Delete failed: %v", err)
} }
if len(store.blobs) != 0 || len(repo.images) != 0 { 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) { t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage() 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 { if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
t.Errorf("got %v, deleted %v", err, store.deleted) 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)) created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
store.failDelete = errRepositoryFailure 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 { 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) 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) create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create) 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 { if err != nil || result.Merged != 2 {
t.Fatalf("merge returned %+v, %v", result, err) t.Fatalf("merge returned %+v, %v", result, err)
+23
View File
@@ -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)
}
}
+2 -2
View File
@@ -143,8 +143,8 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder), CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo), ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder), RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), discardRecorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), discardRecorder), MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ const s3ObjectBody = "0123456789"
func newS3StorageRouter(store domainImage.Storage) *chi.Mux { func newS3StorageRouter(store domainImage.Storage) *chi.Mux {
router := chi.NewRouter() 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 return router
} }
+1 -1
View File
@@ -20,7 +20,7 @@ const spaIndexMarker = "SPA-INDEX-MARKER"
func newServeUploadedImagesRouter(uploadDir string) *chi.Mux { func newServeUploadedImagesRouter(uploadDir string) *chi.Mux {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(chimiddleware.GetHead) 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) { r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(spaIndexMarker)) _, _ = w.Write([]byte(spaIndexMarker))
+10 -1
View File
@@ -169,7 +169,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
after := takeSqliteSnapshot(t, db) after := takeSqliteSnapshot(t, db)
for name := range after.schema { for name := range after.schema {
if strings.HasPrefix(name, "audit_") || strings.HasPrefix(name, "idx_audit_") { if isNewTableObject(name) {
delete(after.schema, 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
}
+1 -1
View File
@@ -43,7 +43,7 @@ func (failingStorage) Open(string) (domainImage.Blob, error) {
func serveFrom(t *testing.T, store domainImage.Storage, method, target string) *httptest.ResponseRecorder { func serveFrom(t *testing.T, store domainImage.Storage, method, target string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store)) handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store), newInertServeVariantUseCase(store))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) handler.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
return rec return rec
+2 -2
View File
@@ -385,7 +385,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) { t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1} 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) err := uc.Delete(testCtx, 1)
if err != nil { if err != nil {
@@ -399,7 +399,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) { t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo() repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1} 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) err := uc.Delete(testCtx, 9)
if !errors.Is(err, imageDomain.ErrImageNotFound) { if !errors.Is(err, imageDomain.ErrImageNotFound) {