From e4ad803392022d6401eee5d287e31696f3527931 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Tue, 26 May 2026 21:23:50 +0200 Subject: [PATCH 1/4] feat!: rework config parsing --- .gitignore | 3 +- app.go | 18 ++- config.example.yml | 68 ---------- config/config.go | 238 +++++++++++++++++++++++---------- config/config_test.go | 145 +++++++------------- config/error.go | 17 +++ config/file.go | 94 +++++++++++++ config/loglevel.go | 25 ++++ config/loglevel_test.go | 22 +++ config/parse.go | 99 ++++++++++++++ go.mod | 3 +- go.sum | 7 +- gotify-server.env.example | 273 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 763 insertions(+), 249 deletions(-) delete mode 100644 config.example.yml create mode 100644 config/error.go create mode 100644 config/file.go create mode 100644 config/loglevel.go create mode 100644 config/loglevel_test.go create mode 100644 config/parse.go create mode 100644 gotify-server.env.example diff --git a/.gitignore b/.gitignore index f31e0701..53dd0061 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ coverage.txt **/*-packr.go config.yml data/ -images/ \ No newline at end of file +images/ +/gotify-server.env.local diff --git a/app.go b/app.go index 92cf47d7..cad54962 100644 --- a/app.go +++ b/app.go @@ -27,13 +27,21 @@ var ( ) func main() { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: noColor()}) - vInfo := &model.VersionInfo{Version: Version, Commit: Commit, BuildDate: BuildDate} mode.Set(Mode) + conf, futureLogs := config.Get() + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: noColor(conf.NoColor)}).Level(zerolog.Level(conf.LogLevel)) log.Info().Str("version", vInfo.Version).Str("build_date", BuildDate).Msg("Gotify") - conf := config.Get() + + exit := false + for _, futureLog := range futureLogs { + log.WithLevel(futureLog.Level).Msg(futureLog.Msg) + exit = exit || futureLog.Level == zerolog.FatalLevel || futureLog.Level == zerolog.PanicLevel + } + if exit { + os.Exit(1) + } if conf.PluginsDir != "" { if err := os.MkdirAll(conf.PluginsDir, 0o755); err != nil { @@ -59,9 +67,9 @@ func main() { } } -func noColor() bool { +func noColor(noColorEnv string) bool { // https://no-color.org/ - if os.Getenv("NO_COLOR") == "1" { + if noColorEnv == "1" { return true } diff --git a/config.example.yml b/config.example.yml deleted file mode 100644 index b1bdfed0..00000000 --- a/config.example.yml +++ /dev/null @@ -1,68 +0,0 @@ -# Example configuration file for the server. -# Save it to `config.yml` when edited - -server: - keepaliveperiodseconds: 0 # 0 = use Go default (15s); -1 = disable keepalive; set the interval in which keepalive packets will be sent. Only change this value if you know what you are doing. - listenaddr: '' # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". - port: 80 # the port the HTTP server will listen on - - ssl: - enabled: false # if https should be enabled - redirecttohttps: true # redirect to https if site is accessed by http - listenaddr: '' # the address to bind on, leave empty to bind on all addresses. Prefix with "unix:" to create a unix socket. Example: "unix:/tmp/gotify.sock". - port: 443 # the https port - certfile: # the cert file (leave empty when using letsencrypt) - certkey: # the cert key (leave empty when using letsencrypt) - letsencrypt: - enabled: false # if the certificate should be requested from letsencrypt - accepttos: false # if you accept the tos from letsencrypt - cache: data/certs # the directory of the cache from letsencrypt - directoryurl: # override the directory url of the ACME server - # Let's Encrypt highly recommend testing against their staging environment before using their production environment. - # Staging server has high rate limits for testing and debugging, issued certificates are not valid - # example: https://acme-staging-v02.api.letsencrypt.org/directory - hosts: # the hosts for which letsencrypt should request certificates -# - mydomain.tld -# - myotherdomain.tld - responseheaders: # response headers are added to every response (default: none) -# X-Custom-Header: "custom value" - - trustedproxies: # IPs or IP ranges of trusted proxies. Used to obtain the remote ip via the X-Forwarded-For header. (configure 127.0.0.1 to trust sockets) -# - 127.0.0.1/32 -# - ::1 - securecookie: false # If the secure flag should be set on cookies. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#secure - - cors: # Sets cors headers only when needed and provides support for multiple allowed origins. Overrides Access-Control-* Headers in response headers. - alloworigins: -# - '.+.example.com' -# - 'otherdomain.com' - allowmethods: -# - "GET" -# - "POST" - allowheaders: -# - "Authorization" -# - "content-type" - stream: - pingperiodseconds: 45 # the interval in which websocket pings will be sent. Only change this value if you know what you are doing. - allowedorigins: # allowed origins for websocket connections (same origin is always allowed) -# - ".+.example.com" -# - "otherdomain.com" -oidc: - enabled: false # Enable OpenID Connect login, allowing users to authenticate via an external identity provider (e.g. Keycloak, Authelia, Google). - issuer: # The OIDC issuer URL. This is the base URL of your identity provider, used to discover endpoints. Example: "https://auth.example.com/realms/myrealm" - clientid: # The client ID registered with your identity provider for this application. - clientsecret: # The client secret for the registered client. - redirecturl: http://gotify.example.org/auth/oidc/callback # The callback URL that the identity provider redirects to after authentication. Must match exactly what is configured in your identity provider. - autoregister: true # If true, automatically create a new user on first OIDC login. If false, only existing users can log in via OIDC. - usernameclaim: preferred_username # The OIDC claim used to determine the username. Common values: "preferred_username" or "email". - -database: # for database see (configure database section) - dialect: sqlite3 - connection: data/gotify.db -defaultuser: # on database creation, gotify creates an admin user (these values will only be used for the first start, if you want to edit the user after the first start use the WebUI) - name: admin # the username of the default user - pass: admin # the password of the default user -passstrength: 10 # the bcrypt password strength (higher = better but also slower) -uploadedimagesdir: data/images # the directory for storing uploaded images -pluginsdir: data/plugins # the directory where plugin resides (leave empty to disable plugins) -registration: false # enable registrations diff --git a/config/config.go b/config/config.go index c299d8ee..1ff6b459 100644 --- a/config/config.go +++ b/config/config.go @@ -4,86 +4,180 @@ import ( "path/filepath" "strings" - "github.com/gotify/server/v2/mode" - "github.com/jinzhu/configor" + "github.com/rs/zerolog" ) -// Configuration is stuff that can be configured externally per env variables or config file (config.yml). -type Configuration struct { - Server struct { - KeepAlivePeriodSeconds int - ListenAddr string `default:""` - Port int `default:"80"` - - SSL struct { - Enabled bool `default:"false"` - RedirectToHTTPS bool `default:"true"` - ListenAddr string `default:""` - Port int `default:"443"` - CertFile string `default:""` - CertKey string `default:""` - LetsEncrypt struct { - Enabled bool `default:"false"` - AcceptTOS bool `default:"false"` - Cache string `default:"data/certs"` - DirectoryURL string `default:""` - Hosts []string - } - } - ResponseHeaders map[string]string - Stream struct { - PingPeriodSeconds int `default:"45"` - AllowedOrigins []string - } - Cors struct { - AllowOrigins []string - AllowMethods []string - AllowHeaders []string - } +type LetsEncrypt struct { + Enabled bool + AcceptTOS bool + Cache string + DirectoryURL string + Hosts []string +} - TrustedProxies []string - SecureCookie bool `default:"false"` - } - Database struct { - Dialect string `default:"sqlite3"` - Connection string `default:"data/gotify.db"` - } - DefaultUser struct { - Name string `default:"admin"` - Pass string `default:"admin"` - } - PassStrength int `default:"10"` - UploadedImagesDir string `default:"data/images"` - PluginsDir string `default:"data/plugins"` - Registration bool `default:"false"` - OIDC struct { - Enabled bool `default:"false"` - Issuer string `default:""` - ClientID string `default:""` - ClientSecret string `default:""` - UsernameClaim string `default:"preferred_username"` - RedirectURL string `default:""` - AutoRegister bool `default:"true"` - Scopes []string - } +type SSL struct { + Enabled bool + RedirectToHTTPS bool + ListenAddr string + Port int + CertFile string + CertKey string + LetsEncrypt LetsEncrypt } -func configFiles() []string { - if mode.Get() == mode.TestDev { - return []string{"config.yml"} - } - return []string{"config.yml", "/etc/gotify/config.yml"} +type Stream struct { + PingPeriodSeconds int + AllowedOrigins []string } -// Get returns the configuration extracted from env variables or config file. -func Get() *Configuration { - conf := new(Configuration) - err := configor.New(&configor.Config{ENVPrefix: "GOTIFY", Silent: true}).Load(conf, configFiles()...) - if err != nil { - panic(err) +type Cors struct { + AllowOrigins []string + AllowMethods []string + AllowHeaders []string +} + +type Server struct { + KeepAlivePeriodSeconds int + ListenAddr string + Port int + SSL SSL + ResponseHeaders map[string]string + Stream Stream + Cors Cors + TrustedProxies []string + SecureCookie bool +} + +type Database struct { + Dialect string + Connection string +} + +type DefaultUser struct { + Name string + Pass string +} + +type OIDC struct { + Enabled bool + Issuer string + ClientID string + ClientSecret string + UsernameClaim string + RedirectURL string + AutoRegister bool + Scopes []string +} + +type Configuration struct { + LogLevel LogLevel + Server Server + Database Database + DefaultUser DefaultUser + PassStrength int + UploadedImagesDir string + PluginsDir string + Registration bool + OIDC OIDC + NoColor string +} + +// Get returns the configuration extracted from env variables. +func Get() (*Configuration, []FutureLog) { + c := &Configuration{ + LogLevel: LogLevel(zerolog.InfoLevel), + Server: Server{ + Port: 80, + SSL: SSL{ + RedirectToHTTPS: true, + Port: 443, + LetsEncrypt: LetsEncrypt{ + Cache: "data/certs", + }, + }, + Stream: Stream{ + PingPeriodSeconds: 45, + }, + }, + Database: Database{ + Dialect: "sqlite3", + Connection: "data/gotify.db", + }, + DefaultUser: DefaultUser{ + Name: "admin", + Pass: "admin", + }, + PassStrength: 10, + UploadedImagesDir: "data/images", + PluginsDir: "data/plugins", + OIDC: OIDC{ + UsernameClaim: "preferred_username", + AutoRegister: true, + }, + } + + logs := loadFiles() + + add := func(err error) { + if err != nil { + logs = append(logs, futureFatal(err.Error())) + } } - addTrailingSlashToPaths(conf) - return conf + + add(parseInt(&c.Server.KeepAlivePeriodSeconds, "GOTIFY_SERVER_KEEPALIVEPERIODSECONDS")) + add(parseString(&c.Server.ListenAddr, "GOTIFY_SERVER_LISTENADDR")) + add(parseInt(&c.Server.Port, "GOTIFY_SERVER_PORT")) + + add(parseBool(&c.Server.SSL.Enabled, "GOTIFY_SERVER_SSL_ENABLED")) + add(parseBool(&c.Server.SSL.RedirectToHTTPS, "GOTIFY_SERVER_SSL_REDIRECTTOHTTPS")) + add(parseString(&c.Server.SSL.ListenAddr, "GOTIFY_SERVER_SSL_LISTENADDR")) + add(parseInt(&c.Server.SSL.Port, "GOTIFY_SERVER_SSL_PORT")) + add(parseString(&c.Server.SSL.CertFile, "GOTIFY_SERVER_SSL_CERTFILE")) + add(parseString(&c.Server.SSL.CertKey, "GOTIFY_SERVER_SSL_CERTKEY")) + + add(parseBool(&c.Server.SSL.LetsEncrypt.Enabled, "GOTIFY_SERVER_SSL_LETSENCRYPT_ENABLED")) + add(parseBool(&c.Server.SSL.LetsEncrypt.AcceptTOS, "GOTIFY_SERVER_SSL_LETSENCRYPT_ACCEPTTOS")) + add(parseString(&c.Server.SSL.LetsEncrypt.Cache, "GOTIFY_SERVER_SSL_LETSENCRYPT_CACHE")) + add(parseString(&c.Server.SSL.LetsEncrypt.DirectoryURL, "GOTIFY_SERVER_SSL_LETSENCRYPT_DIRECTORYURL")) + add(parseList(&c.Server.SSL.LetsEncrypt.Hosts, "GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS")) + + add(parseMap(&c.Server.ResponseHeaders, "GOTIFY_SERVER_RESPONSEHEADERS")) + + add(parseInt(&c.Server.Stream.PingPeriodSeconds, "GOTIFY_SERVER_STREAM_PINGPERIODSECONDS")) + add(parseList(&c.Server.Stream.AllowedOrigins, "GOTIFY_SERVER_STREAM_ALLOWEDORIGINS")) + + add(parseList(&c.Server.Cors.AllowOrigins, "GOTIFY_SERVER_CORS_ALLOWORIGINS")) + add(parseList(&c.Server.Cors.AllowMethods, "GOTIFY_SERVER_CORS_ALLOWMETHODS")) + add(parseList(&c.Server.Cors.AllowHeaders, "GOTIFY_SERVER_CORS_ALLOWHEADERS")) + + add(parseList(&c.Server.TrustedProxies, "GOTIFY_SERVER_TRUSTEDPROXIES")) + add(parseBool(&c.Server.SecureCookie, "GOTIFY_SERVER_SECURECOOKIE")) + + add(parseString(&c.Database.Dialect, "GOTIFY_DATABASE_DIALECT")) + add(parseString(&c.Database.Connection, "GOTIFY_DATABASE_CONNECTION")) + + add(parseString(&c.DefaultUser.Name, "GOTIFY_DEFAULTUSER_NAME")) + add(parseString(&c.DefaultUser.Pass, "GOTIFY_DEFAULTUSER_PASS")) + + add(parseInt(&c.PassStrength, "GOTIFY_PASSSTRENGTH")) + add(parseString(&c.UploadedImagesDir, "GOTIFY_UPLOADEDIMAGESDIR")) + add(parseString(&c.PluginsDir, "GOTIFY_PLUGINSDIR")) + add(parseBool(&c.Registration, "GOTIFY_REGISTRATION")) + + add(parseBool(&c.OIDC.Enabled, "GOTIFY_OIDC_ENABLED")) + add(parseString(&c.OIDC.Issuer, "GOTIFY_OIDC_ISSUER")) + add(parseString(&c.OIDC.ClientID, "GOTIFY_OIDC_CLIENTID")) + add(parseString(&c.OIDC.ClientSecret, "GOTIFY_OIDC_CLIENTSECRET")) + add(parseString(&c.OIDC.UsernameClaim, "GOTIFY_OIDC_USERNAMECLAIM")) + add(parseString(&c.OIDC.RedirectURL, "GOTIFY_OIDC_REDIRECTURL")) + add(parseBool(&c.OIDC.AutoRegister, "GOTIFY_OIDC_AUTOREGISTER")) + add(parseList(&c.OIDC.Scopes, "GOTIFY_OIDC_SCOPES")) + + add(parseString(&c.NoColor, "NOCOLOR")) + + addTrailingSlashToPaths(c) + + return c, logs } func addTrailingSlashToPaths(conf *Configuration) { diff --git a/config/config_test.go b/config/config_test.go index 3e6cc572..c6ca1972 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -12,16 +12,26 @@ import ( func TestConfigEnv(t *testing.T) { mode.Set(mode.TestDev) os.Setenv("GOTIFY_DEFAULTUSER_NAME", "jmattheis") - os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS", "- push.example.tld\n- push.other.tld") + os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS", "push.example.tld,push.other.tld") os.Setenv("GOTIFY_SERVER_RESPONSEHEADERS", - "Access-Control-Allow-Origin: \"*\"\nAccess-Control-Allow-Methods: \"GET,POST\"", + `{"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET,POST"}`, ) - os.Setenv("GOTIFY_SERVER_CORS_ALLOWORIGINS", "- \".+.example.com\"\n- \"otherdomain.com\"") - os.Setenv("GOTIFY_SERVER_CORS_ALLOWMETHODS", "- \"GET\"\n- \"POST\"") - os.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "- \"Authorization\"\n- \"content-type\"") - os.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", "- \".+.example.com\"\n- \"otherdomain.com\"") + os.Setenv("GOTIFY_SERVER_CORS_ALLOWORIGINS", ".+.example.com,otherdomain.com") + os.Setenv("GOTIFY_SERVER_CORS_ALLOWMETHODS", "GET,POST") + os.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "Authorization,content-type") + os.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", ".+.example.com,otherdomain.com") - conf := Get() + defer func() { + os.Unsetenv("GOTIFY_DEFAULTUSER_NAME") + os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS") + os.Unsetenv("GOTIFY_SERVER_RESPONSEHEADERS") + os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWORIGINS") + os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWMETHODS") + os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWHEADERS") + os.Unsetenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS") + }() + + conf, _ := Get() assert.Equal(t, 80, conf.Server.Port, "should use defaults") assert.Equal(t, "jmattheis", conf.DefaultUser.Name, "should not use default but env var") assert.Equal(t, []string{"push.example.tld", "push.other.tld"}, conf.Server.SSL.LetsEncrypt.Hosts) @@ -31,20 +41,43 @@ func TestConfigEnv(t *testing.T) { assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods) assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders) assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins) +} - os.Unsetenv("GOTIFY_DEFAULTUSER_NAME") - os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS") - os.Unsetenv("GOTIFY_SERVER_RESPONSEHEADERS") - os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWORIGINS") - os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWMETHODS") - os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWHEADERS") - os.Unsetenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS") +func TestFile(t *testing.T) { + mode.Set(mode.TestDev) + dir := t.TempDir() + passPath := filepath.Join(dir, "pass") + hostsPath := filepath.Join(dir, "hosts") + assert.Nil(t, os.WriteFile(passPath, []byte("filesecret\n"), 0o600)) + assert.Nil(t, os.WriteFile(hostsPath, []byte("a.example.com,b.example.com"), 0o600)) + + os.Setenv("GOTIFY_DEFAULTUSER_PASS_FILE", passPath) + os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS_FILE", hostsPath) + defer os.Unsetenv("GOTIFY_DEFAULTUSER_PASS_FILE") + defer os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS_FILE") + + conf, _ := Get() + assert.Equal(t, "filesecret", conf.DefaultUser.Pass) + assert.Equal(t, []string{"a.example.com", "b.example.com"}, conf.Server.SSL.LetsEncrypt.Hosts) +} + +func TestGotifyConfigFile(t *testing.T) { + mode.Set(mode.TestDev) + dir := t.TempDir() + configPath := filepath.Join(dir, "custom.env") + assert.Nil(t, os.WriteFile(configPath, []byte("GOTIFY_DEFAULTUSER_NAME=fromfile\n"), 0o600)) + + os.Setenv("GOTIFY_CONFIG_FILE", configPath) + defer os.Unsetenv("GOTIFY_CONFIG_FILE") + + conf, _ := Get() + assert.Equal(t, "fromfile", conf.DefaultUser.Name) } func TestAddSlash(t *testing.T) { mode.Set(mode.TestDev) os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/images") - conf := Get() + conf, _ := Get() assert.Equal(t, "../data/images"+string(filepath.Separator), conf.UploadedImagesDir) os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR") } @@ -52,87 +85,7 @@ func TestAddSlash(t *testing.T) { func TestNotAddSlash(t *testing.T) { mode.Set(mode.TestDev) os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/") - conf := Get() + conf, _ := Get() assert.Equal(t, "../data/", conf.UploadedImagesDir) os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR") } - -func TestFileWithSyntaxErrors(t *testing.T) { - mode.Set(mode.TestDev) - file, err := os.Create("config.yml") - defer func() { - file.Close() - }() - assert.Nil(t, err) - _, err = file.WriteString(` -sdgsgsdfgsdfg -`) - file.Close() - assert.Nil(t, err) - assert.Panics(t, func() { - Get() - }) - - assert.Nil(t, os.Remove("config.yml")) -} - -func TestConfigFile(t *testing.T) { - mode.Set(mode.TestDev) - file, err := os.Create("config.yml") - defer func() { - file.Close() - }() - assert.Nil(t, err) - _, err = file.WriteString(` -server: - port: 1234 - ssl: - port: 3333 - letsencrypt: - hosts: - - push.example.tld - responseheaders: - Access-Control-Allow-Origin: "*" - Access-Control-Allow-Methods: "GET,POST" - cors: - alloworigins: - - ".*" - - ".+" - allowmethods: - - "GET" - - "POST" - allowheaders: - - "Authorization" - - "content-type" - stream: - allowedorigins: - - ".+.example.com" - - "otherdomain.com" -database: - dialect: mysql - connection: user name -defaultuser: - name: nicories - pass: 12345 -pluginsdir: data/plugins -`) - file.Close() - assert.Nil(t, err) - conf := Get() - assert.Equal(t, 1234, conf.Server.Port) - assert.Equal(t, 3333, conf.Server.SSL.Port) - assert.Equal(t, []string{"push.example.tld"}, conf.Server.SSL.LetsEncrypt.Hosts) - assert.Equal(t, "nicories", conf.DefaultUser.Name) - assert.Equal(t, "12345", conf.DefaultUser.Pass) - assert.Equal(t, "mysql", conf.Database.Dialect) - assert.Equal(t, "user name", conf.Database.Connection) - assert.Equal(t, "*", conf.Server.ResponseHeaders["Access-Control-Allow-Origin"]) - assert.Equal(t, "GET,POST", conf.Server.ResponseHeaders["Access-Control-Allow-Methods"]) - assert.Equal(t, []string{".*", ".+"}, conf.Server.Cors.AllowOrigins) - assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods) - assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders) - assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins) - assert.Equal(t, "data/plugins", conf.PluginsDir) - - assert.Nil(t, os.Remove("config.yml")) -} diff --git a/config/error.go b/config/error.go new file mode 100644 index 00000000..7cdd65fa --- /dev/null +++ b/config/error.go @@ -0,0 +1,17 @@ +package config + +import "github.com/rs/zerolog" + +// FutureLog is an intermediate type for log messages. It is used before the config was loaded because without loaded +// config we do not know the log level, so we log these messages once the config was initialized. +type FutureLog struct { + Level zerolog.Level + Msg string +} + +func futureFatal(msg string) FutureLog { + return FutureLog{ + Level: zerolog.FatalLevel, + Msg: msg, + } +} diff --git a/config/file.go b/config/file.go new file mode 100644 index 00000000..22f34adf --- /dev/null +++ b/config/file.go @@ -0,0 +1,94 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gotify/server/v2/mode" + "github.com/joho/godotenv" + "github.com/rs/zerolog" +) + +var ( + files = []string{"gotify-server.env.local", "gotify-server.env"} + absoluteFiles = []string{"/etc/gotify/server.env"} + osExecutable = os.Executable + osStat = os.Stat +) + +func loadFiles() []FutureLog { + var logs []FutureLog + dir, log := getExecutableOrWorkDir() + if log != nil { + logs = append(logs, *log) + } + + for _, file := range getFiles(dir) { + _, fileErr := osStat(file) + if fileErr == nil { + if err := godotenv.Load(file); err != nil { + logs = append(logs, futureFatal(fmt.Sprintf("cannot load file %s: %s", file, err))) + } else { + logs = append(logs, FutureLog{ + Level: zerolog.InfoLevel, + Msg: fmt.Sprintf("Loading file %s", file), + }) + } + } else if os.IsNotExist(fileErr) { + continue + } else { + logs = append(logs, FutureLog{ + Level: zerolog.WarnLevel, + Msg: fmt.Sprintf("cannot read file %s because %s", file, fileErr), + }) + } + } + return logs +} + +func getExecutableOrWorkDir() (string, *FutureLog) { + dir, err := getExecutableDir() + // when using `go run main.go` the executable lives in th temp directory therefore the env.development + // will not be read, this enforces that the current work directory is used in dev mode. + if err != nil || mode.Get() == mode.Dev { + return filepath.Dir("."), err + } + return dir, nil +} + +func getExecutableDir() (string, *FutureLog) { + ex, err := osExecutable() + if err != nil { + return "", &FutureLog{ + Level: zerolog.ErrorLevel, + Msg: "Could not get path of executable using working directory instead. " + err.Error(), + } + } + return filepath.Dir(ex), nil +} + +func getFiles(relativeTo string) []string { + var result []string + if configFile := os.Getenv("GOTIFY_CONFIG_FILE"); configFile != "" { + result = append(result, configFile) + } + for _, file := range files { + result = append(result, filepath.Join(relativeTo, file)) + } + if configHome := getConfigHome(); configHome != "" { + result = append(result, filepath.Join(configHome, "gotify/gotify-server.env")) + } + result = append(result, absoluteFiles...) + return result +} + +func getConfigHome() string { + if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" { + return configHome + } + if homeDir, err := os.UserHomeDir(); err == nil { + return filepath.Join(homeDir, ".config") + } + return "" +} diff --git a/config/loglevel.go b/config/loglevel.go new file mode 100644 index 00000000..786946de --- /dev/null +++ b/config/loglevel.go @@ -0,0 +1,25 @@ +package config + +import ( + "errors" + + "github.com/rs/zerolog" +) + +// LogLevel type that provides helper methods for decoding. +type LogLevel zerolog.Level + +// Decode decodes a string to a log level. +func (ll *LogLevel) Decode(value string) error { + if level, err := zerolog.ParseLevel(value); err == nil { + *ll = LogLevel(level) + return nil + } + *ll = LogLevel(zerolog.InfoLevel) + return errors.New("unknown log level") +} + +// AsZeroLogLevel converts the LogLevel to a zerolog.Level. +func (ll LogLevel) AsZeroLogLevel() zerolog.Level { + return zerolog.Level(ll) +} diff --git a/config/loglevel_test.go b/config/loglevel_test.go new file mode 100644 index 00000000..6bee82a6 --- /dev/null +++ b/config/loglevel_test.go @@ -0,0 +1,22 @@ +package config + +import ( + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func TestLogLevel_Decode_success(t *testing.T) { + ll := new(LogLevel) + err := ll.Decode("fatal") + assert.Nil(t, err) + assert.Equal(t, ll.AsZeroLogLevel(), zerolog.FatalLevel) +} + +func TestLogLevel_Decode_fail(t *testing.T) { + ll := new(LogLevel) + err := ll.Decode("asdasdasdasdasdasd") + assert.EqualError(t, err, "unknown log level") + assert.Equal(t, ll.AsZeroLogLevel(), zerolog.InfoLevel) +} diff --git a/config/parse.go b/config/parse.go new file mode 100644 index 00000000..ce4d8e1f --- /dev/null +++ b/config/parse.go @@ -0,0 +1,99 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" +) + +func lookupEnv(env string) (string, bool, error) { + if raw, ok := os.LookupEnv(env); ok { + return raw, true, nil + } + path, ok := os.LookupEnv(env + "_FILE") + if !ok { + return "", false, nil + } + data, err := os.ReadFile(path) + if err != nil { + return "", false, fmt.Errorf("read file for %s_FILE (%s): %w", env, path, err) + } + return strings.TrimRight(string(data), "\r\n"), true, nil +} + +func parseString(target *string, env string) error { + raw, ok, err := lookupEnv(env) + if err != nil { + return err + } + if ok { + *target = raw + } + return nil +} + +func parseInt(target *int, env string) error { + raw, ok, err := lookupEnv(env) + if err != nil { + return err + } + if !ok { + return nil + } + n, err := strconv.Atoi(raw) + if err != nil { + return fmt.Errorf("invalid int for %s (%q): %w", env, raw, err) + } + *target = n + return nil +} + +func parseBool(target *bool, env string) error { + raw, ok, err := lookupEnv(env) + if err != nil { + return err + } + if !ok { + return nil + } + b, err := strconv.ParseBool(raw) + if err != nil { + return fmt.Errorf("invalid bool for %s (%q): %w", env, raw, err) + } + *target = b + return nil +} + +func parseList(target *[]string, env string) error { + raw, ok, err := lookupEnv(env) + if err != nil { + return err + } + if !ok || raw == "" { + return nil + } + var out []string + for part := range strings.SplitSeq(raw, ",") { + out = append(out, strings.TrimSpace(part)) + } + *target = out + return nil +} + +func parseMap(target *map[string]string, env string) error { + raw, ok, err := lookupEnv(env) + if err != nil { + return err + } + if !ok || raw == "" { + return nil + } + out := map[string]string{} + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return fmt.Errorf("invalid JSON for %s: %w", env, err) + } + *target = out + return nil +} diff --git a/go.mod b/go.mod index 3e641d3e..36057dbf 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 github.com/gotify/plugin-api v1.0.0 github.com/h2non/filetype v1.1.3 - github.com/jinzhu/configor v1.2.2 + github.com/joho/godotenv v1.5.1 github.com/mattn/go-isatty v0.0.20 github.com/robfig/cron v1.2.0 github.com/rs/zerolog v1.35.1 @@ -26,7 +26,6 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect diff --git a/go.sum b/go.sum index a6dae214..65920935 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,5 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -83,12 +80,12 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= -github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA= -github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= diff --git a/gotify-server.env.example b/gotify-server.env.example new file mode 100644 index 00000000..3f5bbc3c --- /dev/null +++ b/gotify-server.env.example @@ -0,0 +1,273 @@ +# Example environment variables for the server. +# Save as `gotify-server.env` (or export the variables) when edited. +# +# Configuration sources are read in the order listed below. Settings will +# never be overridden. Thus, the first occurrence of a setting will be used. +# +# Order: +# 1. Environment variables (already exported in the process environment) +# 2. Loaded from $GOTIFY_CONFIG_FILE +# 3. gotify-server.env.local (in the same directory as the binary) +# 4. gotify-server.env (in the same directory as the binary) +# 5. $XDG_CONFIG_HOME/gotify/gotify-server.env +# ($XDG_CONFIG_HOME falls back to $HOME/.config when unset) +# 6. /etc/gotify/server.env +# +# Value types used below: +# text a plain string value. +# number an integer value. +# boolean `true` or `false`. +# text-list comma-separated list of strings; whitespace around each entry is trimmed. +# Example: a,b,c +# json-map a JSON object mapping string keys to string values. +# Example: {"X-Foo":"bar","X-Baz":"qux"} +# +# Every variable also supports a "_FILE" suffix that reads the value from a +# file at the given path (useful for Docker / Kubernetes secrets), e.g.: +# GOTIFY_DEFAULTUSER_PASS_FILE=/run/secrets/admin_pass + + +# Interval in seconds between TCP keepalive probes on accepted connections. !! Only change this if you know what you are doing. +# +# Example: 0 uses the Go default (15s) +# Example: -1 disables keepalives entirely. +# Type: number +GOTIFY_SERVER_KEEPALIVEPERIODSECONDS=0 + +# The network address the HTTP server binds to. Leave empty to listen on all +# interfaces (both IPv4 and IPv6). Prefix with "unix:" to listen on a Unix +# domain socket instead of a TCP port. +# +# Type: text +# Example: 192.168.178.2 +# Example: unix:/tmp/gotify.sock +GOTIFY_SERVER_LISTENADDR= + +# Port the HTTP server listens on. +# Type: number +GOTIFY_SERVER_PORT=80 + +# Enable the HTTPS listener. Requires either CERTFILE+CERTKEY or LETSENCRYPT_ENABLED=true. +# Type: boolean +GOTIFY_SERVER_SSL_ENABLED=false + +# Redirect plain HTTP requests to HTTPS. Only effective when SSL_ENABLED=true. +# Type: boolean +GOTIFY_SERVER_SSL_REDIRECTTOHTTPS=true + +# The network address the HTTPS server binds to. Leave empty to listen on all +# interfaces (both IPv4 and IPv6). Prefix with "unix:" to listen on a Unix +# domain socket instead of a TCP port. +# +# Type: text +# Example: 192.168.178.2 +# Example: unix:/tmp/gotify-ssl.sock +GOTIFY_SERVER_SSL_LISTENADDR= + +# Port the HTTPS server listens on. +# Type: number +GOTIFY_SERVER_SSL_PORT=443 + +# Path to the TLS certificate. +# Type: text +# Example: /etc/ssl/certs/gotify.crt +GOTIFY_SERVER_SSL_CERTFILE= + +# Path to the TLS private key. +# Type: text +# Example: /etc/ssl/private/gotify.key +GOTIFY_SERVER_SSL_CERTKEY= + +# Obtain the TLS certificate automatically from Let's Encrypt. +# Requires SSL_ENABLED=true and LETSENCRYPT_ACCEPTTOS=true. +# Type: boolean +GOTIFY_SERVER_SSL_LETSENCRYPT_ENABLED=false + +# Accept the Let's Encrypt Terms of Service. +# Type: boolean +GOTIFY_SERVER_SSL_LETSENCRYPT_ACCEPTTOS=false + +# Directory where issued certificates and ACME account data are persisted. Must +# be writable by the server. +# +# Type: text +# Example: /var/lib/gotify/certs +GOTIFY_SERVER_SSL_LETSENCRYPT_CACHE=data/certs + +# Override the ACME directory URL. Leave empty to use the Let's Encrypt +# production server. The staging server has higher rate limits useful for +# testing but issues certificates that are not publicly trusted. +# +# Type: text +# Example: https://acme-staging-v02.api.letsencrypt.org/directory +GOTIFY_SERVER_SSL_LETSENCRYPT_DIRECTORYURL= + +# Hosts Let's Encrypt should issue certificates for. Each host must resolve +# publicly to this server. +# +# Type: text-list +# Example: mydomain.tld,myotherdomain.tld +GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS= + +# Extra HTTP headers attached to every response. +# Type: json-map +# Example: {"X-Custom-Header":"custom value"} +GOTIFY_SERVER_RESPONSEHEADERS= + +# IPs or CIDR ranges of proxies whose X-Forwarded-For header is trusted to +# determine the real client IP. Include 127.0.0.1 when terminating TLS in a +# sidecar on the same host. +# +# Type: text-list +# Example: 127.0.0.1/32,::1 +GOTIFY_SERVER_TRUSTEDPROXIES= + +# Set the Secure flag on session cookies, restricting them to HTTPS +# connections. Enable when the server is reachable over HTTPS. +# +# Type: boolean +GOTIFY_SERVER_SECURECOOKIE=false + +# Allowed origins (regex) for cross-origin requests. Setting any CORS_* value +# enables CORS handling. +# +# Type: text-list +# Example: .+\.example\.com,otherdomain\.com +GOTIFY_SERVER_CORS_ALLOWORIGINS= + +# HTTP methods permitted in cross-origin requests. +# Type: text-list +# Example: GET,POST +GOTIFY_SERVER_CORS_ALLOWMETHODS= + +# Request headers permitted in cross-origin requests. +# Type: text-list +# Example: Authorization,content-type +GOTIFY_SERVER_CORS_ALLOWHEADERS= + +# Interval in seconds between WebSocket ping frames sent to streaming clients. +# Only change this if you know what you are doing. +# +# Type: number +GOTIFY_SERVER_STREAM_PINGPERIODSECONDS=45 + +# Allowed origins (regex) for WebSocket upgrade requests. Same-origin +# connections are always permitted regardless of this setting. +# +# Type: text-list +# Example: .+\.example\.com,otherdomain\.com +GOTIFY_SERVER_STREAM_ALLOWEDORIGINS= + +# Enable OpenID Connect Single Sign-On, allowing users to authenticate via an +# external identity provider (e.g. Authelia, Dex, Keycloak). The provider must +# support PKCE (https://oauth.net/2/pkce/); IdPs without PKCE support are +# currently unsupported. +# +# Type: boolean +GOTIFY_OIDC_ENABLED=false + +# Base URL of the identity provider. It will be used to discover OIDC endpoints +# via /.well-known/openid-configuration. +# +# Type: text +# Example: https://auth.example.com/realms/myrealm +GOTIFY_OIDC_ISSUER= + +# Client ID registered with the identity provider for this application. +# Type: text +# Example: gotify +GOTIFY_OIDC_CLIENTID= + +# Client secret paired with the client ID. +# Type: text +# Example: super-secret +GOTIFY_OIDC_CLIENTSECRET= + +# Callback URL the identity provider redirects to after authentication. Must +# end with `/auth/oidc/callback` and match exactly what is registered at the +# provider. When Gotify is served on a sub-path behind a reverse proxy, include +# it (e.g. https://example.org/gotify/auth/oidc/callback). To support OIDC +# login in the Android app, also register `gotify://oidc/callback` as an +# additional redirect URL at the provider. +# +# Type: text +# Example: https://gotify.example.org/auth/oidc/callback +GOTIFY_OIDC_REDIRECTURL= + +# Automatically create a local user on first OIDC login. When disabled, only +# users that already exist in Gotify can sign in via OIDC. +# +# Type: boolean +GOTIFY_OIDC_AUTOREGISTER=true + +# OIDC ID-token claim used as the local username. Common values are +# preferred_username or email. +# +# Type: text +# Example: email +GOTIFY_OIDC_USERNAMECLAIM=preferred_username + +# OIDC scopes to request from the identity provider. +# Type: text-list +# Example: openid,profile,email +GOTIFY_OIDC_SCOPES= + +# Database driver to use. For mysql and postgres the target database must +# already exist and the configured user must have sufficient permissions. +# +# Type: one of sqlite3, mysql, postgres +GOTIFY_DATABASE_DIALECT=sqlite3 + +# Database connection string. Format depends on the dialect. +# Type: text +# Example: +# sqlite3: path/to/database.db +# mysql: gotify:secret@tcp(localhost:3306)/gotifydb?charset=utf8&parseTime=True&loc=Local +# postgres: host=localhost port=5432 user=gotify dbname=gotifydb password=secret +# When using postgres without SSL, append `sslmode=disable` (see https://github.com/gotify/server/issues/90). +GOTIFY_DATABASE_CONNECTION=data/gotify.db + +# Username for the initial admin account. Only applied when the database is +# first created; later changes must be made through the WebUI. +# +# Type: text +# Example: myadmin +GOTIFY_DEFAULTUSER_NAME=admin + +# Password for the initial admin account. Only applied when the database is +# first created. +# +# Type: text +# Example: super-secret-password +GOTIFY_DEFAULTUSER_PASS=admin + +# Bcrypt cost factor for password hashes. Higher values are more secure but slower. +# Type: number +GOTIFY_PASSSTRENGTH=10 + +# Directory where application icons and other uploaded images are stored. Must +# be writable by the server. +# +# Type: text +# Example: /var/lib/gotify/images +GOTIFY_UPLOADEDIMAGESDIR=data/images + +# Directory scanned for plugin shared libraries on startup. Leave empty to +# disable plugin loading. +# +# Type: text +# Example: /var/lib/gotify/plugins +GOTIFY_PLUGINSDIR=data/plugins + +# Allow unauthenticated users to register new user accounts via the public +# registration endpoint. +# +# Type: boolean +GOTIFY_REGISTRATION=false + +# Disable colored log output. Set to "1" to force-disable colors regardless of +# whether stdout is a terminal. When unset, colors are emitted only if stdout +# is a TTY. See https://no-color.org/. +# +# Type: text +NOCOLOR= From 0eaadd66c3fa457dd5e295dc18b903c4078bd006 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Tue, 26 May 2026 21:31:32 +0200 Subject: [PATCH 2/4] fix: don't ignore cors requests in dev mode This will make testing easier, as it's more similar to the actual prod deployment. We don't have to rewrite anything in vite, as the host and origin is the same. --- api/stream/stream.go | 4 ---- auth/cors.go | 32 +++++++++++--------------------- auth/cors_test.go | 21 --------------------- router/router_test.go | 11 ----------- ui/vite.config.ts | 4 +--- 5 files changed, 12 insertions(+), 60 deletions(-) diff --git a/api/stream/stream.go b/api/stream/stream.go index 384e538b..62061c2e 100644 --- a/api/stream/stream.go +++ b/api/stream/stream.go @@ -11,7 +11,6 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/gotify/server/v2/auth" - "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" ) @@ -214,9 +213,6 @@ func newUpgrader(allowedWebSocketOrigins []string) *websocket.Upgrader { ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { - if mode.IsDev() { - return true - } return isAllowedOrigin(r, compiledAllowedOrigins) }, } diff --git a/auth/cors.go b/auth/cors.go index 3cfb1fed..a5e1c501 100644 --- a/auth/cors.go +++ b/auth/cors.go @@ -7,7 +7,6 @@ import ( "github.com/gin-contrib/cors" "github.com/gotify/server/v2/config" - "github.com/gotify/server/v2/mode" ) // CorsConfig generates a config to use in gin cors middleware based on server configuration. @@ -16,28 +15,19 @@ func CorsConfig(conf *config.Configuration) cors.Config { MaxAge: 12 * time.Hour, AllowBrowserExtensions: true, } - if mode.IsDev() { - corsConf.AllowAllOrigins = true - corsConf.AllowMethods = []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"} - corsConf.AllowHeaders = []string{ - "X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin", - "Connection", "Accept-Encoding", "Accept-Language", "Host", - } - } else { - compiledOrigins := compileAllowedCORSOrigins(conf.Server.Cors.AllowOrigins) - corsConf.AllowMethods = conf.Server.Cors.AllowMethods - corsConf.AllowHeaders = conf.Server.Cors.AllowHeaders - corsConf.AllowOriginFunc = func(origin string) bool { - for _, compiledOrigin := range compiledOrigins { - if compiledOrigin.MatchString(strings.ToLower(origin)) { - return true - } + compiledOrigins := compileAllowedCORSOrigins(conf.Server.Cors.AllowOrigins) + corsConf.AllowMethods = conf.Server.Cors.AllowMethods + corsConf.AllowHeaders = conf.Server.Cors.AllowHeaders + corsConf.AllowOriginFunc = func(origin string) bool { + for _, compiledOrigin := range compiledOrigins { + if compiledOrigin.MatchString(strings.ToLower(origin)) { + return true } - return false - } - if allowedOrigin := headerIgnoreCase(conf, "access-control-allow-origin"); allowedOrigin != "" && len(compiledOrigins) == 0 { - corsConf.AllowOrigins = append(corsConf.AllowOrigins, allowedOrigin) } + return false + } + if allowedOrigin := headerIgnoreCase(conf, "access-control-allow-origin"); allowedOrigin != "" && len(compiledOrigins) == 0 { + corsConf.AllowOrigins = append(corsConf.AllowOrigins, allowedOrigin) } return corsConf diff --git a/auth/cors_test.go b/auth/cors_test.go index 928a34b3..2192f9f6 100644 --- a/auth/cors_test.go +++ b/auth/cors_test.go @@ -50,24 +50,3 @@ func TestEmptyCorsConfigWithResponseHeaders(t *testing.T) { AllowBrowserExtensions: true, }, actual) } - -func TestDevCorsConfig(t *testing.T) { - mode.Set(mode.Dev) - serverConf := config.Configuration{} - serverConf.Server.Cors.AllowOrigins = []string{"http://test.com"} - serverConf.Server.Cors.AllowHeaders = []string{"content-type"} - serverConf.Server.Cors.AllowMethods = []string{"GET"} - - actual := CorsConfig(&serverConf) - - assert.Equal(t, cors.Config{ - AllowHeaders: []string{ - "X-Gotify-Key", "Authorization", "Content-Type", "Upgrade", "Origin", - "Connection", "Accept-Encoding", "Accept-Language", "Host", - }, - AllowMethods: []string{"GET", "POST", "DELETE", "OPTIONS", "PUT"}, - MaxAge: 12 * time.Hour, - AllowAllOrigins: true, - AllowBrowserExtensions: true, - }, actual) -} diff --git a/router/router_test.go b/router/router_test.go index 4ac7c123..f72e95a8 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -59,17 +59,6 @@ func (s *IntegrationSuite) TestVersionInfo() { doRequestAndExpect(s.T(), req, 200, `{"version":"1.0.0", "commit":"asdasds", "buildDate":"2018-02-20-17:30:47"}`) } -func (s *IntegrationSuite) TestHeaderInDev() { - mode.Set(mode.TestDev) - req := s.newRequest("GET", "version", "") - // Needs an origin to indicate that it is a CORS request - req.Header.Add("Origin", "some-origin") - - res, err := client.Do(req) - assert.Nil(s.T(), err) - assert.NotEmpty(s.T(), res.Header.Get("Access-Control-Allow-Origin")) -} - func (s *IntegrationSuite) TestHeaderInProd() { mode.Set(mode.Prod) req := s.newRequest("GET", "version", "") diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 309c2153..5209e770 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -40,13 +40,11 @@ export default defineConfig({ proxy: { '^/(application|message|client|current|user|plugin|version|image|auth)': { target: `http://localhost:${GOTIFY_SERVER_PORT}/`, - changeOrigin: true, secure: false, }, '/stream': { - target: `ws://localhost:${GOTIFY_SERVER_PORT}/`, + target: `http://localhost:${GOTIFY_SERVER_PORT}/`, ws: true, - rewriteWsOrigin: true, }, }, cors: false, From 04d6c310477c406272985b6445812b15698de65a Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Tue, 26 May 2026 21:50:20 +0200 Subject: [PATCH 3/4] fix: default for oidc scopes --- api/oidc.go | 7 +------ config/config.go | 1 + gotify-server.env.example | 3 +-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/api/oidc.go b/api/oidc.go index 706d318f..a686f78a 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -23,11 +23,6 @@ import ( ) func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI { - scopes := conf.OIDC.Scopes - if len(scopes) == 0 { - scopes = []string{"openid", "profile", "email"} - } - cookieKey := make([]byte, 32) if _, err := rand.Read(cookieKey); err != nil { log.Fatal().Err(err).Msg("failed to generate OIDC cookie key") @@ -46,7 +41,7 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo conf.OIDC.ClientID, conf.OIDC.ClientSecret, conf.OIDC.RedirectURL, - scopes, + conf.OIDC.Scopes, opts..., ) if err != nil { diff --git a/config/config.go b/config/config.go index 1ff6b459..d781e9e5 100644 --- a/config/config.go +++ b/config/config.go @@ -113,6 +113,7 @@ func Get() (*Configuration, []FutureLog) { OIDC: OIDC{ UsernameClaim: "preferred_username", AutoRegister: true, + Scopes: []string{"openid", "profile", "email"}, }, } diff --git a/gotify-server.env.example b/gotify-server.env.example index 3f5bbc3c..b08070da 100644 --- a/gotify-server.env.example +++ b/gotify-server.env.example @@ -209,8 +209,7 @@ GOTIFY_OIDC_USERNAMECLAIM=preferred_username # OIDC scopes to request from the identity provider. # Type: text-list -# Example: openid,profile,email -GOTIFY_OIDC_SCOPES= +GOTIFY_OIDC_SCOPES=openid,profile,email # Database driver to use. For mysql and postgres the target database must # already exist and the configured user must have sufficient permissions. From 2e71989597e817e6278afbfa7d64d224ac7a7187 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Tue, 26 May 2026 22:01:20 +0200 Subject: [PATCH 4/4] ci: load gotify-server.env.local for ui --- ui/vite.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5209e770..014bb795 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,6 +2,12 @@ import {defineConfig} from 'vite'; import react from '@vitejs/plugin-react'; import babel from '@rolldown/plugin-babel'; +try { + process.loadEnvFile('../gotify-server.env.local'); +} catch { + // file is optional +} + const GOTIFY_SERVER_PORT = process.env.GOTIFY_SERVER_PORT ?? '80'; function decoratorPreset(options: Record) {