package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/transaction" "context" "errors" "log" "slices" ) type MergeMediaResult struct { ArticlesUpdated int Merged int SitesUpdated int AuthorsUpdated int EntriesUpdated int UpdatedArticleIDs []uint32 } type MergeMediaUseCase struct { imageRepository image.Repository articleRepository article.Repository remover mediaRemover recorder audit.Recorder entries *entryMediaScanner transactor transaction.Transactor logger *log.Logger } func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, contentTypes contenttype.Repository, entries entry.Repository, transactor transaction.Transactor, logger *log.Logger) *MergeMediaUseCase { return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, entries: newEntryMediaScanner(contentTypes, entries), transactor: transactor, logger: logger} } func copyIdsOf(copies []image.Image) []uint32 { copyIds := make([]uint32, 0, len(copies)) for _, duplicate := range copies { copyIds = append(copyIds, duplicate.ID) } return copyIds } func (u *MergeMediaUseCase) Merge(ctx context.Context, keepId uint32, mergeIds []uint32) (MergeMediaResult, error) { result, err := u.merge(ctx, keepId, mergeIds, nil) event := audit.NewEvent(audit.ActionMediaMerge, audit.CategoryMedia) if kept, found := u.keptImage(keepId); found { event.Target = imageTarget(kept) } if err != nil { recordFailure(ctx, u.recorder, event, err) return MergeMediaResult{}, err } event.Params = map[string]any{audit.ParamMerged: result.Merged, audit.ParamArticlesUpdated: result.ArticlesUpdated} u.recorder.Record(ctx, event) return result, nil } func (u *MergeMediaUseCase) keptImage(keepId uint32) (image.Image, bool) { if keepId == 0 { return image.Image{}, false } kept, err := u.imageRepository.Get(keepId) return kept, err == nil } func (u *MergeMediaUseCase) merge(ctx context.Context, keepId uint32, mergeIds []uint32, index *entryMediaIndex) (MergeMediaResult, error) { if err := validateMergeRequest(keepId, mergeIds); err != nil { return MergeMediaResult{}, err } kept, err := u.imageRepository.Get(keepId) if err != nil { return MergeMediaResult{}, err } copies, err := u.loadCopies(kept, mergeIds) if err != nil { return MergeMediaResult{}, err } planned, err := u.planRewrites(copies) if err != nil { return MergeMediaResult{}, err } if index == nil { if index, err = u.entries.index(); err != nil { return MergeMediaResult{}, err } } result, err := u.applyMerge(ctx, kept, copies, planned, index.referencing(copyIdsOf(copies))) if err != nil { return MergeMediaResult{}, err } u.discardCopies(copies) return result, nil } func (u *MergeMediaUseCase) applyMerge(ctx context.Context, kept image.Image, copies []image.Image, planned []uint32, candidates []mediaEntry) (MergeMediaResult, error) { var result MergeMediaResult copyIds := copyIdsOf(copies) err := u.transactor.Run(ctx, func(tx transaction.Repositories) error { rewritten, err := rewriteBodies(tx.Articles, planned, copies, kept) if err != nil { return err } repointedArticles, err := tx.Articles.RepointImage(copyIds, kept.ID) if err != nil { return err } if result.SitesUpdated, err = tx.Sites.RepointImage(copyIds, kept.ID); err != nil { return err } if result.AuthorsUpdated, err = tx.Authors.RepointAvatar(copyIds, kept.ID); err != nil { return err } if result.EntriesUpdated, err = repointEntries(tx.Entries, candidates, copyIds, kept.ID); err != nil { return err } for _, duplicate := range copies { if err := tx.Images.Delete(duplicate.ID); err != nil { return err } } result.UpdatedArticleIDs = updatedArticleIds(rewritten, repointedArticles) result.ArticlesUpdated = len(result.UpdatedArticleIDs) result.Merged = len(copies) return nil }) return result, err } func (u *MergeMediaUseCase) discardCopies(copies []image.Image) { for _, duplicate := range copies { if err := u.remover.discardFiles(duplicate); err != nil { u.logger.Printf("media merge: could not remove the files of %s: %v", image.StoredName(duplicate.Path), err) } } } func rewriteBodies(articles article.Repository, planned []uint32, copies []image.Image, kept image.Image) ([]uint32, error) { keptName := image.StoredName(kept.Path) var rewritten []uint32 for _, id := range planned { current, err := articles.Get(id) if errors.Is(err, article.ErrArticleNotFound) { continue } if err != nil { return nil, err } body := current.Body for _, duplicate := range copies { body = image.ReplaceStoredName(body, image.StoredName(duplicate.Path), keptName) } if body == current.Body { continue } if _, err := articles.UpdateBody(id, body); err != nil { return nil, err } rewritten = append(rewritten, id) } return rewritten, nil } func updatedArticleIds(rewritten []uint32, repointed []uint32) []uint32 { ids := append(slices.Clone(repointed), rewritten...) slices.Sort(ids) return slices.Compact(ids) } func validateMergeRequest(keepId uint32, mergeIds []uint32) error { if keepId == 0 || len(mergeIds) == 0 { return image.ErrInvalidMergeRequest } seen := map[uint32]bool{keepId: true} for _, id := range mergeIds { if id == 0 || seen[id] { return image.ErrInvalidMergeRequest } seen[id] = true } return nil } func (u *MergeMediaUseCase) loadCopies(kept image.Image, mergeIds []uint32) ([]image.Image, error) { copies := make([]image.Image, 0, len(mergeIds)) for _, id := range mergeIds { duplicate, err := u.imageRepository.Get(id) if errors.Is(err, image.ErrImageNotFound) { return nil, image.ErrMergeCopyMissing } if err != nil { return nil, err } if duplicate.SHA256 == "" || duplicate.SHA256 != kept.SHA256 { return nil, image.ErrMergeHashMismatch } copies = append(copies, duplicate) } return copies, nil } func (u *MergeMediaUseCase) planRewrites(copies []image.Image) ([]uint32, error) { seen := map[uint32]bool{} for _, duplicate := range copies { copyName := image.StoredName(duplicate.Path) articles, err := u.articleRepository.FindByBodyContaining(copyName) if err != nil { return nil, err } for _, a := range articles { if image.ContainsStoredName(a.Body, copyName) { seen[a.ID] = true } } } planned := make([]uint32, 0, len(seen)) for id := range seen { planned = append(planned, id) } slices.Sort(planned) return planned, nil }