feat: image variants engine
This commit is contained in:
1 parent
f0845de643
commit
f1d56ef50e
80 files changed
+7312
-84
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user