package controller import ( "context" "io" "net/http" "time" "github.com/danielgtaylor/huma/v2" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" httpapi "github.com/pocket-id/pocket-id/backend/internal/utils/huma" "github.com/pocket-id/pocket-id/backend/internal/webauthn" ) const defaultOneTimeAccessTokenDuration = 15 * time.Minute type userListInput struct { utils.ListRequestOptions Search string `query:"search" required:"false"` } type userIDInput struct { ID string `path:"id"` } type userCredentialIDInput struct { ID string `path:"id"` CredentialID string `path:"credentialId"` } type userCreateInput struct { Body dto.UserCreateDto } type userUpdateInput struct { ID string `path:"id"` Body dto.UserCreateDto } type userGroupsInput struct { ID string `path:"id"` Body dto.UserUpdateUserGroupDto } type userPictureUploadForm struct { File huma.FormFile `form:"file" required:"true"` } type userPictureUploadInput struct { ID string `path:"id"` RawBody huma.MultipartFormFiles[userPictureUploadForm] } type currentUserPictureUploadInput struct { RawBody huma.MultipartFormFiles[userPictureUploadForm] } type oneTimeAccessOwnInput struct { Body dto.OneTimeAccessTokenCreateDto } type oneTimeAccessAdminInput struct { ID string `path:"id"` Body dto.OneTimeAccessTokenCreateDto } type oneTimeAccessEmailAdminInput struct { ID string `path:"id"` Body dto.OneTimeAccessEmailAsAdminDto } type oneTimeAccessEmailInput struct { Body dto.OneTimeAccessEmailAsUnauthenticatedUserDto } type oneTimeAccessExchangeInput struct { Token string `path:"token"` } type emailVerificationInput struct { Body dto.EmailVerificationDto } type userCookieOutput struct { SetCookie []http.Cookie `header:"Set-Cookie"` Body dto.UserDto } type userPictureOutput struct { ContentType string `header:"Content-Type"` ContentLength int64 `header:"Content-Length"` CacheControl string `header:"Cache-Control"` Body func(huma.Context) } // NewUserController registers user management endpoints func NewUserController(api huma.API, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) { controller := &UserController{userService: userService, oneTimeAccessService: oneTimeAccessService, webAuthnService: webAuthnService, appConfigService: appConfigService} adminAuth := authMiddleware.Huma(api) userAuth := authMiddleware.WithAdminNotRequired().Huma(api) listUsers := userOperation("list-users", http.MethodGet, "/api/users", "List users") adminAuth(&listUsers) httpapi.Register(api, listUsers, controller.listUsersHandler) getCurrentUser := userOperation("get-current-user", http.MethodGet, "/api/users/me", "Get current user") userAuth(&getCurrentUser) httpapi.Register(api, getCurrentUser, controller.getCurrentUserHandler) getUser := userOperation("get-user", http.MethodGet, "/api/users/{id}", "Get user by ID") adminAuth(&getUser) httpapi.Register(api, getUser, controller.getUserHandler) createUser := userOperation("create-user", http.MethodPost, "/api/users", "Create user") createUser.DefaultStatus = http.StatusCreated adminAuth(&createUser) httpapi.Register(api, createUser, controller.createUserHandler) updateUser := userOperation("update-user", http.MethodPut, "/api/users/{id}", "Update user") adminAuth(&updateUser) httpapi.Register(api, updateUser, controller.updateUserHandler) updateCurrentUser := userOperation("update-current-user", http.MethodPut, "/api/users/me", "Update current user") userAuth(&updateCurrentUser) httpapi.Register(api, updateCurrentUser, controller.updateCurrentUserHandler) getGroups := userOperation("get-user-groups", http.MethodGet, "/api/users/{id}/groups", "Get user groups") adminAuth(&getGroups) httpapi.Register(api, getGroups, controller.getUserGroupsHandler) listCredentials := userOperation("list-user-webauthn-credentials", http.MethodGet, "/api/users/{id}/webauthn-credentials", "List user passkeys") adminAuth(&listCredentials) httpapi.Register(api, listCredentials, controller.listUserWebauthnCredentialsHandler) deleteUser := userOperation("delete-user", http.MethodDelete, "/api/users/{id}", "Delete user") deleteUser.DefaultStatus = http.StatusNoContent adminAuth(&deleteUser) httpapi.Register(api, deleteUser, controller.deleteUserHandler) deleteCredential := userOperation("delete-user-webauthn-credential", http.MethodDelete, "/api/users/{id}/webauthn-credentials/{credentialId}", "Delete user passkey") deleteCredential.DefaultStatus = http.StatusNoContent adminAuth(&deleteCredential) httpapi.Register(api, deleteCredential, controller.deleteUserWebauthnCredentialHandler) updateGroups := userOperation("update-user-groups", http.MethodPut, "/api/users/{id}/user-groups", "Update user groups") adminAuth(&updateGroups) httpapi.Register(api, updateGroups, controller.updateUserGroups) httpapi.Register(api, userOperation("get-user-profile-picture", http.MethodGet, "/api/users/{id}/profile-picture.png", "Get user profile picture"), controller.getUserProfilePictureHandler) updatePicture := userOperation("update-user-profile-picture", http.MethodPut, "/api/users/{id}/profile-picture", "Update user profile picture") updatePicture.DefaultStatus = http.StatusNoContent adminAuth(&updatePicture) httpapi.Register(api, updatePicture, controller.updateUserProfilePictureHandler) updateCurrentPicture := userOperation("update-current-user-profile-picture", http.MethodPut, "/api/users/me/profile-picture", "Update current user profile picture") updateCurrentPicture.DefaultStatus = http.StatusNoContent userAuth(&updateCurrentPicture) httpapi.Register(api, updateCurrentPicture, controller.updateCurrentUserProfilePictureHandler) createOwnToken := userOperation("create-own-one-time-access-token", http.MethodPost, "/api/users/me/one-time-access-token", "Create one-time access token for current user") createOwnToken.DefaultStatus = http.StatusCreated userAuth(&createOwnToken) httpapi.Register(api, createOwnToken, controller.createOwnOneTimeAccessTokenHandler) createAdminToken := userOperation("create-user-one-time-access-token", http.MethodPost, "/api/users/{id}/one-time-access-token", "Create one-time access token for user") createAdminToken.DefaultStatus = http.StatusCreated adminAuth(&createAdminToken) httpapi.Register(api, createAdminToken, controller.createAdminOneTimeAccessTokenHandler) adminEmail := userOperation("request-user-one-time-access-email", http.MethodPost, "/api/users/{id}/one-time-access-email", "Request one-time access email for user") adminEmail.DefaultStatus = http.StatusNoContent adminAuth(&adminEmail) httpapi.Register(api, adminEmail, controller.requestOneTimeAccessEmailAsAdminHandler) exchangeToken := userOperation("exchange-one-time-access-token", http.MethodPost, "/api/one-time-access-token/{token}", "Exchange one-time access token") exchangeToken.Middlewares = append(exchangeToken.Middlewares, rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessToken)) httpapi.Register(api, exchangeToken, controller.exchangeOneTimeAccessTokenHandler) requestEmail := userOperation("request-one-time-access-email", http.MethodPost, "/api/one-time-access-email", "Request one-time access email") requestEmail.DefaultStatus = http.StatusNoContent requestEmail.Middlewares = append(requestEmail.Middlewares, rateLimitMiddleware.Huma(api, middleware.RateLimitOneTimeAccessEmail)) httpapi.Register(api, requestEmail, controller.requestOneTimeAccessEmailAsUnauthenticatedUserHandler) resetPicture := userOperation("reset-user-profile-picture", http.MethodDelete, "/api/users/{id}/profile-picture", "Reset user profile picture") resetPicture.DefaultStatus = http.StatusNoContent adminAuth(&resetPicture) httpapi.Register(api, resetPicture, controller.resetUserProfilePictureHandler) resetCurrentPicture := userOperation("reset-current-user-profile-picture", http.MethodDelete, "/api/users/me/profile-picture", "Reset current user profile picture") resetCurrentPicture.DefaultStatus = http.StatusNoContent userAuth(&resetCurrentPicture) httpapi.Register(api, resetCurrentPicture, controller.resetCurrentUserProfilePictureHandler) sendVerification := userOperation("send-email-verification", http.MethodPost, "/api/users/me/send-email-verification", "Send email verification") sendVerification.DefaultStatus = http.StatusNoContent sendVerification.Middlewares = append(sendVerification.Middlewares, rateLimitMiddleware.Huma(api, middleware.RateLimitSendEmailVerification)) userAuth(&sendVerification) httpapi.Register(api, sendVerification, controller.sendEmailVerificationHandler) verifyEmail := userOperation("verify-email", http.MethodPost, "/api/users/me/verify-email", "Verify email") verifyEmail.DefaultStatus = http.StatusNoContent verifyEmail.Middlewares = append(verifyEmail.Middlewares, rateLimitMiddleware.Huma(api, middleware.RateLimitVerifyEmail)) userAuth(&verifyEmail) httpapi.Register(api, verifyEmail, controller.verifyEmailHandler) } func userOperation(id, method, path, summary string) huma.Operation { return huma.Operation{OperationID: id, Method: method, Path: path, Summary: summary, Tags: []string{"Users"}} } type UserController struct { userService *service.UserService oneTimeAccessService *service.OneTimeAccessService webAuthnService *webauthn.Module appConfigService *service.AppConfigService } func (uc *UserController) getUserGroupsHandler(ctx context.Context, input *userIDInput) (*httpapi.BodyOutput[[]dto.UserGroupDto], error) { groups, err := uc.userService.GetUserGroups(ctx, input.ID) if err != nil { return nil, err } var output []dto.UserGroupDto if err := dto.MapStructList(groups, &output); err != nil { return nil, err } return &httpapi.BodyOutput[[]dto.UserGroupDto]{Body: output}, nil } func (uc *UserController) listUserWebauthnCredentialsHandler(ctx context.Context, input *userIDInput) (*httpapi.BodyOutput[[]dto.WebauthnCredentialDto], error) { if _, err := uc.userService.GetUser(ctx, input.ID); err != nil { return nil, err } credentials, err := uc.webAuthnService.ListCredentials(ctx, input.ID) if err != nil { return nil, err } var output []dto.WebauthnCredentialDto if err := dto.MapStructList(credentials, &output); err != nil { return nil, err } return &httpapi.BodyOutput[[]dto.WebauthnCredentialDto]{Body: output}, nil } func (uc *UserController) listUsersHandler(ctx context.Context, input *userListInput) (*httpapi.BodyOutput[dto.Paginated[dto.UserDto]], error) { users, pagination, err := uc.userService.ListUsers(ctx, input.Search, input.ListRequestOptions) if err != nil { return nil, err } var output []dto.UserDto if err := dto.MapStructList(users, &output); err != nil { return nil, err } return &httpapi.BodyOutput[dto.Paginated[dto.UserDto]]{Body: dto.Paginated[dto.UserDto]{Data: output, Pagination: pagination}}, nil } func (uc *UserController) getUserHandler(ctx context.Context, input *userIDInput) (*httpapi.BodyOutput[dto.UserDto], error) { user, err := uc.userService.GetUser(ctx, input.ID) if err != nil { return nil, err } return mapUser(user) } func (uc *UserController) getCurrentUserHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.BodyOutput[dto.UserDto], error) { user, err := uc.userService.GetUser(ctx, httpapi.UserID(ctx)) if err != nil { return nil, err } return mapUser(user) } func (uc *UserController) deleteUserHandler(ctx context.Context, input *userIDInput) (*httpapi.EmptyOutput, error) { if err := uc.userService.DeleteUser(ctx, input.ID, false); err != nil { return nil, err } return &httpapi.EmptyOutput{}, nil } func (uc *UserController) deleteUserWebauthnCredentialHandler(ctx context.Context, input *userCredentialIDInput) (*httpapi.EmptyOutput, error) { if err := uc.webAuthnService.DeleteCredential(ctx, input.ID, input.CredentialID, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx), httpapi.UserID(ctx)); err != nil { return nil, err } return &httpapi.EmptyOutput{}, nil } func (uc *UserController) createUserHandler(ctx context.Context, input *userCreateInput) (*httpapi.BodyOutput[dto.UserDto], error) { user, err := uc.userService.CreateUser(ctx, input.Body) if err != nil { return nil, err } return mapUser(user) } func (uc *UserController) updateUserHandler(ctx context.Context, input *userUpdateInput) (*httpapi.BodyOutput[dto.UserDto], error) { return uc.updateUser(ctx, input.ID, input.Body, false) } func (uc *UserController) updateCurrentUserHandler(ctx context.Context, input *userCreateInput) (*httpapi.BodyOutput[dto.UserDto], error) { return uc.updateUser(ctx, httpapi.UserID(ctx), input.Body, true) } func (uc *UserController) updateUser(ctx context.Context, userID string, input dto.UserCreateDto, ownUser bool) (*httpapi.BodyOutput[dto.UserDto], error) { user, err := uc.userService.UpdateUser(ctx, userID, input, ownUser, false) if err != nil { return nil, err } return mapUser(user) } func (uc *UserController) getUserProfilePictureHandler(ctx context.Context, input *userIDInput) (*userPictureOutput, error) { picture, size, err := uc.userService.GetProfilePicture(ctx, input.ID) if err != nil { return nil, err } cacheControl := "" if !httpapi.QueryPresent(ctx, "skipCache") { cacheControl = utils.CacheControlValue(15*time.Minute, time.Hour) } return &userPictureOutput{ ContentType: "image/png", ContentLength: size, CacheControl: cacheControl, Body: func(streamCtx huma.Context) { if picture != nil { defer picture.Close() _, _ = io.Copy(streamCtx.BodyWriter(), picture) } }, }, nil } func (uc *UserController) updateUserProfilePictureHandler(ctx context.Context, input *userPictureUploadInput) (*httpapi.EmptyOutput, error) { return uc.updateProfilePicture(ctx, input.ID, input.RawBody.Data().File) } func (uc *UserController) updateCurrentUserProfilePictureHandler(ctx context.Context, input *currentUserPictureUploadInput) (*httpapi.EmptyOutput, error) { return uc.updateProfilePicture(ctx, httpapi.UserID(ctx), input.RawBody.Data().File) } func (uc *UserController) updateProfilePicture(ctx context.Context, userID string, file huma.FormFile) (*httpapi.EmptyOutput, error) { defer file.Close() if err := uc.userService.UpdateProfilePicture(ctx, userID, file); err != nil { return nil, err } return &httpapi.EmptyOutput{}, nil } func (uc *UserController) createOwnOneTimeAccessTokenHandler(ctx context.Context, _ *oneTimeAccessOwnInput) (*httpapi.BodyOutput[map[string]string], error) { return uc.createOneTimeAccessToken(ctx, httpapi.UserID(ctx), defaultOneTimeAccessTokenDuration) } func (uc *UserController) createAdminOneTimeAccessTokenHandler(ctx context.Context, input *oneTimeAccessAdminInput) (*httpapi.BodyOutput[map[string]string], error) { ttl := input.Body.TTL.Duration if ttl <= 0 { ttl = defaultOneTimeAccessTokenDuration } return uc.createOneTimeAccessToken(ctx, input.ID, ttl) } func (uc *UserController) createOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (*httpapi.BodyOutput[map[string]string], error) { if userID == "" { return nil, &common.UserIdNotProvidedError{} } token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(ctx, userID, ttl) if err != nil { return nil, err } return &httpapi.BodyOutput[map[string]string]{Body: map[string]string{"token": token}}, nil } func (uc *UserController) requestOneTimeAccessEmailAsUnauthenticatedUserHandler(ctx context.Context, input *oneTimeAccessEmailInput) (*emptyOutputWithCookie, error) { deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx, input.Body.Email, input.Body.RedirectPath) if err != nil { return nil, err } return &emptyOutputWithCookie{SetCookie: []http.Cookie{*cookie.NewDeviceTokenCookie(deviceToken)}}, nil } func (uc *UserController) requestOneTimeAccessEmailAsAdminHandler(ctx context.Context, input *oneTimeAccessEmailAdminInput) (*httpapi.EmptyOutput, error) { ttl := input.Body.TTL.Duration if ttl <= 0 { ttl = defaultOneTimeAccessTokenDuration } if err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(ctx, input.ID, ttl); err != nil { return nil, err } return &httpapi.EmptyOutput{}, nil } func (uc *UserController) exchangeOneTimeAccessTokenHandler(ctx context.Context, input *oneTimeAccessExchangeInput) (*userCookieOutput, error) { if len(input.Token) != 6 && len(input.Token) != 16 { return nil, &common.TokenInvalidOrExpiredError{} } deviceToken := "" if requestCookie, err := httpapi.Cookie(ctx, cookie.DeviceTokenCookieName); err == nil { deviceToken = requestCookie.Value } user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(ctx, input.Token, deviceToken, httpapi.ClientIP(ctx), httpapi.UserAgent(ctx)) if err != nil { return nil, err } var output dto.UserDto if err := dto.MapStruct(user, &output); err != nil { return nil, err } maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) return &userCookieOutput{SetCookie: []http.Cookie{*cookie.NewAccessTokenCookie(maxAge, token)}, Body: output}, nil } func (uc *UserController) updateUserGroups(ctx context.Context, input *userGroupsInput) (*httpapi.BodyOutput[dto.UserDto], error) { user, err := uc.userService.UpdateUserGroups(ctx, input.ID, input.Body.UserGroupIds) if err != nil { return nil, err } return mapUser(user) } func (uc *UserController) resetUserProfilePictureHandler(ctx context.Context, input *userIDInput) (*httpapi.EmptyOutput, error) { if err := uc.userService.ResetProfilePicture(ctx, input.ID); err != nil { return nil, err } return &httpapi.EmptyOutput{}, nil } func (uc *UserController) resetCurrentUserProfilePictureHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { if err := uc.userService.ResetProfilePicture(ctx, httpapi.UserID(ctx)); err != nil { return nil, err } return &httpapi.EmptyOutput{}, nil } func (uc *UserController) sendEmailVerificationHandler(ctx context.Context, _ *httpapi.EmptyInput) (*httpapi.EmptyOutput, error) { if err := uc.userService.SendEmailVerification(ctx, httpapi.UserID(ctx)); err != nil { return nil, err } return &httpapi.EmptyOutput{}, nil } func (uc *UserController) verifyEmailHandler(ctx context.Context, input *emailVerificationInput) (*httpapi.EmptyOutput, error) { if err := uc.userService.VerifyEmail(ctx, httpapi.UserID(ctx), input.Body.Token); err != nil { return nil, err } return &httpapi.EmptyOutput{}, nil } type emptyOutputWithCookie struct { SetCookie []http.Cookie `header:"Set-Cookie"` } func mapUser(user model.User) (*httpapi.BodyOutput[dto.UserDto], error) { var output dto.UserDto if err := dto.MapStruct(user, &output); err != nil { return nil, err } return &httpapi.BodyOutput[dto.UserDto]{Body: output}, nil }