mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-22 02:49:04 +02:00
FRANCIS_HOST decides where the Francis actor runtime lives. When set to "embedded" (the default), Pocket ID starts the runtime inside its own process. Any other value is the address, or a comma-separated list of addresses, of a standalone Francis runtime. Pocket ID then connects to it as a remote actor host and starts no embedded runtime. Because when using a remote runtime, it's likewise not possible to enforce a single instance of Pocket ID is running at once, the env vars currently have the `EXPERIMENTAL_` prefix, are **undocumented**, and show a warning if used. Notes: - Connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE, and optionally (but recommended) FRANCIS_CA. - When connecting to a remote runtime, exporting Pocket ID data does not include the actor state, which will need to be backed up and restored separately
280 lines
8.8 KiB
Go
280 lines
8.8 KiB
Go
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
|
|
}
|