• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / cli / 8998328342

08 May 2024 07:59AM UTC coverage: 57.396% (-0.07%) from 57.468%
8998328342

Pull #2129

github

J0
fix: add send sms hook as test
Pull Request #2129: feat: add HTTP hook Secret Configuration

26 of 56 new or added lines in 2 files covered. (46.43%)

10 existing lines in 3 files now uncovered.

6402 of 11154 relevant lines covered (57.4%)

656.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.0
/internal/utils/config.go
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 {
663✔
73
        return "supabase_" + name + "_" + Config.ProjectId
663✔
74
}
663✔
75

76
func UpdateDockerIds() {
39✔
77
        NetId = GetId("network")
39✔
78
        DbId = GetId(DbAliases[0])
39✔
79
        ConfigId = GetId("config")
39✔
80
        KongId = GetId(KongAliases[0])
39✔
81
        GotrueId = GetId(GotrueAliases[0])
39✔
82
        InbucketId = GetId(InbucketAliases[0])
39✔
83
        RealtimeId = GetId(RealtimeAliases[0])
39✔
84
        RestId = GetId(RestAliases[0])
39✔
85
        StorageId = GetId(StorageAliases[0])
39✔
86
        ImgProxyId = GetId(ImgProxyAliases[0])
39✔
87
        DifferId = GetId("differ")
39✔
88
        PgmetaId = GetId(PgmetaAliases[0])
39✔
89
        StudioId = GetId(StudioAliases[0])
39✔
90
        EdgeRuntimeId = GetId(EdgeRuntimeAliases[0])
39✔
91
        LogflareId = GetId(LogflareAliases[0])
39✔
92
        VectorId = GetId(VectorAliases[0])
39✔
93
        PoolerId = GetId(PoolerAliases[0])
39✔
94
}
39✔
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 {
95✔
100
        size, err := units.RAMInBytes(string(text))
95✔
101
        if err == nil {
189✔
102
                *s = sizeInBytes(size)
94✔
103
        }
94✔
104
        return err
95✔
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 {
9✔
133
        if addr == AddressIPv6 {
9✔
134
                return "-proto_dist inet6_tcp"
×
135
        }
×
136
        return "-proto_dist inet_tcp"
9✔
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 {
30✔
153
        if c.ExpiresAt == nil {
60✔
154
                c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0))
30✔
155
        }
30✔
156
        if len(c.Issuer) == 0 {
60✔
157
                c.Issuer = "supabase-demo"
30✔
158
        }
30✔
159
        return jwt.NewWithClaims(jwt.SigningMethodHS256, c)
30✔
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 {
185✔
468
        // If not enabled do nothing
185✔
469
        if !h.Enabled {
368✔
470
                return nil
183✔
471
        }
183✔
472
        if h.URI == "" {
2✔
NEW
473
                return errors.Errorf("missing required field in config: auth.hook.%s.uri", hookType)
×
NEW
474
        }
×
475
        if err := validateHookURI(h.URI, hookType); err != nil {
2✔
NEW
476
                return err
×
NEW
477
        }
×
478
        var err error
2✔
479
        if h.Secrets, err = maybeLoadEnv(h.Secrets); err != nil {
2✔
NEW
480
                return errors.Errorf("missing required field in config: auth.hook.%s.secrets", hookType)
×
NEW
481
        }
×
482
        return nil
2✔
483
}
484

485
func LoadConfigFS(fsys afero.Fs) error {
52✔
486
        // Load default values
52✔
487
        var buf bytes.Buffer
52✔
488
        if err := initConfigTemplate.Execute(&buf, nil); err != nil {
52✔
489
                return errors.Errorf("failed to initialise config template: %w", err)
×
490
        }
×
491
        dec := toml.NewDecoder(&buf)
52✔
492
        if _, err := dec.Decode(&Config); err != nil {
52✔
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 {
66✔
497
                CmdSuggestion = fmt.Sprintf("Have you set up the project with %s?", Aqua("supabase init"))
14✔
498
                cwd, osErr := os.Getwd()
14✔
499
                if osErr != nil {
14✔
500
                        cwd = "current directory"
×
501
                }
×
502
                return errors.Errorf("cannot read config in %s: %w", Bold(cwd), err)
14✔
503
        } else if undecoded := metadata.Undecoded(); len(undecoded) > 0 {
38✔
504
                fmt.Fprintf(os.Stderr, "Unknown config fields: %+v\n", undecoded)
×
505
        }
×
506
        // Load secrets from .env file
507
        if err := loadDefaultEnv(); err != nil {
38✔
508
                return err
×
509
        }
×
510
        if err := viper.Unmarshal(&Config); err != nil {
38✔
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 {
52✔
516
                anonToken := CustomClaims{Role: "anon"}.NewToken()
14✔
517
                if signed, err := anonToken.SignedString([]byte(Config.Auth.JwtSecret)); err != nil {
14✔
518
                        return errors.Errorf("failed to generate anon key: %w", err)
×
519
                } else {
14✔
520
                        Config.Auth.AnonKey = signed
14✔
521
                }
14✔
522
        }
523
        if len(Config.Auth.ServiceRoleKey) == 0 {
52✔
524
                anonToken := CustomClaims{Role: "service_role"}.NewToken()
14✔
525
                if signed, err := anonToken.SignedString([]byte(Config.Auth.JwtSecret)); err != nil {
14✔
526
                        return errors.Errorf("failed to generate service_role key: %w", err)
×
527
                } else {
14✔
528
                        Config.Auth.ServiceRoleKey = signed
14✔
529
                }
14✔
530
        }
531

532
        // Process decoded TOML.
533
        {
38✔
534
                if Config.ProjectId == "" {
38✔
535
                        return errors.New("Missing required field in config: project_id")
×
536
                }
×
537
                Config.Hostname = GetHostname()
38✔
538
                UpdateDockerIds()
38✔
539
                // Validate api config
38✔
540
                if Config.Api.Port == 0 {
38✔
541
                        return errors.New("Missing required field in config: api.port")
×
542
                }
×
543
                if Config.Api.Enabled {
76✔
544
                        if version, err := afero.ReadFile(fsys, RestVersionPath); err == nil && len(version) > 0 && Config.Db.MajorVersion > 14 {
38✔
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...))
38✔
550
                Config.Api.ExtraSearchPath = RemoveDuplicates(append([]string{"public"}, Config.Api.ExtraSearchPath...))
38✔
551
                // Validate db config
38✔
552
                if Config.Db.Port == 0 {
38✔
553
                        return errors.New("Missing required field in config: db.port")
×
554
                }
×
555
                switch Config.Db.MajorVersion {
38✔
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:
38✔
567
                        if len(Config.Experimental.OrioleDBVersion) > 0 {
40✔
568
                                Config.Db.Image = "supabase/postgres:orioledb-" + Config.Experimental.OrioleDBVersion
2✔
569
                                var err error
2✔
570
                                if Config.Experimental.S3Host, err = maybeLoadEnv(Config.Experimental.S3Host); err != nil {
2✔
571
                                        return err
×
572
                                }
×
573
                                if Config.Experimental.S3Region, err = maybeLoadEnv(Config.Experimental.S3Region); err != nil {
2✔
574
                                        return err
×
575
                                }
×
576
                                if Config.Experimental.S3AccessKey, err = maybeLoadEnv(Config.Experimental.S3AccessKey); err != nil {
2✔
577
                                        return err
×
578
                                }
×
579
                                if Config.Experimental.S3SecretKey, err = maybeLoadEnv(Config.Experimental.S3SecretKey); err != nil {
2✔
580
                                        return err
×
581
                                }
×
582
                        } else if version, err := afero.ReadFile(fsys, PostgresVersionPath); err == nil {
36✔
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 {
40✔
592
                        allowed := []PoolMode{TransactionMode, SessionMode}
2✔
593
                        if !SliceContains(allowed, Config.Db.Pooler.PoolMode) {
2✔
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 {
38✔
598
                        Config.Db.Pooler.ConnectionString = string(connString)
×
599
                }
×
600
                // Validate realtime config
601
                if Config.Realtime.Enabled {
76✔
602
                        allowed := []AddressFamily{AddressIPv6, AddressIPv4}
38✔
603
                        if !SliceContains(allowed, Config.Realtime.IpVersion) {
38✔
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 {
76✔
609
                        if version, err := afero.ReadFile(fsys, StorageVersionPath); err == nil && len(version) > 0 && Config.Db.MajorVersion > 14 {
38✔
610
                                Config.Storage.Image = replaceImageTag(StorageImage, string(version))
×
611
                        }
×
612
                }
613
                // Validate studio config
614
                if Config.Studio.Enabled {
76✔
615
                        if Config.Studio.Port == 0 {
38✔
616
                                return errors.New("Missing required field in config: studio.port")
×
617
                        }
×
618
                        Config.Studio.OpenaiApiKey, _ = maybeLoadEnv(Config.Studio.OpenaiApiKey)
38✔
619
                }
620
                // Validate email config
621
                if Config.Inbucket.Enabled {
76✔
622
                        if Config.Inbucket.Port == 0 {
38✔
623
                                return errors.New("Missing required field in config: inbucket.port")
×
624
                        }
×
625
                }
626

627
                // Validate auth config
628
                if Config.Auth.Enabled {
76✔
629
                        if Config.Auth.SiteUrl == "" {
38✔
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 {
38✔
633
                                Config.Auth.Image = replaceImageTag(GotrueImage, string(version))
×
634
                        }
×
635
                        // Validate email template
636
                        for _, tmpl := range Config.Auth.Email.Template {
224✔
637
                                if len(tmpl.ContentPath) > 0 {
188✔
638
                                        if _, err := fsys.Stat(tmpl.ContentPath); err != nil {
3✔
639
                                                return errors.Errorf("failed to read file info: %w", err)
1✔
640
                                        }
1✔
641
                                }
642
                        }
643
                        // Validate sms config
644
                        var err error
37✔
645
                        if Config.Auth.Sms.Twilio.Enabled {
38✔
646
                                if len(Config.Auth.Sms.Twilio.AccountSid) == 0 {
1✔
647
                                        return errors.New("Missing required field in config: auth.sms.twilio.account_sid")
×
648
                                }
×
649
                                if len(Config.Auth.Sms.Twilio.MessageServiceSid) == 0 {
1✔
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 {
1✔
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 {
1✔
656
                                        return err
×
657
                                }
×
658
                        }
659
                        if Config.Auth.Sms.TwilioVerify.Enabled {
37✔
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 {
37✔
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 {
37✔
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 {
37✔
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 {
37✔
NEW
713
                                return err
×
UNCOV
714
                        }
×
715
                        if err := Config.Auth.Hook.PasswordVerificationAttempt.HandleHook("password_verification_attempt"); err != nil {
37✔
NEW
716
                                return err
×
UNCOV
717
                        }
×
718
                        if err := Config.Auth.Hook.CustomAccessToken.HandleHook("custom_access_token"); err != nil {
37✔
NEW
719
                                return err
×
NEW
720
                        }
×
721
                        if err := Config.Auth.Hook.SendSMS.HandleHook("send_sms"); err != nil {
37✔
NEW
722
                                return err
×
NEW
723
                        }
×
724
                        if err := Config.Auth.Hook.SendEmail.HandleHook("send_email"); err != nil {
37✔
NEW
725
                                return err
×
UNCOV
726
                        }
×
727
                        // Validate oauth config
728
                        for ext, provider := range Config.Auth.External {
703✔
729
                                if !provider.Enabled {
1,331✔
730
                                        continue
665✔
731
                                }
732
                                if provider.ClientId == "" {
1✔
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 == "" {
1✔
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 {
1✔
739
                                        return err
×
740
                                }
×
741
                                if provider.Secret, err = maybeLoadEnv(provider.Secret); err != nil {
1✔
742
                                        return err
×
743
                                }
×
744
                                if provider.RedirectUri, err = maybeLoadEnv(provider.RedirectUri); err != nil {
1✔
745
                                        return err
×
746
                                }
×
747
                                if provider.Url, err = maybeLoadEnv(provider.Url); err != nil {
1✔
748
                                        return err
×
749
                                }
×
750
                                Config.Auth.External[ext] = provider
1✔
751
                        }
752
                }
753
        }
754
        // Validate functions config
755
        for name, functionConfig := range Config.Functions {
39✔
756
                if functionConfig.VerifyJWT == nil {
2✔
757
                        verifyJWT := true
×
758
                        functionConfig.VerifyJWT = &verifyJWT
×
759
                        Config.Functions[name] = functionConfig
×
760
                }
×
761
        }
762
        // Validate logflare config
763
        if Config.Analytics.Enabled {
37✔
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
37✔
783
}
784

785
func maybeLoadEnv(s string) (string, error) {
53✔
786
        matches := envPattern.FindStringSubmatch(s)
53✔
787
        if len(matches) == 0 {
64✔
788
                return s, nil
11✔
789
        }
11✔
790

791
        envName := matches[1]
42✔
792
        if value := os.Getenv(envName); value != "" {
46✔
793
                return value, nil
4✔
794
        }
4✔
795

796
        return "", errors.Errorf(`Error evaluating "%s": environment variable %s is unset.`, s, envName)
38✔
797
}
798

799
func sanitizeProjectId(src string) string {
63✔
800
        // A valid project ID must only contain alphanumeric and special characters _.-
63✔
801
        sanitized := invalidProjectId.ReplaceAllString(src, "_")
63✔
802
        // It must also start with an alphanumeric character
63✔
803
        return strings.TrimLeft(sanitized, "_.-")
63✔
804
}
63✔
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 {
58✔
813
        // Defaults to current directory name as project id
58✔
814
        if len(params.ProjectId) == 0 {
112✔
815
                cwd, err := os.Getwd()
54✔
816
                if err != nil {
54✔
817
                        return errors.Errorf("failed to get working directory: %w", err)
×
818
                }
×
819
                params.ProjectId = filepath.Base(cwd)
54✔
820
        }
821
        params.ProjectId = sanitizeProjectId(params.ProjectId)
58✔
822
        // Create config file
58✔
823
        if err := MkdirIfNotExistFS(fsys, filepath.Dir(ConfigPath)); err != nil {
59✔
824
                return err
1✔
825
        }
1✔
826
        flag := os.O_WRONLY | os.O_CREATE
57✔
827
        if params.Overwrite {
57✔
828
                flag |= os.O_TRUNC
×
829
        } else {
57✔
830
                flag |= os.O_EXCL
57✔
831
        }
57✔
832
        f, err := fsys.OpenFile(ConfigPath, flag, 0644)
57✔
833
        if err != nil {
59✔
834
                return errors.Errorf("failed to create config file: %w", err)
2✔
835
        }
2✔
836
        defer f.Close()
55✔
837
        // Update from template
55✔
838
        if err := initConfigTemplate.Execute(f, params); err != nil {
55✔
839
                return errors.Errorf("failed to initialise config: %w", err)
×
840
        }
×
841
        return nil
55✔
842
}
843

844
func WriteConfig(fsys afero.Fs, _test bool) error {
39✔
845
        return InitConfig(InitParams{}, fsys)
39✔
846
}
39✔
847

848
func RemoveDuplicates(slice []string) (result []string) {
82✔
849
        set := make(map[string]struct{})
82✔
850
        for _, item := range slice {
354✔
851
                if _, exists := set[item]; !exists {
468✔
852
                        set[item] = struct{}{}
196✔
853
                        result = append(result, item)
196✔
854
                }
196✔
855
        }
856
        return result
82✔
857
}
858

859
func loadDefaultEnv() error {
38✔
860
        env := viper.GetString("ENV")
38✔
861
        if env == "" {
76✔
862
                env = "development"
38✔
863
        }
38✔
864
        filenames := []string{".env." + env + ".local"}
38✔
865
        if env != "test" {
76✔
866
                filenames = append(filenames, ".env.local")
38✔
867
        }
38✔
868
        filenames = append(filenames, ".env."+env, ".env")
38✔
869
        for _, path := range filenames {
190✔
870
                if err := loadEnvIfExists(path); err != nil {
152✔
871
                        return err
×
872
                }
×
873
        }
874
        return nil
38✔
875
}
876

877
func loadEnvIfExists(path string) error {
152✔
878
        if err := godotenv.Load(path); err != nil && !errors.Is(err, os.ErrNotExist) {
152✔
879
                return errors.Errorf("failed to load %s: %w", Bold(".env"), err)
×
880
        }
×
881
        return nil
152✔
882
}
883

884
func validateHookURI(uri, hookName string) error {
7✔
885
        parsed, err := url.Parse(uri)
7✔
886
        if err != nil {
8✔
887
                return errors.Errorf("failed to parse template url: %w", err)
1✔
888
        }
1✔
889
        if !(parsed.Scheme == "http" || parsed.Scheme == "https" || parsed.Scheme == "pg-functions") {
7✔
890
                return errors.Errorf("Invalid HTTP hook config: auth.hook.%v should be a Postgres function URI, or a HTTP or HTTPS URL", hookName)
1✔
891
        }
1✔
892
        return nil
5✔
893
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc