From 1d2f991be5b2baa58ca575c4e834a32d94a45fa5 Mon Sep 17 00:00:00 2001 From: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:13:00 -0700 Subject: [PATCH] More WIP --- .../appconfig/app_config_middleware.go | 64 ++++++++++ backend/internal/appconfig/model.go | 109 +++++++++++------- backend/internal/appconfig/service.go | 5 - .../internal/controller/user_controller.go | 12 +- backend/internal/service/ldap_service.go | 73 ++++++------ .../internal/service/user_group_service.go | 26 +++-- backend/internal/service/user_service.go | 62 ++++++---- 7 files changed, 239 insertions(+), 112 deletions(-) create mode 100644 backend/internal/appconfig/app_config_middleware.go diff --git a/backend/internal/appconfig/app_config_middleware.go b/backend/internal/appconfig/app_config_middleware.go new file mode 100644 index 00000000..32619277 --- /dev/null +++ b/backend/internal/appconfig/app_config_middleware.go @@ -0,0 +1,64 @@ +package appconfig + +import ( + "context" + "errors" + "sync" + + "github.com/gin-gonic/gin" +) + +// appConfigCtxKey is the context key used to store the AppConfigResolver in the http.Request's context +type appConfigCtxKey struct{} + +type appConfigResolver func(ctx context.Context) (*AppConfigModel, error) + +// AppConfigMiddleware is a Gin middleware that makes the application configuration available to all downstream handlers through the request's context +type AppConfigMiddleware struct { + appConfigService *AppConfigService +} + +func NewAppConfigMiddleware(appConfigService *AppConfigService) *AppConfigMiddleware { + return &AppConfigMiddleware{ + appConfigService: appConfigService, + } +} + +// Add returns a Gin middleware that stores an AppConfigResolver in the http.Request's context +// The resolver loads the application configuration lazily on the first call and caches it for the duration of the request +func (m *AppConfigMiddleware) Add() gin.HandlerFunc { + return func(c *gin.Context) { + reqCtx := c.Request.Context() + + // Create a cache for each request in the middleware's scope, so it's unique per each request + var ( + once sync.Once + cfg *AppConfigModel + err error + ) + // Note: the resolver accepts a context argument, it doesn't use the request's own + // This can be used for example for tracing + resolver := appConfigResolver(func(ctx context.Context) (*AppConfigModel, error) { + once.Do(func() { + cfg, err = m.appConfigService.GetConfig(ctx) + }) + return cfg, err + }) + + // Store the resolver in the request's context + c.Request = c.Request.WithContext(context.WithValue(reqCtx, appConfigCtxKey{}, resolver)) + + c.Next() + } +} + +// FromCtx retrieves the app config from the context +func FromCtx(ctx context.Context) (*AppConfigModel, error) { + resolver, ok := ctx.Value(appConfigCtxKey{}).(appConfigResolver) + if !ok || resolver == nil { + // Indicates a development-time error + return nil, errors.New("middleware AppConfigMiddleware was not registered for the handler") + } + + return resolver(ctx) +} diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go index b2f89bc5..c862ea85 100644 --- a/backend/internal/appconfig/model.go +++ b/backend/internal/appconfig/model.go @@ -6,6 +6,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/italypaleale/go-kit/utils" @@ -14,52 +15,74 @@ import ( type AppConfigModel struct { // General - AppName string `json:"appName" public:"true"` - SessionDuration string `json:"sessionDuration" type:"int"` // In minutes - HomePageURL string `json:"homePageUrl" public:"true"` - EmailsVerified string `json:"emailsVerified" type:"bool"` - AccentColor string `json:"accentColor" public:"true"` - DisableAnimations string `json:"disableAnimations" type:"bool" public:"true"` - AllowOwnAccountEdit string `json:"allowOwnAccountEdit" type:"bool" public:"true"` - AllowUserSignups string `json:"allowUserSignups" public:"true"` + AppName AppConfigValue `json:"appName" public:"true"` + SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes + HomePageURL AppConfigValue `json:"homePageUrl" public:"true"` + EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"` + AccentColor AppConfigValue `json:"accentColor" public:"true"` + DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"` + AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"` + AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"` - SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings - SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string} + SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings + SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string} // Email - RequireUserEmail string `json:"requireUserEmail" type:"bool" public:"true"` - SmtpHost string `json:"smtpHost"` - SmtpPort string `json:"smtpPort"` - SmtpFrom string `json:"smtpFrom"` - SmtpUser string `json:"smtpUser"` - SmtpPassword string `json:"smtpPassword" sensitive:"true"` - SmtpTls string `json:"smtpTls"` - SmtpSkipCertVerify string `json:"smtpSkipCertVerify" type:"bool"` - EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" type:"bool"` - EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"` - EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"` - EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" type:"bool"` - EmailVerificationEnabled string `json:"emailVerificationEnabled" type:"bool" public:"true"` + RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"` + SmtpHost AppConfigValue `json:"smtpHost"` + SmtpPort AppConfigValue `json:"smtpPort"` + SmtpFrom AppConfigValue `json:"smtpFrom"` + SmtpUser AppConfigValue `json:"smtpUser"` + SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"` + SmtpTls AppConfigValue `json:"smtpTls"` + SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"` + EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"` + EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"` + EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"` + EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"` + EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"` // LDAP - LdapEnabled string `json:"ldapEnabled" type:"bool" public:"true"` - LdapUrl string `json:"ldapUrl"` - LdapBindDn string `json:"ldapBindDn"` - LdapBindPassword string `json:"ldapBindPassword" sensitive:"true"` - LdapBase string `json:"ldapBase"` - LdapUserSearchFilter string `json:"ldapUserSearchFilter"` - LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` - LdapSkipCertVerify string `json:"ldapSkipCertVerify" type:"bool"` - LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` - LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` - LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` - LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` - LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` - LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"` - LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` - LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` - LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` - LdapAttributeGroupName string `json:"ldapAttributeGroupName"` - LdapAdminGroupName string `json:"ldapAdminGroupName"` - LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers" type:"bool"` + LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"` + LdapUrl AppConfigValue `json:"ldapUrl"` + LdapBindDn AppConfigValue `json:"ldapBindDn"` + LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"` + LdapBase AppConfigValue `json:"ldapBase"` + LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"` + LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"` + LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"` + LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"` + LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"` + LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"` + LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"` + LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"` + LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"` + LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"` + LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"` + LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"` + LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"` + LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"` + LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"` +} + +// AppConfigValue holds a value +type AppConfigValue string + +// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc. +func (a AppConfigValue) IsTrue() bool { + return utils.IsTruthy(string(a)) +} + +// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes. +func (a AppConfigValue) AsDurationMinutes() time.Duration { + val, err := strconv.Atoi(string(a)) + if err != nil { + return 0 + } + return time.Duration(val) * time.Minute +} + +// String implements fmt.Stringer +func (a AppConfigValue) String() string { + return string(a) } func getDefaultConfig() *AppConfigModel { diff --git a/backend/internal/appconfig/service.go b/backend/internal/appconfig/service.go index 9c1dccba..38a53822 100644 --- a/backend/internal/appconfig/service.go +++ b/backend/internal/appconfig/service.go @@ -96,11 +96,6 @@ func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel return &cfg, nil } -// DELETE -func (s *AppConfigService) GetDbConfig() *model.AppConfig { - return nil -} - func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error { err := tx. WithContext(ctx). diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 34f50630..bf603747 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "net/http" "time" @@ -509,6 +510,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context // @Success 200 {object} dto.UserDto // @Router /api/one-time-access-token/{token} [post] func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { + cfg, err := appconfig.FromCtx(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + loginCode := c.Param("token") // reject invalid length login codes if len(loginCode) != 6 && len(loginCode) != 16 { @@ -524,12 +531,13 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { } var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { + err = dto.MapStruct(user, &userDto) + if err != nil { _ = c.Error(err) return } - maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index cb8a36ab..eb6235a9 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -36,7 +36,7 @@ type LdapService struct { userService *UserService groupService *UserGroupService fileStorage storage.FileStorage - clientFactory func() (ldapClient, error) + clientFactory func(ctx context.Context) (ldapClient, error) } type savePicture struct { @@ -84,15 +84,18 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *appc return service } -func (s *LdapService) createClient() (ldapClient, error) { - dbConfig := s.appConfigService.GetDbConfig() +func (s *LdapService) createClient(ctx context.Context) (ldapClient, error) { + dbConfig, err := appconfig.FromCtx(ctx) + if err != nil { + return nil, fmt.Errorf("error loading app configuration: %w", err) + } if !dbConfig.LdapEnabled.IsTrue() { return nil, fmt.Errorf("LDAP is not enabled") } // Setup LDAP connection - client, err := ldap.DialURL(dbConfig.LdapUrl.Value, ldap.DialWithTLSConfig(&tls.Config{ + client, err := ldap.DialURL(dbConfig.LdapUrl.String(), ldap.DialWithTLSConfig(&tls.Config{ InsecureSkipVerify: dbConfig.LdapSkipCertVerify.IsTrue(), //nolint:gosec })) if err != nil { @@ -100,7 +103,7 @@ func (s *LdapService) createClient() (ldapClient, error) { } // Bind as service account - err = client.Bind(dbConfig.LdapBindDn.Value, dbConfig.LdapBindPassword.Value) + err = client.Bind(dbConfig.LdapBindDn.String(), dbConfig.LdapBindPassword.String()) if err != nil { return nil, fmt.Errorf("failed to bind to LDAP: %w", err) } @@ -196,13 +199,13 @@ func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) { dbConfig := s.appConfigService.GetDbConfig() - if dbConfig.LdapAdminGroupName.Value == "" { + if dbConfig.LdapAdminGroupName == "" { return } adminUsernames := make(map[string]struct{}) for _, group := range desiredGroups { - if group.input.Name != dbConfig.LdapAdminGroupName.Value { + if group.input.Name != dbConfig.LdapAdminGroupName { continue } @@ -259,7 +262,7 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value) memberUsernames := make([]string, 0, len(groupMembers)) for _, member := range groupMembers { - username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN) + username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN, dbConfig.LdapAttributeUserUsername.Value) if username == "" { continue } @@ -291,27 +294,30 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient } func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) { - dbConfig := s.appConfigService.GetDbConfig() + dbConfig, err := appconfig.FromCtx(ctx) + if err != nil { + return nil, nil, nil, fmt.Errorf("error loading app configuration: %w", err) + } // Query LDAP for all users we want to manage searchAttrs := []string{ "sn", "cn", - dbConfig.LdapAttributeUserUniqueIdentifier.Value, - dbConfig.LdapAttributeUserUsername.Value, - dbConfig.LdapAttributeUserEmail.Value, - dbConfig.LdapAttributeUserFirstName.Value, - dbConfig.LdapAttributeUserLastName.Value, - dbConfig.LdapAttributeUserProfilePicture.Value, - dbConfig.LdapAttributeUserDisplayName.Value, + dbConfig.LdapAttributeUserUniqueIdentifier.String(), + dbConfig.LdapAttributeUserUsername.String(), + dbConfig.LdapAttributeUserEmail.String(), + dbConfig.LdapAttributeUserFirstName.String(), + dbConfig.LdapAttributeUserLastName.String(), + dbConfig.LdapAttributeUserProfilePicture.String(), + dbConfig.LdapAttributeUserDisplayName.String(), } // Filters must start and finish with ()! searchReq := ldap.NewSearchRequest( - dbConfig.LdapBase.Value, + dbConfig.LdapBase.String(), ldap.ScopeWholeSubtree, 0, 0, 0, false, - dbConfig.LdapUserSearchFilter.Value, + dbConfig.LdapUserSearchFilter.String(), searchAttrs, []ldap.Control{}, ) @@ -327,28 +333,28 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries)) for _, value := range result.Entries { - username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)) + username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String())) if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" { usernamesByDN[normalizedDN] = username } - ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value)) + ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.String())) // Skip users without a valid LDAP ID if ldapID == "" { - slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value)) + slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.String())) continue } ldapUserIDs[ldapID] = struct{}{} newUser := dto.UserCreateDto{ - Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), - Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)), + Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.String()), + Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.String())), EmailVerified: true, - FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), - LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), - DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), + FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.String()), + LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.String()), + DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.String()), // Admin status is computed after groups are loaded so it can use the // configured group member attribute instead of a hard-coded memberOf. IsAdmin: false, @@ -370,16 +376,14 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) desiredUsers = append(desiredUsers, ldapDesiredUser{ ldapID: ldapID, input: newUser, - picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value), + picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.String()), }) } return desiredUsers, ldapUserIDs, usernamesByDN, nil } -func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string { - dbConfig := s.appConfigService.GetDbConfig() - +func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string, usernameAttr string) string { // First try the DN cache we built while loading users username, exists := usernamesByDN[normalizeLDAPDN(member)] if exists && username != "" { @@ -387,14 +391,15 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda } // Then try to extract the username directly from the DN - username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member) + username = getDNProperty(usernameAttr, member) if username != "" { return norm.NFC.String(username) } // posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value // that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408 - if _, err := ldap.ParseDN(member); err != nil { + _, err := ldap.ParseDN(member) + if err != nil { return norm.NFC.String(member) } @@ -404,7 +409,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda ldap.ScopeBaseObject, 0, 0, 0, false, "(objectClass=*)", - []string{dbConfig.LdapAttributeUserUsername.Value}, + []string{usernameAttr}, []ldap.Control{}, ) @@ -414,7 +419,7 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda return "" } - username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value) + username = userResult.Entries[0].GetAttributeValue(usernameAttr) if username == "" { slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member)) return "" diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 81d239e1..f0f46baa 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "time" "github.com/pocket-id/pocket-id/backend/internal/appconfig" @@ -64,13 +65,18 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm. } func (s *UserGroupService) Delete(ctx context.Context, id string) error { + cfg, err := appconfig.FromCtx(ctx) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } + tx := s.db.Begin() defer func() { tx.Rollback() }() var group model.UserGroup - err := tx. + err = tx. WithContext(ctx). Where("id = ?", id). First(&group). @@ -80,7 +86,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error { } // Disallow deleting the group if it is an LDAP group and LDAP is enabled - if group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { + if group.LdapID != nil && cfg.LdapEnabled.IsTrue() { return &common.LdapUserGroupUpdateError{} } @@ -123,10 +129,9 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro Preload("Users"). Create(&group). Error - if err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} - } + if errors.Is(err, gorm.ErrDuplicatedKey) { + return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"} + } else if err != nil { return model.UserGroup{}, err } @@ -162,8 +167,13 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input return model.UserGroup{}, err } + cfg, err := appconfig.FromCtx(ctx) + if err != nil { + return model.UserGroup{}, fmt.Errorf("error loading app configuration: %w", err) + } + // Disallow updating the group if it is an LDAP group and LDAP is enabled - if !isLdapSync && group.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { + if !isLdapSync && group.LdapID != nil && cfg.LdapEnabled.IsTrue() { return model.UserGroup{}, &common.LdapUserGroupUpdateError{} } @@ -217,7 +227,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u // Fetch the users based on the userIds var users []model.User if len(userIds) > 0 { - err := tx. + err = tx. WithContext(ctx). Where("id IN (?)", userIds). Find(&users). diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index d292dfcd..51595cdc 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -13,17 +13,17 @@ import ( "time" "github.com/google/uuid" - "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" "gorm.io/gorm" "gorm.io/gorm/clause" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "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/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" + "github.com/pocket-id/pocket-id/backend/internal/utils/email" profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image" ) @@ -205,9 +205,13 @@ func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDe } func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool) error { - var user model.User + cfg, err := appconfig.FromCtx(ctx) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } - err := tx. + var user model.User + err = tx. WithContext(ctx). Where("id = ?", userID). Clauses(clause.Locking{Strength: "UPDATE"}). @@ -218,7 +222,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI } // Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled - if !allowLdapDelete && !user.Disabled && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { + if !allowLdapDelete && !user.Disabled && user.LdapID != nil && cfg.LdapEnabled.IsTrue() { return &common.LdapUserUpdateError{} } @@ -254,7 +258,12 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) ( } func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { - if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil { + cfg, err := appconfig.FromCtx(ctx) + if err != nil { + return model.User{}, fmt.Errorf("error loading app configuration: %w", err) + } + + if cfg.RequireUserEmail.IsTrue() && input.Email == nil { return model.User{}, &common.UserEmailNotSetError{} } @@ -286,7 +295,7 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea user.LdapID = &input.LdapID } - err := tx.WithContext(ctx).Create(&user).Error + err = tx.WithContext(ctx).Create(&user).Error if errors.Is(err, gorm.ErrDuplicatedKey) { // Do not follow this path if we're using LDAP, as we don't want to roll-back the transaction here if !isLdapSync { @@ -305,12 +314,14 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea // Apply default groups and claims for new non-LDAP users if !isLdapSync { if len(input.UserGroupIds) == 0 { - if err := s.applyDefaultGroups(ctx, &user, tx); err != nil { + err = s.applyDefaultGroups(ctx, &user, tx) + if err != nil { return model.User{}, err } } - if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil { + err = s.applyDefaultCustomClaims(ctx, &user, tx) + if err != nil { return model.User{}, err } } @@ -323,12 +334,15 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea } func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error { - config := s.appConfigService.GetDbConfig() + cfg, err := appconfig.FromCtx(ctx) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } var groupIDs []string - v := config.SignupDefaultUserGroupIDs.Value + v := cfg.SignupDefaultUserGroupIDs if v != "" && v != "[]" { - err := json.Unmarshal([]byte(v), &groupIDs) + err = json.Unmarshal([]byte(v), &groupIDs) if err != nil { return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err) } @@ -355,12 +369,15 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, } func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error { - config := s.appConfigService.GetDbConfig() + cfg, err := appconfig.FromCtx(ctx) + if err != nil { + return fmt.Errorf("error loading app configuration: %w", err) + } var claims []dto.CustomClaimCreateDto - v := config.SignupDefaultCustomClaims.Value + v := cfg.SignupDefaultCustomClaims if v != "" && v != "[]" { - err := json.Unmarshal([]byte(v), &claims) + err = json.Unmarshal([]byte(v), &claims) if err != nil { return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err) } @@ -395,12 +412,17 @@ func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser } func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) { - if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && updatedUser.Email == nil { + cfg, err := appconfig.FromCtx(ctx) + if err != nil { + return model.User{}, fmt.Errorf("error loading app configuration: %w", err) + } + + if cfg.RequireUserEmail.IsTrue() && updatedUser.Email == nil { return model.User{}, &common.UserEmailNotSetError{} } var user model.User - err := tx. + err = tx. WithContext(ctx). Where("id = ?", userID). Clauses(clause.Locking{Strength: "UPDATE"}). @@ -411,8 +433,8 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd } // Check if this is an LDAP user and LDAP is enabled - isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() - allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() + isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue() + allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue() if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) { // Restricted update: Only locale can be changed when: @@ -430,7 +452,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd if (user.Email == nil && updatedUser.Email != nil) || (user.Email != nil && updatedUser.Email != nil && *user.Email != *updatedUser.Email) { // Email has changed, reset email verification status - user.EmailVerified = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue() + user.EmailVerified = cfg.EmailsVerified.IsTrue() } user.Email = updatedUser.Email