Files
summercms/modules/tide/normalize.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

220 lines
6.4 KiB
Go

package tide
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var carbonOffsetRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$`)
const (
maskDatetime = "<datetime>"
maskID = "<id>"
)
// 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
}
val, err := decodeJSON(raw)
if err != nil {
return raw, nil
}
var diffs []Diff
masked := maskValue("$", val, step, opts, &diffs)
out, err := json.Marshal(masked)
if err != nil {
return raw, diffs
}
return out, diffs
}
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, 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, opts, diffs)
}
return out
default:
return maskLeaf(path, val, step, opts, diffs)
}
}
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
}
if _, ok := val.(string); !ok {
*diffs = append(*diffs, Diff{Path: path, Expected: "string " + key, Actual: formatValue(val)})
return val
}
return maskID
}
if strings.HasSuffix(key, "_issued_at") {
return maskIDValue(path, val, diffs)
}
if isDateKey(key) {
return maskDate(path, val, diffs)
}
if isIDKey(key) {
return maskIDValue(path, val, diffs)
}
return val
}
func maskDate(path string, val any, diffs *[]Diff) any {
if val == nil {
return nil
}
s, ok := val.(string)
if !ok {
*diffs = append(*diffs, Diff{Path: path, Expected: "Carbon +00:00 string or null", Actual: formatValue(val)})
return val
}
if !carbonOffsetRe.MatchString(s) {
*diffs = append(*diffs, Diff{Path: path, Expected: "Carbon +00:00", Actual: strconvQuote(s)})
return val
}
return maskDatetime
}
func maskIDValue(path string, val any, diffs *[]Diff) any {
if val == nil {
return nil
}
n, ok := val.(json.Number)
if !ok {
*diffs = append(*diffs, Diff{Path: path, Expected: "integer id", Actual: formatValue(val)})
return val
}
if strings.Contains(string(n), ".") {
*diffs = append(*diffs, Diff{Path: path, Expected: "integer id", Actual: "number " + string(n)})
return val
}
return maskID
}
func isDateKey(key string) bool {
return strings.HasSuffix(key, "_at") || key == "checkpoint"
}
func isIDKey(key string) bool {
if key == "id" {
return true
}
if strings.HasSuffix(key, "_at") {
return false
}
// "_ids" covers plural raw-integer-array fields such as collection_ids:
// each array element still reaches maskLeaf individually (maskValue
// recurses into []any before calling maskLeaf), so this masks every
// element the same way a singular "_id" scalar would be masked.
return strings.HasSuffix(key, "_id") || strings.HasSuffix(key, "_ids")
}
func lastPathKey(path string) string {
path = strings.TrimPrefix(path, "$.")
if i := strings.LastIndex(path, "."); i >= 0 {
path = path[i+1:]
}
if i := strings.IndexByte(path, '['); i >= 0 {
path = path[:i]
}
return path
}
func disabledPath(step Step, jsonPath, key string) bool {
for _, rule := range step.Normalize {
if !rule.Disable {
continue
}
p := strings.TrimSpace(rule.Path)
if p == jsonPath || p == key || strings.TrimPrefix(p, "$.") == strings.TrimPrefix(jsonPath, "$.") {
return true
}
}
return false
}
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
}