feat(12-01): record multipart uploads and match Winter upload URLs

- attach.PublicURL and (*File).URL build Winter File::getPath() URLs; the
  thumbnailer decodes webp via golang.org/x/image v0.46.0 and checks the
  image size from the header before decoding
- tide requests carry multipart parts (files beside the fixture pinned by
  sha256) encoded with the fixed MultipartBoundary, so PHP and Go receive
  byte-identical bodies
- tide masks the random partition, disk name and file id of url/thumb_url
  upload URLs while still diffing prefix, size, mode and extension, and
  NormalizePublications masks Carbon dates in the published album
This commit is contained in:
Jakub Zych
2026-10-02 11:33:42 +02:00
parent f9b7f2ea33
commit e06e0cc8bf
19 changed files with 870 additions and 34 deletions

View File

@@ -1,6 +1,7 @@
package attach
import (
"bytes"
"context"
"fmt"
"image"
@@ -14,6 +15,10 @@ import (
"github.com/disintegration/imaging"
"gocloud.dev/blob"
// The webp decoder lets image.DecodeConfig and File.Thumb read .webp
// originals. imaging cannot encode webp, so a webp thumbnail holds JPEG
// bytes under the original's .webp name (see defaultEncodeImage).
_ "golang.org/x/image/webp"
)
const (
@@ -67,7 +72,11 @@ func fileExt(diskName string) string {
return strings.ToLower(ext)
}
func publicURL(key string) string {
// PublicURL returns the public URL of a blob key: storage.uploads.
// public_path_prefix and the key joined by exactly one slash. With the
// WinterCMS layout (bucket rooted at storage/app/uploads/public, prefix
// /storage/app/uploads/public) it is Winter's File::getPath() path.
func PublicURL(key string) string {
prefix := strings.TrimRight(PublicPathPrefix(), "/")
key = strings.TrimLeft(key, "/")
if prefix == "" {
@@ -76,6 +85,15 @@ func publicURL(key string) string {
return prefix + "/" + key
}
// URL returns the public URL of the original file, Winter's File::getPath():
// PublicURL of BlobKey(DiskName).
func (f *File) URL() string {
if f == nil {
return ""
}
return PublicURL(BlobKey(f.DiskName))
}
func defaultResizeImage(src image.Image, w, h int, mode string) image.Image {
switch strings.ToLower(mode) {
case "crop":
@@ -133,21 +151,35 @@ func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode st
return "", fmt.Errorf("attach: thumb exists: %w", err)
}
if exists {
return publicURL(thumbKey), nil
return PublicURL(thumbKey), nil
}
origKey := part + f.DiskName
r, err := bucket.NewReader(ctx, origKey, nil)
if err != nil {
return "", fmt.Errorf("attach: read original: %w", err)
}
src, _, err := image.Decode(io.LimitReader(r, maxThumbSourceBytes))
raw, err := io.ReadAll(io.LimitReader(r, maxThumbSourceBytes))
closeErr := r.Close()
if err != nil {
return "", fmt.Errorf("attach: decode original: %w", err)
return "", fmt.Errorf("attach: read original: %w", err)
}
if closeErr != nil {
return "", closeErr
}
// Check the dimensions from the header before decoding the pixels, so
// a small file that declares a huge image is refused without
// allocating it.
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return "", fmt.Errorf("attach: decode original: %w", err)
}
if int64(cfg.Width)*int64(cfg.Height) > maxThumbSourcePixels {
return "", fmt.Errorf("attach: original image is too large")
}
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return "", fmt.Errorf("attach: decode original: %w", err)
}
bounds := src.Bounds()
if int64(bounds.Dx())*int64(bounds.Dy()) > maxThumbSourcePixels {
return "", fmt.Errorf("attach: original image is too large")
@@ -173,5 +205,5 @@ func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode st
}
return "", closeErr
}
return publicURL(thumbKey), nil
return PublicURL(thumbKey), nil
}