326 lines
12 KiB
Go
326 lines
12 KiB
Go
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
|
|
}
|
|
// Child grants cannot bypass a parent. The effective role is inherited from
|
|
// the root board; workspace membership participates at every ancestor.
|
|
err = tx.QueryRowContext(ctx, `WITH RECURSIVE chain AS (
|
|
SELECT m.id,m.kind,0 AS depth,ARRAY[m.id] AS path FROM materials m
|
|
WHERE m.project=$1 AND m.workspace_id=$2 AND m.id=$3
|
|
UNION ALL
|
|
SELECT m.id,m.kind,c.depth+1,c.path||m.id FROM chain c
|
|
JOIN canvas_subgraphs s ON s.project=$1 AND s.workspace_id=$2 AND s.child_board_id=c.id
|
|
JOIN materials m ON m.project=s.project AND m.workspace_id=s.workspace_id AND m.id=s.parent_board_id
|
|
WHERE c.depth<8 AND NOT m.id=ANY(c.path)
|
|
), root AS (SELECT * FROM chain ORDER BY depth DESC LIMIT 1)
|
|
SELECT (SELECT kind FROM chain WHERE depth=0),CASE
|
|
WHEN member.role='admin' THEN 'admin'
|
|
WHEN member.role='editor' OR access.role='editor' THEN 'editor' ELSE 'viewer' END
|
|
FROM root LEFT JOIN workspace_members member ON member.project=$1 AND member.workspace_id=$2 AND member.account_id=$4
|
|
LEFT JOIN material_grants access ON access.project=$1 AND access.workspace_id=$2 AND access.material_id=root.id AND access.account_id=$4
|
|
WHERE (member.role IS NOT NULL OR access.role IS NOT NULL)
|
|
AND NOT EXISTS(SELECT 1 FROM canvas_subgraphs s WHERE s.project=$1 AND s.workspace_id=$2 AND s.child_board_id IN(SELECT id FROM chain) AND s.archived_at IS NOT NULL)
|
|
`, 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"`
|
|
Slug *string `json:"slug,omitempty"`
|
|
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 || !validSlug(input.Slug) || 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, slug string
|
|
err = tx.QueryRowContext(r.Context(), `
|
|
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
|
|
}
|
|
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, "slug": slug})
|
|
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,'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)
|
|
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 {
|
|
Slug *string `json:"slug,omitempty"`
|
|
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 || !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 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, input.Slug)
|
|
} 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
|
|
}
|
|
includeSubgraphs := false
|
|
if value := r.URL.Query().Get("include_subgraphs"); value != "" {
|
|
if value != "true" && value != "false" {
|
|
reject(w, 400, "invalid_filter")
|
|
return nil
|
|
}
|
|
includeSubgraphs = value == "true"
|
|
}
|
|
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,'slug',m.slug,'title',m.title,
|
|
'description',m.description,'version',m.version,'created_at',m.created_at,'modified_at',m.modified_at,'parent_board_id',sg.parent_board_id,'frame_id',sg.frame_id,
|
|
'opened_at',opened.opened_at,'tags',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name,'color',t.color) ORDER BY lower(t.name),t.id)
|
|
FROM material_tags mt JOIN tags t ON (t.project,t.workspace_id,t.id)=(mt.project,mt.workspace_id,mt.tag_id)
|
|
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
|
|
LEFT JOIN canvas_subgraphs sg ON sg.project=m.project AND sg.workspace_id=m.workspace_id AND sg.child_board_id=m.id
|
|
JOIN LATERAL (
|
|
WITH RECURSIVE chain(id,depth,archived,path) AS (
|
|
SELECT m.id,0,false,ARRAY[m.id]
|
|
UNION ALL SELECT parent.parent_board_id,c.depth+1,c.archived OR parent.archived_at IS NOT NULL,c.path||parent.parent_board_id
|
|
FROM chain c JOIN canvas_subgraphs parent ON parent.project=m.project AND parent.workspace_id=m.workspace_id AND parent.child_board_id=c.id
|
|
WHERE c.depth<8 AND NOT parent.parent_board_id=ANY(c.path)
|
|
) SELECT id AS root_id,bool_or(archived) OVER () AS archived FROM chain ORDER BY depth DESC LIMIT 1
|
|
) ancestry ON TRUE
|
|
WHERE m.project=$1 AND m.workspace_id=$2
|
|
AND NOT ancestry.archived AND ($9 OR sg.child_board_id IS NULL)
|
|
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,ancestry.root_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, includeSubgraphs)
|
|
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
|
|
}
|