Files
summercms/modules/tide/multipart.go
Jakub Zych e06e0cc8bf 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
2026-10-02 11:33:42 +02:00

202 lines
5.7 KiB
Go

package tide
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"mime"
"mime/multipart"
"net/textproto"
"os"
"path"
"path/filepath"
"regexp"
"strings"
)
// MultipartBoundary is the fixed boundary every multipart request body is
// encoded with, so a recording against one backend and a replay against
// another send byte-identical bodies.
const MultipartBoundary = "SummerTideMultipartBoundary7MA4YWxkTrZu0gW"
// Part is one field of a multipart/form-data request. A text field sets
// Value; a file field sets File, a path relative to the fixture directory
// (for example files/cover.png), whose bytes are never inlined into the
// YAML, with SHA256 pinning its content. Filename defaults to the base name
// of File and ContentType to application/octet-stream.
type Part struct {
Name string `yaml:"name"`
Value string `yaml:"value,omitempty"`
File string `yaml:"file,omitempty"`
Filename string `yaml:"filename,omitempty"`
ContentType string `yaml:"content_type,omitempty"`
SHA256 string `yaml:"sha256,omitempty"`
}
var sha256Hex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// validateParts checks the shape of a request's parts without reading files.
func validateParts(req Request) error {
if len(req.Parts) == 0 {
return nil
}
if req.Body != "" {
return fmt.Errorf("request has both body and parts")
}
for i, p := range req.Parts {
if strings.TrimSpace(p.Name) == "" {
return fmt.Errorf("parts[%d] is missing name", i)
}
if p.File == "" {
if p.Filename != "" || p.ContentType != "" || p.SHA256 != "" {
return fmt.Errorf("part %q has file attributes but no file", p.Name)
}
continue
}
if p.Value != "" {
return fmt.Errorf("part %q has both value and file", p.Name)
}
if err := validateSidecar(p.File); err != nil {
return fmt.Errorf("part %q: %w", p.Name, err)
}
if !sha256Hex.MatchString(p.SHA256) {
return fmt.Errorf("part %q needs the sha256 of its file", p.Name)
}
}
return nil
}
// verifyPartFiles checks that every part file of flow exists under base and
// matches its sha256.
func verifyPartFiles(base string, flow Flow) error {
for _, step := range flow.Steps {
for _, p := range step.Request.Parts {
if p.File == "" {
continue
}
if _, err := readPartFile(base, p); err != nil {
return fmt.Errorf("tide: step %s: %w", step.ID, err)
}
}
}
return nil
}
func readPartFile(base string, p Part) ([]byte, error) {
if err := validateSidecar(p.File); err != nil {
return nil, fmt.Errorf("part %q: %w", p.Name, err)
}
full := p.File
if base != "" {
full = filepath.Join(base, p.File)
}
resolved, err := resolvePath(full)
if err != nil {
return nil, fmt.Errorf("part %q file %s: %w", p.Name, p.File, err)
}
if base != "" {
root, err := resolvePath(base)
if err != nil {
return nil, fmt.Errorf("fixture dir: %w", err)
}
if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
return nil, fmt.Errorf("part %q file %q escapes the fixture directory", p.Name, p.File)
}
}
raw, err := os.ReadFile(resolved)
if err != nil {
return nil, fmt.Errorf("part %q file %s: %w", p.Name, p.File, err)
}
sum := sha256.Sum256(raw)
if !strings.EqualFold(hex.EncodeToString(sum[:]), p.SHA256) {
return nil, fmt.Errorf("part %q file %s sha256 mismatch", p.Name, p.File)
}
return raw, nil
}
var quoteEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`)
// encodeParts writes parts in declaration order as a multipart/form-data
// body with MultipartBoundary and returns the body and its Content-Type.
func encodeParts(base string, parts []Part) ([]byte, string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.SetBoundary(MultipartBoundary); err != nil {
return nil, "", err
}
marker := []byte(MultipartBoundary)
for _, p := range parts {
h := make(textproto.MIMEHeader)
var content []byte
if p.File == "" {
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, quoteEscaper.Replace(p.Name)))
content = []byte(p.Value)
} else {
raw, err := readPartFile(base, p)
if err != nil {
return nil, "", err
}
filename := p.Filename
if filename == "" {
filename = path.Base(filepath.ToSlash(p.File))
}
ct := p.ContentType
if ct == "" {
ct = "application/octet-stream"
}
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, quoteEscaper.Replace(p.Name), quoteEscaper.Replace(filename)))
h.Set("Content-Type", ct)
content = raw
}
if bytes.Contains(content, marker) {
return nil, "", fmt.Errorf("part %q contains the multipart boundary", p.Name)
}
pw, err := w.CreatePart(h)
if err != nil {
return nil, "", err
}
if _, err := pw.Write(content); err != nil {
return nil, "", err
}
}
if err := w.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), w.FormDataContentType(), nil
}
// prepareRequest encodes a request's parts into its body. The multipart
// Content-Type replaces a recorded multipart Content-Type (whose boundary
// would be stale) and is added when none is recorded; a recorded non-
// multipart Content-Type is kept as it is.
func prepareRequest(req Request, base string) (Request, error) {
if len(req.Parts) == 0 {
return req, nil
}
if err := validateParts(req); err != nil {
return Request{}, err
}
body, ct, err := encodeParts(base, req.Parts)
if err != nil {
return Request{}, err
}
out := req
out.Body = Body(body)
out.Headers = make(map[string]string, len(req.Headers)+1)
set := false
for k, v := range req.Headers {
if strings.EqualFold(k, "Content-Type") {
set = true
if media, _, err := mime.ParseMediaType(v); err != nil || strings.HasPrefix(media, "multipart/") {
v = ct
}
}
out.Headers[k] = v
}
if !set {
out.Headers["Content-Type"] = ct
}
return out, nil
}