185 lines
6.7 KiB
Go
185 lines
6.7 KiB
Go
package platform
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
type canvasSnapshot struct {
|
|
BoardID string `json:"board_id"`
|
|
Version int64 `json:"version"`
|
|
Document json.RawMessage `json:"document"`
|
|
}
|
|
|
|
func loadCanvas(r *http.Request, tx *sql.Tx, project, workspace, board string) (canvasSnapshot, error) {
|
|
result := canvasSnapshot{BoardID: board}
|
|
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 FOR UPDATE`, project, workspace, board).Scan(&result.Version, &result.Document)
|
|
return result, err
|
|
}
|
|
|
|
func (s *service) createSubgraph(w http.ResponseWriter, r *http.Request, project, account string) error {
|
|
if r.Method != http.MethodPost {
|
|
reject(w, 405, "method_not_allowed")
|
|
return nil
|
|
}
|
|
if !acceptsCanvasV2(r) {
|
|
reject(w, 409, "canvas_schema_upgrade_required")
|
|
return nil
|
|
}
|
|
key := canvasIdempotencyKey(r)
|
|
if !uuidSlugPattern.MatchString(key) {
|
|
reject(w, 400, "invalid_idempotency_key")
|
|
return nil
|
|
}
|
|
expected, ok := expectedVersion(w, r)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var input struct {
|
|
Frame struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Position map[string]any `json:"position"`
|
|
Width float64 `json:"width"`
|
|
Height float64 `json:"height"`
|
|
LabelMode string `json:"labelMode"`
|
|
} `json:"frame"`
|
|
}
|
|
if !decode(w, r, &input) {
|
|
return nil
|
|
}
|
|
f := input.Frame
|
|
if !canvasID.MatchString(f.ID) || !canvasString(f.Title, 300) || !canvasPoint(f.Position) || !canvasNumber(f.Width, 32, 100000) || !canvasNumber(f.Height, 32, 100000) || !canvasChoice(f.LabelMode, "hidden", "fixed", "scaled") {
|
|
reject(w, 400, "invalid_canvas")
|
|
return nil
|
|
}
|
|
payload, _ := json.Marshal(input)
|
|
digest := sha256.Sum256(payload)
|
|
hash := hex.EncodeToString(digest[:])
|
|
tx, err := s.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
|
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if kind != "board" {
|
|
reject(w, 400, "invalid_material")
|
|
return nil
|
|
}
|
|
if role == "viewer" {
|
|
reject(w, 403, "forbidden")
|
|
return nil
|
|
}
|
|
var previousHash string
|
|
var previous json.RawMessage
|
|
var status int
|
|
err = tx.QueryRowContext(r.Context(), `SELECT payload_hash,response_status,response_body FROM canvas_transactions WHERE project=$1 AND account_id=$2 AND board_id=$3 AND action='create_subgraph' AND idempotency_key=$4 AND workspace_id=$5`, project, account, board, key, workspace).Scan(&previousHash, &status, &previous)
|
|
if err == nil {
|
|
if previousHash != hash {
|
|
reject(w, 409, "idempotency_conflict")
|
|
return nil
|
|
}
|
|
var result struct {
|
|
Parent canvasSnapshot `json:"parent"`
|
|
}
|
|
_ = json.Unmarshal(previous, &result)
|
|
setVersion(w, result.Parent.Version)
|
|
reply(w, status, previous)
|
|
return nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return err
|
|
}
|
|
parent, err := loadCanvas(r, tx, project, workspace, board)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if expected != parent.Version {
|
|
reject(w, 412, "version_conflict")
|
|
return nil
|
|
}
|
|
var doc map[string]any
|
|
if err = json.Unmarshal(parent.Document, &doc); err != nil {
|
|
return err
|
|
}
|
|
nodes := doc["nodes"].([]any)
|
|
for _, v := range nodes {
|
|
if v.(map[string]any)["id"] == f.ID {
|
|
reject(w, 409, "ownership_conflict")
|
|
return nil
|
|
}
|
|
}
|
|
if len(nodes) >= 500 {
|
|
reject(w, 413, "graph_limit_exceeded")
|
|
return nil
|
|
}
|
|
var childID, slug string
|
|
err = tx.QueryRowContext(r.Context(), `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board',$3,$4,'{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}') RETURNING id::text,slug`, project, workspace, f.Title, account).Scan(&childID, &slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = validateSubgraphPlacement(r, tx, project, workspace, board, childID); err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_subgraphs(project,workspace_id,parent_board_id,frame_id,child_board_id) VALUES($1,$2,$3,$4,$5)`, project, workspace, board, f.ID, childID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
node := map[string]any{"id": f.ID, "type": "frame", "position": f.Position, "width": f.Width, "height": f.Height, "data": map[string]any{
|
|
"kind": "frame", "title": f.Title, "text": "", "rotation": 0, "labelMode": f.LabelMode,
|
|
"subgraph": map[string]any{"boardId": childID}, "ports": []any{},
|
|
"presentation": map[string]any{"collapsed": false, "contentScale": 1, "expandedSize": map[string]any{"width": f.Width, "height": f.Height}, "collapsedSize": map[string]any{"width": 240, "height": 140}},
|
|
}}
|
|
doc["nodes"] = append(nodes, node)
|
|
doc["schema_version"] = 2
|
|
raw, _ := json.Marshal(doc)
|
|
if len(raw) > canvasLimit {
|
|
reject(w, 413, "canvas_too_large")
|
|
return nil
|
|
}
|
|
normalized, ok := normalizeCanvas(raw)
|
|
if !ok {
|
|
reject(w, 400, "invalid_canvas")
|
|
return nil
|
|
}
|
|
before := parent
|
|
parent.Document = normalized
|
|
parent.Version++
|
|
_, err = tx.ExecContext(r.Context(), `UPDATE materials SET canvas_document=$4,canvas_version=$5 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, board, parent.Document, parent.Version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
child, err := loadCanvas(r, tx, project, workspace, childID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var transaction string
|
|
var receiptExpiry time.Time
|
|
err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text,now()+interval '7 days'`).Scan(&transaction, &receiptExpiry)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := response{"transaction_id": transaction, "frame_id": f.ID, "child_board": response{"id": childID, "slug": slug, "workspace_id": workspace}, "parent": parent, "child": child, "undo_receipt": response{"transaction_id": transaction, "valid_for_seconds": 604800, "expires_at": receiptExpiry}}
|
|
encoded, _ := json.Marshal(result)
|
|
savedBefore, _ := json.Marshal([]canvasSnapshot{before})
|
|
versions, _ := json.Marshal(map[string]int64{board: parent.Version, childID: child.Version})
|
|
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions) VALUES($1,$2,$3,$4,$5,'create_subgraph',$6,$7,201,$8,$9,$10)`, project, workspace, board, transaction, account, key, hash, encoded, savedBefore, versions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
setVersion(w, parent.Version)
|
|
reply(w, 201, result)
|
|
return nil
|
|
}
|