294 lines
8.3 KiB
Go
294 lines
8.3 KiB
Go
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
|
|
}
|