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 }