From bfcea1b3af34ef97fd8976e1a3d57adc41c6c45e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 5 Nov 2026 18:36:53 +0800 Subject: [PATCH] Add structured materials, canvas layouts, tags and content permissions --- internal/platform/content.go | 129 +++++++++++ internal/platform/grants.go | 96 +++++++++ internal/platform/http.go | 11 + internal/platform/integration_test.go | 46 +++- internal/platform/layouts.go | 158 ++++++++++++++ internal/platform/materials.go | 297 ++++++++++++++++++++++++++ internal/platform/tags.go | 169 +++++++++++++++ 7 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 internal/platform/content.go create mode 100644 internal/platform/grants.go create mode 100644 internal/platform/layouts.go create mode 100644 internal/platform/materials.go create mode 100644 internal/platform/tags.go diff --git a/internal/platform/content.go b/internal/platform/content.go new file mode 100644 index 0000000..262f330 --- /dev/null +++ b/internal/platform/content.go @@ -0,0 +1,129 @@ +package platform + +import ( + "bytes" + "encoding/json" + "io" + "net/url" + "regexp" +) + +type Paragraph struct { + ID string `json:"id"` + Text string `json:"text"` + Description string `json:"description,omitempty"` +} +type Section struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Paragraphs []Paragraph `json:"paragraphs"` +} +type Article struct { + Sections []Section `json:"sections"` +} +type Image struct { + URL string `json:"url"` + Alt string `json:"alt,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} +type CardPart struct { + Kind string `json:"kind"` + Text *string `json:"text,omitempty"` + Article *Article `json:"article,omitempty"` + Image *Image `json:"image,omitempty"` +} +type Content struct { + SchemaVersion int `json:"schema_version"` + Text *string `json:"text,omitempty"` + Article *Article `json:"article,omitempty"` + Image *Image `json:"image,omitempty"` + Parts []CardPart `json:"parts,omitempty"` +} + +var blockID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +func validArticle(article *Article) bool { + if article == nil || len(article.Sections) > 200 { + return false + } + seen := map[string]bool{} + total := 0 + for _, section := range article.Sections { + if !blockID.MatchString(section.ID) || seen[section.ID] || len(section.Title) > 300 || len(section.Description) > 10000 { + return false + } + seen[section.ID] = true + total += len(section.Paragraphs) + if total > 2000 { + return false + } + for _, paragraph := range section.Paragraphs { + if !blockID.MatchString(paragraph.ID) || seen[paragraph.ID] || len(paragraph.Text) > 100000 || len(paragraph.Description) > 10000 { + return false + } + seen[paragraph.ID] = true + } + } + return true +} +func validImage(image *Image) bool { + if image == nil || len(image.URL) > 2048 || len(image.Alt) > 2000 || image.Width < 0 || image.Width > 100000 || image.Height < 0 || image.Height > 100000 { + return false + } + u, err := url.Parse(image.URL) + return err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.Fragment == "" +} +func validPart(part CardPart) bool { + switch part.Kind { + case "text": + return part.Text != nil && len(*part.Text) <= 100000 && part.Article == nil && part.Image == nil + case "article": + return part.Text == nil && part.Image == nil && validArticle(part.Article) + case "image": + return part.Text == nil && part.Article == nil && validImage(part.Image) + default: + return false + } +} +func normalizeContent(kind string, raw json.RawMessage) (json.RawMessage, bool) { + if raw = bytes.TrimSpace(raw); len(raw) == 0 || raw[0] != '{' { + return nil, false + } + var content Content + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if d.Decode(&content) != nil || d.Decode(new(any)) != io.EOF { + return nil, false + } + if content.SchemaVersion == 0 { + content.SchemaVersion = 1 + } + if content.SchemaVersion != 1 { + return nil, false + } + switch kind { + case "text", "article", "image": + if len(content.Parts) > 0 || !validPart(CardPart{Kind: kind, Text: content.Text, Article: content.Article, Image: content.Image}) { + return nil, false + } + case "board": + if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) > 0 { + return nil, false + } + case "card": + if content.Text != nil || content.Article != nil || content.Image != nil || len(content.Parts) < 1 || len(content.Parts) > 20 { + return nil, false + } + for _, part := range content.Parts { + if !validPart(part) { + return nil, false + } + } + default: + return nil, false + } + out, err := json.Marshal(content) + return out, err == nil +} diff --git a/internal/platform/grants.go b/internal/platform/grants.go new file mode 100644 index 0000000..6dde717 --- /dev/null +++ b/internal/platform/grants.go @@ -0,0 +1,96 @@ +package platform + +import "net/http" + +func (s *service) materialGrants(w http.ResponseWriter, r *http.Request, project, account string) error { + workspace, material := r.PathValue("workspace"), r.PathValue("material") + if r.Method != http.MethodGet && r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), "SELECT account_id::text,role FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 ORDER BY account_id LIMIT 1000", project, workspace, material) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, role string + if err := rows.Scan(&id, &role); err != nil { + return err + } + items = append(items, response{"account_id": id, "role": role}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + var input struct { + AccountID string `json:"account_id"` + Role string `json:"role"` + } + if !decode(w, r, &input) { + return nil + } + if input.Role != "editor" && input.Role != "viewer" { + reject(w, 400, "invalid_role") + return nil + } + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_grants(project,workspace_id,material_id,account_id,role) VALUES($1,$2,$3,$4,$5) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET role=EXCLUDED.role +`, project, workspace, material, input.AccountID, input.Role) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} +func (s *service) deleteMaterialGrant(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM material_grants WHERE project=$1 AND workspace_id=$2 AND material_id=$3 AND account_id=$4", project, workspace, material, r.PathValue("account")) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} diff --git a/internal/platform/http.go b/internal/platform/http.go index 58d24d2..4067bdf 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -69,6 +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") } if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") @@ -103,6 +104,16 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origin string, configs map[string]OnboardingConfig) { 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}/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)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/tags", s.protected(callbacks, s.materialTags)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants", s.protected(callbacks, s.materialGrants)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/grants/{account}", s.protected(callbacks, s.deleteMaterialGrant)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts", s.protected(callbacks, s.layouts)) + mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/layouts/{layout}", s.protected(callbacks, s.layouts)) mux.HandleFunc("/projects/{project}/onboarding", s.protected(callbacks, s.onboarding)) mux.HandleFunc("/s/{token}", s.shortLink) mux.HandleFunc("/projects/{project}/shares/redeem", s.protected(callbacks, s.redeemShare)) diff --git a/internal/platform/integration_test.go b/internal/platform/integration_test.go index ead8ccc..14cff61 100644 --- a/internal/platform/integration_test.go +++ b/internal/platform/integration_test.go @@ -67,7 +67,7 @@ func TestOnboardingAndSharing(t *testing.T) { Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, }) - call := func(method, path, token string, body any, status int) response { + call := func(method, path, token string, body any, status int, versions ...string) response { t.Helper() encoded, err := json.Marshal(body) if err != nil { @@ -77,6 +77,9 @@ func TestOnboardingAndSharing(t *testing.T) { if token != "" { r.Header.Set("Authorization", "Bearer "+token) } + if len(versions) > 0 { + r.Header.Set("If-Match", versions[0]) + } w := httptest.NewRecorder() mux.ServeHTTP(w, r) if w.Code != status { @@ -119,6 +122,47 @@ func TestOnboardingAndSharing(t *testing.T) { contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) call("GET", workspacePath, tokens[2], nil, 404) + articleContent := response{"schema_version": 1, "article": response{"sections": []any{response{ + "id": "chapter-1", "title": "Chapter", "description": "Chapter summary", "paragraphs": []any{response{"id": "p1", "text": "Paragraph", "description": "Paragraph summary"}}, + }}}} + created := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Article", "content": articleContent}, 201) + articleID := created["id"].(string) + text := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "text", "title": "Alpha", "content": response{"text": "Original"}}, 201) + textPath := workspacePath + "/materials/" + text["id"].(string) + call("GET", textPath, tokens[2], nil, 404) + call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) + call("GET", textPath, tokens[2], nil, 200) + changed := response{"title": "Beta", "content": response{"text": "Changed"}} + call("PATCH", textPath, tokens[2], changed, 403, `"1"`) + call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204) + call("PATCH", textPath, tokens[2], changed, 428) + call("PATCH", textPath, tokens[2], changed, 204, `"1"`) + call("PATCH", textPath, tokens[0], changed, 412, `"1"`) + call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Invalid", "content": response{"markdown": "raw markdown"}}, 400) + tag := call("POST", workspacePath+"/tags", tokens[0], response{"name": "Research", "color": "#123456"}, 201) + call("GET", workspacePath+"/tags", tokens[2], nil, 404) + call("POST", textPath+"/tags", tokens[2], response{"tags": []string{tag["id"].(string)}}, 204, `"2"`) + call("POST", textPath+"/open", tokens[2], nil, 204) + filtered := call("GET", workspacePath+"/materials?sort=opened&tag="+tag["id"].(string), tokens[2], nil, 200) + if len(filtered["items"].([]any)) != 1 { + t.Fatal("tag-filtered material list leaked inaccessible materials") + } + 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"`) + 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) + if len(hidden["items"].([]any)) != 0 { + 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", 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) + call("PATCH", textPath, tokens[0], changed, 412, `"3"`) + email := "fixture@example.invalid" password := "a-long-fixture-password" call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) diff --git a/internal/platform/layouts.go b/internal/platform/layouts.go new file mode 100644 index 0000000..4674f1b --- /dev/null +++ b/internal/platform/layouts.go @@ -0,0 +1,158 @@ +package platform + +import ( + "database/sql" + "encoding/json" + "math" + "net/http" +) + +type placement struct { + MaterialID string `json:"material_id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + ZIndex int `json:"z_index"` +} + +func (p placement) valid() bool { + return !math.IsNaN(p.X) && !math.IsNaN(p.Y) && !math.IsNaN(p.Width) && !math.IsNaN(p.Height) && + p.X >= -1000000 && p.X <= 1000000 && p.Y >= -1000000 && p.Y <= 1000000 && + p.Width >= 1 && p.Width <= 100000 && p.Height >= 1 && p.Height <= 100000 && + p.ZIndex >= -1000000 && p.ZIndex <= 1000000 +} +func (s *service) layouts(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, board, layout := r.PathValue("workspace"), r.PathValue("material"), r.PathValue("layout") + if (layout == "" && (r.Method == http.MethodPatch || r.Method == http.MethodDelete)) || (layout != "" && (r.Method == http.MethodGet || r.Method == http.MethodPost)) { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) + if err != nil { + return err + } + if kind != "board" { + reject(w, 400, "not_a_board") + return nil + } + var version int64 + if err = tx.QueryRowContext(r.Context(), "SELECT version FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board).Scan(&version); err != nil { + return err + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), ` +SELECT jsonb_build_object('id',l.id,'material_id',l.material_id,'x',l.x,'y',l.y,'width',l.width,'height',l.height,'z_index',l.z_index,'version',l.version) +FROM board_layouts l WHERE l.project=$1 AND l.workspace_id=$2 AND l.board_id=$3 +AND (EXISTS(SELECT 1 FROM workspace_members m WHERE m.project=l.project AND m.workspace_id=l.workspace_id AND m.account_id=$4) + OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(l.project,l.workspace_id,l.material_id) AND g.account_id=$4)) +ORDER BY l.z_index,l.id LIMIT 2000 +`, project, workspace, board, account) + if err != nil { + return err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var item json.RawMessage + if err := rows.Scan(&item); err != nil { + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return err + } + setVersion(w, version) + reply(w, 200, response{"items": items, "board_version": version}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + expected, ok := expectedVersion(w, r) + if !ok { + return nil + } + if expected != version { + reject(w, 412, "version_conflict") + return nil + } + var input placement + if r.Method != http.MethodDelete { + if !decode(w, r, &input) { + return nil + } + if !input.valid() || input.MaterialID == board { + reject(w, 400, "invalid_layout") + return nil + } + refKind, _, err := lockMaterial(r.Context(), tx, project, workspace, input.MaterialID, account) + if err != nil { + return err + } + if refKind == "board" { + reject(w, 400, "nested_board_not_supported") + return nil + } + } + var result sql.Result + switch r.Method { + case http.MethodPost: + var count int + if err = tx.QueryRowContext(r.Context(), "SELECT count(*) FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3", project, workspace, board).Scan(&count); err != nil { + return err + } + if count >= 2000 { + reject(w, 409, "board_full") + return nil + } + err = tx.QueryRowContext(r.Context(), ` +INSERT INTO board_layouts(project,workspace_id,board_id,material_id,x,y,width,height,z_index) +VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text +`, project, workspace, board, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex).Scan(&layout) + case http.MethodPatch: + result, err = tx.ExecContext(r.Context(), ` +UPDATE board_layouts SET material_id=$5,x=$6,y=$7,width=$8,height=$9,z_index=$10,version=version+1 +WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4 +`, project, workspace, board, layout, input.MaterialID, input.X, input.Y, input.Width, input.Height, input.ZIndex) + case http.MethodDelete: + result, err = tx.ExecContext(r.Context(), "DELETE FROM board_layouts WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND id=$4", project, workspace, board, layout) + } + if err != nil { + return err + } + if result != nil { + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + } + _, err = tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3", project, workspace, board) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + if r.Method == http.MethodPost { + reply(w, 201, response{"id": layout, "board_version": version + 1}) + } else { + reply(w, 204, nil) + } + return nil +} diff --git a/internal/platform/materials.go b/internal/platform/materials.go new file mode 100644 index 0000000..2d03c0b --- /dev/null +++ b/internal/platform/materials.go @@ -0,0 +1,297 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "strconv" +) + +func lockMaterial(ctx context.Context, tx *sql.Tx, project, workspace, material, account string) (string, string, error) { + var locked, kind, role string + err := tx.QueryRowContext(ctx, "SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE", project, workspace).Scan(&locked) + if err != nil { + return "", "", err + } + err = tx.QueryRowContext(ctx, ` +SELECT m.kind,CASE + WHEN member.role='admin' THEN 'admin' + WHEN member.role='editor' OR access.role='editor' THEN 'editor' + ELSE 'viewer' END +FROM materials m +LEFT JOIN workspace_members member ON (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$4 +LEFT JOIN material_grants access ON (access.project,access.workspace_id,access.material_id)=(m.project,m.workspace_id,m.id) AND access.account_id=$4 +WHERE m.project=$1 AND m.workspace_id=$2 AND m.id=$3 AND (member.role IS NOT NULL OR access.role IS NOT NULL) +FOR UPDATE OF m +`, project, workspace, material, account).Scan(&kind, &role) + return kind, role, err +} + +func expectedVersion(w http.ResponseWriter, r *http.Request) (int64, bool) { + header := r.Header.Get("If-Match") + if header == "" { + reject(w, 428, "version_required") + return 0, false + } + if len(header) < 3 || header[0] != '"' || header[len(header)-1] != '"' { + reject(w, 400, "invalid_version") + return 0, false + } + version, err := strconv.ParseInt(header[1:len(header)-1], 10, 64) + if err != nil || version < 1 { + reject(w, 400, "invalid_version") + return 0, false + } + return version, true +} +func setVersion(w http.ResponseWriter, version int64) { + w.Header().Set("ETag", strconv.Quote(strconv.FormatInt(version, 10))) +} + +func (s *service) materials(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method == http.MethodGet { + return s.listMaterials(w, r, project, account) + } + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Kind string `json:"kind"` + Title string `json:"title"` + Description string `json:"description"` + Content json.RawMessage `json:"content"` + } + if !decode(w, r, &input) { + return nil + } + content, ok := normalizeContent(input.Kind, input.Content) + if !ok || len(input.Title) > 300 || len(input.Description) > 10000 { + reject(w, 400, "invalid_material") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + workspace := r.PathValue("workspace") + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + var id 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) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, 1) + reply(w, 201, response{"id": id, "workspace_id": workspace, "kind": input.Kind, "version": 1}) + return nil +} + +func (s *service) material(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPatch && r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + kind, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if r.Method == http.MethodGet { + 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, +'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) + if err != nil { + return err + } + setVersion(w, version) + reply(w, 200, result) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + version, ok := expectedVersion(w, r) + if !ok { + return nil + } + var result sql.Result + if r.Method == http.MethodPatch { + var input struct { + Title string `json:"title"` + Description string `json:"description"` + Content json.RawMessage `json:"content"` + } + if !decode(w, r, &input) { + return nil + } + content, ok := normalizeContent(kind, input.Content) + if !ok || 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() +WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$7 +`, project, workspace, material, input.Title, input.Description, content, version) + } else { + if role != "admin" { + reject(w, 403, "admin_required") + return nil + } + result, err = tx.ExecContext(r.Context(), "DELETE FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version) + } + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + reject(w, 412, "version_conflict") + return nil + } + if err = tx.Commit(); err != nil { + return err + } + if r.Method == http.MethodPatch { + setVersion(w, version+1) + } + reply(w, 204, nil) + return nil +} + +func (s *service) openMaterial(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + workspace, material := r.PathValue("workspace"), r.PathValue("material") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, _, err = lockMaterial(r.Context(), tx, project, workspace, material, account); err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), ` +INSERT INTO material_opens(project,workspace_id,material_id,account_id) VALUES($1,$2,$3,$4) +ON CONFLICT(project,workspace_id,material_id,account_id) DO UPDATE SET opened_at=now() +`, project, workspace, material, account) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} + +func (s *service) listMaterials(w http.ResponseWriter, r *http.Request, project, account string) error { + sort := r.URL.Query().Get("sort") + orders := map[string]string{ + "": "m.modified_at DESC,m.id", + "modified": "m.modified_at DESC,m.id", + "created": "m.created_at DESC,m.id", + "alphabetical": "lower(m.title),m.id", + "opened": "opened.opened_at DESC NULLS LAST,m.id", + "tag": "COALESCE((SELECT min(lower(t.name)) FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id) WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),''),lower(m.title),m.id", + } + order, ok := orders[sort] + if !ok { + reject(w, 400, "invalid_sort") + return nil + } + limit, offset := 50, 0 + var err error + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + } + if err != nil || limit < 1 || limit > 100 { + reject(w, 400, "invalid_pagination") + return nil + } + if raw := r.URL.Query().Get("offset"); raw != "" { + offset, err = strconv.Atoi(raw) + } + if err != nil || offset < 0 || offset > 1000000 { + reject(w, 400, "invalid_pagination") + return nil + } + query := r.URL.Query().Get("q") + kind := r.URL.Query().Get("kind") + if len(query) > 256 { + reject(w, 400, "invalid_filter") + return nil + } + switch kind { + case "", "text", "article", "image", "board", "card": + default: + reject(w, 400, "invalid_filter") + return nil + } + 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, +'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) +WHERE (mt.project,mt.workspace_id,mt.material_id)=(m.project,m.workspace_id,m.id)),'[]'::jsonb)) +FROM materials m LEFT JOIN material_opens opened ON (opened.project,opened.workspace_id,opened.material_id)=(m.project,m.workspace_id,m.id) AND opened.account_id=$3 +WHERE m.project=$1 AND m.workspace_id=$2 +AND (EXISTS(SELECT 1 FROM workspace_members member WHERE (member.project,member.workspace_id)=(m.project,m.workspace_id) AND member.account_id=$3) + OR EXISTS(SELECT 1 FROM material_grants g WHERE (g.project,g.workspace_id,g.material_id)=(m.project,m.workspace_id,m.id) AND g.account_id=$3)) +AND ($4='' OR m.title ILIKE '%'||$4||'%') AND ($5='' OR m.kind=$5) +AND ($6='' OR EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id::text=$6)) +ORDER BY `+order+` LIMIT $7 OFFSET $8`, project, r.PathValue("workspace"), account, query, kind, tag, limit+1, offset) + if err != nil { + return err + } + defer rows.Close() + items := []json.RawMessage{} + for rows.Next() { + var value json.RawMessage + if err := rows.Scan(&value); err != nil { + return err + } + items = append(items, value) + } + if err := rows.Err(); err != nil { + return err + } + var next *int + if len(items) > limit { + items = items[:limit] + value := offset + limit + next = &value + } + reply(w, 200, response{"items": items, "next_offset": next}) + return nil +} diff --git a/internal/platform/tags.go b/internal/platform/tags.go new file mode 100644 index 0000000..32c95ea --- /dev/null +++ b/internal/platform/tags.go @@ -0,0 +1,169 @@ +package platform + +import ( + "net/http" + "regexp" + "strings" +) + +var colorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + +func (s *service) tags(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + workspace := r.PathValue("workspace") + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, workspace, account) + if err != nil { + return err + } + if r.Method == http.MethodGet { + rows, err := tx.QueryContext(r.Context(), "SELECT id::text,name,color FROM tags WHERE project=$1 AND workspace_id=$2 ORDER BY lower(name),id LIMIT 1000", project, workspace) + if err != nil { + return err + } + defer rows.Close() + items := []response{} + for rows.Next() { + var id, name, color string + if err := rows.Scan(&id, &name, &color); err != nil { + return err + } + items = append(items, response{"id": id, "name": name, "color": color}) + } + if err := rows.Err(); err != nil { + return err + } + reply(w, 200, response{"items": items}) + return nil + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + var input struct { + Name string `json:"name"` + Color string `json:"color"` + } + if !decode(w, r, &input) { + return nil + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 80 || (input.Color != "" && !colorPattern.MatchString(input.Color)) { + reject(w, 400, "invalid_tag") + return nil + } + var id string + err = tx.QueryRowContext(r.Context(), "INSERT INTO tags(project,workspace_id,name,color) VALUES($1,$2,$3,$4) RETURNING id::text", project, workspace, input.Name, input.Color).Scan(&id) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 201, response{"id": id, "name": input.Name, "color": input.Color}) + return nil +} +func (s *service) deleteTag(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodDelete { + reject(w, 405, "method_not_allowed") + return nil + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return err + } + defer tx.Rollback() + _, role, err := lockWorkspace(r.Context(), tx, project, r.PathValue("workspace"), account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + _, err = tx.ExecContext(r.Context(), ` +UPDATE materials m SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 + AND EXISTS(SELECT 1 FROM material_tags t WHERE (t.project,t.workspace_id,t.material_id)=(m.project,m.workspace_id,m.id) AND t.tag_id=$3) +`, project, r.PathValue("workspace"), r.PathValue("tag")) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM tags WHERE project=$1 AND workspace_id=$2 AND id=$3", project, r.PathValue("workspace"), r.PathValue("tag")) + if err != nil { + return err + } + if err = tx.Commit(); err != nil { + return err + } + reply(w, 204, nil) + return nil +} +func (s *service) materialTags(w http.ResponseWriter, r *http.Request, project, account string) error { + if r.Method != http.MethodPost { + reject(w, 405, "method_not_allowed") + return nil + } + var input struct { + Tags []string `json:"tags"` + } + if !decode(w, r, &input) { + return nil + } + if len(input.Tags) > 100 { + reject(w, 400, "too_many_tags") + 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") + _, role, err := lockMaterial(r.Context(), tx, project, workspace, material, account) + if err != nil { + return err + } + if role == "viewer" { + reject(w, 403, "forbidden") + return nil + } + version, ok := expectedVersion(w, r) + if !ok { + return nil + } + result, err := tx.ExecContext(r.Context(), "UPDATE materials SET version=version+1,modified_at=now() WHERE project=$1 AND workspace_id=$2 AND id=$3 AND version=$4", project, workspace, material, version) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + reject(w, 412, "version_conflict") + return nil + } + _, err = tx.ExecContext(r.Context(), "DELETE FROM material_tags WHERE project=$1 AND workspace_id=$2 AND material_id=$3", project, workspace, material) + if err != nil { + return err + } + for _, tag := range input.Tags { + _, err = tx.ExecContext(r.Context(), "INSERT INTO material_tags(project,workspace_id,material_id,tag_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING", project, workspace, material, tag) + if err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + setVersion(w, version+1) + reply(w, 204, nil) + return nil +}