Files
codex-bot 506f778ac3
Foundry Go checks / check (push) Successful in 3m26s
Foundry Go checks / check (pull_request) Successful in 3m28s
fix(canvas): retain canonical frame rotation across owner changes
2026-11-11 18:53:06 +08:00

451 lines
15 KiB
Go

package platform
import (
"encoding/json"
"errors"
"math"
"reflect"
"sort"
"strings"
)
const canvasModelFormat = 1
const canvasModelProtocol = 2
var errCanvasModel = errors.New("invalid_canvas_model")
var errModelConflict = errors.New("canvas_model_field_conflict")
type modelParent struct {
CanvasID string `json:"canvasId"`
EntryElementID string `json:"entryElementId"`
}
type modelCanvas struct {
ID string `json:"id"`
Parent *modelParent `json:"parent"`
}
type canvasModel struct {
Root string
Canvases map[string]modelCanvas
Elements map[string]map[string]any
Tombstones map[string]int64
TombstoneOwners map[string]string
DeadRecords map[string]map[string]any
Versions map[string]int64
}
type modelView struct {
Format int `json:"documentFormatVersion"`
ProjectID string `json:"projectId"`
Canvases []modelCanvas `json:"canvases"`
Elements []map[string]any `json:"elements"`
Tombstones map[string]int64 `json:"tombstones"`
Versions map[string]int64 `json:"versions"`
Complete bool `json:"complete"`
Removed []string `json:"removedCanvasIds"`
}
type modelTarget struct {
ID string `json:"id"`
Incarnation int64 `json:"incarnation"`
}
type modelChange struct {
Kind string `json:"kind"`
ID string `json:"id,omitempty"`
Target modelTarget `json:"target,omitempty"`
Field string `json:"field,omitempty"`
Before json.RawMessage `json:"before"`
After json.RawMessage `json:"after"`
BeforePresent *bool `json:"beforePresent,omitempty"`
AfterPresent *bool `json:"afterPresent,omitempty"`
}
type modelOperation struct {
ID string `json:"id"`
Changes []modelChange `json:"changes"`
}
type modelBatch struct {
ProtocolVersion int `json:"protocol_version"`
RequestID string `json:"request_id"`
Expected map[string]int64 `json:"expected_versions"`
Operations []modelOperation `json:"operations"`
}
func emptyModel(root string) canvasModel {
return canvasModel{Root: root, Canvases: map[string]modelCanvas{}, Elements: map[string]map[string]any{}, Tombstones: map[string]int64{}, TombstoneOwners: map[string]string{}, DeadRecords: map[string]map[string]any{}, Versions: map[string]int64{}}
}
func modelString(value any) string { text, _ := value.(string); return text }
func modelInt(value any) int64 {
number, ok := value.(float64)
if !ok || number < 1 || number > 9007199254740991 || math.Trunc(number) != number {
return 0
}
return int64(number)
}
func modelJSONSafe(value any, depth int) bool {
if depth > 64 {
return false
}
switch value := value.(type) {
case nil, bool, string:
return true
case float64:
return !math.IsNaN(value) && !math.IsInf(value, 0)
case []any:
for _, item := range value {
if !modelJSONSafe(item, depth+1) {
return false
}
}
return true
case map[string]any:
for _, item := range value {
if !modelJSONSafe(item, depth+1) {
return false
}
}
return true
}
return false
}
func modelEqual(left, right any) bool {
// Round-trip numeric Go structs and JSON records to the same JSON value domain.
a, ea := json.Marshal(left)
b, eb := json.Marshal(right)
if ea != nil || eb != nil {
return false
}
var av, bv any
return json.Unmarshal(a, &av) == nil && json.Unmarshal(b, &bv) == nil && reflect.DeepEqual(av, bv)
}
func (model canvasModel) view(participants map[string]int64) modelView {
view := modelView{Format: canvasModelFormat, ProjectID: model.Root, Canvases: []modelCanvas{}, Elements: []map[string]any{}, Tombstones: map[string]int64{}, Versions: map[string]int64{}, Complete: participants == nil, Removed: []string{}}
for _, id := range sortedKeys(model.Canvases) {
if participants != nil {
if _, exists := participants[id]; !exists {
continue
}
}
view.Canvases = append(view.Canvases, model.Canvases[id])
view.Versions[id] = model.Versions[id]
}
for _, id := range sortedKeys(model.Elements) {
element := model.Elements[id]
if participants != nil {
if _, exists := participants[modelString(element["ownerCanvasId"])]; !exists {
continue
}
}
view.Elements = append(view.Elements, element)
}
for id, incarnation := range model.Tombstones {
owner := model.TombstoneOwners[id]
if live := model.Elements[id]; live != nil {
owner = modelString(live["ownerCanvasId"])
}
if participants != nil {
if _, exists := participants[owner]; !exists {
continue
}
}
view.Tombstones[id] = incarnation
}
for _, id := range sortedKeys(model.Versions) {
if participants != nil {
if _, exists := participants[id]; !exists {
continue
}
}
view.Versions[id] = model.Versions[id]
if _, exists := model.Canvases[id]; !exists {
view.Removed = append(view.Removed, id)
}
}
return view
}
func sortedKeys[T any](values map[string]T) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func cloneModel(model canvasModel) canvasModel {
raw, _ := json.Marshal(model)
var copy canvasModel
_ = json.Unmarshal(raw, &copy)
return copy
}
func modelAncestors(model canvasModel, id string) ([]string, error) {
path, seen := []string{}, map[string]bool{}
for id != "" {
canvas, exists := model.Canvases[id]
if !exists || seen[id] || len(path) > 8 {
return nil, errCanvasModel
}
seen[id] = true
path = append(path, id)
id = ""
if canvas.Parent != nil {
id = canvas.Parent.CanvasID
}
}
return path, nil
}
func modelCommonCanvas(model canvasModel, left, right string) (string, error) {
a, err := modelAncestors(model, left)
if err != nil {
return "", err
}
b, err := modelAncestors(model, right)
if err != nil {
return "", err
}
seen := map[string]bool{}
for _, id := range a {
seen[id] = true
}
for _, id := range b {
if seen[id] {
return id, nil
}
}
return "", errCanvasModel
}
func validateCanvasModel(model canvasModel) error {
if !uuidSlugPattern.MatchString(model.Root) || len(model.Canvases) == 0 || len(model.Canvases) > 256 || len(model.Elements) > 128000 {
return errCanvasModel
}
roots, entries, counts := 0, map[string]bool{}, map[string][2]int{}
for id, canvas := range model.Canvases {
if id != canvas.ID || !uuidSlugPattern.MatchString(id) {
return errCanvasModel
}
if _, err := modelAncestors(model, id); err != nil {
return err
}
if canvas.Parent == nil {
roots++
if id != model.Root {
return errCanvasModel
}
continue
}
entry := model.Elements[canvas.Parent.EntryElementID]
if entry == nil || entry["structure"] != "node" || entry["ownerCanvasId"] != canvas.Parent.CanvasID || entries[canvas.Parent.EntryElementID] {
return errCanvasModel
}
entries[canvas.Parent.EntryElementID] = true
}
if roots != 1 {
return errCanvasModel
}
for id, incarnation := range model.Tombstones {
if !canvasID.MatchString(id) || incarnation < 1 || incarnation > 9007199254740991 {
return errCanvasModel
}
}
for id, element := range model.Elements {
if !modelJSONSafe(element, 0) {
return errCanvasModel
}
owner, component := modelString(element["ownerCanvasId"]), modelString(element["componentId"])
_, canvasExists := model.Canvases[owner]
incarnation, version := modelInt(element["incarnation"]), modelInt(element["dataVersion"])
data, dataOK := element["data"].(map[string]any)
if !canvasID.MatchString(id) || element["id"] != id || !canvasExists || incarnation == 0 || incarnation <= model.Tombstones[id] || version == 0 || component == "" || len(component) > 128 || !dataOK || data == nil {
return errCanvasModel
}
encoded, err := json.Marshal(element)
if err != nil || len(encoded) > 1<<20 {
return errCanvasModel
}
count := counts[owner]
switch element["structure"] {
case "node":
count[0]++
geometry, ok := element["geometry"].(map[string]any)
if !ok || !canvasNumber(geometry["x"], -1e6, 1e6) || !canvasNumber(geometry["y"], -1e6, 1e6) || !canvasNumber(geometry["width"], 0.000001, 1e6) || !canvasNumber(geometry["height"], 0.000001, 1e6) || !canvasNumber(geometry["rotation"], 0, math.Nextafter(360, 0)) {
return errCanvasModel
}
if version == 1 && canvasChoice(component, "text", "image", "script", "plain-text", "plain-image", "icon", "frame") {
// Canonical frames can inherit rotation while changing owners. The
// legacy snapshot writer's zero-angle restriction does not apply here.
if !canvasString(data["title"], 300) || !canvasString(data["text"], 100000) {
return errCanvasModel
}
if ai, exists := data["ai"]; exists && !validCanvasAI(ai, component) {
return errCanvasModel
}
if asset, exists := data["assetId"]; exists && (!canvasChoice(component, "image", "plain-image") || !uuidSlugPattern.MatchString(modelString(asset))) {
return errCanvasModel
}
}
case "connection":
count[1]++
source, sourceOK := element["source"].(map[string]any)
target, targetOK := element["target"].(map[string]any)
if !sourceOK || !targetOK {
return errCanvasModel
}
a, b := model.Elements[modelString(source["elementId"])], model.Elements[modelString(target["elementId"])]
if a == nil || b == nil || a["structure"] != "node" || b["structure"] != "node" {
return errCanvasModel
}
for _, endpoint := range []map[string]any{source, target} {
if port, exists := endpoint["portId"]; exists && (!canvasString(port, 256) || modelString(port) == "") {
return errCanvasModel
}
}
common, err := modelCommonCanvas(model, modelString(a["ownerCanvasId"]), modelString(b["ownerCanvasId"]))
if err != nil || common != owner {
return errCanvasModel
}
default:
return errCanvasModel
}
if count[0] > 500 || count[1] > 2000 {
return errGraphCapacity
}
counts[owner] = count
}
return nil
}
// Pure transaction reducer. Unknown component data/envelope fields are retained.
func applyModelOperations(initial canvasModel, operations []modelOperation) (canvasModel, map[string]bool, error) {
model, touched, seen := cloneModel(initial), map[string]bool{}, map[string]bool{}
if len(operations) == 0 || len(operations) > 2500 {
return model, nil, errCanvasModel
}
changeCount := 0
for _, operation := range operations {
if !uuidSlugPattern.MatchString(operation.ID) || seen[operation.ID] || len(operation.Changes) == 0 {
return model, nil, errCanvasModel
}
seen[operation.ID] = true
for _, change := range operation.Changes {
changeCount++
if changeCount > 10000 {
return model, nil, errGraphCapacity
}
var before, after any
if json.Unmarshal(change.Before, &before) != nil || json.Unmarshal(change.After, &after) != nil {
return model, nil, errCanvasModel
}
switch change.Kind {
case "field":
element := model.Elements[change.Target.ID]
if element == nil || modelInt(element["incarnation"]) != change.Target.Incarnation {
return model, nil, errModelConflict
}
touched[modelString(element["ownerCanvasId"])] = true
if strings.HasPrefix(change.Field, "data.") {
key := strings.TrimPrefix(change.Field, "data.")
if key == "" || canvasChoice(key, "__proto__", "constructor", "prototype") {
return model, nil, errCanvasModel
}
data := element["data"].(map[string]any)
prior, present := data[key]
if present != (change.BeforePresent == nil || *change.BeforePresent) || !modelEqual(prior, before) {
return model, nil, errModelConflict
}
if change.AfterPresent != nil && !*change.AfterPresent {
delete(data, key)
} else {
data[key] = after
}
} else {
if !canvasChoice(change.Field, "geometry", "ownerCanvasId", "source", "target", "path") {
return model, nil, errCanvasModel
}
if !modelEqual(element[change.Field], before) {
return model, nil, errModelConflict
}
element[change.Field] = after
if change.Field == "ownerCanvasId" {
touched[modelString(after)] = true
}
}
case "element":
if !canvasID.MatchString(change.ID) || !modelEqual(model.Elements[change.ID], before) {
return model, nil, errModelConflict
}
if prior := model.Elements[change.ID]; prior != nil {
touched[modelString(prior["ownerCanvasId"])] = true
}
if after == nil {
if prior := model.Elements[change.ID]; prior != nil {
model.Tombstones[change.ID] = modelInt(prior["incarnation"])
model.TombstoneOwners[change.ID] = modelString(prior["ownerCanvasId"])
model.DeadRecords[change.ID] = prior
}
delete(model.Elements, change.ID)
} else {
record, ok := after.(map[string]any)
if !ok || record["id"] != change.ID {
return model, nil, errCanvasModel
}
if before == nil && modelInt(record["incarnation"]) <= model.Tombstones[change.ID] {
return model, nil, errModelConflict
}
if prior := model.Elements[change.ID]; prior != nil && modelInt(record["incarnation"]) != modelInt(prior["incarnation"]) {
return model, nil, errModelConflict
}
model.Elements[change.ID] = record
touched[modelString(record["ownerCanvasId"])] = true
}
case "canvas":
prior, exists := model.Canvases[change.ID]
var priorValue any
if exists {
priorValue = prior
}
if !uuidSlugPattern.MatchString(change.ID) || !modelEqual(priorValue, before) {
return model, nil, errModelConflict
}
touched[change.ID] = true
if prior.Parent != nil {
touched[prior.Parent.CanvasID] = true
}
if after == nil {
delete(model.Canvases, change.ID)
} else {
var canvas modelCanvas
if json.Unmarshal(change.After, &canvas) != nil || canvas.ID != change.ID {
return model, nil, errCanvasModel
}
model.Canvases[change.ID] = canvas
if canvas.Parent != nil {
touched[canvas.Parent.CanvasID] = true
}
}
default:
return model, nil, errCanvasModel
}
}
}
if err := validateCanvasModel(model); err != nil {
return model, nil, err
}
// Endpoint owners are read participants, guarding against delete/restore races in child canvases.
for _, operation := range operations {
for _, change := range operation.Changes {
if change.Kind == "element" && string(change.Before) == "null" || change.Kind == "field" && (change.Field == "source" || change.Field == "target") {
id := change.ID
if change.Kind == "field" {
id = change.Target.ID
}
element := model.Elements[id]
if element != nil && element["structure"] == "connection" {
for _, field := range []string{"source", "target"} {
endpoint := element[field].(map[string]any)
touched[modelString(model.Elements[modelString(endpoint["elementId"])]["ownerCanvasId"])] = true
}
}
}
}
}
for id := range touched {
model.Versions[id] = initial.Versions[id] + 1
}
return model, touched, nil
}