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