mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-15 10:18:22 +00:00
Dabase setup widget
This commit is contained in:
1 parent
853ef909a7
commit
741c9c982f
25 files changed
+1133
-66
No files matched your search
+68
-44
@@ -30,59 +30,83 @@ func main() {
|
||||
config.CreateDataFolders()
|
||||
logger.Info("Build Version: %s (%s)", version, commit)
|
||||
|
||||
migrations.Migrate(func() {
|
||||
if err := jwt.LoadKeys(); err != nil {
|
||||
logger.Error("KeysError", err)
|
||||
if err := config.LoadDBConfig(); err != nil {
|
||||
logger.Error("DatabaseConfigError", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if config.Configuration.DB.IsConfigured() {
|
||||
if err := start(); err != nil {
|
||||
logger.Error("StartupError", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
logger.Warn("No database configured, starting in Database Setup Mode")
|
||||
api.SetHandler(api.NewSetupRouter(start))
|
||||
}
|
||||
|
||||
checkSetup()
|
||||
// Http server
|
||||
go api.StartServer()
|
||||
|
||||
// Internal Job Queue
|
||||
jobqueue.Start()
|
||||
certificate.AddPendingJobs()
|
||||
host.AddPendingJobs()
|
||||
irqchan := make(chan os.Signal, 1)
|
||||
signal.Notify(irqchan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Http server
|
||||
api.StartServer()
|
||||
irqchan := make(chan os.Signal, 1)
|
||||
signal.Notify(irqchan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
for irq := range irqchan {
|
||||
if irq == syscall.SIGINT || irq == syscall.SIGTERM {
|
||||
logger.Info("Got ", irq, " shutting server down ...")
|
||||
// Close db
|
||||
sqlDB, _ := database.GetDB().DB()
|
||||
err := sqlDB.Close()
|
||||
if err != nil {
|
||||
logger.Error("DatabaseCloseError", err)
|
||||
}
|
||||
// nolint
|
||||
jobqueue.Shutdown()
|
||||
break
|
||||
}
|
||||
for irq := range irqchan {
|
||||
if irq == syscall.SIGINT || irq == syscall.SIGTERM {
|
||||
logger.Info("Got %v, shutting server down ...", irq)
|
||||
database.Close()
|
||||
// nolint
|
||||
jobqueue.Shutdown()
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// start connects to the configured database, applies migrations and
|
||||
// starts everything that depends on the database, then switches the http
|
||||
// server over to the full router. It runs at boot when a database is
|
||||
// already configured, otherwise when the database setup wizard completes.
|
||||
func start() error {
|
||||
if !migrations.Migrate(func() {}) {
|
||||
return errors.ErrDatabaseUnavailable
|
||||
}
|
||||
|
||||
if err := jwt.LoadKeys(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkSetup(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Internal Job Queue
|
||||
jobqueue.Start()
|
||||
certificate.AddPendingJobs()
|
||||
host.AddPendingJobs()
|
||||
|
||||
config.IsDBSetup = true
|
||||
api.SetHandler(api.NewRouter())
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSetup Quick check by counting the number of users in the database
|
||||
func checkSetup() {
|
||||
func checkSetup() error {
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
|
||||
if db != nil {
|
||||
db.Model(&user.Model{}).
|
||||
Where("is_disabled = ?", false).
|
||||
Where("is_system = ?", false).
|
||||
Count(&count)
|
||||
|
||||
if count == 0 {
|
||||
logger.Warn("No users found, starting in Setup Mode")
|
||||
} else {
|
||||
config.IsSetup = true
|
||||
logger.Info("Application is setup")
|
||||
}
|
||||
} else {
|
||||
logger.Error("DatabaseError", errors.ErrDatabaseUnavailable)
|
||||
if db == nil {
|
||||
return errors.ErrDatabaseUnavailable
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&user.Model{}).
|
||||
Where("is_disabled = ?", false).
|
||||
Where("is_system = ?", false).
|
||||
Count(&count)
|
||||
|
||||
if count == 0 {
|
||||
logger.Warn("No users found, starting in Setup Mode")
|
||||
} else {
|
||||
config.IsSetup = true
|
||||
logger.Info("Application is setup")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -138,6 +138,11 @@
|
||||
"$ref": "file://./paths/schema/get.json"
|
||||
}
|
||||
},
|
||||
"/setup/database": {
|
||||
"post": {
|
||||
"$ref": "file://./paths/setup/database/post.json"
|
||||
}
|
||||
},
|
||||
"/settings": {
|
||||
"get": {
|
||||
"$ref": "file://./paths/settings/get.json"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"type": "object",
|
||||
"description": "HealthObject",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "commit", "healthy", "setup"],
|
||||
"required": ["version", "commit", "healthy", "db_setup", "setup"],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
@@ -18,6 +18,10 @@
|
||||
"type": "boolean",
|
||||
"description": "Healthy?"
|
||||
},
|
||||
"db_setup": {
|
||||
"type": "boolean",
|
||||
"description": "Is the database configured?"
|
||||
},
|
||||
"setup": {
|
||||
"type": "boolean",
|
||||
"description": "Is the application set up?"
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"version": "3.0.0",
|
||||
"commit": "9f119b6",
|
||||
"healthy": true,
|
||||
"db_setup": true,
|
||||
"setup": true
|
||||
}
|
||||
}
|
||||
@@ -32,6 +33,7 @@
|
||||
"version": "3.0.0",
|
||||
"commit": "9f119b6",
|
||||
"healthy": false,
|
||||
"db_setup": true,
|
||||
"setup": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"operationId": "setupDatabase",
|
||||
"summary": "Configure the database connection. Only available before a database has been configured",
|
||||
"tags": ["Setup"],
|
||||
"requestBody": {
|
||||
"description": "Database Connection Payload",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": "{{schema.SetupDatabase}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "200 response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["result"],
|
||||
"properties": {
|
||||
"result": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"default": {
|
||||
"value": {
|
||||
"result": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "400 response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["error"],
|
||||
"properties": {
|
||||
"result": {
|
||||
"type": "object",
|
||||
"nullable": true
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/ErrorObject"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"default": {
|
||||
"value": {
|
||||
"result": null,
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": "failed to connect to `user=npm database=npm`: dial tcp 172.18.0.2:5432: connect: connection refused"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "403 response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["error"],
|
||||
"properties": {
|
||||
"result": {
|
||||
"type": "object",
|
||||
"nullable": true
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/ErrorObject"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"default": {
|
||||
"value": {
|
||||
"result": null,
|
||||
"error": {
|
||||
"code": 403,
|
||||
"message": "Database is already configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type healthCheckResponse struct {
|
||||
Commit string `json:"commit"`
|
||||
AcmeShVersion string `json:"acme.sh"`
|
||||
Healthy bool `json:"healthy"`
|
||||
IsDBSetup bool `json:"db_setup"`
|
||||
IsSetup bool `json:"setup"`
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ func Health() func(http.ResponseWriter, *http.Request) {
|
||||
Version: config.Version,
|
||||
Commit: config.Commit,
|
||||
Healthy: true,
|
||||
IsDBSetup: config.IsDBSetup,
|
||||
IsSetup: config.IsSetup,
|
||||
AcmeShVersion: acme.GetAcmeShVersion(),
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ func replaceIncomingSchemas(swaggerSchema []byte) []byte {
|
||||
str = strings.ReplaceAll(str, `"{{schema.CreateCertificate}}"`, schema.CreateCertificate())
|
||||
str = strings.ReplaceAll(str, `"{{schema.UpdateCertificate}}"`, schema.UpdateCertificate(""))
|
||||
|
||||
str = strings.ReplaceAll(str, `"{{schema.SetupDatabase}}"`, schema.SetupDatabase())
|
||||
|
||||
str = strings.ReplaceAll(str, `"{{schema.CreateSetting}}"`, schema.CreateSetting())
|
||||
str = strings.ReplaceAll(str, `"{{schema.UpdateSetting}}"`, schema.UpdateSetting())
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
c "npm/internal/api/context"
|
||||
h "npm/internal/api/http"
|
||||
"npm/internal/config"
|
||||
"npm/internal/database"
|
||||
"npm/internal/logger"
|
||||
)
|
||||
|
||||
// SetupDatabase tests the connection parameters provided by the setup
|
||||
// wizard, saves them to the db.conf file and then calls onConfigured to
|
||||
// connect, migrate and finish starting the application.
|
||||
// Route: POST /setup/database
|
||||
func SetupDatabase(onConfigured func() error) func(http.ResponseWriter, *http.Request) {
|
||||
// Serialises submissions so that two browsers can't race to
|
||||
// configure different databases
|
||||
var mu sync.Mutex
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if config.IsDBSetup {
|
||||
h.ResultErrorJSON(w, r, http.StatusForbidden, "Database is already configured", nil)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte)
|
||||
var newCfg config.DBConfig
|
||||
if err := json.Unmarshal(bodyBytes, &newCfg); err != nil {
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Sqlite lives in the data folder and needs nothing else
|
||||
if newCfg.GetDriver() == config.DatabaseSqlite {
|
||||
newCfg = config.DBConfig{Driver: config.DatabaseSqlite}
|
||||
} else if newCfg.SSLMode == "" {
|
||||
newCfg.SSLMode = "disable"
|
||||
}
|
||||
|
||||
if err := database.TestConnection(newCfg); err != nil {
|
||||
logger.Warn("Database setup connection test failed: %s", err.Error())
|
||||
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveDBConfig(newCfg); err != nil {
|
||||
logger.Error("DatabaseConfigSaveError", err)
|
||||
h.ResultErrorJSON(w, r, http.StatusInternalServerError, "Unable to save database configuration", nil)
|
||||
return
|
||||
}
|
||||
|
||||
prevCfg := config.Configuration.DB
|
||||
config.Configuration.DB = newCfg
|
||||
logger.Info("Database configuration saved to %s", config.GetDBConfigFile())
|
||||
|
||||
if err := onConfigured(); err != nil {
|
||||
logger.Error("DatabaseSetupError", err)
|
||||
// Roll back so the wizard can be attempted again
|
||||
database.Close()
|
||||
config.Configuration.DB = prevCfg
|
||||
if rmErr := config.RemoveDBConfig(); rmErr != nil {
|
||||
logger.Error("DatabaseConfigRemoveError", rmErr)
|
||||
}
|
||||
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
h.ResultResponseJSON(w, r, http.StatusOK, true)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"npm/internal/api/handler"
|
||||
h "npm/internal/api/http"
|
||||
"npm/internal/api/middleware"
|
||||
"npm/internal/api/schema"
|
||||
"npm/internal/config"
|
||||
@@ -28,6 +29,37 @@ import (
|
||||
|
||||
// NewRouter returns a new router object
|
||||
func NewRouter() http.Handler {
|
||||
r := newBaseRouter(middleware.DecodeAuth())
|
||||
return applyRoutes(r)
|
||||
}
|
||||
|
||||
// NewSetupRouter returns the router used before a database has been
|
||||
// configured. It only serves the frontend, the health and schema endpoints
|
||||
// and the database setup wizard endpoint. Everything else needs the database
|
||||
// (including JWT keys for auth) so it is unavailable until onConfigured has
|
||||
// run and the full router has replaced this one.
|
||||
func NewSetupRouter(onConfigured func() error) http.Handler {
|
||||
r := newBaseRouter()
|
||||
r.NotFound(handler.NotFound())
|
||||
r.MethodNotAllowed(handler.NotAllowed())
|
||||
|
||||
r.With(chiMiddleware.Timeout(30*time.Second)).Route("/api", func(r chi.Router) {
|
||||
r.Get("/", handler.Health())
|
||||
r.Get("/schema", handler.Schema())
|
||||
r.With(middleware.EnforceRequestSchema(schema.SetupDatabase())).
|
||||
Post("/setup/database", handler.SetupDatabase(onConfigured))
|
||||
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.ResultErrorJSON(w, r, http.StatusForbidden, "Not available during database setup phase", nil)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// newBaseRouter returns a router with the middleware common to all routers.
|
||||
// extra is inserted before the request body and logging middleware.
|
||||
func newBaseRouter(extra ...func(http.Handler) http.Handler) *chi.Mux {
|
||||
// Cors
|
||||
corss := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
@@ -60,12 +92,14 @@ func NewRouter() http.Handler {
|
||||
chiMiddleware.Throttle(5),
|
||||
middleware.PrettyPrint,
|
||||
middleware.Expansion,
|
||||
middleware.DecodeAuth(),
|
||||
)
|
||||
r.Use(extra...)
|
||||
r.Use(
|
||||
middleware.BodyContext(),
|
||||
middleware.Log,
|
||||
)
|
||||
|
||||
return applyRoutes(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// applyRoutes is where the magic happens
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"npm/internal/config"
|
||||
"npm/internal/database"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSetupRouter(t *testing.T) {
|
||||
config.Configuration.DataFolder = t.TempDir()
|
||||
config.Configuration.DB = config.DBConfig{}
|
||||
config.IsDBSetup = false
|
||||
defer func() {
|
||||
database.Close()
|
||||
config.Configuration.DB = config.DBConfig{}
|
||||
config.IsDBSetup = false
|
||||
}()
|
||||
|
||||
post := func(sr http.Handler, body string) *httptest.ResponseRecorder {
|
||||
respRec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/setup/database", strings.NewReader(body))
|
||||
sr.ServeHTTP(respRec, req)
|
||||
return respRec
|
||||
}
|
||||
|
||||
failing := NewSetupRouter(func() error { return errors.New("boom") })
|
||||
|
||||
// Health is available and reports the database isn't set up
|
||||
respRec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/", nil)
|
||||
failing.ServeHTTP(respRec, req)
|
||||
assert.Equal(t, http.StatusOK, respRec.Code)
|
||||
assert.Contains(t, respRec.Body.String(), `"db_setup":false`)
|
||||
|
||||
// Everything else in the api is blocked
|
||||
respRec = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/api/users", strings.NewReader(`{}`))
|
||||
failing.ServeHTTP(respRec, req)
|
||||
assert.Equal(t, http.StatusForbidden, respRec.Code)
|
||||
|
||||
// Schema validation
|
||||
assert.Equal(t, http.StatusBadRequest, post(failing, `{"driver":"oracle"}`).Code)
|
||||
|
||||
// Postgres/mysql config missing fields
|
||||
assert.Equal(t, http.StatusBadRequest, post(failing, `{"driver":"postgres","host":"db"}`).Code)
|
||||
|
||||
// A failure to start rolls back the saved config
|
||||
assert.Equal(t, http.StatusInternalServerError, post(failing, `{"driver":"sqlite"}`).Code)
|
||||
assert.False(t, config.Configuration.DB.IsConfigured())
|
||||
_, err := os.Stat(config.GetDBConfigFile())
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
// Success saves the config and calls the callback
|
||||
called := false
|
||||
sr := NewSetupRouter(func() error {
|
||||
called = true
|
||||
config.IsDBSetup = true
|
||||
return nil
|
||||
})
|
||||
respRec = post(sr, `{"driver":"sqlite","host":"ignored"}`)
|
||||
require.Equal(t, http.StatusOK, respRec.Code, respRec.Body.String())
|
||||
assert.True(t, called)
|
||||
assert.Equal(t, config.DBConfig{Driver: config.DatabaseSqlite}, config.Configuration.DB)
|
||||
content, err := os.ReadFile(config.GetDBConfigFile())
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), `"driver": "sqlite"`)
|
||||
|
||||
// Can't be configured twice
|
||||
assert.Equal(t, http.StatusForbidden, post(sr, `{"driver":"sqlite"}`).Code)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package schema
|
||||
|
||||
import "fmt"
|
||||
|
||||
// SetupDatabase is the schema for incoming data validation.
|
||||
// Fields required by postgres and mysql are checked by
|
||||
// config.DBConfig.IsValid once the driver is known.
|
||||
func SetupDatabase() string {
|
||||
stdField := stringMinMax(1, 255)
|
||||
return fmt.Sprintf(`
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"driver"
|
||||
],
|
||||
"properties": {
|
||||
"driver": {
|
||||
"type": "string",
|
||||
"enum": ["sqlite", "postgres", "mysql"]
|
||||
},
|
||||
"host": %s,
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"username": %s,
|
||||
"password": %s,
|
||||
"name": %s,
|
||||
"sslmode": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"]
|
||||
}
|
||||
}
|
||||
}
|
||||
`, stdField, stdField, stringMinMax(0, 255), stdField)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"npm/internal/logger"
|
||||
@@ -11,13 +12,25 @@ import (
|
||||
|
||||
const httpPort = 3000
|
||||
|
||||
// currentHandler is the router serving requests. It starts as the setup
|
||||
// router when no database is configured and is replaced by the full router
|
||||
// once the database setup wizard completes, without restarting the server.
|
||||
var currentHandler atomic.Pointer[http.Handler]
|
||||
|
||||
// SetHandler replaces the router serving requests
|
||||
func SetHandler(handler http.Handler) {
|
||||
currentHandler.Store(&handler)
|
||||
}
|
||||
|
||||
// StartServer creates a http server
|
||||
func StartServer() {
|
||||
logger.Info("Server starting on port %v", httpPort)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%v", httpPort),
|
||||
Handler: NewRouter(),
|
||||
Addr: fmt.Sprintf(":%v", httpPort),
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
(*currentHandler.Load()).ServeHTTP(w, r)
|
||||
}),
|
||||
ReadHeaderTimeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,11 @@ const (
|
||||
DatabaseMysql = "mysql"
|
||||
)
|
||||
|
||||
type db struct {
|
||||
Driver string `json:"driver" envconfig:"optional,default=sqlite"`
|
||||
// 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.
|
||||
type DBConfig struct {
|
||||
Driver string `json:"driver" envconfig:"optional"`
|
||||
Host string `json:"host" envconfig:"optional,default="`
|
||||
Port int `json:"port" envconfig:"optional,default="`
|
||||
Username string `json:"username" envconfig:"optional,default="`
|
||||
@@ -26,13 +29,19 @@ type db struct {
|
||||
}
|
||||
|
||||
// GetDriver returns the lowercase driver name
|
||||
func (d *db) GetDriver() string {
|
||||
func (d *DBConfig) GetDriver() string {
|
||||
return strings.ToLower(d.Driver)
|
||||
}
|
||||
|
||||
// IsConfigured returns true when a database driver has been chosen, either
|
||||
// by environment variables or by the setup wizard
|
||||
func (d *DBConfig) IsConfigured() bool {
|
||||
return d.Driver != ""
|
||||
}
|
||||
|
||||
// IsValid is a basic check for config. Sqlite has no host/port/credentials
|
||||
// to validate; postgres and mysql require them to form a usable DSN.
|
||||
func (d *db) IsValid() (bool, error) {
|
||||
func (d *DBConfig) IsValid() (bool, error) {
|
||||
var errs []error
|
||||
|
||||
switch d.GetDriver() {
|
||||
@@ -59,7 +68,7 @@ func (d *db) IsValid() (bool, error) {
|
||||
}
|
||||
|
||||
// GetGormConnectURL is used by Gorm
|
||||
func (d *db) GetGormConnectURL() string {
|
||||
func (d *DBConfig) GetGormConnectURL() string {
|
||||
switch d.GetDriver() {
|
||||
case DatabaseSqlite:
|
||||
return fmt.Sprintf("%s/nginxproxymanager.db", Configuration.DataFolder)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"npm/internal/logger"
|
||||
|
||||
"github.com/rotisserie/eris"
|
||||
)
|
||||
|
||||
const dbConfigFilename = "db.conf"
|
||||
|
||||
// GetDBConfigFile returns the path of the file the database
|
||||
// connection parameters are persisted to
|
||||
func GetDBConfigFile() string {
|
||||
return filepath.Join(Configuration.DataFolder, dbConfigFilename)
|
||||
}
|
||||
|
||||
// LoadDBConfig populates the database configuration from the db.conf file,
|
||||
// unless NPM_DB_DRIVER has been provided in which case the environment
|
||||
// variables take precedence and the file is ignored. A missing file is not
|
||||
// an error; the database simply remains unconfigured and the setup wizard
|
||||
// will ask for it.
|
||||
func LoadDBConfig() error {
|
||||
if Configuration.DB.IsConfigured() {
|
||||
logger.Info("Using database configuration from environment variables")
|
||||
return nil
|
||||
}
|
||||
|
||||
file := GetDBConfigFile()
|
||||
// nolint: gosec
|
||||
content, err := os.ReadFile(file)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return eris.Wrapf(err, "unable to read %s", file)
|
||||
}
|
||||
|
||||
cfg := Configuration.DB
|
||||
if err := json.Unmarshal(content, &cfg); err != nil {
|
||||
return eris.Wrapf(err, "unable to parse %s", file)
|
||||
}
|
||||
|
||||
if !cfg.IsConfigured() {
|
||||
return eris.Errorf("%s does not specify a database driver", file)
|
||||
}
|
||||
|
||||
Configuration.DB = cfg
|
||||
logger.Info("Using database configuration from %s", file)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveDBConfig writes the database connection parameters to the db.conf
|
||||
// file. The file contains credentials so it is only readable by the owner.
|
||||
// It is written to a temporary file first and renamed so that a crash
|
||||
// can never leave a half written config behind.
|
||||
func SaveDBConfig(cfg DBConfig) error {
|
||||
// Persisting the password is the point of this file
|
||||
// nolint: gosec
|
||||
content, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file := GetDBConfigFile()
|
||||
tmp, err := os.CreateTemp(filepath.Dir(file), dbConfigFilename+".*")
|
||||
if err != nil {
|
||||
return eris.Wrapf(err, "unable to write %s", file)
|
||||
}
|
||||
// nolint: errcheck
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
// nolint: errcheck, gosec
|
||||
tmp.Close()
|
||||
return eris.Wrapf(err, "unable to write %s", file)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return eris.Wrapf(err, "unable to write %s", file)
|
||||
}
|
||||
|
||||
return os.Rename(tmp.Name(), file)
|
||||
}
|
||||
|
||||
// RemoveDBConfig deletes the db.conf file, used to roll back a
|
||||
// database setup that could not be completed
|
||||
func RemoveDBConfig() error {
|
||||
err := os.Remove(GetDBConfigFile())
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestDBConfigFile(t *testing.T) {
|
||||
// goleak is used to detect goroutine leaks
|
||||
defer goleak.VerifyNone(t, goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"))
|
||||
|
||||
Configuration.DataFolder = t.TempDir()
|
||||
Configuration.DB = DBConfig{SSLMode: "disable"}
|
||||
|
||||
// No file, no env: stays unconfigured
|
||||
require.NoError(t, LoadDBConfig())
|
||||
assert.False(t, Configuration.DB.IsConfigured())
|
||||
|
||||
cfg := DBConfig{
|
||||
Driver: DatabasePostgres,
|
||||
Host: "db",
|
||||
Port: 5432,
|
||||
Username: "npm",
|
||||
Password: "secret",
|
||||
Name: "npm",
|
||||
SSLMode: "require",
|
||||
}
|
||||
require.NoError(t, SaveDBConfig(cfg))
|
||||
|
||||
stat, err := os.Stat(filepath.Join(Configuration.DataFolder, "db.conf"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, os.FileMode(0600), stat.Mode().Perm())
|
||||
|
||||
require.NoError(t, LoadDBConfig())
|
||||
assert.Equal(t, cfg, Configuration.DB)
|
||||
|
||||
require.NoError(t, RemoveDBConfig())
|
||||
require.NoError(t, RemoveDBConfig())
|
||||
_, err = os.Stat(GetDBConfigFile())
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestDBConfigFileEnvPrecedence(t *testing.T) {
|
||||
// goleak is used to detect goroutine leaks
|
||||
defer goleak.VerifyNone(t, goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"))
|
||||
|
||||
Configuration.DataFolder = t.TempDir()
|
||||
require.NoError(t, SaveDBConfig(DBConfig{Driver: DatabaseMysql}))
|
||||
|
||||
Configuration.DB = DBConfig{Driver: DatabaseSqlite}
|
||||
require.NoError(t, LoadDBConfig())
|
||||
assert.Equal(t, DatabaseSqlite, Configuration.DB.Driver)
|
||||
}
|
||||
|
||||
func TestDBConfigFileInvalid(t *testing.T) {
|
||||
// goleak is used to detect goroutine leaks
|
||||
defer goleak.VerifyNone(t, goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"))
|
||||
|
||||
Configuration.DataFolder = t.TempDir()
|
||||
|
||||
Configuration.DB = DBConfig{}
|
||||
require.NoError(t, os.WriteFile(GetDBConfigFile(), []byte("not json"), 0600))
|
||||
assert.Error(t, LoadDBConfig())
|
||||
|
||||
Configuration.DB = DBConfig{}
|
||||
require.NoError(t, os.WriteFile(GetDBConfigFile(), []byte(`{"host":"db"}`), 0600))
|
||||
assert.Error(t, LoadDBConfig())
|
||||
assert.False(t, Configuration.DB.IsConfigured())
|
||||
}
|
||||
@@ -12,6 +12,10 @@ var Version string
|
||||
// Commit is the git commit set by ldflags
|
||||
var Commit string
|
||||
|
||||
// IsDBSetup defines whether the database has been configured, connected
|
||||
// to and migrated
|
||||
var IsDBSetup bool
|
||||
|
||||
// IsSetup defines whether we have an admin user or not
|
||||
var IsSetup bool
|
||||
|
||||
@@ -19,12 +23,12 @@ var logLevel logger.Level
|
||||
|
||||
// Configuration is the main configuration object
|
||||
var Configuration struct {
|
||||
DataFolder string `json:"data_folder" envconfig:"optional,default=/data"`
|
||||
DisableIPV4 bool `json:"disable_ipv4" envconfig:"optional"`
|
||||
DisableIPV6 bool `json:"disable_ipv6" envconfig:"optional"`
|
||||
Acmesh acmesh `json:"acmesh"`
|
||||
DB db `json:"db"`
|
||||
Log log `json:"log"`
|
||||
DataFolder string `json:"data_folder" envconfig:"optional,default=/data"`
|
||||
DisableIPV4 bool `json:"disable_ipv4" envconfig:"optional"`
|
||||
DisableIPV6 bool `json:"disable_ipv6" envconfig:"optional"`
|
||||
Acmesh acmesh `json:"acmesh"`
|
||||
DB DBConfig `json:"db"`
|
||||
Log log `json:"log"`
|
||||
}
|
||||
|
||||
type log struct {
|
||||
|
||||
@@ -20,13 +20,18 @@ var dbInstance *gorm.DB
|
||||
|
||||
// NewDB creates a new connection
|
||||
func NewDB() {
|
||||
if !config.Configuration.DB.IsConfigured() {
|
||||
// The setup wizard hasn't been completed yet
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := config.Configuration.DB.IsValid(); !ok {
|
||||
logger.Error("DatabaseError", eris.Wrap(err, "database configuration is invalid, check for missing environment variables"))
|
||||
logger.Error("DatabaseError", eris.Wrapf(err, "database configuration is invalid, check %s or NPM_DB_* environment variables", config.GetDBConfigFile()))
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Creating new DB instance using %s", strings.ToLower(config.Configuration.DB.Driver))
|
||||
db, err := connect()
|
||||
db, err := connect(&config.Configuration.DB)
|
||||
if err != nil {
|
||||
logger.Error("DatabaseConnectError", err)
|
||||
} else if db != nil {
|
||||
@@ -42,17 +47,52 @@ func GetDB() *gorm.DB {
|
||||
return dbInstance
|
||||
}
|
||||
|
||||
// TestConnection opens a connection using the given config and pings the
|
||||
// server, without affecting the current instance. Used by the setup wizard
|
||||
// to validate parameters before they are saved.
|
||||
func TestConnection(cfg config.DBConfig) error {
|
||||
if ok, err := cfg.IsValid(); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := connect(&cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// nolint: errcheck
|
||||
defer sqlDB.Close()
|
||||
|
||||
return sqlDB.Ping()
|
||||
}
|
||||
|
||||
// Close closes and discards the current instance, if any
|
||||
func Close() {
|
||||
if dbInstance == nil {
|
||||
return
|
||||
}
|
||||
if sqlDB, err := dbInstance.DB(); err == nil {
|
||||
// nolint: errcheck, gosec
|
||||
sqlDB.Close()
|
||||
}
|
||||
dbInstance = nil
|
||||
}
|
||||
|
||||
// SetDB will set the dbInstance to this
|
||||
// Used by unit testing to set the db to a mock database
|
||||
func SetDB(db *gorm.DB) {
|
||||
dbInstance = db
|
||||
}
|
||||
|
||||
func connect() (*gorm.DB, error) {
|
||||
func connect(dbCfg *config.DBConfig) (*gorm.DB, error) {
|
||||
var d gorm.Dialector
|
||||
dsn := config.Configuration.DB.GetGormConnectURL()
|
||||
dsn := dbCfg.GetGormConnectURL()
|
||||
|
||||
switch strings.ToLower(config.Configuration.DB.Driver) {
|
||||
switch dbCfg.GetDriver() {
|
||||
case config.DatabaseSqlite:
|
||||
// autocreate(dsn)
|
||||
d = sqlite.Open(dsn)
|
||||
@@ -64,7 +104,7 @@ func connect() (*gorm.DB, error) {
|
||||
d = mysql.Open(dsn)
|
||||
|
||||
default:
|
||||
return nil, eris.New(fmt.Sprintf("Database driver %s is not supported. Valid options are: %s, %s or %s", config.Configuration.DB.Driver, config.DatabaseSqlite, config.DatabasePostgres, config.DatabaseMysql))
|
||||
return nil, eris.New(fmt.Sprintf("Database driver %s is not supported. Valid options are: %s, %s or %s", dbCfg.Driver, config.DatabaseSqlite, config.DatabasePostgres, config.DatabaseMysql))
|
||||
}
|
||||
|
||||
// see: https://gorm.io/docs/gorm_config.html
|
||||
|
||||
@@ -20,6 +20,7 @@ const NginxTemplates = lazy(() => import("src/pages/NginxTemplates"));
|
||||
const Login = lazy(() => import("src/pages/Login"));
|
||||
const GeneralSettings = lazy(() => import("src/pages/Settings"));
|
||||
const Setup = lazy(() => import("src/pages/Setup"));
|
||||
const SetupDatabase = lazy(() => import("src/pages/SetupDatabase"));
|
||||
const Upstreams = lazy(() => import("src/pages/Upstreams"));
|
||||
const Users = lazy(() => import("src/pages/Users"));
|
||||
|
||||
@@ -48,6 +49,14 @@ function Router() {
|
||||
return <Unhealthy />;
|
||||
}
|
||||
|
||||
if (health.data?.healthy && !health.data?.dbSetup) {
|
||||
return (
|
||||
<Suspense fallback={Spinner}>
|
||||
<SetupDatabase />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (health.data?.healthy && !health.data?.setup) {
|
||||
return (
|
||||
<Suspense fallback={Spinner}>
|
||||
|
||||
@@ -31,3 +31,4 @@ export * from "./setCertificate";
|
||||
export * from "./setCertificateAuthority";
|
||||
export * from "./setDNSProvider";
|
||||
export * from "./setUser";
|
||||
export * from "./setupDatabase";
|
||||
@@ -20,6 +20,7 @@ export interface BaseResponse {
|
||||
|
||||
export interface HealthResponse {
|
||||
commit: string;
|
||||
dbSetup: boolean;
|
||||
errorReporting: boolean;
|
||||
healthy: boolean;
|
||||
setup: boolean;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as api from "./base";
|
||||
|
||||
export interface DatabaseConfig {
|
||||
driver: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
sslmode?: string;
|
||||
}
|
||||
|
||||
export async function setupDatabase(
|
||||
data: DatabaseConfig,
|
||||
abortController?: AbortController,
|
||||
): Promise<boolean> {
|
||||
const { result } = await api.post(
|
||||
{
|
||||
url: "/setup/database",
|
||||
data,
|
||||
},
|
||||
abortController,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@@ -341,6 +341,42 @@
|
||||
"settings.title": {
|
||||
"defaultMessage": "Einstellungen"
|
||||
},
|
||||
"setup-database.description": {
|
||||
"defaultMessage": "Wählen Sie, wo Nginx Proxy Manager seine Daten speichern soll. Diese Einstellungen werden in db.conf im Datenordner gespeichert."
|
||||
},
|
||||
"setup-database.driver": {
|
||||
"defaultMessage": "Datenbanktyp"
|
||||
},
|
||||
"setup-database.error": {
|
||||
"defaultMessage": "Verbindung zur Datenbank nicht möglich"
|
||||
},
|
||||
"setup-database.host": {
|
||||
"defaultMessage": "Host"
|
||||
},
|
||||
"setup-database.name": {
|
||||
"defaultMessage": "Datenbankname"
|
||||
},
|
||||
"setup-database.password": {
|
||||
"defaultMessage": "Passwort"
|
||||
},
|
||||
"setup-database.port": {
|
||||
"defaultMessage": "Port"
|
||||
},
|
||||
"setup-database.sqlite-description": {
|
||||
"defaultMessage": "SQLite speichert alles in einer einzigen Datei im Datenordner und benötigt keine weitere Konfiguration."
|
||||
},
|
||||
"setup-database.sslmode": {
|
||||
"defaultMessage": "SSL-Modus"
|
||||
},
|
||||
"setup-database.submit": {
|
||||
"defaultMessage": "Weiter"
|
||||
},
|
||||
"setup-database.title": {
|
||||
"defaultMessage": "Datenbank konfigurieren"
|
||||
},
|
||||
"setup-database.username": {
|
||||
"defaultMessage": "Benutzername"
|
||||
},
|
||||
"setup.create": {
|
||||
"defaultMessage": "Registrieren"
|
||||
},
|
||||
|
||||
@@ -617,6 +617,42 @@
|
||||
"settings.title": {
|
||||
"defaultMessage": "Settings"
|
||||
},
|
||||
"setup-database.description": {
|
||||
"defaultMessage": "Choose where Nginx Proxy Manager should store its data. These settings will be saved to db.conf in the data folder."
|
||||
},
|
||||
"setup-database.driver": {
|
||||
"defaultMessage": "Database Type"
|
||||
},
|
||||
"setup-database.error": {
|
||||
"defaultMessage": "Unable to connect to the database"
|
||||
},
|
||||
"setup-database.host": {
|
||||
"defaultMessage": "Host"
|
||||
},
|
||||
"setup-database.name": {
|
||||
"defaultMessage": "Database Name"
|
||||
},
|
||||
"setup-database.password": {
|
||||
"defaultMessage": "Password"
|
||||
},
|
||||
"setup-database.port": {
|
||||
"defaultMessage": "Port"
|
||||
},
|
||||
"setup-database.sqlite-description": {
|
||||
"defaultMessage": "SQLite stores everything in a single file in the data folder and requires no further configuration."
|
||||
},
|
||||
"setup-database.sslmode": {
|
||||
"defaultMessage": "SSL Mode"
|
||||
},
|
||||
"setup-database.submit": {
|
||||
"defaultMessage": "Continue"
|
||||
},
|
||||
"setup-database.title": {
|
||||
"defaultMessage": "Configure your Database"
|
||||
},
|
||||
"setup-database.username": {
|
||||
"defaultMessage": "Username"
|
||||
},
|
||||
"setup.create": {
|
||||
"defaultMessage": "Sign up"
|
||||
},
|
||||
|
||||
@@ -341,6 +341,42 @@
|
||||
"settings.title": {
|
||||
"defaultMessage": "تنظیمات"
|
||||
},
|
||||
"setup-database.description": {
|
||||
"defaultMessage": "محل ذخیره دادههای Nginx Proxy Manager را انتخاب کنید. این تنظیمات در فایل db.conf در پوشه داده ذخیره میشود."
|
||||
},
|
||||
"setup-database.driver": {
|
||||
"defaultMessage": "نوع پایگاه داده"
|
||||
},
|
||||
"setup-database.error": {
|
||||
"defaultMessage": "اتصال به پایگاه داده ممکن نیست"
|
||||
},
|
||||
"setup-database.host": {
|
||||
"defaultMessage": "میزبان"
|
||||
},
|
||||
"setup-database.name": {
|
||||
"defaultMessage": "نام پایگاه داده"
|
||||
},
|
||||
"setup-database.password": {
|
||||
"defaultMessage": "رمز عبور"
|
||||
},
|
||||
"setup-database.port": {
|
||||
"defaultMessage": "پورت"
|
||||
},
|
||||
"setup-database.sqlite-description": {
|
||||
"defaultMessage": "SQLite همه چیز را در یک فایل در پوشه داده ذخیره میکند و به پیکربندی بیشتری نیاز ندارد."
|
||||
},
|
||||
"setup-database.sslmode": {
|
||||
"defaultMessage": "حالت SSL"
|
||||
},
|
||||
"setup-database.submit": {
|
||||
"defaultMessage": "ادامه"
|
||||
},
|
||||
"setup-database.title": {
|
||||
"defaultMessage": "پیکربندی پایگاه داده"
|
||||
},
|
||||
"setup-database.username": {
|
||||
"defaultMessage": "نام کاربری"
|
||||
},
|
||||
"setup.create": {
|
||||
"defaultMessage": "ثبت نام"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Field, Form, Formik } from "formik";
|
||||
|
||||
import { DatabaseConfig, setupDatabase } from "src/api/npm";
|
||||
import { LocalePicker, PrettyButton, ThemeSwitcher } from "src/components";
|
||||
import { intl } from "src/locale";
|
||||
import { validateNumber, validateString } from "src/modules/Validations";
|
||||
|
||||
interface Payload {
|
||||
driver: string;
|
||||
host: string;
|
||||
port: string;
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
sslmode: string;
|
||||
}
|
||||
|
||||
const defaultPorts: Record<string, string> = {
|
||||
postgres: "5432",
|
||||
mysql: "3306",
|
||||
};
|
||||
|
||||
const sslModes = [
|
||||
"disable",
|
||||
"allow",
|
||||
"prefer",
|
||||
"require",
|
||||
"verify-ca",
|
||||
"verify-full",
|
||||
];
|
||||
|
||||
function SetupDatabase() {
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const mutedColor = useColorModeValue("gray.600", "gray.400");
|
||||
|
||||
const onSubmit = async (values: Payload, { setSubmitting }: any) => {
|
||||
const payload: DatabaseConfig =
|
||||
values.driver === "sqlite"
|
||||
? { driver: values.driver }
|
||||
: {
|
||||
driver: values.driver,
|
||||
host: values.host,
|
||||
port: +values.port,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
name: values.name,
|
||||
...(values.driver === "postgres"
|
||||
? { sslmode: values.sslmode }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await setupDatabase(payload);
|
||||
// Trigger a Health change, which moves on to the user setup step
|
||||
await queryClient.refetchQueries({ queryKey: ["health"] });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: intl.formatMessage({ id: "setup-database.error" }),
|
||||
description: err.message,
|
||||
status: "error",
|
||||
position: "top",
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
minH="100vh"
|
||||
w="100vw"
|
||||
flexDir="column"
|
||||
bg={useColorModeValue("gray.50", "gray.800")}>
|
||||
<Stack h={10} m={4} justify="end" direction="row">
|
||||
<ThemeSwitcher />
|
||||
<LocalePicker className="text-right" />
|
||||
</Stack>
|
||||
|
||||
<Flex align="center" justify="center" flex="1">
|
||||
<Stack spacing={8} mx="auto" maxW="md" w="full" py={4} px={6}>
|
||||
<Box>
|
||||
<Center>
|
||||
<img src="/images/logo-256.png" width={100} alt="Logo" />
|
||||
</Center>
|
||||
</Box>
|
||||
<Box
|
||||
rounded="lg"
|
||||
bg={useColorModeValue("white", "gray.700")}
|
||||
boxShadow="lg"
|
||||
p={8}>
|
||||
<Stack spacing={4}>
|
||||
<Heading
|
||||
color={useColorModeValue("gray.800", "gray.300")}
|
||||
lineHeight={1.1}
|
||||
fontSize={{ base: "1xl", sm: "2xl", md: "3xl" }}>
|
||||
{intl.formatMessage({ id: "setup-database.title" })}
|
||||
</Heading>
|
||||
<Text color={mutedColor}>
|
||||
{intl.formatMessage({ id: "setup-database.description" })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box mt={10}>
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
driver: "sqlite",
|
||||
host: "",
|
||||
port: "",
|
||||
username: "",
|
||||
password: "",
|
||||
name: "",
|
||||
sslmode: "disable",
|
||||
} as Payload
|
||||
}
|
||||
onSubmit={onSubmit}>
|
||||
{({ isSubmitting, values, handleChange, setFieldValue }) => (
|
||||
<Form>
|
||||
<Stack spacing={4}>
|
||||
<Field name="driver">
|
||||
{({ field }: any) => (
|
||||
<FormControl isRequired>
|
||||
<FormLabel htmlFor="driver">
|
||||
{intl.formatMessage({
|
||||
id: "setup-database.driver",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
id="driver"
|
||||
onChange={(e: any) => {
|
||||
handleChange(e);
|
||||
// Swap the port along with the driver unless it's been customised
|
||||
if (
|
||||
values.port === "" ||
|
||||
Object.values(defaultPorts).includes(
|
||||
values.port,
|
||||
)
|
||||
) {
|
||||
setFieldValue(
|
||||
"port",
|
||||
defaultPorts[e.target.value] || "",
|
||||
);
|
||||
}
|
||||
}}>
|
||||
<option value="sqlite">SQLite</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="mysql">MySQL / MariaDB</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
{values.driver === "sqlite" ? (
|
||||
<Text fontSize="sm" color={mutedColor}>
|
||||
{intl.formatMessage({
|
||||
id: "setup-database.sqlite-description",
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Field name="host" validate={validateString(1, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
form.errors.host && form.touched.host
|
||||
}>
|
||||
<FormLabel htmlFor="host">
|
||||
{intl.formatMessage({
|
||||
id: "setup-database.host",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input {...field} id="host" autoFocus />
|
||||
<FormErrorMessage>
|
||||
{form.errors.host}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name="port"
|
||||
validate={validateNumber(1, 65535)}>
|
||||
{({ field, form }: any) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
form.errors.port && form.touched.port
|
||||
}>
|
||||
<FormLabel htmlFor="port">
|
||||
{intl.formatMessage({
|
||||
id: "setup-database.port",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input {...field} id="port" type="number" />
|
||||
<FormErrorMessage>
|
||||
{form.errors.port}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="name" validate={validateString(1, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
form.errors.name && form.touched.name
|
||||
}>
|
||||
<FormLabel htmlFor="name">
|
||||
{intl.formatMessage({
|
||||
id: "setup-database.name",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input {...field} id="name" />
|
||||
<FormErrorMessage>
|
||||
{form.errors.name}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name="username"
|
||||
validate={validateString(1, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
form.errors.username && form.touched.username
|
||||
}>
|
||||
<FormLabel htmlFor="username">
|
||||
{intl.formatMessage({
|
||||
id: "setup-database.username",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="username"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.errors.username}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="password">
|
||||
{({ field }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="password">
|
||||
{intl.formatMessage({
|
||||
id: "setup-database.password",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
{values.driver === "postgres" ? (
|
||||
<Field name="sslmode">
|
||||
{({ field }: any) => (
|
||||
<FormControl isRequired>
|
||||
<FormLabel htmlFor="sslmode">
|
||||
{intl.formatMessage({
|
||||
id: "setup-database.sslmode",
|
||||
})}
|
||||
</FormLabel>
|
||||
<Select {...field} id="sslmode">
|
||||
{sslModes.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<PrettyButton isLoading={isSubmitting} mt={8} w="full">
|
||||
{intl.formatMessage({ id: "setup-database.submit" })}
|
||||
</PrettyButton>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Box h={10} m={4} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupDatabase;
|
||||
Reference in new issue
Block a user