408 lines
14 KiB
Go
408 lines
14 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
|
|
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{}, 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 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)
|
|
}
|
|
// Tombstones are small identity facts, not payloads; include them for safe incarnation checks.
|
|
for id, incarnation := range model.Tombstones {
|
|
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, ©)
|
|
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 {
|
|
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") {
|
|
if !canvasString(data["title"], 300) || !canvasString(data["text"], 100000) || component == "frame" && geometry["rotation"] != float64(0) {
|
|
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"])
|
|
}
|
|
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]+1 {
|
|
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
|
|
}
|