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 }