memos/server/router/api/v1/space_service.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: &currentUser.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: &currentUser.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: &currentUser.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: &currentUser.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: &currentUser.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
}