From 26cb1cef3787d04511e15130a84f74d2a4bd4c41 Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Mon, 30 Dec 2024 20:26:33 +0200 Subject: [PATCH] added ServeEvent.InstallerFunc field --- CHANGELOG.md | 2 ++ apis/installer.go | 66 ++++++++++++++++------------------ apis/serve.go | 68 ++++++++++++++++++++++-------------- core/events.go | 16 +++++++++ tools/osutils/cmd.go | 31 ++++++++++++++++ ui/src/stores/collections.js | 1 - 6 files changed, 120 insertions(+), 64 deletions(-) create mode 100644 tools/osutils/cmd.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e089910..5d7aea6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ - Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1). +- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`. + - Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)). - Eagerly interrupt waiting for the email alert send in case it takes longer than 15s. diff --git a/apis/installer.go b/apis/installer.go index 2bd93a31..03467f44 100644 --- a/apis/installer.go +++ b/apis/installer.go @@ -5,37 +5,31 @@ import ( "errors" "fmt" "os" - "os/exec" - "runtime" "strings" "time" "github.com/fatih/color" - "github.com/go-ozzo/ozzo-validation/v4/is" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/security" + "github.com/pocketbase/pocketbase/tools/osutils" ) -// @todo consider combining with the installer specific hooks after refactoring cmd -func loadInstaller(app core.App, dashboardURL string) error { - if !needInstallerSuperuser(app) { - return nil - } - - installerRecord, err := findOrCreateInstallerSuperuser(app) - if err != nil { - return err - } - - token, err := installerRecord.NewStaticAuthToken(30 * time.Minute) +// DefaultInstallerFunc is the default PocketBase installer function. +// +// It will attempt to open a link in the browser (with a short-lived auth +// token for the systemSuperuser) to the installer UI so that users can +// create their own custom superuser record. +// +// See https://github.com/pocketbase/pocketbase/discussions/5814. +func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error { + token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute) if err != nil { return err } // launch url (ignore errors and always print a help text as fallback) - url := fmt.Sprintf("%s/#/pbinstal/%s", strings.TrimRight(dashboardURL, "/"), token) - _ = launchURL(url) + url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token) + _ = osutils.LaunchURL(url) color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:") color.New(color.Bold).Add(color.FgCyan).Println(url) color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", os.Args[0]) @@ -43,6 +37,23 @@ func loadInstaller(app core.App, dashboardURL string) error { return nil } +func loadInstaller( + app core.App, + baseURL string, + installerFunc func(app core.App, systemSuperuser *core.Record, baseURL string) error, +) error { + if installerFunc == nil || !needInstallerSuperuser(app) { + return nil + } + + superuser, err := findOrCreateInstallerSuperuser(app) + if err != nil { + return err + } + + return installerFunc(app, superuser, baseURL) +} + func needInstallerSuperuser(app core.App) bool { total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{ "email": core.DefaultInstallerEmail, @@ -65,7 +76,7 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) { record = core.NewRecord(col) record.SetEmail(core.DefaultInstallerEmail) - record.SetPassword(security.RandomString(30)) + record.SetRandomPassword() err = app.Save(record) if err != nil { @@ -75,20 +86,3 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) { return record, nil } - -func launchURL(url string) error { - if err := is.URL.Validate(url); err != nil { - return err - } - - switch runtime.GOOS { - case "darwin": - return exec.Command("open", url).Start() - case "windows": - // not sure if this is the best command but seems to be the most reliable based on the comments in - // https://stackoverflow.com/questions/3739327/launching-a-website-via-the-windows-commandline#answer-49115945 - return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - default: // linux, freebsd, etc. - return exec.Command("xdg-open", url).Start() - } -} diff --git a/apis/serve.go b/apis/serve.go index 7530a0f4..c3a450d6 100644 --- a/apis/serve.go +++ b/apis/serve.go @@ -4,7 +4,6 @@ import ( "context" "crypto/tls" "errors" - "fmt" "log" "net" "net/http" @@ -17,6 +16,7 @@ import ( "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/routine" "github.com/pocketbase/pocketbase/ui" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" @@ -158,6 +158,7 @@ func Serve(app core.App, config ServeConfig) error { serveEvent.Router = pbRouter serveEvent.Server = server serveEvent.CertManager = certManager + serveEvent.InstallerFunc = DefaultInstallerFunc var listener net.Listener @@ -204,6 +205,8 @@ func Serve(app core.App, config ServeConfig) error { }() // --------------------------------------------------------------- + var baseURL string + // trigger the OnServe hook and start the tcp listener serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error { handler, err := e.Router.BuildMux() @@ -213,10 +216,20 @@ func Serve(app core.App, config ServeConfig) error { e.Server.Handler = handler - addr := e.Server.Addr + if config.HttpsAddr == "" { + baseURL = "http://" + serverAddrToHost(serveEvent.Server.Addr) + } else { + baseURL = "https://" + if len(config.CertificateDomains) > 0 { + baseURL += config.CertificateDomains[0] + } else { + baseURL += serverAddrToHost(serveEvent.Server.Addr) + } + } - // fallback similar to the std Server.ListenAndServe/ListenAndServeTLS + addr := e.Server.Addr if addr == "" { + // fallback similar to the std Server.ListenAndServe/ListenAndServeTLS if config.HttpsAddr != "" { addr = ":https" } else { @@ -224,11 +237,22 @@ func Serve(app core.App, config ServeConfig) error { } } - var lnErr error + listener, err = net.Listen("tcp", addr) + if err != nil { + return err + } - listener, lnErr = net.Listen("tcp", addr) + if e.InstallerFunc != nil { + app := e.App + installerFunc := e.InstallerFunc + routine.FireAndForget(func() { + if err := loadInstaller(app, baseURL, installerFunc); err != nil { + app.Logger().Warn("Failed to initialize installer", "error", err) + } + }) + } - return lnErr + return nil }) if serveHookErr != nil { return serveHookErr @@ -238,17 +262,6 @@ func Serve(app core.App, config ServeConfig) error { return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?") } - schema := "http" - addr := server.Addr - if config.HttpsAddr != "" { - schema = "https" - if len(config.CertificateDomains) > 0 { - addr = config.CertificateDomains[0] - } - } - baseURL := fmt.Sprintf("%s://%s", schema, addr) - dashboardURL := fmt.Sprintf("%s/_", baseURL) - if config.ShowStartBanner { date := new(strings.Builder) log.New(date, "", log.LstdFlags).Print() @@ -262,16 +275,9 @@ func Serve(app core.App, config ServeConfig) error { regular := color.New() regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL)) - regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/", dashboardURL)) + regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL)) } - go func() { - installerErr := loadInstaller(app, dashboardURL) - if installerErr != nil { - app.Logger().Warn("Failed to initialize installer", "error", installerErr) - } - }() - var serveErr error if config.HttpsAddr != "" { if config.HttpAddr != "" { @@ -280,10 +286,10 @@ func Serve(app core.App, config ServeConfig) error { } // start HTTPS server - serveErr = server.ServeTLS(listener, "", "") + serveErr = serveEvent.Server.ServeTLS(listener, "", "") } else { // OR start HTTP server - serveErr = server.Serve(listener) + serveErr = serveEvent.Server.Serve(listener) } if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { return serveErr @@ -292,6 +298,14 @@ func Serve(app core.App, config ServeConfig) error { return nil } +// serverAddrToHost loosely converts http.Server.Addr string into a host to print. +func serverAddrToHost(addr string) string { + if addr == "" || strings.HasSuffix(addr, ":http") || strings.HasSuffix(addr, ":https") { + return "127.0.0.1" + } + return addr +} + type serverErrorLogWriter struct { app core.App } diff --git a/core/events.go b/core/events.go index e3826031..d0115fcf 100644 --- a/core/events.go +++ b/core/events.go @@ -103,6 +103,22 @@ type ServeEvent struct { Router *router.Router[*RequestEvent] Server *http.Server CertManager *autocert.Manager + + // InstallerFunc is the "installer" function that is called after + // successfull server tcp bind but only if there is no explicit + // superuser record created yet. + // + // It runs in a separate goroutine and its default value is [apis.DefaultInstallerFunc]. + // + // It receives a system superuser record as argument that you can use to generate + // a short-lived auth token (e.g. systemSuperuser.NewStaticAuthToken(30 * time.Minute)) + // and concatenate it as query param for your installer page + // (if you are using the client-side SDKs, you can then load the + // token with pb.authStore.save(token) and perform any Web API request + // e.g. creating a new superuser). + // + // Set it to nil if you want to skip the installer. + InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error } // ------------------------------------------------------------------- diff --git a/tools/osutils/cmd.go b/tools/osutils/cmd.go new file mode 100644 index 00000000..07fbc452 --- /dev/null +++ b/tools/osutils/cmd.go @@ -0,0 +1,31 @@ +package osutils + +import ( + "os/exec" + "runtime" + + "github.com/go-ozzo/ozzo-validation/v4/is" +) + +// LaunchURL attempts to open the provided url in the user's default browser. +// +// It is platform dependent and it uses: +// - "open" on macOS +// - "rundll32" on Windows +// - "xdg-open" on everything else (Linux, FreeBSD, etc.) +func LaunchURL(url string) error { + if err := is.URL.Validate(url); err != nil { + return err + } + + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "windows": + // not sure if this is the best command but seems to be the most reliable based on the comments in + // https://stackoverflow.com/questions/3739327/launching-a-website-via-the-windows-commandline#answer-49115945 + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + return exec.Command("xdg-open", url).Start() + } +} diff --git a/ui/src/stores/collections.js b/ui/src/stores/collections.js index 56180225..d2871359 100644 --- a/ui/src/stores/collections.js +++ b/ui/src/stores/collections.js @@ -116,4 +116,3 @@ function refreshProtectedFilesCollectionsCache() { return cache; }); } -