Compare commits

...

6 Commits

Author SHA1 Message Date
codex-bot 34c461a61c feat(auth): renew sessions with 30-day idle and 90-day absolute limits
Foundry Go checks / check (push) Successful in 2m53s
Foundry Go checks / check (pull_request) Successful in 3m9s
2026-11-06 12:32:04 +08:00
Zayden-Jung 022086beb8 Merge pull request '支持 Canvas 框选区、成员归属与名称显示模式' (#9) from codex/canvas-frames into main
Foundry Go checks / check (push) Successful in 3m28s
Deploy Foundry Core / deploy (push) Successful in 3m4s
Reviewed-on: #9
2026-11-06 03:21:48 +00:00
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
15 changed files with 901 additions and 53 deletions
+65
View File
@@ -72,3 +72,68 @@ disables command execution tools. It receives prompts via stdin and returns
the final agent message. The worker has no published port and Foundry receives 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 neither the Codex home nor the Docker socket. Keep both containers on the same
host for Unix socket access. 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.
### Browser session lifetime
Sessions use random, revocable bearer tokens rather than JWTs. New email and OAuth
logins have a 30-day idle lifetime; authenticated requests atomically renew that
idle deadline, capped at 90 days from issuance. Idle-expired, absolute-expired,
revoked and wrong-project tokens are never revived. Logout removes the session.
Apply `db/004-session-lifetime.sql` before running this version. Existing sessions
keep their prior idle expiry, so expired logins remain expired. For legacy rows,
the absolute 90-day cap starts at migration time because issuance was not recorded;
an unexpired legacy session gains the new idle lifetime on its next authenticated
request. New rows record their actual creation time and absolute deadline.
Login and exchange responses return `expires_in: 2592000`. Protected platform
responses and `/auth/me` include `X-Foundry-Session-Expires-In` with the remaining
seconds after renewal, shortened near the absolute deadline. The frontend proxy
must renew its HttpOnly cookie using that value, with a maximum of 30 days. It must
not extend a cookie beyond the backend-provided remaining lifetime, or expose the
session token to client JavaScript. CORS exposes the lifetime header alongside
ETag where applicable. The absolute deadline stays server-authoritative.
+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;
+9
View File
@@ -0,0 +1,9 @@
BEGIN;
-- Existing sessions keep their current idle deadline: expired sessions stay expired.
-- Their original issuance time was not recorded, so the new absolute cap starts here.
ALTER TABLE project_sessions
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN absolute_expires_at timestamptz NOT NULL DEFAULT (now() + interval '90 days');
COMMIT;
+8 -1
View File
@@ -17,7 +17,8 @@ psql <<'SQL'
INSERT INTO project_accounts(project,id,google_subject,email) INSERT INTO project_accounts(project,id,google_subject,email)
VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid'); VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid');
INSERT INTO project_sessions VALUES INSERT INTO project_sessions VALUES
('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'); ('existing-session','fixture','00000000-0000-0000-0000-000000000001',now()+interval '1 hour'),
('expired-session','fixture','00000000-0000-0000-0000-000000000001',now()-interval '1 hour');
SQL SQL
psql <<'SQLROLE' psql <<'SQLROLE'
CREATE ROLE foundry_fixture LOGIN; CREATE ROLE foundry_fixture LOGIN;
@@ -33,6 +34,12 @@ BEGIN
JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id) JOIN account_identities i ON (i.project,i.account_id)=(a.project,a.id)
WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google' WHERE s.token_hash='existing-session' AND i.provider='google' AND i.subject='existing-google'
) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF; ) THEN RAISE EXCEPTION 'Existing Google login was lost'; END IF;
IF EXISTS (SELECT 1 FROM project_sessions WHERE token_hash='expired-session' AND expires_at>now()) THEN
RAISE EXCEPTION 'Migration revived expired session';
END IF;
IF NOT EXISTS (SELECT 1 FROM project_sessions WHERE token_hash='existing-session' AND absolute_expires_at>now()+interval '89 days') THEN
RAISE EXCEPTION 'Missing absolute session deadline';
END IF;
END $$; END $$;
BEGIN; BEGIN;
INSERT INTO project_accounts(project,id,email) VALUES INSERT INTO project_accounts(project,id,email) VALUES
+14 -11
View File
@@ -129,6 +129,7 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error {
return reject(w, 403, "origin_denied") return reject(w, 403, "origin_denied")
} }
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Expose-Headers", SessionExpiryHeader)
} }
switch { switch {
case r.Method == http.MethodOptions: case r.Method == http.MethodOptions:
@@ -289,8 +290,8 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin
WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now() WHERE token_hash = $1 AND project = $2 AND frontend_challenge = $3 AND expires_at > now()
RETURNING account_id RETURNING account_id
) )
INSERT INTO project_sessions INSERT INTO project_sessions(token_hash,project,account_id,expires_at)
SELECT $4, $2, account_id, now() + interval '8 hours' FROM redeemed_grant SELECT $4, $2, account_id, now() + interval '30 days' FROM redeemed_grant
`, digest(code), project, digest(verifier), digest(session)) `, digest(code), project, digest(verifier), digest(session))
if err != nil { if err != nil {
return err return err
@@ -302,7 +303,7 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin
if count == 0 { if count == 0 {
return reject(w, 401, "invalid_grant") return reject(w, 401, "invalid_grant")
} }
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds})
return nil return nil
} }
@@ -319,19 +320,21 @@ func (s *service) account(w http.ResponseWriter, r *http.Request, project string
reply(w, 204, nil) reply(w, 204, nil)
return nil return nil
} }
var id string session, err := AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization"))
var email *string if errors.Is(err, ErrInvalidSession) {
err := s.db.QueryRowContext(r.Context(), `
SELECT a.id::text, a.email FROM project_sessions s
JOIN project_accounts a ON a.project = s.project AND a.id = s.account_id
WHERE s.token_hash = $1 AND s.project = $2 AND s.expires_at > now()
`, digest(token), project).Scan(&id, &email)
if errors.Is(err, sql.ErrNoRows) {
return reject(w, 401, "invalid_session") return reject(w, 401, "invalid_session")
} }
if err != nil { if err != nil {
return err return err
} }
var email *string
err = s.db.QueryRowContext(r.Context(), "SELECT email FROM project_accounts WHERE project=$1 AND id=$2", project, session.AccountID).Scan(&email)
if err != nil {
return err
}
WriteSessionHeaders(w, session)
id := session.AccountID
reply(w, 200, payload{"project": project, "id": id, "email": email}) reply(w, 200, payload{"project": project, "id": id, "email": email})
return nil return nil
} }
+4 -4
View File
@@ -94,11 +94,11 @@ func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, a
return reject(w, 401, "invalid_credentials") return reject(w, 401, "invalid_credentials")
} }
session := token() session := token()
_, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) _, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '30 days')", digest(session), project, account)
if err != nil { if err != nil {
return err return err
} }
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds})
return nil return nil
} }
@@ -205,13 +205,13 @@ SELECT $1,id,$2,
return err return err
} }
session := token() session := token()
_, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account) _, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '30 days')", digest(session), project, account)
if err != nil { if err != nil {
return err return err
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return err return err
} }
reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800}) reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": SessionIdleSeconds})
return nil return nil
} }
+32 -9
View File
@@ -4,24 +4,47 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"net/http"
"strconv"
"strings" "strings"
) )
const SessionIdleSeconds = 30 * 24 * 60 * 60
const SessionExpiryHeader = "X-Foundry-Session-Expires-In"
var ErrInvalidSession = errors.New("invalid session") var ErrInvalidSession = errors.New("invalid session")
// Authenticate resolves only an unexpired session belonging to this project. type Session struct {
func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) { AccountID string
ExpiresIn int64
}
// AuthenticateSession atomically validates and renews a live session, bounded by
// its absolute deadline. Expired, revoked and wrong-project tokens never renew.
func AuthenticateSession(ctx context.Context, db *sql.DB, project, authorization string) (Session, error) {
value, ok := strings.CutPrefix(authorization, "Bearer ") value, ok := strings.CutPrefix(authorization, "Bearer ")
if !ok || !tokenPattern.MatchString(value) { if !ok || !tokenPattern.MatchString(value) {
return "", ErrInvalidSession return Session{}, ErrInvalidSession
} }
var id string var session Session
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
SELECT account_id::text FROM project_sessions UPDATE project_sessions
WHERE project=$1 AND token_hash=$2 AND expires_at>now() SET expires_at=LEAST(now()+interval '30 days',absolute_expires_at)
`, project, digest(value)).Scan(&id) WHERE project=$1 AND token_hash=$2 AND expires_at>now() AND absolute_expires_at>now()
RETURNING account_id::text,GREATEST(0,FLOOR(EXTRACT(EPOCH FROM (expires_at-now()))))::bigint
`, project, digest(value)).Scan(&session.AccountID, &session.ExpiresIn)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return "", ErrInvalidSession return Session{}, ErrInvalidSession
} }
return id, err return session, err
}
func WriteSessionHeaders(w http.ResponseWriter, session Session) {
w.Header().Set(SessionExpiryHeader, strconv.FormatInt(session.ExpiresIn, 10))
}
// Authenticate preserves the existing API for clients that only need an account ID.
func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) {
session, err := AuthenticateSession(ctx, db, project, authorization)
return session.AccountID, err
} }
+151
View File
@@ -0,0 +1,151 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func sessionFixture(t *testing.T) (*sql.DB, string, string) {
t.Helper()
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires isolated integration database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
project := "session-" + token()[:12]
var account string
if err = db.QueryRow("INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&account); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = db.Exec("DELETE FROM project_accounts WHERE project=$1", project); db.Close() })
return db, project, account
}
func TestSessionIdleAndAbsoluteExpiry(t *testing.T) {
db, project, account := sessionFixture(t)
for _, test := range []struct {
name, idle, absolute string
valid bool
}{
{"active", "1 hour", "90 days", true},
{"idle-expired", "-1 second", "90 days", false},
{"absolute-cap", "1 hour", "2 days", true},
{"absolute-expired", "1 hour", "-1 second", false},
} {
t.Run(test.name, func(t *testing.T) {
value := token()
_, err := db.Exec("INSERT INTO project_sessions(token_hash,project,account_id,expires_at,absolute_expires_at) VALUES($1,$2,$3,now()+$4::interval,now()+$5::interval)", digest(value), project, account, test.idle, test.absolute)
if err != nil {
t.Fatal(err)
}
var before time.Time
if err := db.QueryRow("SELECT expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&before); err != nil {
t.Fatal(err)
}
session, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value)
if !test.valid {
if err != ErrInvalidSession {
t.Fatalf("expired session accepted: %v", err)
}
var after time.Time
if err := db.QueryRow("SELECT expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&after); err != nil {
t.Fatal(err)
}
if !after.Equal(before) {
t.Fatal("expired session revived")
}
return
}
if err != nil || session.AccountID != account || session.ExpiresIn <= 0 || session.ExpiresIn > SessionIdleSeconds {
t.Fatalf("invalid renewal result: %+v %v", session, err)
}
var expiry, cap time.Time
if err := db.QueryRow("SELECT expires_at,absolute_expires_at FROM project_sessions WHERE token_hash=$1", digest(value)).Scan(&expiry, &cap); err != nil {
t.Fatal(err)
}
if expiry.After(cap) || (test.name == "absolute-cap" && !expiry.Equal(cap)) {
t.Fatal("absolute cap not enforced")
}
if test.name == "active" && session.ExpiresIn < SessionIdleSeconds-2 {
t.Fatal("active session did not receive 30 idle days")
}
})
}
}
func TestSessionIsolationRevocationAndConcurrentRenewal(t *testing.T) {
db, project, account := sessionFixture(t)
value := token()
if _, err := db.Exec("INSERT INTO project_sessions(token_hash,project,account_id,expires_at,absolute_expires_at) VALUES($1,$2,$3,now()+interval '1 hour',now()+interval '2 days')", digest(value), project, account); err != nil {
t.Fatal(err)
}
for _, input := range []struct{ project, authorization string }{{"other", "Bearer " + value}, {project, "Bearer bad-token"}, {project, ""}} {
if _, err := AuthenticateSession(context.Background(), db, input.project, input.authorization); err != ErrInvalidSession {
t.Fatalf("invalid or cross-project token accepted: %v", err)
}
}
var group sync.WaitGroup
for range 4 {
group.Add(1)
go func() {
defer group.Done()
session, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value)
if err != nil || session.ExpiresIn > 2*86400 {
t.Errorf("concurrent renewal escaped cap: %+v %v", session, err)
}
}()
}
group.Wait()
if _, err := db.Exec("DELETE FROM project_sessions WHERE token_hash=$1", digest(value)); err != nil {
t.Fatal(err)
}
if _, err := AuthenticateSession(context.Background(), db, project, "Bearer "+value); err != ErrInvalidSession {
t.Fatal("revoked session renewed")
}
}
func TestOAuthExchangeIssuesLongLivedSessionAndMeReturnsRemainingLifetime(t *testing.T) {
db, project, account := sessionFixture(t)
code, verifier := token(), token()
_, err := db.Exec("INSERT INTO login_grants(token_hash,project,account_id,frontend_challenge,expires_at) VALUES($1,$2,$3,$4,now()+interval '1 minute')", digest(code), project, account, digest(verifier))
if err != nil {
t.Fatal(err)
}
s := &service{db: db}
r := httptest.NewRequest("POST", "/exchange", nil)
r.Header.Set("X-Foundry-Code", code)
r.Header.Set("X-Foundry-Verifier", verifier)
out := httptest.NewRecorder()
if err = s.exchange(out, r, project); err != nil || out.Code != 200 {
t.Fatalf("exchange: %d %v", out.Code, err)
}
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err = json.Unmarshal(out.Body.Bytes(), &result); err != nil || result.ExpiresIn != SessionIdleSeconds {
t.Fatal("incorrect login lifetime")
}
var idle, absolute float64
if err = db.QueryRow("SELECT EXTRACT(EPOCH FROM(expires_at-created_at)),EXTRACT(EPOCH FROM(absolute_expires_at-created_at)) FROM project_sessions WHERE token_hash=$1", digest(result.AccessToken)).Scan(&idle, &absolute); err != nil || idle < SessionIdleSeconds-2 || absolute != 90*86400 {
t.Fatalf("wrong session deadlines: %f %f %v", idle, absolute, err)
}
r = httptest.NewRequest("GET", "/me", strings.NewReader(""))
r.Header.Set("Authorization", "Bearer "+result.AccessToken)
out = httptest.NewRecorder()
if err = s.account(out, r, project, false); err != nil || out.Header().Get(SessionExpiryHeader) == "" {
t.Fatal("missing renewal header")
}
}
+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")
}
}
+21 -2
View File
@@ -69,7 +69,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
return return
} }
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Expose-Headers", "ETag") w.Header().Set("Access-Control-Expose-Headers", "ETag, "+auth.SessionExpiryHeader)
} }
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
@@ -77,11 +77,29 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
reply(w, 204, nil) reply(w, 204, nil)
return return
} }
account, err := auth.Authenticate(r.Context(), s.db, project, r.Header.Get("Authorization")) session, err := auth.AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization"))
if errors.Is(err, auth.ErrInvalidSession) { if errors.Is(err, auth.ErrInvalidSession) {
reject(w, 401, "invalid_session") reject(w, 401, "invalid_session")
return return
} }
account := session.AccountID
if err == nil {
auth.WriteSessionHeaders(w, session)
}
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 { if err == nil {
err = handle(w, r, project, account) err = handle(w, r, project, account)
} }
@@ -106,6 +124,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi
s := &service{db: db, origin: origin, callbacks: callbacks, onboardingConfigs: configs} 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", 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}", 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}/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", s.protected(callbacks, s.tags))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/tags/{tag}", s.protected(callbacks, s.deleteTag)) 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" "net/http/httptest"
"os" "os"
"regexp" "regexp"
"strconv"
"strings"
"testing" "testing"
_ "github.com/jackc/pgx/v5/stdlib" _ "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) board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201)
boardPath := workspacePath + "/materials/" + board["id"].(string) 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("GET", boardPath+"/layouts", tokens[1], nil, 200)
call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200) 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") 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, 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("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204)
call("GET", textPath, tokens[2], nil, 404) call("GET", textPath, tokens[2], nil, 404)
call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) 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 { var input struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Slug *string `json:"slug,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
@@ -67,7 +68,7 @@ func (s *service) materials(w http.ResponseWriter, r *http.Request, project, acc
return nil return nil
} }
content, ok := normalizeContent(input.Kind, input.Content) 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") reject(w, 400, "invalid_material")
return nil return nil
} }
@@ -85,11 +86,11 @@ func (s *service) materials(w http.ResponseWriter, r *http.Request, project, acc
reject(w, 403, "forbidden") reject(w, 403, "forbidden")
return nil return nil
} }
var id string var id, slug string
err = tx.QueryRowContext(r.Context(), ` err = tx.QueryRowContext(r.Context(), `
INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by) INSERT INTO materials(project,workspace_id,kind,title,description,content,created_by,slug)
VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id::text,slug
`, project, workspace, input.Kind, input.Title, input.Description, content, account).Scan(&id) `, project, workspace, input.Kind, input.Title, input.Description, content, account, input.Slug).Scan(&id, &slug)
if err != nil { if err != nil {
return err return err
} }
@@ -97,7 +98,7 @@ VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id::text
return err return err
} }
setVersion(w, 1) 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 return nil
} }
@@ -120,7 +121,7 @@ func (s *service) material(w http.ResponseWriter, r *http.Request, project, acco
var result json.RawMessage var result json.RawMessage
var version int64 var version int64
err = tx.QueryRowContext(r.Context(), ` 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 '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 FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3
`, project, workspace, material).Scan(&result, &version) `, 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 var result sql.Result
if r.Method == http.MethodPatch { if r.Method == http.MethodPatch {
var input struct { var input struct {
Slug *string `json:"slug,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
@@ -150,14 +152,14 @@ FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3
return nil return nil
} }
content, ok := normalizeContent(kind, input.Content) 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") reject(w, 400, "invalid_material")
return nil return nil
} }
result, err = tx.ExecContext(r.Context(), ` 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 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 { } else {
if role != "admin" { if role != "admin" {
reject(w, 403, "admin_required") 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") tag := r.URL.Query().Get("tag")
rows, err := s.db.QueryContext(r.Context(), ` 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, '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) '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) 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 { switch r.Method {
case http.MethodGet: case http.MethodGet:
rows, err := s.db.QueryContext(r.Context(), ` 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) 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 WHERE m.project=$1 AND m.account_id=$2 ORDER BY lower(w.name),w.id LIMIT 200
`, project, account) `, 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() defer rows.Close()
items := []response{} items := []response{}
for rows.Next() { for rows.Next() {
var id, name, role, owner string var id, name, role, owner, slug string
if err := rows.Scan(&id, &name, &role, &owner); err != nil { if err := rows.Scan(&id, &name, &role, &owner, &slug); err != nil {
return err 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 { if err := rows.Err(); err != nil {
return err 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}) reply(w, 200, response{"items": items})
case http.MethodPost: case http.MethodPost:
var input struct { var input struct {
Name string `json:"name"` Name string `json:"name"`
Slug *string `json:"slug,omitempty"`
} }
if !decode(w, r, &input) { if !decode(w, r, &input) {
return nil return nil
} }
input.Name = strings.TrimSpace(input.Name) 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") reject(w, 400, "invalid_workspace")
return nil 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 { if err != nil {
return err 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 { if err = tx.Commit(); err != nil {
return err 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: default:
reject(w, 405, "method_not_allowed") 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 { switch r.Method {
case http.MethodGet: case http.MethodGet:
var name string var name, slug string
if err := tx.QueryRowContext(r.Context(), "SELECT name FROM workspaces WHERE project=$1 AND id=$2", project, workspace).Scan(&name); err != nil { 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 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 return nil
case http.MethodPatch: case http.MethodPatch:
if role != "admin" { if role != "admin" {
@@ -116,17 +127,18 @@ func (s *service) workspace(w http.ResponseWriter, r *http.Request, project, acc
return nil return nil
} }
var input struct { var input struct {
Name string `json:"name"` Name string `json:"name"`
Slug *string `json:"slug,omitempty"`
} }
if !decode(w, r, &input) { if !decode(w, r, &input) {
return nil return nil
} }
input.Name = strings.TrimSpace(input.Name) 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") reject(w, 400, "invalid_workspace")
return nil 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: case http.MethodDelete:
if owner != account { if owner != account {
reject(w, 403, "owner_required") reject(w, 403, "owner_required")