diff --git a/backend/internal/appconfig/appconfig_actor.go b/backend/internal/appconfig/appconfig_actor.go new file mode 100644 index 00000000..d15b6a02 --- /dev/null +++ b/backend/internal/appconfig/appconfig_actor.go @@ -0,0 +1,112 @@ +package appconfig + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/italypaleale/francis/actor" + "github.com/pocket-id/pocket-id/backend/internal/common" +) + +// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster +// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed) +// After startup, Peek can be used for read-only operations such as retrieving the config or listing it + +// AppConfigActorType is the actor type for the AppConfig actor +const AppConfigActorType = "AppConfig" + +// appConfigActor is a singleton actor that manages the dynamic app configuration +type appConfigActor struct { + log *slog.Logger + client actor.Client[*AppConfigModel] +} + +// appConfigActorBootstrap is the type for the payload of the init method +type appConfigActorBootstrap struct { + LegacyConfig map[string]string +} + +// NewAppConfigActor allocates a new AppConfig actor +// It satisfies actor.Factory +func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor { + log := slog. + With( + slog.String("scope", "actor"), + slog.String("actorType", AppConfigActorType), + slog.String("actorID", actorID), + ) + + log.Info("AppConfig actor created") + + return &appConfigActor{ + log: log, + client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service), + } +} + +// Bootstrap implements actor.ActorBootstrapper for the singleton actor +func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error { + // Load the actor state + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + state, err := a.client.GetState(ctx) + if err != nil { + return fmt.Errorf("error retrieving actor state: %w", err) + } + + // If we already have a state, nothing else to do + if state != nil { + return nil + } + + // Check if the request data contains legacy config to init from + if data != nil { + payload := appConfigActorBootstrap{} + err = data.Decode(&payload) + if err != nil { + return fmt.Errorf("request body is not valid for method 'init': %w", err) + } + + if len(payload.LegacyConfig) > 0 { + state, err = fromLegacyConfig(payload.LegacyConfig) + if err != nil { + return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err) + } + } + } + + // If we still have no state, generate a new default config + if state == nil { + state = getDefaultConfig() + } + + // Save the updated state + ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + err = a.client.SetState(ctx, state, nil) + if err != nil { + return fmt.Errorf("error saving actor state: %w", err) + } + + return nil +} + +func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) { + // Only supported method is "get" + if method != "get" { + return nil, common.ErrUnsupportedActorMethod{Method: method} + } + + // Load the actor state + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + state, err := a.client.GetState(ctx) + if err != nil { + return nil, fmt.Errorf("error retrieving actor state: %w", err) + } + + // Return the state + return state, nil +} diff --git a/backend/internal/appconfig/legacy_config.go b/backend/internal/appconfig/legacy_config.go new file mode 100644 index 00000000..db5b26ba --- /dev/null +++ b/backend/internal/appconfig/legacy_config.go @@ -0,0 +1,47 @@ +package appconfig + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +// loadLegacyConfig loads the legacy config from the database +// This was migrated to the "config_migrated" key in the kv table +func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) { + // Retrieve the migrated config from the kv table + row := model.KV{ + Key: "config_migrated", + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + err := db.WithContext(ctx).First(&row).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + // There's no migrated config in the database, nothing to do + return nil, nil + case err != nil: + return nil, fmt.Errorf("failed to load migrated config from the database: %w", err) + case row.Value == nil || len(*row.Value) == 0: + // Also no migrated config, nothing to do + return nil, nil + } + + // The value is a JSON-encoded dictionary + res := map[string]string{} + err = json.Unmarshal([]byte(*row.Value), &res) + if err != nil { + return nil, fmt.Errorf("error parsing migrated config: %w", err) + } + + if len(res) == 0 { + return nil, nil + } + return res, nil +} diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go new file mode 100644 index 00000000..4040eee9 --- /dev/null +++ b/backend/internal/appconfig/model.go @@ -0,0 +1,155 @@ +package appconfig + +import ( + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/italypaleale/go-kit/utils" +) + +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"` + + SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings + SignupDefaultCustomClaims string `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"` + // 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"` +} + +func getDefaultConfig() *AppConfigModel { + // Values are the default ones + return &AppConfigModel{ + // General + AppName: "Pocket ID", + SessionDuration: "60", + HomePageURL: "/settings/account", + EmailsVerified: "false", + DisableAnimations: "false", + AllowOwnAccountEdit: "true", + AllowUserSignups: "disabled", + SignupDefaultUserGroupIDs: "[]", + SignupDefaultCustomClaims: "[]", + AccentColor: "default", + // Email + RequireUserEmail: "true", + SmtpHost: "", + SmtpPort: "", + SmtpFrom: "", + SmtpUser: "", + SmtpPassword: "", + SmtpTls: "none", + SmtpSkipCertVerify: "false", + EmailLoginNotificationEnabled: "false", + EmailOneTimeAccessAsUnauthenticatedEnabled: "false", + EmailOneTimeAccessAsAdminEnabled: "false", + EmailApiKeyExpirationEnabled: "false", + EmailVerificationEnabled: "false", + // LDAP + LdapEnabled: "false", + LdapUrl: "", + LdapBindDn: "", + LdapBindPassword: "", + LdapBase: "", + LdapUserSearchFilter: "(objectClass=person)", + LdapUserGroupSearchFilter: "(objectClass=groupOfNames)", + LdapSkipCertVerify: "false", + LdapAttributeUserUniqueIdentifier: "", + LdapAttributeUserUsername: "", + LdapAttributeUserEmail: "", + LdapAttributeUserFirstName: "", + LdapAttributeUserLastName: "", + LdapAttributeUserDisplayName: "cn", + LdapAttributeUserProfilePicture: "", + LdapAttributeGroupMember: "member", + LdapAttributeGroupUniqueIdentifier: "", + LdapAttributeGroupName: "", + LdapAdminGroupName: "", + LdapSoftDeleteUsers: "true", + } +} + +// fromLegacyConfig builds an appConfigModel from a legacy config map +// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type +// Keys that are missing (or have an empty value) retain the default value +func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) { + // Start from the default configuration, then override with the values from the legacy config + dest := getDefaultConfig() + + rt := reflect.ValueOf(dest).Elem().Type() + rv := reflect.ValueOf(dest).Elem() + for i := range rt.NumField() { + field := rt.Field(i) + + // Get the value of the json tag, taking only what's before the comma + key, _, _ := strings.Cut(field.Tag.Get("json"), ",") + + // Look up the value in the legacy config + // If the key is missing or the value is empty, we keep the default value + value, ok := legacyCfg[key] + if !ok || value == "" { + continue + } + + // Cast the string value to the field's type + fv := rv.Field(i) + switch fv.Kind() { //nolint:exhaustive + case reflect.String: + fv.SetString(value) + case reflect.Bool: + fv.SetBool(utils.IsTruthy(value)) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err) + } + fv.SetInt(n) + default: + return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key) + } + } + + return dest, nil +} diff --git a/backend/internal/appconfig/service.go b/backend/internal/appconfig/service.go new file mode 100644 index 00000000..9c1dccba --- /dev/null +++ b/backend/internal/appconfig/service.go @@ -0,0 +1,306 @@ +package appconfig + +import ( + "context" + "errors" + "fmt" + "os" + "reflect" + "strings" + "time" + + "github.com/italypaleale/francis/actor" + "github.com/italypaleale/francis/host/local" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "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" + "github.com/pocket-id/pocket-id/backend/internal/tracing" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +type AppConfigService struct { + actSvc *actor.Service + envConfig *AppConfigModel +} + +func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) { + service = &AppConfigService{} + + // If the UI config is disabled, we do not need to init the config actor + if common.EnvConfig.UiConfigDisabled { + service.envConfig, err = service.loadDbConfigFromEnv() + if err != nil { + return nil, fmt.Errorf("error loading app config from the env: %w", err) + } + + return service, nil + } + + // Note: we need to assign to the "err" variable in this method (for tracing), do not inline this into the "if" + ctx, span := tracing.Start(ctx, "pocketid.appconfig.init") + defer tracing.End(span, err) + + // Load the legacy config if any, which we need to send to the actor as bootstrap data + legacyCfg, err := LoadLegacyConfig(ctx, db) + if err != nil { + return nil, fmt.Errorf("error loading legacy config: %w", err) + } + + // Register the AppConfig actor + // This is a singleton actor and it's bootstrapped with the legacy config if present + bootstrapData := &appConfigActorBootstrap{ + LegacyConfig: legacyCfg, + } + err = actors.RegisterSingletonActor( + AppConfigActorType, NewAppConfigActor, + local.WithBootstrapData(bootstrapData), + local.WithIdleTimeout(-1), // Disable idle timeout for this actor + ) + if err != nil { + return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err) + } + + service.actSvc = actors.Service() + + return service, nil +} + +// GetConfig returns the application configuration +// Important: Treat the object as read-only: do not modify its properties directly! +func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel, error) { + // If the UI config is disabled, only load from the env + if common.EnvConfig.UiConfigDisabled { + return s.envConfig, nil + } + + // Retrieve the config from the actor + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + res, err := s.actSvc.Peek(ctx, AppConfigActorType, actor.SingletonActorID, "get", nil) + if err != nil { + return nil, fmt.Errorf("error retrieving config from actor: %w", err) + } + if res == nil { + return nil, errors.New("config actor response was empty") + } + + var cfg AppConfigModel + err = res.Decode(&cfg) + if err != nil { + return nil, fmt.Errorf("error decoding config actor response: %w", err) + } + + 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). + Clauses(clause.OnConflict{ + // Perform an "upsert" if the key already exists, replacing the value + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.AssignmentColumns([]string{"value"}), + }). + Create(&dbUpdate). + Error + if err != nil { + return fmt.Errorf("failed to update config in database: %w", err) + } + + return nil +} + +func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { + if common.EnvConfig.UiConfigDisabled { + return nil, &common.UiConfigDisabledError{} + } + + // From here onwards, we know we are the only process/goroutine with exclusive access to the config + // Re-load the config from the database to be sure we have the correct data + cfg, err := s.loadDbConfigInternal(ctx, tx) + if err != nil { + return nil, fmt.Errorf("failed to reload config from database: %w", err) + } + + defaultCfg := getDefaultConfig() + + // Iterate through all the fields to update + // We update the in-memory data (in the cfg struct) and collect values to update in the database + rt := reflect.ValueOf(input).Type() + rv := reflect.ValueOf(input) + dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField()) + for field := range rt.Fields() { + value := rv.FieldByName(field.Name).String() + + // Get the value of the json tag, taking only what's before the comma + key, _, _ := strings.Cut(field.Tag.Get("json"), ",") + + // Update the in-memory config value + // If the new value is an empty string, then we set the in-memory value to the default one + if value == "" { + // Ignore errors here as we know the key exists + defaultValue, _ := defaultCfg.FieldByKey(key) + err = cfg.UpdateField(key, defaultValue) + } else { + err = cfg.UpdateField(key, value) + } + + if err != nil { + return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err) + } + + // We always save "value" which can be an empty string + dbUpdate = append(dbUpdate, model.AppConfigVariable{ + Key: key, + Value: value, + }) + } + + // Update the values in the database + err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate) + if err != nil { + return nil, err + } + + // Commit the changes to the DB, then finally save the updated config in the object + err = tx.Commit().Error + if err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + + s.dbConfig.Store(cfg) + + // Return the updated config + res := cfg.ToAppConfigVariableSlice(true, false) + return res, nil +} + +// UpdateAppConfigValues updates the application configuration values in the database. +func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error { + // Count of keysAndValues must be even + if len(keysAndValues)%2 != 0 { + return errors.New("invalid number of arguments received") + } + + if common.EnvConfig.UiConfigDisabled { + return &common.UiConfigDisabledError{} + } + + // Start the transaction + tx, err := s.updateAppConfigStartTransaction(ctx) + if err != nil { + return err + } + defer tx.Rollback() + + // From here onwards, we know we are the only process/goroutine with exclusive access to the config + // Re-load the config from the database to be sure we have the correct data + cfg, err := s.loadDbConfigInternal(ctx, tx) + if err != nil { + return fmt.Errorf("failed to reload config from database: %w", err) + } + + defaultCfg := getDefaultDbConfig() + + // Iterate through all the fields to update + // We update the in-memory data (in the cfg struct) and collect values to update in the database + // (Note the += 2, as we are iterating through key-value pairs) + dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2) + for i := 1; i < len(keysAndValues); i += 2 { + key := keysAndValues[i-1] + value := keysAndValues[i] + + // Ensure that the field is valid + // We do this by grabbing the default value + var defaultValue string + defaultValue, err := defaultCfg.FieldByKey(key) + if err != nil { + return fmt.Errorf("invalid configuration key '%s': %w", key, err) + } + + // Update the in-memory config value + // If the new value is an empty string, then we set the in-memory value to the default one + if value == "" { + err = cfg.UpdateField(key, defaultValue) + } else { + err = cfg.UpdateField(key, value) + } + if err != nil { + return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err) + } + + // We always save "value" which can be an empty string + dbUpdate = append(dbUpdate, model.AppConfigVariable{ + Key: key, + Value: value, + }) + } + + // Update the values in the database + err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate) + if err != nil { + return err + } + + // Commit the changes to the DB, then finally save the updated config in the object + err = tx.Commit().Error + if err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + s.dbConfig.Store(cfg) + + return nil +} + +func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable { + return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true) +} + +func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) { + // First, start from the default configuration + dest := getDefaultConfig() + + // Iterate through each field + rt := reflect.ValueOf(dest).Elem().Type() + rv := reflect.ValueOf(dest).Elem() + for i := range rt.NumField() { + field := rt.Field(i) + + // Get the key and internal tag values + key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",") + envVarName := utils.CamelCaseToScreamingSnakeCase(key) + + // Set the value if it's set + value, ok := os.LookupEnv(envVarName) + if ok { + rv.Field(i).Set(reflect.ValueOf(value)) + continue + } + + // If it's sensitive, we also allow reading from file + if attrs == "sensitive" { + fileName := os.Getenv(envVarName + "_FILE") + if fileName != "" { + // #nosec G703 - Value is provided by admin + b, err := os.ReadFile(fileName) + if err != nil { + return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err) + } + + rv.Field(i).Set(reflect.ValueOf(string(b))) + continue + } + } + } + + return dest, nil +} diff --git a/backend/internal/appconfig/testing_unit.go b/backend/internal/appconfig/testing_unit.go new file mode 100644 index 00000000..2fe4444d --- /dev/null +++ b/backend/internal/appconfig/testing_unit.go @@ -0,0 +1,25 @@ +//go:build unit + +// This file contains utils for unit tests and it's only built when the "unit" tag is set +package appconfig + +import ( + "sync/atomic" + + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values +func NewTestAppConfigService(config *model.AppConfig) *AppConfigService { + if config == nil { + // If there's no config, set the default one + config = getDefaultDbConfig() + } + + service := &AppConfigService{ + dbConfig: atomic.Pointer[model.AppConfig]{}, + } + service.dbConfig.Store(config) + + return service +} diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 53505426..8fd171e8 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -100,7 +100,7 @@ func Bootstrap(ctx context.Context) error { services = append(services, actorsRun) // Create all services - svc, err := initServices(ctx, db, instanceID, httpClient, imageExtensions, fileStorage, scheduler) + svc, err := initServices(ctx, db, instanceID, actors, httpClient, imageExtensions, fileStorage, scheduler) if err != nil { return fmt.Errorf("failed to initialize services: %w", err) } diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index cfc8ac8d..f0dcbaa1 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -5,12 +5,14 @@ import ( "fmt" "net/http" - "github.com/pocket-id/pocket-id/backend/internal/apikey" - "github.com/pocket-id/pocket-id/backend/internal/job" + "github.com/italypaleale/francis/host/local" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/api" + "github.com/pocket-id/pocket-id/backend/internal/apikey" + "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/job" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/storage" @@ -19,7 +21,7 @@ import ( ) type services struct { - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService appImagesService *service.AppImagesService emailService *service.EmailService geoLiteService *service.GeoLiteService @@ -44,10 +46,20 @@ type services struct { } // Initializes all services -func initServices(ctx context.Context, db *gorm.DB, instanceID string, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) { +func initServices( + ctx context.Context, + db *gorm.DB, + instanceID string, + actors *local.Host, + httpClient *http.Client, + imageExtensions map[string]string, + fileStorage storage.FileStorage, + scheduler *job.Scheduler, +) (svc *services, err error) { svc = &services{} - svc.appConfigService, err = service.NewAppConfigService(ctx, db) + // Init the app config service + svc.appConfigService, err = appconfig.NewService(ctx, actors, db) if err != nil { return nil, fmt.Errorf("failed to create app config service: %w", err) } diff --git a/backend/internal/cmds/encryption_key_rotate.go b/backend/internal/cmds/encryption_key_rotate.go index d28a535f..e620ac3c 100644 --- a/backend/internal/cmds/encryption_key_rotate.go +++ b/backend/internal/cmds/encryption_key_rotate.go @@ -6,13 +6,13 @@ import ( "fmt" "os" - "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/spf13/cobra" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/bootstrap" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/instanceid" + "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/utils" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" diff --git a/backend/internal/cmds/encryption_key_rotate_test.go b/backend/internal/cmds/encryption_key_rotate_test.go index a90749ee..a22f0e46 100644 --- a/backend/internal/cmds/encryption_key_rotate_test.go +++ b/backend/internal/cmds/encryption_key_rotate_test.go @@ -4,12 +4,12 @@ import ( "testing" "time" - "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/instanceid" + "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" diff --git a/backend/internal/common/internal_errors.go b/backend/internal/common/internal_errors.go new file mode 100644 index 00000000..26765d3e --- /dev/null +++ b/backend/internal/common/internal_errors.go @@ -0,0 +1,20 @@ +package common + +import ( + "errors" +) + +// ErrUnsupportedActorMethod is returned by custom actors when the invoked method isn't supported +type ErrUnsupportedActorMethod struct { + Method string +} + +func (e ErrUnsupportedActorMethod) Error() string { + return "method '" + e.Method + "' unsupported for actor invocation" +} + +func (e ErrUnsupportedActorMethod) Is(target error) bool { + // Ignore the field method when checking if an error is of the type ErrUnsupportedActorMethod + _, ok := errors.AsType[ErrUnsupportedActorMethod](target) + return ok +} diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index c52d8b1c..9c7442e3 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" + "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/middleware" @@ -19,7 +20,7 @@ import ( func NewAppConfigController( group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, - appConfigService *service.AppConfigService, + appConfigService *appconfig.AppConfigService, emailService *service.EmailService, ldapService *service.LdapService, ) { @@ -38,7 +39,7 @@ func NewAppConfigController( } type AppConfigController struct { - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService emailService *service.EmailService ldapService *service.LdapService } diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 615aa21e..34f50630 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "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/utils/cookie" @@ -21,7 +22,7 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute // @Summary User management controller // @Description Initializes all user-related API endpoints // @Tags Users -func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) { +func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *appconfig.AppConfigService) { uc := UserController{ userService: userService, oneTimeAccessService: oneTimeAccessService, @@ -64,7 +65,7 @@ type UserController struct { userService *service.UserService oneTimeAccessService *service.OneTimeAccessService webAuthnService *webauthn.Module - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService } // getUserGroupsHandler godoc diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go index eccdeb98..9ca13c40 100644 --- a/backend/internal/job/api_key_expiry_job.go +++ b/backend/internal/job/api_key_expiry_job.go @@ -8,17 +8,18 @@ import ( "github.com/go-co-op/gocron/v2" "github.com/pocket-id/pocket-id/backend/internal/apikey" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils/email" ) type ApiKeyEmailJobs struct { apiKeyModule *apikey.Module - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService emailService *service.EmailService } -func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *service.AppConfigService, emailService *service.EmailService) error { +func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *appconfig.AppConfigService, emailService *service.EmailService) error { jobs := &ApiKeyEmailJobs{ apiKeyModule: apiKeyModule, appConfigService: appConfigService, diff --git a/backend/internal/job/ldap_job.go b/backend/internal/job/ldap_job.go index 1547d954..24ac3a24 100644 --- a/backend/internal/job/ldap_job.go +++ b/backend/internal/job/ldap_job.go @@ -4,15 +4,16 @@ import ( "context" "time" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/service" ) type LdapJobs struct { ldapService *service.LdapService - appConfigService *service.AppConfigService + appConfigService *appconfig.AppConfigService } -func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error { +func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *appconfig.AppConfigService) error { jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} // Register the job to run every hour (with some jitter) diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go index 08379af2..948a9951 100644 --- a/backend/internal/middleware/auth_middleware_test.go +++ b/backend/internal/middleware/auth_middleware_test.go @@ -12,6 +12,7 @@ import ( "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/apikey" + "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/instanceid" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -33,8 +34,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) { db := testutils.NewDatabaseForTest(t) - appConfigService, err := service.NewAppConfigService(t.Context(), db) - require.NoError(t, err) + appConfigService := appconfig.NewTestAppConfigService(nil) instanceID, err := instanceid.Load(t.Context(), db) require.NoError(t, err) diff --git a/backend/internal/middleware/rate_limit_test.go b/backend/internal/middleware/rate_limit_test.go index 863b6204..42a567bc 100644 --- a/backend/internal/middleware/rate_limit_test.go +++ b/backend/internal/middleware/rate_limit_test.go @@ -39,6 +39,7 @@ func startRateLimitServices(t *testing.T, policies ...RateLimitPolicy) map[strin for name, rl := range limiters { services[name] = rl.Service(svc) } + return services } diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 76d508d5..2b134953 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "reflect" - "slices" "strconv" "strings" "time" @@ -86,7 +85,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues cfgValue := reflect.ValueOf(c).Elem() cfgType := cfgValue.Type() - var res []AppConfigVariable + res := make([]AppConfigVariable, 0, cfgType.NumField()) for i := range cfgType.NumField() { field := cfgType.Field(i) @@ -119,7 +118,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues return res } -func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) { +func (c *AppConfig) FieldByKey(key string) (defaultValue string, err error) { rv := reflect.ValueOf(c).Elem() rt := rv.Type() @@ -128,36 +127,30 @@ func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool // 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] - isInternal = slices.Contains(tagValue, "internal") if keyFromTag != key { continue } valueField := rv.Field(i).FieldByName("Value") - return valueField.String(), isInternal, nil + return valueField.String(), nil } // If we are here, the config key was not found - return "", false, AppConfigKeyNotFoundError{field: key} + return "", AppConfigKeyNotFoundError{field: key} } -func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error { +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, attrs, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",") + tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",") if tagValue != key { continue } - // If the field is internal and noInternal is true, we skip that - if noInternal && attrs == "internal" { - return AppConfigInternalForbiddenError{field: key} - } - valueField := rv.Field(i).FieldByName("Value") if !valueField.CanSet() { return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key) @@ -184,20 +177,6 @@ func (e AppConfigKeyNotFoundError) Error() string { func (e AppConfigKeyNotFoundError) Is(target error) bool { // Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError - x := AppConfigKeyNotFoundError{} - return errors.As(target, &x) -} - -type AppConfigInternalForbiddenError struct { - field string -} - -func (e AppConfigInternalForbiddenError) Error() string { - return "field '" + e.field + "' is internal and can't be updated" -} - -func (e AppConfigInternalForbiddenError) Is(target error) bool { - // Ignore the field property when checking if an error is of the type AppConfigInternalForbiddenError - x := AppConfigInternalForbiddenError{} - return errors.As(target, &x) + _, ok := errors.AsType[*AppConfigKeyNotFoundError](target) + return ok } diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go deleted file mode 100644 index d2670fa9..00000000 --- a/backend/internal/service/app_config_service.go +++ /dev/null @@ -1,416 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "os" - "reflect" - "strings" - "sync/atomic" - "time" - - "gorm.io/gorm" - "gorm.io/gorm/clause" - - "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" - "github.com/pocket-id/pocket-id/backend/internal/tracing" - "github.com/pocket-id/pocket-id/backend/internal/utils" -) - -type AppConfigService struct { - dbConfig atomic.Pointer[model.AppConfig] - db *gorm.DB -} - -func NewAppConfigService(ctx context.Context, db *gorm.DB) (service *AppConfigService, err error) { - service = &AppConfigService{ - db: db, - } - - ctx, span := tracing.Start(ctx, "pocketid.appconfig.init") - defer tracing.End(span, err) - - // We need to assign to the "err" variable, do not inline this into the "if" - err = service.LoadDbConfig(ctx) - if err != nil { - return nil, fmt.Errorf("failed to initialize app config service: %w", err) - } - - return service, nil -} - -// GetDbConfig returns the application configuration. -// Important: Treat the object as read-only: do not modify its properties directly! -func (s *AppConfigService) GetDbConfig() *model.AppConfig { - v := s.dbConfig.Load() - if v == nil { - // This indicates a development-time error - panic("called GetDbConfig before DbConfig is loaded") - } - - return v -} - -func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { - // Values are the default ones - return &model.AppConfig{ - // General - AppName: model.AppConfigVariable{Value: "Pocket ID"}, - SessionDuration: model.AppConfigVariable{Value: "60"}, - HomePageURL: model.AppConfigVariable{Value: "/settings/account"}, - EmailsVerified: model.AppConfigVariable{Value: "false"}, - DisableAnimations: model.AppConfigVariable{Value: "false"}, - AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, - AllowUserSignups: model.AppConfigVariable{Value: "disabled"}, - SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"}, - SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"}, - AccentColor: model.AppConfigVariable{Value: "default"}, - // Email - RequireUserEmail: model.AppConfigVariable{Value: "true"}, - SmtpHost: model.AppConfigVariable{}, - SmtpPort: model.AppConfigVariable{}, - SmtpFrom: model.AppConfigVariable{}, - SmtpUser: model.AppConfigVariable{}, - SmtpPassword: model.AppConfigVariable{}, - SmtpTls: model.AppConfigVariable{Value: "none"}, - SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"}, - EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"}, - EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"}, - EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"}, - EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"}, - EmailVerificationEnabled: model.AppConfigVariable{Value: "false"}, - // LDAP - LdapEnabled: model.AppConfigVariable{Value: "false"}, - LdapUrl: model.AppConfigVariable{}, - LdapBindDn: model.AppConfigVariable{}, - LdapBindPassword: model.AppConfigVariable{}, - LdapBase: model.AppConfigVariable{}, - LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"}, - LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"}, - LdapSkipCertVerify: model.AppConfigVariable{Value: "false"}, - LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{}, - LdapAttributeUserUsername: model.AppConfigVariable{}, - LdapAttributeUserEmail: model.AppConfigVariable{}, - LdapAttributeUserFirstName: model.AppConfigVariable{}, - LdapAttributeUserLastName: model.AppConfigVariable{}, - LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"}, - LdapAttributeUserProfilePicture: model.AppConfigVariable{}, - LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"}, - LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{}, - LdapAttributeGroupName: model.AppConfigVariable{}, - LdapAdminGroupName: model.AppConfigVariable{}, - LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"}, - } -} - -func (s *AppConfigService) updateAppConfigStartTransaction(ctx context.Context) (tx *gorm.DB, err error) { - // We start a transaction before doing any work, to ensure that we are the only ones updating the data in the database - // This works across multiple processes too - tx = s.db.Begin() - err = tx.Error - if err != nil { - return nil, fmt.Errorf("failed to begin database transaction: %w", err) - } - - // With SQLite there's nothing else we need to do, because a transaction blocks the entire database - // However, with Postgres we need to manually lock the table to prevent others from doing the same - switch s.db.Name() { - case "postgres": - // We do not use "NOWAIT" so this blocks until the database is available, or the context is canceled - // Here we use a context with a 10s timeout in case the database is blocked for longer - lockCtx, lockCancel := context.WithTimeout(ctx, 10*time.Second) - defer lockCancel() - err = tx. - WithContext(lockCtx). - Exec("LOCK TABLE app_config_variables IN ACCESS EXCLUSIVE MODE"). - Error - if err != nil { - tx.Rollback() - return nil, fmt.Errorf("failed to acquire lock on app_config_variables table: %w", err) - } - default: - // Nothing to do here - } - - return tx, nil -} - -func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error { - err := tx. - WithContext(ctx). - Clauses(clause.OnConflict{ - // Perform an "upsert" if the key already exists, replacing the value - Columns: []clause.Column{{Name: "key"}}, - DoUpdates: clause.AssignmentColumns([]string{"value"}), - }). - Create(&dbUpdate). - Error - if err != nil { - return fmt.Errorf("failed to update config in database: %w", err) - } - - return nil -} - -func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { - if common.EnvConfig.UiConfigDisabled { - return nil, &common.UiConfigDisabledError{} - } - - // Start the transaction - tx, err := s.updateAppConfigStartTransaction(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback() - - // From here onwards, we know we are the only process/goroutine with exclusive access to the config - // Re-load the config from the database to be sure we have the correct data - cfg, err := s.loadDbConfigInternal(ctx, tx) - if err != nil { - return nil, fmt.Errorf("failed to reload config from database: %w", err) - } - - defaultCfg := s.getDefaultDbConfig() - - // Iterate through all the fields to update - // We update the in-memory data (in the cfg struct) and collect values to update in the database - rt := reflect.ValueOf(input).Type() - rv := reflect.ValueOf(input) - dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField()) - for field := range rt.Fields() { - value := rv.FieldByName(field.Name).String() - - // Get the value of the json tag, taking only what's before the comma - key, _, _ := strings.Cut(field.Tag.Get("json"), ",") - - // Update the in-memory config value - // If the new value is an empty string, then we set the in-memory value to the default one - // Skip values that are internal only and can't be updated - if value == "" { - // Ignore errors here as we know the key exists - defaultValue, _, _ := defaultCfg.FieldByKey(key) - err = cfg.UpdateField(key, defaultValue, true) - } else { - err = cfg.UpdateField(key, value, true) - } - - // If we tried to update an internal field, ignore the error (and do not update in the DB) - if errors.Is(err, model.AppConfigInternalForbiddenError{}) { - continue - } else if err != nil { - return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err) - } - - // We always save "value" which can be an empty string - dbUpdate = append(dbUpdate, model.AppConfigVariable{ - Key: key, - Value: value, - }) - } - - // Update the values in the database - err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate) - if err != nil { - return nil, err - } - - // Commit the changes to the DB, then finally save the updated config in the object - err = tx.Commit().Error - if err != nil { - return nil, fmt.Errorf("failed to commit transaction: %w", err) - } - - s.dbConfig.Store(cfg) - - // Return the updated config - res := cfg.ToAppConfigVariableSlice(true, false) - return res, nil -} - -// UpdateAppConfigValues updates the application configuration values in the database. -func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error { - // Count of keysAndValues must be even - if len(keysAndValues)%2 != 0 { - return errors.New("invalid number of arguments received") - } - - // Start the transaction - tx, err := s.updateAppConfigStartTransaction(ctx) - if err != nil { - return err - } - defer tx.Rollback() - - // From here onwards, we know we are the only process/goroutine with exclusive access to the config - // Re-load the config from the database to be sure we have the correct data - cfg, err := s.loadDbConfigInternal(ctx, tx) - if err != nil { - return fmt.Errorf("failed to reload config from database: %w", err) - } - - defaultCfg := s.getDefaultDbConfig() - - // Iterate through all the fields to update - // We update the in-memory data (in the cfg struct) and collect values to update in the database - // (Note the += 2, as we are iterating through key-value pairs) - dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2) - for i := 1; i < len(keysAndValues); i += 2 { - key := keysAndValues[i-1] - value := keysAndValues[i] - - // Ensure that the field is valid - // We do this by grabbing the default value - var defaultValue string - defaultValue, isInternal, err := defaultCfg.FieldByKey(key) - if err != nil { - return fmt.Errorf("invalid configuration key '%s': %w", key, err) - } - if !isInternal && common.EnvConfig.UiConfigDisabled { - return &common.UiConfigDisabledError{} - } - - // Update the in-memory config value - // If the new value is an empty string, then we set the in-memory value to the default one - // Skip values that are internal only and can't be updated - if value == "" { - err = cfg.UpdateField(key, defaultValue, false) - } else { - err = cfg.UpdateField(key, value, false) - } - if err != nil { - return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err) - } - - // We always save "value" which can be an empty string - dbUpdate = append(dbUpdate, model.AppConfigVariable{ - Key: key, - Value: value, - }) - } - - // Update the values in the database - err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate) - if err != nil { - return err - } - - // Commit the changes to the DB, then finally save the updated config in the object - err = tx.Commit().Error - if err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - s.dbConfig.Store(cfg) - - return nil -} - -func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable { - return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true) -} - -// LoadDbConfig loads the configuration values from the database into the DbConfig struct. -func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) { - dest, err := s.loadDbConfigInternal(ctx, s.db) - if err != nil { - return err - } - - s.dbConfig.Store(dest) - - return nil -} - -func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) { - // If the UI config is disabled, only load from the env - if common.EnvConfig.UiConfigDisabled { - dest, err := s.loadDbConfigFromEnv(ctx, tx) - return dest, err - } - - // First, start from the default configuration - dest := s.getDefaultDbConfig() - - // Load all configuration values from the database - // This loads all values in a single shot - var loaded []model.AppConfigVariable - queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second) - defer queryCancel() - err := tx. - WithContext(queryCtx). - Find(&loaded).Error - if err != nil { - return nil, fmt.Errorf("failed to load configuration from the database: %w", err) - } - - // Iterate through all values loaded from the database - for _, v := range loaded { - // Find the field in the struct whose "key" tag matches, then update that - err = dest.UpdateField(v.Key, v.Value, false) - - // We ignore the case of fields that don't exist, as there may be leftover data in the database - if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) { - return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err) - } - } - - return dest, nil -} - -func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) { - // First, start from the default configuration - dest := s.getDefaultDbConfig() - - // Iterate through each field - rt := reflect.ValueOf(dest).Elem().Type() - rv := reflect.ValueOf(dest).Elem() - for i := range rt.NumField() { - field := rt.Field(i) - - // Get the key and internal tag values - key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",") - - // Internal fields are loaded from the database as they can't be set from the environment - if attrs == "internal" { - var value string - err := tx.WithContext(ctx). - Model(&model.AppConfigVariable{}). - Where("key = ?", key). - Select("value"). - First(&value).Error - if err == nil { - rv.Field(i).FieldByName("Value").SetString(value) - } - continue - } - - envVarName := utils.CamelCaseToScreamingSnakeCase(key) - - // Set the value if it's set - value, ok := os.LookupEnv(envVarName) - if ok { - rv.Field(i).FieldByName("Value").SetString(value) - continue - } - - // If it's sensitive, we also allow reading from file - if attrs == "sensitive" { - fileName := os.Getenv(envVarName + "_FILE") - if fileName != "" { - // #nosec G703 - Value is provided by admin - b, err := os.ReadFile(fileName) - if err != nil { - return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err) - } - rv.Field(i).FieldByName("Value").SetString(string(b)) - continue - } - } - } - - return dest, nil -} diff --git a/backend/internal/service/app_config_service_test.go b/backend/internal/service/app_config_service_test.go deleted file mode 100644 index f22684fc..00000000 --- a/backend/internal/service/app_config_service_test.go +++ /dev/null @@ -1,473 +0,0 @@ -package service - -import ( - "sync/atomic" - "testing" - - "github.com/stretchr/testify/require" - - "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" - testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" -) - -// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values -func NewTestAppConfigService(config *model.AppConfig) *AppConfigService { - service := &AppConfigService{ - dbConfig: atomic.Pointer[model.AppConfig]{}, - } - service.dbConfig.Store(config) - - return service -} - -func TestLoadDbConfig(t *testing.T) { - t.Run("empty config table", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - service := &AppConfigService{ - db: db, - } - - // Load the config - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Config should be equal to default config - require.Equal(t, service.GetDbConfig(), service.getDefaultDbConfig()) - }) - - t.Run("loads value from config table", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Populate the config table with some initial values - err := db. - Create([]model.AppConfigVariable{ - // Overrides default value - {Key: "appName", Value: "Test App"}, - {Key: "sessionDuration", Value: "5"}, - // Does not have a default value - {Key: "smtpHost", Value: "example"}, - }). - Error - require.NoError(t, err) - - // Load the config - service := &AppConfigService{ - db: db, - } - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Values should match expected ones - expect := service.getDefaultDbConfig() - expect.AppName.Value = "Test App" - expect.SessionDuration.Value = "5" - expect.SmtpHost.Value = "example" - require.Equal(t, service.GetDbConfig(), expect) - }) - - t.Run("ignores unknown config keys", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Add an entry with a key that doesn't exist in the config struct - err := db.Create([]model.AppConfigVariable{ - {Key: "__nonExistentKey", Value: "some value"}, - {Key: "appName", Value: "TestApp"}, // This one should still be loaded - }).Error - require.NoError(t, err) - - service := &AppConfigService{ - db: db, - } - // This should not fail, just ignore the unknown key - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - config := service.GetDbConfig() - require.Equal(t, "TestApp", config.AppName.Value) - }) - - t.Run("loading config multiple times", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Initial state - err := db.Create([]model.AppConfigVariable{ - {Key: "appName", Value: "InitialApp"}, - }).Error - require.NoError(t, err) - - service := &AppConfigService{ - db: db, - } - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - require.Equal(t, "InitialApp", service.GetDbConfig().AppName.Value) - - // Update the database value - err = db.Model(&model.AppConfigVariable{}). - Where("key = ?", "appName"). - Update("value", "UpdatedApp").Error - require.NoError(t, err) - - // Load the config again, it should reflect the updated value - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - require.Equal(t, "UpdatedApp", service.GetDbConfig().AppName.Value) - }) - - t.Run("loads config from env when UiConfigDisabled is true", func(t *testing.T) { - // Save the original state and restore it after the test - originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled - defer func() { - common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled - }() - - // Set environment variables for testing - t.Setenv("APP_NAME", "EnvTest App") - t.Setenv("SESSION_DURATION", "45") - - // Enable UiConfigDisabled to load from env - common.EnvConfig.UiConfigDisabled = true - - // Create database with config that should be ignored - db := testutils.NewDatabaseForTest(t) - err := db.Create([]model.AppConfigVariable{ - {Key: "appName", Value: "DB App"}, - {Key: "sessionDuration", Value: "120"}, - }).Error - require.NoError(t, err) - - service := &AppConfigService{ - db: db, - } - - // Load the config - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Config should be loaded from env, not DB - config := service.GetDbConfig() - require.Equal(t, "EnvTest App", config.AppName.Value, "Should load appName from env") - require.Equal(t, "45", config.SessionDuration.Value, "Should load sessionDuration from env") - }) - - t.Run("ignores env vars when UiConfigDisabled is false", func(t *testing.T) { - // Save the original state and restore it after the test - originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled - defer func() { - common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled - }() - - // Set environment variables that should be ignored - t.Setenv("APP_NAME", "EnvTest App") - t.Setenv("SESSION_DURATION", "45") - - // Make sure UiConfigDisabled is false to load from DB - common.EnvConfig.UiConfigDisabled = false - - // Create database with config values that should take precedence - db := testutils.NewDatabaseForTest(t) - err := db.Create([]model.AppConfigVariable{ - {Key: "appName", Value: "DB App"}, - {Key: "sessionDuration", Value: "120"}, - }).Error - require.NoError(t, err) - - service := &AppConfigService{ - db: db, - } - - // Load the config - err = service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Config should be loaded from DB, not env - config := service.GetDbConfig() - require.Equal(t, "DB App", config.AppName.Value, "Should load appName from DB, not env") - require.Equal(t, "120", config.SessionDuration.Value, "Should load sessionDuration from DB, not env") - }) -} - -func TestUpdateAppConfigValues(t *testing.T) { - t.Run("update single value", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Update a single config value - err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App") - require.NoError(t, err) - - // Verify in-memory config was updated - config := service.GetDbConfig() - require.Equal(t, "Test App", config.AppName.Value) - - // Verify database was updated - var dbValue model.AppConfigVariable - err = db.Where("key = ?", "appName").First(&dbValue).Error - require.NoError(t, err) - require.Equal(t, "Test App", dbValue.Value) - }) - - t.Run("update multiple values", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Update multiple config values - err = service.UpdateAppConfigValues( - t.Context(), - "appName", "Test App", - "sessionDuration", "30", - "smtpHost", "mail.example.com", - ) - require.NoError(t, err) - - // Verify in-memory config was updated - config := service.GetDbConfig() - require.Equal(t, "Test App", config.AppName.Value) - require.Equal(t, "30", config.SessionDuration.Value) - require.Equal(t, "mail.example.com", config.SmtpHost.Value) - - // Verify database was updated - var count int64 - db.Model(&model.AppConfigVariable{}).Count(&count) - require.Equal(t, int64(3), count) - - var appName, sessionDuration, smtpHost model.AppConfigVariable - err = db.Where("key = ?", "appName").First(&appName).Error - require.NoError(t, err) - require.Equal(t, "Test App", appName.Value) - - err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error - require.NoError(t, err) - require.Equal(t, "30", sessionDuration.Value) - - err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error - require.NoError(t, err) - require.Equal(t, "mail.example.com", smtpHost.Value) - }) - - t.Run("empty value resets to default", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // First change the value - err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "30") - require.NoError(t, err) - require.Equal(t, "30", service.GetDbConfig().SessionDuration.Value) - - // Now set it to empty which should use default value - err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "") - require.NoError(t, err) - require.Equal(t, "60", service.GetDbConfig().SessionDuration.Value) // Default value from getDefaultDbConfig - }) - - t.Run("error with odd number of arguments", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Try to update with odd number of arguments - err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App", "sessionDuration") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid number of arguments") - }) - - t.Run("error with invalid key", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Try to update with invalid key - err = service.UpdateAppConfigValues(t.Context(), "nonExistentKey", "some value") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid configuration key") - }) -} - -func TestUpdateAppConfig(t *testing.T) { - t.Run("updates configuration values from DTO", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Create update DTO - input := dto.AppConfigUpdateDto{ - AppName: "Updated App Name", - SessionDuration: "120", - SmtpHost: "smtp.example.com", - SmtpPort: "587", - } - - // Update config - updatedVars, err := service.UpdateAppConfig(t.Context(), input) - require.NoError(t, err) - - // Verify returned updated variables - require.NotEmpty(t, updatedVars) - - var foundAppName, foundSessionDuration, foundSmtpHost, foundSmtpPort bool - for _, v := range updatedVars { - switch v.Key { - case "appName": - require.Equal(t, "Updated App Name", v.Value) - foundAppName = true - case "sessionDuration": - require.Equal(t, "120", v.Value) - foundSessionDuration = true - case "smtpHost": - require.Equal(t, "smtp.example.com", v.Value) - foundSmtpHost = true - case "smtpPort": - require.Equal(t, "587", v.Value) - foundSmtpPort = true - } - } - require.True(t, foundAppName) - require.True(t, foundSessionDuration) - require.True(t, foundSmtpHost) - require.True(t, foundSmtpPort) - - // Verify in-memory config was updated - config := service.GetDbConfig() - require.Equal(t, "Updated App Name", config.AppName.Value) - require.Equal(t, "120", config.SessionDuration.Value) - require.Equal(t, "smtp.example.com", config.SmtpHost.Value) - require.Equal(t, "587", config.SmtpPort.Value) - - // Verify database was updated - var appName, sessionDuration, smtpHost, smtpPort model.AppConfigVariable - err = db.Where("key = ?", "appName").First(&appName).Error - require.NoError(t, err) - require.Equal(t, "Updated App Name", appName.Value) - - err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error - require.NoError(t, err) - require.Equal(t, "120", sessionDuration.Value) - - err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error - require.NoError(t, err) - require.Equal(t, "smtp.example.com", smtpHost.Value) - - err = db.Where("key = ?", "smtpPort").First(&smtpPort).Error - require.NoError(t, err) - require.Equal(t, "587", smtpPort.Value) - }) - - t.Run("empty values reset to defaults", func(t *testing.T) { - db := testutils.NewDatabaseForTest(t) - - // Create a service with default config and modify some values - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // First set some non-default values - err = service.UpdateAppConfigValues(t.Context(), - "appName", "Custom App", - "sessionDuration", "120", - ) - require.NoError(t, err) - - // Create update DTO with empty values to reset to defaults - input := dto.AppConfigUpdateDto{ - AppName: "", // Should reset to default "Pocket ID" - SessionDuration: "", // Should reset to default "60" - } - - // Update config - updatedVars, err := service.UpdateAppConfig(t.Context(), input) - require.NoError(t, err) - - // Verify returned updated variables (they should be empty strings in DB) - var foundAppName, foundSessionDuration bool - for _, v := range updatedVars { - switch v.Key { - case "appName": - require.Equal(t, "Pocket ID", v.Value) // Returns the default value - foundAppName = true - case "sessionDuration": - require.Equal(t, "60", v.Value) // Returns the default value - foundSessionDuration = true - } - } - require.True(t, foundAppName) - require.True(t, foundSessionDuration) - - // Verify in-memory config was reset to defaults - config := service.GetDbConfig() - require.Equal(t, "Pocket ID", config.AppName.Value) // Default value - require.Equal(t, "60", config.SessionDuration.Value) // Default value - - // Verify database was updated with empty values - for _, key := range []string{"appName", "sessionDuration"} { - var loaded model.AppConfigVariable - err = db.Where("key = ?", key).First(&loaded).Error - require.NoErrorf(t, err, "Failed to load DB value for key '%s'", key) - require.Emptyf(t, loaded.Value, "Loaded value for key '%s' is not empty", key) - } - }) - - t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) { - // Save the original state and restore it after the test - originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled - defer func() { - common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled - }() - - // Disable UI config - common.EnvConfig.UiConfigDisabled = true - - db := testutils.NewDatabaseForTest(t) - service := &AppConfigService{ - db: db, - } - err := service.LoadDbConfig(t.Context()) - require.NoError(t, err) - - // Try to update config - _, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{ - AppName: "Should Not Update", - }) - - // Should get a UiConfigDisabledError - require.Error(t, err) - var uiConfigDisabledErr *common.UiConfigDisabledError - require.ErrorAs(t, err, &uiConfigDisabledErr) - }) -} diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index 7a2290e4..5e995ac3 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -6,6 +6,7 @@ import ( "log/slog" userAgentParser "github.com/mileusna/useragent" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils/email" @@ -14,12 +15,12 @@ import ( type AuditLogService struct { db *gorm.DB - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService emailService *EmailService geoliteService *GeoLiteService } -func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService { +func NewAuditLogService(db *gorm.DB, appConfigService *appconfig.AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService { return &AuditLogService{ db: db, appConfigService: appConfigService, diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index bd32bc38..60d923c2 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -22,6 +22,7 @@ import ( "github.com/ory/fosite/compose" fositejwt "github.com/ory/fosite/token/jwt" "github.com/pocket-id/pocket-id/backend/internal/apikey" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/api" @@ -40,7 +41,7 @@ import ( type TestService struct { db *gorm.DB jwtService *JwtService - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService ldapService *LdapService fileStorage storage.FileStorage appLockService *AppLockService @@ -54,7 +55,7 @@ const ( e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4" ) -func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) { +func NewTestService(db *gorm.DB, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) { s := &TestService{ db: db, appConfigService: appConfigService, diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 19e671f9..a89f8f09 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -13,19 +13,20 @@ import ( "github.com/italypaleale/go-kit/emailer" "gorm.io/gorm" + "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/model" "github.com/pocket-id/pocket-id/backend/internal/utils/email" ) type EmailService struct { - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService db *gorm.DB htmlTemplates map[string]*htemplate.Template textTemplates map[string]*ttemplate.Template } -func NewEmailService(db *gorm.DB, appConfigService *AppConfigService) (*EmailService, error) { +func NewEmailService(db *gorm.DB, appConfigService *appconfig.AppConfigService) (*EmailService, error) { htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths) if err != nil { return nil, fmt.Errorf("prepare html templates: %w", err) diff --git a/backend/internal/service/import_service_test.go b/backend/internal/service/import_service_test.go index a53fabf0..b793bafc 100644 --- a/backend/internal/service/import_service_test.go +++ b/backend/internal/service/import_service_test.go @@ -54,12 +54,14 @@ func seedActorHostSchema(t *testing.T, db *gorm.DB) { func requireActorHostSchemaPreserved(t *testing.T, db *gorm.DB) { t.Helper() var tableRows int64 - require.NoError(t, db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error) + err := db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error + require.NoError(t, err) require.Equal(t, int64(1), tableRows, "francis_ tables and their rows must be preserved by an import") // The view is only valid if its backing table was preserved as well var viewCount int64 - require.NoError(t, db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error) + err = db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error + require.NoError(t, err) require.Equal(t, int64(1), viewCount, "francis_ views must be preserved by an import") } diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go index 07ce8b21..7dc8e5ce 100644 --- a/backend/internal/service/jwt_service.go +++ b/backend/internal/service/jwt_service.go @@ -13,6 +13,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jwt" "gorm.io/gorm" + "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/model" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" @@ -47,12 +48,12 @@ type JwtService struct { envConfig *common.EnvConfigSchema privateKey jwk.Key keyId string - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService instanceID string jwksEncoded []byte } -func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService) (*JwtService, error) { +func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *appconfig.AppConfigService) (*JwtService, error) { service := &JwtService{} err := service.init(ctx, db, instanceID, appConfigService, &common.EnvConfig) @@ -63,7 +64,7 @@ func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfi return service, nil } -func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) { +func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *appconfig.AppConfigService, envConfig *common.EnvConfigSchema) (err error) { s.appConfigService = appConfigService s.envConfig = envConfig s.db = db diff --git a/backend/internal/service/jwt_service_test.go b/backend/internal/service/jwt_service_test.go index b0eae143..45a7776c 100644 --- a/backend/internal/service/jwt_service_test.go +++ b/backend/internal/service/jwt_service_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" + "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/instanceid" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -35,7 +36,7 @@ func newTestEnvConfig() *common.EnvConfigSchema { } } -func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *AppConfigService, envConfig *common.EnvConfigSchema) *JwtService { +func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *appconfig.AppConfigService, envConfig *common.EnvConfigSchema) *JwtService { t.Helper() service := &JwtService{} @@ -45,7 +46,7 @@ func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *App return service } -func setupJwtService(t *testing.T, instanceID string, appConfig *AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) { +func setupJwtService(t *testing.T, instanceID string, appConfig *appconfig.AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) { t.Helper() db := testutils.NewDatabaseForTest(t) @@ -70,7 +71,7 @@ func newTestDbAndEnv(t *testing.T) (*gorm.DB, *common.EnvConfigSchema) { return testutils.NewDatabaseForTest(t), newTestEnvConfig() } -func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, key jwk.Key) string { +func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, key jwk.Key) string { t.Helper() keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID) @@ -87,7 +88,7 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig * } func TestJwtService_Init(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ + mockConfig := appconfig.NewTestAppConfigService(&model.AppConfig{ SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes }) @@ -192,7 +193,7 @@ func TestJwtService_Init(t *testing.T) { } func TestJwtService_GetPublicJWK(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ + mockConfig := appconfig.NewTestAppConfigService(&model.AppConfig{ SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes }) db := testutils.NewDatabaseForTest(t) @@ -310,7 +311,7 @@ func TestJwtService_GetPublicJWK(t *testing.T) { } func TestGenerateVerifyAccessToken(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ + mockConfig := appconfig.NewTestAppConfigService(&model.AppConfig{ SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes }) db, envConfig := newTestDbAndEnv(t) @@ -401,7 +402,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { }) t.Run("uses session duration from config", func(t *testing.T) { - customMockConfig := NewTestAppConfigService(&model.AppConfig{ + customMockConfig := appconfig.NewTestAppConfigService(&model.AppConfig{ SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes }) service, _, _ := setupJwtService(t, instanceID, customMockConfig) @@ -582,7 +583,7 @@ func TestTokenTypeValidator(t *testing.T) { }) } -func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string { +func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService, privateKeyRaw any) string { t.Helper() privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "") @@ -597,7 +598,7 @@ var ( rsaKeyPrecomputeOnce sync.Once ) -func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string { +func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string { t.Helper() rsaKeyPrecomputeOnce.Do(func() { @@ -612,7 +613,7 @@ func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig return importKey(t, db, instanceID, envConfig, appConfig, rsaKeyPrecomputed) } -func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string { +func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string { t.Helper() // Generate a new P-256 ECDSA key @@ -624,7 +625,7 @@ func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig * } // Helper function to create an Ed25519 key and save it as JWK -func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string { +func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *appconfig.AppConfigService) string { t.Helper() // Generate a new Ed25519 key pair diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index 73392c61..cb8a36ab 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -18,6 +18,7 @@ import ( "github.com/go-ldap/ldap/v3" "github.com/google/uuid" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" "golang.org/x/text/unicode/norm" @@ -31,7 +32,7 @@ import ( type LdapService struct { db *gorm.DB httpClient *http.Client - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService userService *UserService groupService *UserGroupService fileStorage storage.FileStorage @@ -69,7 +70,7 @@ type ldapClient interface { Close() error } -func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService { +func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *appconfig.AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService { service := &LdapService{ db: db, httpClient: httpClient, diff --git a/backend/internal/service/ldap_service_test.go b/backend/internal/service/ldap_service_test.go index d340cc74..13e24633 100644 --- a/backend/internal/service/ldap_service_test.go +++ b/backend/internal/service/ldap_service_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/storage" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -316,7 +317,7 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf fileStorage, err := storage.NewDatabaseStorage(db) require.NoError(t, err) - appConfig := NewTestAppConfigService(appConfigModel) + appConfig := appconfig.NewTestAppConfigService(appConfigModel) groupService := NewUserGroupService(db, appConfig, nil) userService := NewUserService( diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index d667e107..b97cc304 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -16,6 +16,7 @@ import ( "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" @@ -39,7 +40,7 @@ const ( type OidcService struct { db *gorm.DB jwtService *JwtService - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService previewBuilder oidcClientPreviewBuilder scimService *ScimService @@ -54,7 +55,7 @@ type oidcClientPreviewBuilder interface { func NewOidcService( db *gorm.DB, jwtService *JwtService, - appConfigService *AppConfigService, + appConfigService *appconfig.AppConfigService, previewBuilder oidcClientPreviewBuilder, scimService *ScimService, httpClient *http.Client, diff --git a/backend/internal/service/one_time_access_service.go b/backend/internal/service/one_time_access_service.go index b22a5871..27f6d7ec 100644 --- a/backend/internal/service/one_time_access_service.go +++ b/backend/internal/service/one_time_access_service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "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/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -20,13 +21,13 @@ import ( type OneTimeAccessService struct { db *gorm.DB userService *UserService - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService jwtService *JwtService auditLogService *AuditLogService emailService *EmailService } -func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService { +func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *appconfig.AppConfigService) *OneTimeAccessService { return &OneTimeAccessService{ db: db, userService: userService, diff --git a/backend/internal/service/one_time_access_service_test.go b/backend/internal/service/one_time_access_service_test.go index 8d2964fb..24977ec9 100644 --- a/backend/internal/service/one_time_access_service_test.go +++ b/backend/internal/service/one_time_access_service_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" + "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/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -14,7 +15,7 @@ import ( func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) { db := testutils.NewDatabaseForTest(t) - appConfig := NewTestAppConfigService((&AppConfigService{}).getDefaultDbConfig()) + appConfig := appconfig.NewTestAppConfigService(nil) instanceID := newInstanceID(t, db) jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig()) auditLogService := NewAuditLogService(db, appConfig, nil, &GeoLiteService{}) diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 0e37a5ac..81d239e1 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/pocket-id/pocket-id/backend/internal/appconfig" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "gorm.io/gorm" @@ -17,10 +18,10 @@ import ( type UserGroupService struct { db *gorm.DB scimService *ScimService - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService } -func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService { +func NewUserGroupService(db *gorm.DB, appConfigService *appconfig.AppConfigService, scimService *ScimService) *UserGroupService { return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService} } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 07be9c67..d292dfcd 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -13,6 +13,7 @@ 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" @@ -31,14 +32,14 @@ type UserService struct { jwtService *JwtService auditLogService *AuditLogService emailService *EmailService - appConfigService *AppConfigService + appConfigService *appconfig.AppConfigService customClaimService *CustomClaimService appImagesService *AppImagesService scimService *ScimService fileStorage storage.FileStorage } -func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService { +func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *appconfig.AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService { return &UserService{ db: db, jwtService: jwtService, diff --git a/backend/resources/migrations/postgres/20260709000000_freeze_config.down.sql b/backend/resources/migrations/postgres/20260709000000_freeze_config.down.sql new file mode 100644 index 00000000..5dbbaac9 --- /dev/null +++ b/backend/resources/migrations/postgres/20260709000000_freeze_config.down.sql @@ -0,0 +1,16 @@ +-- Recreate the standalone config table with the same schema it had before it was frozen +CREATE TABLE app_config_variables +( + key VARCHAR(100) NOT NULL PRIMARY KEY, + value TEXT NOT NULL +); + +-- Populate it from the frozen JSON document stored in the "kv" table +-- json_each expands the JSON object back into one row per key/value pair. +INSERT INTO app_config_variables (key, value) +SELECT je.key, je.value +FROM kv, json_each_text(kv."value"::json) AS je(key, value) +WHERE kv."key" = 'config_migrated'; + +-- Remove the frozen config from the "kv" table +DELETE FROM kv WHERE "key" = 'config_migrated'; diff --git a/backend/resources/migrations/postgres/20260709000000_freeze_config.up.sql b/backend/resources/migrations/postgres/20260709000000_freeze_config.up.sql new file mode 100644 index 00000000..cba856cd --- /dev/null +++ b/backend/resources/migrations/postgres/20260709000000_freeze_config.up.sql @@ -0,0 +1,12 @@ +-- Freeze the app configuration +-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key +-- +-- json_object_agg aggregates all rows into a JSON object +-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty +INSERT INTO kv ("key", "value") +SELECT 'config_migrated', json_object_agg("key", "value")::text +FROM app_config_variables +HAVING count(*) > 0; + +-- Drop the now-frozen standalone config table +DROP TABLE app_config_variables; diff --git a/backend/resources/migrations/sqlite/20260709000000_freeze_config.down.sql b/backend/resources/migrations/sqlite/20260709000000_freeze_config.down.sql new file mode 100644 index 00000000..f807b25f --- /dev/null +++ b/backend/resources/migrations/sqlite/20260709000000_freeze_config.down.sql @@ -0,0 +1,22 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +-- Recreate the standalone config table with the same schema it had before it was frozen +CREATE TABLE app_config_variables +( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL +); + +-- Populate it from the frozen JSON document stored in the "kv" table +-- json_each expands the JSON object back into one row per key/value pair. +INSERT INTO app_config_variables ("key", "value") +SELECT je.key, je.value +FROM kv, json_each(kv."value") AS je +WHERE kv."key" = 'config_migrated'; + +-- Remove the frozen config from the "kv" table +DELETE FROM kv WHERE "key" = 'config_migrated'; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20260709000000_freeze_config.up.sql b/backend/resources/migrations/sqlite/20260709000000_freeze_config.up.sql new file mode 100644 index 00000000..1b951fd2 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260709000000_freeze_config.up.sql @@ -0,0 +1,18 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +-- Freeze the app configuration +-- Encode every row of the standalone config table as a single JSON object (mapping key -> value) and store it in the "kv" table under the "config_migrated" key +-- +-- json_group_object aggregates all rows into a JSON object +-- The "HAVING count(*) > 0" clause ensures that nothing is written to the "kv" table when the config table is empty +INSERT INTO kv ("key", "value") +SELECT 'config_migrated', json_group_object("key", "value") +FROM app_config_variables +HAVING count(*) > 0; + +-- Drop the now-frozen standalone config table +DROP TABLE app_config_variables; + +COMMIT; +PRAGMA foreign_keys=ON;