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"` }