feat(API): media library

This commit is contained in:
fsylvain committed 2026-09-30 18:35:28 +02:00
1 parent 95ae043d3e
commit 0d99544579
65 files changed
+3992 -1530

No files matched your search

+6 -8
View File
@@ -1,7 +1,6 @@
package article
import (
domain "RenewCMS/internal/domain/image"
"errors"
"time"
"unicode/utf8"
@@ -27,11 +26,10 @@ func ValidateTitle(title string) error {
}
type Article struct {
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Images []*domain.Image `json:"images"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint32 `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
IsOnline bool `json:"is_online"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -13,5 +13,5 @@ type Repository interface {
UpdateBody(id uint32, body string) (Article, error)
UpdateIsOnline(id uint32, isOnline bool) (Article, error)
Delete(id uint32) error
AddImage(postId uint32, imageId uint32) error
FindByBodyContaining(filename string) ([]Article, error)
}
+166 -9
View File
@@ -3,16 +3,33 @@ package image
import (
"errors"
"io"
"path"
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
)
var ErrImageNotFound = errors.New("image not found")
var ErrImageTypeNotAllowed = errors.New("the file must be a PNG, JPEG, WEBP, or SVG image")
var ErrImageTooLarge = errors.New("the file exceeds the maximum upload size")
var ErrImageUnreadable = errors.New("the image could not be read")
var ErrInvalidFilename = errors.New("invalid filename")
var ErrInvalidQuery = errors.New("invalid media query")
var ErrInvalidMergeRequest = errors.New("invalid merge request")
var ErrMergeCopyMissing = errors.New("a merged copy no longer exists")
var ErrMergeHashMismatch = errors.New("a merged copy does not share the kept image's content")
const MaxImageSize int64 = 10485760
const (
MinFilenameRunes = 1
MaxFilenameRunes = 100
DefaultListLimit = 24
MaxListLimit = 100
)
var allowedContentTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
@@ -20,6 +37,16 @@ var allowedContentTypes = map[string]bool{
"image/svg+xml": true,
}
var canonicalExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
var filenameStemPattern = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
var sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
func IsAllowedContentType(contentType string) bool {
return allowedContentTypes[contentType]
}
@@ -33,17 +60,147 @@ func AllowedContentTypes() []string {
return types
}
func NormalizeUploadFilename(original string, contentType string) string {
name := original[strings.LastIndexAny(original, `/\`)+1:]
name = strings.TrimSpace(name)
if name == "" || name == "." {
name = "image"
}
if path.Ext(name) == "" {
name += canonicalExtensions[contentType]
}
return name
}
func ExtensionOf(filename string) string {
return path.Ext(filename)
}
func StemOf(filename string) string {
return strings.TrimSuffix(filename, path.Ext(filename))
}
func ValidateFilenameStem(stem string) (string, error) {
trimmed := strings.TrimSpace(stem)
runeCount := utf8.RuneCountInString(trimmed)
if runeCount < MinFilenameRunes || runeCount > MaxFilenameRunes {
return "", ErrInvalidFilename
}
if !filenameStemPattern.MatchString(trimmed) {
return "", ErrInvalidFilename
}
return trimmed, nil
}
func StoredName(imagePath string) string {
return path.Base(imagePath)
}
type Sort string
const (
SortNewest Sort = "newest"
SortOldest Sort = "oldest"
SortName Sort = "name"
SortLargest Sort = "largest"
)
type Filter string
const (
FilterAll Filter = "all"
FilterUnused Filter = "unused"
FilterDuplicates Filter = "duplicates"
)
type ListQuery struct {
Search string
Hash string
Filter Filter
Sort Sort
Limit int
Cursor string
}
func NewListQuery() ListQuery {
return ListQuery{Filter: FilterAll, Sort: SortNewest, Limit: DefaultListLimit}
}
func (q ListQuery) Validate() error {
switch q.Filter {
case FilterAll, FilterUnused, FilterDuplicates:
default:
return ErrInvalidQuery
}
switch q.Sort {
case SortNewest, SortOldest, SortName, SortLargest:
default:
return ErrInvalidQuery
}
if q.Limit < 1 || q.Limit > MaxListLimit {
return ErrInvalidQuery
}
if q.Hash != "" && !sha256Pattern.MatchString(q.Hash) {
return ErrInvalidQuery
}
return nil
}
func SortImages(images []Image, order Sort) {
sort.SliceStable(images, func(i, j int) bool {
a, b := images[i], images[j]
switch order {
case SortOldest:
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.Before(b.CreatedAt)
}
return a.ID < b.ID
case SortName:
nameA, nameB := strings.ToLower(StemOf(a.Filename)), strings.ToLower(StemOf(b.Filename))
if nameA != nameB {
return nameA < nameB
}
return a.ID < b.ID
case SortLargest:
if a.Size != b.Size {
return a.Size > b.Size
}
return a.ID < b.ID
default:
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
return a.ID > b.ID
}
})
}
type Counts struct {
All int
DuplicateGroups int
}
type ImageInput struct {
Content io.Reader
Filename string
Size int64
ContentType string
Content io.Reader
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string
UploadedByID uint32
}
type Image struct {
ID uint32 `json:"id"`
Path string `json:"path"`
ArticleID uint32 `json:"article_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint32 `json:"id"`
Path string `json:"path"`
Filename string `json:"filename"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
Width int `json:"width"`
Height int `json:"height"`
SHA256 string `json:"sha256"`
UploadedByID uint32 `json:"uploaded_by_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -1,6 +1,17 @@
package image
type SearchQuery struct {
Search string
Hash string
}
type Repository interface {
Create(input ImageInput) (Image, error)
Get(id uint32) (Image, error)
Delete(id uint32) error
List(query SearchQuery) ([]Image, error)
FindByHash(hash string) ([]Image, error)
Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error)
Counts() (Counts, error)
}
@@ -2,22 +2,14 @@ package mappers
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
)
func ArticleToDomain(m models.Article) article.Article {
domainImages := make([]*image.Image, len(m.Images))
for i, img := range m.Images {
domainImg := ImageToDomain(*img)
domainImages[i] = &domainImg
}
return article.Article{
ID: m.ID,
Title: m.Title,
Body: m.Body,
Images: domainImages,
IsOnline: m.IsOnline,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
@@ -25,16 +17,9 @@ func ArticleToDomain(m models.Article) article.Article {
}
func ArticleToModel(a article.Article) models.Article {
modelImages := make([]*models.Image, len(a.Images))
for i, img := range a.Images {
modelImage := ImageToModel(*img)
modelImages[i] = &modelImage
}
return models.Article{
Title: a.Title,
Body: a.Body,
Images: modelImages,
IsOnline: a.IsOnline,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
@@ -7,19 +7,32 @@ import (
func ImageToDomain(imageModel models.Image) image.Image {
return image.Image{
ID: imageModel.ID,
Path: imageModel.Path,
ArticleID: imageModel.ArticleID,
CreatedAt: imageModel.CreatedAt,
UpdatedAt: imageModel.UpdatedAt,
ID: imageModel.ID,
Path: imageModel.Path,
Filename: imageModel.Filename,
Size: imageModel.Size,
ContentType: imageModel.ContentType,
Width: imageModel.Width,
Height: imageModel.Height,
SHA256: imageModel.SHA256,
UploadedByID: imageModel.UploadedByID,
CreatedAt: imageModel.CreatedAt,
UpdatedAt: imageModel.UpdatedAt,
}
}
func ImageToModel(imageDomain image.Image) models.Image {
return models.Image{
Path: imageDomain.Path,
ArticleID: imageDomain.ArticleID,
CreatedAt: imageDomain.CreatedAt,
UpdatedAt: imageDomain.UpdatedAt,
ID: imageDomain.ID,
Path: imageDomain.Path,
Filename: imageDomain.Filename,
Size: imageDomain.Size,
ContentType: imageDomain.ContentType,
Width: imageDomain.Width,
Height: imageDomain.Height,
SHA256: imageDomain.SHA256,
UploadedByID: imageDomain.UploadedByID,
CreatedAt: imageDomain.CreatedAt,
UpdatedAt: imageDomain.UpdatedAt,
}
}
@@ -11,7 +11,6 @@ type Article struct {
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string
Body string
Images []*Image `gorm:"many2many:article_images;"`
IsOnline bool `gorm:"not_null;default:false"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -8,9 +8,15 @@ import (
type Image struct {
gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Path string
ArticleID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Path string
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string `gorm:"column:sha256;index"`
UploadedByID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -2,7 +2,6 @@ package persistence
import (
domain "RenewCMS/internal/domain/article"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
@@ -18,7 +17,7 @@ func NewArticleRepository(db *gorm.DB) *ArticleRepository {
}
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{}).Preload("Images")
query := a.db.Model(&entity.Article{})
if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline)
@@ -37,7 +36,7 @@ func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Arti
func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
var article entity.Article
err := a.db.Model(&entity.Article{}).Preload("Images").First(&article, id).Error
err := a.db.Model(&entity.Article{}).First(&article, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
@@ -125,25 +124,18 @@ func (a *ArticleRepository) Delete(id uint32) error {
return deleteOrNotFound(result, domain.ErrArticleNotFound)
}
func (a *ArticleRepository) AddImage(articleId uint32, imageId uint32) error {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, articleId).Error
func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).Where("instr(body, ?) > 0", filename).Order("id asc").Find(&entityArticles).Error
if err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
return nil, err
}
var localImage entity.Image
err = a.db.Model(&entity.Image{}).First(&localImage, imageId).Error
if err != nil {
return mapNotFoundErr(err, imageDomain.ErrImageNotFound)
domainArticles := make([]domain.Article, 0, len(entityArticles))
for _, entityArticle := range entityArticles {
domainArticles = append(domainArticles, mappers.ArticleToDomain(entityArticle))
}
err = a.db.Model(&localArticle).Association("Images").Append(&localImage)
if err != nil {
return err
}
return nil
return domainArticles, nil
}
var _ domain.Repository = &ArticleRepository{}
@@ -0,0 +1,77 @@
package persistence
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"bytes"
"crypto/sha256"
"encoding/hex"
stdimage "image"
"os"
"path/filepath"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
"gorm.io/gorm"
)
var extensionContentTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
func BackfillImageMetadata(db *gorm.DB, uploadDir string) error {
var pending []entity.Image
err := db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&pending).Error
if err != nil {
return err
}
for _, row := range pending {
metadata, ok := readStoredImage(uploadDir, row.Path)
if !ok {
continue
}
err := db.Model(&entity.Image{}).Where("id = ?", row.ID).UpdateColumns(metadata).Error
if err != nil {
return err
}
}
return nil
}
func readStoredImage(uploadDir string, storedPath string) (map[string]any, bool) {
fileName := filepath.Base(storedPath)
contentType, known := extensionContentTypes[filepath.Ext(fileName)]
if !known {
return nil, false
}
data, err := os.ReadFile(filepath.Join(uploadDir, fileName))
if err != nil {
return nil, false
}
width, height := 0, 0
if contentType != "image/svg+xml" {
config, _, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false
}
width, height = config.Width, config.Height
}
checksum := sha256.Sum256(data)
return map[string]any{
"filename": fileName,
"size": int64(len(data)),
"content_type": contentType,
"width": width,
"height": height,
"sha256": hex.EncodeToString(checksum[:]),
}, true
}
@@ -4,14 +4,18 @@ import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"io"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
const uploadedImagesURLPrefix = "/static/uploadedImages/"
type ImageRepository struct {
db *gorm.DB
uploadDir string
@@ -29,49 +33,142 @@ var contentTypeExtensions = map[string]string{
}
func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
uploadDir := i.uploadDir
fileBytes, err := io.ReadAll(input.Content)
if err != nil {
return domain.Image{}, err
}
err = os.MkdirAll(uploadDir, 0755)
err = os.MkdirAll(i.uploadDir, 0755)
if err != nil {
return domain.Image{}, err
}
extension := contentTypeExtensions[input.ContentType]
newName := uuid.NewString() + extension
finalPath := filepath.Join(uploadDir, newName)
newName := uuid.NewString() + contentTypeExtensions[input.ContentType]
finalPath := filepath.Join(i.uploadDir, newName)
err = os.WriteFile(finalPath, fileBytes, 0666)
if err != nil {
return domain.Image{}, err
}
newImage := i.db.Create(&domain.Image{Path: "/static/uploadedImages/" + newName})
var createdImage entity.Image
newImage.Scan(&createdImage)
model := mappers.ImageToModel(domain.Image{
Path: uploadedImagesURLPrefix + newName,
Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
})
if err := i.db.Create(&model).Error; err != nil {
_ = os.Remove(finalPath)
return domain.Image{}, err
}
return mappers.ImageToDomain(createdImage), nil
return mappers.ImageToDomain(model), nil
}
func (i ImageRepository) Get(id uint32) (domain.Image, error) {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return domain.Image{}, mapNotFoundErr(err, domain.ErrImageNotFound)
}
return mappers.ImageToDomain(model), nil
}
func (i ImageRepository) Delete(id uint32) error {
uploadDir := i.uploadDir
var entityImage entity.Image
err := i.db.Model(&entity.Image{}).First(&entityImage, id).Error
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return mapNotFoundErr(err, domain.ErrImageNotFound)
}
fileName := filepath.Base(entityImage.Path)
err = os.Remove(filepath.Join(uploadDir, fileName))
if err != nil {
err = os.Remove(filepath.Join(i.uploadDir, filepath.Base(model.Path)))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return i.db.Delete(&entity.Image{}, id).Error
}
func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) {
statement := i.db.Model(&entity.Image{})
if query.Search != "" {
statement = statement.Where("instr(lower(filename), ?) > 0", strings.ToLower(query.Search))
}
if query.Hash != "" {
statement = statement.Where("sha256 = ?", query.Hash)
}
var models []entity.Image
if err := statement.Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) FindByHash(hash string) ([]domain.Image, error) {
if hash == "" {
return []domain.Image{}, nil
}
var models []entity.Image
if err := i.db.Where("sha256 = ?", hash).Order("id asc").Find(&models).Error; err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) Rename(id uint32, filename string) (domain.Image, error) {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return domain.Image{}, mapNotFoundErr(err, domain.ErrImageNotFound)
}
model.Filename = filename
if err := i.db.Save(&model).Error; err != nil {
return domain.Image{}, err
}
return mappers.ImageToDomain(model), nil
}
func (i ImageRepository) ListDuplicates() ([]domain.Image, error) {
var models []entity.Image
err := i.db.Where("sha256 IN (?)", i.duplicatedHashes()).Order("sha256 asc, id asc").Find(&models).Error
if err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) Counts() (domain.Counts, error) {
var all int64
if err := i.db.Model(&entity.Image{}).Count(&all).Error; err != nil {
return domain.Counts{}, err
}
var groups int64
if err := i.db.Table("(?) as duplicated", i.duplicatedHashes()).Count(&groups).Error; err != nil {
return domain.Counts{}, err
}
return domain.Counts{All: int(all), DuplicateGroups: int(groups)}, nil
}
func (i ImageRepository) duplicatedHashes() *gorm.DB {
return i.db.Model(&entity.Image{}).
Select("sha256").
Where("sha256 <> ''").
Group("sha256").
Having("COUNT(*) > 1")
}
func imagesToDomain(models []entity.Image) []domain.Image {
images := make([]domain.Image, 0, len(models))
for _, model := range models {
images = append(images, mappers.ImageToDomain(model))
}
return images
}
var _ domain.Repository = &ImageRepository{}
@@ -2,8 +2,26 @@ package useCases
import (
"RenewCMS/internal/domain/image"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
stdimage "image"
"io"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
)
const svgRootElement = "svg"
var rasterFormats = map[string]string{
"image/png": "png",
"image/jpeg": "jpeg",
"image/webp": "webp",
}
type CreateImageUseCase struct {
imageRepository image.Repository
}
@@ -16,8 +34,68 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e
if !image.IsAllowedContentType(input.ContentType) {
return image.Image{}, image.ErrImageTypeNotAllowed
}
if input.Size > image.MaxImageSize {
return image.Image{}, image.ErrImageTooLarge
data, err := readWithinLimit(input.Content)
if err != nil {
return image.Image{}, err
}
width, height, err := measure(data, input.ContentType)
if err != nil {
return image.Image{}, err
}
checksum := sha256.Sum256(data)
return g.imageRepository.Create(image.ImageInput{
Content: bytes.NewReader(data),
Filename: image.NormalizeUploadFilename(input.Filename, input.ContentType),
Size: int64(len(data)),
ContentType: input.ContentType,
Width: width,
Height: height,
SHA256: hex.EncodeToString(checksum[:]),
UploadedByID: input.UploadedByID,
})
}
func readWithinLimit(content io.Reader) ([]byte, error) {
if content == nil {
return nil, image.ErrImageUnreadable
}
data, err := io.ReadAll(io.LimitReader(content, image.MaxImageSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > image.MaxImageSize {
return nil, image.ErrImageTooLarge
}
return data, nil
}
func measure(data []byte, contentType string) (int, int, error) {
if contentType == "image/svg+xml" {
if !hasSvgRoot(data) {
return 0, 0, image.ErrImageUnreadable
}
return 0, 0, nil
}
config, format, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil || format != rasterFormats[contentType] {
return 0, 0, image.ErrImageUnreadable
}
return config.Width, config.Height, nil
}
func hasSvgRoot(data []byte) bool {
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, err := decoder.Token()
if err != nil {
return false
}
if start, ok := token.(xml.StartElement); ok {
return start.Name.Local == svgRootElement
}
}
return g.imageRepository.Create(input)
}
@@ -1,30 +1,15 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
)
import "RenewCMS/internal/domain/article"
type DeleteArticleUseCase struct {
articleRepository article.Repository
imageRepository image.Repository
}
func NewDeleteArticleUseCase(articleRepository article.Repository, imageRepository image.Repository) *DeleteArticleUseCase {
return &DeleteArticleUseCase{articleRepository, imageRepository}
func NewDeleteArticleUseCase(articleRepository article.Repository) *DeleteArticleUseCase {
return &DeleteArticleUseCase{articleRepository}
}
func (g *DeleteArticleUseCase) DeleteArticle(id uint32) error {
a, err := g.articleRepository.Get(id)
if err != nil {
return err
}
for _, img := range a.Images {
if err := g.imageRepository.Delete(img.ID); err != nil {
return err
}
}
return g.articleRepository.Delete(id)
}
@@ -1,15 +0,0 @@
package useCases
import "RenewCMS/internal/domain/image"
type DeleteImageUseCase struct {
imageRepository image.Repository
}
func NewDeleteImageUseCase(imageRepository image.Repository) *DeleteImageUseCase {
return &DeleteImageUseCase{imageRepository}
}
func (g *DeleteImageUseCase) DeleteImage(imageId uint32) error {
return g.imageRepository.Delete(imageId)
}
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/image"
type DeleteMediaUseCase struct {
imageRepository image.Repository
}
func NewDeleteMediaUseCase(imageRepository image.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository}
}
func (u *DeleteMediaUseCase) Delete(id uint32) error {
return u.imageRepository.Delete(id)
}
@@ -0,0 +1,23 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
)
type GetMediaUsageUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
}
func NewGetMediaUsageUseCase(imageRepository image.Repository, articleRepository article.Repository) *GetMediaUsageUseCase {
return &GetMediaUsageUseCase{imageRepository, articleRepository}
}
func (u *GetMediaUsageUseCase) Usage(id uint32) ([]article.Article, error) {
img, err := u.imageRepository.Get(id)
if err != nil {
return nil, err
}
return u.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
}
@@ -0,0 +1,199 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"encoding/base64"
"strconv"
"strings"
)
const cursorPrefix = "offset:"
type MediaItem struct {
Image image.Image
UploadedBy string
UsedIn int
Duplicate bool
}
type MediaCounts struct {
All int
Unused int
Duplicates int
}
type MediaPage struct {
Items []MediaItem
NextCursor string
Counts MediaCounts
}
type ListMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
userRepository user.Repository
}
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository) *ListMediaUseCase {
return &ListMediaUseCase{imageRepository, articleRepository, userRepository}
}
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
if err := query.Validate(); err != nil {
return MediaPage{}, err
}
offset, err := decodeCursor(query.Cursor)
if err != nil {
return MediaPage{}, err
}
usage, err := l.usageCounter()
if err != nil {
return MediaPage{}, err
}
duplicateHashes, err := l.duplicateHashes()
if err != nil {
return MediaPage{}, err
}
counts, err := l.counts(usage)
if err != nil {
return MediaPage{}, err
}
images, err := l.imageRepository.List(image.SearchQuery{Search: strings.TrimSpace(query.Search), Hash: query.Hash})
if err != nil {
return MediaPage{}, err
}
image.SortImages(images, query.Sort)
matching := make([]image.Image, 0, len(images))
for _, img := range images {
if matchesFilter(query.Filter, usage(img), duplicateHashes[img.SHA256] && img.SHA256 != "") {
matching = append(matching, img)
}
}
pageEnd := min(offset+query.Limit, len(matching))
pageStart := min(offset, pageEnd)
names := newUploaderNames(l.userRepository)
items := []MediaItem{}
for _, img := range matching[pageStart:pageEnd] {
uploadedBy, err := names.nameOf(img.UploadedByID)
if err != nil {
return MediaPage{}, err
}
items = append(items, MediaItem{
Image: img,
UploadedBy: uploadedBy,
UsedIn: usage(img),
Duplicate: img.SHA256 != "" && duplicateHashes[img.SHA256],
})
}
page := MediaPage{Items: items, Counts: counts}
if pageEnd < len(matching) {
page.NextCursor = encodeCursor(pageEnd)
}
return page, nil
}
func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
articles, err := l.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
if err != nil {
return MediaItem{}, err
}
uploadedBy, err := newUploaderNames(l.userRepository).nameOf(img.UploadedByID)
if err != nil {
return MediaItem{}, err
}
duplicate := false
if img.SHA256 != "" {
sameContent, err := l.imageRepository.FindByHash(img.SHA256)
if err != nil {
return MediaItem{}, err
}
duplicate = len(sameContent) > 1
}
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles), Duplicate: duplicate}, nil
}
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
articles, err := l.articleRepository.FindByFilters(article.Filters{})
if err != nil {
return nil, err
}
return func(img image.Image) int {
storedName := image.StoredName(img.Path)
used := 0
for _, a := range articles {
if strings.Contains(a.Body, storedName) {
used++
}
}
return used
}, nil
}
func (l *ListMediaUseCase) duplicateHashes() (map[string]bool, error) {
duplicates, err := l.imageRepository.ListDuplicates()
if err != nil {
return nil, err
}
hashes := make(map[string]bool, len(duplicates))
for _, img := range duplicates {
hashes[img.SHA256] = true
}
return hashes, nil
}
func (l *ListMediaUseCase) counts(usage func(image.Image) int) (MediaCounts, error) {
libraryCounts, err := l.imageRepository.Counts()
if err != nil {
return MediaCounts{}, err
}
everything, err := l.imageRepository.List(image.SearchQuery{})
if err != nil {
return MediaCounts{}, err
}
unused := 0
for _, img := range everything {
if usage(img) == 0 {
unused++
}
}
return MediaCounts{All: libraryCounts.All, Unused: unused, Duplicates: libraryCounts.DuplicateGroups}, nil
}
func matchesFilter(filter image.Filter, usedIn int, duplicate bool) bool {
switch filter {
case image.FilterUnused:
return usedIn == 0
case image.FilterDuplicates:
return duplicate
default:
return true
}
}
func encodeCursor(offset int) string {
return base64.RawURLEncoding.EncodeToString([]byte(cursorPrefix + strconv.Itoa(offset)))
}
func decodeCursor(cursor string) (int, error) {
if cursor == "" {
return 0, nil
}
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil || !strings.HasPrefix(string(raw), cursorPrefix) {
return 0, image.ErrInvalidQuery
}
offset, err := strconv.Atoi(strings.TrimPrefix(string(raw), cursorPrefix))
if err != nil || offset < 0 {
return 0, image.ErrInvalidQuery
}
return offset, nil
}
@@ -0,0 +1,137 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/image"
"cmp"
"errors"
"slices"
"strings"
)
type MergeMediaResult struct {
ArticlesUpdated int
Merged int
}
type MergeMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository}
}
type bodyRewrite struct {
articleId uint32
original string
rewritten string
}
func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (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
}
rewrites, err := u.planRewrites(kept, copies)
if err != nil {
return MergeMediaResult{}, err
}
if err := u.applyRewrites(rewrites); err != nil {
return MergeMediaResult{}, err
}
for _, duplicate := range copies {
if err := u.imageRepository.Delete(duplicate.ID); err != nil {
return MergeMediaResult{}, err
}
}
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies)}, nil
}
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(kept image.Image, copies []image.Image) ([]bodyRewrite, error) {
keptName := image.StoredName(kept.Path)
rewritten := map[uint32]*bodyRewrite{}
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 {
plan, ok := rewritten[a.ID]
if !ok {
plan = &bodyRewrite{articleId: a.ID, original: a.Body, rewritten: a.Body}
rewritten[a.ID] = plan
}
plan.rewritten = strings.ReplaceAll(plan.rewritten, copyName, keptName)
}
}
plans := make([]bodyRewrite, 0, len(rewritten))
for _, plan := range rewritten {
plans = append(plans, *plan)
}
slices.SortFunc(plans, func(a, b bodyRewrite) int { return cmp.Compare(a.articleId, b.articleId) })
return plans, nil
}
func (u *MergeMediaUseCase) applyRewrites(rewrites []bodyRewrite) error {
for applied, plan := range rewrites {
if _, err := u.articleRepository.UpdateBody(plan.articleId, plan.rewritten); err != nil {
u.restoreBodies(rewrites[:applied])
return err
}
}
return nil
}
func (u *MergeMediaUseCase) restoreBodies(applied []bodyRewrite) {
for _, plan := range applied {
_, _ = u.articleRepository.UpdateBody(plan.articleId, plan.original)
}
}
@@ -0,0 +1,25 @@
package useCases
import "RenewCMS/internal/domain/image"
type RenameMediaUseCase struct {
imageRepository image.Repository
}
func NewRenameMediaUseCase(imageRepository image.Repository) *RenameMediaUseCase {
return &RenameMediaUseCase{imageRepository}
}
func (u *RenameMediaUseCase) Rename(id uint32, stem string) (image.Image, error) {
validStem, err := image.ValidateFilenameStem(stem)
if err != nil {
return image.Image{}, err
}
current, err := u.imageRepository.Get(id)
if err != nil {
return image.Image{}, err
}
return u.imageRepository.Rename(id, validStem+image.ExtensionOf(current.Filename))
}
@@ -14,10 +14,6 @@ func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Artic
return g.articleRepository.UpdateBody(id, body)
}
func (g *UpdateArticleUseCase) AddImage(articleId uint32, imageId uint32) error {
return g.articleRepository.AddImage(articleId, imageId)
}
func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
return g.articleRepository.UpdateIsOnline(id, isOnline)
}
@@ -0,0 +1,40 @@
package useCases
import (
"RenewCMS/internal/domain/user"
"errors"
)
type uploaderNames struct {
userRepository user.Repository
names map[uint32]string
}
func newUploaderNames(userRepository user.Repository) *uploaderNames {
return &uploaderNames{userRepository: userRepository, names: map[uint32]string{}}
}
func (u *uploaderNames) nameOf(userId uint32) (string, error) {
if userId == 0 {
return "", nil
}
if name, ok := u.names[userId]; ok {
return name, nil
}
uploader, err := u.userRepository.Get(userId)
if errors.Is(err, user.ErrUserNotFound) {
u.names[userId] = ""
return "", nil
}
if err != nil {
return "", err
}
name := uploader.DisplayName
if name == "" {
name = uploader.Username
}
u.names[userId] = name
return name, nil
}