From fc8b1fcde295976146d322ec4052c12ba5bb08e8 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Thu, 1 Oct 2026 13:22:36 +1000 Subject: [PATCH] Postgres schema support --- backend/internal/api/handler/setup.go | 18 ++++++++++--- backend/internal/api/router_setup_test.go | 3 +++ backend/internal/api/schema/setup_database.go | 4 +++ backend/internal/config/config_test.go | 17 +++++++++++- backend/internal/config/db.go | 23 +++++++++++++++- backend/internal/config/db_file_test.go | 1 + backend/internal/database/db.go | 22 ++++++++++++++- frontend/src/api/npm/setupDatabase.ts | 1 + frontend/src/locale/src/de.json | 3 +++ frontend/src/locale/src/en.json | 3 +++ frontend/src/locale/src/fa.json | 3 +++ frontend/src/pages/SetupDatabase/index.tsx | 27 ++++++++++++++++++- 12 files changed, 117 insertions(+), 8 deletions(-) diff --git a/backend/internal/api/handler/setup.go b/backend/internal/api/handler/setup.go index ba3350909..91535dc03 100644 --- a/backend/internal/api/handler/setup.go +++ b/backend/internal/api/handler/setup.go @@ -37,11 +37,21 @@ func SetupDatabase(onConfigured func() error) func(http.ResponseWriter, *http.Re return } - // Sqlite lives in the data folder and needs nothing else - if newCfg.GetDriver() == config.DatabaseSqlite { + switch newCfg.GetDriver() { + case config.DatabaseSqlite: + // Sqlite lives in the data folder and needs nothing else newCfg = config.DBConfig{Driver: config.DatabaseSqlite} - } else if newCfg.SSLMode == "" { - newCfg.SSLMode = "disable" + case config.DatabasePostgres: + if newCfg.SSLMode == "" { + newCfg.SSLMode = "disable" + } + newCfg.Schema = newCfg.GetSchema() + default: + if newCfg.SSLMode == "" { + newCfg.SSLMode = "disable" + } + // Schemas are a postgres concept + newCfg.Schema = "" } if err := database.TestConnection(newCfg); err != nil { diff --git a/backend/internal/api/router_setup_test.go b/backend/internal/api/router_setup_test.go index 53498784d..6b7539706 100644 --- a/backend/internal/api/router_setup_test.go +++ b/backend/internal/api/router_setup_test.go @@ -50,6 +50,9 @@ func TestSetupRouter(t *testing.T) { // Schema validation assert.Equal(t, http.StatusBadRequest, post(failing, `{"driver":"oracle"}`).Code) + // Postgres schema must be a plain identifier + assert.Equal(t, http.StatusBadRequest, post(failing, `{"driver":"postgres","schema":"public; drop table x"}`).Code) + // Postgres/mysql config missing fields assert.Equal(t, http.StatusBadRequest, post(failing, `{"driver":"postgres","host":"db"}`).Code) diff --git a/backend/internal/api/schema/setup_database.go b/backend/internal/api/schema/setup_database.go index 56944672d..3cacb7c52 100644 --- a/backend/internal/api/schema/setup_database.go +++ b/backend/internal/api/schema/setup_database.go @@ -31,6 +31,10 @@ func SetupDatabase() string { "sslmode": { "type": "string", "enum": ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] + }, + "schema": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_$]{0,62}$" } } } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2563c3ffa..26b425b49 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -83,7 +83,22 @@ func TestConnectURLs(t *testing.T) { "NPM_DB_NAME=npm", }, want: want{ - gorm: "host=2.2.2.2 user=postgresuser password=pgpass dbname=npm port=9824 sslmode=strict TimeZone=UTC", + gorm: "host=2.2.2.2 user=postgresuser password=pgpass dbname=npm port=9824 sslmode=strict search_path=public TimeZone=UTC", + }, + }, + { + name: "postgres-schema", + envs: []string{ + "NPM_DB_DRIVER=postgres", + "NPM_DB_HOST=2.2.2.2", + "NPM_DB_PORT=5432", + "NPM_DB_USERNAME=postgresuser", + "NPM_DB_PASSWORD=pgpass", + "NPM_DB_NAME=npm", + "NPM_DB_SCHEMA=npm_app", + }, + want: want{ + gorm: "host=2.2.2.2 user=postgresuser password=pgpass dbname=npm port=5432 sslmode=disable search_path=npm_app TimeZone=UTC", }, }, { diff --git a/backend/internal/config/db.go b/backend/internal/config/db.go index 95737d628..457b0bbda 100644 --- a/backend/internal/config/db.go +++ b/backend/internal/config/db.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "regexp" "strings" "npm/internal/errors" @@ -13,8 +14,15 @@ const ( DatabaseSqlite = "sqlite" DatabasePostgres = "postgres" DatabaseMysql = "mysql" + + // DefaultPostgresSchema is used when no schema has been specified + DefaultPostgresSchema = "public" ) +// postgresSchemaRegex limits schema names to unquoted Postgres identifiers +// (max 63 bytes) so they can be placed in the connection string safely +var postgresSchemaRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_$]{0,62}$`) + // DBConfig holds the database connection parameters. It is populated from // NPM_DB_* environment variables when NPM_DB_DRIVER is set, otherwise from // the db.conf file in the data folder written by the setup wizard. @@ -26,6 +34,7 @@ type DBConfig struct { Password string `json:"password" envconfig:"optional,default="` Name string `json:"name" envconfig:"optional,default="` SSLMode string `json:"sslmode" envconfig:"optional,default=disable"` + Schema string `json:"schema,omitempty" envconfig:"optional,default=public"` } // GetDriver returns the lowercase driver name @@ -33,6 +42,14 @@ func (d *DBConfig) GetDriver() string { return strings.ToLower(d.Driver) } +// GetSchema returns the postgres schema, falling back to the default +func (d *DBConfig) GetSchema() string { + if d.Schema == "" { + return DefaultPostgresSchema + } + return d.Schema +} + // IsConfigured returns true when a database driver has been chosen, either // by environment variables or by the setup wizard func (d *DBConfig) IsConfigured() bool { @@ -60,6 +77,9 @@ func (d *DBConfig) IsValid() (bool, error) { if d.Username == "" { errs = append(errs, eris.New("database username is empty")) } + if d.GetDriver() == DatabasePostgres && !postgresSchemaRegex.MatchString(d.GetSchema()) { + errs = append(errs, eris.Errorf("database schema %s is invalid", d.Schema)) + } default: errs = append(errs, eris.Errorf("database driver %s is not supported. Valid options are: %s, %s or %s", d.Driver, DatabaseSqlite, DatabasePostgres, DatabaseMysql)) } @@ -73,13 +93,14 @@ func (d *DBConfig) GetGormConnectURL() string { case DatabaseSqlite: return fmt.Sprintf("%s/nginxproxymanager.db", Configuration.DataFolder) case DatabasePostgres: - return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=UTC", + return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s search_path=%s TimeZone=UTC", d.Host, d.Username, d.Password, d.Name, d.Port, d.SSLMode, + d.GetSchema(), ) case DatabaseMysql: return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", diff --git a/backend/internal/config/db_file_test.go b/backend/internal/config/db_file_test.go index be39cf787..58caae1cf 100644 --- a/backend/internal/config/db_file_test.go +++ b/backend/internal/config/db_file_test.go @@ -29,6 +29,7 @@ func TestDBConfigFile(t *testing.T) { Password: "secret", Name: "npm", SSLMode: "require", + Schema: "npm", } require.NoError(t, SaveDBConfig(cfg)) diff --git a/backend/internal/database/db.go b/backend/internal/database/db.go index 856132b41..00794e497 100644 --- a/backend/internal/database/db.go +++ b/backend/internal/database/db.go @@ -67,7 +67,27 @@ func TestConnection(cfg config.DBConfig) error { // nolint: errcheck defer sqlDB.Close() - return sqlDB.Ping() + if err := sqlDB.Ping(); err != nil { + return err + } + + // Postgres will happily connect with a search_path that doesn't exist, + // then fail to create any tables during migration + if cfg.GetDriver() == config.DatabasePostgres { + var exists bool + row := sqlDB.QueryRow( + "SELECT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = $1)", + cfg.GetSchema(), + ) + if err := row.Scan(&exists); err != nil { + return err + } + if !exists { + return eris.Errorf("schema %s does not exist in database %s", cfg.GetSchema(), cfg.Name) + } + } + + return nil } // Close closes and discards the current instance, if any diff --git a/frontend/src/api/npm/setupDatabase.ts b/frontend/src/api/npm/setupDatabase.ts index fc8f8d7d9..eba1e3dde 100644 --- a/frontend/src/api/npm/setupDatabase.ts +++ b/frontend/src/api/npm/setupDatabase.ts @@ -8,6 +8,7 @@ export interface DatabaseConfig { password?: string; name?: string; sslmode?: string; + schema?: string; } export async function setupDatabase( diff --git a/frontend/src/locale/src/de.json b/frontend/src/locale/src/de.json index c7e87bc2b..fb6ffed48 100644 --- a/frontend/src/locale/src/de.json +++ b/frontend/src/locale/src/de.json @@ -365,6 +365,9 @@ "setup-database.sqlite-description": { "defaultMessage": "SQLite speichert alles in einer einzigen Datei im Datenordner und benötigt keine weitere Konfiguration." }, + "setup-database.schema": { + "defaultMessage": "Schema" + }, "setup-database.sslmode": { "defaultMessage": "SSL-Modus" }, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 5e852e312..7976c4279 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -641,6 +641,9 @@ "setup-database.sqlite-description": { "defaultMessage": "SQLite stores everything in a single file in the data folder and requires no further configuration." }, + "setup-database.schema": { + "defaultMessage": "Schema" + }, "setup-database.sslmode": { "defaultMessage": "SSL Mode" }, diff --git a/frontend/src/locale/src/fa.json b/frontend/src/locale/src/fa.json index 481393e1b..f5c64621c 100644 --- a/frontend/src/locale/src/fa.json +++ b/frontend/src/locale/src/fa.json @@ -365,6 +365,9 @@ "setup-database.sqlite-description": { "defaultMessage": "SQLite همه چیز را در یک فایل در پوشه داده ذخیره می‌کند و به پیکربندی بیشتری نیاز ندارد." }, + "setup-database.schema": { + "defaultMessage": "اسکیما" + }, "setup-database.sslmode": { "defaultMessage": "حالت SSL" }, diff --git a/frontend/src/pages/SetupDatabase/index.tsx b/frontend/src/pages/SetupDatabase/index.tsx index c84ca4306..6478c7e29 100644 --- a/frontend/src/pages/SetupDatabase/index.tsx +++ b/frontend/src/pages/SetupDatabase/index.tsx @@ -29,6 +29,7 @@ interface Payload { password: string; name: string; sslmode: string; + schema: string; } const defaultPorts: Record = { @@ -62,7 +63,7 @@ function SetupDatabase() { password: values.password, name: values.name, ...(values.driver === "postgres" - ? { sslmode: values.sslmode } + ? { sslmode: values.sslmode, schema: values.schema } : {}), }; @@ -128,6 +129,7 @@ function SetupDatabase() { password: "", name: "", sslmode: "disable", + schema: "public", } as Payload } onSubmit={onSubmit}> @@ -276,6 +278,29 @@ function SetupDatabase() { )} + {values.driver === "postgres" ? ( + + {({ field, form }: any) => ( + + + {intl.formatMessage({ + id: "setup-database.schema", + })} + + + + {form.errors.schema} + + + )} + + ) : null} {values.driver === "postgres" ? ( {({ field }: any) => (