4cddfcf2f3
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
554 lines
18 KiB
Go
554 lines
18 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"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"
|
|
storepb "github.com/usememos/memos/proto/gen/store"
|
|
"github.com/usememos/memos/store"
|
|
)
|
|
|
|
const maxBatchGetUsers = 100
|
|
|
|
func validatePassword(password string) error {
|
|
if password == "" {
|
|
return errors.New("password must not be empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateUserTagsSetting(setting *v1pb.UserSetting_TagsSetting) error {
|
|
if setting == nil {
|
|
return errors.New("tags setting is required")
|
|
}
|
|
for tag, metadata := range setting.Tags {
|
|
if strings.TrimSpace(tag) == "" {
|
|
return errors.New("tag key cannot be empty")
|
|
}
|
|
if _, err := regexp.Compile(tag); err != nil {
|
|
return errors.Wrapf(err, "tag key %q is not a valid regex pattern", tag)
|
|
}
|
|
if metadata == nil {
|
|
return errors.Errorf("tag metadata is required for %q", tag)
|
|
}
|
|
if metadata.GetBackgroundColor() != nil {
|
|
if err := validateInstanceColor(metadata.GetBackgroundColor()); err != nil {
|
|
return errors.Wrapf(err, "background_color for %q", tag)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersRequest) (*v1pb.ListUsersResponse, error) {
|
|
currentUser, err := s.fetchCurrentUser(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
|
}
|
|
if currentUser == nil {
|
|
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
|
}
|
|
if currentUser.Role != store.RoleAdmin {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
|
}
|
|
|
|
userFind := &store.FindUser{}
|
|
|
|
if request.Filter != "" {
|
|
username, err := extractUsernameFromFilter(request.Filter)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
|
|
}
|
|
if username != "" {
|
|
userFind.Username = &username
|
|
}
|
|
}
|
|
|
|
var limit, offset int
|
|
if request.PageToken != "" {
|
|
var pageToken v1pb.PageToken
|
|
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
|
|
}
|
|
limit = normalizePageSize(pageToken.Limit)
|
|
offset = max(int(pageToken.Offset), 0)
|
|
} else {
|
|
limit = normalizePageSize(request.PageSize)
|
|
}
|
|
// Fetch one extra row to detect whether a subsequent page exists.
|
|
limitPlusOne := limit + 1
|
|
userFind.Limit = &limitPlusOne
|
|
userFind.Offset = &offset
|
|
|
|
users, err := s.Store.ListUsers(ctx, userFind)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
|
}
|
|
|
|
nextPageToken := ""
|
|
if len(users) == limitPlusOne {
|
|
users = users[:limit]
|
|
nextPageToken, err = getPageToken(limit, offset+limit)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get next page token: %v", err)
|
|
}
|
|
}
|
|
|
|
response := &v1pb.ListUsersResponse{
|
|
Users: make([]*v1pb.User, 0, len(users)),
|
|
NextPageToken: nextPageToken,
|
|
}
|
|
for _, user := range users {
|
|
response.Users = append(response.Users, convertUserFromStore(user, currentUser))
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func normalizeBatchUsernames(usernames []string) ([]string, int) {
|
|
uniqueUsernames := make([]string, 0, len(usernames))
|
|
seen := make(map[string]struct{}, len(usernames))
|
|
nonEmptyCount := 0
|
|
for _, username := range usernames {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
continue
|
|
}
|
|
nonEmptyCount++
|
|
if _, ok := seen[username]; ok {
|
|
continue
|
|
}
|
|
seen[username] = struct{}{}
|
|
uniqueUsernames = append(uniqueUsernames, username)
|
|
}
|
|
return uniqueUsernames, nonEmptyCount
|
|
}
|
|
|
|
func (s *APIV1Service) BatchGetUsers(ctx context.Context, request *v1pb.BatchGetUsersRequest) (*v1pb.BatchGetUsersResponse, error) {
|
|
if len(request.Usernames) == 0 {
|
|
return &v1pb.BatchGetUsersResponse{Users: []*v1pb.User{}}, nil
|
|
}
|
|
|
|
uniqueUsernames, nonEmptyUsernameCount := normalizeBatchUsernames(request.Usernames)
|
|
if nonEmptyUsernameCount > maxBatchGetUsers {
|
|
return nil, status.Errorf(codes.InvalidArgument, "too many usernames (max %d)", maxBatchGetUsers)
|
|
}
|
|
|
|
if len(uniqueUsernames) == 0 {
|
|
return &v1pb.BatchGetUsersResponse{Users: []*v1pb.User{}}, nil
|
|
}
|
|
|
|
normal := store.Normal
|
|
users, err := s.Store.ListUsers(ctx, &store.FindUser{
|
|
UsernameList: uniqueUsernames,
|
|
RowStatus: &normal,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
|
}
|
|
|
|
currentUser, _ := s.fetchCurrentUser(ctx)
|
|
response := &v1pb.BatchGetUsersResponse{
|
|
Users: make([]*v1pb.User, 0, len(users)),
|
|
}
|
|
for _, user := range users {
|
|
response.Users = append(response.Users, convertUserFromStore(user, currentUser))
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) {
|
|
user, err := ResolveUserByName(ctx, s.Store, request.Name)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %s", request.Name)
|
|
}
|
|
if user == nil {
|
|
return nil, status.Errorf(codes.NotFound, "user not found")
|
|
}
|
|
currentUser, _ := s.fetchCurrentUser(ctx)
|
|
return convertUserFromStore(user, currentUser), nil
|
|
}
|
|
|
|
func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserRequest) (*v1pb.User, error) {
|
|
// Get current user (might be nil for unauthenticated requests)
|
|
currentUser, _ := s.fetchCurrentUser(ctx)
|
|
|
|
if request.User == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "user is required")
|
|
}
|
|
if err := validateWritableUsername(request.User.Username); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
|
|
}
|
|
if err := validatePassword(request.User.Password); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
|
|
}
|
|
|
|
roleToAssign := store.RoleUser
|
|
if currentUser != nil && currentUser.Role == store.RoleAdmin {
|
|
// Authenticated ADMIN user can create users with any role specified in request
|
|
if request.User.Role != v1pb.User_ROLE_UNSPECIFIED {
|
|
roleToAssign = convertUserRoleToStore(request.User.Role)
|
|
}
|
|
} else {
|
|
limitOne := 1
|
|
allUsers, err := s.Store.ListUsers(ctx, &store.FindUser{Limit: &limitOne})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
|
}
|
|
if len(allUsers) == 0 {
|
|
roleToAssign = store.RoleAdmin
|
|
if !request.ValidateOnly {
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to generate password hash: %v", err)
|
|
}
|
|
user, created, err := s.Store.CreateUserIfNoUsers(ctx, &store.User{
|
|
Username: request.User.Username,
|
|
Role: store.RoleAdmin,
|
|
Email: request.User.Email,
|
|
Nickname: request.User.DisplayName,
|
|
PasswordHash: string(passwordHash),
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create first user: %v", err)
|
|
}
|
|
if created {
|
|
return convertUserFromStore(user, user), nil
|
|
}
|
|
roleToAssign = store.RoleUser
|
|
}
|
|
}
|
|
|
|
// Only allow user registration if it is enabled in the settings, or if the user is a superuser
|
|
if roleToAssign != store.RoleAdmin {
|
|
instanceGeneralSetting, err := s.Store.GetInstanceGeneralSetting(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get instance general setting, error: %v", err)
|
|
}
|
|
if instanceGeneralSetting.DisallowUserRegistration {
|
|
return nil, status.Errorf(codes.PermissionDenied, "user registration is not allowed")
|
|
}
|
|
if instanceGeneralSetting.DisallowPasswordAuth {
|
|
return nil, status.Errorf(codes.PermissionDenied, "password signup is not allowed")
|
|
}
|
|
}
|
|
}
|
|
|
|
// If validate_only is true, just validate without creating
|
|
if request.ValidateOnly {
|
|
// Perform validation checks without actually creating the user
|
|
return &v1pb.User{
|
|
Username: request.User.Username,
|
|
Email: request.User.Email,
|
|
DisplayName: request.User.DisplayName,
|
|
Role: convertUserRoleFromStore(roleToAssign),
|
|
}, nil
|
|
}
|
|
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to generate password hash: %v", err)
|
|
}
|
|
|
|
user, err := s.Store.CreateUser(ctx, &store.User{
|
|
Username: request.User.Username,
|
|
Role: roleToAssign,
|
|
Email: request.User.Email,
|
|
Nickname: request.User.DisplayName,
|
|
PasswordHash: string(passwordHash),
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
|
|
}
|
|
|
|
return convertUserFromStore(user, user), nil
|
|
}
|
|
|
|
func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserRequest) (*v1pb.User, error) {
|
|
if request.User == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "user is required")
|
|
}
|
|
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
|
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
|
|
}
|
|
user, err := ResolveUserByName(ctx, s.Store, request.User.Name)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
|
|
}
|
|
if user == nil {
|
|
if request.AllowMissing {
|
|
return nil, status.Errorf(codes.NotFound, "user not found")
|
|
}
|
|
return nil, status.Errorf(codes.NotFound, "user not found")
|
|
}
|
|
userID := user.ID
|
|
currentUser, err := s.fetchCurrentUser(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
|
}
|
|
if currentUser == nil {
|
|
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
|
}
|
|
// Check permission.
|
|
// Only allow admin or self to update user.
|
|
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
|
}
|
|
|
|
currentTs := time.Now().Unix()
|
|
update := &store.UpdateUser{
|
|
ID: user.ID,
|
|
UpdatedTs: ¤tTs,
|
|
}
|
|
instanceGeneralSetting, err := s.Store.GetInstanceGeneralSetting(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get instance general setting: %v", err)
|
|
}
|
|
for _, field := range request.UpdateMask.Paths {
|
|
switch field {
|
|
case "username":
|
|
if instanceGeneralSetting.DisallowChangeUsername {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change username")
|
|
}
|
|
if err := validateWritableUsername(request.User.Username); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
|
|
}
|
|
update.Username = &request.User.Username
|
|
case "display_name":
|
|
if instanceGeneralSetting.DisallowChangeNickname {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change nickname")
|
|
}
|
|
update.Nickname = &request.User.DisplayName
|
|
case "email":
|
|
update.Email = &request.User.Email
|
|
case "avatar_url":
|
|
// Validate avatar MIME type to prevent XSS during upload
|
|
if request.User.AvatarUrl != "" {
|
|
imageType, _, err := extractImageInfo(request.User.AvatarUrl)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid avatar format: %v", err)
|
|
}
|
|
// Only allow safe image formats for avatars
|
|
allowedAvatarTypes := map[string]bool{
|
|
"image/png": true,
|
|
"image/jpeg": true,
|
|
"image/jpg": true,
|
|
"image/gif": true,
|
|
"image/webp": true,
|
|
}
|
|
if !allowedAvatarTypes[imageType] {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid avatar image type: %s. Only PNG, JPEG, GIF, and WebP are allowed", imageType)
|
|
}
|
|
}
|
|
update.AvatarURL = &request.User.AvatarUrl
|
|
case "description":
|
|
update.Description = &request.User.Description
|
|
case "role":
|
|
// Only allow admin to update role.
|
|
if currentUser.Role != store.RoleAdmin {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
|
}
|
|
role := convertUserRoleToStore(request.User.Role)
|
|
update.Role = &role
|
|
case "password":
|
|
if err := validatePassword(request.User.Password); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
|
|
}
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to generate password hash: %v", err)
|
|
}
|
|
passwordHashStr := string(passwordHash)
|
|
update.PasswordHash = &passwordHashStr
|
|
case "state":
|
|
if currentUser.Role != store.RoleAdmin {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
|
}
|
|
rowStatus := convertStateToStore(request.User.State)
|
|
update.RowStatus = &rowStatus
|
|
default:
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
|
|
}
|
|
}
|
|
|
|
updatedUser, err := s.Store.UpdateUser(ctx, update)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
|
|
}
|
|
|
|
return convertUserFromStore(updatedUser, currentUser), nil
|
|
}
|
|
|
|
func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserRequest) (*emptypb.Empty, error) {
|
|
user, err := ResolveUserByName(ctx, s.Store, request.Name)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
|
|
}
|
|
if user == nil {
|
|
return nil, status.Errorf(codes.NotFound, "user not found")
|
|
}
|
|
userID := user.ID
|
|
currentUser, err := s.fetchCurrentUser(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
|
}
|
|
if currentUser == nil {
|
|
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
|
}
|
|
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
|
}
|
|
isSelfDelete := currentUser.ID == userID
|
|
|
|
deleteResult, err := s.Store.DeleteUser(ctx, &store.DeleteUser{
|
|
ID: user.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
|
|
}
|
|
attachments := deleteResult.Attachments
|
|
var attachmentCleanupErr error
|
|
failedAttachmentIDs := make([]int32, 0)
|
|
attachmentStorageSetting, attachmentStorageSettingErr := getDeleteUserAttachmentStorageSetting(ctx, s.Store, attachments)
|
|
for _, attachment := range attachments {
|
|
var err error
|
|
if attachmentStorageSettingErr != nil && store.AttachmentNeedsInstanceStorageSetting(attachment) {
|
|
err = attachmentStorageSettingErr
|
|
} else {
|
|
err = s.Store.DeleteAttachmentStorageWithInstanceSetting(ctx, attachment, attachmentStorageSetting)
|
|
}
|
|
if err != nil {
|
|
slog.Warn("failed to delete attachment storage after deleting user", "user_id", userID, "attachment_id", attachment.ID, "error", err)
|
|
failedAttachmentIDs = append(failedAttachmentIDs, attachment.ID)
|
|
if attachmentCleanupErr == nil {
|
|
attachmentCleanupErr = err
|
|
}
|
|
}
|
|
}
|
|
if isSelfDelete {
|
|
if err := s.clearAuthCookies(ctx); err != nil {
|
|
slog.Warn("failed to clear auth cookies after self delete", "user_id", userID, "error", err)
|
|
}
|
|
}
|
|
if attachmentCleanupErr != nil {
|
|
return nil, status.Errorf(
|
|
codes.Internal,
|
|
"user was deleted but attachment storage cleanup failed for %d attachment(s), first attachment_id=%d: %v",
|
|
len(failedAttachmentIDs),
|
|
failedAttachmentIDs[0],
|
|
attachmentCleanupErr,
|
|
)
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
func getDeleteUserAttachmentStorageSetting(ctx context.Context, stores *store.Store, attachments []*store.Attachment) (*storepb.InstanceStorageSetting, error) {
|
|
for _, attachment := range attachments {
|
|
if store.AttachmentNeedsInstanceStorageSetting(attachment) {
|
|
instanceStorageSetting, err := stores.GetInstanceStorageSetting(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to get instance storage setting")
|
|
}
|
|
return instanceStorageSetting, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting {
|
|
return &v1pb.UserSetting_GeneralSetting{
|
|
Locale: "en",
|
|
MemoVisibility: "PRIVATE",
|
|
Theme: "",
|
|
}
|
|
}
|
|
|
|
func (s *APIV1Service) resolveUserFromName(ctx context.Context, name string) (*store.User, error) {
|
|
user, err := ResolveUserByName(ctx, s.Store, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, errors.Errorf("user not found: %s", name)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *APIV1Service) resolveUserAndSettingKeyFromName(ctx context.Context, name string) (*store.User, string, error) {
|
|
parts := strings.Split(name, "/")
|
|
if len(parts) != 4 || parts[0] != "users" || parts[2] != "settings" {
|
|
return nil, "", errors.Errorf("invalid resource name format: %s", name)
|
|
}
|
|
|
|
user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return user, parts[3], nil
|
|
}
|
|
|
|
func (s *APIV1Service) resolveUserAndWebhookIDFromName(ctx context.Context, name string) (*store.User, string, error) {
|
|
parts := strings.Split(name, "/")
|
|
if len(parts) != 4 || parts[0] != "users" || parts[2] != "webhooks" {
|
|
return nil, "", errors.New("invalid webhook name format")
|
|
}
|
|
|
|
user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return user, parts[3], nil
|
|
}
|
|
|
|
func (s *APIV1Service) resolveUserAndLinkedIdentityProviderFromName(ctx context.Context, name string) (*store.User, string, error) {
|
|
parts := strings.Split(name, "/")
|
|
if len(parts) != 4 || parts[0] != "users" || parts[2] != "linkedIdentities" {
|
|
return nil, "", errors.Errorf("invalid linked identity name: %s", name)
|
|
}
|
|
|
|
user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return user, parts[3], nil
|
|
}
|
|
|
|
func convertLinkedIdentityFromStore(user *store.User, identity *store.UserIdentity) *v1pb.LinkedIdentity {
|
|
return &v1pb.LinkedIdentity{
|
|
Name: fmt.Sprintf("%s/linkedIdentities/%s", BuildUserName(user.Username), identity.Provider),
|
|
IdpName: IdentityProviderNamePrefix + identity.Provider,
|
|
ExternUid: identity.ExternUID,
|
|
}
|
|
}
|
|
|
|
func (s *APIV1Service) resolveUserAndNotificationIDFromName(ctx context.Context, name string) (*store.User, int32, error) {
|
|
parts := strings.Split(name, "/")
|
|
if len(parts) != 4 || parts[0] != "users" || parts[2] != "notifications" {
|
|
return nil, 0, errors.Errorf("invalid notification name: %s", name)
|
|
}
|
|
|
|
user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
id, err := strconv.Atoi(parts[3])
|
|
if err != nil {
|
|
return nil, 0, errors.Errorf("invalid notification id: %s", parts[3])
|
|
}
|
|
|
|
return user, int32(id), nil
|
|
}
|