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 = "" maskID = "" ) // 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 /: 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 /thumb______. // (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 + "//" + m[5] } if m := uploadThumbRe.FindStringSubmatch(rest); m != nil { return prefix + "//thumb__" + m[5] } *diffs = append(*diffs, Diff{Path: path, Expected: "upload URL " + prefix + "/xxx/yyy/zzz/", 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 }