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

@@ -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)
}