Scert/backend/internal/httpapi/project_members_api.go

155 lines
5.0 KiB
Go

package httpapi
import (
"errors"
"io"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
const projectMemberTargetKey = "project_member_target_id"
func (s *Server) registerProjectMemberRoutes() {
api := s.router.Group("/api/v1/projects/:projectID/members", privateProjectResponse)
api.GET("", s.projectMemberFailureAudit("project_member.list"), s.requireAuthenticated, s.listProjectMembers)
api.POST("", s.projectMemberFailureAudit("project_member.grant"), s.requireAuthenticated, s.grantProjectMember)
api.DELETE("/:userID/roles/:role", s.projectMemberFailureAudit("project_member.revoke"), s.requireAuthenticated, s.revokeProjectMember)
}
func (s *Server) projectMemberRequestReady(c *gin.Context) bool {
if s.projectMembers == nil {
s.writeError(c, http.StatusServiceUnavailable, "项目成员服务尚未就绪", nil)
return false
}
if !validMemberIdentifier(c.Param("projectID")) {
s.writeError(c, http.StatusBadRequest, "项目标识无效", nil)
return false
}
return true
}
func (s *Server) listProjectMembers(c *gin.Context) {
if !s.projectMemberRequestReady(c) {
return
}
query, err := projectMemberPageQuery(c)
if err != nil {
s.writeError(c, http.StatusBadRequest, "分页参数无效,每页仅支持 5、10、15 或 30 位成员", nil)
return
}
page, err := s.projectMembers.ListProjectMembers(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID"), query.After, query.Size)
if err != nil {
s.writeProjectMemberError(c, err)
return
}
c.JSON(http.StatusOK, page)
}
func (s *Server) grantProjectMember(c *gin.Context) {
if !s.projectMemberRequestReady(c) {
return
}
var request struct {
UserID string `json:"user_id"`
Role domain.ProjectRole `json:"role"`
}
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil || !validMemberIdentifier(request.UserID) || !request.Role.Valid() || c.Request.URL.RawQuery != "" {
s.writeError(c, http.StatusBadRequest, "项目成员参数无效", nil)
return
}
c.Set(projectMemberTargetKey, request.UserID)
if err := s.projectMembers.GrantProjectRole(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID"), request.UserID, request.Role); err != nil {
s.writeProjectMemberError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"message": "项目角色已授予"})
}
func (s *Server) revokeProjectMember(c *gin.Context) {
if !s.projectMemberRequestReady(c) {
return
}
role := domain.ProjectRole(c.Param("role"))
if c.Request.Body != nil {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1))
if err != nil || len(body) != 0 {
s.writeError(c, http.StatusBadRequest, "撤销角色请求不接受请求体", nil)
return
}
}
if !validMemberIdentifier(c.Param("userID")) || !role.Valid() || c.Request.URL.RawQuery != "" {
s.writeError(c, http.StatusBadRequest, "项目成员参数无效", nil)
return
}
c.Set(projectMemberTargetKey, c.Param("userID"))
if err := s.projectMembers.RevokeProjectRole(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID"), c.Param("userID"), role); err != nil {
s.writeProjectMemberError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (s *Server) writeProjectMemberError(c *gin.Context, err error) {
switch {
case errors.Is(err, repository.ErrProjectAccessDenied):
// 不区分无权限、项目不存在或目标不满足条件,避免泄露其他项目与账户信息。
s.writeError(c, http.StatusForbidden, "无权访问项目或执行此操作", nil)
case errors.Is(err, repository.ErrProjectMemberConflict):
s.writeError(c, http.StatusConflict, "项目成员关系冲突,请刷新后重试", nil)
default:
s.writeError(c, http.StatusInternalServerError, "项目成员操作失败,请稍后重试", nil)
}
}
type projectMemberQuery struct {
After string
Size int
}
func projectMemberPageQuery(c *gin.Context) (projectMemberQuery, error) {
result := projectMemberQuery{Size: 10}
values, err := url.ParseQuery(c.Request.URL.RawQuery)
if err != nil {
return result, err
}
for key, entries := range values {
if (key != "after" && key != "page_size") || len(entries) != 1 {
return result, errors.New("分页参数重复或未知")
}
}
if values.Has("page_size") {
result.Size, err = strconv.Atoi(values.Get("page_size"))
if err != nil {
return result, err
}
}
switch result.Size {
case 5, 10, 15, 30:
default:
return result, errors.New("每页成员数量无效")
}
result.After = values.Get("after")
if result.After != "" && !validMemberIdentifier(result.After) {
return result, errors.New("分页游标无效")
}
return result, nil
}
func validMemberIdentifier(value string) bool {
if len(value) == 0 || len(value) > 128 {
return false
}
for _, ch := range value {
if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '-' || ch == '_') {
return false
}
}
return true
}