430 lines
12 KiB
Go
430 lines
12 KiB
Go
package platform
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const canvasLimit = 5 << 20
|
|
|
|
var canvasColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|
var canvasID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
|
var canvasIcon = regexp.MustCompile(`^[A-Z][A-Za-z0-9]{0,63}$`)
|
|
|
|
func canvasNumber(v any, low, high float64) bool {
|
|
n, ok := v.(float64)
|
|
return ok && !math.IsNaN(n) && !math.IsInf(n, 0) && n >= low && n <= high
|
|
}
|
|
func canvasString(v any, limit int) bool {
|
|
s, ok := v.(string)
|
|
return ok && utf8.RuneCountInString(s) <= limit
|
|
}
|
|
func canvasChoice(v any, choices ...string) bool {
|
|
for _, s := range choices {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func canvasFields(m map[string]any, fields ...string) bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
for k := range m {
|
|
found := false
|
|
for _, f := range fields {
|
|
if k == f {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
func canvasImage(v any) bool {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(s, "https://") {
|
|
u, e := url.Parse(s)
|
|
return e == nil && u.Hostname() != "" && u.User == nil && len(s) < 2048
|
|
}
|
|
for _, mime := range []string{"png", "jpeg", "webp", "gif"} {
|
|
prefix := "data:image/" + mime + ";base64,"
|
|
if strings.HasPrefix(s, prefix) {
|
|
b, e := base64.StdEncoding.DecodeString(strings.TrimPrefix(s, prefix))
|
|
return e == nil && len(b) > 0 && len(b) <= 2<<20 && http.DetectContentType(b) == "image/"+mime
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func normalizeCanvas(raw json.RawMessage) (json.RawMessage, bool) {
|
|
if len(raw) > canvasLimit {
|
|
return nil, false
|
|
}
|
|
var doc map[string]any
|
|
if json.Unmarshal(raw, &doc) != nil || !canvasFields(doc, "schema_version", "nodes", "edges", "viewport") || (doc["schema_version"] != float64(1) && doc["schema_version"] != float64(2)) {
|
|
return nil, false
|
|
}
|
|
v2 := doc["schema_version"] == float64(2)
|
|
nodes, ok := doc["nodes"].([]any)
|
|
if !ok || len(nodes) > 500 {
|
|
return nil, false
|
|
}
|
|
edges, ok := doc["edges"].([]any)
|
|
if !ok || len(edges) > 2000 {
|
|
return nil, false
|
|
}
|
|
viewport, ok := doc["viewport"].(map[string]any)
|
|
if !ok || !canvasFields(viewport, "x", "y", "zoom") || !canvasNumber(viewport["x"], -math.MaxFloat64, math.MaxFloat64) || !canvasNumber(viewport["y"], -math.MaxFloat64, math.MaxFloat64) || !canvasNumber(viewport["zoom"], .1, 4) {
|
|
return nil, false
|
|
}
|
|
if v2 && (!canvasNumber(viewport["x"], -1e6, 1e6) || !canvasNumber(viewport["y"], -1e6, 1e6)) {
|
|
return nil, false
|
|
}
|
|
kinds := map[string]string{}
|
|
framePorts := map[string]map[string]string{}
|
|
for _, v := range nodes {
|
|
n, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if style, ok := n["style"].(map[string]any); ok {
|
|
if _, exists := n["width"]; !exists {
|
|
if v, exists := style["width"]; exists {
|
|
n["width"] = v
|
|
}
|
|
}
|
|
if _, exists := n["height"]; !exists {
|
|
if v, exists := style["height"]; exists {
|
|
n["height"] = v
|
|
}
|
|
}
|
|
}
|
|
for _, key := range []string{"selected", "dragging", "measured", "style", "zIndex"} {
|
|
delete(n, key)
|
|
}
|
|
if !canvasFields(n, "id", "type", "position", "width", "height", "data") {
|
|
return nil, false
|
|
}
|
|
id, ok := n["id"].(string)
|
|
if !ok || !canvasID.MatchString(id) || kinds[id] != "" {
|
|
return nil, false
|
|
}
|
|
p, ok := n["position"].(map[string]any)
|
|
if !ok || !canvasFields(p, "x", "y") || !canvasNumber(p["x"], -1e6, 1e6) || !canvasNumber(p["y"], -1e6, 1e6) {
|
|
return nil, false
|
|
}
|
|
_, w := n["width"]
|
|
_, h := n["height"]
|
|
low, high := float64(20), float64(3000)
|
|
if data, _ := n["data"].(map[string]any); v2 && data["kind"] == "frame" {
|
|
low, high = 32, 100000
|
|
}
|
|
if w != h || (w && (!canvasNumber(n["width"], low, high) || !canvasNumber(n["height"], low, high))) {
|
|
return nil, false
|
|
}
|
|
d, ok := n["data"].(map[string]any)
|
|
if !ok || !canvasFields(d, "kind", "title", "text", "src", "icon", "rotation", "fontSize", "textStyle", "frameId", "labelMode", "subgraph", "presentation", "ports", "assetId", "ai") {
|
|
return nil, false
|
|
}
|
|
if ai, exists := d["ai"]; exists {
|
|
kind, _ := d["kind"].(string)
|
|
if !v2 || !validCanvasAI(ai, kind) {
|
|
return nil, false
|
|
}
|
|
}
|
|
if !validCanvasFrameExtensions(d, v2) {
|
|
return nil, false
|
|
}
|
|
if id, exists := d["assetId"]; exists {
|
|
value, ok := id.(string)
|
|
if !v2 || !ok || !uuidSlugPattern.MatchString(value) || !canvasChoice(d["kind"], "image", "plain-image") {
|
|
return nil, false
|
|
}
|
|
}
|
|
kind, ok := d["kind"].(string)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
typ := "material"
|
|
if kind == "plain-text" {
|
|
typ = "plainText"
|
|
} else if kind == "plain-image" {
|
|
typ = "plainImage"
|
|
} else if kind == "icon" {
|
|
typ = "icon"
|
|
} else if kind == "frame" {
|
|
typ = "frame"
|
|
} else if !canvasChoice(kind, "text", "image", "script") {
|
|
return nil, false
|
|
}
|
|
if n["type"] != typ || !canvasString(d["title"], 300) || !canvasString(d["text"], 100000) {
|
|
return nil, false
|
|
}
|
|
if frameID, exists := d["frameId"]; exists {
|
|
value, ok := frameID.(string)
|
|
if kind == "frame" || !ok || !canvasID.MatchString(value) {
|
|
return nil, false
|
|
}
|
|
}
|
|
if mode, exists := d["labelMode"]; exists && (kind != "frame" || !canvasChoice(mode, "hidden", "fixed", "scaled")) {
|
|
return nil, false
|
|
}
|
|
if kind == "frame" {
|
|
if rotation, exists := d["rotation"]; exists && rotation != float64(0) {
|
|
return nil, false
|
|
}
|
|
}
|
|
if kind == "frame" && v2 {
|
|
ports := map[string]string{}
|
|
if values, ok := d["ports"].([]any); ok {
|
|
for _, v := range values {
|
|
p := v.(map[string]any)
|
|
ports[p["id"].(string)] = p["direction"].(string)
|
|
}
|
|
}
|
|
framePorts[id] = ports
|
|
}
|
|
kinds[id] = kind
|
|
if r, exists := d["rotation"]; exists && (!canvasNumber(r, 0, 360) || r == float64(360)) {
|
|
return nil, false
|
|
}
|
|
if f, exists := d["fontSize"]; exists && !canvasNumber(f, 8, 160) {
|
|
return nil, false
|
|
}
|
|
if kind == "icon" {
|
|
s, ok := d["icon"].(string)
|
|
if !ok || !canvasIcon.MatchString(s) {
|
|
return nil, false
|
|
}
|
|
}
|
|
if src, exists := d["src"]; (exists || kind == "image" || kind == "plain-image") && !canvasImage(src) {
|
|
return nil, false
|
|
}
|
|
if value, exists := d["textStyle"]; exists {
|
|
style, ok := value.(map[string]any)
|
|
if !ok || !canvasFields(style, "color", "background", "bold", "italic", "underline", "align", "fontFamily", "lineHeight") {
|
|
return nil, false
|
|
}
|
|
for k, v := range style {
|
|
switch k {
|
|
case "color", "background":
|
|
s, ok := v.(string)
|
|
if !ok || !(canvasColor.MatchString(s) || (k == "background" && s == "transparent")) {
|
|
return nil, false
|
|
}
|
|
case "bold", "italic", "underline":
|
|
if _, ok := v.(bool); !ok {
|
|
return nil, false
|
|
}
|
|
case "align":
|
|
if !canvasChoice(v, "left", "center", "right") {
|
|
return nil, false
|
|
}
|
|
case "fontFamily":
|
|
if !canvasChoice(v, "sans", "serif", "mono") {
|
|
return nil, false
|
|
}
|
|
case "lineHeight":
|
|
if !canvasNumber(v, 1, 3) {
|
|
return nil, false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Resolve memberships after all nodes so forward references are valid.
|
|
// Geometry never creates membership: only explicit frameId is persisted.
|
|
for _, value := range nodes {
|
|
data := value.(map[string]any)["data"].(map[string]any)
|
|
if id, exists := data["frameId"]; exists && kinds[id.(string)] != "frame" {
|
|
return nil, false
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, v := range edges {
|
|
e, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
delete(e, "selected")
|
|
fields := []string{"id", "source", "target", "sourceHandle", "targetHandle", "type"}
|
|
if v2 {
|
|
fields = append(fields, "data")
|
|
}
|
|
if !canvasFields(e, fields...) {
|
|
return nil, false
|
|
}
|
|
id, ok := e["id"].(string)
|
|
if !ok || !canvasID.MatchString(id) || seen[id] {
|
|
return nil, false
|
|
}
|
|
seen[id] = true
|
|
for _, key := range []string{"source", "target"} {
|
|
s, ok := e[key].(string)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if v2 && kinds[s] == "frame" {
|
|
handle, _ := e[key+"Handle"].(string)
|
|
direction := "input"
|
|
if key == "source" {
|
|
direction = "output"
|
|
}
|
|
if !strings.HasPrefix(handle, "port:") || framePorts[s][strings.TrimPrefix(handle, "port:")] != direction {
|
|
return nil, false
|
|
}
|
|
continue
|
|
}
|
|
if !(canvasChoice(kinds[s], "text", "image", "script") || (v2 && canvasChoice(kinds[s], "plain-text", "plain-image"))) {
|
|
return nil, false
|
|
}
|
|
}
|
|
for _, key := range []string{"sourceHandle", "targetHandle"} {
|
|
endpoint := strings.TrimSuffix(key, "Handle")
|
|
id, _ := e[endpoint].(string)
|
|
if v2 && kinds[id] == "frame" {
|
|
continue
|
|
}
|
|
if h, exists := e[key]; exists && h != nil && !canvasChoice(h, "", "top", "right", "bottom", "left") {
|
|
return nil, false
|
|
}
|
|
}
|
|
if data, exists := e["data"]; exists && (!v2 || !validCanvasEdgeData(data)) {
|
|
return nil, false
|
|
}
|
|
if typ, exists := e["type"]; exists && !canvasChoice(typ, "default", "bezier") {
|
|
return nil, false
|
|
}
|
|
}
|
|
out, err := json.Marshal(doc)
|
|
return out, err == nil
|
|
}
|
|
|
|
func (s *service) canvas(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodPatch {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
tx, err := s.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
workspace, material := r.PathValue("workspace"), r.PathValue("material")
|
|
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if kind != "board" {
|
|
reject(w, 400, "invalid_material")
|
|
return nil
|
|
}
|
|
var version int64
|
|
var document json.RawMessage
|
|
err = tx.QueryRowContext(r.Context(), `SELECT canvas_version,COALESCE(canvas_document,'{"schema_version":1,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, material).Scan(&version, &document)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if canvasSchema(document) == 2 && !acceptsCanvasV2(r) {
|
|
reject(w, 409, "canvas_schema_upgrade_required")
|
|
return nil
|
|
}
|
|
if r.Method == http.MethodGet {
|
|
w.Header().Set("X-Canvas-Schema-Version", strconv.Itoa(canvasSchema(document)))
|
|
setVersion(w, version)
|
|
reply(w, 200, response{"version": version, "document": document})
|
|
return nil
|
|
}
|
|
if role == "viewer" {
|
|
reject(w, 403, "forbidden")
|
|
return nil
|
|
}
|
|
expected, ok := expectedVersion(w, r)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, canvasLimit))
|
|
if err != nil {
|
|
reject(w, 413, "canvas_too_large")
|
|
return nil
|
|
}
|
|
var input struct {
|
|
Document json.RawMessage `json:"document"`
|
|
}
|
|
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
|
decoder.DisallowUnknownFields()
|
|
if !utf8.Valid(body) || decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF {
|
|
reject(w, 400, "invalid_canvas")
|
|
return nil
|
|
}
|
|
if canvasExceededGraphLimits(input.Document) {
|
|
reject(w, 413, "graph_limit_exceeded")
|
|
return nil
|
|
}
|
|
previousDocument := document
|
|
previousSchema := canvasSchema(document)
|
|
document, ok = normalizeCanvas(input.Document)
|
|
if !ok {
|
|
reject(w, 400, "invalid_canvas")
|
|
return nil
|
|
}
|
|
if canvasSchema(document) == 2 && !acceptsCanvasV2(r) {
|
|
reject(w, 409, "canvas_schema_upgrade_required")
|
|
return nil
|
|
}
|
|
if previousSchema == 2 && canvasSchema(document) != 2 {
|
|
reject(w, 409, "canvas_schema_upgrade_required")
|
|
return nil
|
|
}
|
|
if expected != version {
|
|
reject(w, 412, "version_conflict")
|
|
return nil
|
|
}
|
|
if valid, e := validateCanvasRelations(r, tx, project, workspace, material, previousDocument, document); e != nil {
|
|
return e
|
|
} else if !valid {
|
|
reject(w, 409, "ownership_conflict")
|
|
return nil
|
|
}
|
|
if !s.validateCanvasAIModels(w, r, document) {
|
|
return nil
|
|
}
|
|
if valid, e := s.validateCanvasAssets(r, tx, project, workspace, account, document); e != nil {
|
|
return e
|
|
} else if !valid {
|
|
reject(w, 400, "invalid_asset_reference")
|
|
return nil
|
|
}
|
|
if err = s.attachCanvasAssets(r, tx, project, workspace, material, document); err != nil {
|
|
return err
|
|
}
|
|
if err = invalidateCanvasAITargets(r, tx, project, workspace, material, previousDocument, document); err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=canvas_version+1 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, material, document)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
setVersion(w, version+1)
|
|
reply(w, 200, response{"version": version + 1})
|
|
return nil
|
|
}
|