- 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
220 lines
6.4 KiB
Go
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
|
|
}
|