Add structured materials, canvas layouts, tags and content permissions
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user