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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user