Postgres schema support

This commit is contained in:
Jamie Curnow committed 2026-10-01 13:22:36 +10:00
1 parent 741c9c982f
commit fc8b1fcde2
12 files changed
+117 -8

No files matched your search

+14 -4
View File
@@ -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 {
@@ -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)
@@ -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}$"
}
}
}
+16 -1
View File
@@ -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",
},
},
{
+22 -1
View File
@@ -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",
+1
View File
@@ -29,6 +29,7 @@ func TestDBConfigFile(t *testing.T) {
Password: "secret",
Name: "npm",
SSLMode: "require",
Schema: "npm",
}
require.NoError(t, SaveDBConfig(cfg))
+21 -1
View File
@@ -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
+1
View File
@@ -8,6 +8,7 @@ export interface DatabaseConfig {
password?: string;
name?: string;
sslmode?: string;
schema?: string;
}
export async function setupDatabase(
+3
View File
@@ -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"
},
+3
View File
@@ -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"
},
+3
View File
@@ -365,6 +365,9 @@
"setup-database.sqlite-description": {
"defaultMessage": "SQLite همه چیز را در یک فایل در پوشه داده ذخیره می‌کند و به پیکربندی بیشتری نیاز ندارد."
},
"setup-database.schema": {
"defaultMessage": "اسکیما"
},
"setup-database.sslmode": {
"defaultMessage": "حالت SSL"
},
+26 -1
View File
@@ -29,6 +29,7 @@ interface Payload {
password: string;
name: string;
sslmode: string;
schema: string;
}
const defaultPorts: Record<string, string> = {
@@ -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() {
</FormControl>
)}
</Field>
{values.driver === "postgres" ? (
<Field
name="schema"
validate={validateString(1, 63)}>
{({ field, form }: any) => (
<FormControl
isRequired
isInvalid={
form.errors.schema && form.touched.schema
}>
<FormLabel htmlFor="schema">
{intl.formatMessage({
id: "setup-database.schema",
})}
</FormLabel>
<Input {...field} id="schema" />
<FormErrorMessage>
{form.errors.schema}
</FormErrorMessage>
</FormControl>
)}
</Field>
) : null}
{values.driver === "postgres" ? (
<Field name="sslmode">
{({ field }: any) => (