485 lines
18 KiB
Go
485 lines
18 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
|
|
"github.com/pkg/errors"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
|
|
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
|
"github.com/usememos/memos/store"
|
|
)
|
|
|
|
func (s *APIV1Service) requireCurrentSpaceUser(ctx context.Context) (*store.User, error) {
|
|
user, err := s.fetchCurrentUser(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
|
}
|
|
if user == nil {
|
|
return nil, status.Error(codes.Unauthenticated, "user not authenticated")
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *APIV1Service) resolveMemberSpace(ctx context.Context, name string, currentUser *store.User) (*store.Space, *store.SpaceMember, error) {
|
|
uid, err := ExtractSpaceUIDFromName(name)
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.InvalidArgument, "invalid space name: %v", err)
|
|
}
|
|
space, err := s.Store.GetSpace(ctx, &store.FindSpace{UID: &uid, MemberUserID: ¤tUser.ID})
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.Internal, "failed to get space: %v", err)
|
|
}
|
|
if space == nil {
|
|
return nil, nil, status.Error(codes.NotFound, "space not found")
|
|
}
|
|
member, err := s.Store.GetSpaceMember(ctx, &store.FindSpaceMember{SpaceID: &space.ID, UserID: ¤tUser.ID})
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.Internal, "failed to get space membership: %v", err)
|
|
}
|
|
if member == nil || !member.Role.IsActiveMember() {
|
|
// A non-member must not be able to distinguish an existing private
|
|
// collaboration boundary from a missing resource.
|
|
return nil, nil, status.Error(codes.NotFound, "space not found")
|
|
}
|
|
return space, member, nil
|
|
}
|
|
|
|
func requireSpaceAdministrator(member *store.SpaceMember) error {
|
|
if member == nil || member.Role != store.SpaceMemberRoleAdmin {
|
|
return status.Error(codes.PermissionDenied, "space administrator permission required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mapSpaceMutationError(err error, operation string) error {
|
|
switch {
|
|
case err == nil:
|
|
return nil
|
|
case errors.Is(err, store.ErrLastSpaceAdmin):
|
|
return status.Error(codes.FailedPrecondition, "a space must retain an active administrator")
|
|
case errors.Is(err, store.ErrSpacePermissionDenied):
|
|
return status.Error(codes.NotFound, "space not found")
|
|
case errors.Is(err, store.ErrSpaceMemberNotActive):
|
|
return status.Error(codes.FailedPrecondition, "space members must be active users")
|
|
case errors.Is(err, store.ErrSpaceAlreadyExists):
|
|
return status.Error(codes.AlreadyExists, "space already exists")
|
|
case errors.Is(err, store.ErrSpaceMemberAlreadyExists):
|
|
return status.Error(codes.AlreadyExists, "space member already exists")
|
|
case errors.Is(err, store.ErrSpaceNotFound), errors.Is(err, store.ErrSpaceMemberNotFound):
|
|
return status.Error(codes.NotFound, "space or membership not found")
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return status.Error(codes.NotFound, "space or membership not found")
|
|
default:
|
|
return status.Errorf(codes.Internal, "%s: %v", operation, err)
|
|
}
|
|
}
|
|
|
|
// CreateSpace creates a space with the caller as its first administrator.
|
|
func (s *APIV1Service) CreateSpace(ctx context.Context, request *v1pb.CreateSpaceRequest) (*v1pb.Space, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request.GetSpace() == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "space is required")
|
|
}
|
|
title := strings.TrimSpace(request.Space.Title)
|
|
if title == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "space title is required")
|
|
}
|
|
uid, err := ValidateAndGenerateUID(request.SpaceId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
created, err := s.Store.CreateSpace(ctx, &store.Space{
|
|
UID: uid,
|
|
Title: title,
|
|
Description: strings.TrimSpace(request.Space.Description),
|
|
}, currentUser.ID)
|
|
if err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to create space")
|
|
}
|
|
s.SSEHub.publishMemoChanged()
|
|
return convertSpaceFromStore(created), nil
|
|
}
|
|
|
|
// ListSpaces lists only spaces with a membership for the caller.
|
|
func (s *APIV1Service) ListSpaces(ctx context.Context, request *v1pb.ListSpacesRequest) (*v1pb.ListSpacesResponse, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limit, offset, err := listSpacePage(request.PageSize, request.PageToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitPlusOne := limit + 1
|
|
spaces, err := s.Store.ListSpaces(ctx, &store.FindSpace{
|
|
MemberUserID: ¤tUser.ID,
|
|
Limit: &limitPlusOne,
|
|
Offset: &offset,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list spaces: %v", err)
|
|
}
|
|
|
|
nextPageToken := ""
|
|
if len(spaces) == limitPlusOne {
|
|
spaces = spaces[:limit]
|
|
nextPageToken, err = getPageToken(limit, offset+limit)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create next page token: %v", err)
|
|
}
|
|
}
|
|
response := &v1pb.ListSpacesResponse{Spaces: make([]*v1pb.Space, 0, len(spaces)), NextPageToken: nextPageToken}
|
|
for _, space := range spaces {
|
|
response.Spaces = append(response.Spaces, convertSpaceFromStore(space))
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func listSpacePage(pageSize int32, pageToken string) (int, int, error) {
|
|
if pageToken == "" {
|
|
return normalizePageSize(pageSize), 0, nil
|
|
}
|
|
var token v1pb.PageToken
|
|
if err := unmarshalPageToken(pageToken, &token); err != nil {
|
|
return 0, 0, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
|
|
}
|
|
return normalizePageSize(token.Limit), max(int(token.Offset), 0), nil
|
|
}
|
|
|
|
// GetSpace gets a space visible to the caller through membership.
|
|
func (s *APIV1Service) GetSpace(ctx context.Context, request *v1pb.GetSpaceRequest) (*v1pb.Space, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
space, _, err := s.resolveMemberSpace(ctx, request.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return convertSpaceFromStore(space), nil
|
|
}
|
|
|
|
// UpdateSpace updates Space metadata.
|
|
func (s *APIV1Service) UpdateSpace(ctx context.Context, request *v1pb.UpdateSpaceRequest) (*v1pb.Space, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request.GetSpace() == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "space is required")
|
|
}
|
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "update mask is required")
|
|
}
|
|
space, membership, err := s.resolveMemberSpace(ctx, request.Space.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := requireSpaceAdministrator(membership); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
update := &store.UpdateSpace{ID: space.ID}
|
|
for _, path := range request.UpdateMask.Paths {
|
|
switch path {
|
|
case "title":
|
|
title := strings.TrimSpace(request.Space.Title)
|
|
if title == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "space title is required")
|
|
}
|
|
update.Title = &title
|
|
case "description":
|
|
description := strings.TrimSpace(request.Space.Description)
|
|
update.Description = &description
|
|
default:
|
|
return nil, status.Errorf(codes.InvalidArgument, "unsupported update mask path: %s", path)
|
|
}
|
|
}
|
|
updated, err := s.Store.UpdateSpace(ctx, update, currentUser.ID)
|
|
if err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to update space")
|
|
}
|
|
if updated == nil {
|
|
return nil, status.Error(codes.NotFound, "space not found")
|
|
}
|
|
s.SSEHub.publishMemoChanged()
|
|
return convertSpaceFromStore(updated), nil
|
|
}
|
|
|
|
// DeleteSpace permanently deletes the Space and every memo directly placed
|
|
// in it. The result deliberately exposes no memo inventory to the administrator.
|
|
func (s *APIV1Service) DeleteSpace(ctx context.Context, request *v1pb.DeleteSpaceRequest) (*emptypb.Empty, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
space, membership, err := s.resolveMemberSpace(ctx, request.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := requireSpaceAdministrator(membership); err != nil {
|
|
return nil, err
|
|
}
|
|
deleteResult, err := s.Store.DeleteSpace(ctx, &store.DeleteSpace{ID: space.ID, ActorUserID: currentUser.ID})
|
|
if err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to delete space")
|
|
}
|
|
s.SSEHub.publishMemoChanged()
|
|
if err := s.cleanupDeletedAttachmentStorage(ctx, deleteResult.Attachments); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "space was deleted but attachment storage cleanup failed: %v", err)
|
|
}
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// CreateSpaceMember directly adds an active user to a Space.
|
|
func (s *APIV1Service) CreateSpaceMember(ctx context.Context, request *v1pb.CreateSpaceMemberRequest) (*v1pb.SpaceMember, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request.GetSpaceMember() == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "space member is required")
|
|
}
|
|
space, callerMembership, err := s.resolveMemberSpace(ctx, request.Parent, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := requireSpaceAdministrator(callerMembership); err != nil {
|
|
return nil, err
|
|
}
|
|
targetUsername, err := parseUsernameFromName(request.SpaceMember.User)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid member user: %v", err)
|
|
}
|
|
targetUser, err := s.Store.GetUser(ctx, &store.FindUser{Username: &targetUsername})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get member user: %v", err)
|
|
}
|
|
if targetUser == nil {
|
|
return nil, status.Error(codes.NotFound, "user not found")
|
|
}
|
|
if targetUser.RowStatus != store.Normal {
|
|
return nil, status.Error(codes.FailedPrecondition, "only active users can be added to a space")
|
|
}
|
|
role, ok := convertSpaceMemberRoleToStore(request.SpaceMember.Role)
|
|
if !ok {
|
|
return nil, status.Error(codes.InvalidArgument, "space member role must be ADMIN or USER")
|
|
}
|
|
expectedName := buildSpaceMemberName(space.UID, targetUser.Username)
|
|
if request.SpaceMember.Name != "" && request.SpaceMember.Name != expectedName {
|
|
return nil, status.Error(codes.InvalidArgument, "space member name does not match parent and user")
|
|
}
|
|
created, err := s.Store.CreateSpaceMember(ctx, &store.SpaceMember{
|
|
SpaceID: space.ID,
|
|
UserID: targetUser.ID,
|
|
Role: role,
|
|
}, currentUser.ID)
|
|
if err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to create space member")
|
|
}
|
|
s.SSEHub.publishMemoChanged()
|
|
return convertSpaceMemberFromStore(space, targetUser, created), nil
|
|
}
|
|
|
|
// ListSpaceMembers lists memberships after authorizing the caller's own
|
|
// membership before applying pagination.
|
|
func (s *APIV1Service) ListSpaceMembers(ctx context.Context, request *v1pb.ListSpaceMembersRequest) (*v1pb.ListSpaceMembersResponse, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
space, _, err := s.resolveMemberSpace(ctx, request.Parent, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limit, offset, err := listSpacePage(request.PageSize, request.PageToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitPlusOne := limit + 1
|
|
members, err := s.Store.ListSpaceMembers(ctx, &store.FindSpaceMember{
|
|
SpaceID: &space.ID,
|
|
ViewerUserID: ¤tUser.ID,
|
|
Limit: &limitPlusOne,
|
|
Offset: &offset,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list space members: %v", err)
|
|
}
|
|
nextPageToken := ""
|
|
if len(members) == limitPlusOne {
|
|
members = members[:limit]
|
|
nextPageToken, err = getPageToken(limit, offset+limit)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create next page token: %v", err)
|
|
}
|
|
}
|
|
response := &v1pb.ListSpaceMembersResponse{SpaceMembers: make([]*v1pb.SpaceMember, 0, len(members)), NextPageToken: nextPageToken}
|
|
if len(members) == 0 {
|
|
return response, nil
|
|
}
|
|
|
|
userIDs := make([]int32, 0, len(members))
|
|
for _, member := range members {
|
|
userIDs = append(userIDs, member.UserID)
|
|
}
|
|
users, err := s.Store.ListUsers(ctx, &store.FindUser{IDList: userIDs})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to resolve space members: %v", err)
|
|
}
|
|
usersByID := make(map[int32]*store.User, len(users))
|
|
for _, user := range users {
|
|
usersByID[user.ID] = user
|
|
}
|
|
for _, member := range members {
|
|
user := usersByID[member.UserID]
|
|
if user == nil || user.RowStatus != store.Normal || convertSpaceMemberRoleFromStore(member.Role) == v1pb.SpaceMember_ROLE_UNSPECIFIED {
|
|
// Malformed/dangling memberships are never exposed as active access.
|
|
continue
|
|
}
|
|
response.SpaceMembers = append(response.SpaceMembers, convertSpaceMemberFromStore(space, user, member))
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (s *APIV1Service) resolveSpaceMemberResource(ctx context.Context, name string, currentUser *store.User) (*store.Space, *store.User, *store.SpaceMember, *store.SpaceMember, error) {
|
|
spaceUID, username, err := ExtractSpaceMemberTokensFromName(name)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, status.Errorf(codes.InvalidArgument, "invalid space member name: %v", err)
|
|
}
|
|
space, callerMembership, err := s.resolveMemberSpace(ctx, buildSpaceName(spaceUID), currentUser)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, err
|
|
}
|
|
targetUser, err := ResolveUserByName(ctx, s.Store, BuildUserName(username))
|
|
if err != nil {
|
|
return nil, nil, nil, nil, status.Errorf(codes.Internal, "failed to resolve member user: %v", err)
|
|
}
|
|
if targetUser == nil {
|
|
return nil, nil, nil, nil, status.Error(codes.NotFound, "space member not found")
|
|
}
|
|
if targetUser.RowStatus != store.Normal {
|
|
return nil, nil, nil, nil, status.Error(codes.NotFound, "space member not found")
|
|
}
|
|
targetMembership, err := s.Store.GetSpaceMember(ctx, &store.FindSpaceMember{
|
|
SpaceID: &space.ID,
|
|
UserID: &targetUser.ID,
|
|
ViewerUserID: ¤tUser.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, nil, nil, status.Errorf(codes.Internal, "failed to get space membership: %v", err)
|
|
}
|
|
if targetMembership == nil || !targetMembership.Role.IsActiveMember() {
|
|
return nil, nil, nil, nil, status.Error(codes.NotFound, "space member not found")
|
|
}
|
|
return space, targetUser, callerMembership, targetMembership, nil
|
|
}
|
|
|
|
// GetSpaceMember gets one membership visible to another member of the space.
|
|
func (s *APIV1Service) GetSpaceMember(ctx context.Context, request *v1pb.GetSpaceMemberRequest) (*v1pb.SpaceMember, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
space, targetUser, _, membership, err := s.resolveSpaceMemberResource(ctx, request.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return convertSpaceMemberFromStore(space, targetUser, membership), nil
|
|
}
|
|
|
|
// UpdateSpaceMember changes a member's role in a Space.
|
|
func (s *APIV1Service) UpdateSpaceMember(ctx context.Context, request *v1pb.UpdateSpaceMemberRequest) (*v1pb.SpaceMember, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request.GetSpaceMember() == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "space member is required")
|
|
}
|
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "update mask must include role")
|
|
}
|
|
roleIncluded := false
|
|
userIncluded := false
|
|
for _, path := range request.UpdateMask.Paths {
|
|
switch path {
|
|
case "role":
|
|
roleIncluded = true
|
|
case "user":
|
|
// grpc-gateway infers user from the schema-required REST body. It is
|
|
// accepted as immutable identity context, never as a mutable field.
|
|
userIncluded = true
|
|
default:
|
|
return nil, status.Errorf(codes.InvalidArgument, "unsupported update mask path: %s", path)
|
|
}
|
|
}
|
|
if !roleIncluded {
|
|
return nil, status.Error(codes.InvalidArgument, "update mask must include role")
|
|
}
|
|
space, targetUser, callerMembership, targetMembership, err := s.resolveSpaceMemberResource(ctx, request.SpaceMember.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
expectedUser := BuildUserName(targetUser.Username)
|
|
if userIncluded && request.SpaceMember.User == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "space member user is required when included in the update mask")
|
|
}
|
|
if request.SpaceMember.User != "" && request.SpaceMember.User != expectedUser {
|
|
return nil, status.Error(codes.InvalidArgument, "space member user does not match name")
|
|
}
|
|
if err := requireSpaceAdministrator(callerMembership); err != nil {
|
|
return nil, err
|
|
}
|
|
role, ok := convertSpaceMemberRoleToStore(request.SpaceMember.Role)
|
|
if !ok {
|
|
return nil, status.Error(codes.InvalidArgument, "space member role must be ADMIN or USER")
|
|
}
|
|
updated, err := s.Store.UpdateSpaceMember(ctx, &store.UpdateSpaceMember{
|
|
SpaceID: targetMembership.SpaceID,
|
|
UserID: targetMembership.UserID,
|
|
Role: &role,
|
|
}, currentUser.ID)
|
|
if err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to update space member")
|
|
}
|
|
if updated == nil {
|
|
return nil, status.Error(codes.NotFound, "space member not found")
|
|
}
|
|
s.SSEHub.publishMemoChanged()
|
|
return convertSpaceMemberFromStore(space, targetUser, updated), nil
|
|
}
|
|
|
|
// DeleteSpaceMember removes a membership or lets a member leave a space.
|
|
func (s *APIV1Service) DeleteSpaceMember(ctx context.Context, request *v1pb.DeleteSpaceMemberRequest) (*emptypb.Empty, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, targetUser, callerMembership, targetMembership, err := s.resolveSpaceMemberResource(ctx, request.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
isSelf := targetUser.ID == currentUser.ID
|
|
if !isSelf {
|
|
if err := requireSpaceAdministrator(callerMembership); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := s.Store.DeleteSpaceMember(ctx, &store.DeleteSpaceMember{
|
|
SpaceID: targetMembership.SpaceID,
|
|
UserID: targetMembership.UserID,
|
|
}, currentUser.ID); err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to delete space member")
|
|
}
|
|
s.SSEHub.publishMemoChanged()
|
|
return &emptypb.Empty{}, nil
|
|
}
|