github.com/supabase/cli@v1.168.1/internal/utils/config.go (about)

     1  package utils
     2  
     3  import (
     4  	"bytes"
     5  	_ "embed"
     6  	"fmt"
     7  	"net/url"
     8  	"os"
     9  	"path/filepath"
    10  	"regexp"
    11  	"strings"
    12  	"text/template"
    13  	"time"
    14  
    15  	"github.com/BurntSushi/toml"
    16  	"github.com/docker/go-units"
    17  	"github.com/go-errors/errors"
    18  	"github.com/golang-jwt/jwt/v5"
    19  	"github.com/joho/godotenv"
    20  	"github.com/spf13/afero"
    21  	"github.com/spf13/viper"
    22  	"golang.org/x/mod/semver"
    23  )
    24  
    25  var (
    26  	NetId         string
    27  	DbId          string
    28  	ConfigId      string
    29  	KongId        string
    30  	GotrueId      string
    31  	InbucketId    string
    32  	RealtimeId    string
    33  	RestId        string
    34  	StorageId     string
    35  	ImgProxyId    string
    36  	DifferId      string
    37  	PgmetaId      string
    38  	StudioId      string
    39  	EdgeRuntimeId string
    40  	LogflareId    string
    41  	VectorId      string
    42  	PoolerId      string
    43  
    44  	DbAliases          = []string{"db", "db.supabase.internal"}
    45  	KongAliases        = []string{"kong", "api.supabase.internal"}
    46  	GotrueAliases      = []string{"auth"}
    47  	InbucketAliases    = []string{"inbucket"}
    48  	RealtimeAliases    = []string{"realtime", Config.Realtime.TenantId}
    49  	RestAliases        = []string{"rest"}
    50  	StorageAliases     = []string{"storage"}
    51  	ImgProxyAliases    = []string{"imgproxy"}
    52  	PgmetaAliases      = []string{"pg_meta"}
    53  	StudioAliases      = []string{"studio"}
    54  	EdgeRuntimeAliases = []string{"edge_runtime"}
    55  	LogflareAliases    = []string{"analytics"}
    56  	VectorAliases      = []string{"vector"}
    57  	PoolerAliases      = []string{"pooler"}
    58  
    59  	InitialSchemaSql string
    60  	//go:embed templates/initial_schemas/13.sql
    61  	InitialSchemaPg13Sql string
    62  	//go:embed templates/initial_schemas/14.sql
    63  	InitialSchemaPg14Sql string
    64  
    65  	//go:embed templates/init_config.toml
    66  	initConfigEmbed    string
    67  	initConfigTemplate = template.Must(template.New("initConfig").Parse(initConfigEmbed))
    68  	invalidProjectId   = regexp.MustCompile("[^a-zA-Z0-9_.-]+")
    69  	envPattern         = regexp.MustCompile(`^env\((.*)\)$`)
    70  )
    71  
    72  func GetId(name string) string {
    73  	return "supabase_" + name + "_" + Config.ProjectId
    74  }
    75  
    76  func UpdateDockerIds() {
    77  	NetId = GetId("network")
    78  	DbId = GetId(DbAliases[0])
    79  	ConfigId = GetId("config")
    80  	KongId = GetId(KongAliases[0])
    81  	GotrueId = GetId(GotrueAliases[0])
    82  	InbucketId = GetId(InbucketAliases[0])
    83  	RealtimeId = GetId(RealtimeAliases[0])
    84  	RestId = GetId(RestAliases[0])
    85  	StorageId = GetId(StorageAliases[0])
    86  	ImgProxyId = GetId(ImgProxyAliases[0])
    87  	DifferId = GetId("differ")
    88  	PgmetaId = GetId(PgmetaAliases[0])
    89  	StudioId = GetId(StudioAliases[0])
    90  	EdgeRuntimeId = GetId(EdgeRuntimeAliases[0])
    91  	LogflareId = GetId(LogflareAliases[0])
    92  	VectorId = GetId(VectorAliases[0])
    93  	PoolerId = GetId(PoolerAliases[0])
    94  }
    95  
    96  // Type for turning human-friendly bytes string ("5MB", "32kB") into an int64 during toml decoding.
    97  type sizeInBytes int64
    98  
    99  func (s *sizeInBytes) UnmarshalText(text []byte) error {
   100  	size, err := units.RAMInBytes(string(text))
   101  	if err == nil {
   102  		*s = sizeInBytes(size)
   103  	}
   104  	return err
   105  }
   106  
   107  func (s sizeInBytes) MarshalText() (text []byte, err error) {
   108  	return []byte(units.BytesSize(float64(s))), nil
   109  }
   110  
   111  type LogflareBackend string
   112  
   113  const (
   114  	LogflarePostgres LogflareBackend = "postgres"
   115  	LogflareBigQuery LogflareBackend = "bigquery"
   116  )
   117  
   118  type PoolMode string
   119  
   120  const (
   121  	TransactionMode PoolMode = "transaction"
   122  	SessionMode     PoolMode = "session"
   123  )
   124  
   125  type AddressFamily string
   126  
   127  const (
   128  	AddressIPv6 AddressFamily = "IPv6"
   129  	AddressIPv4 AddressFamily = "IPv4"
   130  )
   131  
   132  func ToRealtimeEnv(addr AddressFamily) string {
   133  	if addr == AddressIPv6 {
   134  		return "-proto_dist inet6_tcp"
   135  	}
   136  	return "-proto_dist inet_tcp"
   137  }
   138  
   139  type CustomClaims struct {
   140  	// Overrides Issuer to maintain json order when marshalling
   141  	Issuer string `json:"iss,omitempty"`
   142  	Ref    string `json:"ref,omitempty"`
   143  	Role   string `json:"role"`
   144  	jwt.RegisteredClaims
   145  }
   146  
   147  const (
   148  	defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"
   149  	defaultJwtExpiry = 1983812996
   150  )
   151  
   152  func (c CustomClaims) NewToken() *jwt.Token {
   153  	if c.ExpiresAt == nil {
   154  		c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0))
   155  	}
   156  	if len(c.Issuer) == 0 {
   157  		c.Issuer = "supabase-demo"
   158  	}
   159  	return jwt.NewWithClaims(jwt.SigningMethodHS256, c)
   160  }
   161  
   162  var Config = config{
   163  	Api: api{
   164  		Image: PostgrestImage,
   165  	},
   166  	Db: db{
   167  		Image:    Pg15Image,
   168  		Password: "postgres",
   169  		RootKey:  "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275",
   170  	},
   171  	Realtime: realtime{
   172  		IpVersion:       AddressIPv4,
   173  		MaxHeaderLength: 4096,
   174  		TenantId:        "realtime-dev",
   175  		EncryptionKey:   "supabaserealtime",
   176  		SecretKeyBase:   "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG",
   177  	},
   178  	Storage: storage{
   179  		Image: StorageImage,
   180  		S3Credentials: storageS3Credentials{
   181  			AccessKeyId:     "625729a08b95bf1b7ff351a663f3a23c",
   182  			SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907",
   183  			Region:          "local",
   184  		},
   185  		ImageTransformation: imageTransformation{
   186  			Enabled: true,
   187  		},
   188  	},
   189  	Auth: auth{
   190  		Image: GotrueImage,
   191  		Email: email{
   192  			Template: map[string]emailTemplate{
   193  				"invite":       {},
   194  				"confirmation": {},
   195  				"recovery":     {},
   196  				"magic_link":   {},
   197  				"email_change": {},
   198  			},
   199  		},
   200  		External: map[string]provider{
   201  			"apple":         {},
   202  			"azure":         {},
   203  			"bitbucket":     {},
   204  			"discord":       {},
   205  			"facebook":      {},
   206  			"github":        {},
   207  			"gitlab":        {},
   208  			"google":        {},
   209  			"keycloak":      {},
   210  			"linkedin":      {}, // TODO: remove this field in v2
   211  			"linkedin_oidc": {},
   212  			"notion":        {},
   213  			"twitch":        {},
   214  			"twitter":       {},
   215  			"slack":         {},
   216  			"spotify":       {},
   217  			"workos":        {},
   218  			"zoom":          {},
   219  		},
   220  		JwtSecret: defaultJwtSecret,
   221  	},
   222  	Analytics: analytics{
   223  		ApiKey: "api-key",
   224  		// Defaults to bigquery for backwards compatibility with existing config.toml
   225  		Backend: LogflareBigQuery,
   226  	},
   227  }
   228  
   229  // We follow these rules when adding new config:
   230  //  1. Update init_config.toml (and init_config.test.toml) with the new key, default value, and comments to explain usage.
   231  //  2. Update config struct with new field and toml tag (spelled in snake_case).
   232  //  3. Add custom field validations to LoadConfigFS function for eg. integer range checks.
   233  //
   234  // If you are adding new user defined secrets, such as OAuth provider secret, the default value in
   235  // init_config.toml should be an env var substitution. For example,
   236  //
   237  // > secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
   238  //
   239  // If you are adding an internal config or secret that doesn't need to be overridden by the user,
   240  // exclude the field from toml serialization. For example,
   241  //
   242  //	type auth struct {
   243  //		AnonKey string `toml:"-" mapstructure:"anon_key"`
   244  //	}
   245  //
   246  // Use `mapstructure:"anon_key"` tag only if you want inject values from a predictable environment
   247  // variable, such as SUPABASE_AUTH_ANON_KEY.
   248  //
   249  // Default values for internal configs should be added to `var Config` initializer.
   250  type (
   251  	config struct {
   252  		ProjectId    string              `toml:"project_id"`
   253  		Hostname     string              `toml:"-"`
   254  		Api          api                 `toml:"api"`
   255  		Db           db                  `toml:"db" mapstructure:"db"`
   256  		Realtime     realtime            `toml:"realtime"`
   257  		Studio       studio              `toml:"studio"`
   258  		Inbucket     inbucket            `toml:"inbucket"`
   259  		Storage      storage             `toml:"storage"`
   260  		Auth         auth                `toml:"auth" mapstructure:"auth"`
   261  		Functions    map[string]function `toml:"functions"`
   262  		Analytics    analytics           `toml:"analytics"`
   263  		Experimental experimental        `toml:"experimental" mapstructure:"-"`
   264  		// TODO
   265  		// Scripts   scripts
   266  	}
   267  
   268  	api struct {
   269  		Enabled         bool     `toml:"enabled"`
   270  		Image           string   `toml:"-"`
   271  		Port            uint16   `toml:"port"`
   272  		Schemas         []string `toml:"schemas"`
   273  		ExtraSearchPath []string `toml:"extra_search_path"`
   274  		MaxRows         uint     `toml:"max_rows"`
   275  	}
   276  
   277  	db struct {
   278  		Image        string `toml:"-"`
   279  		Port         uint16 `toml:"port"`
   280  		ShadowPort   uint16 `toml:"shadow_port"`
   281  		MajorVersion uint   `toml:"major_version"`
   282  		Password     string `toml:"-"`
   283  		RootKey      string `toml:"-" mapstructure:"root_key"`
   284  		Pooler       pooler `toml:"pooler"`
   285  	}
   286  
   287  	pooler struct {
   288  		Enabled          bool     `toml:"enabled"`
   289  		Port             uint16   `toml:"port"`
   290  		PoolMode         PoolMode `toml:"pool_mode"`
   291  		DefaultPoolSize  uint     `toml:"default_pool_size"`
   292  		MaxClientConn    uint     `toml:"max_client_conn"`
   293  		ConnectionString string   `toml:"-"`
   294  	}
   295  
   296  	realtime struct {
   297  		Enabled         bool          `toml:"enabled"`
   298  		IpVersion       AddressFamily `toml:"ip_version"`
   299  		MaxHeaderLength uint          `toml:"max_header_length"`
   300  		TenantId        string        `toml:"-"`
   301  		EncryptionKey   string        `toml:"-"`
   302  		SecretKeyBase   string        `toml:"-"`
   303  	}
   304  
   305  	studio struct {
   306  		Enabled      bool   `toml:"enabled"`
   307  		Port         uint16 `toml:"port"`
   308  		ApiUrl       string `toml:"api_url"`
   309  		OpenaiApiKey string `toml:"openai_api_key"`
   310  	}
   311  
   312  	inbucket struct {
   313  		Enabled  bool   `toml:"enabled"`
   314  		Port     uint16 `toml:"port"`
   315  		SmtpPort uint16 `toml:"smtp_port"`
   316  		Pop3Port uint16 `toml:"pop3_port"`
   317  	}
   318  
   319  	storage struct {
   320  		Enabled             bool                 `toml:"enabled"`
   321  		Image               string               `toml:"-"`
   322  		FileSizeLimit       sizeInBytes          `toml:"file_size_limit"`
   323  		S3Credentials       storageS3Credentials `toml:"-"`
   324  		ImageTransformation imageTransformation  `toml:"image_transformation"`
   325  	}
   326  
   327  	imageTransformation struct {
   328  		Enabled bool `toml:"enabled"`
   329  	}
   330  
   331  	storageS3Credentials struct {
   332  		AccessKeyId     string `toml:"-"`
   333  		SecretAccessKey string `toml:"-"`
   334  		Region          string `toml:"-"`
   335  	}
   336  
   337  	auth struct {
   338  		Enabled                bool     `toml:"enabled"`
   339  		Image                  string   `toml:"-"`
   340  		SiteUrl                string   `toml:"site_url"`
   341  		AdditionalRedirectUrls []string `toml:"additional_redirect_urls"`
   342  
   343  		JwtExpiry                  uint `toml:"jwt_expiry"`
   344  		EnableRefreshTokenRotation bool `toml:"enable_refresh_token_rotation"`
   345  		RefreshTokenReuseInterval  uint `toml:"refresh_token_reuse_interval"`
   346  		EnableManualLinking        bool `toml:"enable_manual_linking"`
   347  		Hook                       hook `toml:"hook"`
   348  
   349  		EnableSignup           bool  `toml:"enable_signup"`
   350  		EnableAnonymousSignIns bool  `toml:"enable_anonymous_sign_ins"`
   351  		Email                  email `toml:"email"`
   352  		Sms                    sms   `toml:"sms"`
   353  		External               map[string]provider
   354  
   355  		// Custom secrets can be injected from .env file
   356  		JwtSecret      string `toml:"-" mapstructure:"jwt_secret"`
   357  		AnonKey        string `toml:"-" mapstructure:"anon_key"`
   358  		ServiceRoleKey string `toml:"-" mapstructure:"service_role_key"`
   359  	}
   360  
   361  	email struct {
   362  		EnableSignup         bool                     `toml:"enable_signup"`
   363  		DoubleConfirmChanges bool                     `toml:"double_confirm_changes"`
   364  		EnableConfirmations  bool                     `toml:"enable_confirmations"`
   365  		Template             map[string]emailTemplate `toml:"template"`
   366  		MaxFrequency         time.Duration            `toml:"max_frequency"`
   367  	}
   368  
   369  	emailTemplate struct {
   370  		Subject     string `toml:"subject"`
   371  		ContentPath string `toml:"content_path"`
   372  	}
   373  
   374  	sms struct {
   375  		EnableSignup        bool              `toml:"enable_signup"`
   376  		EnableConfirmations bool              `toml:"enable_confirmations"`
   377  		Template            string            `toml:"template"`
   378  		Twilio              twilioConfig      `toml:"twilio" mapstructure:"twilio"`
   379  		TwilioVerify        twilioConfig      `toml:"twilio_verify" mapstructure:"twilio_verify"`
   380  		Messagebird         messagebirdConfig `toml:"messagebird" mapstructure:"messagebird"`
   381  		Textlocal           textlocalConfig   `toml:"textlocal" mapstructure:"textlocal"`
   382  		Vonage              vonageConfig      `toml:"vonage" mapstructure:"vonage"`
   383  		TestOTP             map[string]string `toml:"test_otp"`
   384  		MaxFrequency        time.Duration     `toml:"max_frequency"`
   385  	}
   386  
   387  	hook struct {
   388  		MFAVerificationAttempt      hookConfig `toml:"mfa_verification_attempt"`
   389  		PasswordVerificationAttempt hookConfig `toml:"password_verification_attempt"`
   390  		CustomAccessToken           hookConfig `toml:"custom_access_token"`
   391  		SendSMS                     hookConfig `toml:"send_sms"`
   392  		SendEmail                   hookConfig `toml:"send_email"`
   393  	}
   394  
   395  	hookConfig struct {
   396  		Enabled bool   `toml:"enabled"`
   397  		URI     string `toml:"uri"`
   398  		Secrets string `toml:"secrets"`
   399  	}
   400  
   401  	twilioConfig struct {
   402  		Enabled           bool   `toml:"enabled"`
   403  		AccountSid        string `toml:"account_sid"`
   404  		MessageServiceSid string `toml:"message_service_sid"`
   405  		AuthToken         string `toml:"auth_token" mapstructure:"auth_token"`
   406  	}
   407  
   408  	messagebirdConfig struct {
   409  		Enabled    bool   `toml:"enabled"`
   410  		Originator string `toml:"originator"`
   411  		AccessKey  string `toml:"access_key" mapstructure:"access_key"`
   412  	}
   413  
   414  	textlocalConfig struct {
   415  		Enabled bool   `toml:"enabled"`
   416  		Sender  string `toml:"sender"`
   417  		ApiKey  string `toml:"api_key" mapstructure:"api_key"`
   418  	}
   419  
   420  	vonageConfig struct {
   421  		Enabled   bool   `toml:"enabled"`
   422  		From      string `toml:"from"`
   423  		ApiKey    string `toml:"api_key" mapstructure:"api_key"`
   424  		ApiSecret string `toml:"api_secret" mapstructure:"api_secret"`
   425  	}
   426  
   427  	provider struct {
   428  		Enabled        bool   `toml:"enabled"`
   429  		ClientId       string `toml:"client_id"`
   430  		Secret         string `toml:"secret"`
   431  		Url            string `toml:"url"`
   432  		RedirectUri    string `toml:"redirect_uri"`
   433  		SkipNonceCheck bool   `toml:"skip_nonce_check"`
   434  	}
   435  
   436  	function struct {
   437  		VerifyJWT *bool  `toml:"verify_jwt"`
   438  		ImportMap string `toml:"import_map"`
   439  	}
   440  
   441  	analytics struct {
   442  		Enabled          bool            `toml:"enabled"`
   443  		Port             uint16          `toml:"port"`
   444  		Backend          LogflareBackend `toml:"backend"`
   445  		VectorPort       uint16          `toml:"vector_port"`
   446  		GcpProjectId     string          `toml:"gcp_project_id"`
   447  		GcpProjectNumber string          `toml:"gcp_project_number"`
   448  		GcpJwtPath       string          `toml:"gcp_jwt_path"`
   449  		ApiKey           string          `toml:"-" mapstructure:"api_key"`
   450  	}
   451  
   452  	experimental struct {
   453  		OrioleDBVersion string `toml:"orioledb_version"`
   454  		S3Host          string `toml:"s3_host"`
   455  		S3Region        string `toml:"s3_region"`
   456  		S3AccessKey     string `toml:"s3_access_key"`
   457  		S3SecretKey     string `toml:"s3_secret_key"`
   458  	}
   459  
   460  	// TODO
   461  	// scripts struct {
   462  	// 	BeforeMigrations string `toml:"before_migrations"`
   463  	// 	AfterMigrations  string `toml:"after_migrations"`
   464  	// }
   465  )
   466  
   467  func (h *hookConfig) HandleHook(hookType string) error {
   468  	// If not enabled do nothing
   469  	if !h.Enabled {
   470  		return nil
   471  	}
   472  	if h.URI == "" {
   473  		return errors.Errorf("missing required field in config: auth.hook.%s.uri", hookType)
   474  	}
   475  	if err := validateHookURI(h.URI, hookType); err != nil {
   476  		return err
   477  	}
   478  	var err error
   479  	if h.Secrets, err = maybeLoadEnv(h.Secrets); err != nil {
   480  		return errors.Errorf("missing required field in config: auth.hook.%s.secrets", hookType)
   481  	}
   482  	return nil
   483  }
   484  
   485  func LoadConfigFS(fsys afero.Fs) error {
   486  	// Load default values
   487  	var buf bytes.Buffer
   488  	if err := initConfigTemplate.Execute(&buf, nil); err != nil {
   489  		return errors.Errorf("failed to initialise config template: %w", err)
   490  	}
   491  	dec := toml.NewDecoder(&buf)
   492  	if _, err := dec.Decode(&Config); err != nil {
   493  		return errors.Errorf("failed to decode config template: %w", err)
   494  	}
   495  	// Load user defined config
   496  	if metadata, err := toml.DecodeFS(afero.NewIOFS(fsys), ConfigPath, &Config); err != nil {
   497  		CmdSuggestion = fmt.Sprintf("Have you set up the project with %s?", Aqua("supabase init"))
   498  		cwd, osErr := os.Getwd()
   499  		if osErr != nil {
   500  			cwd = "current directory"
   501  		}
   502  		return errors.Errorf("cannot read config in %s: %w", Bold(cwd), err)
   503  	} else if undecoded := metadata.Undecoded(); len(undecoded) > 0 {
   504  		fmt.Fprintf(os.Stderr, "Unknown config fields: %+v\n", undecoded)
   505  	}
   506  	// Load secrets from .env file
   507  	if err := loadDefaultEnv(); err != nil {
   508  		return err
   509  	}
   510  	if err := viper.Unmarshal(&Config); err != nil {
   511  		return errors.Errorf("failed to parse env to config: %w", err)
   512  	}
   513  
   514  	// Generate JWT tokens
   515  	if len(Config.Auth.AnonKey) == 0 {
   516  		anonToken := CustomClaims{Role: "anon"}.NewToken()
   517  		if signed, err := anonToken.SignedString([]byte(Config.Auth.JwtSecret)); err != nil {
   518  			return errors.Errorf("failed to generate anon key: %w", err)
   519  		} else {
   520  			Config.Auth.AnonKey = signed
   521  		}
   522  	}
   523  	if len(Config.Auth.ServiceRoleKey) == 0 {
   524  		anonToken := CustomClaims{Role: "service_role"}.NewToken()
   525  		if signed, err := anonToken.SignedString([]byte(Config.Auth.JwtSecret)); err != nil {
   526  			return errors.Errorf("failed to generate service_role key: %w", err)
   527  		} else {
   528  			Config.Auth.ServiceRoleKey = signed
   529  		}
   530  	}
   531  
   532  	// Process decoded TOML.
   533  	{
   534  		if Config.ProjectId == "" {
   535  			return errors.New("Missing required field in config: project_id")
   536  		}
   537  		Config.Hostname = GetHostname()
   538  		UpdateDockerIds()
   539  		// Validate api config
   540  		if Config.Api.Port == 0 {
   541  			return errors.New("Missing required field in config: api.port")
   542  		}
   543  		if Config.Api.Enabled {
   544  			if version, err := afero.ReadFile(fsys, RestVersionPath); err == nil && len(version) > 0 && Config.Db.MajorVersion > 14 {
   545  				Config.Api.Image = replaceImageTag(PostgrestImage, string(version))
   546  			}
   547  		}
   548  		// Append required schemas if they are missing
   549  		Config.Api.Schemas = RemoveDuplicates(append([]string{"public", "storage"}, Config.Api.Schemas...))
   550  		Config.Api.ExtraSearchPath = RemoveDuplicates(append([]string{"public"}, Config.Api.ExtraSearchPath...))
   551  		// Validate db config
   552  		if Config.Db.Port == 0 {
   553  			return errors.New("Missing required field in config: db.port")
   554  		}
   555  		switch Config.Db.MajorVersion {
   556  		case 0:
   557  			return errors.New("Missing required field in config: db.major_version")
   558  		case 12:
   559  			return errors.New("Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.")
   560  		case 13:
   561  			Config.Db.Image = Pg13Image
   562  			InitialSchemaSql = InitialSchemaPg13Sql
   563  		case 14:
   564  			Config.Db.Image = Pg14Image
   565  			InitialSchemaSql = InitialSchemaPg14Sql
   566  		case 15:
   567  			if len(Config.Experimental.OrioleDBVersion) > 0 {
   568  				Config.Db.Image = "supabase/postgres:orioledb-" + Config.Experimental.OrioleDBVersion
   569  				var err error
   570  				if Config.Experimental.S3Host, err = maybeLoadEnv(Config.Experimental.S3Host); err != nil {
   571  					return err
   572  				}
   573  				if Config.Experimental.S3Region, err = maybeLoadEnv(Config.Experimental.S3Region); err != nil {
   574  					return err
   575  				}
   576  				if Config.Experimental.S3AccessKey, err = maybeLoadEnv(Config.Experimental.S3AccessKey); err != nil {
   577  					return err
   578  				}
   579  				if Config.Experimental.S3SecretKey, err = maybeLoadEnv(Config.Experimental.S3SecretKey); err != nil {
   580  					return err
   581  				}
   582  			} else if version, err := afero.ReadFile(fsys, PostgresVersionPath); err == nil {
   583  				if strings.HasPrefix(string(version), "15.") && semver.Compare(string(version[3:]), "1.0.55") >= 0 {
   584  					Config.Db.Image = replaceImageTag(Pg15Image, string(version))
   585  				}
   586  			}
   587  		default:
   588  			return errors.Errorf("Failed reading config: Invalid %s: %v.", Aqua("db.major_version"), Config.Db.MajorVersion)
   589  		}
   590  		// Validate pooler config
   591  		if Config.Db.Pooler.Enabled {
   592  			allowed := []PoolMode{TransactionMode, SessionMode}
   593  			if !SliceContains(allowed, Config.Db.Pooler.PoolMode) {
   594  				return errors.Errorf("Invalid config for db.pooler.pool_mode. Must be one of: %v", allowed)
   595  			}
   596  		}
   597  		if connString, err := afero.ReadFile(fsys, PoolerUrlPath); err == nil && len(connString) > 0 {
   598  			Config.Db.Pooler.ConnectionString = string(connString)
   599  		}
   600  		// Validate realtime config
   601  		if Config.Realtime.Enabled {
   602  			allowed := []AddressFamily{AddressIPv6, AddressIPv4}
   603  			if !SliceContains(allowed, Config.Realtime.IpVersion) {
   604  				return errors.Errorf("Invalid config for realtime.ip_version. Must be one of: %v", allowed)
   605  			}
   606  		}
   607  		// Validate storage config
   608  		if Config.Storage.Enabled {
   609  			if version, err := afero.ReadFile(fsys, StorageVersionPath); err == nil && len(version) > 0 && Config.Db.MajorVersion > 14 {
   610  				Config.Storage.Image = replaceImageTag(StorageImage, string(version))
   611  			}
   612  		}
   613  		// Validate studio config
   614  		if Config.Studio.Enabled {
   615  			if Config.Studio.Port == 0 {
   616  				return errors.New("Missing required field in config: studio.port")
   617  			}
   618  			Config.Studio.OpenaiApiKey, _ = maybeLoadEnv(Config.Studio.OpenaiApiKey)
   619  		}
   620  		// Validate email config
   621  		if Config.Inbucket.Enabled {
   622  			if Config.Inbucket.Port == 0 {
   623  				return errors.New("Missing required field in config: inbucket.port")
   624  			}
   625  		}
   626  
   627  		// Validate auth config
   628  		if Config.Auth.Enabled {
   629  			if Config.Auth.SiteUrl == "" {
   630  				return errors.New("Missing required field in config: auth.site_url")
   631  			}
   632  			if version, err := afero.ReadFile(fsys, GotrueVersionPath); err == nil && len(version) > 0 && Config.Db.MajorVersion > 14 {
   633  				Config.Auth.Image = replaceImageTag(GotrueImage, string(version))
   634  			}
   635  			// Validate email template
   636  			for _, tmpl := range Config.Auth.Email.Template {
   637  				if len(tmpl.ContentPath) > 0 {
   638  					if _, err := fsys.Stat(tmpl.ContentPath); err != nil {
   639  						return errors.Errorf("failed to read file info: %w", err)
   640  					}
   641  				}
   642  			}
   643  			// Validate sms config
   644  			var err error
   645  			if Config.Auth.Sms.Twilio.Enabled {
   646  				if len(Config.Auth.Sms.Twilio.AccountSid) == 0 {
   647  					return errors.New("Missing required field in config: auth.sms.twilio.account_sid")
   648  				}
   649  				if len(Config.Auth.Sms.Twilio.MessageServiceSid) == 0 {
   650  					return errors.New("Missing required field in config: auth.sms.twilio.message_service_sid")
   651  				}
   652  				if len(Config.Auth.Sms.Twilio.AuthToken) == 0 {
   653  					return errors.New("Missing required field in config: auth.sms.twilio.auth_token")
   654  				}
   655  				if Config.Auth.Sms.Twilio.AuthToken, err = maybeLoadEnv(Config.Auth.Sms.Twilio.AuthToken); err != nil {
   656  					return err
   657  				}
   658  			}
   659  			if Config.Auth.Sms.TwilioVerify.Enabled {
   660  				if len(Config.Auth.Sms.TwilioVerify.AccountSid) == 0 {
   661  					return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid")
   662  				}
   663  				if len(Config.Auth.Sms.TwilioVerify.MessageServiceSid) == 0 {
   664  					return errors.New("Missing required field in config: auth.sms.twilio_verify.message_service_sid")
   665  				}
   666  				if len(Config.Auth.Sms.TwilioVerify.AuthToken) == 0 {
   667  					return errors.New("Missing required field in config: auth.sms.twilio_verify.auth_token")
   668  				}
   669  				if Config.Auth.Sms.TwilioVerify.AuthToken, err = maybeLoadEnv(Config.Auth.Sms.TwilioVerify.AuthToken); err != nil {
   670  					return err
   671  				}
   672  			}
   673  			if Config.Auth.Sms.Messagebird.Enabled {
   674  				if len(Config.Auth.Sms.Messagebird.Originator) == 0 {
   675  					return errors.New("Missing required field in config: auth.sms.messagebird.originator")
   676  				}
   677  				if len(Config.Auth.Sms.Messagebird.AccessKey) == 0 {
   678  					return errors.New("Missing required field in config: auth.sms.messagebird.access_key")
   679  				}
   680  				if Config.Auth.Sms.Messagebird.AccessKey, err = maybeLoadEnv(Config.Auth.Sms.Messagebird.AccessKey); err != nil {
   681  					return err
   682  				}
   683  			}
   684  			if Config.Auth.Sms.Textlocal.Enabled {
   685  				if len(Config.Auth.Sms.Textlocal.Sender) == 0 {
   686  					return errors.New("Missing required field in config: auth.sms.textlocal.sender")
   687  				}
   688  				if len(Config.Auth.Sms.Textlocal.ApiKey) == 0 {
   689  					return errors.New("Missing required field in config: auth.sms.textlocal.api_key")
   690  				}
   691  				if Config.Auth.Sms.Textlocal.ApiKey, err = maybeLoadEnv(Config.Auth.Sms.Textlocal.ApiKey); err != nil {
   692  					return err
   693  				}
   694  			}
   695  			if Config.Auth.Sms.Vonage.Enabled {
   696  				if len(Config.Auth.Sms.Vonage.From) == 0 {
   697  					return errors.New("Missing required field in config: auth.sms.vonage.from")
   698  				}
   699  				if len(Config.Auth.Sms.Vonage.ApiKey) == 0 {
   700  					return errors.New("Missing required field in config: auth.sms.vonage.api_key")
   701  				}
   702  				if len(Config.Auth.Sms.Vonage.ApiSecret) == 0 {
   703  					return errors.New("Missing required field in config: auth.sms.vonage.api_secret")
   704  				}
   705  				if Config.Auth.Sms.Vonage.ApiKey, err = maybeLoadEnv(Config.Auth.Sms.Vonage.ApiKey); err != nil {
   706  					return err
   707  				}
   708  				if Config.Auth.Sms.Vonage.ApiSecret, err = maybeLoadEnv(Config.Auth.Sms.Vonage.ApiSecret); err != nil {
   709  					return err
   710  				}
   711  			}
   712  			if err := Config.Auth.Hook.MFAVerificationAttempt.HandleHook("mfa_verification_attempt"); err != nil {
   713  				return err
   714  			}
   715  			if err := Config.Auth.Hook.PasswordVerificationAttempt.HandleHook("password_verification_attempt"); err != nil {
   716  				return err
   717  			}
   718  			if err := Config.Auth.Hook.CustomAccessToken.HandleHook("custom_access_token"); err != nil {
   719  				return err
   720  			}
   721  			if err := Config.Auth.Hook.SendSMS.HandleHook("send_sms"); err != nil {
   722  				return err
   723  			}
   724  			if err := Config.Auth.Hook.SendEmail.HandleHook("send_email"); err != nil {
   725  				return err
   726  			}
   727  			// Validate oauth config
   728  			for ext, provider := range Config.Auth.External {
   729  				if !provider.Enabled {
   730  					continue
   731  				}
   732  				if provider.ClientId == "" {
   733  					return errors.Errorf("Missing required field in config: auth.external.%s.client_id", ext)
   734  				}
   735  				if !SliceContains([]string{"apple", "google"}, ext) && provider.Secret == "" {
   736  					return errors.Errorf("Missing required field in config: auth.external.%s.secret", ext)
   737  				}
   738  				if provider.ClientId, err = maybeLoadEnv(provider.ClientId); err != nil {
   739  					return err
   740  				}
   741  				if provider.Secret, err = maybeLoadEnv(provider.Secret); err != nil {
   742  					return err
   743  				}
   744  				if provider.RedirectUri, err = maybeLoadEnv(provider.RedirectUri); err != nil {
   745  					return err
   746  				}
   747  				if provider.Url, err = maybeLoadEnv(provider.Url); err != nil {
   748  					return err
   749  				}
   750  				Config.Auth.External[ext] = provider
   751  			}
   752  		}
   753  	}
   754  	// Validate functions config
   755  	for name, functionConfig := range Config.Functions {
   756  		if functionConfig.VerifyJWT == nil {
   757  			verifyJWT := true
   758  			functionConfig.VerifyJWT = &verifyJWT
   759  			Config.Functions[name] = functionConfig
   760  		}
   761  	}
   762  	// Validate logflare config
   763  	if Config.Analytics.Enabled {
   764  		switch Config.Analytics.Backend {
   765  		case LogflareBigQuery:
   766  			if len(Config.Analytics.GcpProjectId) == 0 {
   767  				return errors.New("Missing required field in config: analytics.gcp_project_id")
   768  			}
   769  			if len(Config.Analytics.GcpProjectNumber) == 0 {
   770  				return errors.New("Missing required field in config: analytics.gcp_project_number")
   771  			}
   772  			if len(Config.Analytics.GcpJwtPath) == 0 {
   773  				return errors.New("Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path")
   774  			}
   775  		case LogflarePostgres:
   776  			break
   777  		default:
   778  			allowed := []LogflareBackend{LogflarePostgres, LogflareBigQuery}
   779  			return errors.Errorf("Invalid config for analytics.backend. Must be one of: %v", allowed)
   780  		}
   781  	}
   782  	return nil
   783  }
   784  
   785  func maybeLoadEnv(s string) (string, error) {
   786  	matches := envPattern.FindStringSubmatch(s)
   787  	if len(matches) == 0 {
   788  		return s, nil
   789  	}
   790  
   791  	envName := matches[1]
   792  	if value := os.Getenv(envName); value != "" {
   793  		return value, nil
   794  	}
   795  
   796  	return "", errors.Errorf(`Error evaluating "%s": environment variable %s is unset.`, s, envName)
   797  }
   798  
   799  func sanitizeProjectId(src string) string {
   800  	// A valid project ID must only contain alphanumeric and special characters _.-
   801  	sanitized := invalidProjectId.ReplaceAllString(src, "_")
   802  	// It must also start with an alphanumeric character
   803  	return strings.TrimLeft(sanitized, "_.-")
   804  }
   805  
   806  type InitParams struct {
   807  	ProjectId   string
   808  	UseOrioleDB bool
   809  	Overwrite   bool
   810  }
   811  
   812  func InitConfig(params InitParams, fsys afero.Fs) error {
   813  	// Defaults to current directory name as project id
   814  	if len(params.ProjectId) == 0 {
   815  		cwd, err := os.Getwd()
   816  		if err != nil {
   817  			return errors.Errorf("failed to get working directory: %w", err)
   818  		}
   819  		params.ProjectId = filepath.Base(cwd)
   820  	}
   821  	params.ProjectId = sanitizeProjectId(params.ProjectId)
   822  	// Create config file
   823  	if err := MkdirIfNotExistFS(fsys, filepath.Dir(ConfigPath)); err != nil {
   824  		return err
   825  	}
   826  	flag := os.O_WRONLY | os.O_CREATE
   827  	if params.Overwrite {
   828  		flag |= os.O_TRUNC
   829  	} else {
   830  		flag |= os.O_EXCL
   831  	}
   832  	f, err := fsys.OpenFile(ConfigPath, flag, 0644)
   833  	if err != nil {
   834  		return errors.Errorf("failed to create config file: %w", err)
   835  	}
   836  	defer f.Close()
   837  	// Update from template
   838  	if err := initConfigTemplate.Execute(f, params); err != nil {
   839  		return errors.Errorf("failed to initialise config: %w", err)
   840  	}
   841  	return nil
   842  }
   843  
   844  func WriteConfig(fsys afero.Fs, _test bool) error {
   845  	return InitConfig(InitParams{}, fsys)
   846  }
   847  
   848  func RemoveDuplicates(slice []string) (result []string) {
   849  	set := make(map[string]struct{})
   850  	for _, item := range slice {
   851  		if _, exists := set[item]; !exists {
   852  			set[item] = struct{}{}
   853  			result = append(result, item)
   854  		}
   855  	}
   856  	return result
   857  }
   858  
   859  func loadDefaultEnv() error {
   860  	env := viper.GetString("ENV")
   861  	if env == "" {
   862  		env = "development"
   863  	}
   864  	filenames := []string{".env." + env + ".local"}
   865  	if env != "test" {
   866  		filenames = append(filenames, ".env.local")
   867  	}
   868  	filenames = append(filenames, ".env."+env, ".env")
   869  	for _, path := range filenames {
   870  		if err := loadEnvIfExists(path); err != nil {
   871  			return err
   872  		}
   873  	}
   874  	return nil
   875  }
   876  
   877  func loadEnvIfExists(path string) error {
   878  	if err := godotenv.Load(path); err != nil && !errors.Is(err, os.ErrNotExist) {
   879  		return errors.Errorf("failed to load %s: %w", Bold(".env"), err)
   880  	}
   881  	return nil
   882  }
   883  
   884  func validateHookURI(uri, hookName string) error {
   885  	parsed, err := url.Parse(uri)
   886  	if err != nil {
   887  		return errors.Errorf("failed to parse template url: %w", err)
   888  	}
   889  	if !(parsed.Scheme == "http" || parsed.Scheme == "https" || parsed.Scheme == "pg-functions") {
   890  		return errors.Errorf("Invalid HTTP hook config: auth.hook.%v should be a Postgres function URI, or a HTTP or HTTPS URL", hookName)
   891  	}
   892  	return nil
   893  }