package appconfig import ( "context" "encoding/json" "errors" "fmt" "os" "reflect" "time" "github.com/go-playground/validator/v10" "github.com/italypaleale/francis/actor" francishost "github.com/italypaleale/francis/host" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/apperror" "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/tracing" "github.com/pocket-id/pocket-id/backend/internal/utils" ) type AppConfigService struct { actSvc *actor.Service envConfig *AppConfigModel } func NewService(ctx context.Context, actors francishost.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, francishost.WithBootstrapData(bootstrapData), francishost.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 } // GetCIMDURLAllowlist returns the configured CIMD metadata-document URL // allowlist. Returns an empty slice if unset or malformed (which denies all). func (s *AppConfigService) GetCIMDURLAllowlist() []string { cfg, err := s.GetConfig(context.Background()) if err != nil { return nil } raw := string(cfg.CIMDURLAllowlist) if raw == "" { return nil } var patterns []string if err := json.Unmarshal([]byte(raw), &patterns); err != nil { return nil } return patterns } // UpdateAppConfig replaces the entire application configuration with the values from the input DTO. func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]AppConfigVariable, error) { // If the UI config is disabled, we cannot continue if common.EnvConfig.UiConfigDisabled { return nil, apperror.UIConfigDisabled() } // Validate the CIMD URL allowlist patterns, if provided if input.CIMDURLAllowlist != "" { var patterns []string if err := json.Unmarshal([]byte(input.CIMDURLAllowlist), &patterns); err != nil { return nil, apperror.InvalidCIMDURLPattern(input.CIMDURLAllowlist) } for _, p := range patterns { if err := utils.ValidateCallbackURLPattern(p); err != nil { return nil, apperror.InvalidCIMDURLPattern(p) } } } // Replace the entire config by invoking the actor cfg, err := s.invokeConfigActor(ctx, "replace", input) if err != nil { return nil, err } // Return the updated config return cfg.ToAppConfigVariableSlice(true, false), nil } // UpdateAppConfigValues updates the provided application configuration values. // Keys correspond to the "json" tags on the config model. // An empty string value resets the property to its default value. 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 the UI config is disabled, we cannot continue if common.EnvConfig.UiConfigDisabled { return apperror.UIConfigDisabled() } // Collect the key-value pairs into a map for the actor // (Note the += 2, as we are iterating through key-value pairs) values := make(map[string]string, len(keysAndValues)/2) for i := 1; i < len(keysAndValues); i += 2 { values[keysAndValues[i-1]] = keysAndValues[i] } // Update the config by invoking the actor _, err := s.invokeConfigActor(ctx, "update", values) return err } // ListAppConfig returns the application configuration as a slice of key/value pairs. // If showAll is false, only properties marked as public are included. func (s *AppConfigService) ListAppConfig(ctx context.Context, showAll bool) ([]AppConfigVariable, error) { cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } return cfg.ToAppConfigVariableSlice(showAll, true), nil } // invokeConfigActor invokes a method on the AppConfig actor and decodes the returned state. func (s *AppConfigService) invokeConfigActor(parentCtx context.Context, method string, data any) (*AppConfigModel, error) { ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) defer cancel() res, err := s.actSvc.Invoke(ctx, AppConfigActorType, actor.SingletonActorID, method, data) if err != nil { return nil, fmt.Errorf("error invoking config actor method '%s': %w", method, 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 } 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) envVarName := field.Tag.Get("env") if envVarName == "" { return nil, fmt.Errorf("app configuration field %s is missing its environment variable name", field.Name) } // Sensitive values can also be loaded from a file, using the variable with the "_FILE" suffix (files have precedence over the env var) if field.Tag.Get("sensitive") == "true" { value, ok, err := common.LoadStringEnvVarFromFile(envVarName) if err != nil { return nil, err } if ok { rv.Field(i).SetString(value) continue } } // Set the value from the environment variable if it's set value, ok := os.LookupEnv(envVarName) if ok { rv.Field(i).SetString(value) } } // Validate the resolved configuration before exposing values to the rest of the application err := validateEnvConfig(dest) if err != nil { return nil, err } return dest, nil } // validateEnvConfig applies the HTTP configuration rules and reports failures using environment variable names func validateEnvConfig(config *AppConfigModel) error { // Map the resolved model to the canonical update DTO so both configuration paths share validation rules var input dto.AppConfigUpdateDto err := dto.MapStruct(config, &input) if err != nil { return fmt.Errorf("failed to prepare environment app configuration for validation: %w", err) } // Collect every invalid environment variable err = input.Validate() if err != nil { validationErrors, ok := errors.AsType[validator.ValidationErrors](err) if !ok { return fmt.Errorf("failed to validate environment app configuration: %w", err) } failures := make([]error, 0, len(validationErrors)) for _, validationError := range validationErrors { envName, ok := appConfigEnvName(validationError.Field()) if !ok { return fmt.Errorf("failed to find the environment variable for app configuration field %s", validationError.Field()) } _, message := dto.ValidationErrorDetails(validationError) failures = append(failures, fmt.Errorf("%s %s", envName, message)) } return fmt.Errorf("invalid environment app configuration: %w", errors.Join(failures...)) } return nil }