feat(API): media library
This commit is contained in:
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{}
|
||||
Reference in new issue
Block a user