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 }