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

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