- A response body that is not valid UTF-8 (a cover image) is written as a YAML !!binary scalar, never masked and replayed byte for byte - The upload URL normalizer covers every key ending in _url (cover_url), not only url and thumb_url - README and parity-testing docs updated
228 lines
6.7 KiB
Go
228 lines
6.7 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 isUploadURLKey(key) {
|
|
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
|
|
}
|
|
|
|
// isUploadURLKey reports the keys whose string values are compared as
|
|
// uploaded-file URLs: url and every key ending in _url (thumb_url,
|
|
// cover_url). A value outside the uploads prefix that does not look like an
|
|
// upload is compared as it is.
|
|
func isUploadURLKey(key string) bool {
|
|
return key == "url" || strings.HasSuffix(key, "_url")
|
|
}
|
|
|
|
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
|
|
}
|