feat(API): media library
This commit is contained in:
1 parent
95ae043d3e
commit
0d99544579
65 files changed
+3992
-1530
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user