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)
}