- 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
309 lines
7.7 KiB
Go
309 lines
7.7 KiB
Go
package tide
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
"github.com/goccy/go-yaml/token"
|
|
)
|
|
|
|
func (b *Body) UnmarshalYAML(data []byte) error {
|
|
if b == nil {
|
|
return fmt.Errorf("tide: nil body")
|
|
}
|
|
var s string
|
|
if err := yaml.Unmarshal(data, &s); err != nil {
|
|
return err
|
|
}
|
|
*b = Body(s)
|
|
return nil
|
|
}
|
|
|
|
// LoadFlow reads and validates a version-1 YAML flow from path. Request
|
|
// part files are checked against their sha256, relative to path's directory.
|
|
func LoadFlow(path string) (Flow, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Flow{}, fmt.Errorf("tide: read %s: %w", path, err)
|
|
}
|
|
flow, err := ParseFlow(raw)
|
|
if err != nil {
|
|
return Flow{}, err
|
|
}
|
|
if err := verifyPartFiles(filepath.Dir(path), flow); err != nil {
|
|
return Flow{}, err
|
|
}
|
|
return flow, nil
|
|
}
|
|
|
|
// ParseFlow decodes a version-1 YAML flow, rejecting unknown fields.
|
|
func ParseFlow(raw []byte) (Flow, error) {
|
|
var flow Flow
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
|
if err := dec.Decode(&flow); err != nil {
|
|
return Flow{}, fmt.Errorf("tide: parse flow: %w", err)
|
|
}
|
|
if err := validateFlow(flow); err != nil {
|
|
return Flow{}, err
|
|
}
|
|
return flow, nil
|
|
}
|
|
|
|
// SaveFlow writes a validated flow atomically, syncing before rename.
|
|
func SaveFlow(path string, flow Flow) error {
|
|
return saveFlow(path, flow, false)
|
|
}
|
|
|
|
// SaveFlowExclusive writes a validated flow and fails if path already exists.
|
|
func SaveFlowExclusive(path string, flow Flow) error {
|
|
return saveFlow(path, flow, true)
|
|
}
|
|
|
|
func saveFlow(path string, flow Flow, exclusive bool) error {
|
|
if err := validateFlow(flow); err != nil {
|
|
return err
|
|
}
|
|
raw, err := marshalFlow(flow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(path)
|
|
if dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("tide: create fixture dir: %w", err)
|
|
}
|
|
}
|
|
if exclusive {
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("tide: create fixture: %w", err)
|
|
}
|
|
ok := false
|
|
defer func() {
|
|
_ = f.Close()
|
|
if !ok {
|
|
_ = os.Remove(path)
|
|
}
|
|
}()
|
|
if _, err := f.Write(raw); err != nil {
|
|
return fmt.Errorf("tide: write fixture: %w", err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
return fmt.Errorf("tide: sync fixture: %w", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return fmt.Errorf("tide: close fixture: %w", err)
|
|
}
|
|
ok = true
|
|
return nil
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".tide-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("tide: create temp fixture: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
ok := false
|
|
defer func() {
|
|
if !ok {
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
}()
|
|
if _, err := tmp.Write(raw); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("tide: write fixture: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("tide: sync fixture: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("tide: close fixture: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
return fmt.Errorf("tide: commit fixture: %w", err)
|
|
}
|
|
ok = true
|
|
return nil
|
|
}
|
|
|
|
func marshalFlow(flow Flow) ([]byte, error) {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "version: %d\n", flow.Version)
|
|
writeKV(&b, 0, "name", flow.Name)
|
|
if flow.Description != "" {
|
|
writeKV(&b, 0, "description", flow.Description)
|
|
}
|
|
if flow.SeedHook != "" {
|
|
writeKV(&b, 0, "seed_hook", flow.SeedHook)
|
|
}
|
|
b.WriteString("steps:\n")
|
|
for _, step := range flow.Steps {
|
|
writeStep(&b, step)
|
|
}
|
|
return []byte(b.String()), nil
|
|
}
|
|
|
|
func writeStep(b *strings.Builder, step Step) {
|
|
fmt.Fprintf(b, " - id: %s\n", encodeScalar(step.ID))
|
|
if step.RouteID != "" {
|
|
writeKV(b, 4, "route_id", step.RouteID)
|
|
}
|
|
b.WriteString(" request:\n")
|
|
writeRequest(b, 6, step.Request)
|
|
b.WriteString(" response:\n")
|
|
writeResponse(b, 6, step.Response)
|
|
writeCapture(b, 4, step.Capture)
|
|
writeNormalize(b, 4, step.Normalize)
|
|
writeHeaders(b, 4, step.Headers)
|
|
}
|
|
|
|
func writeRequest(b *strings.Builder, indent int, req Request) {
|
|
writeKV(b, indent, "method", req.Method)
|
|
writeKV(b, indent, "path", req.Path)
|
|
if req.Query != "" {
|
|
writeKV(b, indent, "query", req.Query)
|
|
}
|
|
writeHeaders(b, indent, req.Headers)
|
|
writeBody(b, indent, string(req.Body))
|
|
writeParts(b, indent, req.Parts)
|
|
}
|
|
|
|
func writeParts(b *strings.Builder, indent int, parts []Part) {
|
|
if len(parts) == 0 {
|
|
return
|
|
}
|
|
pad := strings.Repeat(" ", indent)
|
|
fmt.Fprintf(b, "%sparts:\n", pad)
|
|
inner := strings.Repeat(" ", indent+2)
|
|
for _, p := range parts {
|
|
fmt.Fprintf(b, "%s- name: %s\n", inner, encodeScalar(p.Name))
|
|
for _, kv := range [][2]string{
|
|
{"value", p.Value},
|
|
{"file", p.File},
|
|
{"filename", p.Filename},
|
|
{"content_type", p.ContentType},
|
|
{"sha256", p.SHA256},
|
|
} {
|
|
if kv[1] != "" {
|
|
fmt.Fprintf(b, "%s %s: %s\n", inner, kv[0], encodeScalar(kv[1]))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeResponse(b *strings.Builder, indent int, resp Response) {
|
|
if resp.Status != 0 {
|
|
fmt.Fprintf(b, "%sstatus: %d\n", strings.Repeat(" ", indent), resp.Status)
|
|
}
|
|
writeHeaders(b, indent, resp.Headers)
|
|
writeBody(b, indent, string(resp.Body))
|
|
if resp.BodyFile != "" {
|
|
writeKV(b, indent, "body_file", resp.BodyFile)
|
|
}
|
|
if resp.SHA256 != "" {
|
|
writeKV(b, indent, "sha256", resp.SHA256)
|
|
}
|
|
}
|
|
|
|
func writeCapture(b *strings.Builder, indent int, rules []CaptureRule) {
|
|
if len(rules) == 0 {
|
|
return
|
|
}
|
|
pad := strings.Repeat(" ", indent)
|
|
fmt.Fprintf(b, "%scapture:\n", pad)
|
|
inner := strings.Repeat(" ", indent+2)
|
|
for _, rule := range rules {
|
|
fmt.Fprintf(b, "%s- as: %s\n", inner, encodeScalar(rule.As))
|
|
if rule.From != "" {
|
|
fmt.Fprintf(b, "%s from: %s\n", inner, encodeScalar(rule.From))
|
|
}
|
|
if rule.Path != "" {
|
|
fmt.Fprintf(b, "%s path: %s\n", inner, encodeScalar(rule.Path))
|
|
}
|
|
if rule.Name != "" {
|
|
fmt.Fprintf(b, "%s name: %s\n", inner, encodeScalar(rule.Name))
|
|
}
|
|
if rule.Identity != "" {
|
|
fmt.Fprintf(b, "%s identity: %s\n", inner, encodeScalar(rule.Identity))
|
|
}
|
|
if rule.Category != "" {
|
|
fmt.Fprintf(b, "%s category: %s\n", inner, encodeScalar(rule.Category))
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeNormalize(b *strings.Builder, indent int, rules []NormalizeRule) {
|
|
if len(rules) == 0 {
|
|
return
|
|
}
|
|
pad := strings.Repeat(" ", indent)
|
|
fmt.Fprintf(b, "%snormalize:\n", pad)
|
|
inner := strings.Repeat(" ", indent+2)
|
|
for _, rule := range rules {
|
|
fmt.Fprintf(b, "%s- path: %s\n", inner, encodeScalar(rule.Path))
|
|
if rule.Disable {
|
|
fmt.Fprintf(b, "%s disable: true\n", inner)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeHeaders(b *strings.Builder, indent int, headers map[string]string) {
|
|
if len(headers) == 0 {
|
|
return
|
|
}
|
|
pad := strings.Repeat(" ", indent)
|
|
fmt.Fprintf(b, "%sheaders:\n", pad)
|
|
keys := make([]string, 0, len(headers))
|
|
for k := range headers {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
inner := strings.Repeat(" ", indent+2)
|
|
for _, k := range keys {
|
|
fmt.Fprintf(b, "%s%s: %s\n", inner, k, encodeScalar(headers[k]))
|
|
}
|
|
}
|
|
|
|
func writeBody(b *strings.Builder, indent int, body string) {
|
|
if body == "" {
|
|
return
|
|
}
|
|
pad := strings.Repeat(" ", indent)
|
|
header := token.LiteralBlockHeader(body)
|
|
if header == "" {
|
|
header = "|-"
|
|
}
|
|
fmt.Fprintf(b, "%sbody: %s\n", pad, header)
|
|
inner := strings.Repeat(" ", indent+2)
|
|
content := body
|
|
if strings.HasSuffix(content, "\n") {
|
|
content = content[:len(content)-1]
|
|
}
|
|
for _, line := range strings.Split(content, "\n") {
|
|
b.WriteString(inner)
|
|
b.WriteString(line)
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
|
|
func writeKV(b *strings.Builder, indent int, key, value string) {
|
|
pad := strings.Repeat(" ", indent)
|
|
fmt.Fprintf(b, "%s%s: %s\n", pad, key, encodeScalar(value))
|
|
}
|
|
|
|
func encodeScalar(v string) string {
|
|
if v == "" {
|
|
return `""`
|
|
}
|
|
if token.IsNeedQuoted(v) || strings.ContainsAny(v, " \t") {
|
|
return strconv.Quote(v)
|
|
}
|
|
return v
|
|
}
|