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

@@ -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 {
Environment string
CorsAllowedOrigins []string
PublicURL string
UploadDir string
JWTSecret string
DBFile string
Database DatabaseSettings
Storage StorageSettings
DataDirPath string
Port string
Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings
TrustedProxies []string
Version string
UpdateFeedURL string
Container bool
Environment string
CorsAllowedOrigins []string
PublicURL string
UploadDir string
JWTSecret string
DBFile string
Database DatabaseSettings
Storage StorageSettings
DataDirPath string
Port string
Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings
TrustedProxies []string
Version string
UpdateFeedURL string
Container bool
ImageVariantMaxSourceBytes int64
ImageVariantMaxSourcePixels int64
ImageVariantMaxConcurrentRenders int
}
func (s Settings) DataDir() string {
@@ -77,6 +80,21 @@ func NewSettingsFromEnv() (Settings, error) {
return Settings{}, err
}
maxSourceBytes, err := imageVariantMaxSourceBytesFromEnv()
if err != nil {
return Settings{}, err
}
maxSourcePixels, err := imageVariantMaxSourcePixelsFromEnv()
if err != nil {
return Settings{}, err
}
maxConcurrentRenders, err := imageVariantMaxConcurrentRendersFromEnv()
if err != nil {
return Settings{}, err
}
return Settings{
Environment: os.Getenv("ENVIRONMENT"),
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
@@ -99,9 +117,12 @@ func NewSettingsFromEnv() (Settings, error) {
MaxAttempts: authRateLimitMaxAttempts,
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
},
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
ImageVariantMaxSourceBytes: maxSourceBytes,
ImageVariantMaxSourcePixels: maxSourcePixels,
ImageVariantMaxConcurrentRenders: maxConcurrentRenders,
}, nil
}
@@ -0,0 +1,60 @@
package imaging
import (
domain "RenewCMS/internal/domain/image"
"bytes"
"image"
"image/jpeg"
"image/png"
"github.com/gen2brain/webp"
)
type encoder interface {
contentType() string
flattensTransparency() bool
encode(output *bytes.Buffer, source image.Image, quality int) error
}
type jpegEncoder struct{}
func (jpegEncoder) contentType() string { return "image/jpeg" }
func (jpegEncoder) flattensTransparency() bool { return true }
func (jpegEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
return jpeg.Encode(output, source, &jpeg.Options{Quality: quality})
}
type pngEncoder struct{}
func (pngEncoder) contentType() string { return "image/png" }
func (pngEncoder) flattensTransparency() bool { return false }
func (pngEncoder) encode(output *bytes.Buffer, source image.Image, _ int) error {
return png.Encode(output, source)
}
type webpEncoder struct{}
func (webpEncoder) contentType() string { return "image/webp" }
func (webpEncoder) flattensTransparency() bool { return false }
func (webpEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
return webp.Encode(output, source, webp.Options{Quality: quality})
}
var knownEncoders = map[domain.VariantFormat]encoder{
domain.FormatJPEG: jpegEncoder{},
domain.FormatPNG: pngEncoder{},
domain.FormatWebP: webpEncoder{},
}
var sourceFormats = map[string]domain.VariantFormat{
"jpeg": domain.FormatJPEG,
"png": domain.FormatPNG,
"webp": domain.FormatWebP,
"gif": domain.FormatPNG,
}
@@ -0,0 +1,89 @@
package imaging
import (
domain "RenewCMS/internal/domain/image"
"image"
"math"
)
type layout struct {
canvas image.Point
source image.Rectangle
content image.Rectangle
}
func layoutFor(source image.Point, settings domain.VariantSettings) layout {
if settings.Width == nil || settings.Height == nil {
return proportionalLayout(source, settings)
}
box := image.Pt(*settings.Width, *settings.Height)
switch settings.Fit {
case domain.FitContain:
return containLayout(source, box)
case domain.FitInside:
return insideLayout(source, box)
default:
return coverLayout(source, box)
}
}
func proportionalLayout(source image.Point, settings domain.VariantSettings) layout {
scale := 1.0
if settings.Width != nil {
scale = math.Min(scale, ratio(*settings.Width, source.X))
}
if settings.Height != nil {
scale = math.Min(scale, ratio(*settings.Height, source.Y))
}
return wholeSourceLayout(source, scaled(source, scale))
}
func insideLayout(source image.Point, box image.Point) layout {
scale := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
return wholeSourceLayout(source, scaled(source, scale))
}
func wholeSourceLayout(source image.Point, output image.Point) layout {
return layout{canvas: output, source: image.Rectangle{Max: source}, content: image.Rectangle{Max: output}}
}
func coverLayout(source image.Point, box image.Point) layout {
shrink := math.Min(1, math.Min(ratio(source.X, box.X), ratio(source.Y, box.Y)))
output := scaled(box, shrink)
return layout{canvas: output, source: centeredCrop(source, output), content: image.Rectangle{Max: output}}
}
func containLayout(source image.Point, box image.Point) layout {
fit := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
content := scaled(source, fit)
content = image.Pt(min(content.X, box.X), min(content.Y, box.Y))
origin := image.Pt((box.X-content.X)/2, (box.Y-content.Y)/2)
return layout{canvas: box, source: image.Rectangle{Max: source}, content: image.Rectangle{Min: origin, Max: origin.Add(content)}}
}
func centeredCrop(source image.Point, output image.Point) image.Rectangle {
crop := source
if source.X*output.Y >= source.Y*output.X {
crop.X = min(source.X, roundedDivision(source.Y*output.X, output.Y))
} else {
crop.Y = min(source.Y, roundedDivision(source.X*output.Y, output.X))
}
origin := image.Pt((source.X-crop.X)/2, (source.Y-crop.Y)/2)
return image.Rectangle{Min: origin, Max: origin.Add(crop)}
}
func ratio(numerator int, denominator int) float64 {
return float64(numerator) / float64(denominator)
}
func roundedDivision(numerator int, denominator int) int {
return int(math.Round(ratio(numerator, denominator)))
}
func scaled(size image.Point, factor float64) image.Point {
return image.Pt(scaledLength(size.X, factor), scaledLength(size.Y, factor))
}
func scaledLength(length int, factor float64) int {
return max(1, int(math.Round(float64(length)*factor)))
}
@@ -0,0 +1,102 @@
package imaging
import (
domain "RenewCMS/internal/domain/image"
"bytes"
"image"
"image/color"
"io"
"slices"
xdraw "golang.org/x/image/draw"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/webp"
)
type VariantRenderer struct {
encoders map[domain.VariantFormat]encoder
}
var _ domain.VariantRenderer = &VariantRenderer{}
func NewVariantRenderer() *VariantRenderer {
return NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG, domain.FormatWebP)
}
func NewVariantRendererEncoding(formats ...domain.VariantFormat) *VariantRenderer {
encoders := map[domain.VariantFormat]encoder{}
for format, candidate := range knownEncoders {
if slices.Contains(formats, format) {
encoders[format] = candidate
}
}
return &VariantRenderer{encoders}
}
func (r *VariantRenderer) EncodableFormats() []domain.VariantFormat {
formats := []domain.VariantFormat{domain.FormatOriginal}
for _, format := range domain.VariantFormats() {
if _, encodable := r.encoders[format]; encodable {
formats = append(formats, format)
}
}
return formats
}
func (r *VariantRenderer) DeclaredSize(source io.Reader) (int, int, error) {
header, _, err := image.DecodeConfig(source)
if err != nil {
return 0, 0, err
}
return header.Width, header.Height, nil
}
func (r *VariantRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
decoded, sourceFormat, err := image.Decode(source)
if err != nil {
return domain.RenderedVariant{}, err
}
chosen := r.encoderFor(settings.Format, sourceFormat)
arranged := arrange(decoded, layoutFor(decoded.Bounds().Size(), settings), chosen.flattensTransparency())
var output bytes.Buffer
if err := chosen.encode(&output, arranged, qualityOf(settings)); err != nil {
return domain.RenderedVariant{}, err
}
return domain.RenderedVariant{Content: output.Bytes(), ContentType: chosen.contentType()}, nil
}
func (r *VariantRenderer) encoderFor(format domain.VariantFormat, sourceFormat string) encoder {
if format == domain.FormatOriginal {
format = sourceFormats[sourceFormat]
}
if chosen, encodable := r.encoders[format]; encodable {
return chosen
}
return pngEncoder{}
}
func qualityOf(settings domain.VariantSettings) int {
if settings.Quality == nil {
return domain.DefaultVariantQuality
}
return *settings.Quality
}
func arrange(decoded image.Image, plan layout, onWhite bool) image.Image {
canvas := image.NewRGBA(image.Rectangle{Max: plan.canvas})
if onWhite {
xdraw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, xdraw.Src)
}
sourceRegion := plan.source.Add(decoded.Bounds().Min)
if plan.source.Size() == plan.content.Size() {
xdraw.Draw(canvas, plan.content, decoded, sourceRegion.Min, xdraw.Over)
return canvas
}
xdraw.CatmullRom.Scale(canvas, plan.content, decoded, sourceRegion, xdraw.Over, nil)
return canvas
}
@@ -7,9 +7,29 @@ import (
)
func Migrate(db *gorm.DB) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{})
if err != nil {
return err
}
if err := ensureImagePathIndex(db); err != nil {
return err
}
return BackfillUserRoles(db)
}
const (
imagePathIndexName = "idx_images_path"
mysqlDialectorName = "mysql"
imagePathIndexStatement = "CREATE INDEX " + imagePathIndexName + " ON images (path)"
mysqlImagePathStatement = "CREATE INDEX " + imagePathIndexName + " ON images (path(191))"
)
func ensureImagePathIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&models.Image{}, imagePathIndexName) {
return nil
}
if db.Dialector.Name() == mysqlDialectorName {
return db.Exec(mysqlImagePathStatement).Error
}
return db.Exec(imagePathIndexStatement).Error
}
@@ -0,0 +1,60 @@
package mappers
import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
)
func VariantToDomain(variantModel models.ImageVariant) image.Variant {
return image.Variant{
Name: variantModel.Name,
Width: variantModel.Width,
Height: variantModel.Height,
Fit: image.VariantFit(variantModel.Fit),
Format: image.VariantFormat(variantModel.Format),
Quality: variantModel.Quality,
GenerateOnUpload: variantModel.GenerateOnUpload,
Enabled: variantModel.Enabled,
CreatedAt: variantModel.CreatedAt,
UpdatedAt: variantModel.UpdatedAt,
}
}
func VariantToModel(variantDomain image.Variant) models.ImageVariant {
return models.ImageVariant{
Name: variantDomain.Name,
Width: variantDomain.Width,
Height: variantDomain.Height,
Fit: string(variantDomain.Fit),
Format: string(variantDomain.Format),
Quality: variantDomain.Quality,
GenerateOnUpload: variantDomain.GenerateOnUpload,
Enabled: variantDomain.Enabled,
CreatedAt: variantDomain.CreatedAt,
UpdatedAt: variantDomain.UpdatedAt,
}
}
func VariantFileToDomain(fileModel models.ImageVariantFile) image.VariantFile {
return image.VariantFile{
ID: fileModel.ID,
VariantName: fileModel.VariantName,
SourceName: fileModel.SourceName,
CachedName: fileModel.CachedName,
ContentType: fileModel.ContentType,
Size: fileModel.Size,
Fingerprint: fileModel.Fingerprint,
}
}
func VariantFileToModel(fileDomain image.VariantFile) models.ImageVariantFile {
return models.ImageVariantFile{
ID: fileDomain.ID,
VariantName: fileDomain.VariantName,
SourceName: fileDomain.SourceName,
CachedName: fileDomain.CachedName,
ContentType: fileDomain.ContentType,
Size: fileDomain.Size,
Fingerprint: fileDomain.Fingerprint,
}
}
@@ -0,0 +1,14 @@
package models
import "time"
type ImageVariantFile struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
VariantName string `gorm:"not null;size:32;uniqueIndex:idx_image_variant_files_pair,priority:1"`
SourceName string `gorm:"not null;size:191;uniqueIndex:idx_image_variant_files_pair,priority:2;index:idx_image_variant_files_source"`
CachedName string `gorm:"not null;size:191"`
ContentType string `gorm:"not null;size:64"`
Size int64 `gorm:"not null"`
Fingerprint string `gorm:"not null;size:64"`
CreatedAt time.Time
}
@@ -0,0 +1,16 @@
package models
import "time"
type ImageVariant struct {
Name string `gorm:"primaryKey;size:32"`
Width *int
Height *int
Fit string `gorm:"not null;size:16"`
Format string `gorm:"not null;size:16"`
Quality *int
GenerateOnUpload bool `gorm:"not null"`
Enabled bool `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -101,6 +101,12 @@ func (i ImageRepository) Counts() (domain.Counts, error) {
return domain.Counts{All: int(all), DuplicateGroups: int(groups)}, nil
}
func (i ImageRepository) ExistsByStoredName(storedName string) (bool, error) {
var matches int64
err := i.db.Model(&entity.Image{}).Where("path = ?", domain.PublicPath(storedName)).Limit(1).Count(&matches).Error
return matches > 0, err
}
func (i ImageRepository) duplicatedHashes() *gorm.DB {
return i.db.Model(&entity.Image{}).
Select("sha256").
@@ -0,0 +1,91 @@
package persistence
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type VariantFileRepository struct {
db *gorm.DB
}
var _ domain.VariantFileRepository = &VariantFileRepository{}
func NewVariantFileRepository(db *gorm.DB) *VariantFileRepository {
return &VariantFileRepository{db}
}
func (v *VariantFileRepository) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
var model entity.ImageVariantFile
err := v.db.Where("variant_name = ? AND source_name = ?", variantName, sourceName).First(&model).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return domain.VariantFile{}, false, nil
}
if err != nil {
return domain.VariantFile{}, false, err
}
return mappers.VariantFileToDomain(model), true, nil
}
func (v *VariantFileRepository) Save(file domain.VariantFile) (domain.VariantFile, error) {
model := mappers.VariantFileToModel(file)
model.ID = 0
err := v.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "variant_name"}, {Name: "source_name"}},
DoUpdates: clause.AssignmentColumns([]string{"cached_name", "content_type", "size", "fingerprint"}),
}).Create(&model).Error
if err != nil {
return domain.VariantFile{}, err
}
saved, _, err := v.Find(file.VariantName, file.SourceName)
return saved, err
}
func (v *VariantFileRepository) ListByVariant(variantName string) ([]domain.VariantFile, error) {
return v.list("variant_name = ?", variantName)
}
func (v *VariantFileRepository) ListBySource(sourceName string) ([]domain.VariantFile, error) {
return v.list("source_name = ?", sourceName)
}
func (v *VariantFileRepository) list(condition string, value string) ([]domain.VariantFile, error) {
var found []entity.ImageVariantFile
if err := v.db.Where(condition, value).Order("id asc").Find(&found).Error; err != nil {
return nil, err
}
files := make([]domain.VariantFile, 0, len(found))
for _, model := range found {
files = append(files, mappers.VariantFileToDomain(model))
}
return files, nil
}
func (v *VariantFileRepository) Delete(id uint32) error {
return v.db.Delete(&entity.ImageVariantFile{}, id).Error
}
func (v *VariantFileRepository) Totals() (map[string]domain.VariantFileTotals, error) {
var rows []struct {
VariantName string
Files int
Bytes int64
}
err := v.db.Model(&entity.ImageVariantFile{}).
Select("variant_name, COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes").
Group("variant_name").
Scan(&rows).Error
if err != nil {
return nil, err
}
totals := make(map[string]domain.VariantFileTotals, len(rows))
for _, row := range rows {
totals[row.VariantName] = domain.VariantFileTotals{Files: row.Files, Bytes: row.Bytes}
}
return totals, nil
}
@@ -0,0 +1,65 @@
package persistence
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type VariantRepository struct {
db *gorm.DB
}
var _ domain.VariantRepository = &VariantRepository{}
func NewVariantRepository(db *gorm.DB) *VariantRepository {
return &VariantRepository{db}
}
func (v *VariantRepository) List() ([]domain.Variant, error) {
var found []entity.ImageVariant
if err := v.db.Order("name asc").Find(&found).Error; err != nil {
return nil, err
}
variants := make([]domain.Variant, 0, len(found))
for _, model := range found {
variants = append(variants, mappers.VariantToDomain(model))
}
return variants, nil
}
func (v *VariantRepository) Get(name string) (domain.Variant, error) {
var model entity.ImageVariant
if err := v.db.Where("name = ?", name).First(&model).Error; err != nil {
return domain.Variant{}, mapNotFoundErr(err, domain.ErrVariantNotFound)
}
return mappers.VariantToDomain(model), nil
}
func (v *VariantRepository) Create(variant domain.Variant) (domain.Variant, error) {
model := mappers.VariantToModel(variant)
result := v.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model)
if result.Error != nil {
return domain.Variant{}, result.Error
}
if result.RowsAffected == 0 {
return domain.Variant{}, domain.ErrVariantExists
}
return mappers.VariantToDomain(model), nil
}
func (v *VariantRepository) Update(variant domain.Variant) (domain.Variant, error) {
model := mappers.VariantToModel(variant)
result := v.db.Model(&entity.ImageVariant{}).Where("name = ?", model.Name).Select("*").Omit("name", "created_at").UpdateColumns(&model)
if err := deleteOrNotFound(result, domain.ErrVariantNotFound); err != nil {
return domain.Variant{}, err
}
return v.Get(variant.Name)
}
func (v *VariantRepository) Delete(name string) error {
return deleteOrNotFound(v.db.Where("name = ?", name).Delete(&entity.ImageVariant{}), domain.ErrVariantNotFound)
}
@@ -0,0 +1,58 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
"time"
)
type CreateVariantCommand struct {
Name string
VariantDefinitionCommand
GenerateOnUpload *bool
Enabled *bool
}
type CreateVariantUseCase struct {
variants image.VariantRepository
renderer image.VariantRenderer
now func() time.Time
recorder audit.Recorder
}
func NewCreateVariantUseCase(variants image.VariantRepository, renderer image.VariantRenderer, now func() time.Time, recorder audit.Recorder) *CreateVariantUseCase {
return &CreateVariantUseCase{variants, renderer, now, recorder}
}
func (u *CreateVariantUseCase) EncodableFormats() []image.VariantFormat {
return u.renderer.EncodableFormats()
}
func (u *CreateVariantUseCase) Create(ctx context.Context, command CreateVariantCommand) (VariantSummary, error) {
variant, err := u.build(command)
if err != nil {
return VariantSummary{}, err
}
event := audit.NewEvent(audit.ActionVariantCreate, audit.CategoryMedia)
event.Target = audit.VariantTarget(variant.Name)
created, err := u.variants.Create(variant)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return VariantSummary{}, err
}
event.Params = audit.VariantParams(created.Settings())
u.recorder.Record(ctx, event)
return VariantSummary{Variant: created}, nil
}
func (u *CreateVariantUseCase) build(command CreateVariantCommand) (image.Variant, error) {
settings, err := command.settings(u.renderer.EncodableFormats())
if err != nil {
return image.Variant{}, err
}
generateOnUpload := command.GenerateOnUpload != nil && *command.GenerateOnUpload
enabled := command.Enabled == nil || *command.Enabled
return image.NewVariant(command.Name, settings, generateOnUpload, enabled, u.now())
}
@@ -12,8 +12,8 @@ type DeleteMediaUseCase struct {
recorder audit.Recorder
}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}, recorder}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
}
func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
@@ -0,0 +1,41 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
)
type DeleteVariantUseCase struct {
variants image.VariantRepository
cache variantCache
recorder audit.Recorder
}
func NewDeleteVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *DeleteVariantUseCase {
return &DeleteVariantUseCase{variants, variantCache{files, storage}, recorder}
}
func (u *DeleteVariantUseCase) Delete(ctx context.Context, name string) error {
if _, err := u.variants.Get(name); err != nil {
return err
}
event := audit.NewEvent(audit.ActionVariantDelete, audit.CategoryMedia)
event.Target = audit.VariantTarget(name)
purged, err := u.cache.purgeVariant(name)
if err == nil {
err = u.variants.Delete(name)
}
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return err
}
event.Params = variantPurgeParams(purged)
u.recorder.Record(ctx, event)
return nil
}
func variantPurgeParams(purged VariantPurgeResult) map[string]any {
return map[string]any{audit.ParamPurgedFiles: purged.Files, audit.ParamPurgedBytes: purged.Bytes}
}
@@ -0,0 +1,28 @@
package useCases
import "RenewCMS/internal/domain/image"
type ListVariantsUseCase struct {
variants image.VariantRepository
files image.VariantFileRepository
}
func NewListVariantsUseCase(variants image.VariantRepository, files image.VariantFileRepository) *ListVariantsUseCase {
return &ListVariantsUseCase{variants, files}
}
func (u *ListVariantsUseCase) List() ([]VariantSummary, error) {
variants, err := u.variants.List()
if err != nil {
return nil, err
}
totals, err := u.files.Totals()
if err != nil {
return nil, err
}
summaries := make([]VariantSummary, 0, len(variants))
for _, variant := range variants {
summaries = append(summaries, summarize(variant, totals))
}
return summaries, nil
}
@@ -24,8 +24,8 @@ type MergeMediaUseCase struct {
recorder audit.Recorder
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, recorder audit.Recorder) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}, recorder}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
}
type bodyRewrite struct {
@@ -0,0 +1,36 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
)
type PurgeVariantUseCase struct {
variants image.VariantRepository
cache variantCache
recorder audit.Recorder
}
func NewPurgeVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *PurgeVariantUseCase {
return &PurgeVariantUseCase{variants, variantCache{files, storage}, recorder}
}
func (u *PurgeVariantUseCase) Purge(ctx context.Context, name string) (VariantPurgeResult, error) {
if _, err := u.variants.Get(name); err != nil {
return VariantPurgeResult{}, err
}
event := audit.NewEvent(audit.ActionVariantPurge, audit.CategoryMedia)
event.Target = audit.VariantTarget(name)
purged, err := u.cache.purgeVariant(name)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return VariantPurgeResult{}, err
}
if purged.Files > 0 {
event.Params = variantPurgeParams(purged)
u.recorder.Record(ctx, event)
}
return purged, nil
}
@@ -0,0 +1,293 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"bytes"
"context"
"errors"
"io"
"path"
"slices"
"strings"
"time"
"golang.org/x/sync/singleflight"
)
const (
svgExtension = ".svg"
gifExtension = ".gif"
flightKeySep = "|"
)
type ServeVariantLimits struct {
MaxSourceBytes int64
MaxSourcePixels int64
MaxConcurrentRenders int
}
type ServeVariantRequest struct {
Context context.Context
StoredName string
Variant *string
Parameters []string
IfNoneMatch string
}
type ServedVariant struct {
Original bool
NotModified bool
ETag string
ContentType string
Content io.ReadCloser
Size int64
ModTime time.Time
}
type ServeVariantUseCase struct {
variants image.VariantRepository
files image.VariantFileRepository
images image.Repository
storage image.Storage
renderer image.VariantRenderer
maxSourceBytes int64
maxSourcePixels int64
renderSlots chan struct{}
flights singleflight.Group
}
func NewServeVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, images image.Repository, storage image.Storage, renderer image.VariantRenderer, limits ServeVariantLimits) *ServeVariantUseCase {
return &ServeVariantUseCase{
variants: variants,
files: files,
images: images,
storage: storage,
renderer: renderer,
maxSourceBytes: limits.MaxSourceBytes,
maxSourcePixels: limits.MaxSourcePixels,
renderSlots: make(chan struct{}, max(1, limits.MaxConcurrentRenders)),
}
}
func (u *ServeVariantUseCase) Serve(request ServeVariantRequest) (ServedVariant, error) {
if parameter := unsupportedParameter(request.Parameters); parameter != "" {
return ServedVariant{}, image.NewUnsupportedParameterError(parameter)
}
if request.Variant == nil {
return ServedVariant{Original: true}, nil
}
variant, err := u.enabledVariant(*request.Variant)
if err != nil {
return ServedVariant{}, err
}
return u.serveSource(variant, request)
}
func unsupportedParameter(parameters []string) string {
for _, unsupported := range image.UnsupportedQueryParameters() {
if slices.Contains(parameters, unsupported) {
return unsupported
}
}
return ""
}
func (u *ServeVariantUseCase) enabledVariant(name string) (image.Variant, error) {
if !image.IsValidVariantName(name) {
return image.Variant{}, image.NewUnknownVariantError(name)
}
variant, err := u.variants.Get(name)
if errors.Is(err, image.ErrVariantNotFound) {
return image.Variant{}, image.NewUnknownVariantError(name)
}
if err != nil {
return image.Variant{}, err
}
if !variant.Enabled || variant.Name != name {
return image.Variant{}, image.NewUnknownVariantError(name)
}
return variant, nil
}
func (u *ServeVariantUseCase) serveSource(variant image.Variant, request ServeVariantRequest) (ServedVariant, error) {
known, err := u.images.ExistsByStoredName(request.StoredName)
if err != nil {
return ServedVariant{}, err
}
if !known {
return ServedVariant{}, image.ErrImageNotFound
}
source, err := u.storage.Open(request.StoredName)
if err != nil {
return ServedVariant{}, err
}
defer source.Content.Close()
if servedAsIs(request.StoredName, source.Content) {
return ServedVariant{Original: true}, nil
}
if source.Size > u.maxSourceBytes {
return ServedVariant{}, image.ErrSourceTooLarge
}
settings := variant.Settings()
etag := image.VariantETag(settings, request.StoredName)
if image.ETagMatches(request.IfNoneMatch, etag) {
return ServedVariant{NotModified: true, ETag: etag}, nil
}
fingerprint := settings.Fingerprint()
cached, previous, hit, err := u.lookupCached(variant.Name, request.StoredName, fingerprint, etag)
if err != nil || hit {
return cached, err
}
file, err := u.generated(requestContext(request), variant, request.StoredName, fingerprint, previous)
if err != nil {
return ServedVariant{}, err
}
return u.serveFile(file, etag)
}
func requestContext(request ServeVariantRequest) context.Context {
if request.Context == nil {
return context.Background()
}
return request.Context
}
func servedAsIs(storedName string, content io.Reader) bool {
switch strings.ToLower(path.Ext(storedName)) {
case svgExtension:
return true
case gifExtension:
animated, err := image.IsAnimatedGIF(content)
return err == nil && animated
default:
return false
}
}
func (u *ServeVariantUseCase) lookupCached(variantName string, storedName string, fingerprint string, etag string) (ServedVariant, image.VariantFile, bool, error) {
existing, found, err := u.files.Find(variantName, storedName)
if err != nil {
return ServedVariant{}, image.VariantFile{}, false, err
}
if !found || existing.Fingerprint != fingerprint {
return ServedVariant{}, existing, false, nil
}
served, err := u.serveFile(existing, etag)
if errors.Is(err, image.ErrBlobNotFound) {
return ServedVariant{}, existing, false, nil
}
return served, existing, err == nil, err
}
func (u *ServeVariantUseCase) generated(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
key := strings.Join([]string{variant.Name, fingerprint, storedName}, flightKeySep)
for {
file, err, _ := u.flights.Do(key, func() (any, error) {
return u.generate(ctx, variant, storedName, fingerprint, previous)
})
if err == nil {
return file.(image.VariantFile), nil
}
if ctx.Err() != nil || !errors.Is(err, context.Canceled) {
return image.VariantFile{}, err
}
}
}
func (u *ServeVariantUseCase) generate(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
current, found, err := u.files.Find(variant.Name, storedName)
if err != nil {
return image.VariantFile{}, err
}
if found && current.Fingerprint == fingerprint && current.CachedName != previous.CachedName {
return current, nil
}
rendered, err := u.render(ctx, variant.Settings(), storedName)
if err != nil {
return image.VariantFile{}, err
}
cachedName, err := u.storage.Put(rendered.ContentType, bytes.NewReader(rendered.Content))
if err != nil {
return image.VariantFile{}, err
}
saved, err := u.files.Save(image.VariantFile{
VariantName: variant.Name,
SourceName: storedName,
CachedName: cachedName,
ContentType: rendered.ContentType,
Size: int64(len(rendered.Content)),
Fingerprint: fingerprint,
})
if err != nil {
_ = u.storage.Delete(cachedName)
return image.VariantFile{}, err
}
u.discardReplaced(previous, cachedName)
return saved, nil
}
func (u *ServeVariantUseCase) discardReplaced(previous image.VariantFile, replacementName string) {
if previous.CachedName != "" && previous.CachedName != replacementName {
_ = u.storage.Delete(previous.CachedName)
}
}
func (u *ServeVariantUseCase) render(ctx context.Context, settings image.VariantSettings, storedName string) (image.RenderedVariant, error) {
if err := u.acquireRenderSlot(ctx); err != nil {
return image.RenderedVariant{}, err
}
defer u.releaseRenderSlot()
source, err := u.storage.Open(storedName)
if err != nil {
return image.RenderedVariant{}, err
}
defer source.Content.Close()
data, err := io.ReadAll(io.LimitReader(source.Content, u.maxSourceBytes+1))
if err != nil {
return image.RenderedVariant{}, err
}
if int64(len(data)) > u.maxSourceBytes {
return image.RenderedVariant{}, image.ErrSourceTooLarge
}
if err := u.checkDeclaredPixels(data); err != nil {
return image.RenderedVariant{}, err
}
return u.renderer.Render(bytes.NewReader(data), settings)
}
func (u *ServeVariantUseCase) checkDeclaredPixels(data []byte) error {
width, height, err := u.renderer.DeclaredSize(bytes.NewReader(data))
if err != nil {
return err
}
if int64(width)*int64(height) > u.maxSourcePixels {
return image.ErrSourceTooLarge
}
return nil
}
func (u *ServeVariantUseCase) acquireRenderSlot(ctx context.Context) error {
select {
case u.renderSlots <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (u *ServeVariantUseCase) releaseRenderSlot() {
<-u.renderSlots
}
func (u *ServeVariantUseCase) serveFile(file image.VariantFile, etag string) (ServedVariant, error) {
blob, err := u.storage.Open(file.CachedName)
if err != nil {
return ServedVariant{}, err
}
return ServedVariant{ETag: etag, ContentType: file.ContentType, Content: blob.Content, Size: blob.Size, ModTime: blob.ModTime}, nil
}
@@ -0,0 +1,40 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
"time"
)
type SetVariantEnabledUseCase struct {
variants image.VariantRepository
files image.VariantFileRepository
now func() time.Time
recorder audit.Recorder
}
func NewSetVariantEnabledUseCase(variants image.VariantRepository, files image.VariantFileRepository, now func() time.Time, recorder audit.Recorder) *SetVariantEnabledUseCase {
return &SetVariantEnabledUseCase{variants, files, now, recorder}
}
func (u *SetVariantEnabledUseCase) SetEnabled(ctx context.Context, name string, enabled bool) (VariantSummary, error) {
current, err := u.variants.Get(name)
if err != nil {
return VariantSummary{}, err
}
if current.Enabled == enabled {
return summaryOf(current, u.files)
}
next := current
next.Enabled = enabled
next.UpdatedAt = u.now()
updated, err := u.variants.Update(next)
if err != nil {
recordFailure(ctx, u.recorder, enabledEvent(next), err)
return VariantSummary{}, err
}
u.recorder.Record(ctx, enabledEvent(updated))
return summaryOf(updated, u.files)
}
@@ -0,0 +1,117 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"context"
"time"
)
type UpdateVariantCommand struct {
Name string
VariantDefinitionCommand
GenerateOnUpload *bool
Enabled *bool
}
type UpdateVariantResult struct {
VariantSummary
PurgedFiles int
}
type UpdateVariantUseCase struct {
variants image.VariantRepository
files image.VariantFileRepository
cache variantCache
renderer image.VariantRenderer
now func() time.Time
recorder audit.Recorder
}
func NewUpdateVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, renderer image.VariantRenderer, now func() time.Time, recorder audit.Recorder) *UpdateVariantUseCase {
return &UpdateVariantUseCase{variants, files, variantCache{files, storage}, renderer, now, recorder}
}
func (u *UpdateVariantUseCase) EncodableFormats() []image.VariantFormat {
return u.renderer.EncodableFormats()
}
func (u *UpdateVariantUseCase) Update(ctx context.Context, command UpdateVariantCommand) (UpdateVariantResult, error) {
current, err := u.variants.Get(command.Name)
if err != nil {
return UpdateVariantResult{}, err
}
settings, err := command.settings(u.renderer.EncodableFormats())
if err != nil {
return UpdateVariantResult{}, err
}
next := withSwitches(current.WithSettings(settings), command)
renderingChanged := !settings.IsSameAs(current.Settings())
if !renderingChanged && next.Enabled == current.Enabled && next.GenerateOnUpload == current.GenerateOnUpload {
return u.result(current, 0)
}
event := audit.NewEvent(audit.ActionVariantUpdate, audit.CategoryMedia)
event.Target = audit.VariantTarget(current.Name)
purged, err := u.purgeIfRendering(renderingChanged, current.Name)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return UpdateVariantResult{}, err
}
next.UpdatedAt = u.now()
updated, err := u.variants.Update(next)
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return UpdateVariantResult{}, err
}
u.record(ctx, event, current, updated, renderingChanged, purged)
return u.result(updated, purged)
}
func withSwitches(variant image.Variant, command UpdateVariantCommand) image.Variant {
if command.GenerateOnUpload != nil {
variant.GenerateOnUpload = *command.GenerateOnUpload
}
if command.Enabled != nil {
variant.Enabled = *command.Enabled
}
return variant
}
func (u *UpdateVariantUseCase) purgeIfRendering(renderingChanged bool, name string) (int, error) {
if !renderingChanged {
return 0, nil
}
result, err := u.cache.purgeVariant(name)
return result.Files, err
}
func (u *UpdateVariantUseCase) record(ctx context.Context, event audit.Event, before image.Variant, after image.Variant, renderingChanged bool, purged int) {
if renderingChanged {
event.Changes = audit.VariantChanges(before.Settings(), after.Settings())
event.Params = map[string]any{audit.ParamPurgedFiles: purged}
u.recorder.Record(ctx, event)
}
if before.Enabled != after.Enabled {
u.recorder.Record(ctx, enabledEvent(after))
}
}
func (u *UpdateVariantUseCase) result(variant image.Variant, purged int) (UpdateVariantResult, error) {
summary, err := summaryOf(variant, u.files)
if err != nil {
return UpdateVariantResult{}, err
}
return UpdateVariantResult{summary, purged}, nil
}
func enabledEvent(variant image.Variant) audit.Event {
action := audit.ActionVariantDisable
if variant.Enabled {
action = audit.ActionVariantEnable
}
event := audit.NewEvent(action, audit.CategoryMedia)
event.Target = audit.VariantTarget(variant.Name)
return event
}
@@ -29,6 +29,7 @@ var failureReasons = []failureReason{
{image.ErrInvalidMergeRequest, "The merge request is not valid."},
{image.ErrMergeCopyMissing, "One of the copies no longer exists."},
{image.ErrMergeHashMismatch, "The images are not identical copies."},
{image.ErrVariantExists, "A variant with that name already exists."},
{purge.ErrPurgeConflict, "The library changed since the preview. Review it again."},
{purge.ErrPurgeBusy, "A purge is already running."},
{user.ErrRegistrationClosed, "Registration is closed on this site."},
@@ -65,6 +66,7 @@ var downloadFailureReasons = map[string]string{
var notFoundCauses = []error{
article.ErrArticleNotFound,
image.ErrImageNotFound,
image.ErrVariantNotFound,
invite.ErrInviteNotFound,
user.ErrUserNotFound,
}
@@ -5,10 +5,19 @@ import "RenewCMS/internal/domain/image"
type mediaRemover struct {
imageRepository image.Repository
storage image.Storage
generated variantCache
}
func newMediaRemover(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository) mediaRemover {
return mediaRemover{imageRepository, storage, variantCache{variantFiles, storage}}
}
func (r mediaRemover) remove(img image.Image) error {
if err := r.storage.Delete(image.StoredName(img.Path)); err != nil {
storedName := image.StoredName(img.Path)
if err := r.generated.purgeSource(storedName); err != nil {
return err
}
if err := r.storage.Delete(storedName); err != nil {
return err
}
return r.imageRepository.Delete(img.ID)
@@ -0,0 +1,51 @@
package useCases
import "RenewCMS/internal/domain/image"
type VariantPurgeResult struct {
Files int
Bytes int64
}
type variantCache struct {
files image.VariantFileRepository
storage image.Storage
}
func (c variantCache) purgeVariant(name string) (VariantPurgeResult, error) {
rows, err := c.files.ListByVariant(name)
if err != nil {
return VariantPurgeResult{}, err
}
return c.purge(rows)
}
func (c variantCache) purgeSource(storedName string) error {
rows, err := c.files.ListBySource(storedName)
if err != nil {
return err
}
_, err = c.purge(rows)
return err
}
func (c variantCache) purge(rows []image.VariantFile) (VariantPurgeResult, error) {
var result VariantPurgeResult
var failure error
for _, row := range rows {
if err := c.purgeRow(row); err != nil {
failure = err
continue
}
result.Files++
result.Bytes += row.Size
}
return result, failure
}
func (c variantCache) purgeRow(row image.VariantFile) error {
if err := c.storage.Delete(row.CachedName); err != nil {
return err
}
return c.files.Delete(row.ID)
}
@@ -0,0 +1,31 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"slices"
)
type VariantDefinitionCommand struct {
Width *int
Height *int
Fit string
Format string
Quality *int
}
func (c VariantDefinitionCommand) settings(encodable []image.VariantFormat) (image.VariantSettings, error) {
settings, err := image.VariantSettings{
Width: c.Width,
Height: c.Height,
Fit: image.VariantFit(c.Fit),
Format: image.VariantFormat(c.Format),
Quality: c.Quality,
}.Normalized()
if err != nil {
return image.VariantSettings{}, err
}
if !slices.Contains(encodable, settings.Format) {
return image.VariantSettings{}, image.ErrVariantFormatUnavailable
}
return settings, nil
}
@@ -0,0 +1,22 @@
package useCases
import "RenewCMS/internal/domain/image"
type VariantSummary struct {
Variant image.Variant
GeneratedFiles int
CacheSize int64
}
func summarize(variant image.Variant, totals map[string]image.VariantFileTotals) VariantSummary {
own := totals[variant.Name]
return VariantSummary{Variant: variant, GeneratedFiles: own.Files, CacheSize: own.Bytes}
}
func summaryOf(variant image.Variant, files image.VariantFileRepository) (VariantSummary, error) {
totals, err := files.Totals()
if err != nil {
return VariantSummary{}, err
}
return summarize(variant, totals), nil
}