249 lines
5.7 KiB
Go
249 lines
5.7 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
|
|
DefaultListPage = 1
|
|
DefaultListSize = 25
|
|
)
|
|
|
|
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
|
|
Page int
|
|
Size int
|
|
}
|
|
|
|
func NewListQuery() ListQuery {
|
|
return ListQuery{Filter: FilterAll, Sort: SortNewest, Page: DefaultListPage, Size: DefaultListSize}
|
|
}
|
|
|
|
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.Page < 1 {
|
|
return ErrInvalidQuery
|
|
}
|
|
switch q.Size {
|
|
case 5, 25, 50, 100:
|
|
default:
|
|
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"`
|
|
}
|