Files
pocket-id/backend/internal/appconfig/service.go
T
ItalyPaleAleandClaude Opus 5 cea1267f37 feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When it is
empty or set to "embedded" (the default), Pocket ID starts the runtime inside its own process, backed by its own database.

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.

Note: connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT, and optionally (but recommended) FRANCIS_CA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMgoTZtznSjP4SHTaHbRen
2026-09-15 16:02:05 +00:00

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
}