- Generate UUID v4 values in the client with validated custom UID support.\n- Show immutable UIDs where Space titles need disambiguation.\n- Standardize Space surfaces on the Lucide Astroid icon.
743 lines
28 KiB
Go
743 lines
28 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 membership or invitation already exists")
|
|
case errors.Is(err, store.ErrSpaceInvitationNotFound):
|
|
return status.Error(codes.NotFound, "space invitation not found")
|
|
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 := ValidateAndGenerateSpaceUID(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.publishSpaceChanged()
|
|
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.publishSpaceChanged()
|
|
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.publishSpaceChanged()
|
|
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
|
|
}
|
|
|
|
// CreateSpaceInvitation creates a pending invitation without granting any
|
|
// Space access to the invitee.
|
|
func (s *APIV1Service) CreateSpaceInvitation(ctx context.Context, request *v1pb.CreateSpaceInvitationRequest) (*v1pb.SpaceInvitation, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request.GetSpaceInvitation() == nil {
|
|
return nil, status.Error(codes.InvalidArgument, "space invitation 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.SpaceInvitation.Invitee)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid invitation invitee: %v", err)
|
|
}
|
|
targetUser, err := s.Store.GetUser(ctx, &store.FindUser{Username: &targetUsername})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get invitation invitee: %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 invited to a space")
|
|
}
|
|
role, ok := convertSpaceMemberRoleToStore(request.SpaceInvitation.Role)
|
|
if !ok {
|
|
return nil, status.Error(codes.InvalidArgument, "space invitation role must be ADMIN or USER")
|
|
}
|
|
expectedName := buildSpaceInvitationName(space.UID, targetUser.Username)
|
|
if request.SpaceInvitation.Name != "" && request.SpaceInvitation.Name != expectedName {
|
|
return nil, status.Error(codes.InvalidArgument, "space invitation name does not match parent and invitee")
|
|
}
|
|
created, err := s.Store.CreateSpaceInvitation(ctx, &store.SpaceInvitation{
|
|
SpaceID: space.ID,
|
|
UserID: targetUser.ID,
|
|
Role: role,
|
|
}, currentUser.ID)
|
|
if err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to create space invitation")
|
|
}
|
|
s.SSEHub.publishSpaceChanged()
|
|
return convertSpaceInvitationFromStore(space, targetUser, created), nil
|
|
}
|
|
|
|
// ListSpaceInvitations lists pending invitations after requiring an active
|
|
// Space administrator.
|
|
func (s *APIV1Service) ListSpaceInvitations(ctx context.Context, request *v1pb.ListSpaceInvitationsRequest) (*v1pb.ListSpaceInvitationsResponse, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
space, callerMembership, err := s.resolveMemberSpace(ctx, request.Parent, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := requireSpaceAdministrator(callerMembership); err != nil {
|
|
return nil, err
|
|
}
|
|
limit, offset, err := listSpacePage(request.PageSize, request.PageToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitPlusOne := limit + 1
|
|
invitations, err := s.Store.ListSpaceInvitations(ctx, &store.FindSpaceInvitation{
|
|
SpaceID: &space.ID,
|
|
ViewerUserID: ¤tUser.ID,
|
|
Limit: &limitPlusOne,
|
|
Offset: &offset,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list space invitations: %v", err)
|
|
}
|
|
nextPageToken := ""
|
|
if len(invitations) == limitPlusOne {
|
|
invitations = invitations[: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.ListSpaceInvitationsResponse{
|
|
SpaceInvitations: make([]*v1pb.SpaceInvitation, 0, len(invitations)),
|
|
NextPageToken: nextPageToken,
|
|
}
|
|
if len(invitations) == 0 {
|
|
return response, nil
|
|
}
|
|
userIDs := make([]int32, 0, len(invitations))
|
|
for _, invitation := range invitations {
|
|
userIDs = append(userIDs, invitation.UserID)
|
|
}
|
|
users, err := s.Store.ListUsers(ctx, &store.FindUser{IDList: userIDs})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to resolve invitation invitees: %v", err)
|
|
}
|
|
usersByID := make(map[int32]*store.User, len(users))
|
|
for _, user := range users {
|
|
usersByID[user.ID] = user
|
|
}
|
|
for _, invitation := range invitations {
|
|
user := usersByID[invitation.UserID]
|
|
if user == nil || convertSpaceMemberRoleFromStore(invitation.Role) == v1pb.SpaceMember_ROLE_UNSPECIFIED {
|
|
continue
|
|
}
|
|
response.SpaceInvitations = append(response.SpaceInvitations, convertSpaceInvitationFromStore(space, user, invitation))
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
// ListUserSpaceInvitations lists the authenticated user's received pending
|
|
// invitations. A user cannot enumerate another user's invitations.
|
|
func (s *APIV1Service) ListUserSpaceInvitations(ctx context.Context, request *v1pb.ListUserSpaceInvitationsRequest) (*v1pb.ListUserSpaceInvitationsResponse, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
username, err := parseUsernameFromName(request.Parent)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid user parent: %v", err)
|
|
}
|
|
if username != currentUser.Username {
|
|
return nil, status.Error(codes.PermissionDenied, "users may only list their own space invitations")
|
|
}
|
|
limit, offset, err := listSpacePage(request.PageSize, request.PageToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limitPlusOne := limit + 1
|
|
invitations, err := s.Store.ListSpaceInvitations(ctx, &store.FindSpaceInvitation{
|
|
UserID: ¤tUser.ID,
|
|
ViewerUserID: ¤tUser.ID,
|
|
Limit: &limitPlusOne,
|
|
Offset: &offset,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list user space invitations: %v", err)
|
|
}
|
|
nextPageToken := ""
|
|
if len(invitations) == limitPlusOne {
|
|
invitations = invitations[: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.ListUserSpaceInvitationsResponse{
|
|
SpaceInvitations: make([]*v1pb.SpaceInvitation, 0, len(invitations)),
|
|
NextPageToken: nextPageToken,
|
|
}
|
|
if len(invitations) == 0 {
|
|
return response, nil
|
|
}
|
|
spaceIDs := make([]int32, 0, len(invitations))
|
|
for _, invitation := range invitations {
|
|
spaceIDs = append(spaceIDs, invitation.SpaceID)
|
|
}
|
|
spaces, err := s.Store.ListSpaces(ctx, &store.FindSpace{IDList: spaceIDs})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to resolve invitation spaces: %v", err)
|
|
}
|
|
spacesByID := make(map[int32]*store.Space, len(spaces))
|
|
for _, space := range spaces {
|
|
spacesByID[space.ID] = space
|
|
}
|
|
for _, invitation := range invitations {
|
|
space := spacesByID[invitation.SpaceID]
|
|
if space == nil || convertSpaceMemberRoleFromStore(invitation.Role) == v1pb.SpaceMember_ROLE_UNSPECIFIED {
|
|
continue
|
|
}
|
|
response.SpaceInvitations = append(response.SpaceInvitations, convertSpaceInvitationFromStore(space, currentUser, invitation))
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
// resolveSpaceInvitationResource resolves an invitation and authorizes either
|
|
// its invitee or an active administrator of its Space.
|
|
func (s *APIV1Service) resolveSpaceInvitationResource(ctx context.Context, name string, currentUser *store.User) (*store.Space, *store.User, *store.SpaceMember, *store.SpaceInvitation, error) {
|
|
spaceUID, username, err := ExtractSpaceInvitationTokensFromName(name)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, status.Errorf(codes.InvalidArgument, "invalid space invitation name: %v", err)
|
|
}
|
|
space, err := s.Store.GetSpace(ctx, &store.FindSpace{UID: &spaceUID})
|
|
if err != nil {
|
|
return nil, nil, nil, nil, status.Errorf(codes.Internal, "failed to resolve invitation space: %v", err)
|
|
}
|
|
if space == nil {
|
|
return nil, nil, nil, nil, status.Error(codes.NotFound, "space invitation not found")
|
|
}
|
|
targetUser, err := s.Store.GetUser(ctx, &store.FindUser{Username: &username})
|
|
if err != nil {
|
|
return nil, nil, nil, nil, status.Errorf(codes.Internal, "failed to resolve invitation invitee: %v", err)
|
|
}
|
|
if targetUser == nil {
|
|
return nil, nil, nil, nil, status.Error(codes.NotFound, "space invitation not found")
|
|
}
|
|
callerMembership, err := s.Store.GetSpaceMember(ctx, &store.FindSpaceMember{SpaceID: &space.ID, UserID: ¤tUser.ID})
|
|
if err != nil {
|
|
return nil, nil, nil, nil, status.Errorf(codes.Internal, "failed to resolve caller space membership: %v", err)
|
|
}
|
|
isInvitee := targetUser.ID == currentUser.ID
|
|
isAdministrator := callerMembership != nil && callerMembership.Role == store.SpaceMemberRoleAdmin
|
|
if !isInvitee && !isAdministrator {
|
|
return nil, nil, nil, nil, status.Error(codes.NotFound, "space invitation not found")
|
|
}
|
|
invitation, err := s.Store.GetSpaceInvitation(ctx, &store.FindSpaceInvitation{
|
|
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 invitation: %v", err)
|
|
}
|
|
if invitation == nil || convertSpaceMemberRoleFromStore(invitation.Role) == v1pb.SpaceMember_ROLE_UNSPECIFIED {
|
|
return nil, nil, nil, nil, status.Error(codes.NotFound, "space invitation not found")
|
|
}
|
|
return space, targetUser, callerMembership, invitation, nil
|
|
}
|
|
|
|
// GetSpaceInvitation gets a pending invitation for its invitee or a Space
|
|
// administrator.
|
|
func (s *APIV1Service) GetSpaceInvitation(ctx context.Context, request *v1pb.GetSpaceInvitationRequest) (*v1pb.SpaceInvitation, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
space, targetUser, _, invitation, err := s.resolveSpaceInvitationResource(ctx, request.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return convertSpaceInvitationFromStore(space, targetUser, invitation), nil
|
|
}
|
|
|
|
// DeleteSpaceInvitation revokes a pending invitation. Only an active Space
|
|
// administrator can revoke it.
|
|
func (s *APIV1Service) DeleteSpaceInvitation(ctx context.Context, request *v1pb.DeleteSpaceInvitationRequest) (*emptypb.Empty, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, targetUser, callerMembership, invitation, err := s.resolveSpaceInvitationResource(ctx, request.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := requireSpaceAdministrator(callerMembership); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.Store.RevokeSpaceInvitation(ctx, &store.RevokeSpaceInvitation{
|
|
SpaceID: invitation.SpaceID,
|
|
UserID: targetUser.ID,
|
|
}, currentUser.ID); err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to revoke space invitation")
|
|
}
|
|
s.SSEHub.publishSpaceChanged()
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
// AcceptSpaceInvitation activates the invited user's membership with the role
|
|
// selected by the administrator who created the invitation.
|
|
func (s *APIV1Service) AcceptSpaceInvitation(ctx context.Context, request *v1pb.AcceptSpaceInvitationRequest) (*v1pb.SpaceMember, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
space, targetUser, _, invitation, err := s.resolveSpaceInvitationResource(ctx, request.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if targetUser.ID != currentUser.ID {
|
|
return nil, status.Error(codes.PermissionDenied, "only the invitee may accept a space invitation")
|
|
}
|
|
member, err := s.Store.AcceptSpaceInvitation(ctx, &store.AcceptSpaceInvitation{SpaceID: invitation.SpaceID, UserID: currentUser.ID}, currentUser.ID)
|
|
if err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to accept space invitation")
|
|
}
|
|
s.SSEHub.publishSpaceChanged()
|
|
return convertSpaceMemberFromStore(space, currentUser, member), nil
|
|
}
|
|
|
|
// DeclineSpaceInvitation deletes the authenticated invitee's pending
|
|
// invitation without ever creating a membership.
|
|
func (s *APIV1Service) DeclineSpaceInvitation(ctx context.Context, request *v1pb.DeclineSpaceInvitationRequest) (*emptypb.Empty, error) {
|
|
currentUser, err := s.requireCurrentSpaceUser(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, targetUser, _, invitation, err := s.resolveSpaceInvitationResource(ctx, request.Name, currentUser)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if targetUser.ID != currentUser.ID {
|
|
return nil, status.Error(codes.PermissionDenied, "only the invitee may decline a space invitation")
|
|
}
|
|
if err := s.Store.DeclineSpaceInvitation(ctx, &store.DeclineSpaceInvitation{SpaceID: invitation.SpaceID, UserID: currentUser.ID}, currentUser.ID); err != nil {
|
|
return nil, mapSpaceMutationError(err, "failed to decline space invitation")
|
|
}
|
|
s.SSEHub.publishSpaceChanged()
|
|
return &emptypb.Empty{}, 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.publishSpaceChanged()
|
|
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.publishSpaceChanged()
|
|
return &emptypb.Empty{}, nil
|
|
}
|