Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:02:24 +08:00

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: &currentTs,
}
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
}