Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34c461a61c | |||
| 022086beb8 | |||
| 6f504ebe15 | |||
| 57aab71d45 | |||
| 6f36ef27bb | |||
| e8ed5e4f9f | |||
| b1e0d783e6 | |||
| 4043c89520 |
@@ -29,6 +29,17 @@ Google OAuth 的授权回调地址为 `https://api.foundry.entroia.com/auth/goog
|
||||
|
||||
用同一个 Google 测试账号分别登录两个项目,确认用户 ID 不同。用 A 的会话请求 B 的 `me` 应返回 401。验证兑换码重放被拒绝,并确认退出 A 后 B 的会话仍有效。
|
||||
|
||||
## Runtime HTTPS trust
|
||||
|
||||
The Go backend needs an operating-system CA bundle for Google and Slack HTTPS
|
||||
requests. The `node:24-bookworm-slim` runtime does not include that trust store;
|
||||
Node's own successful HTTPS checks do not validate Go's trust configuration.
|
||||
`deploy/release.sh` mounts the deployment host's
|
||||
`/etc/ssl/certs/ca-certificates.crt` read-only and sets `SSL_CERT_FILE` to that
|
||||
path. The deployment fails before switching traffic if the host bundle is
|
||||
missing or unreadable in the candidate. Keep TLS certificate verification
|
||||
enabled and maintain the deployment host's trusted CA package.
|
||||
|
||||
## Codex execution
|
||||
|
||||
When enabled, `POST /projects/{project}/codex/exec` accepts a JSON object
|
||||
@@ -61,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
|
||||
neither the Codex home nor the Docker socket. Keep both containers on the same
|
||||
host for Unix socket access.
|
||||
|
||||
### Board Canvas snapshots and slugs
|
||||
|
||||
Apply `db/003-canvas.sql` through the normal migration runner before starting this
|
||||
version. `GET /projects/{project}/workspaces/{workspace}/materials/{board}/canvas`
|
||||
returns `{version, document}` and a quoted `ETag`. An untouched board returns
|
||||
version 1 and an empty schema-1 graph. `PATCH` takes `{document: <full snapshot>}`
|
||||
and the quoted `If-Match`; it atomically replaces the snapshot and returns
|
||||
`{version}` with the new `ETag`. Canvas and material metadata have separate
|
||||
versions. GET uses existing material read permissions; PATCH requires editor or
|
||||
admin. Missing versions return 428, stale versions 412 `version_conflict`, invalid
|
||||
graphs 400 `invalid_canvas`, and bodies over 5 MiB (UTF-8 bytes) 413. PNG/JPEG/WebP/
|
||||
GIF data URLs up to 2 MiB decoded and HTTPS image URLs are supported. Temporary
|
||||
node selection/drag/measurement state and mirrored styles are discarded.
|
||||
|
||||
Workspace and material create/get/list responses include `slug`. Create and
|
||||
metadata PATCH accept an optional slug (1–100 lowercase ASCII letters/digits,
|
||||
with single hyphens between segments; UUID-form custom values are reserved).
|
||||
Workspace slugs are unique within a project, material slugs within a workspace;
|
||||
duplicates return 409. Defaults use the ASCII name/title and UUID, falling back
|
||||
to a generic prefix for names without ASCII letters/digits. Existing records are
|
||||
backfilled. Renaming leaves slugs stable unless explicitly supplied. Workspace
|
||||
and material path parameters accept either ID or slug, with unchanged permissions.
|
||||
Canvas uses its board material's slug; there is no separate Canvas identity.
|
||||
Slugs enable frontend SEO URLs but do not make private content public or add
|
||||
search-engine metadata. Changing a custom slug does not retain an old alias.
|
||||
|
||||
Frontend follow-up: allowlist the Canvas proxy route and set its body limit to
|
||||
at least 5 MiB; load the server snapshot, offer migration of old local drafts,
|
||||
serialize autosaves and handle 412 explicitly. Restore node style dimensions
|
||||
from width/height and render edges as Bézier curves. These frontend changes are
|
||||
outside this Core delivery; local drafts alone are not database persistence.
|
||||
|
||||
Canvas schema 1 also supports frames: `type: "frame"` with `data.kind: "frame"`.
|
||||
Frame names use `data.title`; optional `data.labelMode` is `hidden`, `fixed`, or
|
||||
`scaled` (frontend default: `fixed`). Frames cannot rotate, connect to edges,
|
||||
or belong to other frames. Any non-frame node can specify `data.frameId`, which
|
||||
must reference a frame in the same snapshot; forward references are supported.
|
||||
Membership is explicit and independent of geometry: moving a frame over existing
|
||||
content does not add members. The frontend assigns membership on content drop
|
||||
and implements label zoom behavior. Core preserves the supplied membership and
|
||||
label mode without inferring them from positions. Renderer-only `zIndex` is
|
||||
ignored alongside transient state; restore frame layering in the frontend.
|
||||
This extends existing JSONB snapshots and requires no additional migration.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -17,7 +17,8 @@ psql <<'SQL'
|
||||
INSERT INTO project_accounts(project,id,google_subject,email)
|
||||
VALUES ('fixture','00000000-0000-0000-0000-000000000001','existing-google','existing@example.invalid');
|
||||
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
|
||||
psql <<'SQLROLE'
|
||||
CREATE ROLE foundry_fixture LOGIN;
|
||||
@@ -33,6 +34,12 @@ BEGIN
|
||||
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'
|
||||
) 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 $$;
|
||||
BEGIN;
|
||||
INSERT INTO project_accounts(project,id,email) VALUES
|
||||
|
||||
@@ -39,6 +39,10 @@ PYCONFIG
|
||||
export FOUNDRY_PROJECTS
|
||||
|
||||
test -s "$foundry/database.env"
|
||||
# Go's HTTPS client uses the OS trust store, not Node's bundled roots.
|
||||
# The slim Node runtime has no system CA bundle of its own.
|
||||
ca_bundle=/etc/ssl/certs/ca-certificates.crt
|
||||
test -s "$ca_bundle"
|
||||
docker network inspect "$network" >/dev/null
|
||||
python3 "$(dirname "$0")/migrate.py"
|
||||
|
||||
@@ -88,6 +92,8 @@ docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" "${slack
|
||||
-e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \
|
||||
--user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \
|
||||
--read-only --memory 128m \
|
||||
--mount "type=bind,source=$ca_bundle,target=/etc/ssl/certs/ca-certificates.crt,readonly" \
|
||||
-e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
|
||||
--mount "type=bind,source=$release/foundry,target=/opt/foundry,readonly" \
|
||||
node:24-bookworm-slim /opt/foundry >/dev/null
|
||||
|
||||
@@ -98,7 +104,9 @@ trap cleanup_candidate EXIT
|
||||
|
||||
docker exec "$candidate" node -e '
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
async function verify() {
|
||||
assert.ok(fs.readFileSync(process.env.SSL_CERT_FILE, "utf8").includes("-----BEGIN CERTIFICATE-----"));
|
||||
const base = "http://127.0.0.1:8080";
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
|
||||
+14
-11
@@ -129,6 +129,7 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error {
|
||||
return reject(w, 403, "origin_denied")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Expose-Headers", SessionExpiryHeader)
|
||||
}
|
||||
switch {
|
||||
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()
|
||||
RETURNING account_id
|
||||
)
|
||||
INSERT INTO project_sessions
|
||||
SELECT $4, $2, account_id, now() + interval '8 hours' FROM redeemed_grant
|
||||
INSERT INTO project_sessions(token_hash,project,account_id,expires_at)
|
||||
SELECT $4, $2, account_id, now() + interval '30 days' FROM redeemed_grant
|
||||
`, digest(code), project, digest(verifier), digest(session))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -302,7 +303,7 @@ func (s *service) exchange(w http.ResponseWriter, r *http.Request, project strin
|
||||
if count == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -319,19 +320,21 @@ func (s *service) account(w http.ResponseWriter, r *http.Request, project string
|
||||
reply(w, 204, nil)
|
||||
return nil
|
||||
}
|
||||
var id string
|
||||
var email *string
|
||||
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) {
|
||||
session, err := AuthenticateSession(r.Context(), s.db, project, r.Header.Get("Authorization"))
|
||||
if errors.Is(err, ErrInvalidSession) {
|
||||
return reject(w, 401, "invalid_session")
|
||||
}
|
||||
if err != nil {
|
||||
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})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,11 +94,11 @@ func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, a
|
||||
return reject(w, 401, "invalid_credentials")
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -205,13 +205,13 @@ SELECT $1,id,$2,
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,24 +4,47 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const SessionIdleSeconds = 30 * 24 * 60 * 60
|
||||
const SessionExpiryHeader = "X-Foundry-Session-Expires-In"
|
||||
|
||||
var ErrInvalidSession = errors.New("invalid session")
|
||||
|
||||
// Authenticate resolves only an unexpired session belonging to this project.
|
||||
func Authenticate(ctx context.Context, db *sql.DB, project, authorization string) (string, error) {
|
||||
type Session struct {
|
||||
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 ")
|
||||
if !ok || !tokenPattern.MatchString(value) {
|
||||
return "", ErrInvalidSession
|
||||
return Session{}, ErrInvalidSession
|
||||
}
|
||||
var id string
|
||||
var session Session
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT account_id::text FROM project_sessions
|
||||
WHERE project=$1 AND token_hash=$2 AND expires_at>now()
|
||||
`, project, digest(value)).Scan(&id)
|
||||
UPDATE project_sessions
|
||||
SET expires_at=LEAST(now()+interval '30 days',absolute_expires_at)
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
|
||||
return
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
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) {
|
||||
reject(w, 401, "invalid_session")
|
||||
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 {
|
||||
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}
|
||||
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))
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
@@ -154,7 +156,108 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
}
|
||||
board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201)
|
||||
boardPath := workspacePath + "/materials/" + board["id"].(string)
|
||||
layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"1"`)
|
||||
|
||||
canvasPath := boardPath + "/canvas"
|
||||
empty := call("GET", canvasPath, tokens[0], nil, 200)
|
||||
if empty["version"] != float64(1) {
|
||||
t.Fatal("old board not initialized at version 1")
|
||||
}
|
||||
snapshot := response{"document": json.RawMessage(canvasFixture)}
|
||||
call("PATCH", canvasPath, "", snapshot, 401, `"1"`)
|
||||
call("PATCH", canvasPath, tokens[1], snapshot, 403, `"1"`)
|
||||
call("PATCH", canvasPath, tokens[2], snapshot, 404, `"1"`)
|
||||
call("PATCH", canvasPath, tokens[0], snapshot, 428)
|
||||
call("PATCH", canvasPath, tokens[0], snapshot, 200, `"1"`)
|
||||
call("PATCH", canvasPath, tokens[0], snapshot, 412, `"1"`)
|
||||
saved := call("GET", canvasPath, tokens[0], nil, 200)
|
||||
expected, _ := normalizeCanvas(json.RawMessage(canvasFixture))
|
||||
expectedJSON, _ := json.Marshal(json.RawMessage(expected))
|
||||
actualJSON, _ := json.Marshal(saved["document"])
|
||||
if !bytes.Equal(expectedJSON, actualJSON) {
|
||||
t.Fatal("snapshot failed round trip")
|
||||
}
|
||||
call("PATCH", canvasPath, tokens[0], response{"document": response{}}, 400, `"2"`)
|
||||
call("PATCH", boardPath, tokens[0], response{"title": "Renamed", "slug": "my-canvas", "content": response{}}, 204, `"1"`)
|
||||
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(2) {
|
||||
t.Fatal("rename changed canvas version")
|
||||
}
|
||||
slugPath := workspacePath + "/materials/my-canvas"
|
||||
if call("GET", slugPath, tokens[0], nil, 200)["title"] != "Renamed" {
|
||||
t.Fatal("slug lookup failed")
|
||||
}
|
||||
// A PNG at the local upload limit must survive JSON and database storage.
|
||||
var imageDoc map[string]any
|
||||
json.Unmarshal([]byte(canvasFixture), &imageDoc)
|
||||
imageBytes := make([]byte, 2<<20)
|
||||
copy(imageBytes, []byte{137, 80, 78, 71, 13, 10, 26, 10})
|
||||
data := imageDoc["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)
|
||||
data["kind"] = "image"
|
||||
data["src"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes)
|
||||
call("PATCH", canvasPath, tokens[0], response{"document": imageDoc}, 200, `"2"`)
|
||||
restored := call("GET", canvasPath, tokens[0], nil, 200)
|
||||
if restored["document"].(map[string]any)["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)["src"] != data["src"] {
|
||||
t.Fatal("large image changed")
|
||||
}
|
||||
call("PATCH", canvasPath, tokens[0], response{"document": strings.Repeat("x", canvasLimit)}, 413, `"3"`)
|
||||
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(3) {
|
||||
t.Fatal("rejected snapshot changed version")
|
||||
}
|
||||
// Restore the material version expected by the pre-existing layout checks below.
|
||||
|
||||
// Exercise two independent HTTP transactions against the same expected version.
|
||||
encodedSnapshot, _ := json.Marshal(snapshot)
|
||||
statuses := make(chan int, 2)
|
||||
start := make(chan struct{})
|
||||
for range 2 {
|
||||
go func() {
|
||||
<-start
|
||||
request := httptest.NewRequest("PATCH", canvasPath, bytes.NewReader(encodedSnapshot))
|
||||
request.Header.Set("Authorization", "Bearer "+tokens[0])
|
||||
request.Header.Set("If-Match", `"3"`)
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
statuses <- recorder.Code
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
one, two := <-statuses, <-statuses
|
||||
if !((one == 200 && two == 412) || (one == 412 && two == 200)) {
|
||||
t.Fatalf("concurrent writes: %d %d", one, two)
|
||||
}
|
||||
if call("GET", boardPath, tokens[0], nil, 200)["title"] != "Renamed" {
|
||||
t.Fatal("canvas save changed title")
|
||||
}
|
||||
call("PATCH", workspacePath, tokens[0], response{"name": "Team", "slug": "research-team"}, 204)
|
||||
call("GET", base+"/workspaces/research-team/materials/my-canvas/canvas", tokens[0], nil, 200)
|
||||
call("PATCH", workspacePath, tokens[1], response{"name": "Team", "slug": "stolen"}, 403)
|
||||
call("POST", base+"/workspaces", tokens[0], response{"name": "Duplicate", "slug": "research-team"}, 409)
|
||||
call("PATCH", workspacePath, tokens[0], response{"name": "Renamed Team"}, 204)
|
||||
if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" {
|
||||
t.Fatal("rename changed slug")
|
||||
}
|
||||
|
||||
// Frames round-trip through PostgreSQL with all label modes and explicit memberships.
|
||||
for index, mode := range []string{"hidden", "fixed", "scaled"} {
|
||||
version := strconv.Itoa(4 + index)
|
||||
raw := strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1)
|
||||
call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(raw)}, 200, `"`+version+`"`)
|
||||
persisted := call("GET", canvasPath, tokens[0], nil, 200)
|
||||
normalized, _ := normalizeCanvas(json.RawMessage(raw))
|
||||
want, _ := json.Marshal(json.RawMessage(normalized))
|
||||
got, _ := json.Marshal(persisted["document"])
|
||||
if !bytes.Equal(want, got) {
|
||||
t.Fatal("frame snapshot not preserved")
|
||||
}
|
||||
}
|
||||
invalidFrame := strings.Replace(frameFixture, `"frameId":"frame"`, `"frameId":"missing"`, 1)
|
||||
rejected := call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(invalidFrame)}, 400, `"7"`)
|
||||
if rejected["error"] != "invalid_canvas" {
|
||||
t.Fatal("unexpected frame error")
|
||||
}
|
||||
if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(7) {
|
||||
t.Fatal("invalid frame changed saved version")
|
||||
}
|
||||
layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"2"`)
|
||||
call("GET", boardPath+"/layouts", tokens[1], nil, 200)
|
||||
call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204)
|
||||
hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200)
|
||||
@@ -162,7 +265,7 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
t.Fatal("board share disclosed private child material")
|
||||
}
|
||||
call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`)
|
||||
call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"2"`)
|
||||
call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"3"`)
|
||||
call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204)
|
||||
call("GET", textPath, tokens[2], nil, 404)
|
||||
call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user