Compare commits

...

4 Commits

Author SHA1 Message Date
codex-bot 6f504ebe15 Support canvas frame nodes and explicit membership snapshots
Foundry Go checks / check (push) Successful in 3m13s
Foundry Go checks / check (pull_request) Successful in 4m24s
2026-11-06 11:20:05 +08:00
Zayden-Jung 57aab71d45 Merge pull request 'Canvas 快照持久化与 workspace/board SEO slug' (#7) from codex/canvas-persistence into main
Foundry Go checks / check (push) Successful in 2m38s
Deploy Foundry Core / deploy (push) Successful in 3m45s
Foundry Go checks / check (pull_request) Successful in 3m20s
Reviewed-on: #7
2026-11-05 18:57:43 +00:00
codex-bot 6f36ef27bb Add versioned board canvas persistence and stable SEO slugs
Foundry Go checks / check (push) Successful in 7m30s
Foundry Go checks / check (pull_request) Successful in 8m43s
2026-11-06 02:47:27 +08:00
Zayden-Jung e8ed5e4f9f Merge pull request '修复 Google/Slack OAuth 回调的系统 CA 证书缺失' (#6) from codex/fix-oauth-ca into main
Foundry Go checks / check (push) Successful in 2m47s
Deploy Foundry Core / deploy (push) Successful in 2m30s
Reviewed-on: #6
2026-11-05 15:16:11 +00:00
9 changed files with 656 additions and 26 deletions
+44
View File
@@ -72,3 +72,47 @@ disables command execution tools. It receives prompts via stdin and returns
the final agent message. The worker has no published port and Foundry receives
neither the Codex home nor the Docker socket. Keep both containers on the same
host for Unix socket access.
### Board Canvas snapshots and slugs
Apply `db/003-canvas.sql` through the normal migration runner before starting this
version. `GET /projects/{project}/workspaces/{workspace}/materials/{board}/canvas`
returns `{version, document}` and a quoted `ETag`. An untouched board returns
version 1 and an empty schema-1 graph. `PATCH` takes `{document: <full snapshot>}`
and the quoted `If-Match`; it atomically replaces the snapshot and returns
`{version}` with the new `ETag`. Canvas and material metadata have separate
versions. GET uses existing material read permissions; PATCH requires editor or
admin. Missing versions return 428, stale versions 412 `version_conflict`, invalid
graphs 400 `invalid_canvas`, and bodies over 5 MiB (UTF-8 bytes) 413. PNG/JPEG/WebP/
GIF data URLs up to 2 MiB decoded and HTTPS image URLs are supported. Temporary
node selection/drag/measurement state and mirrored styles are discarded.
Workspace and material create/get/list responses include `slug`. Create and
metadata PATCH accept an optional slug (1–100 lowercase ASCII letters/digits,
with single hyphens between segments; UUID-form custom values are reserved).
Workspace slugs are unique within a project, material slugs within a workspace;
duplicates return 409. Defaults use the ASCII name/title and UUID, falling back
to a generic prefix for names without ASCII letters/digits. Existing records are
backfilled. Renaming leaves slugs stable unless explicitly supplied. Workspace
and material path parameters accept either ID or slug, with unchanged permissions.
Canvas uses its board material's slug; there is no separate Canvas identity.
Slugs enable frontend SEO URLs but do not make private content public or add
search-engine metadata. Changing a custom slug does not retain an old alias.
Frontend follow-up: allowlist the Canvas proxy route and set its body limit to
at least 5 MiB; load the server snapshot, offer migration of old local drafts,
serialize autosaves and handle 412 explicitly. Restore node style dimensions
from width/height and render edges as Bézier curves. These frontend changes are
outside this Core delivery; local drafts alone are not database persistence.
Canvas schema 1 also supports frames: `type: "frame"` with `data.kind: "frame"`.
Frame names use `data.title`; optional `data.labelMode` is `hidden`, `fixed`, or
`scaled` (frontend default: `fixed`). Frames cannot rotate, connect to edges,
or belong to other frames. Any non-frame node can specify `data.frameId`, which
must reference a frame in the same snapshot; forward references are supported.
Membership is explicit and independent of geometry: moving a frame over existing
content does not add members. The frontend assigns membership on content drop
and implements label zoom behavior. Core preserves the supplied membership and
label mode without inferring them from positions. Renderer-only `zIndex` is
ignored alongside transient state; restore frame layering in the frontend.
This extends existing JSONB snapshots and requires no additional migration.
+29
View File
@@ -0,0 +1,29 @@
BEGIN;
ALTER TABLE materials ADD COLUMN canvas_version bigint NOT NULL DEFAULT 1 CHECK (canvas_version > 0);
ALTER TABLE materials ADD COLUMN canvas_document jsonb;
ALTER TABLE materials ADD CONSTRAINT canvas_board_only CHECK (canvas_document IS NULL OR kind='board');
ALTER TABLE workspaces ADD COLUMN slug text;
UPDATE workspaces SET slug=COALESCE(NULLIF(trim(both '-' from left(trim(both '-' from regexp_replace(lower(name),'[^a-z0-9]+','-','g')),50)),''),'workspace') || '-' || id::text;
ALTER TABLE workspaces ALTER COLUMN slug SET NOT NULL;
ALTER TABLE workspaces ADD CONSTRAINT workspace_slug_unique UNIQUE(project,slug);
ALTER TABLE workspaces ADD CONSTRAINT workspace_slug_valid CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(slug)<=100);
ALTER TABLE materials ADD COLUMN slug text;
UPDATE materials SET slug=COALESCE(NULLIF(trim(both '-' from left(trim(both '-' from regexp_replace(lower(title),'[^a-z0-9]+','-','g')),50)),''),'canvas') || '-' || id::text;
ALTER TABLE materials ALTER COLUMN slug SET NOT NULL;
ALTER TABLE materials ADD CONSTRAINT material_slug_unique UNIQUE(project,workspace_id,slug);
ALTER TABLE materials ADD CONSTRAINT material_slug_valid CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(slug)<=100);
CREATE FUNCTION foundry_default_slug() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE label text;
BEGIN
IF NEW.slug IS NULL THEN
IF TG_TABLE_NAME='workspaces' THEN label=NEW.name; ELSE label=NEW.title; END IF;
label=left(trim(both '-' from regexp_replace(lower(label),'[^a-z0-9]+','-','g')),50);
NEW.slug=COALESCE(NULLIF(trim(both '-' from label),''),'canvas') || '-' || NEW.id::text;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER workspace_default_slug BEFORE INSERT ON workspaces FOR EACH ROW EXECUTE FUNCTION foundry_default_slug();
CREATE TRIGGER material_default_slug BEFORE INSERT ON materials FOR EACH ROW EXECUTE FUNCTION foundry_default_slug();
COMMIT;
+323
View File
@@ -0,0 +1,323 @@
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", "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"]
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", "frameId", "labelMode") {
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 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
}
}
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
}
}
}
}
}
// 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")
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
}
+90
View File
@@ -0,0 +1,90 @@
package platform
import (
"encoding/json"
"strings"
"testing"
)
const canvasFixture = `{"schema_version":1,"nodes":[{"id":"a","type":"material","position":{"x":100,"y":200},"width":320,"height":220,"selected":true,"data":{"kind":"text","title":"灵感笔记","text":"正文","rotation":30,"fontSize":24,"textStyle":{"color":"#37352f","background":"transparent","bold":true,"italic":false,"underline":false,"align":"left","fontFamily":"sans","lineHeight":1.5}}},{"id":"b","type":"material","position":{"x":0,"y":0},"data":{"kind":"script","title":"Script","text":""}},{"id":"icon","type":"icon","position":{"x":500,"y":200},"width":80,"height":80,"data":{"kind":"icon","title":"火箭","text":"","icon":"Rocket","rotation":0}}],"edges":[{"id":"e","source":"a","target":"b","sourceHandle":"top","targetHandle":"bottom"}],"viewport":{"x":0,"y":0,"zoom":1}}`
func TestCanvasValidation(t *testing.T) {
out, ok := normalizeCanvas(json.RawMessage(canvasFixture))
if !ok || strings.Contains(string(out), "selected") {
t.Fatal("valid snapshot rejected or UI state retained")
}
cases := [][2]string{{`"rotation":30`, `"rotation":360`}, {`"width":320`, `"width":19`}, {`"color":"#37352f"`, `"color":"red"`}, {`"bold":true`, `"bold":"true"`}, {`"target":"b"`, `"target":"missing"`}, {`"target":"b"`, `"target":"icon"`}, {`"zoom":1`, `"zoom":0`}, {`"sourceHandle":"top"`, `"sourceHandle":"unknown"`}, {`"fontSize":24`, `"fontSize":161`}, {`"lineHeight":1.5`, `"lineHeight":4`}, {`"id":"b"`, `"id":"a"`}, {`"schema_version":1`, `"schema_version":2`}}
for _, c := range cases {
t.Run(c[1], func(t *testing.T) {
if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(canvasFixture, c[0], c[1], 1))); ok {
t.Fatal("accepted invalid snapshot")
}
})
}
for _, bad := range []string{`null`, `{}`, `{"schema_version":1,"nodes":null,"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`} {
if _, ok := normalizeCanvas(json.RawMessage(bad)); ok {
t.Fatal("accepted missing document fields")
}
}
}
func TestSlugValidation(t *testing.T) {
for _, v := range []string{"my-board", "workspace-2026"} {
if !validSlug(&v) {
t.Fatal(v)
}
}
for _, v := range []string{"", "Bad Slug", "../board", strings.Repeat("a", 101)} {
if validSlug(&v) {
t.Fatal(v)
}
}
}
// Frame nodes follow the frontend contract: membership is data, not containment.
const frameFixture = `{"schema_version":1,"nodes":[{"id":"text","type":"material","position":{"x":100,"y":100},"data":{"kind":"text","title":"成员","text":"正文","frameId":"frame"}},{"id":"plain","type":"plainText","position":{"x":0,"y":0},"data":{"kind":"plain-text","title":"纯文本","text":"文字","frameId":"frame"}},{"id":"icon","type":"icon","position":{"x":10,"y":10},"data":{"kind":"icon","title":"图标","text":"","icon":"Star","frameId":"frame"}},{"id":"frame","type":"frame","position":{"x":0,"y":0},"width":600,"height":400,"zIndex":-1,"style":{"width":600,"height":400,"pointerEvents":"none"},"data":{"kind":"frame","title":"框选区","text":"","rotation":0,"labelMode":"fixed"}},{"id":"outside","type":"material","position":{"x":100,"y":100},"data":{"kind":"script","title":"重叠但未加入","text":""}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
func TestCanvasFrames(t *testing.T) {
for _, mode := range []string{"hidden", "fixed", "scaled"} {
t.Run(mode, func(t *testing.T) {
out, ok := normalizeCanvas(json.RawMessage(strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)))
if !ok {
t.Fatal("valid frame rejected")
}
var doc map[string]any
json.Unmarshal(out, &doc)
nodes := doc["nodes"].([]any)
if nodes[0].(map[string]any)["data"].(map[string]any)["frameId"] != "frame" || nodes[3].(map[string]any)["data"].(map[string]any)["labelMode"] != mode {
t.Fatal("frame data lost")
}
if _, exists := nodes[4].(map[string]any)["data"].(map[string]any)["frameId"]; exists {
t.Fatal("position inferred membership")
}
if _, exists := nodes[3].(map[string]any)["zIndex"]; exists {
t.Fatal("rendering state persisted")
}
})
}
cases := [][2]string{
{`"labelMode":"fixed"`, `"labelMode":"invalid"`},
{`"labelMode":"fixed"`, `"labelMode":null`},
{`"rotation":0`, `"rotation":1`},
{`"frameId":"frame"`, `"frameId":"missing"`},
{`"frameId":"frame"`, `"frameId":"text"`},
{`"frameId":"frame"`, `"frameId":null`},
{`"frameId":"frame"`, `"frameId":""`},
{`"labelMode":"fixed"`, `"labelMode":"fixed","frameId":"frame"`},
{`"type":"frame"`, `"type":"material"`},
{`"edges":[]`, `"edges":[{"id":"e","source":"text","target":"frame"}]`},
}
for _, c := range cases {
t.Run(c[1], func(t *testing.T) {
if _, ok := normalizeCanvas(json.RawMessage(strings.Replace(frameFixture, c[0], c[1], 1))); ok {
t.Fatal("invalid frame accepted")
}
})
}
withoutMode := strings.Replace(frameFixture, `,"labelMode":"fixed"`, "", 1)
if _, ok := normalizeCanvas(json.RawMessage(withoutMode)); !ok {
t.Fatal("optional labelMode rejected")
}
}
+15
View File
@@ -82,6 +82,20 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
reject(w, 401, "invalid_session")
return
}
if err == nil && r.PathValue("workspace") != "" {
var id string
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM workspaces WHERE project=$1 AND (id::text=$2 OR slug=$2)", project, r.PathValue("workspace")).Scan(&id)
if err == nil {
r.SetPathValue("workspace", id)
}
}
if err == nil && r.PathValue("material") != "" {
var id string
err = s.db.QueryRowContext(r.Context(), "SELECT id::text FROM materials WHERE project=$1 AND workspace_id=$2 AND (id::text=$3 OR slug=$3)", project, r.PathValue("workspace"), r.PathValue("material")).Scan(&id)
if err == nil {
r.SetPathValue("material", id)
}
}
if err == nil {
err = handle(w, r, project, account)
}
@@ -106,6 +120,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi
s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs}
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas", s.protected(callbacks, s.canvas))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/open", s.protected(callbacks, s.openMaterial))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags", s.protected(callbacks, s.tags))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags/{tag}", s.protected(callbacks, s.deleteTag))
+105 -2
View File
@@ -14,6 +14,8 @@ import (
"net/http/httptest"
"os"
"regexp"
"strconv"
"strings"
"testing"
_ "github.com/jackc/pgx/v5/stdlib"
@@ -154,7 +156,108 @@ func TestOnboardingAndSharing(t *testing.T) {
}
board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201)
boardPath := workspacePath + "/materials/" + board["id"].(string)
layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"1"`)
canvasPath := boardPath + "/canvas"
empty := call("GET", canvasPath, tokens[0], nil, 200)
if empty["version"] != float64(1) {
t.Fatal("old board not initialized at version 1")
}
snapshot := response{"document": json.RawMessage(canvasFixture)}
call("PATCH", canvasPath, "", snapshot, 401, `"1"`)
call("PATCH", canvasPath, tokens[1], snapshot, 403, `"1"`)
call("PATCH", canvasPath, tokens[2], snapshot, 404, `"1"`)
call("PATCH", canvasPath, tokens[0], snapshot, 428)
call("PATCH", canvasPath, tokens[0], snapshot, 200, `"1"`)
call("PATCH", canvasPath, tokens[0], snapshot, 412, `"1"`)
saved := call("GET", canvasPath, tokens[0], nil, 200)
expected, _ := normalizeCanvas(json.RawMessage(canvasFixture))
expectedJSON, _ := json.Marshal(json.RawMessage(expected))
actualJSON, _ := json.Marshal(saved["document"])
if !bytes.Equal(expectedJSON, actualJSON) {
t.Fatal("snapshot failed round trip")
}
call("PATCH", canvasPath, tokens[0], response{"document": response{}}, 400, `"2"`)
call("PATCH", boardPath, tokens[0], response{"title": "Renamed", "slug": "my-canvas", "content": response{}}, 204, `"1"`)
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(2) {
t.Fatal("rename changed canvas version")
}
slugPath := workspacePath + "/materials/my-canvas"
if call("GET", slugPath, tokens[0], nil, 200)["title"] != "Renamed" {
t.Fatal("slug lookup failed")
}
// A PNG at the local upload limit must survive JSON and database storage.
var imageDoc map[string]any
json.Unmarshal([]byte(canvasFixture), &imageDoc)
imageBytes := make([]byte, 2<<20)
copy(imageBytes, []byte{137, 80, 78, 71, 13, 10, 26, 10})
data := imageDoc["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)
data["kind"] = "image"
data["src"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes)
call("PATCH", canvasPath, tokens[0], response{"document": imageDoc}, 200, `"2"`)
restored := call("GET", canvasPath, tokens[0], nil, 200)
if restored["document"].(map[string]any)["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)["src"] != data["src"] {
t.Fatal("large image changed")
}
call("PATCH", canvasPath, tokens[0], response{"document": strings.Repeat("x", canvasLimit)}, 413, `"3"`)
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(3) {
t.Fatal("rejected snapshot changed version")
}
// Restore the material version expected by the pre-existing layout checks below.
// Exercise two independent HTTP transactions against the same expected version.
encodedSnapshot, _ := json.Marshal(snapshot)
statuses := make(chan int, 2)
start := make(chan struct{})
for range 2 {
go func() {
<-start
request := httptest.NewRequest("PATCH", canvasPath, bytes.NewReader(encodedSnapshot))
request.Header.Set("Authorization", "Bearer "+tokens[0])
request.Header.Set("If-Match", `"3"`)
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, request)
statuses <- recorder.Code
}()
}
close(start)
one, two := <-statuses, <-statuses
if !((one == 200 && two == 412) || (one == 412 && two == 200)) {
t.Fatalf("concurrent writes: %d %d", one, two)
}
if call("GET", boardPath, tokens[0], nil, 200)["title"] != "Renamed" {
t.Fatal("canvas save changed title")
}
call("PATCH", workspacePath, tokens[0], response{"name": "Team", "slug": "research-team"}, 204)
call("GET", base+"/workspaces/research-team/materials/my-canvas/canvas", tokens[0], nil, 200)
call("PATCH", workspacePath, tokens[1], response{"name": "Team", "slug": "stolen"}, 403)
call("POST", base+"/workspaces", tokens[0], response{"name": "Duplicate", "slug": "research-team"}, 409)
call("PATCH", workspacePath, tokens[0], response{"name": "Renamed Team"}, 204)
if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" {
t.Fatal("rename changed slug")
}
// Frames round-trip through PostgreSQL with all label modes and explicit memberships.
for index, mode := range []string{"hidden", "fixed", "scaled"} {
version := strconv.Itoa(4 + index)
raw := strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)
call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(raw)}, 200, `"`+version+`"`)
persisted := call("GET", canvasPath, tokens[0], nil, 200)
normalized, _ := normalizeCanvas(json.RawMessage(raw))
want, _ := json.Marshal(json.RawMessage(normalized))
got, _ := json.Marshal(persisted["document"])
if !bytes.Equal(want, got) {
t.Fatal("frame snapshot not preserved")
}
}
invalidFrame := strings.Replace(frameFixture, `"frameId":"frame"`, `"frameId":"missing"`, 1)
rejected := call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(invalidFrame)}, 400, `"7"`)
if rejected["error"] != "invalid_canvas" {
t.Fatal("unexpected frame error")
}
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(7) {
t.Fatal("invalid frame changed saved version")
}
layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"2"`)
call("GET", boardPath+"/layouts", tokens[1], nil, 200)
call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200)
@@ -162,7 +265,7 @@ func TestOnboardingAndSharing(t *testing.T) {
t.Fatal("board share disclosed private child material")
}
call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`)
call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"2"`)
call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"3"`)
call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204)
call("GET", textPath, tokens[2], nil, 404)
call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204)
+13 -11
View File
@@ -59,6 +59,7 @@ func (s *service) materials(w http.ResponseWriter, r *http.Request, project, acc
}
var input struct {
Kind string `json:"kind"`
Slug *string `json:"slug,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Content json.RawMessage `json:"content"`
@@ -67,7 +68,7 @@ func (s *service) materials(w http.ResponseWriter, r *http.Request, project, acc
return nil
}
content, ok := normalizeContent(input.Kind, input.Content)
if !ok || len(input.Title) > 300 || len(input.Description) > 10000 {
if !ok || !validSlug(input.Slug) || len(input.Title) > 300 || len(input.Description) > 10000 {
reject(w, 400, "invalid_material")
return nil
}
@@ -85,11 +86,11 @@ func (s *service) materials(w http.ResponseWriter, r *http.Request, project, acc
reject(w, 403, "forbidden")
return nil
}
var id string
var id, slug string
err = tx.QueryRowContext(r.Context(), `
INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by)
VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text
`, project, workspace, input.Kind, input.Title, input.Description, content, account).Scan(&id)
INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by,slug)
VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id::text,slug
`, project, workspace, input.Kind, input.Title, input.Description, content, account, input.Slug).Scan(&id, &slug)
if err != nil {
return err
}
@@ -97,7 +98,7 @@ VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text
return err
}
setVersion(w, 1)
reply(w, 201, response{"id": id, "workspace_id": workspace, "kind": input.Kind, "version": 1})
reply(w, 201, response{"id": id, "workspace_id": workspace, "kind": input.Kind, "version": 1, "slug": slug})
return nil
}
@@ -120,7 +121,7 @@ func (s *service) material(w http.ResponseWriter, r *http.Request, project, acco
var result json.RawMessage
var version int64
err = tx.QueryRowContext(r.Context(), `
SELECT jsonb_build_object('id',id,'workspace_id',workspace_id,'kind',kind,'title',title,'description',description,
SELECT jsonb_build_object('id',id,'workspace_id',workspace_id,'kind',kind,'slug',slug,'title',title,'description',description,
'content',content,'version',version,'created_at',created_at,'modified_at',modified_at,'created_by',created_by),version
FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3
`, project, workspace, material).Scan(&result, &version)
@@ -142,6 +143,7 @@ FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3
var result sql.Result
if r.Method == http.MethodPatch {
var input struct {
Slug *string `json:"slug,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
Content json.RawMessage `json:"content"`
@@ -150,14 +152,14 @@ FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3
return nil
}
content, ok := normalizeContent(kind, input.Content)
if !ok || len(input.Title) > 300 || len(input.Description) > 10000 {
if !ok || !validSlug(input.Slug) || len(input.Title) > 300 || len(input.Description) > 10000 {
reject(w, 400, "invalid_material")
return nil
}
result, err = tx.ExecContext(r.Context(), `
UPDATE materials SET title=$4,description=$5,content=$6,version=version+1,modified_at=now()
UPDATE materials SET slug=COALESCE($8,slug),title=$4,description=$5,content=$6,version=version+1,modified_at=now()
WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$7
`, project, workspace, material, input.Title, input.Description, content, version)
`, project, workspace, material, input.Title, input.Description, content, version, input.Slug)
} else {
if role != "admin" {
reject(w, 403, "admin_required")
@@ -259,7 +261,7 @@ func (s *service) listMaterials(w http.ResponseWriter, r *http.Request, project,
}
tag := r.URL.Query().Get("tag")
rows, err := s.db.QueryContext(r.Context(), `
SELECT jsonb_build_object('id',m.id,'workspace_id',m.workspace_id,'kind',m.kind,'title',m.title,
SELECT jsonb_build_object('id',m.id,'workspace_id',m.workspace_id,'kind',m.kind,'slug',m.slug,'title',m.title,
'description',m.description,'version',m.version,'created_at',m.created_at,'modified_at',m.modified_at,
'opened_at',opened.opened_at,'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name,'color',t.color) ORDER BY lower(t.name),t.id)
FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id)
+12
View File
@@ -0,0 +1,12 @@
package platform
import "regexp"
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
var uuidSlugPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// Slugs remain stable across renames unless explicitly changed by an authorized edit.
func validSlug(slug *string) bool {
return slug == nil || (len(*slug) <= 100 && slugPattern.MatchString(*slug) && !uuidSlugPattern.MatchString(*slug))
}
+25 -13
View File
@@ -11,7 +11,7 @@ func (s *service) workspaces(w http.ResponseWriter, r *http.Request, project, ac
switch r.Method {
case http.MethodGet:
rows, err := s.db.QueryContext(r.Context(), `
SELECT w.id::text,w.name,m.role,w.owner_id::text FROM workspaces w
SELECT w.id::text,w.name,m.role,w.owner_id::text,w.slug FROM workspaces w
JOIN workspace_members m ON (m.project,m.workspace_id)=(w.project,w.id)
WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200
`, project, account)
@@ -21,11 +21,11 @@ WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200
defer rows.Close()
items := []response{}
for rows.Next() {
var id, name, role, owner string
if err := rows.Scan(&id, &name, &role, &owner); err != nil {
var id, name, role, owner, slug string
if err := rows.Scan(&id, &name, &role, &owner, &slug); err != nil {
return err
}
items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner})
items = append(items, response{"id": id, "name": name, "role": role, "owner_id": owner, "slug": slug})
}
if err := rows.Err(); err != nil {
return err
@@ -33,13 +33,14 @@ WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200
reply(w, 200, response{"items": items})
case http.MethodPost:
var input struct {
Name string `json:"name"`
Name string `json:"name"`
Slug *string `json:"slug,omitempty"`
}
if !decode(w, r, &input) {
return nil
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" || len(input.Name) > 160 {
if input.Name == "" || len(input.Name) > 160 || !validSlug(input.Slug) {
reject(w, 400, "invalid_workspace")
return nil
}
@@ -52,10 +53,20 @@ WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200
if err != nil {
return err
}
var slug string
if input.Slug != nil {
_, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET slug=$3 WHERE project=$1 AND id=$2", project, id, *input.Slug)
if err != nil {
return err
}
}
if err = tx.QueryRowContext(r.Context(), "SELECT slug FROM workspaces WHERE project=$1 AND id=$2", project, id).Scan(&slug); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account})
reply(w, 201, response{"id": id, "name": input.Name, "role": "admin", "owner_id": account, "slug": slug})
default:
reject(w, 405, "method_not_allowed")
}
@@ -104,11 +115,11 @@ func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, acc
}
switch r.Method {
case http.MethodGet:
var name string
if err := tx.QueryRowContext(r.Context(), "SELECT name FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name); err != nil {
var name, slug string
if err := tx.QueryRowContext(r.Context(), "SELECT name,slug FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name, &slug); err != nil {
return err
}
reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner})
reply(w, 200, response{"id": workspace, "name": name, "role": role, "owner_id": owner, "slug": slug})
return nil
case http.MethodPatch:
if role != "admin" {
@@ -116,17 +127,18 @@ func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, acc
return nil
}
var input struct {
Name string `json:"name"`
Name string `json:"name"`
Slug *string `json:"slug,omitempty"`
}
if !decode(w, r, &input) {
return nil
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" || len(input.Name) > 160 {
if input.Name == "" || len(input.Name) > 160 || !validSlug(input.Slug) {
reject(w, 400, "invalid_workspace")
return nil
}
_, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name)
_, err = tx.ExecContext(r.Context(), "UPDATE workspaces SET name=$3,slug=COALESCE($4,slug),updated_at=now() WHERE project=$1 AND id=$2", project, workspace, input.Name, input.Slug)
case http.MethodDelete:
if owner != account {
reject(w, 403, "owner_required")