package platform import ( "bytes" "container/list" "context" "crypto/sha256" "encoding/binary" "errors" "fmt" "image" "image/color" "image/jpeg" "image/png" "sync" "golang.org/x/image/draw" ) type assetPreviewResult struct { data []byte mime string } type assetPreviewEntry struct { key string value assetPreviewResult } var previewMemory = struct { sync.Mutex entries map[string]*list.Element order *list.List bytes int }{entries: make(map[string]*list.Element), order: list.New()} var previewSlots = make(chan struct{}, 1) func previewWidth(variant, width string) (int, error) { if variant == "" && width == "" { return 0, nil } if variant == "preview" && (width == "" || width == "1024") { return 1024, nil } return 0, errors.New("invalid_asset_variant") } // Cache only immutable bytes, after the caller checks current asset permission. // Bound both decoded concurrency and retained encoded data. func cachedAssetPreview(ctx context.Context, encoded []byte, mime string, width int) (assetPreviewResult, error) { key := fmt.Sprintf("%x:%d", sha256.Sum256(encoded), width) previewMemory.Lock() if entry := previewMemory.entries[key]; entry != nil { previewMemory.order.MoveToFront(entry) result := entry.Value.(assetPreviewEntry).value previewMemory.Unlock() return result, nil } previewMemory.Unlock() select { case previewSlots <- struct{}{}: case <-ctx.Done(): return assetPreviewResult{}, ctx.Err() } defer func() { <-previewSlots }() // Another request may have finished this same preview while we waited. previewMemory.Lock() if entry := previewMemory.entries[key]; entry != nil { result := entry.Value.(assetPreviewEntry).value previewMemory.order.MoveToFront(entry) previewMemory.Unlock() return result, nil } previewMemory.Unlock() result, err := makeAssetPreview(encoded, mime, width) if err != nil { return result, err } if len(result.data) > 8<<20 { return result, nil } previewMemory.Lock() defer previewMemory.Unlock() for previewMemory.bytes+len(result.data) > 32<<20 || previewMemory.order.Len() >= 128 { tail := previewMemory.order.Back() if tail == nil { break } old := tail.Value.(assetPreviewEntry) previewMemory.bytes -= len(old.value.data) delete(previewMemory.entries, old.key) previewMemory.order.Remove(tail) } previewMemory.entries[key] = previewMemory.order.PushFront(assetPreviewEntry{key, result}) previewMemory.bytes += len(result.data) return result, nil } func makeAssetPreview(encoded []byte, mime string, width int) (assetPreviewResult, error) { original := assetPreviewResult{encoded, mime} // Keep animation intact. Original bytes remain the canonical AI/download input. if mime == "image/gif" { return original, nil } config, _, err := image.DecodeConfig(bytes.NewReader(encoded)) if err != nil { return original, err } if config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > assetPixelLimit { return original, errors.New("invalid_image_dimensions") } if config.Width <= width && config.Height <= width && len(encoded) <= 64<<10 { return original, nil } source, _, err := image.Decode(bytes.NewReader(encoded)) if err != nil { return original, err } orientation := jpegOrientation(encoded) if orientation != 1 { source = orientedAsset{source, orientation} } bounds := source.Bounds() w, h := bounds.Dx(), bounds.Dy() if w > width || h > width { if w >= h { h = max(1, h*width/w) w = width } else { w = max(1, w*width/h) h = width } } resized := image.NewNRGBA(image.Rect(0, 0, w, h)) draw.CatmullRom.Scale(resized, resized.Bounds(), source, source.Bounds(), draw.Src, nil) var output bytes.Buffer outputMime := "image/png" if resized.Opaque() { err = jpeg.Encode(&output, resized, &jpeg.Options{Quality: 82}) outputMime = "image/jpeg" } else { err = png.Encode(&output, resized) } if err != nil { return original, err } if output.Len() >= len(encoded) && orientation == 1 { return original, nil } return assetPreviewResult{output.Bytes(), outputMime}, nil } // JPEG EXIF orientation is applied to previews instead of silently rotating a // phone photograph after its EXIF metadata is removed by the image encoder. func jpegOrientation(data []byte) int { if len(data) < 4 || data[0] != 0xff || data[1] != 0xd8 { return 1 } for at := 2; at+4 <= len(data); { if data[at] != 0xff { return 1 } marker := data[at+1] if marker == 0xda || marker == 0xd9 { return 1 } length := int(binary.BigEndian.Uint16(data[at+2 : at+4])) if length < 2 || at+2+length > len(data) { return 1 } part := data[at+4 : at+2+length] if marker == 0xe1 && len(part) >= 14 && string(part[:6]) == "Exif\x00\x00" { tiff := part[6:] var order binary.ByteOrder switch string(tiff[:2]) { case "II": order = binary.LittleEndian case "MM": order = binary.BigEndian default: return 1 } if order.Uint16(tiff[2:4]) != 42 { return 1 } offset := int(order.Uint32(tiff[4:8])) if offset < 8 || offset+2 > len(tiff) { return 1 } count := int(order.Uint16(tiff[offset : offset+2])) for i := 0; i < count; i++ { entry := offset + 2 + i*12 if entry+12 > len(tiff) { return 1 } if order.Uint16(tiff[entry:entry+2]) == 0x112 && order.Uint16(tiff[entry+2:entry+4]) == 3 && order.Uint32(tiff[entry+4:entry+8]) == 1 { value := int(order.Uint16(tiff[entry+8 : entry+10])) if value >= 1 && value <= 8 { return value } return 1 } } } at += 2 + length } return 1 } type orientedAsset struct { image.Image orientation int } func (im orientedAsset) Bounds() image.Rectangle { b := im.Image.Bounds() if im.orientation >= 5 { return image.Rect(0, 0, b.Dy(), b.Dx()) } return image.Rect(0, 0, b.Dx(), b.Dy()) } func (im orientedAsset) ColorModel() color.Model { return im.Image.ColorModel() } func (im orientedAsset) At(x, y int) color.Color { b := im.Image.Bounds() w, h := b.Dx(), b.Dy() switch im.orientation { case 2: x = w - 1 - x case 3: x, y = w-1-x, h-1-y case 4: y = h - 1 - y case 5: x, y = y, x case 6: x, y = y, h-1-x case 7: x, y = w-1-y, h-1-x case 8: x, y = w-1-y, x } return im.Image.At(b.Min.X+x, b.Min.Y+y) }