diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 4ee2f78a..57b6738d 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -82,11 +82,10 @@ func initServices( svc.customClaimService = service.NewCustomClaimService(db) svc.webauthnModule, err = webauthn.New(webauthn.Dependencies{ - DB: db, - AppURL: common.EnvConfig.AppURL, - Signer: svc.jwtService, - AuditLog: svc.auditLogService, - AppConfig: svc.appConfigService, + DB: db, + AppURL: common.EnvConfig.AppURL, + Signer: svc.jwtService, + AuditLog: svc.auditLogService, }) if err != nil { return nil, fmt.Errorf("failed to create WebAuthn module: %w", err) diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go deleted file mode 100644 index 2b134953..00000000 --- a/backend/internal/model/app_config.go +++ /dev/null @@ -1,182 +0,0 @@ -package model - -import ( - "errors" - "fmt" - "reflect" - "strconv" - "strings" - "time" - - "github.com/pocket-id/pocket-id/backend/internal/common" -) - -type AppConfigVariable struct { - Key string `gorm:"primaryKey;not null"` - Value string -} - -// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc. -func (a *AppConfigVariable) IsTrue() bool { - ok, _ := strconv.ParseBool(a.Value) - return ok -} - -// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes. -func (a *AppConfigVariable) AsDurationMinutes() time.Duration { - val, err := strconv.Atoi(a.Value) - if err != nil { - return 0 - } - return time.Duration(val) * time.Minute -} - -type AppConfig struct { - // General - AppName AppConfigVariable `key:"appName,public"` // Public - SessionDuration AppConfigVariable `key:"sessionDuration"` - HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public - EmailsVerified AppConfigVariable `key:"emailsVerified"` - AccentColor AppConfigVariable `key:"accentColor,public"` // Public - DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public - AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public - AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public - SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"` - SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"` - // Email - RequireUserEmail AppConfigVariable `key:"requireUserEmail,public"` // Public - SmtpHost AppConfigVariable `key:"smtpHost"` - SmtpPort AppConfigVariable `key:"smtpPort"` - SmtpFrom AppConfigVariable `key:"smtpFrom"` - SmtpUser AppConfigVariable `key:"smtpUser"` - SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"` - SmtpTls AppConfigVariable `key:"smtpTls"` - SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"` - EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"` - EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public - EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public - EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"` - EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public - // LDAP - LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public - LdapUrl AppConfigVariable `key:"ldapUrl"` - LdapBindDn AppConfigVariable `key:"ldapBindDn"` - LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"` - LdapBase AppConfigVariable `key:"ldapBase"` - LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"` - LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"` - LdapSkipCertVerify AppConfigVariable `key:"ldapSkipCertVerify"` - LdapAttributeUserUniqueIdentifier AppConfigVariable `key:"ldapAttributeUserUniqueIdentifier"` - LdapAttributeUserUsername AppConfigVariable `key:"ldapAttributeUserUsername"` - LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"` - LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"` - LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"` - LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"` - LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"` - LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"` - LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"` - LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"` - LdapAdminGroupName AppConfigVariable `key:"ldapAdminGroupName"` - LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"` -} - -func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable { - // Use reflection to iterate through all fields - cfgValue := reflect.ValueOf(c).Elem() - cfgType := cfgValue.Type() - - res := make([]AppConfigVariable, 0, cfgType.NumField()) - - for i := range cfgType.NumField() { - field := cfgType.Field(i) - - key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",") - if key == "" { - continue - } - - // If we're only showing public variables and this is not public, skip it - if !showAll && attrs != "public" { - continue - } - - value := cfgValue.Field(i).FieldByName("Value").String() - - // Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true - if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" { - value = "XXXXXXXXXX" - } - - appConfigVariable := AppConfigVariable{ - Key: key, - Value: value, - } - - res = append(res, appConfigVariable) - } - - return res -} - -func (c *AppConfig) FieldByKey(key string) (defaultValue string, err error) { - rv := reflect.ValueOf(c).Elem() - rt := rv.Type() - - // Find the field in the struct whose "key" tag matches - for i := range rt.NumField() { - // Grab only the first part of the key, if there's a comma with additional properties - tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",") - keyFromTag := tagValue[0] - if keyFromTag != key { - continue - } - - valueField := rv.Field(i).FieldByName("Value") - return valueField.String(), nil - } - - // If we are here, the config key was not found - return "", AppConfigKeyNotFoundError{field: key} -} - -func (c *AppConfig) UpdateField(key string, value string) error { - rv := reflect.ValueOf(c).Elem() - rt := rv.Type() - - // Find the field in the struct whose "key" tag matches, then update that - for i := range rt.NumField() { - // Separate the key (before the comma) from any optional attributes after - tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",") - if tagValue != key { - continue - } - - valueField := rv.Field(i).FieldByName("Value") - if !valueField.CanSet() { - return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key) - } - - // Update the value - valueField.SetString(value) - - // Return once updated - return nil - } - - // If we're here, we have not found the right field to update - return AppConfigKeyNotFoundError{field: key} -} - -type AppConfigKeyNotFoundError struct { - field string -} - -func (e AppConfigKeyNotFoundError) Error() string { - return "cannot find config key '" + e.field + "'" -} - -func (e AppConfigKeyNotFoundError) Is(target error) bool { - // Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError - _, ok := errors.AsType[*AppConfigKeyNotFoundError](target) - return ok -} diff --git a/backend/internal/model/app_config_test.go b/backend/internal/model/app_config_test.go deleted file mode 100644 index 4f28eb99..00000000 --- a/backend/internal/model/app_config_test.go +++ /dev/null @@ -1,126 +0,0 @@ -// We use model_test here to avoid an import cycle -package model_test - -import ( - "reflect" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/pocket-id/pocket-id/backend/internal/dto" - "github.com/pocket-id/pocket-id/backend/internal/model" -) - -func TestAppConfigVariable_AsMinutesDuration(t *testing.T) { - tests := []struct { - name string - value string - expected time.Duration - expectedSeconds int - }{ - { - name: "valid positive integer", - value: "60", - expected: 60 * time.Minute, - expectedSeconds: 3600, - }, - { - name: "valid zero integer", - value: "0", - expected: 0, - expectedSeconds: 0, - }, - { - name: "negative integer", - value: "-30", - expected: -30 * time.Minute, - expectedSeconds: -1800, - }, - { - name: "invalid non-integer", - value: "not-a-number", - expected: 0, - expectedSeconds: 0, - }, - { - name: "empty string", - value: "", - expected: 0, - expectedSeconds: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - configVar := model.AppConfigVariable{ - Value: tt.value, - } - - result := configVar.AsDurationMinutes() - assert.Equal(t, tt.expected, result) - assert.Equal(t, tt.expectedSeconds, int(result.Seconds())) - }) - } -} - -// This test ensures that the model.AppConfig and dto.AppConfigUpdateDto structs match: -// - They should have the same properties, where the "json" tag of dto.AppConfigUpdateDto should match the "key" tag in model.AppConfig -// - dto.AppConfigDto should not include "internal" fields from model.AppConfig -// This test is primarily meant to catch discrepancies between the two structs as fields are added or removed over time -func TestAppConfigStructMatchesUpdateDto(t *testing.T) { - appConfigType := reflect.TypeFor[model.AppConfig]() - updateDtoType := reflect.TypeFor[dto.AppConfigUpdateDto]() - - // Process AppConfig fields - appConfigFields := make(map[string]string) - for field := range appConfigType.Fields() { - if field.Tag.Get("key") == "" { - // Skip internal fields - continue - } - - // Extract the key name from the tag (takes the part before any comma) - keyTag := field.Tag.Get("key") - keyName, _, _ := strings.Cut(keyTag, ",") - - appConfigFields[field.Name] = keyName - } - - // Process AppConfigUpdateDto fields - dtoFields := make(map[string]string) - for field := range updateDtoType.Fields() { - // Extract the json name from the tag (takes the part before any binding constraints) - jsonTag := field.Tag.Get("json") - jsonName, _, _ := strings.Cut(jsonTag, ",") - - dtoFields[jsonName] = field.Name - } - - // Verify every AppConfig field has a matching DTO field with the same name - for fieldName, keyName := range appConfigFields { - if strings.HasSuffix(fieldName, "ImageType") { - // Skip internal fields that shouldn't be in the DTO - continue - } - - // Check if there's a DTO field with a matching JSON tag - _, exists := dtoFields[keyName] - assert.True(t, exists, "Field %s with key '%s' in AppConfig has no matching field in AppConfigUpdateDto", fieldName, keyName) - } - - // Verify every DTO field has a matching AppConfig field - for jsonName, fieldName := range dtoFields { - // Find a matching field in AppConfig by key tag - found := false - for _, keyName := range appConfigFields { - if keyName == jsonName { - found = true - break - } - } - - assert.True(t, found, "Field %s with json tag '%s' in AppConfigUpdateDto has no matching field in AppConfig", fieldName, jsonName) - } -} diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 60d923c2..091ec782 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -696,7 +696,7 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error { } for key, value := range ldapConfigs { - configVar := model.AppConfigVariable{Key: key, Value: value} + configVar := appconfig.AppConfigVariable{Key: key, Value: value} if err := tx.Create(&configVar).Error; err != nil { return fmt.Errorf("failed to create config variable '%s': %w", key, err) } @@ -708,7 +708,8 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error { return fmt.Errorf("failed to set LDAP test config: %w", err) } - if err := s.appConfigService.LoadDbConfig(ctx); err != nil { + err = s.appConfigService.LoadDbConfig(ctx) + if err != nil { return fmt.Errorf("failed to load app config: %w", err) } diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index a89f8f09..e68df9e3 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -103,7 +103,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr } // getEmailer builds an emailer.Emailer from the current app config. -func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *model.AppConfig) (emailer.Emailer, error) { +func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *appconfig.AppConfigModel) (emailer.Emailer, error) { // We support SMTP only (for now) connString, err := smtpConnString(dbConfig) if err != nil { @@ -117,8 +117,8 @@ func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *model.AppConf // smtpConnString builds the SMTP connection string that go-kit's emailer expects: // smtp://:@:?fromAddress=
&fromName=&tls=&insecureSkipVerify= -func smtpConnString(dbConfig *model.AppConfig) (string, error) { - host := dbConfig.SmtpHost.Value +func smtpConnString(dbConfig *appconfig.AppConfigModel) (string, error) { + host := dbConfig.SmtpHost.String() if host == "" { return "", errors.New("SMTP host is not configured") } @@ -127,28 +127,28 @@ func smtpConnString(dbConfig *model.AppConfig) (string, error) { Scheme: "smtp", Host: host, } - port := dbConfig.SmtpPort.Value + port := dbConfig.SmtpPort.String() if port != "" { u.Host = net.JoinHostPort(host, port) } // Include credentials when set - smtpUser := dbConfig.SmtpUser.Value - smtpPassword := dbConfig.SmtpPassword.Value + smtpUser := dbConfig.SmtpUser.String() + smtpPassword := dbConfig.SmtpPassword.String() if smtpUser != "" || smtpPassword != "" { u.User = url.UserPassword(smtpUser, smtpPassword) } // TLS values from config: none, starttls, tls - tlsMode := dbConfig.SmtpTls.Value + tlsMode := dbConfig.SmtpTls.String() if tlsMode == "" { tlsMode = "none" } // Build the query string args q := url.Values{} - q.Set("fromAddress", dbConfig.SmtpFrom.Value) - q.Set("fromName", dbConfig.AppName.Value) + q.Set("fromAddress", dbConfig.SmtpFrom.String()) + q.Set("fromName", dbConfig.AppName.String()) q.Set("tls", tlsMode) if dbConfig.SmtpSkipCertVerify.IsTrue() { q.Set("insecureSkipVerify", "true")