Files
RenewCMS/internal/domain/image/imageDomain.go
T

244 lines
5.6 KiB
Go

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,
"image/webp": true,
"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]
}
func AllowedContentTypes() []string {
types := make([]string, 0, len(allowedContentTypes))
for contentType := range allowedContentTypes {
types = append(types, contentType)
}
sort.Strings(types)
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"
SortTotalSize Sort = "total_size"
)
type Filter string
const (
FilterAll Filter = "all"
FilterUnused Filter = "unused"
FilterDuplicates Filter = "duplicates"
)
type VariantsFilter string
const (
VariantsAny VariantsFilter = ""
VariantsWith VariantsFilter = "with"
VariantsWithout VariantsFilter = "without"
)
type ListQuery struct {
Search string
Hash string
Filter Filter
Variants VariantsFilter
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, SortTotalSize:
default:
return ErrInvalidQuery
}
switch q.Variants {
case VariantsAny, VariantsWith, VariantsWithout:
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
}
})
}
func SortImagesByTotalSize(images []Image, totals map[string]VariantFileTotals) {
totalOf := func(img Image) int64 {
return img.Size + totals[StoredName(img.Path)].Bytes
}
sort.SliceStable(images, func(i, j int) bool {
a, b := images[i], images[j]
if totalOf(a) != totalOf(b) {
return totalOf(a) > totalOf(b)
}
return a.ID < b.ID
})
}
type Counts struct {
All int
DuplicateGroups int
}
type ImageInput struct {
Content io.Reader
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string
UploadedByID uint32
}
type Metadata struct {
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string
}
type Image struct {
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"`
}