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

@@ -14,7 +14,25 @@ const (
maskID = "<id>"
)
func normalizeJSON(raw []byte, step Step) ([]byte, []Diff) {
// DefaultUploadPrefix is the WinterCMS public uploads URL prefix
// (cms.storage.uploads.path plus /public) that url and thumb_url values are
// checked against when ReplayConfig.UploadPrefix is empty.
const DefaultUploadPrefix = "/storage/app/uploads/public"
// maskOptions configures the response-body normalizer.
type maskOptions struct {
uploadPrefix string
}
func (o maskOptions) prefix() string {
p := strings.TrimRight(o.uploadPrefix, "/")
if p == "" {
return DefaultUploadPrefix
}
return p
}
func normalizeJSON(raw []byte, step Step, opts maskOptions) ([]byte, []Diff) {
if len(strings.TrimSpace(string(raw))) == 0 {
return raw, nil
}
@@ -23,7 +41,7 @@ func normalizeJSON(raw []byte, step Step) ([]byte, []Diff) {
return raw, nil
}
var diffs []Diff
masked := maskValue("$", val, step, &diffs)
masked := maskValue("$", val, step, opts, &diffs)
out, err := json.Marshal(masked)
if err != nil {
return raw, diffs
@@ -31,30 +49,36 @@ func normalizeJSON(raw []byte, step Step) ([]byte, []Diff) {
return out, diffs
}
func maskValue(path string, val any, step Step, diffs *[]Diff) any {
func maskValue(path string, val any, step Step, opts maskOptions, diffs *[]Diff) any {
switch v := val.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for k, child := range v {
out[k] = maskValue(pathJoin(path, k), child, step, diffs)
out[k] = maskValue(pathJoin(path, k), child, step, opts, diffs)
}
return out
case []any:
out := make([]any, len(v))
for i, child := range v {
out[i] = maskValue(fmt.Sprintf("%s[%d]", path, i), child, step, diffs)
out[i] = maskValue(fmt.Sprintf("%s[%d]", path, i), child, step, opts, diffs)
}
return out
default:
return maskLeaf(path, val, step, diffs)
return maskLeaf(path, val, step, opts, diffs)
}
}
func maskLeaf(path string, val any, step Step, diffs *[]Diff) any {
func maskLeaf(path string, val any, step Step, opts maskOptions, diffs *[]Diff) any {
key := lastPathKey(path)
if key == "slug" || disabledPath(step, path, key) {
return val
}
if key == "url" || key == "thumb_url" {
if s, ok := val.(string); ok {
return maskUploadURL(path, s, opts.prefix(), diffs)
}
return val
}
if key == "collection_key" || key == "client_id" {
if val == nil {
return nil
@@ -154,3 +178,42 @@ func disabledPath(step Step, jsonPath, key string) bool {
func strconvQuote(s string) string {
return `"` + s + `"`
}
var (
// uploadOriginalRe is <partition>/<disk_name>: Winter's partition
// directory (the first nine characters of the disk name in three groups)
// and a hex disk name with its extension.
uploadOriginalRe = regexp.MustCompile(`^/([0-9a-f]{3})/([0-9a-f]{3})/([0-9a-f]{3})/([0-9a-f]{9,})(\.[a-z0-9]+)?$`)
// uploadThumbRe is <partition>/thumb_<id>_<w>_<h>_<ox>_<oy>_<mode>.<ext>
// (Winter getThumbFilename).
uploadThumbRe = regexp.MustCompile(`^/([0-9a-f]{3})/([0-9a-f]{3})/([0-9a-f]{3})/thumb_([0-9]+)_([0-9]+_[0-9]+_-?[0-9]+_-?[0-9]+_[a-z0-9]+\.[a-z0-9]+)$`)
// uploadShapeRe recognises an upload URL under any prefix.
uploadShapeRe = regexp.MustCompile(`/[0-9a-f]{3}/[0-9a-f]{3}/[0-9a-f]{3}/(?:thumb_[0-9]+_[0-9]+_[0-9]+_-?[0-9]+_-?[0-9]+_[a-z0-9]+\.[a-z0-9]+|[0-9a-f]{9,}(?:\.[a-z0-9]+)?)$`)
)
// maskUploadURL masks the random parts of an uploaded file's URL, the
// partition and disk name of an original and the partition and file id of a
// thumbnail, after checking the shape. The prefix, thumbnail size, offsets,
// mode and extension stay visible, so a different size or extension still
// shows as a mismatch. A URL under another prefix that looks like an upload
// is a Diff; any other value is left as it is.
func maskUploadURL(path, s, prefix string, diffs *[]Diff) any {
if rest, ok := strings.CutPrefix(s, prefix); ok && strings.HasPrefix(rest, "/") {
if m := uploadOriginalRe.FindStringSubmatch(rest); m != nil {
if m[1]+m[2]+m[3] != m[4][:9] {
*diffs = append(*diffs, Diff{Path: path, Expected: "partition from the disk name", Actual: strconvQuote(s)})
return s
}
return prefix + "/<partition>/<disk_name>" + m[5]
}
if m := uploadThumbRe.FindStringSubmatch(rest); m != nil {
return prefix + "/<partition>/thumb_<id>_" + m[5]
}
*diffs = append(*diffs, Diff{Path: path, Expected: "upload URL " + prefix + "/xxx/yyy/zzz/<disk_name>", Actual: strconvQuote(s)})
return s
}
if uploadShapeRe.MatchString(s) && !strings.Contains(s, "://") {
*diffs = append(*diffs, Diff{Path: path, Expected: "upload URL under " + prefix, Actual: strconvQuote(s)})
}
return s
}