From 6f36ef27bbaf50d2c502863f4bff781f1a773d66 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 02:47:27 +0800 Subject: [PATCH] Add versioned board canvas persistence and stable SEO slugs --- README.md | 32 +++ db/003-canvas.sql | 29 +++ internal/platform/canvas.go | 299 ++++++++++++++++++++++++++ internal/platform/canvas_test.go | 41 ++++ internal/platform/http.go | 15 ++ internal/platform/integration_test.go | 84 +++++++- internal/platform/materials.go | 24 ++- internal/platform/slug.go | 12 ++ internal/platform/workspaces.go | 38 ++-- 9 files changed, 548 insertions(+), 26 deletions(-) create mode 100644 db/003-canvas.sql create mode 100644 internal/platform/canvas.go create mode 100644 internal/platform/canvas_test.go create mode 100644 internal/platform/slug.go diff --git a/README.md b/README.md index 5efc091..8f6a90d 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,35 @@ 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: }` +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. diff --git a/db/003-canvas.sql b/db/003-canvas.sql new file mode 100644 index 0000000..73a3d1d --- /dev/null +++ b/db/003-canvas.sql @@ -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; diff --git a/internal/platform/canvas.go b/internal/platform/canvas.go new file mode 100644 index 0000000..a0e02a4 --- /dev/null +++ b/internal/platform/canvas.go @@ -0,0 +1,299 @@ +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 +} diff --git a/internal/platform/canvas_test.go b/internal/platform/canvas_test.go new file mode 100644 index 0000000..7cba1d9 --- /dev/null +++ b/internal/platform/canvas_test.go @@ -0,0 +1,41 @@ +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) + } + } +} diff --git a/internal/platform/http.go b/internal/platform/http.go index f9872c7..87e9678 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -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)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index d05381d..5292294 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -14,6 +14,7 @@ import ( "net/http/httptest" "os" "regexp" + "strings" "testing" _ "github.com/jackc/pgx/v5/stdlib" @@ -154,7 +155,86 @@ 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") + } + 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 +242,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) diff --git a/internal/platform/materials.go b/internal/platform/materials.go index 2d03c0b..bd41eda 100644 --- a/internal/platform/materials.go +++ b/internal/platform/materials.go @@ -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) diff --git a/internal/platform/slug.go b/internal/platform/slug.go new file mode 100644 index 0000000..eec8d55 --- /dev/null +++ b/internal/platform/slug.go @@ -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)) +} diff --git a/internal/platform/workspaces.go b/internal/platform/workspaces.go index b428f92..2062efe 100644 --- a/internal/platform/workspaces.go +++ b/internal/platform/workspaces.go @@ -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")