package platform import ( "encoding/base64" "encoding/json" "io" "math" "net/http" "net/url" "regexp" "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) { var doc map[string]any if json.Unmarshal(raw, &doc) != nil || !canvasFields(doc, "schema_version", "nodes", "edges", "viewport") || doc["schema_version"] != float64(1) { return nil, false } 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 } kinds := 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"} { 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"] if w != h || (w && (!canvasNumber(n["width"], 20, 3000) || !canvasNumber(n["height"], 20, 3000))) { return nil, false } d, ok := n["data"].(map[string]any) if !ok || !canvasFields(d, "kind", "title", "text", "src", "icon", "rotation", "fontSize", "textStyle") { return nil, false } kind, ok := d["kind"].(string) if !ok { return nil, false } typ := "material" if kind == "plain-text" { typ = "plainText" } else if kind == "icon" { typ = "icon" } 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 } 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") && !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 } } } } } seen := map[string]bool{} for _, v := range edges { e, ok := v.(map[string]any) if !ok { return nil, false } delete(e, "selected") if !canvasFields(e, "id", "source", "target", "sourceHandle", "targetHandle", "type") { 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 || !canvasChoice(kinds[s], "text", "image", "script") { return nil, false } } for _, key := range []string{"sourceHandle", "targetHandle"} { if h, exists := e[key]; exists && h != nil && !canvasChoice(h, "", "top", "right", "bottom", "left") { 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 r.Method == http.MethodGet { 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 } document, ok = normalizeCanvas(input.Document) if !ok { reject(w, 400, "invalid_canvas") return nil } if expected != version { reject(w, 412, "version_conflict") return nil } _, 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 }