300 lines
8.3 KiB
Go
300 lines
8.3 KiB
Go
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
|
|
}
|