Files

725 lines
20 KiB
Go

package handlers
import "database/sql"
import "errors"
import "net/http"
import "time"
import "codit/internal/db"
import "codit/internal/middleware"
import "codit/internal/models"
// boardRoleRank returns a numeric rank for board roles (higher = more access).
func boardRoleRank(role string) int {
switch role {
case models.RoleAdmin:
return 3
case models.RoleWriter:
return 2
case models.RoleViewer:
return 1
default:
return 0
}
}
// boardRoleAllows checks whether actual satisfies the required board role.
// Hierarchy: admin > writer > viewer.
func boardRoleAllows(actual, required string) bool {
return boardRoleRank(actual) >= boardRoleRank(required)
}
func (api *API) boardRoleForUser(r *http.Request, boardID string, user models.User) (string, error) {
var role string
var boardRole string
var boardErr error
var projectID string
var projRole string
var err error
if user.IsAdmin {
return models.RoleAdmin, nil
}
projectID, err = api.store(r).GetBoardProjectID(boardID)
if err != nil {
return "", err
}
projRole, err = api.store(r).GetProjectRoleForUser(projectID, user.ID)
if err == nil {
role = projectRoleToBoardRole(projRole)
} else if err != sql.ErrNoRows {
return "", err
}
boardRole, boardErr = api.store(r).GetBoardMemberRole(boardID, user.ID)
if boardErr == nil && boardRoleRank(boardRole) > boardRoleRank(role) {
role = boardRole
} else if boardErr != nil && boardErr != sql.ErrNoRows {
return "", boardErr
}
return role, nil
}
func projectRoleToBoardRole(projectRole string) string {
switch projectRole {
case models.RoleAdmin:
return models.RoleAdmin
case models.RoleWriter:
return models.RoleWriter
default:
return models.RoleViewer
}
}
// normalizeBoardRole validates and returns the canonical role name.
// Returns "" for unknown roles.
func normalizeBoardRole(role string) string {
switch role {
case models.RoleAdmin, models.RoleWriter, models.RoleViewer:
return role
default:
return ""
}
}
// requireBoardRole checks that the caller has at least the required board role.
// Board membership and project role are both checked; the higher access wins.
func (api *API) requireBoardRole(w http.ResponseWriter, r *http.Request, boardID, required string) bool {
var user models.User
var principal models.ServicePrincipal
var ok bool
var role string
var err error
var projectID string
var projRole string
user, ok = middleware.UserFromContext(r.Context())
if ok {
role, err = api.boardRoleForUser(r, boardID, user)
if err != nil {
w.WriteHeader(http.StatusForbidden)
return false
}
if role == "" {
w.WriteHeader(http.StatusForbidden)
return false
}
if !boardRoleAllows(role, required) {
w.WriteHeader(http.StatusForbidden)
return false
}
return true
}
principal, ok = middleware.PrincipalFromContext(r.Context())
if !ok || principal.Disabled {
w.WriteHeader(http.StatusUnauthorized)
return false
}
if principal.IsAdmin {
return true
}
projectID, err = api.store(r).GetBoardProjectID(boardID)
if err != nil {
w.WriteHeader(http.StatusForbidden)
return false
}
projRole, err = api.store(r).GetPrincipalProjectRole(principal.ID, projectID)
if err != nil || !boardRoleAllows(projectRoleToBoardRole(projRole), required) {
w.WriteHeader(http.StatusForbidden)
return false
}
return true
}
// ----- boards -----
type createBoardRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
ShowDescription bool `json:"show_description"`
CardProperties string `json:"card_properties"`
IsTemplate bool `json:"is_template"`
SeedDefaultFields bool `json:"seed_default_fields"`
}
type updateBoardRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Icon *string `json:"icon"`
ShowDescription *bool `json:"show_description"`
CardProperties *string `json:"card_properties"`
}
func (api *API) ListAllBoards(w http.ResponseWriter, r *http.Request, params map[string]string) {
var boards []models.Board
var err error
var user models.User
var userOK bool
user, userOK = middleware.UserFromContext(r.Context())
if !userOK {
w.WriteHeader(http.StatusUnauthorized)
return
}
if user.IsAdmin {
boards, err = api.store(r).ListAllBoardsAdmin()
} else {
boards, err = api.store(r).ListAllBoardsForUser(user.ID)
}
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if boards == nil {
boards = []models.Board{}
}
WriteJSON(w, http.StatusOK, boards)
}
func (api *API) ListBoards(w http.ResponseWriter, r *http.Request, params map[string]string) {
var boards []models.Board
var err error
var projRoleErr error
var user models.User
var principal models.ServicePrincipal
var userOK bool
var principalOK bool
user, userOK = middleware.UserFromContext(r.Context())
principal, principalOK = middleware.PrincipalFromContext(r.Context())
if !userOK && (!principalOK || principal.Disabled) {
w.WriteHeader(http.StatusUnauthorized)
return
}
if (userOK && user.IsAdmin) || (principalOK && !principal.Disabled && principal.IsAdmin) {
boards, err = api.store(r).ListBoards(params["projectId"])
} else if userOK {
_, projRoleErr = api.store(r).GetProjectRoleForUser(params["projectId"], user.ID)
if projRoleErr == nil {
// Project member: see all boards in the project.
boards, err = api.store(r).ListBoards(params["projectId"])
} else {
// No project role: return only boards with an explicit membership entry.
boards, err = api.store(r).ListBoardsWhereExplicitMember(params["projectId"], user.ID)
if err == nil && len(boards) == 0 {
w.WriteHeader(http.StatusForbidden)
return
}
}
} else {
// Service principal: project-role access only; no per-board membership.
_, projRoleErr = api.store(r).GetPrincipalProjectRole(principal.ID, params["projectId"])
if projRoleErr != nil {
w.WriteHeader(http.StatusForbidden)
return
}
boards, err = api.store(r).ListBoards(params["projectId"])
}
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if boards == nil {
boards = []models.Board{}
}
WriteJSON(w, http.StatusOK, boards)
}
func (api *API) CreateBoard(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req createBoardRequest
var err error
var user models.User
var ok bool
var board models.Board
var created models.Board
// Board mutations are attributed to a user account; service principals cannot create boards.
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "board creation requires a user account")
return
}
if !api.requireProjectRole(w, r, params["projectId"], models.RoleWriter) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
if req.Title == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "title is required")
return
}
board = models.Board{
ProjectID: params["projectId"],
Title: req.Title,
Description: req.Description,
Icon: req.Icon,
ShowDescription: req.ShowDescription,
CardProperties: req.CardProperties,
IsTemplate: req.IsTemplate,
CreatedBy: user.ID,
}
created, err = api.store(r).CreateBoard(r.Context(), board)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if req.SeedDefaultFields {
err = api.store(r).SeedDefaultBoardFieldValues(r.Context(), created.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
}
WriteJSON(w, http.StatusCreated, created)
}
func (api *API) GetBoard(w http.ResponseWriter, r *http.Request, params map[string]string) {
var board models.Board
var err error
board, err = api.store(r).GetBoard(params["boardId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "board not found")
return
}
if !api.requireBoardRole(w, r, board.ID, models.RoleViewer) {
return
}
WriteJSON(w, http.StatusOK, board)
}
func (api *API) UpdateBoard(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req updateBoardRequest
var err error
var board models.Board
var updated models.Board
var user models.User
var ok bool
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "board updates require a user account")
return
}
board, err = api.store(r).GetBoard(params["boardId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "board not found")
return
}
if !api.requireBoardRole(w, r, board.ID, models.RoleWriter) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
if req.Title != nil {
board.Title = *req.Title
}
if req.Description != nil {
board.Description = *req.Description
}
if req.Icon != nil {
board.Icon = *req.Icon
}
if req.ShowDescription != nil {
board.ShowDescription = *req.ShowDescription
}
if req.CardProperties != nil {
board.CardProperties = *req.CardProperties
}
board.UpdatedBy = user.ID
updated, err = api.store(r).UpdateBoard(board)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, updated)
}
func (api *API) DeleteBoard(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
var board models.Board
var user models.User
var ok bool
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "board deletion requires a user account")
return
}
board, err = api.store(r).GetBoard(params["boardId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "board not found")
return
}
if !api.requireBoardRole(w, r, board.ID, models.RoleAdmin) {
return
}
err = api.store(r).DeleteBoard(board.ID, user.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ----- blocks -----
type createBlockRequest struct {
Type string `json:"type"`
ParentID string `json:"parent_id"`
Title string `json:"title"`
Fields string `json:"fields"`
}
type updateBlockRequest struct {
ParentID *string `json:"parent_id"`
Title *string `json:"title"`
Fields *string `json:"fields"`
Completed *bool `json:"completed"`
}
func (api *API) ListBlocks(w http.ResponseWriter, r *http.Request, params map[string]string) {
var blocks []models.Block
var err error
var blockType string
if !api.requireBoardRole(w, r, params["boardId"], models.RoleViewer) {
return
}
blockType = r.URL.Query().Get("type")
if blockType != "" {
blocks, err = api.store(r).ListBlocksByType(params["boardId"], blockType)
} else {
blocks, err = api.store(r).ListBlocks(params["boardId"])
}
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if blocks == nil {
blocks = []models.Block{}
}
WriteJSON(w, http.StatusOK, blocks)
}
func (api *API) CreateBlock(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req createBlockRequest
var err error
var user models.User
var ok bool
var block models.Block
var created models.Block
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "block creation requires a user account")
return
}
if !api.requireBoardRole(w, r, params["boardId"], models.RoleWriter) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
if req.Type == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "type is required")
return
}
block = models.Block{
BoardID: params["boardId"],
ParentID: req.ParentID,
Type: req.Type,
Title: req.Title,
Fields: req.Fields,
CreatedBy: user.ID,
UpdatedBy: user.ID,
}
created, err = api.store(r).CreateBlock(r.Context(), block)
if err != nil {
if errors.Is(err, db.ErrParentNotInBoard) {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusCreated, created)
}
func (api *API) GetBlock(w http.ResponseWriter, r *http.Request, params map[string]string) {
var block models.Block
var err error
if !api.requireBoardRole(w, r, params["boardId"], models.RoleViewer) {
return
}
block, err = api.store(r).GetBlock(params["boardId"], params["blockId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found")
return
}
WriteJSON(w, http.StatusOK, block)
}
func (api *API) UpdateBlock(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req updateBlockRequest
var err error
var block models.Block
var updated models.Block
var user models.User
var ok bool
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "block updates require a user account")
return
}
if !api.requireBoardRole(w, r, params["boardId"], models.RoleWriter) {
return
}
block, err = api.store(r).GetBlock(params["boardId"], params["blockId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found")
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
if req.ParentID != nil {
block.ParentID = *req.ParentID
}
if req.Title != nil {
block.Title = *req.Title
}
if req.Fields != nil {
block.Fields = *req.Fields
}
if req.Completed != nil {
if *req.Completed {
block.CompletedAt = time.Now().UTC().Unix()
block.CompletedBy = user.ID
} else {
block.CompletedAt = 0
block.CompletedBy = ""
}
}
block.UpdatedBy = user.ID
updated, err = api.store(r).UpdateBlock(r.Context(), block)
if err != nil {
if errors.Is(err, db.ErrParentNotInBoard) || errors.Is(err, db.ErrParentCycle) {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, updated)
}
func (api *API) PatchBlocks(w http.ResponseWriter, r *http.Request, params map[string]string) {
var patches []models.BlockPatch
var err error
var user models.User
var ok bool
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "block updates require a user account")
return
}
if !api.requireBoardRole(w, r, params["boardId"], models.RoleWriter) {
return
}
err = DecodeJSON(r, &patches)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
err = api.store(r).PatchBlocks(r.Context(), params["boardId"], patches, user.ID)
if err != nil {
if err == sql.ErrNoRows {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found")
return
}
if errors.Is(err, db.ErrParentNotInBoard) || errors.Is(err, db.ErrParentCycle) {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *API) DeleteBlock(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
var user models.User
var ok bool
user, ok = middleware.UserFromContext(r.Context())
if !ok {
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "block deletion requires a user account")
return
}
if !api.requireBoardRole(w, r, params["boardId"], models.RoleWriter) {
return
}
err = api.store(r).DeleteBlock(r.Context(), params["boardId"], params["blockId"], user.ID)
if err != nil {
if err == sql.ErrNoRows {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "block not found")
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ----- board members -----
type upsertBoardMemberRequest struct {
UserID string `json:"user_id"`
Role string `json:"role"`
}
func (api *API) ListBoardMembers(w http.ResponseWriter, r *http.Request, params map[string]string) {
var members []models.BoardMember
var err error
if !api.requireBoardRole(w, r, params["boardId"], models.RoleAdmin) {
return
}
members, err = api.store(r).ListBoardMembers(params["boardId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if members == nil {
members = []models.BoardMember{}
}
WriteJSON(w, http.StatusOK, members)
}
func (api *API) ListBoardAssignableUsers(w http.ResponseWriter, r *http.Request, params map[string]string) {
var users []models.BoardAssignableUser
var err error
if !api.requireBoardRole(w, r, params["boardId"], models.RoleViewer) {
return
}
users, err = api.store(r).ListBoardAssignableUsers(params["boardId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if users == nil {
users = []models.BoardAssignableUser{}
}
WriteJSON(w, http.StatusOK, users)
}
func (api *API) AddBoardMember(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req upsertBoardMemberRequest
var err error
var member models.BoardMember
var role string
if !api.requireBoardRole(w, r, params["boardId"], models.RoleAdmin) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
if req.UserID == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "user_id is required")
return
}
if req.Role == "" {
req.Role = models.RoleWriter
}
role = normalizeBoardRole(req.Role)
if role == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "role must be admin, writer, or viewer")
return
}
member = models.BoardMember{
BoardID: params["boardId"],
UserID: req.UserID,
Role: role,
}
err = api.store(r).UpsertBoardMember(r.Context(), member)
if err != nil {
if errors.Is(err, db.ErrBoardNotFound) || errors.Is(err, db.ErrUserNotFound) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, err.Error())
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *API) UpdateBoardMember(w http.ResponseWriter, r *http.Request, params map[string]string) {
var req upsertBoardMemberRequest
var err error
var member models.BoardMember
var role string
if !api.requireBoardRole(w, r, params["boardId"], models.RoleAdmin) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
role = normalizeBoardRole(req.Role)
if role == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "role must be admin, writer, or viewer")
return
}
member = models.BoardMember{
BoardID: params["boardId"],
UserID: params["userId"],
Role: role,
}
err = api.store(r).UpsertBoardMember(r.Context(), member)
if err != nil {
if errors.Is(err, db.ErrBoardNotFound) || errors.Is(err, db.ErrUserNotFound) {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, err.Error())
return
}
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *API) RemoveBoardMember(w http.ResponseWriter, r *http.Request, params map[string]string) {
var err error
if !api.requireBoardRole(w, r, params["boardId"], models.RoleAdmin) {
return
}
err = api.store(r).DeleteBoardMember(params["boardId"], params["userId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}