feat: image variants engine
This commit is contained in:
1 parent
f0845de643
commit
f1d56ef50e
80 files changed
+7312
-84
No files matched your search
@@ -2,6 +2,7 @@ package audit
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -28,3 +29,34 @@ func ArticleChanges(before article.Article, after article.Article) []Change {
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func VariantChanges(before image.VariantSettings, after image.VariantSettings) []Change {
|
||||
changes := []Change{}
|
||||
changes = appendChange(changes, FieldWidth, image.DimensionText(before.Width), image.DimensionText(after.Width))
|
||||
changes = appendChange(changes, FieldHeight, image.DimensionText(before.Height), image.DimensionText(after.Height))
|
||||
changes = appendChange(changes, FieldFit, string(before.Fit), string(after.Fit))
|
||||
changes = appendChange(changes, FieldFormat, string(before.Format), string(after.Format))
|
||||
changes = appendChange(changes, FieldQuality, image.QualityText(before.Quality), image.QualityText(after.Quality))
|
||||
return changes
|
||||
}
|
||||
|
||||
func appendChange(changes []Change, field string, before string, after string) []Change {
|
||||
if before == after {
|
||||
return changes
|
||||
}
|
||||
return append(changes, TextChange(field, before, after))
|
||||
}
|
||||
|
||||
func VariantParams(settings image.VariantSettings) map[string]any {
|
||||
params := map[string]any{ParamFit: string(settings.Fit), ParamFormat: string(settings.Format)}
|
||||
if settings.Width != nil {
|
||||
params[ParamWidth] = *settings.Width
|
||||
}
|
||||
if settings.Height != nil {
|
||||
params[ParamHeight] = *settings.Height
|
||||
}
|
||||
if settings.Quality != nil {
|
||||
params[ParamQuality] = *settings.Quality
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -76,6 +76,12 @@ const (
|
||||
ActionMediaDelete = "media.delete"
|
||||
ActionMediaMerge = "media.merge"
|
||||
ActionMediaPurge = "media.purge"
|
||||
ActionVariantCreate = "media.variant_create"
|
||||
ActionVariantUpdate = "media.variant_update"
|
||||
ActionVariantDelete = "media.variant_delete"
|
||||
ActionVariantEnable = "media.variant_enable"
|
||||
ActionVariantDisable = "media.variant_disable"
|
||||
ActionVariantPurge = "media.variant_purge"
|
||||
ActionUserRegister = "user.register"
|
||||
ActionUserVerify = "user.verify"
|
||||
ActionUserUpdateProfile = "user.update_profile"
|
||||
@@ -94,6 +100,7 @@ const (
|
||||
TargetUser = "user"
|
||||
TargetArticle = "article"
|
||||
TargetImage = "image"
|
||||
TargetVariant = "image_variant"
|
||||
TargetInvite = "invite"
|
||||
TargetRelease = "release"
|
||||
)
|
||||
@@ -112,12 +119,24 @@ const (
|
||||
ParamFailedGroups = "failed_groups"
|
||||
ParamUpdateAvailable = "update_available"
|
||||
ParamKind = "kind"
|
||||
ParamPurgedFiles = "purged_files"
|
||||
ParamPurgedBytes = "purged_bytes"
|
||||
ParamWidth = "width"
|
||||
ParamHeight = "height"
|
||||
ParamFit = "fit"
|
||||
ParamFormat = "format"
|
||||
ParamQuality = "quality"
|
||||
ParamTag = "tag"
|
||||
FieldTitle = "title"
|
||||
FieldOnline = "online"
|
||||
FieldBody = "body"
|
||||
FieldFilename = "filename"
|
||||
FieldDisplayName = "display_name"
|
||||
FieldWidth = "width"
|
||||
FieldHeight = "height"
|
||||
FieldFit = "fit"
|
||||
FieldFormat = "format"
|
||||
FieldQuality = "quality"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
|
||||
@@ -9,3 +9,7 @@ func NewTarget(targetType string, id uint32, name string) Target {
|
||||
func ReleaseTarget(tag string) Target {
|
||||
return Target{Type: TargetRelease, ID: tag, Name: tag}
|
||||
}
|
||||
|
||||
func VariantTarget(name string) Target {
|
||||
return Target{Type: TargetVariant, ID: name, Name: name}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var ErrNotAGif = errors.New("not a gif")
|
||||
|
||||
const (
|
||||
gifSignaturePrefix = "GIF"
|
||||
gifHeaderLength = 6
|
||||
gifScreenDescriptorSize = 7
|
||||
gifImageDescriptorSize = 9
|
||||
gifExtensionIntroducer = 0x21
|
||||
gifImageSeparator = 0x2C
|
||||
gifTrailer = 0x3B
|
||||
gifColorTableFlag = 0x80
|
||||
gifColorTableSizeMask = 0x07
|
||||
gifColorTableEntryBytes = 3
|
||||
gifFramesBeforeAnimation = 2
|
||||
)
|
||||
|
||||
func IsAnimatedGIF(source io.Reader) (bool, error) {
|
||||
reader := bufio.NewReader(source)
|
||||
if err := skipGifPreamble(reader); err != nil {
|
||||
return false, err
|
||||
}
|
||||
frames := 0
|
||||
for {
|
||||
marker, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch marker {
|
||||
case gifTrailer:
|
||||
return false, nil
|
||||
case gifExtensionIntroducer:
|
||||
err = skipGifExtension(reader)
|
||||
case gifImageSeparator:
|
||||
frames++
|
||||
if frames >= gifFramesBeforeAnimation {
|
||||
return true, nil
|
||||
}
|
||||
err = skipGifImage(reader)
|
||||
default:
|
||||
return false, ErrNotAGif
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skipGifPreamble(reader *bufio.Reader) error {
|
||||
header := make([]byte, gifHeaderLength)
|
||||
if _, err := io.ReadFull(reader, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(header[:len(gifSignaturePrefix)]) != gifSignaturePrefix {
|
||||
return ErrNotAGif
|
||||
}
|
||||
screen := make([]byte, gifScreenDescriptorSize)
|
||||
if _, err := io.ReadFull(reader, screen); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifColorTable(reader, screen[4])
|
||||
}
|
||||
|
||||
func skipGifColorTable(reader *bufio.Reader, packed byte) error {
|
||||
if packed&gifColorTableFlag == 0 {
|
||||
return nil
|
||||
}
|
||||
entries := 1 << (int(packed&gifColorTableSizeMask) + 1)
|
||||
_, err := reader.Discard(entries * gifColorTableEntryBytes)
|
||||
return err
|
||||
}
|
||||
|
||||
func skipGifExtension(reader *bufio.Reader) error {
|
||||
if _, err := reader.ReadByte(); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifSubBlocks(reader)
|
||||
}
|
||||
|
||||
func skipGifImage(reader *bufio.Reader) error {
|
||||
descriptor := make([]byte, gifImageDescriptorSize)
|
||||
if _, err := io.ReadFull(reader, descriptor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := skipGifColorTable(reader, descriptor[8]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := reader.ReadByte(); err != nil {
|
||||
return err
|
||||
}
|
||||
return skipGifSubBlocks(reader)
|
||||
}
|
||||
|
||||
func skipGifSubBlocks(reader *bufio.Reader) error {
|
||||
for {
|
||||
size, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := reader.Discard(int(size)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type Repository interface {
|
||||
Rename(id uint32, filename string) (Image, error)
|
||||
ListDuplicates() ([]Image, error)
|
||||
Counts() (Counts, error)
|
||||
ExistsByStoredName(storedName string) (bool, error)
|
||||
ListMissingMetadata() ([]Image, error)
|
||||
UpdateMetadata(id uint32, metadata Metadata) error
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrVariantNotFound = errors.New("variant not found")
|
||||
var ErrVariantExists = errors.New("variant already exists")
|
||||
var ErrVariantInvalid = errors.New("variant is not valid")
|
||||
var ErrVariantFormatUnavailable = errors.New("variant format cannot be encoded")
|
||||
var ErrUnknownVariant = errors.New("unknown variant")
|
||||
var ErrUnsupportedParameter = errors.New("unsupported parameter")
|
||||
var ErrSourceTooLarge = errors.New("source image is too large to generate this variant")
|
||||
|
||||
type VariantFit string
|
||||
|
||||
const (
|
||||
FitCover VariantFit = "cover"
|
||||
FitContain VariantFit = "contain"
|
||||
FitInside VariantFit = "inside"
|
||||
)
|
||||
|
||||
type VariantFormat string
|
||||
|
||||
const (
|
||||
FormatOriginal VariantFormat = "original"
|
||||
FormatJPEG VariantFormat = "jpeg"
|
||||
FormatPNG VariantFormat = "png"
|
||||
FormatWebP VariantFormat = "webp"
|
||||
)
|
||||
|
||||
const (
|
||||
MinVariantNameLength = 2
|
||||
MaxVariantNameLength = 32
|
||||
MinVariantDimension = 1
|
||||
MaxVariantDimension = 8000
|
||||
MinVariantQuality = 1
|
||||
MaxVariantQuality = 100
|
||||
DefaultVariantQuality = 82
|
||||
DefaultVariantFit = FitCover
|
||||
DefaultVariantFormat = FormatOriginal
|
||||
VariantQueryParameter = "v"
|
||||
)
|
||||
|
||||
var variantNamePattern = regexp.MustCompile(`^[a-z0-9-]{2,32}$`)
|
||||
|
||||
var unsupportedQueryParameters = []string{"width", "height", "format", "quality"}
|
||||
|
||||
func VariantFits() []VariantFit {
|
||||
return []VariantFit{FitCover, FitContain, FitInside}
|
||||
}
|
||||
|
||||
func VariantFormats() []VariantFormat {
|
||||
return []VariantFormat{FormatOriginal, FormatJPEG, FormatPNG, FormatWebP}
|
||||
}
|
||||
|
||||
func UnsupportedQueryParameters() []string {
|
||||
return append([]string(nil), unsupportedQueryParameters...)
|
||||
}
|
||||
|
||||
func IsValidVariantName(name string) bool {
|
||||
return variantNamePattern.MatchString(name)
|
||||
}
|
||||
|
||||
type VariantSettings struct {
|
||||
Width *int
|
||||
Height *int
|
||||
Fit VariantFit
|
||||
Format VariantFormat
|
||||
Quality *int
|
||||
}
|
||||
|
||||
func (s VariantSettings) Normalized() (VariantSettings, error) {
|
||||
normalized := s
|
||||
if normalized.Fit == "" {
|
||||
normalized.Fit = DefaultVariantFit
|
||||
}
|
||||
if normalized.Format == "" {
|
||||
normalized.Format = DefaultVariantFormat
|
||||
}
|
||||
if !isKnownFit(normalized.Fit) || !isKnownFormat(normalized.Format) {
|
||||
return VariantSettings{}, ErrVariantInvalid
|
||||
}
|
||||
if !hasValidDimensions(normalized.Width, normalized.Height) {
|
||||
return VariantSettings{}, ErrVariantInvalid
|
||||
}
|
||||
quality, err := normalizedQuality(normalized.Format, normalized.Quality)
|
||||
if err != nil {
|
||||
return VariantSettings{}, err
|
||||
}
|
||||
normalized.Quality = quality
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (s VariantSettings) Fingerprint() string {
|
||||
parts := []string{
|
||||
"w=" + DimensionText(s.Width),
|
||||
"h=" + DimensionText(s.Height),
|
||||
"fit=" + string(s.Fit),
|
||||
"format=" + string(s.Format),
|
||||
"q=" + QualityText(s.Quality),
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, ";")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s VariantSettings) IsSameAs(other VariantSettings) bool {
|
||||
return s.Fingerprint() == other.Fingerprint()
|
||||
}
|
||||
|
||||
func isKnownFit(fit VariantFit) bool {
|
||||
for _, known := range VariantFits() {
|
||||
if fit == known {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isKnownFormat(format VariantFormat) bool {
|
||||
for _, known := range VariantFormats() {
|
||||
if format == known {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasValidDimensions(width *int, height *int) bool {
|
||||
if width == nil && height == nil {
|
||||
return false
|
||||
}
|
||||
return isValidDimension(width) && isValidDimension(height)
|
||||
}
|
||||
|
||||
func isValidDimension(dimension *int) bool {
|
||||
return dimension == nil || (*dimension >= MinVariantDimension && *dimension <= MaxVariantDimension)
|
||||
}
|
||||
|
||||
func normalizedQuality(format VariantFormat, quality *int) (*int, error) {
|
||||
if format == FormatPNG {
|
||||
return nil, nil
|
||||
}
|
||||
if quality == nil {
|
||||
defaultQuality := DefaultVariantQuality
|
||||
return &defaultQuality, nil
|
||||
}
|
||||
if *quality < MinVariantQuality || *quality > MaxVariantQuality {
|
||||
return nil, ErrVariantInvalid
|
||||
}
|
||||
kept := *quality
|
||||
return &kept, nil
|
||||
}
|
||||
|
||||
func DimensionText(dimension *int) string {
|
||||
if dimension == nil {
|
||||
return "auto"
|
||||
}
|
||||
return strconv.Itoa(*dimension)
|
||||
}
|
||||
|
||||
func QualityText(quality *int) string {
|
||||
if quality == nil {
|
||||
return "none"
|
||||
}
|
||||
return strconv.Itoa(*quality)
|
||||
}
|
||||
|
||||
type Variant struct {
|
||||
Name string `json:"name"`
|
||||
Width *int `json:"width"`
|
||||
Height *int `json:"height"`
|
||||
Fit VariantFit `json:"fit"`
|
||||
Format VariantFormat `json:"format"`
|
||||
Quality *int `json:"quality"`
|
||||
GenerateOnUpload bool `json:"generate_on_upload"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func NewVariant(name string, settings VariantSettings, generateOnUpload bool, enabled bool, now time.Time) (Variant, error) {
|
||||
if !IsValidVariantName(name) {
|
||||
return Variant{}, ErrVariantInvalid
|
||||
}
|
||||
normalized, err := settings.Normalized()
|
||||
if err != nil {
|
||||
return Variant{}, err
|
||||
}
|
||||
variant := Variant{Name: name, GenerateOnUpload: generateOnUpload, Enabled: enabled, CreatedAt: now, UpdatedAt: now}
|
||||
return variant.WithSettings(normalized), nil
|
||||
}
|
||||
|
||||
func (v Variant) Settings() VariantSettings {
|
||||
return VariantSettings{Width: v.Width, Height: v.Height, Fit: v.Fit, Format: v.Format, Quality: v.Quality}
|
||||
}
|
||||
|
||||
func (v Variant) WithSettings(settings VariantSettings) Variant {
|
||||
v.Width, v.Height, v.Fit, v.Format, v.Quality = settings.Width, settings.Height, settings.Fit, settings.Format, settings.Quality
|
||||
return v
|
||||
}
|
||||
|
||||
type VariantFile struct {
|
||||
ID uint32
|
||||
VariantName string
|
||||
SourceName string
|
||||
CachedName string
|
||||
ContentType string
|
||||
Size int64
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type VariantFileTotals struct {
|
||||
Files int
|
||||
Bytes int64
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package image
|
||||
|
||||
import "io"
|
||||
|
||||
type VariantRepository interface {
|
||||
List() ([]Variant, error)
|
||||
Get(name string) (Variant, error)
|
||||
Create(variant Variant) (Variant, error)
|
||||
Update(variant Variant) (Variant, error)
|
||||
Delete(name string) error
|
||||
}
|
||||
|
||||
type VariantFileRepository interface {
|
||||
Find(variantName string, sourceName string) (VariantFile, bool, error)
|
||||
Save(file VariantFile) (VariantFile, error)
|
||||
ListByVariant(variantName string) ([]VariantFile, error)
|
||||
ListBySource(sourceName string) ([]VariantFile, error)
|
||||
Delete(id uint32) error
|
||||
Totals() (map[string]VariantFileTotals, error)
|
||||
}
|
||||
|
||||
type RenderedVariant struct {
|
||||
Content []byte
|
||||
ContentType string
|
||||
}
|
||||
|
||||
type VariantRenderer interface {
|
||||
DeclaredSize(source io.Reader) (width int, height int, err error)
|
||||
Render(source io.Reader, settings VariantSettings) (RenderedVariant, error)
|
||||
EncodableFormats() []VariantFormat
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const etagDigestLength = 32
|
||||
|
||||
type VariantRequestError struct {
|
||||
cause error
|
||||
Detail string
|
||||
}
|
||||
|
||||
func NewUnknownVariantError(name string) error {
|
||||
return VariantRequestError{cause: ErrUnknownVariant, Detail: name}
|
||||
}
|
||||
|
||||
func NewUnsupportedParameterError(parameter string) error {
|
||||
return VariantRequestError{cause: ErrUnsupportedParameter, Detail: parameter}
|
||||
}
|
||||
|
||||
func (e VariantRequestError) Error() string {
|
||||
return e.cause.Error() + ": " + e.Detail
|
||||
}
|
||||
|
||||
func (e VariantRequestError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func VariantRequestDetail(err error) string {
|
||||
var requestError VariantRequestError
|
||||
if errors.As(err, &requestError) {
|
||||
return requestError.Detail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func VariantETag(settings VariantSettings, sourceName string) string {
|
||||
digest := sha256.Sum256([]byte(settings.Fingerprint() + "|" + sourceName))
|
||||
return `"` + hex.EncodeToString(digest[:])[:etagDigestLength] + `"`
|
||||
}
|
||||
|
||||
func ETagMatches(ifNoneMatch string, etag string) bool {
|
||||
for _, candidate := range strings.Split(ifNoneMatch, ",") {
|
||||
trimmed := strings.TrimPrefix(strings.TrimSpace(candidate), "W/")
|
||||
if trimmed == "*" || trimmed == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvImageVariantMaxSourceBytes = "IMAGE_VARIANT_MAX_SOURCE_BYTES"
|
||||
DefaultImageVariantMaxSourceBytes = "10485760"
|
||||
|
||||
EnvImageVariantMaxSourcePixels = "IMAGE_VARIANT_MAX_SOURCE_PIXELS"
|
||||
DefaultImageVariantMaxSourcePixels = "50000000"
|
||||
|
||||
EnvImageVariantMaxConcurrentRenders = "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS"
|
||||
DefaultImageVariantMaxConcurrentRenders = "4"
|
||||
)
|
||||
|
||||
func imageVariantMaxSourceBytesFromEnv() (int64, error) {
|
||||
raw := envOrDefault(EnvImageVariantMaxSourceBytes, DefaultImageVariantMaxSourceBytes)
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("%s must be a whole number of bytes greater than zero, got %q", EnvImageVariantMaxSourceBytes, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func imageVariantMaxSourcePixelsFromEnv() (int64, error) {
|
||||
raw := envOrDefault(EnvImageVariantMaxSourcePixels, DefaultImageVariantMaxSourcePixels)
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("%s must be a whole number of pixels greater than zero, got %q", EnvImageVariantMaxSourcePixels, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func imageVariantMaxConcurrentRendersFromEnv() (int, error) {
|
||||
raw := envOrDefault(EnvImageVariantMaxConcurrentRenders, DefaultImageVariantMaxConcurrentRenders)
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 1 {
|
||||
return 0, fmt.Errorf("%s must be a whole number of renders greater than zero, got %q", EnvImageVariantMaxConcurrentRenders, raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -22,22 +22,25 @@ type AuthRateLimitSettings struct {
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Environment string
|
||||
CorsAllowedOrigins []string
|
||||
PublicURL string
|
||||
UploadDir string
|
||||
JWTSecret string
|
||||
DBFile string
|
||||
Database DatabaseSettings
|
||||
Storage StorageSettings
|
||||
DataDirPath string
|
||||
Port string
|
||||
Smtp SmtpSettings
|
||||
AuthRateLimit AuthRateLimitSettings
|
||||
TrustedProxies []string
|
||||
Version string
|
||||
UpdateFeedURL string
|
||||
Container bool
|
||||
Environment string
|
||||
CorsAllowedOrigins []string
|
||||
PublicURL string
|
||||
UploadDir string
|
||||
JWTSecret string
|
||||
DBFile string
|
||||
Database DatabaseSettings
|
||||
Storage StorageSettings
|
||||
DataDirPath string
|
||||
Port string
|
||||
Smtp SmtpSettings
|
||||
AuthRateLimit AuthRateLimitSettings
|
||||
TrustedProxies []string
|
||||
Version string
|
||||
UpdateFeedURL string
|
||||
Container bool
|
||||
ImageVariantMaxSourceBytes int64
|
||||
ImageVariantMaxSourcePixels int64
|
||||
ImageVariantMaxConcurrentRenders int
|
||||
}
|
||||
|
||||
func (s Settings) DataDir() string {
|
||||
@@ -77,6 +80,21 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
maxSourceBytes, err := imageVariantMaxSourceBytesFromEnv()
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
maxSourcePixels, err := imageVariantMaxSourcePixelsFromEnv()
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
maxConcurrentRenders, err := imageVariantMaxConcurrentRendersFromEnv()
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
return Settings{
|
||||
Environment: os.Getenv("ENVIRONMENT"),
|
||||
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
|
||||
@@ -99,9 +117,12 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
MaxAttempts: authRateLimitMaxAttempts,
|
||||
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
|
||||
},
|
||||
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
||||
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
|
||||
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
|
||||
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
||||
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
|
||||
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
|
||||
ImageVariantMaxSourceBytes: maxSourceBytes,
|
||||
ImageVariantMaxSourcePixels: maxSourcePixels,
|
||||
ImageVariantMaxConcurrentRenders: maxConcurrentRenders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
|
||||
"github.com/gen2brain/webp"
|
||||
)
|
||||
|
||||
type encoder interface {
|
||||
contentType() string
|
||||
flattensTransparency() bool
|
||||
encode(output *bytes.Buffer, source image.Image, quality int) error
|
||||
}
|
||||
|
||||
type jpegEncoder struct{}
|
||||
|
||||
func (jpegEncoder) contentType() string { return "image/jpeg" }
|
||||
|
||||
func (jpegEncoder) flattensTransparency() bool { return true }
|
||||
|
||||
func (jpegEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
|
||||
return jpeg.Encode(output, source, &jpeg.Options{Quality: quality})
|
||||
}
|
||||
|
||||
type pngEncoder struct{}
|
||||
|
||||
func (pngEncoder) contentType() string { return "image/png" }
|
||||
|
||||
func (pngEncoder) flattensTransparency() bool { return false }
|
||||
|
||||
func (pngEncoder) encode(output *bytes.Buffer, source image.Image, _ int) error {
|
||||
return png.Encode(output, source)
|
||||
}
|
||||
|
||||
type webpEncoder struct{}
|
||||
|
||||
func (webpEncoder) contentType() string { return "image/webp" }
|
||||
|
||||
func (webpEncoder) flattensTransparency() bool { return false }
|
||||
|
||||
func (webpEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
|
||||
return webp.Encode(output, source, webp.Options{Quality: quality})
|
||||
}
|
||||
|
||||
var knownEncoders = map[domain.VariantFormat]encoder{
|
||||
domain.FormatJPEG: jpegEncoder{},
|
||||
domain.FormatPNG: pngEncoder{},
|
||||
domain.FormatWebP: webpEncoder{},
|
||||
}
|
||||
|
||||
var sourceFormats = map[string]domain.VariantFormat{
|
||||
"jpeg": domain.FormatJPEG,
|
||||
"png": domain.FormatPNG,
|
||||
"webp": domain.FormatWebP,
|
||||
"gif": domain.FormatPNG,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"image"
|
||||
"math"
|
||||
)
|
||||
|
||||
type layout struct {
|
||||
canvas image.Point
|
||||
source image.Rectangle
|
||||
content image.Rectangle
|
||||
}
|
||||
|
||||
func layoutFor(source image.Point, settings domain.VariantSettings) layout {
|
||||
if settings.Width == nil || settings.Height == nil {
|
||||
return proportionalLayout(source, settings)
|
||||
}
|
||||
box := image.Pt(*settings.Width, *settings.Height)
|
||||
switch settings.Fit {
|
||||
case domain.FitContain:
|
||||
return containLayout(source, box)
|
||||
case domain.FitInside:
|
||||
return insideLayout(source, box)
|
||||
default:
|
||||
return coverLayout(source, box)
|
||||
}
|
||||
}
|
||||
|
||||
func proportionalLayout(source image.Point, settings domain.VariantSettings) layout {
|
||||
scale := 1.0
|
||||
if settings.Width != nil {
|
||||
scale = math.Min(scale, ratio(*settings.Width, source.X))
|
||||
}
|
||||
if settings.Height != nil {
|
||||
scale = math.Min(scale, ratio(*settings.Height, source.Y))
|
||||
}
|
||||
return wholeSourceLayout(source, scaled(source, scale))
|
||||
}
|
||||
|
||||
func insideLayout(source image.Point, box image.Point) layout {
|
||||
scale := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
|
||||
return wholeSourceLayout(source, scaled(source, scale))
|
||||
}
|
||||
|
||||
func wholeSourceLayout(source image.Point, output image.Point) layout {
|
||||
return layout{canvas: output, source: image.Rectangle{Max: source}, content: image.Rectangle{Max: output}}
|
||||
}
|
||||
|
||||
func coverLayout(source image.Point, box image.Point) layout {
|
||||
shrink := math.Min(1, math.Min(ratio(source.X, box.X), ratio(source.Y, box.Y)))
|
||||
output := scaled(box, shrink)
|
||||
return layout{canvas: output, source: centeredCrop(source, output), content: image.Rectangle{Max: output}}
|
||||
}
|
||||
|
||||
func containLayout(source image.Point, box image.Point) layout {
|
||||
fit := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
|
||||
content := scaled(source, fit)
|
||||
content = image.Pt(min(content.X, box.X), min(content.Y, box.Y))
|
||||
origin := image.Pt((box.X-content.X)/2, (box.Y-content.Y)/2)
|
||||
return layout{canvas: box, source: image.Rectangle{Max: source}, content: image.Rectangle{Min: origin, Max: origin.Add(content)}}
|
||||
}
|
||||
|
||||
func centeredCrop(source image.Point, output image.Point) image.Rectangle {
|
||||
crop := source
|
||||
if source.X*output.Y >= source.Y*output.X {
|
||||
crop.X = min(source.X, roundedDivision(source.Y*output.X, output.Y))
|
||||
} else {
|
||||
crop.Y = min(source.Y, roundedDivision(source.X*output.Y, output.X))
|
||||
}
|
||||
origin := image.Pt((source.X-crop.X)/2, (source.Y-crop.Y)/2)
|
||||
return image.Rectangle{Min: origin, Max: origin.Add(crop)}
|
||||
}
|
||||
|
||||
func ratio(numerator int, denominator int) float64 {
|
||||
return float64(numerator) / float64(denominator)
|
||||
}
|
||||
|
||||
func roundedDivision(numerator int, denominator int) int {
|
||||
return int(math.Round(ratio(numerator, denominator)))
|
||||
}
|
||||
|
||||
func scaled(size image.Point, factor float64) image.Point {
|
||||
return image.Pt(scaledLength(size.X, factor), scaledLength(size.Y, factor))
|
||||
}
|
||||
|
||||
func scaledLength(length int, factor float64) int {
|
||||
return max(1, int(math.Round(float64(length)*factor)))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"slices"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type VariantRenderer struct {
|
||||
encoders map[domain.VariantFormat]encoder
|
||||
}
|
||||
|
||||
var _ domain.VariantRenderer = &VariantRenderer{}
|
||||
|
||||
func NewVariantRenderer() *VariantRenderer {
|
||||
return NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG, domain.FormatWebP)
|
||||
}
|
||||
|
||||
func NewVariantRendererEncoding(formats ...domain.VariantFormat) *VariantRenderer {
|
||||
encoders := map[domain.VariantFormat]encoder{}
|
||||
for format, candidate := range knownEncoders {
|
||||
if slices.Contains(formats, format) {
|
||||
encoders[format] = candidate
|
||||
}
|
||||
}
|
||||
return &VariantRenderer{encoders}
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) EncodableFormats() []domain.VariantFormat {
|
||||
formats := []domain.VariantFormat{domain.FormatOriginal}
|
||||
for _, format := range domain.VariantFormats() {
|
||||
if _, encodable := r.encoders[format]; encodable {
|
||||
formats = append(formats, format)
|
||||
}
|
||||
}
|
||||
return formats
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) DeclaredSize(source io.Reader) (int, int, error) {
|
||||
header, _, err := image.DecodeConfig(source)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return header.Width, header.Height, nil
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
|
||||
decoded, sourceFormat, err := image.Decode(source)
|
||||
if err != nil {
|
||||
return domain.RenderedVariant{}, err
|
||||
}
|
||||
chosen := r.encoderFor(settings.Format, sourceFormat)
|
||||
arranged := arrange(decoded, layoutFor(decoded.Bounds().Size(), settings), chosen.flattensTransparency())
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := chosen.encode(&output, arranged, qualityOf(settings)); err != nil {
|
||||
return domain.RenderedVariant{}, err
|
||||
}
|
||||
return domain.RenderedVariant{Content: output.Bytes(), ContentType: chosen.contentType()}, nil
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) encoderFor(format domain.VariantFormat, sourceFormat string) encoder {
|
||||
if format == domain.FormatOriginal {
|
||||
format = sourceFormats[sourceFormat]
|
||||
}
|
||||
if chosen, encodable := r.encoders[format]; encodable {
|
||||
return chosen
|
||||
}
|
||||
return pngEncoder{}
|
||||
}
|
||||
|
||||
func qualityOf(settings domain.VariantSettings) int {
|
||||
if settings.Quality == nil {
|
||||
return domain.DefaultVariantQuality
|
||||
}
|
||||
return *settings.Quality
|
||||
}
|
||||
|
||||
func arrange(decoded image.Image, plan layout, onWhite bool) image.Image {
|
||||
canvas := image.NewRGBA(image.Rectangle{Max: plan.canvas})
|
||||
if onWhite {
|
||||
xdraw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, xdraw.Src)
|
||||
}
|
||||
sourceRegion := plan.source.Add(decoded.Bounds().Min)
|
||||
if plan.source.Size() == plan.content.Size() {
|
||||
xdraw.Draw(canvas, plan.content, decoded, sourceRegion.Min, xdraw.Over)
|
||||
return canvas
|
||||
}
|
||||
xdraw.CatmullRom.Scale(canvas, plan.content, decoded, sourceRegion, xdraw.Over, nil)
|
||||
return canvas
|
||||
}
|
||||
@@ -7,9 +7,29 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB) error {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureImagePathIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return BackfillUserRoles(db)
|
||||
}
|
||||
|
||||
const (
|
||||
imagePathIndexName = "idx_images_path"
|
||||
mysqlDialectorName = "mysql"
|
||||
imagePathIndexStatement = "CREATE INDEX " + imagePathIndexName + " ON images (path)"
|
||||
mysqlImagePathStatement = "CREATE INDEX " + imagePathIndexName + " ON images (path(191))"
|
||||
)
|
||||
|
||||
func ensureImagePathIndex(db *gorm.DB) error {
|
||||
if db.Migrator().HasIndex(&models.Image{}, imagePathIndexName) {
|
||||
return nil
|
||||
}
|
||||
if db.Dialector.Name() == mysqlDialectorName {
|
||||
return db.Exec(mysqlImagePathStatement).Error
|
||||
}
|
||||
return db.Exec(imagePathIndexStatement).Error
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func VariantToDomain(variantModel models.ImageVariant) image.Variant {
|
||||
return image.Variant{
|
||||
Name: variantModel.Name,
|
||||
Width: variantModel.Width,
|
||||
Height: variantModel.Height,
|
||||
Fit: image.VariantFit(variantModel.Fit),
|
||||
Format: image.VariantFormat(variantModel.Format),
|
||||
Quality: variantModel.Quality,
|
||||
GenerateOnUpload: variantModel.GenerateOnUpload,
|
||||
Enabled: variantModel.Enabled,
|
||||
CreatedAt: variantModel.CreatedAt,
|
||||
UpdatedAt: variantModel.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func VariantToModel(variantDomain image.Variant) models.ImageVariant {
|
||||
return models.ImageVariant{
|
||||
Name: variantDomain.Name,
|
||||
Width: variantDomain.Width,
|
||||
Height: variantDomain.Height,
|
||||
Fit: string(variantDomain.Fit),
|
||||
Format: string(variantDomain.Format),
|
||||
Quality: variantDomain.Quality,
|
||||
GenerateOnUpload: variantDomain.GenerateOnUpload,
|
||||
Enabled: variantDomain.Enabled,
|
||||
CreatedAt: variantDomain.CreatedAt,
|
||||
UpdatedAt: variantDomain.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func VariantFileToDomain(fileModel models.ImageVariantFile) image.VariantFile {
|
||||
return image.VariantFile{
|
||||
ID: fileModel.ID,
|
||||
VariantName: fileModel.VariantName,
|
||||
SourceName: fileModel.SourceName,
|
||||
CachedName: fileModel.CachedName,
|
||||
ContentType: fileModel.ContentType,
|
||||
Size: fileModel.Size,
|
||||
Fingerprint: fileModel.Fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
func VariantFileToModel(fileDomain image.VariantFile) models.ImageVariantFile {
|
||||
return models.ImageVariantFile{
|
||||
ID: fileDomain.ID,
|
||||
VariantName: fileDomain.VariantName,
|
||||
SourceName: fileDomain.SourceName,
|
||||
CachedName: fileDomain.CachedName,
|
||||
ContentType: fileDomain.ContentType,
|
||||
Size: fileDomain.Size,
|
||||
Fingerprint: fileDomain.Fingerprint,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ImageVariantFile struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
VariantName string `gorm:"not null;size:32;uniqueIndex:idx_image_variant_files_pair,priority:1"`
|
||||
SourceName string `gorm:"not null;size:191;uniqueIndex:idx_image_variant_files_pair,priority:2;index:idx_image_variant_files_source"`
|
||||
CachedName string `gorm:"not null;size:191"`
|
||||
ContentType string `gorm:"not null;size:64"`
|
||||
Size int64 `gorm:"not null"`
|
||||
Fingerprint string `gorm:"not null;size:64"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ImageVariant struct {
|
||||
Name string `gorm:"primaryKey;size:32"`
|
||||
Width *int
|
||||
Height *int
|
||||
Fit string `gorm:"not null;size:16"`
|
||||
Format string `gorm:"not null;size:16"`
|
||||
Quality *int
|
||||
GenerateOnUpload bool `gorm:"not null"`
|
||||
Enabled bool `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -101,6 +101,12 @@ func (i ImageRepository) Counts() (domain.Counts, error) {
|
||||
return domain.Counts{All: int(all), DuplicateGroups: int(groups)}, nil
|
||||
}
|
||||
|
||||
func (i ImageRepository) ExistsByStoredName(storedName string) (bool, error) {
|
||||
var matches int64
|
||||
err := i.db.Model(&entity.Image{}).Where("path = ?", domain.PublicPath(storedName)).Limit(1).Count(&matches).Error
|
||||
return matches > 0, err
|
||||
}
|
||||
|
||||
func (i ImageRepository) duplicatedHashes() *gorm.DB {
|
||||
return i.db.Model(&entity.Image{}).
|
||||
Select("sha256").
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type VariantFileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var _ domain.VariantFileRepository = &VariantFileRepository{}
|
||||
|
||||
func NewVariantFileRepository(db *gorm.DB) *VariantFileRepository {
|
||||
return &VariantFileRepository{db}
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
|
||||
var model entity.ImageVariantFile
|
||||
err := v.db.Where("variant_name = ? AND source_name = ?", variantName, sourceName).First(&model).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domain.VariantFile{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.VariantFile{}, false, err
|
||||
}
|
||||
return mappers.VariantFileToDomain(model), true, nil
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Save(file domain.VariantFile) (domain.VariantFile, error) {
|
||||
model := mappers.VariantFileToModel(file)
|
||||
model.ID = 0
|
||||
err := v.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "variant_name"}, {Name: "source_name"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"cached_name", "content_type", "size", "fingerprint"}),
|
||||
}).Create(&model).Error
|
||||
if err != nil {
|
||||
return domain.VariantFile{}, err
|
||||
}
|
||||
saved, _, err := v.Find(file.VariantName, file.SourceName)
|
||||
return saved, err
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) ListByVariant(variantName string) ([]domain.VariantFile, error) {
|
||||
return v.list("variant_name = ?", variantName)
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) ListBySource(sourceName string) ([]domain.VariantFile, error) {
|
||||
return v.list("source_name = ?", sourceName)
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) list(condition string, value string) ([]domain.VariantFile, error) {
|
||||
var found []entity.ImageVariantFile
|
||||
if err := v.db.Where(condition, value).Order("id asc").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]domain.VariantFile, 0, len(found))
|
||||
for _, model := range found {
|
||||
files = append(files, mappers.VariantFileToDomain(model))
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Delete(id uint32) error {
|
||||
return v.db.Delete(&entity.ImageVariantFile{}, id).Error
|
||||
}
|
||||
|
||||
func (v *VariantFileRepository) Totals() (map[string]domain.VariantFileTotals, error) {
|
||||
var rows []struct {
|
||||
VariantName string
|
||||
Files int
|
||||
Bytes int64
|
||||
}
|
||||
err := v.db.Model(&entity.ImageVariantFile{}).
|
||||
Select("variant_name, COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes").
|
||||
Group("variant_name").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totals := make(map[string]domain.VariantFileTotals, len(rows))
|
||||
for _, row := range rows {
|
||||
totals[row.VariantName] = domain.VariantFileTotals{Files: row.Files, Bytes: row.Bytes}
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type VariantRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var _ domain.VariantRepository = &VariantRepository{}
|
||||
|
||||
func NewVariantRepository(db *gorm.DB) *VariantRepository {
|
||||
return &VariantRepository{db}
|
||||
}
|
||||
|
||||
func (v *VariantRepository) List() ([]domain.Variant, error) {
|
||||
var found []entity.ImageVariant
|
||||
if err := v.db.Order("name asc").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
variants := make([]domain.Variant, 0, len(found))
|
||||
for _, model := range found {
|
||||
variants = append(variants, mappers.VariantToDomain(model))
|
||||
}
|
||||
return variants, nil
|
||||
}
|
||||
|
||||
func (v *VariantRepository) Get(name string) (domain.Variant, error) {
|
||||
var model entity.ImageVariant
|
||||
if err := v.db.Where("name = ?", name).First(&model).Error; err != nil {
|
||||
return domain.Variant{}, mapNotFoundErr(err, domain.ErrVariantNotFound)
|
||||
}
|
||||
return mappers.VariantToDomain(model), nil
|
||||
}
|
||||
|
||||
func (v *VariantRepository) Create(variant domain.Variant) (domain.Variant, error) {
|
||||
model := mappers.VariantToModel(variant)
|
||||
result := v.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model)
|
||||
if result.Error != nil {
|
||||
return domain.Variant{}, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return domain.Variant{}, domain.ErrVariantExists
|
||||
}
|
||||
return mappers.VariantToDomain(model), nil
|
||||
}
|
||||
|
||||
func (v *VariantRepository) Update(variant domain.Variant) (domain.Variant, error) {
|
||||
model := mappers.VariantToModel(variant)
|
||||
result := v.db.Model(&entity.ImageVariant{}).Where("name = ?", model.Name).Select("*").Omit("name", "created_at").UpdateColumns(&model)
|
||||
if err := deleteOrNotFound(result, domain.ErrVariantNotFound); err != nil {
|
||||
return domain.Variant{}, err
|
||||
}
|
||||
return v.Get(variant.Name)
|
||||
}
|
||||
|
||||
func (v *VariantRepository) Delete(name string) error {
|
||||
return deleteOrNotFound(v.db.Where("name = ?", name).Delete(&entity.ImageVariant{}), domain.ErrVariantNotFound)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateVariantCommand struct {
|
||||
Name string
|
||||
VariantDefinitionCommand
|
||||
GenerateOnUpload *bool
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
type CreateVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
renderer image.VariantRenderer
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewCreateVariantUseCase(variants image.VariantRepository, renderer image.VariantRenderer, now func() time.Time, recorder audit.Recorder) *CreateVariantUseCase {
|
||||
return &CreateVariantUseCase{variants, renderer, now, recorder}
|
||||
}
|
||||
|
||||
func (u *CreateVariantUseCase) EncodableFormats() []image.VariantFormat {
|
||||
return u.renderer.EncodableFormats()
|
||||
}
|
||||
|
||||
func (u *CreateVariantUseCase) Create(ctx context.Context, command CreateVariantCommand) (VariantSummary, error) {
|
||||
variant, err := u.build(command)
|
||||
if err != nil {
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantCreate, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(variant.Name)
|
||||
created, err := u.variants.Create(variant)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
event.Params = audit.VariantParams(created.Settings())
|
||||
u.recorder.Record(ctx, event)
|
||||
return VariantSummary{Variant: created}, nil
|
||||
}
|
||||
|
||||
func (u *CreateVariantUseCase) build(command CreateVariantCommand) (image.Variant, error) {
|
||||
settings, err := command.settings(u.renderer.EncodableFormats())
|
||||
if err != nil {
|
||||
return image.Variant{}, err
|
||||
}
|
||||
generateOnUpload := command.GenerateOnUpload != nil && *command.GenerateOnUpload
|
||||
enabled := command.Enabled == nil || *command.Enabled
|
||||
return image.NewVariant(command.Name, settings, generateOnUpload, enabled, u.now())
|
||||
}
|
||||
@@ -12,8 +12,8 @@ type DeleteMediaUseCase struct {
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}, recorder}
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
|
||||
}
|
||||
|
||||
func (u *DeleteMediaUseCase) Delete(ctx context.Context, id uint32) error {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
type DeleteVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
cache variantCache
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewDeleteVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *DeleteVariantUseCase {
|
||||
return &DeleteVariantUseCase{variants, variantCache{files, storage}, recorder}
|
||||
}
|
||||
|
||||
func (u *DeleteVariantUseCase) Delete(ctx context.Context, name string) error {
|
||||
if _, err := u.variants.Get(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantDelete, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(name)
|
||||
purged, err := u.cache.purgeVariant(name)
|
||||
if err == nil {
|
||||
err = u.variants.Delete(name)
|
||||
}
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
event.Params = variantPurgeParams(purged)
|
||||
u.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func variantPurgeParams(purged VariantPurgeResult) map[string]any {
|
||||
return map[string]any{audit.ParamPurgedFiles: purged.Files, audit.ParamPurgedBytes: purged.Bytes}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
|
||||
type ListVariantsUseCase struct {
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
}
|
||||
|
||||
func NewListVariantsUseCase(variants image.VariantRepository, files image.VariantFileRepository) *ListVariantsUseCase {
|
||||
return &ListVariantsUseCase{variants, files}
|
||||
}
|
||||
|
||||
func (u *ListVariantsUseCase) List() ([]VariantSummary, error) {
|
||||
variants, err := u.variants.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totals, err := u.files.Totals()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries := make([]VariantSummary, 0, len(variants))
|
||||
for _, variant := range variants {
|
||||
summaries = append(summaries, summarize(variant, totals))
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
@@ -24,8 +24,8 @@ type MergeMediaUseCase struct {
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, recorder audit.Recorder) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}, recorder}
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository, articleRepository, newMediaRemover(imageRepository, storage, variantFiles), recorder}
|
||||
}
|
||||
|
||||
type bodyRewrite struct {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
)
|
||||
|
||||
type PurgeVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
cache variantCache
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewPurgeVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, recorder audit.Recorder) *PurgeVariantUseCase {
|
||||
return &PurgeVariantUseCase{variants, variantCache{files, storage}, recorder}
|
||||
}
|
||||
|
||||
func (u *PurgeVariantUseCase) Purge(ctx context.Context, name string) (VariantPurgeResult, error) {
|
||||
if _, err := u.variants.Get(name); err != nil {
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantPurge, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(name)
|
||||
purged, err := u.cache.purgeVariant(name)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
if purged.Files > 0 {
|
||||
event.Params = variantPurgeParams(purged)
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
return purged, nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
svgExtension = ".svg"
|
||||
gifExtension = ".gif"
|
||||
flightKeySep = "|"
|
||||
)
|
||||
|
||||
type ServeVariantLimits struct {
|
||||
MaxSourceBytes int64
|
||||
MaxSourcePixels int64
|
||||
MaxConcurrentRenders int
|
||||
}
|
||||
|
||||
type ServeVariantRequest struct {
|
||||
Context context.Context
|
||||
StoredName string
|
||||
Variant *string
|
||||
Parameters []string
|
||||
IfNoneMatch string
|
||||
}
|
||||
|
||||
type ServedVariant struct {
|
||||
Original bool
|
||||
NotModified bool
|
||||
ETag string
|
||||
ContentType string
|
||||
Content io.ReadCloser
|
||||
Size int64
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
type ServeVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
images image.Repository
|
||||
storage image.Storage
|
||||
renderer image.VariantRenderer
|
||||
maxSourceBytes int64
|
||||
maxSourcePixels int64
|
||||
renderSlots chan struct{}
|
||||
flights singleflight.Group
|
||||
}
|
||||
|
||||
func NewServeVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, images image.Repository, storage image.Storage, renderer image.VariantRenderer, limits ServeVariantLimits) *ServeVariantUseCase {
|
||||
return &ServeVariantUseCase{
|
||||
variants: variants,
|
||||
files: files,
|
||||
images: images,
|
||||
storage: storage,
|
||||
renderer: renderer,
|
||||
maxSourceBytes: limits.MaxSourceBytes,
|
||||
maxSourcePixels: limits.MaxSourcePixels,
|
||||
renderSlots: make(chan struct{}, max(1, limits.MaxConcurrentRenders)),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) Serve(request ServeVariantRequest) (ServedVariant, error) {
|
||||
if parameter := unsupportedParameter(request.Parameters); parameter != "" {
|
||||
return ServedVariant{}, image.NewUnsupportedParameterError(parameter)
|
||||
}
|
||||
if request.Variant == nil {
|
||||
return ServedVariant{Original: true}, nil
|
||||
}
|
||||
variant, err := u.enabledVariant(*request.Variant)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
return u.serveSource(variant, request)
|
||||
}
|
||||
|
||||
func unsupportedParameter(parameters []string) string {
|
||||
for _, unsupported := range image.UnsupportedQueryParameters() {
|
||||
if slices.Contains(parameters, unsupported) {
|
||||
return unsupported
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) enabledVariant(name string) (image.Variant, error) {
|
||||
if !image.IsValidVariantName(name) {
|
||||
return image.Variant{}, image.NewUnknownVariantError(name)
|
||||
}
|
||||
variant, err := u.variants.Get(name)
|
||||
if errors.Is(err, image.ErrVariantNotFound) {
|
||||
return image.Variant{}, image.NewUnknownVariantError(name)
|
||||
}
|
||||
if err != nil {
|
||||
return image.Variant{}, err
|
||||
}
|
||||
if !variant.Enabled || variant.Name != name {
|
||||
return image.Variant{}, image.NewUnknownVariantError(name)
|
||||
}
|
||||
return variant, nil
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) serveSource(variant image.Variant, request ServeVariantRequest) (ServedVariant, error) {
|
||||
known, err := u.images.ExistsByStoredName(request.StoredName)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
if !known {
|
||||
return ServedVariant{}, image.ErrImageNotFound
|
||||
}
|
||||
source, err := u.storage.Open(request.StoredName)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
defer source.Content.Close()
|
||||
|
||||
if servedAsIs(request.StoredName, source.Content) {
|
||||
return ServedVariant{Original: true}, nil
|
||||
}
|
||||
if source.Size > u.maxSourceBytes {
|
||||
return ServedVariant{}, image.ErrSourceTooLarge
|
||||
}
|
||||
|
||||
settings := variant.Settings()
|
||||
etag := image.VariantETag(settings, request.StoredName)
|
||||
if image.ETagMatches(request.IfNoneMatch, etag) {
|
||||
return ServedVariant{NotModified: true, ETag: etag}, nil
|
||||
}
|
||||
fingerprint := settings.Fingerprint()
|
||||
cached, previous, hit, err := u.lookupCached(variant.Name, request.StoredName, fingerprint, etag)
|
||||
if err != nil || hit {
|
||||
return cached, err
|
||||
}
|
||||
file, err := u.generated(requestContext(request), variant, request.StoredName, fingerprint, previous)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
return u.serveFile(file, etag)
|
||||
}
|
||||
|
||||
func requestContext(request ServeVariantRequest) context.Context {
|
||||
if request.Context == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return request.Context
|
||||
}
|
||||
|
||||
func servedAsIs(storedName string, content io.Reader) bool {
|
||||
switch strings.ToLower(path.Ext(storedName)) {
|
||||
case svgExtension:
|
||||
return true
|
||||
case gifExtension:
|
||||
animated, err := image.IsAnimatedGIF(content)
|
||||
return err == nil && animated
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) lookupCached(variantName string, storedName string, fingerprint string, etag string) (ServedVariant, image.VariantFile, bool, error) {
|
||||
existing, found, err := u.files.Find(variantName, storedName)
|
||||
if err != nil {
|
||||
return ServedVariant{}, image.VariantFile{}, false, err
|
||||
}
|
||||
if !found || existing.Fingerprint != fingerprint {
|
||||
return ServedVariant{}, existing, false, nil
|
||||
}
|
||||
served, err := u.serveFile(existing, etag)
|
||||
if errors.Is(err, image.ErrBlobNotFound) {
|
||||
return ServedVariant{}, existing, false, nil
|
||||
}
|
||||
return served, existing, err == nil, err
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) generated(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
|
||||
key := strings.Join([]string{variant.Name, fingerprint, storedName}, flightKeySep)
|
||||
for {
|
||||
file, err, _ := u.flights.Do(key, func() (any, error) {
|
||||
return u.generate(ctx, variant, storedName, fingerprint, previous)
|
||||
})
|
||||
if err == nil {
|
||||
return file.(image.VariantFile), nil
|
||||
}
|
||||
if ctx.Err() != nil || !errors.Is(err, context.Canceled) {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) generate(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
|
||||
current, found, err := u.files.Find(variant.Name, storedName)
|
||||
if err != nil {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
if found && current.Fingerprint == fingerprint && current.CachedName != previous.CachedName {
|
||||
return current, nil
|
||||
}
|
||||
|
||||
rendered, err := u.render(ctx, variant.Settings(), storedName)
|
||||
if err != nil {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
cachedName, err := u.storage.Put(rendered.ContentType, bytes.NewReader(rendered.Content))
|
||||
if err != nil {
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
saved, err := u.files.Save(image.VariantFile{
|
||||
VariantName: variant.Name,
|
||||
SourceName: storedName,
|
||||
CachedName: cachedName,
|
||||
ContentType: rendered.ContentType,
|
||||
Size: int64(len(rendered.Content)),
|
||||
Fingerprint: fingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
_ = u.storage.Delete(cachedName)
|
||||
return image.VariantFile{}, err
|
||||
}
|
||||
u.discardReplaced(previous, cachedName)
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) discardReplaced(previous image.VariantFile, replacementName string) {
|
||||
if previous.CachedName != "" && previous.CachedName != replacementName {
|
||||
_ = u.storage.Delete(previous.CachedName)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) render(ctx context.Context, settings image.VariantSettings, storedName string) (image.RenderedVariant, error) {
|
||||
if err := u.acquireRenderSlot(ctx); err != nil {
|
||||
return image.RenderedVariant{}, err
|
||||
}
|
||||
defer u.releaseRenderSlot()
|
||||
|
||||
source, err := u.storage.Open(storedName)
|
||||
if err != nil {
|
||||
return image.RenderedVariant{}, err
|
||||
}
|
||||
defer source.Content.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(source.Content, u.maxSourceBytes+1))
|
||||
if err != nil {
|
||||
return image.RenderedVariant{}, err
|
||||
}
|
||||
if int64(len(data)) > u.maxSourceBytes {
|
||||
return image.RenderedVariant{}, image.ErrSourceTooLarge
|
||||
}
|
||||
if err := u.checkDeclaredPixels(data); err != nil {
|
||||
return image.RenderedVariant{}, err
|
||||
}
|
||||
return u.renderer.Render(bytes.NewReader(data), settings)
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) checkDeclaredPixels(data []byte) error {
|
||||
width, height, err := u.renderer.DeclaredSize(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(width)*int64(height) > u.maxSourcePixels {
|
||||
return image.ErrSourceTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) acquireRenderSlot(ctx context.Context) error {
|
||||
select {
|
||||
case u.renderSlots <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) releaseRenderSlot() {
|
||||
<-u.renderSlots
|
||||
}
|
||||
|
||||
func (u *ServeVariantUseCase) serveFile(file image.VariantFile, etag string) (ServedVariant, error) {
|
||||
blob, err := u.storage.Open(file.CachedName)
|
||||
if err != nil {
|
||||
return ServedVariant{}, err
|
||||
}
|
||||
return ServedVariant{ETag: etag, ContentType: file.ContentType, Content: blob.Content, Size: blob.Size, ModTime: blob.ModTime}, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SetVariantEnabledUseCase struct {
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewSetVariantEnabledUseCase(variants image.VariantRepository, files image.VariantFileRepository, now func() time.Time, recorder audit.Recorder) *SetVariantEnabledUseCase {
|
||||
return &SetVariantEnabledUseCase{variants, files, now, recorder}
|
||||
}
|
||||
|
||||
func (u *SetVariantEnabledUseCase) SetEnabled(ctx context.Context, name string, enabled bool) (VariantSummary, error) {
|
||||
current, err := u.variants.Get(name)
|
||||
if err != nil {
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
if current.Enabled == enabled {
|
||||
return summaryOf(current, u.files)
|
||||
}
|
||||
|
||||
next := current
|
||||
next.Enabled = enabled
|
||||
next.UpdatedAt = u.now()
|
||||
updated, err := u.variants.Update(next)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, enabledEvent(next), err)
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
u.recorder.Record(ctx, enabledEvent(updated))
|
||||
return summaryOf(updated, u.files)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdateVariantCommand struct {
|
||||
Name string
|
||||
VariantDefinitionCommand
|
||||
GenerateOnUpload *bool
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
type UpdateVariantResult struct {
|
||||
VariantSummary
|
||||
PurgedFiles int
|
||||
}
|
||||
|
||||
type UpdateVariantUseCase struct {
|
||||
variants image.VariantRepository
|
||||
files image.VariantFileRepository
|
||||
cache variantCache
|
||||
renderer image.VariantRenderer
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewUpdateVariantUseCase(variants image.VariantRepository, files image.VariantFileRepository, storage image.Storage, renderer image.VariantRenderer, now func() time.Time, recorder audit.Recorder) *UpdateVariantUseCase {
|
||||
return &UpdateVariantUseCase{variants, files, variantCache{files, storage}, renderer, now, recorder}
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) EncodableFormats() []image.VariantFormat {
|
||||
return u.renderer.EncodableFormats()
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) Update(ctx context.Context, command UpdateVariantCommand) (UpdateVariantResult, error) {
|
||||
current, err := u.variants.Get(command.Name)
|
||||
if err != nil {
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
settings, err := command.settings(u.renderer.EncodableFormats())
|
||||
if err != nil {
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
|
||||
next := withSwitches(current.WithSettings(settings), command)
|
||||
renderingChanged := !settings.IsSameAs(current.Settings())
|
||||
if !renderingChanged && next.Enabled == current.Enabled && next.GenerateOnUpload == current.GenerateOnUpload {
|
||||
return u.result(current, 0)
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionVariantUpdate, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(current.Name)
|
||||
purged, err := u.purgeIfRendering(renderingChanged, current.Name)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
next.UpdatedAt = u.now()
|
||||
updated, err := u.variants.Update(next)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
u.record(ctx, event, current, updated, renderingChanged, purged)
|
||||
return u.result(updated, purged)
|
||||
}
|
||||
|
||||
func withSwitches(variant image.Variant, command UpdateVariantCommand) image.Variant {
|
||||
if command.GenerateOnUpload != nil {
|
||||
variant.GenerateOnUpload = *command.GenerateOnUpload
|
||||
}
|
||||
if command.Enabled != nil {
|
||||
variant.Enabled = *command.Enabled
|
||||
}
|
||||
return variant
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) purgeIfRendering(renderingChanged bool, name string) (int, error) {
|
||||
if !renderingChanged {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := u.cache.purgeVariant(name)
|
||||
return result.Files, err
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) record(ctx context.Context, event audit.Event, before image.Variant, after image.Variant, renderingChanged bool, purged int) {
|
||||
if renderingChanged {
|
||||
event.Changes = audit.VariantChanges(before.Settings(), after.Settings())
|
||||
event.Params = map[string]any{audit.ParamPurgedFiles: purged}
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
if before.Enabled != after.Enabled {
|
||||
u.recorder.Record(ctx, enabledEvent(after))
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UpdateVariantUseCase) result(variant image.Variant, purged int) (UpdateVariantResult, error) {
|
||||
summary, err := summaryOf(variant, u.files)
|
||||
if err != nil {
|
||||
return UpdateVariantResult{}, err
|
||||
}
|
||||
return UpdateVariantResult{summary, purged}, nil
|
||||
}
|
||||
|
||||
func enabledEvent(variant image.Variant) audit.Event {
|
||||
action := audit.ActionVariantDisable
|
||||
if variant.Enabled {
|
||||
action = audit.ActionVariantEnable
|
||||
}
|
||||
event := audit.NewEvent(action, audit.CategoryMedia)
|
||||
event.Target = audit.VariantTarget(variant.Name)
|
||||
return event
|
||||
}
|
||||
@@ -29,6 +29,7 @@ var failureReasons = []failureReason{
|
||||
{image.ErrInvalidMergeRequest, "The merge request is not valid."},
|
||||
{image.ErrMergeCopyMissing, "One of the copies no longer exists."},
|
||||
{image.ErrMergeHashMismatch, "The images are not identical copies."},
|
||||
{image.ErrVariantExists, "A variant with that name already exists."},
|
||||
{purge.ErrPurgeConflict, "The library changed since the preview. Review it again."},
|
||||
{purge.ErrPurgeBusy, "A purge is already running."},
|
||||
{user.ErrRegistrationClosed, "Registration is closed on this site."},
|
||||
@@ -65,6 +66,7 @@ var downloadFailureReasons = map[string]string{
|
||||
var notFoundCauses = []error{
|
||||
article.ErrArticleNotFound,
|
||||
image.ErrImageNotFound,
|
||||
image.ErrVariantNotFound,
|
||||
invite.ErrInviteNotFound,
|
||||
user.ErrUserNotFound,
|
||||
}
|
||||
|
||||
@@ -5,10 +5,19 @@ import "RenewCMS/internal/domain/image"
|
||||
type mediaRemover struct {
|
||||
imageRepository image.Repository
|
||||
storage image.Storage
|
||||
generated variantCache
|
||||
}
|
||||
|
||||
func newMediaRemover(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository) mediaRemover {
|
||||
return mediaRemover{imageRepository, storage, variantCache{variantFiles, storage}}
|
||||
}
|
||||
|
||||
func (r mediaRemover) remove(img image.Image) error {
|
||||
if err := r.storage.Delete(image.StoredName(img.Path)); err != nil {
|
||||
storedName := image.StoredName(img.Path)
|
||||
if err := r.generated.purgeSource(storedName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.storage.Delete(storedName); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.imageRepository.Delete(img.ID)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
|
||||
type VariantPurgeResult struct {
|
||||
Files int
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
type variantCache struct {
|
||||
files image.VariantFileRepository
|
||||
storage image.Storage
|
||||
}
|
||||
|
||||
func (c variantCache) purgeVariant(name string) (VariantPurgeResult, error) {
|
||||
rows, err := c.files.ListByVariant(name)
|
||||
if err != nil {
|
||||
return VariantPurgeResult{}, err
|
||||
}
|
||||
return c.purge(rows)
|
||||
}
|
||||
|
||||
func (c variantCache) purgeSource(storedName string) error {
|
||||
rows, err := c.files.ListBySource(storedName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.purge(rows)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c variantCache) purge(rows []image.VariantFile) (VariantPurgeResult, error) {
|
||||
var result VariantPurgeResult
|
||||
var failure error
|
||||
for _, row := range rows {
|
||||
if err := c.purgeRow(row); err != nil {
|
||||
failure = err
|
||||
continue
|
||||
}
|
||||
result.Files++
|
||||
result.Bytes += row.Size
|
||||
}
|
||||
return result, failure
|
||||
}
|
||||
|
||||
func (c variantCache) purgeRow(row image.VariantFile) error {
|
||||
if err := c.storage.Delete(row.CachedName); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.files.Delete(row.ID)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/image"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type VariantDefinitionCommand struct {
|
||||
Width *int
|
||||
Height *int
|
||||
Fit string
|
||||
Format string
|
||||
Quality *int
|
||||
}
|
||||
|
||||
func (c VariantDefinitionCommand) settings(encodable []image.VariantFormat) (image.VariantSettings, error) {
|
||||
settings, err := image.VariantSettings{
|
||||
Width: c.Width,
|
||||
Height: c.Height,
|
||||
Fit: image.VariantFit(c.Fit),
|
||||
Format: image.VariantFormat(c.Format),
|
||||
Quality: c.Quality,
|
||||
}.Normalized()
|
||||
if err != nil {
|
||||
return image.VariantSettings{}, err
|
||||
}
|
||||
if !slices.Contains(encodable, settings.Format) {
|
||||
return image.VariantSettings{}, image.ErrVariantFormatUnavailable
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/image"
|
||||
|
||||
type VariantSummary struct {
|
||||
Variant image.Variant
|
||||
GeneratedFiles int
|
||||
CacheSize int64
|
||||
}
|
||||
|
||||
func summarize(variant image.Variant, totals map[string]image.VariantFileTotals) VariantSummary {
|
||||
own := totals[variant.Name]
|
||||
return VariantSummary{Variant: variant, GeneratedFiles: own.Files, CacheSize: own.Bytes}
|
||||
}
|
||||
|
||||
func summaryOf(variant image.Variant, files image.VariantFileRepository) (VariantSummary, error) {
|
||||
totals, err := files.Totals()
|
||||
if err != nil {
|
||||
return VariantSummary{}, err
|
||||
}
|
||||
return summarize(variant, totals), nil
|
||||
}
|
||||
Reference in new issue
Block a user