1383 lines
42 KiB
Go
1383 lines
42 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
"github.com/docker/go-units"
|
|
"github.com/go-errors/errors"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/joho/godotenv"
|
|
"github.com/mitchellh/mapstructure"
|
|
"github.com/spf13/viper"
|
|
"github.com/supabase/cli/pkg/cast"
|
|
"github.com/supabase/cli/pkg/fetcher"
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
// Type for turning human-friendly bytes string ("5MB", "32kB") into an int64 during toml decoding.
|
|
type sizeInBytes int64
|
|
|
|
func (s *sizeInBytes) UnmarshalText(text []byte) error {
|
|
size, err := units.RAMInBytes(string(text))
|
|
if err == nil {
|
|
*s = sizeInBytes(size)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s sizeInBytes) MarshalText() (text []byte, err error) {
|
|
return []byte(units.BytesSize(float64(s))), nil
|
|
}
|
|
|
|
type LogflareBackend string
|
|
|
|
const (
|
|
LogflarePostgres LogflareBackend = "postgres"
|
|
LogflareBigQuery LogflareBackend = "bigquery"
|
|
)
|
|
|
|
func (b *LogflareBackend) UnmarshalText(text []byte) error {
|
|
allowed := []LogflareBackend{LogflarePostgres, LogflareBigQuery}
|
|
if *b = LogflareBackend(text); !sliceContains(allowed, *b) {
|
|
return errors.Errorf("must be one of %v", allowed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type AddressFamily string
|
|
|
|
const (
|
|
AddressIPv6 AddressFamily = "IPv6"
|
|
AddressIPv4 AddressFamily = "IPv4"
|
|
)
|
|
|
|
func (f *AddressFamily) UnmarshalText(text []byte) error {
|
|
allowed := []AddressFamily{AddressIPv6, AddressIPv4}
|
|
if *f = AddressFamily(text); !sliceContains(allowed, *f) {
|
|
return errors.Errorf("must be one of %v", allowed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type RequestPolicy string
|
|
|
|
const (
|
|
PolicyPerWorker RequestPolicy = "per_worker"
|
|
PolicyOneshot RequestPolicy = "oneshot"
|
|
)
|
|
|
|
func (p *RequestPolicy) UnmarshalText(text []byte) error {
|
|
allowed := []RequestPolicy{PolicyPerWorker, PolicyOneshot}
|
|
if *p = RequestPolicy(text); !sliceContains(allowed, *p) {
|
|
return errors.Errorf("must be one of %v", allowed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Glob []string
|
|
|
|
// Match the glob patterns in the given FS to get a deduplicated
|
|
// array of all migrations files to apply in the declared order.
|
|
func (g Glob) Files(fsys fs.FS) ([]string, error) {
|
|
var result []string
|
|
var allErrors []error
|
|
set := make(map[string]struct{})
|
|
for _, pattern := range g {
|
|
// Glob expects / as path separator on windows
|
|
matches, err := fs.Glob(fsys, filepath.ToSlash(pattern))
|
|
if err != nil {
|
|
allErrors = append(allErrors, errors.Errorf("failed to glob files: %w", err))
|
|
} else if len(matches) == 0 {
|
|
allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern))
|
|
}
|
|
sort.Strings(matches)
|
|
// Remove duplicates
|
|
for _, item := range matches {
|
|
if _, exists := set[item]; !exists {
|
|
set[item] = struct{}{}
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
}
|
|
return result, errors.Join(allErrors...)
|
|
}
|
|
|
|
type CustomClaims struct {
|
|
// Overrides Issuer to maintain json order when marshalling
|
|
Issuer string `json:"iss,omitempty"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Role string `json:"role"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
const (
|
|
defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"
|
|
defaultJwtExpiry = 1983812996
|
|
)
|
|
|
|
func (c CustomClaims) NewToken() *jwt.Token {
|
|
if c.ExpiresAt == nil {
|
|
c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0))
|
|
}
|
|
if len(c.Issuer) == 0 {
|
|
c.Issuer = "supabase-demo"
|
|
}
|
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, c)
|
|
}
|
|
|
|
// We follow these rules when adding new config:
|
|
// 1. Update init_config.toml (and init_config.test.toml) with the new key, default value, and comments to explain usage.
|
|
// 2. Update config struct with new field and toml tag (spelled in snake_case).
|
|
// 3. Add custom field validations to LoadConfigFS function for eg. integer range checks.
|
|
//
|
|
// If you are adding new user defined secrets, such as OAuth provider secret, the default value in
|
|
// init_config.toml should be an env var substitution. For example,
|
|
//
|
|
// > secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
|
//
|
|
// If you are adding an internal config or secret that doesn't need to be overridden by the user,
|
|
// exclude the field from toml serialization. For example,
|
|
//
|
|
// type auth struct {
|
|
// AnonKey string `toml:"-" mapstructure:"anon_key"`
|
|
// }
|
|
//
|
|
// Use `mapstructure:"anon_key"` tag only if you want inject values from a predictable environment
|
|
// variable, such as SUPABASE_AUTH_ANON_KEY.
|
|
//
|
|
// Default values for internal configs should be added to `var Config` initializer.
|
|
type (
|
|
// Common config fields between our "base" config and any "remote" branch specific
|
|
baseConfig struct {
|
|
ProjectId string `toml:"project_id"`
|
|
Hostname string `toml:"-"`
|
|
Api api `toml:"api"`
|
|
Db db `toml:"db" mapstructure:"db"`
|
|
Realtime realtime `toml:"realtime"`
|
|
Studio studio `toml:"studio"`
|
|
Inbucket inbucket `toml:"inbucket"`
|
|
Storage storage `toml:"storage"`
|
|
Auth auth `toml:"auth" mapstructure:"auth"`
|
|
EdgeRuntime edgeRuntime `toml:"edge_runtime"`
|
|
Functions FunctionConfig `toml:"functions"`
|
|
Analytics analytics `toml:"analytics"`
|
|
Experimental experimental `toml:"experimental"`
|
|
}
|
|
|
|
config struct {
|
|
baseConfig `mapstructure:",squash"`
|
|
Remotes map[string]baseConfig `toml:"remotes"`
|
|
}
|
|
|
|
realtime struct {
|
|
Enabled bool `toml:"enabled"`
|
|
Image string `toml:"-"`
|
|
IpVersion AddressFamily `toml:"ip_version"`
|
|
MaxHeaderLength uint `toml:"max_header_length"`
|
|
TenantId string `toml:"-"`
|
|
EncryptionKey string `toml:"-"`
|
|
SecretKeyBase string `toml:"-"`
|
|
}
|
|
|
|
studio struct {
|
|
Enabled bool `toml:"enabled"`
|
|
Image string `toml:"-"`
|
|
Port uint16 `toml:"port"`
|
|
ApiUrl string `toml:"api_url"`
|
|
OpenaiApiKey string `toml:"openai_api_key"`
|
|
PgmetaImage string `toml:"-"`
|
|
}
|
|
|
|
inbucket struct {
|
|
Enabled bool `toml:"enabled"`
|
|
Image string `toml:"-"`
|
|
Port uint16 `toml:"port"`
|
|
SmtpPort uint16 `toml:"smtp_port"`
|
|
Pop3Port uint16 `toml:"pop3_port"`
|
|
AdminEmail string `toml:"admin_email"`
|
|
SenderName string `toml:"sender_name"`
|
|
}
|
|
|
|
edgeRuntime struct {
|
|
Enabled bool `toml:"enabled"`
|
|
Image string `toml:"-"`
|
|
Policy RequestPolicy `toml:"policy"`
|
|
InspectorPort uint16 `toml:"inspector_port"`
|
|
}
|
|
|
|
FunctionConfig map[string]function
|
|
|
|
function struct {
|
|
Enabled bool `toml:"enabled" json:"-"`
|
|
VerifyJWT bool `toml:"verify_jwt" json:"verifyJWT"`
|
|
ImportMap string `toml:"import_map" json:"importMapPath,omitempty"`
|
|
Entrypoint string `toml:"entrypoint" json:"entrypointPath,omitempty"`
|
|
StaticFiles Glob `toml:"static_files" json:"staticFiles,omitempty"`
|
|
}
|
|
|
|
analytics struct {
|
|
Enabled bool `toml:"enabled"`
|
|
Image string `toml:"-"`
|
|
VectorImage string `toml:"-"`
|
|
Port uint16 `toml:"port"`
|
|
Backend LogflareBackend `toml:"backend"`
|
|
GcpProjectId string `toml:"gcp_project_id"`
|
|
GcpProjectNumber string `toml:"gcp_project_number"`
|
|
GcpJwtPath string `toml:"gcp_jwt_path"`
|
|
ApiKey string `toml:"-" mapstructure:"api_key"`
|
|
// Deprecated together with syslog
|
|
VectorPort uint16 `toml:"vector_port"`
|
|
}
|
|
|
|
webhooks struct {
|
|
Enabled bool `toml:"enabled"`
|
|
}
|
|
|
|
experimental struct {
|
|
OrioleDBVersion string `toml:"orioledb_version"`
|
|
S3Host string `toml:"s3_host"`
|
|
S3Region string `toml:"s3_region"`
|
|
S3AccessKey string `toml:"s3_access_key"`
|
|
S3SecretKey string `toml:"s3_secret_key"`
|
|
Webhooks *webhooks `toml:"webhooks"`
|
|
}
|
|
)
|
|
|
|
func (a *auth) Clone() auth {
|
|
copy := *a
|
|
if copy.Captcha != nil {
|
|
capt := *a.Captcha
|
|
copy.Captcha = &capt
|
|
}
|
|
copy.External = maps.Clone(a.External)
|
|
if a.Email.Smtp != nil {
|
|
mailer := *a.Email.Smtp
|
|
copy.Email.Smtp = &mailer
|
|
}
|
|
copy.Email.Template = maps.Clone(a.Email.Template)
|
|
if a.Hook.MFAVerificationAttempt != nil {
|
|
hook := *a.Hook.MFAVerificationAttempt
|
|
copy.Hook.MFAVerificationAttempt = &hook
|
|
}
|
|
if a.Hook.PasswordVerificationAttempt != nil {
|
|
hook := *a.Hook.PasswordVerificationAttempt
|
|
copy.Hook.PasswordVerificationAttempt = &hook
|
|
}
|
|
if a.Hook.CustomAccessToken != nil {
|
|
hook := *a.Hook.CustomAccessToken
|
|
copy.Hook.CustomAccessToken = &hook
|
|
}
|
|
if a.Hook.SendSMS != nil {
|
|
hook := *a.Hook.SendSMS
|
|
copy.Hook.SendSMS = &hook
|
|
}
|
|
if a.Hook.SendEmail != nil {
|
|
hook := *a.Hook.SendEmail
|
|
copy.Hook.SendEmail = &hook
|
|
}
|
|
copy.Sms.TestOTP = maps.Clone(a.Sms.TestOTP)
|
|
return copy
|
|
}
|
|
|
|
func (s *storage) Clone() storage {
|
|
copy := *s
|
|
copy.Buckets = maps.Clone(s.Buckets)
|
|
if s.ImageTransformation != nil {
|
|
img := *s.ImageTransformation
|
|
copy.ImageTransformation = &img
|
|
}
|
|
return copy
|
|
}
|
|
|
|
func (c *baseConfig) Clone() baseConfig {
|
|
copy := *c
|
|
copy.Storage = c.Storage.Clone()
|
|
copy.Functions = maps.Clone(c.Functions)
|
|
copy.Auth = c.Auth.Clone()
|
|
if c.Experimental.Webhooks != nil {
|
|
webhooks := *c.Experimental.Webhooks
|
|
copy.Experimental.Webhooks = &webhooks
|
|
}
|
|
return copy
|
|
}
|
|
|
|
type ConfigEditor func(*config)
|
|
|
|
func WithHostname(hostname string) ConfigEditor {
|
|
return func(c *config) {
|
|
c.Hostname = hostname
|
|
}
|
|
}
|
|
|
|
func NewConfig(editors ...ConfigEditor) config {
|
|
initial := config{baseConfig: baseConfig{
|
|
Hostname: "127.0.0.1",
|
|
Api: api{
|
|
Image: Images.Postgrest,
|
|
KongImage: Images.Kong,
|
|
},
|
|
Db: db{
|
|
Image: Images.Pg15,
|
|
Password: "postgres",
|
|
RootKey: "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275",
|
|
Pooler: pooler{
|
|
Image: Images.Supavisor,
|
|
TenantId: "pooler-dev",
|
|
EncryptionKey: "12345678901234567890123456789032",
|
|
SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG",
|
|
},
|
|
Seed: seed{
|
|
Enabled: true,
|
|
SqlPaths: []string{"seed.sql"},
|
|
},
|
|
},
|
|
Realtime: realtime{
|
|
Image: Images.Realtime,
|
|
IpVersion: AddressIPv4,
|
|
MaxHeaderLength: 4096,
|
|
TenantId: "realtime-dev",
|
|
EncryptionKey: "supabaserealtime",
|
|
SecretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG",
|
|
},
|
|
Storage: storage{
|
|
Image: Images.Storage,
|
|
ImgProxyImage: Images.ImgProxy,
|
|
S3Credentials: storageS3Credentials{
|
|
AccessKeyId: "625729a08b95bf1b7ff351a663f3a23c",
|
|
SecretAccessKey: "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907",
|
|
Region: "local",
|
|
},
|
|
},
|
|
Auth: auth{
|
|
Image: Images.Gotrue,
|
|
Email: email{
|
|
Template: map[string]emailTemplate{},
|
|
},
|
|
Sms: sms{
|
|
TestOTP: map[string]string{},
|
|
},
|
|
External: map[string]provider{},
|
|
JwtSecret: defaultJwtSecret,
|
|
},
|
|
Inbucket: inbucket{
|
|
Image: Images.Inbucket,
|
|
AdminEmail: "admin@email.com",
|
|
SenderName: "Admin",
|
|
},
|
|
Studio: studio{
|
|
Image: Images.Studio,
|
|
PgmetaImage: Images.Pgmeta,
|
|
},
|
|
Analytics: analytics{
|
|
Image: Images.Logflare,
|
|
VectorImage: Images.Vector,
|
|
ApiKey: "api-key",
|
|
// Defaults to bigquery for backwards compatibility with existing config.toml
|
|
Backend: LogflareBigQuery,
|
|
},
|
|
EdgeRuntime: edgeRuntime{
|
|
Image: Images.EdgeRuntime,
|
|
},
|
|
}}
|
|
for _, apply := range editors {
|
|
apply(&initial)
|
|
}
|
|
return initial
|
|
}
|
|
|
|
var (
|
|
//go:embed templates/config.toml
|
|
initConfigEmbed string
|
|
initConfigTemplate = template.Must(template.New("initConfig").Parse(initConfigEmbed))
|
|
|
|
invalidProjectId = regexp.MustCompile("[^a-zA-Z0-9_.-]+")
|
|
refPattern = regexp.MustCompile(`^[a-z]{20}$`)
|
|
)
|
|
|
|
func (c *config) Eject(w io.Writer) error {
|
|
// Defaults to current directory name as project id
|
|
if len(c.ProjectId) == 0 {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return errors.Errorf("failed to get working directory: %w", err)
|
|
}
|
|
c.ProjectId = filepath.Base(cwd)
|
|
}
|
|
c.ProjectId = sanitizeProjectId(c.ProjectId)
|
|
// TODO: templatize all fields eventually
|
|
if err := initConfigTemplate.Option("missingkey=error").Execute(w, c); err != nil {
|
|
return errors.Errorf("failed to initialise config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Loads custom config file to struct fields tagged with toml.
|
|
func (c *config) loadFromFile(filename string, fsys fs.FS) error {
|
|
v := viper.New()
|
|
v.SetConfigType("toml")
|
|
// Load default values
|
|
var buf bytes.Buffer
|
|
if err := c.Eject(&buf); err != nil {
|
|
return err
|
|
} else if err := c.loadFromReader(v, &buf); err != nil {
|
|
return err
|
|
}
|
|
// Load custom config
|
|
if ext := filepath.Ext(filename); len(ext) > 0 {
|
|
v.SetConfigType(ext[1:])
|
|
}
|
|
f, err := fsys.Open(filename)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
} else if err != nil {
|
|
return errors.Errorf("failed to read file config: %w", err)
|
|
}
|
|
defer f.Close()
|
|
return c.loadFromReader(v, f)
|
|
}
|
|
|
|
func (c *config) loadFromReader(v *viper.Viper, r io.Reader) error {
|
|
if err := v.MergeConfig(r); err != nil {
|
|
return errors.Errorf("failed to merge config: %w", err)
|
|
}
|
|
// Find [remotes.*] block to override base config
|
|
baseId := v.GetString("project_id")
|
|
idToName := map[string]string{baseId: "base"}
|
|
for name, remote := range v.GetStringMap("remotes") {
|
|
projectId := v.GetString(fmt.Sprintf("remotes.%s.project_id", name))
|
|
// Track remote project_id to check for duplication
|
|
if other, exists := idToName[projectId]; exists {
|
|
return errors.Errorf("duplicate project_id for [remotes.%s] and %s", name, other)
|
|
}
|
|
idToName[projectId] = fmt.Sprintf("[remotes.%s]", name)
|
|
if projectId == c.ProjectId {
|
|
fmt.Fprintln(os.Stderr, "Loading config override:", idToName[projectId])
|
|
if err := v.MergeConfigMap(remote.(map[string]any)); err != nil {
|
|
return err
|
|
}
|
|
v.Set("project_id", baseId)
|
|
}
|
|
}
|
|
// Set default values for [functions.*] when config struct is empty
|
|
for key, value := range v.GetStringMap("functions") {
|
|
if _, ok := value.(map[string]any); !ok {
|
|
// Leave validation to decode hook
|
|
continue
|
|
}
|
|
if k := fmt.Sprintf("functions.%s.enabled", key); !v.IsSet(k) {
|
|
v.Set(k, true)
|
|
}
|
|
if k := fmt.Sprintf("functions.%s.verify_jwt", key); !v.IsSet(k) {
|
|
v.Set(k, true)
|
|
}
|
|
}
|
|
// Set default values when [auth.email.smtp] is defined
|
|
if smtp := v.GetStringMap("auth.email.smtp"); len(smtp) > 0 {
|
|
if _, exists := smtp["enabled"]; !exists {
|
|
v.Set("auth.email.smtp.enabled", true)
|
|
}
|
|
}
|
|
if err := v.UnmarshalExact(c, func(dc *mapstructure.DecoderConfig) {
|
|
dc.TagName = "toml"
|
|
dc.Squash = true
|
|
dc.ZeroFields = true
|
|
dc.DecodeHook = c.newDecodeHook(LoadEnvHook, ValidateFunctionsHook)
|
|
}); err != nil {
|
|
return errors.Errorf("failed to parse config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *config) newDecodeHook(fs ...mapstructure.DecodeHookFunc) mapstructure.DecodeHookFunc {
|
|
fs = append(fs,
|
|
mapstructure.StringToTimeDurationHookFunc(),
|
|
mapstructure.StringToIPHookFunc(),
|
|
mapstructure.StringToSliceHookFunc(","),
|
|
mapstructure.TextUnmarshallerHookFunc(),
|
|
DecryptSecretHookFunc(c.ProjectId),
|
|
)
|
|
return mapstructure.ComposeDecodeHookFunc(fs...)
|
|
}
|
|
|
|
// Loads envs prefixed with supabase_ to struct fields tagged with mapstructure.
|
|
func (c *config) loadFromEnv() error {
|
|
v := viper.New()
|
|
v.SetEnvPrefix("SUPABASE")
|
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
v.AutomaticEnv()
|
|
// Viper does not parse env vars automatically. Instead of calling viper.BindEnv
|
|
// per key, we decode all keys from an existing struct, and merge them to viper.
|
|
// Ref: https://github.com/spf13/viper/issues/761#issuecomment-859306364
|
|
envKeysMap := map[string]interface{}{}
|
|
if dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
|
Result: &envKeysMap,
|
|
IgnoreUntaggedFields: true,
|
|
}); err != nil {
|
|
return errors.Errorf("failed to create decoder: %w", err)
|
|
} else if err := dec.Decode(c.baseConfig); err != nil {
|
|
return errors.Errorf("failed to decode env: %w", err)
|
|
} else if err := v.MergeConfigMap(envKeysMap); err != nil {
|
|
return errors.Errorf("failed to merge env config: %w", err)
|
|
}
|
|
// Writes viper state back to config struct, with automatic env substitution
|
|
if err := v.UnmarshalExact(c, viper.DecodeHook(c.newDecodeHook())); err != nil {
|
|
return errors.Errorf("failed to parse env override: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *config) Load(path string, fsys fs.FS) error {
|
|
builder := NewPathBuilder(path)
|
|
// Load secrets from .env file
|
|
if err := loadNestedEnv(builder.SupabaseDirPath); err != nil {
|
|
return err
|
|
}
|
|
if err := c.loadFromFile(builder.ConfigPath, fsys); err != nil {
|
|
return err
|
|
}
|
|
if err := c.loadFromEnv(); err != nil {
|
|
return err
|
|
}
|
|
// Generate JWT tokens
|
|
if len(c.Auth.AnonKey) == 0 {
|
|
anonToken := CustomClaims{Role: "anon"}.NewToken()
|
|
if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret)); err != nil {
|
|
return errors.Errorf("failed to generate anon key: %w", err)
|
|
} else {
|
|
c.Auth.AnonKey = signed
|
|
}
|
|
}
|
|
if len(c.Auth.ServiceRoleKey) == 0 {
|
|
anonToken := CustomClaims{Role: "service_role"}.NewToken()
|
|
if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret)); err != nil {
|
|
return errors.Errorf("failed to generate service_role key: %w", err)
|
|
} else {
|
|
c.Auth.ServiceRoleKey = signed
|
|
}
|
|
}
|
|
// TODO: move linked pooler connection string elsewhere
|
|
if connString, err := fs.ReadFile(fsys, builder.PoolerUrlPath); err == nil && len(connString) > 0 {
|
|
c.Db.Pooler.ConnectionString = string(connString)
|
|
}
|
|
if len(c.Api.ExternalUrl) == 0 {
|
|
// Update external api url
|
|
apiUrl := url.URL{Host: net.JoinHostPort(c.Hostname,
|
|
strconv.FormatUint(uint64(c.Api.Port), 10),
|
|
)}
|
|
if c.Api.Tls.Enabled {
|
|
apiUrl.Scheme = "https"
|
|
} else {
|
|
apiUrl.Scheme = "http"
|
|
}
|
|
c.Api.ExternalUrl = apiUrl.String()
|
|
}
|
|
// Update image versions
|
|
if version, err := fs.ReadFile(fsys, builder.PostgresVersionPath); err == nil {
|
|
if strings.HasPrefix(string(version), "15.") && semver.Compare(string(version[3:]), "1.0.55") >= 0 {
|
|
c.Db.Image = replaceImageTag(Images.Pg15, string(version))
|
|
}
|
|
}
|
|
if c.Db.MajorVersion > 14 {
|
|
if version, err := fs.ReadFile(fsys, builder.RestVersionPath); err == nil && len(version) > 0 {
|
|
c.Api.Image = replaceImageTag(Images.Postgrest, string(version))
|
|
}
|
|
if version, err := fs.ReadFile(fsys, builder.StorageVersionPath); err == nil && len(version) > 0 {
|
|
c.Storage.Image = replaceImageTag(Images.Storage, string(version))
|
|
}
|
|
if version, err := fs.ReadFile(fsys, builder.GotrueVersionPath); err == nil && len(version) > 0 {
|
|
c.Auth.Image = replaceImageTag(Images.Gotrue, string(version))
|
|
}
|
|
}
|
|
if version, err := fs.ReadFile(fsys, builder.EdgeRuntimeVersionPath); err == nil && len(version) > 0 {
|
|
c.EdgeRuntime.Image = replaceImageTag(Images.EdgeRuntime, string(version))
|
|
}
|
|
if version, err := fs.ReadFile(fsys, builder.PoolerVersionPath); err == nil && len(version) > 0 {
|
|
c.Db.Pooler.Image = replaceImageTag(Images.Supavisor, string(version))
|
|
}
|
|
if version, err := fs.ReadFile(fsys, builder.RealtimeVersionPath); err == nil && len(version) > 0 {
|
|
c.Realtime.Image = replaceImageTag(Images.Realtime, string(version))
|
|
}
|
|
if version, err := fs.ReadFile(fsys, builder.StudioVersionPath); err == nil && len(version) > 0 {
|
|
c.Studio.Image = replaceImageTag(Images.Studio, string(version))
|
|
}
|
|
if version, err := fs.ReadFile(fsys, builder.PgmetaVersionPath); err == nil && len(version) > 0 {
|
|
c.Studio.PgmetaImage = replaceImageTag(Images.Pgmeta, string(version))
|
|
}
|
|
// TODO: replace derived config resolution with viper decode hooks
|
|
if err := c.baseConfig.resolve(builder, fsys); err != nil {
|
|
return err
|
|
}
|
|
return c.Validate(fsys)
|
|
}
|
|
|
|
func (c *baseConfig) resolve(builder pathBuilder, fsys fs.FS) error {
|
|
// Update content paths
|
|
for name, tmpl := range c.Auth.Email.Template {
|
|
// FIXME: only email template is relative to repo directory
|
|
cwd := filepath.Dir(builder.SupabaseDirPath)
|
|
if len(tmpl.ContentPath) > 0 && !filepath.IsAbs(tmpl.ContentPath) {
|
|
tmpl.ContentPath = filepath.Join(cwd, tmpl.ContentPath)
|
|
}
|
|
c.Auth.Email.Template[name] = tmpl
|
|
}
|
|
// Update fallback configs
|
|
for name, bucket := range c.Storage.Buckets {
|
|
if bucket.FileSizeLimit == 0 {
|
|
bucket.FileSizeLimit = c.Storage.FileSizeLimit
|
|
}
|
|
if len(bucket.ObjectsPath) > 0 && !filepath.IsAbs(bucket.ObjectsPath) {
|
|
bucket.ObjectsPath = filepath.Join(builder.SupabaseDirPath, bucket.ObjectsPath)
|
|
}
|
|
c.Storage.Buckets[name] = bucket
|
|
}
|
|
// Resolve functions config
|
|
for slug, function := range c.Functions {
|
|
if len(function.Entrypoint) == 0 {
|
|
function.Entrypoint = filepath.Join(builder.FunctionsDir, slug, "index.ts")
|
|
} else if !filepath.IsAbs(function.Entrypoint) {
|
|
// Append supabase/ because paths in configs are specified relative to config.toml
|
|
function.Entrypoint = filepath.Join(builder.SupabaseDirPath, function.Entrypoint)
|
|
}
|
|
if len(function.ImportMap) == 0 {
|
|
functionDir := filepath.Dir(function.Entrypoint)
|
|
denoJsonPath := filepath.Join(functionDir, "deno.json")
|
|
denoJsoncPath := filepath.Join(functionDir, "deno.jsonc")
|
|
if _, err := fs.Stat(fsys, denoJsonPath); err == nil {
|
|
function.ImportMap = denoJsonPath
|
|
} else if _, err := fs.Stat(fsys, denoJsoncPath); err == nil {
|
|
function.ImportMap = denoJsoncPath
|
|
}
|
|
// Functions may not use import map so we don't set a default value
|
|
} else if !filepath.IsAbs(function.ImportMap) {
|
|
function.ImportMap = filepath.Join(builder.SupabaseDirPath, function.ImportMap)
|
|
}
|
|
for i, val := range function.StaticFiles {
|
|
if len(val) > 0 && !filepath.IsAbs(val) {
|
|
function.StaticFiles[i] = filepath.Join(builder.SupabaseDirPath, val)
|
|
}
|
|
}
|
|
c.Functions[slug] = function
|
|
}
|
|
if c.Db.Seed.Enabled {
|
|
for i, pattern := range c.Db.Seed.SqlPaths {
|
|
if len(pattern) > 0 && !filepath.IsAbs(pattern) {
|
|
c.Db.Seed.SqlPaths[i] = path.Join(builder.SupabaseDirPath, pattern)
|
|
}
|
|
}
|
|
}
|
|
for i, pattern := range c.Db.Migrations.SchemaPaths {
|
|
if len(pattern) > 0 && !filepath.IsAbs(pattern) {
|
|
c.Db.Migrations.SchemaPaths[i] = path.Join(builder.SupabaseDirPath, pattern)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *config) Validate(fsys fs.FS) error {
|
|
if c.ProjectId == "" {
|
|
return errors.New("Missing required field in config: project_id")
|
|
} else if sanitized := sanitizeProjectId(c.ProjectId); sanitized != c.ProjectId {
|
|
fmt.Fprintln(os.Stderr, "WARN: project_id field in config is invalid. Auto-fixing to", sanitized)
|
|
c.ProjectId = sanitized
|
|
}
|
|
// Since remote config is merged to base, we only need to validate the project_id field.
|
|
for name, remote := range c.Remotes {
|
|
if !refPattern.MatchString(remote.ProjectId) {
|
|
return errors.Errorf("Invalid config for remotes.%s.project_id. Must be like: abcdefghijklmnopqrst", name)
|
|
}
|
|
}
|
|
// Validate api config
|
|
if c.Api.Enabled {
|
|
if c.Api.Port == 0 {
|
|
return errors.New("Missing required field in config: api.port")
|
|
}
|
|
}
|
|
// Validate db config
|
|
if c.Db.Port == 0 {
|
|
return errors.New("Missing required field in config: db.port")
|
|
}
|
|
switch c.Db.MajorVersion {
|
|
case 0:
|
|
return errors.New("Missing required field in config: db.major_version")
|
|
case 12:
|
|
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.")
|
|
case 13:
|
|
c.Db.Image = pg13
|
|
case 14:
|
|
c.Db.Image = pg14
|
|
case 15:
|
|
if len(c.Experimental.OrioleDBVersion) > 0 {
|
|
c.Db.Image = "supabase/postgres:orioledb-" + c.Experimental.OrioleDBVersion
|
|
if err := assertEnvLoaded(c.Experimental.S3Host); err != nil {
|
|
return err
|
|
}
|
|
if err := assertEnvLoaded(c.Experimental.S3Region); err != nil {
|
|
return err
|
|
}
|
|
if err := assertEnvLoaded(c.Experimental.S3AccessKey); err != nil {
|
|
return err
|
|
}
|
|
if err := assertEnvLoaded(c.Experimental.S3SecretKey); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
default:
|
|
return errors.Errorf("Failed reading config: Invalid %s: %v.", "db.major_version", c.Db.MajorVersion)
|
|
}
|
|
// Validate storage config
|
|
for name := range c.Storage.Buckets {
|
|
if err := ValidateBucketName(name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Validate studio config
|
|
if c.Studio.Enabled {
|
|
if c.Studio.Port == 0 {
|
|
return errors.New("Missing required field in config: studio.port")
|
|
}
|
|
if parsed, err := url.Parse(c.Studio.ApiUrl); err != nil {
|
|
return errors.Errorf("Invalid config for studio.api_url: %w", err)
|
|
} else if parsed.Host == "" || parsed.Host == c.Hostname {
|
|
c.Studio.ApiUrl = c.Api.ExternalUrl
|
|
}
|
|
}
|
|
// Validate smtp config
|
|
if c.Inbucket.Enabled {
|
|
if c.Inbucket.Port == 0 {
|
|
return errors.New("Missing required field in config: inbucket.port")
|
|
}
|
|
}
|
|
// Validate auth config
|
|
if c.Auth.Enabled {
|
|
if c.Auth.SiteUrl == "" {
|
|
return errors.New("Missing required field in config: auth.site_url")
|
|
}
|
|
if err := assertEnvLoaded(c.Auth.SiteUrl); err != nil {
|
|
return err
|
|
}
|
|
for i, url := range c.Auth.AdditionalRedirectUrls {
|
|
if err := assertEnvLoaded(url); err != nil {
|
|
return errors.Errorf("Invalid config for auth.additional_redirect_urls[%d]: %v", i, err)
|
|
}
|
|
}
|
|
if c.Auth.Captcha != nil && c.Auth.Captcha.Enabled {
|
|
if len(c.Auth.Captcha.Provider) == 0 {
|
|
return errors.New("Missing required field in config: auth.captcha.provider")
|
|
}
|
|
if len(c.Auth.Captcha.Secret.Value) == 0 {
|
|
return errors.Errorf("Missing required field in config: auth.captcha.secret")
|
|
}
|
|
if err := assertEnvLoaded(c.Auth.Captcha.Secret.Value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := c.Auth.Hook.validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Auth.MFA.validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Auth.Email.validate(fsys); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Auth.Sms.validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Auth.External.validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.Auth.ThirdParty.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Validate functions config
|
|
for name := range c.Functions {
|
|
if err := ValidateFunctionSlug(name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Validate logflare config
|
|
if c.Analytics.Enabled {
|
|
if c.Analytics.Backend == LogflareBigQuery {
|
|
if len(c.Analytics.GcpProjectId) == 0 {
|
|
return errors.New("Missing required field in config: analytics.gcp_project_id")
|
|
}
|
|
if len(c.Analytics.GcpProjectNumber) == 0 {
|
|
return errors.New("Missing required field in config: analytics.gcp_project_number")
|
|
}
|
|
if len(c.Analytics.GcpJwtPath) == 0 {
|
|
return errors.New("Path to GCP Service Account Key must be provided in config, relative to config.toml: analytics.gcp_jwt_path")
|
|
}
|
|
}
|
|
}
|
|
if err := c.Experimental.validate(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertEnvLoaded(s string) error {
|
|
if matches := envPattern.FindStringSubmatch(s); len(matches) > 1 {
|
|
fmt.Fprintln(os.Stderr, "WARN: environment variable is unset:", matches[1])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func truncateText(text string, maxLen int) string {
|
|
if len(text) > maxLen {
|
|
return text[:maxLen]
|
|
}
|
|
return text
|
|
}
|
|
|
|
const maxProjectIdLength = 40
|
|
|
|
func sanitizeProjectId(src string) string {
|
|
// A valid project ID must only contain alphanumeric and special characters _.-
|
|
sanitized := invalidProjectId.ReplaceAllString(src, "_")
|
|
// It must also start with an alphanumeric character
|
|
sanitized = strings.TrimLeft(sanitized, "_.-")
|
|
// Truncate sanitized ID to 40 characters since docker hostnames cannot exceed
|
|
// 63 characters, and we need to save space for padding supabase_*_edge_runtime.
|
|
return truncateText(sanitized, maxProjectIdLength)
|
|
}
|
|
|
|
func loadNestedEnv(basePath string) error {
|
|
repoDir, err := os.Getwd()
|
|
if err != nil {
|
|
return errors.Errorf("failed to get repo directory: %w", err)
|
|
}
|
|
if !filepath.IsAbs(basePath) {
|
|
basePath = filepath.Join(repoDir, basePath)
|
|
}
|
|
env := viper.GetString("ENV")
|
|
for cwd := basePath; cwd != filepath.Dir(repoDir); cwd = filepath.Dir(cwd) {
|
|
if err := os.Chdir(cwd); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return errors.Errorf("failed to change directory: %w", err)
|
|
}
|
|
if err := loadDefaultEnv(env); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := os.Chdir(repoDir); err != nil {
|
|
return errors.Errorf("failed to restore directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadDefaultEnv(env string) error {
|
|
if env == "" {
|
|
env = "development"
|
|
}
|
|
filenames := []string{".env." + env + ".local"}
|
|
if env != "test" {
|
|
filenames = append(filenames, ".env.local")
|
|
}
|
|
filenames = append(filenames, ".env."+env, ".env")
|
|
for _, path := range filenames {
|
|
if err := loadEnvIfExists(path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadEnvIfExists(path string) error {
|
|
if err := godotenv.Load(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return errors.Errorf("failed to load %s: %w", ".env", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *email) validate(fsys fs.FS) (err error) {
|
|
for name, tmpl := range e.Template {
|
|
if len(tmpl.ContentPath) == 0 {
|
|
if tmpl.Content != nil {
|
|
return errors.Errorf("Invalid config for auth.email.%s.content: please use content_path instead", name)
|
|
}
|
|
continue
|
|
}
|
|
if content, err := fs.ReadFile(fsys, tmpl.ContentPath); err != nil {
|
|
return errors.Errorf("Invalid config for auth.email.%s.content_path: %w", name, err)
|
|
} else {
|
|
tmpl.Content = cast.Ptr(string(content))
|
|
}
|
|
e.Template[name] = tmpl
|
|
}
|
|
if e.Smtp != nil && e.Smtp.Enabled {
|
|
if len(e.Smtp.Host) == 0 {
|
|
return errors.New("Missing required field in config: auth.email.smtp.host")
|
|
}
|
|
if e.Smtp.Port == 0 {
|
|
return errors.New("Missing required field in config: auth.email.smtp.port")
|
|
}
|
|
if len(e.Smtp.User) == 0 {
|
|
return errors.New("Missing required field in config: auth.email.smtp.user")
|
|
}
|
|
if len(e.Smtp.Pass.Value) == 0 {
|
|
return errors.New("Missing required field in config: auth.email.smtp.pass")
|
|
}
|
|
if len(e.Smtp.AdminEmail) == 0 {
|
|
return errors.New("Missing required field in config: auth.email.smtp.admin_email")
|
|
}
|
|
if err := assertEnvLoaded(e.Smtp.Pass.Value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *sms) validate() (err error) {
|
|
switch {
|
|
case s.Twilio.Enabled:
|
|
if len(s.Twilio.AccountSid) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.twilio.account_sid")
|
|
}
|
|
if len(s.Twilio.MessageServiceSid) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.twilio.message_service_sid")
|
|
}
|
|
if len(s.Twilio.AuthToken.Value) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.twilio.auth_token")
|
|
}
|
|
if err := assertEnvLoaded(s.Twilio.AuthToken.Value); err != nil {
|
|
return err
|
|
}
|
|
case s.TwilioVerify.Enabled:
|
|
if len(s.TwilioVerify.AccountSid) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid")
|
|
}
|
|
if len(s.TwilioVerify.MessageServiceSid) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.twilio_verify.message_service_sid")
|
|
}
|
|
if len(s.TwilioVerify.AuthToken.Value) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.twilio_verify.auth_token")
|
|
}
|
|
if err := assertEnvLoaded(s.TwilioVerify.AuthToken.Value); err != nil {
|
|
return err
|
|
}
|
|
case s.Messagebird.Enabled:
|
|
if len(s.Messagebird.Originator) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.messagebird.originator")
|
|
}
|
|
if len(s.Messagebird.AccessKey.Value) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.messagebird.access_key")
|
|
}
|
|
if err := assertEnvLoaded(s.Messagebird.AccessKey.Value); err != nil {
|
|
return err
|
|
}
|
|
case s.Textlocal.Enabled:
|
|
if len(s.Textlocal.Sender) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.textlocal.sender")
|
|
}
|
|
if len(s.Textlocal.ApiKey.Value) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.textlocal.api_key")
|
|
}
|
|
if err := assertEnvLoaded(s.Textlocal.ApiKey.Value); err != nil {
|
|
return err
|
|
}
|
|
case s.Vonage.Enabled:
|
|
if len(s.Vonage.From) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.vonage.from")
|
|
}
|
|
if len(s.Vonage.ApiKey) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.vonage.api_key")
|
|
}
|
|
if len(s.Vonage.ApiSecret.Value) == 0 {
|
|
return errors.New("Missing required field in config: auth.sms.vonage.api_secret")
|
|
}
|
|
if err := assertEnvLoaded(s.Vonage.ApiKey); err != nil {
|
|
return err
|
|
}
|
|
if err := assertEnvLoaded(s.Vonage.ApiSecret.Value); err != nil {
|
|
return err
|
|
}
|
|
case s.EnableSignup:
|
|
s.EnableSignup = false
|
|
fmt.Fprintln(os.Stderr, "WARN: no SMS provider is enabled. Disabling phone login")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e external) validate() (err error) {
|
|
for _, ext := range []string{"linkedin", "slack"} {
|
|
if e[ext].Enabled {
|
|
fmt.Fprintf(os.Stderr, `WARN: disabling deprecated "%[1]s" provider. Please use [auth.external.%[1]s_oidc] instead\n`, ext)
|
|
}
|
|
delete(e, ext)
|
|
}
|
|
for ext, provider := range e {
|
|
if !provider.Enabled {
|
|
continue
|
|
}
|
|
if provider.ClientId == "" {
|
|
return errors.Errorf("Missing required field in config: auth.external.%s.client_id", ext)
|
|
}
|
|
if !sliceContains([]string{"apple", "google"}, ext) && len(provider.Secret.Value) == 0 {
|
|
return errors.Errorf("Missing required field in config: auth.external.%s.secret", ext)
|
|
}
|
|
if err := assertEnvLoaded(provider.ClientId); err != nil {
|
|
return err
|
|
}
|
|
if err := assertEnvLoaded(provider.Secret.Value); err != nil {
|
|
return err
|
|
}
|
|
if err := assertEnvLoaded(provider.RedirectUri); err != nil {
|
|
return err
|
|
}
|
|
if err := assertEnvLoaded(provider.Url); err != nil {
|
|
return err
|
|
}
|
|
e[ext] = provider
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *hook) validate() error {
|
|
if hook := h.MFAVerificationAttempt; hook != nil {
|
|
if err := hook.validate("mfa_verification_attempt"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if hook := h.PasswordVerificationAttempt; hook != nil {
|
|
if err := hook.validate("password_verification_attempt"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if hook := h.CustomAccessToken; hook != nil {
|
|
if err := hook.validate("custom_access_token"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if hook := h.SendSMS; hook != nil {
|
|
if err := hook.validate("send_sms"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if hook := h.SendEmail; hook != nil {
|
|
if err := h.SendEmail.validate("send_email"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var hookSecretPattern = regexp.MustCompile(`^v1,whsec_[A-Za-z0-9+/=]{32,88}$`)
|
|
|
|
func (h *hookConfig) validate(hookType string) (err error) {
|
|
// If not enabled do nothing
|
|
if !h.Enabled {
|
|
return nil
|
|
}
|
|
if h.URI == "" {
|
|
return errors.Errorf("Missing required field in config: auth.hook.%s.uri", hookType)
|
|
}
|
|
parsed, err := url.Parse(h.URI)
|
|
if err != nil {
|
|
return errors.Errorf("failed to parse template url: %w", err)
|
|
}
|
|
switch strings.ToLower(parsed.Scheme) {
|
|
case "http", "https":
|
|
if len(h.Secrets.Value) == 0 {
|
|
return errors.Errorf("Missing required field in config: auth.hook.%s.secrets", hookType)
|
|
} else if err := assertEnvLoaded(h.Secrets.Value); err != nil {
|
|
return err
|
|
}
|
|
for _, secret := range strings.Split(h.Secrets.Value, "|") {
|
|
if !hookSecretPattern.MatchString(secret) {
|
|
return errors.Errorf(`Invalid hook config: auth.hook.%s.secrets must be formatted as "v1,whsec_<base64_encoded_secret>" with a minimum length of 32 characters.`, hookType)
|
|
}
|
|
}
|
|
case "pg-functions":
|
|
if len(h.Secrets.Value) > 0 {
|
|
return errors.Errorf("Invalid hook config: auth.hook.%s.secrets is unsupported for pg-functions URI", hookType)
|
|
}
|
|
default:
|
|
return errors.Errorf("Invalid hook config: auth.hook.%s.uri should be a HTTP, HTTPS, or pg-functions URI", hookType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mfa) validate() error {
|
|
if m.TOTP.EnrollEnabled && !m.TOTP.VerifyEnabled {
|
|
return errors.Errorf("Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled")
|
|
}
|
|
if m.Phone.EnrollEnabled && !m.Phone.VerifyEnabled {
|
|
return errors.Errorf("Invalid MFA config: auth.mfa.phone.enroll_enabled requires verify_enabled")
|
|
}
|
|
if m.WebAuthn.EnrollEnabled && !m.WebAuthn.VerifyEnabled {
|
|
return errors.Errorf("Invalid MFA config: auth.mfa.web_authn.enroll_enabled requires verify_enabled")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TODO: use field tag validator instead
|
|
var funcSlugPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
|
|
|
|
func ValidateFunctionSlug(slug string) error {
|
|
if !funcSlugPattern.MatchString(slug) {
|
|
return errors.Errorf(`Invalid Function name: %s. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (%s)`, slug, funcSlugPattern.String())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Ref: https://github.com/supabase/storage/blob/master/src/storage/limits.ts#L59
|
|
var bucketNamePattern = regexp.MustCompile(`^(\w|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$`)
|
|
|
|
func ValidateBucketName(name string) error {
|
|
if !bucketNamePattern.MatchString(name) {
|
|
return errors.Errorf("Invalid Bucket name: %s. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (%s)", name, bucketNamePattern.String())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *tpaFirebase) issuerURL() string {
|
|
return fmt.Sprintf("https://securetoken.google.com/%s", f.ProjectID)
|
|
}
|
|
|
|
func (f *tpaFirebase) validate() error {
|
|
if f.ProjectID == "" {
|
|
return errors.New("Invalid config: auth.third_party.firebase is enabled but without a project_id.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *tpaAuth0) issuerURL() string {
|
|
if a.TenantRegion != "" {
|
|
return fmt.Sprintf("https://%s.%s.auth0.com", a.Tenant, a.TenantRegion)
|
|
}
|
|
|
|
return fmt.Sprintf("https://%s.auth0.com", a.Tenant)
|
|
}
|
|
|
|
func (a *tpaAuth0) validate() error {
|
|
if a.Tenant == "" {
|
|
return errors.New("Invalid config: auth.third_party.auth0 is enabled but without a tenant.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *tpaCognito) issuerURL() string {
|
|
return fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", c.UserPoolRegion, c.UserPoolID)
|
|
}
|
|
|
|
func (c *tpaCognito) validate() (err error) {
|
|
if c.UserPoolID == "" {
|
|
return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.")
|
|
} else if err := assertEnvLoaded(c.UserPoolID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if c.UserPoolRegion == "" {
|
|
return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.")
|
|
} else if err := assertEnvLoaded(c.UserPoolRegion); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (tpa *thirdParty) validate() error {
|
|
enabled := 0
|
|
|
|
if tpa.Firebase.Enabled {
|
|
enabled += 1
|
|
|
|
if err := tpa.Firebase.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if tpa.Auth0.Enabled {
|
|
enabled += 1
|
|
|
|
if err := tpa.Auth0.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if tpa.Cognito.Enabled {
|
|
enabled += 1
|
|
|
|
if err := tpa.Cognito.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if enabled > 1 {
|
|
return errors.New("Invalid config: Only one third_party provider allowed to be enabled at a time.")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (tpa *thirdParty) IssuerURL() string {
|
|
if tpa.Firebase.Enabled {
|
|
return tpa.Firebase.issuerURL()
|
|
}
|
|
|
|
if tpa.Auth0.Enabled {
|
|
return tpa.Auth0.issuerURL()
|
|
}
|
|
|
|
if tpa.Cognito.Enabled {
|
|
return tpa.Cognito.issuerURL()
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ResolveJWKS creates the JWKS from the JWT secret and Third-Party Auth
|
|
// configs by resolving the JWKS via the OIDC discovery URL.
|
|
// It always returns a JWKS string, except when there's an error fetching.
|
|
func (a *auth) ResolveJWKS(ctx context.Context) (string, error) {
|
|
var jwks struct {
|
|
Keys []json.RawMessage `json:"keys"`
|
|
}
|
|
|
|
issuerURL := a.ThirdParty.IssuerURL()
|
|
if issuerURL != "" {
|
|
discoveryURL := issuerURL + "/.well-known/openid-configuration"
|
|
|
|
t := &http.Client{Timeout: 10 * time.Second}
|
|
client := fetcher.NewFetcher(
|
|
discoveryURL,
|
|
fetcher.WithHTTPClient(t),
|
|
fetcher.WithExpectedStatus(http.StatusOK),
|
|
)
|
|
|
|
resp, err := client.Send(ctx, http.MethodGet, "", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
type oidcConfiguration struct {
|
|
JWKSURI string `json:"jwks_uri"`
|
|
}
|
|
|
|
oidcConfig, err := fetcher.ParseJSON[oidcConfiguration](resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if oidcConfig.JWKSURI == "" {
|
|
return "", fmt.Errorf("auth.third_party: OIDC configuration at URL %q does not expose a jwks_uri property", discoveryURL)
|
|
}
|
|
|
|
client = fetcher.NewFetcher(
|
|
oidcConfig.JWKSURI,
|
|
fetcher.WithHTTPClient(t),
|
|
fetcher.WithExpectedStatus(http.StatusOK),
|
|
)
|
|
|
|
resp, err = client.Send(ctx, http.MethodGet, "", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
type remoteJWKS struct {
|
|
Keys []json.RawMessage `json:"keys"`
|
|
}
|
|
|
|
rJWKS, err := fetcher.ParseJSON[remoteJWKS](resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(rJWKS.Keys) == 0 {
|
|
return "", fmt.Errorf("auth.third_party: JWKS at URL %q as discovered from %q does not contain any JWK keys", oidcConfig.JWKSURI, discoveryURL)
|
|
}
|
|
|
|
jwks.Keys = rJWKS.Keys
|
|
}
|
|
|
|
var secretJWK struct {
|
|
KeyType string `json:"kty"`
|
|
KeyBase64URL string `json:"k"`
|
|
}
|
|
|
|
secretJWK.KeyType = "oct"
|
|
secretJWK.KeyBase64URL = base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret))
|
|
|
|
secretJWKEncoded, err := json.Marshal(&secretJWK)
|
|
if err != nil {
|
|
return "", errors.Errorf("failed to marshal secret jwk: %w", err)
|
|
}
|
|
|
|
jwks.Keys = append(jwks.Keys, json.RawMessage(secretJWKEncoded))
|
|
|
|
jwksEncoded, err := json.Marshal(jwks)
|
|
if err != nil {
|
|
return "", errors.Errorf("failed to marshal jwks keys: %w", err)
|
|
}
|
|
|
|
return string(jwksEncoded), nil
|
|
}
|
|
|
|
func (c *baseConfig) GetServiceImages() []string {
|
|
return []string{
|
|
c.Db.Image,
|
|
c.Auth.Image,
|
|
c.Api.Image,
|
|
c.Realtime.Image,
|
|
c.Storage.Image,
|
|
c.EdgeRuntime.Image,
|
|
c.Studio.Image,
|
|
c.Studio.PgmetaImage,
|
|
c.Analytics.Image,
|
|
c.Db.Pooler.Image,
|
|
}
|
|
}
|
|
|
|
// Retrieve the final base config to use taking into account the remotes override
|
|
// Pre: config must be loaded after setting config.ProjectID = "ref"
|
|
func (c *config) GetRemoteByProjectRef(projectRef string) (baseConfig, error) {
|
|
base := c.baseConfig.Clone()
|
|
for _, remote := range c.Remotes {
|
|
if remote.ProjectId == projectRef {
|
|
base.ProjectId = projectRef
|
|
return base, nil
|
|
}
|
|
}
|
|
return base, errors.Errorf("no remote found for project_id: %s", projectRef)
|
|
}
|
|
|
|
func ToTomlBytes(config any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
enc := toml.NewEncoder(&buf)
|
|
enc.Indent = ""
|
|
if err := enc.Encode(config); err != nil {
|
|
return nil, errors.Errorf("failed to marshal toml config: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func (e *experimental) validate() error {
|
|
if e.Webhooks != nil && !e.Webhooks.Enabled {
|
|
return errors.Errorf("Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined")
|
|
}
|
|
return nil
|
|
}
|