diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb53571..6248fc20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,36 @@ - (@todo docs) Added `Dao.WithoutHooks()` helper to create a new `Dao` from the current one but without the create/update/delete hooks. +- **!** Renamed `*Options` to `*Config` for consistency and replaced the unnecessary pointers with their value equivalent to keep the applied configuration defaults isolated within their function calls: + ```go + old: pocketbase.NewWithConfig(config *pocketbase.Config) + new: pocketbase.NewWithConfig(config pocketbase.Config) + + old: core.NewBaseApp(config *core.BaseAppConfig) + new: core.NewBaseApp(config core.BaseAppConfig) + + old: apis.Serve(app core.App, options *apis.ServeOptions) + new: apis.Serve(app core.App, config apis.ServeConfig) + + old: jsvm.MustRegisterMigrations(app core.App, options *jsvm.MigrationsOptions) + new: jsvm.MustRegisterMigrations(app core.App, config jsvm.MigrationsConfig) + + old: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, options *ghupdate.Options) + new: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, config ghupdate.Config) + + old: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, options *migratecmd.Options) + new: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, config migratecmd.Config) + ``` + +- (@todo docs) Added new optional JavaScript app hooks binding via [goja](https://github.com/dop251/goja). + There are available by default with the prebuilt executable if you add a `*.pb.js` file in `pb_hooks` directory. + To enable them as part of a custom Go build: + ```go + jsvm.MustRegisterHooks(app core.App, config jsvm.HooksConfig{}) + ``` + +- Refactored `apis.ApiError` validation errors serialization to allow `map[string]error` and `map[string]any` when generating the public safe formatted `ApiError.Data`. + ## v0.16.4 diff --git a/apis/api_error.go b/apis/api_error.go index f5f82391..8658ac8f 100644 --- a/apis/api_error.go +++ b/apis/api_error.go @@ -66,43 +66,67 @@ func NewUnauthorizedError(message string, data any) *ApiError { // NewApiError creates and returns new normalized `ApiError` instance. func NewApiError(status int, message string, data any) *ApiError { - message = inflector.Sentenize(message) - - formattedData := map[string]any{} - - if v, ok := data.(validation.Errors); ok { - formattedData = resolveValidationErrors(v) - } - return &ApiError{ rawData: data, - Data: formattedData, + Data: safeErrorsData(data), Code: status, - Message: strings.TrimSpace(message), + Message: strings.TrimSpace(inflector.Sentenize(message)), } } -func resolveValidationErrors(validationErrors validation.Errors) map[string]any { +func safeErrorsData(data any) map[string]any { + switch v := data.(type) { + case validation.Errors: + return resolveSafeErrorsData[error](v) + case map[string]validation.Error: + return resolveSafeErrorsData[validation.Error](v) + case map[string]error: + return resolveSafeErrorsData[error](v) + case map[string]any: + return resolveSafeErrorsData[any](v) + default: + return map[string]any{} // not nil to ensure that is json serialized as object + } +} + +func resolveSafeErrorsData[T any](data map[string]T) map[string]any { result := map[string]any{} - // extract from each validation error its error code and message. - for name, err := range validationErrors { - // check for nested errors - if nestedErrs, ok := err.(validation.Errors); ok { - result[name] = resolveValidationErrors(nestedErrs) + for name, err := range data { + if isNestedError(err) { + result[name] = safeErrorsData(err) continue } - - errCode := "validation_invalid_value" // default - if errObj, ok := err.(validation.ErrorObject); ok { - errCode = errObj.Code() - } - - result[name] = map[string]string{ - "code": errCode, - "message": inflector.Sentenize(err.Error()), - } + result[name] = resolveSafeErrorItem(err) } return result } + +func isNestedError(err any) bool { + switch err.(type) { + case validation.Errors, map[string]validation.Error, map[string]error, map[string]any: + return true + } + + return false +} + +// resolveSafeErrorItem extracts from each validation error its +// public safe error code and message. +func resolveSafeErrorItem(err any) map[string]string { + // default public safe error values + code := "validation_invalid_value" + msg := "Invalid value." + + // only validation errors are public safe + if obj, ok := err.(validation.Error); ok { + code = obj.Code() + msg = inflector.Sentenize(obj.Error()) + } + + return map[string]string{ + "code": code, + "message": msg, + } +} diff --git a/apis/base.go b/apis/base.go index ba03929c..cbf97c5d 100644 --- a/apis/base.go +++ b/apis/base.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" + "github.com/dop251/goja" "github.com/labstack/echo/v5" "github.com/labstack/echo/v5/middleware" "github.com/pocketbase/pocketbase/core" @@ -34,6 +35,7 @@ func InitApi(app core.App) (*echo.Echo, error) { e.ResetRouterCreator(func(ec *echo.Echo) echo.Router { return echo.NewRouter(echo.RouterConfig{ UnescapePathParamValues: true, + AllowOverwritingRoute: true, }) }) @@ -58,6 +60,14 @@ func InitApi(app core.App) (*echo.Echo, error) { return } + // manually extract the goja exception error value for + // consistency when throwing or returning errors + if jsException, ok := err.(*goja.Exception); ok { + if wrapped, ok := jsException.Value().Export().(error); ok { + err = wrapped + } + } + var apiErr *ApiError switch v := err.(type) { diff --git a/apis/record_helpers.go b/apis/record_helpers.go index e89b650c..a556870f 100644 --- a/apis/record_helpers.go +++ b/apis/record_helpers.go @@ -57,6 +57,8 @@ func RequestData(c echo.Context) *models.RequestData { return result } +// RecordAuthResponse generates and writes a properly formatted record +// auth response into the specified request context. func RecordAuthResponse( app core.App, c echo.Context, diff --git a/apis/serve.go b/apis/serve.go index cb168c41..16bffbf1 100644 --- a/apis/serve.go +++ b/apis/serve.go @@ -21,22 +21,25 @@ import ( "golang.org/x/crypto/acme/autocert" ) -// ServeOptions defines an optional struct for apis.Serve(). -type ServeOptions struct { +// ServeConfig defines a configuration struct for apis.Serve(). +type ServeConfig struct { + // ShowStartBanner indicates whether to show or hide the server start console message. ShowStartBanner bool - HttpAddr string - HttpsAddr string - AllowedOrigins []string // optional list of CORS origins (default to "*") + + // HttpAddr is the HTTP server address to bind (eg. `127.0.0.1:80`). + HttpAddr string + + // HttpsAddr is the HTTPS server address to bind (eg. `127.0.0.1:443`). + HttpsAddr string + + // AllowedOrigins is an optional list of CORS origins (default to "*"). + AllowedOrigins []string } // Serve starts a new app web server. -func Serve(app core.App, options *ServeOptions) error { - if options == nil { - options = &ServeOptions{} - } - - if len(options.AllowedOrigins) == 0 { - options.AllowedOrigins = []string{"*"} +func Serve(app core.App, config ServeConfig) error { + if len(config.AllowedOrigins) == 0 { + config.AllowedOrigins = []string{"*"} } // ensure that the latest migrations are applied before starting the server @@ -61,15 +64,15 @@ func Serve(app core.App, options *ServeOptions) error { // configure cors router.Use(middleware.CORSWithConfig(middleware.CORSConfig{ Skipper: middleware.DefaultSkipper, - AllowOrigins: options.AllowedOrigins, + AllowOrigins: config.AllowedOrigins, AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete}, })) // start http server // --- - mainAddr := options.HttpAddr - if options.HttpsAddr != "" { - mainAddr = options.HttpsAddr + mainAddr := config.HttpAddr + if config.HttpsAddr != "" { + mainAddr = config.HttpsAddr } mainHost, _, _ := net.SplitHostPort(mainAddr) @@ -80,7 +83,7 @@ func Serve(app core.App, options *ServeOptions) error { HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost), } - serverConfig := &http.Server{ + server := &http.Server{ TLSConfig: &tls.Config{ GetCertificate: certManager.GetCertificate, NextProtos: []string{acme.ALPNProto}, @@ -95,16 +98,16 @@ func Serve(app core.App, options *ServeOptions) error { serveEvent := &core.ServeEvent{ App: app, Router: router, - Server: serverConfig, + Server: server, CertManager: certManager, } if err := app.OnBeforeServe().Trigger(serveEvent); err != nil { return err } - if options.ShowStartBanner { + if config.ShowStartBanner { schema := "http" - if options.HttpsAddr != "" { + if config.HttpsAddr != "" { schema = "https" } @@ -115,34 +118,34 @@ func Serve(app core.App, options *ServeOptions) error { bold.Printf( "%s Server started at %s\n", strings.TrimSpace(date.String()), - color.CyanString("%s://%s", schema, serverConfig.Addr), + color.CyanString("%s://%s", schema, server.Addr), ) regular := color.New() - regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr)) - regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr)) + regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, server.Addr)) + regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, server.Addr)) } // try to gracefully shutdown the server on app termination app.OnTerminate().Add(func(e *core.TerminateEvent) error { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - serverConfig.Shutdown(ctx) + server.Shutdown(ctx) return nil }) // start HTTPS server - if options.HttpsAddr != "" { + if config.HttpsAddr != "" { // if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version - if options.HttpAddr != "" { - go http.ListenAndServe(options.HttpAddr, certManager.HTTPHandler(nil)) + if config.HttpAddr != "" { + go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil)) } - return serverConfig.ListenAndServeTLS("", "") + return server.ListenAndServeTLS("", "") } // OR start HTTP server - return serverConfig.ListenAndServe() + return server.ListenAndServe() } type migrationsConnection struct { diff --git a/cmd/serve.go b/cmd/serve.go index 996f9b8c..b6ffc3e1 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -20,7 +20,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command { Use: "serve", Short: "Starts the web server (default to 127.0.0.1:8090)", Run: func(command *cobra.Command, args []string) { - err := apis.Serve(app, &apis.ServeOptions{ + err := apis.Serve(app, apis.ServeConfig{ HttpAddr: httpAddr, HttpsAddr: httpsAddr, ShowStartBanner: showStartBanner, diff --git a/core/base.go b/core/base.go index 69d8c782..a940c610 100644 --- a/core/base.go +++ b/core/base.go @@ -180,7 +180,7 @@ type BaseAppConfig struct { // configured with the provided arguments. // // To initialize the app, you need to call `app.Bootstrap()`. -func NewBaseApp(config *BaseAppConfig) *BaseApp { +func NewBaseApp(config BaseAppConfig) *BaseApp { app := &BaseApp{ dataDir: config.DataDir, isDebug: config.IsDebug, diff --git a/examples/base/main.go b/examples/base/main.go index 908d31d0..77e74cc4 100644 --- a/examples/base/main.go +++ b/examples/base/main.go @@ -22,6 +22,14 @@ func main() { // Optional plugin flags: // --------------------------------------------------------------- + var hooksDir string + app.RootCmd.PersistentFlags().StringVar( + &hooksDir, + "hooksDir", + "", + "the directory with the JS app hooks", + ) + var migrationsDir string app.RootCmd.PersistentFlags().StringVar( &migrationsDir, @@ -68,20 +76,25 @@ func main() { // Plugins and hooks: // --------------------------------------------------------------- + // load js pb_hooks + jsvm.MustRegisterHooks(app, jsvm.HooksConfig{ + Dir: hooksDir, + }) + // load js pb_migrations - jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{ + jsvm.MustRegisterMigrations(app, jsvm.MigrationsConfig{ Dir: migrationsDir, }) // migrate command (with js templates) - migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{ + migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ TemplateLang: migratecmd.TemplateLangJS, Automigrate: automigrate, Dir: migrationsDir, }) // GitHub selfupdate - ghupdate.MustRegister(app, app.RootCmd, nil) + ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{}) app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error { app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second @@ -105,5 +118,6 @@ func defaultPublicDir() string { // most likely ran with go run return "./pb_public" } + return filepath.Join(os.Args[0], "../pb_public") } diff --git a/go.mod b/go.mod index a493e338..9c781977 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,13 @@ go 1.18 require ( github.com/AlecAivazis/survey/v2 v2.3.6 - github.com/aws/aws-sdk-go v1.44.274 + github.com/aws/aws-sdk-go v1.44.278 github.com/disintegration/imaging v1.6.2 github.com/domodwyer/mailyak/v3 v3.6.0 - github.com/dop251/goja v0.0.0-20230427124612-428fc442ff5f - github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124 + github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3 + github.com/dop251/goja_nodejs v0.0.0-20230602164024-804a84515562 github.com/fatih/color v1.15.0 + github.com/fsnotify/fsnotify v1.6.0 github.com/gabriel-vasile/mimetype v1.4.2 github.com/ganigeorgiev/fexpr v0.3.0 github.com/go-ozzo/ozzo-validation/v4 v4.3.0 @@ -23,7 +24,7 @@ require ( golang.org/x/crypto v0.9.0 golang.org/x/net v0.10.0 golang.org/x/oauth2 v0.8.0 - modernc.org/sqlite v1.22.1 + modernc.org/sqlite v1.23.0 ) require ( @@ -54,7 +55,7 @@ require ( github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/pprof v0.0.0-20230510103437-eeec1cb781c3 // indirect + github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 // indirect github.com/google/uuid v1.3.0 // indirect github.com/google/wire v0.5.0 // indirect github.com/googleapis/gax-go/v2 v2.10.0 // indirect @@ -83,11 +84,11 @@ require ( google.golang.org/grpc v1.55.0 // indirect google.golang.org/protobuf v1.30.0 // indirect lukechampine.com/uint128 v1.3.0 // indirect - modernc.org/cc/v3 v3.40.0 // indirect + modernc.org/cc/v3 v3.41.0 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect - modernc.org/libc v1.22.6 // indirect + modernc.org/libc v1.23.0 // indirect modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect + modernc.org/memory v1.6.0 // indirect modernc.org/opt v0.1.3 // indirect modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.1.0 // indirect diff --git a/go.sum b/go.sum index 3db084b5..e065771d 100644 --- a/go.sum +++ b/go.sum @@ -545,8 +545,8 @@ github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4 github.com/aws/aws-sdk-go v1.44.156/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go v1.44.187/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go v1.44.200/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= -github.com/aws/aws-sdk-go v1.44.274 h1:vfreSv19e/9Ka9YytOzgzJasrRZfX7dnttLlbh8NKeA= -github.com/aws/aws-sdk-go v1.44.274/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.44.278 h1:jJFDO/unYFI48WQk7UGSyO3rBA/gnmRpNYNuAw/fPgE= +github.com/aws/aws-sdk-go v1.44.278/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2 v1.17.4/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= github.com/aws/aws-sdk-go-v2 v1.18.0 h1:882kkTpSFhdgYRKVZ/VCgf7sd0ru57p2JCxz4/oN5RY= @@ -851,13 +851,13 @@ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3 github.com/domodwyer/mailyak/v3 v3.6.0 h1:MdKYNjL709iDKieDF9wGrH3PVhg9I4Tz3vmp7AcgInY= github.com/domodwyer/mailyak/v3 v3.6.0/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= -github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs= -github.com/dop251/goja v0.0.0-20230427124612-428fc442ff5f h1:3Z9NjtffvA8Qoh8xjgUpPmyKawJw/mDRcJlR9oPCvqI= -github.com/dop251/goja v0.0.0-20230427124612-428fc442ff5f/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja v0.0.0-20230531210528-d7324b2d74f7/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= +github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3 h1:+3HCtB74++ClLy8GgjUQYeC8R4ILzVcIe8+5edAJJnE= +github.com/dop251/goja v0.0.0-20230605162241-28ee0ee714f3/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM= -github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124 h1:QDuDMgEkC/lnmvk0d/fZfcUUml18uUbS9TY5QtbdFhs= -github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124/go.mod h1:0tlktQL7yHfYEtjcRGi/eiOkbDR5XF7gyFFvbC5//E0= +github.com/dop251/goja_nodejs v0.0.0-20230602164024-804a84515562 h1:0gomDSJiLLlpfKxQAHt5zj+9toIcyLMPgkI/Mgv7FAU= +github.com/dop251/goja_nodejs v0.0.0-20230602164024-804a84515562/go.mod h1:X2TOTJ+Uamd454RFp7ig2tmP3hQg0Z2Qk8gbVQmU0mk= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -912,6 +912,7 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= @@ -1161,8 +1162,8 @@ github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20220318212150-b2ab0324ddda/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20230111200839-76d1ae5aea2b/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= -github.com/google/pprof v0.0.0-20230510103437-eeec1cb781c3 h1:2XF1Vzq06X+inNqgJ9tRnGuw+ZVCB3FazXODD6JE1R8= -github.com/google/pprof v0.0.0-20230510103437-eeec1cb781c3/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk= +github.com/google/pprof v0.0.0-20230602150820-91b7bce49751 h1:hR7/MlvK23p6+lIw9SN1TigNLn9ZnF3W4SYRKq2gAHs= +github.com/google/pprof v0.0.0-20230602150820-91b7bce49751/go.mod h1:Jh3hGz2jkYak8qXPD19ryItVnUgpgeqzdkY/D0EaeuA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= @@ -2902,22 +2903,22 @@ k8s.io/utils v0.0.0-20221107191617-1a15be271d1d/go.mod h1:OLgZIPagt7ERELqWJFomSt k8s.io/utils v0.0.0-20221128185143-99ec85e7a448/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= -modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= -modernc.org/libc v1.22.6 h1:cbXU8R+A6aOjRuhsFh3nbDWXO/Hs4ClJRXYB11KmPDo= -modernc.org/libc v1.22.6/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/libc v1.23.0 h1:8+5HcgFJUiuLxg1iDxT2YW1LU5/+5r8bcfF0eAQylr4= +modernc.org/libc v1.23.0/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o= +modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE= -modernc.org/sqlite v1.22.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +modernc.org/sqlite v1.23.0 h1:MWTFBI5H1WLnXpNBh/BTruBVqzzoh28DA0iOnlkkRaM= +modernc.org/sqlite v1.23.0/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= diff --git a/plugins/ghupdate/ghupdate.go b/plugins/ghupdate/ghupdate.go index b93c423c..80b5739e 100644 --- a/plugins/ghupdate/ghupdate.go +++ b/plugins/ghupdate/ghupdate.go @@ -1,5 +1,9 @@ // Package ghupdate implements a new command to selfupdate the current // PocketBase executable with the latest GitHub release. +// +// Example usage: +// +// ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{}) package ghupdate import ( @@ -27,10 +31,10 @@ type HttpClient interface { Do(req *http.Request) (*http.Response, error) } -// Options defines optional struct to customize the default plugin behavior. +// Config defines the config options of the ghupdate plugin. // -// NB! This plugin is considered experimental and its options may change in the future. -type Options struct { +// NB! This plugin is considered experimental and its config options may change in the future. +type Config struct { // Owner specifies the account owner of the repository (default to "pocketbase"). Owner string @@ -51,43 +55,38 @@ type Options struct { // MustRegister registers the ghupdate plugin to the provided app instance // and panic if it fails. -func MustRegister(app core.App, rootCmd *cobra.Command, options *Options) { - if err := Register(app, rootCmd, options); err != nil { +func MustRegister(app core.App, rootCmd *cobra.Command, config Config) { + if err := Register(app, rootCmd, config); err != nil { panic(err) } } // Register registers the ghupdate plugin to the provided app instance. -func Register(app core.App, rootCmd *cobra.Command, options *Options) error { +func Register(app core.App, rootCmd *cobra.Command, config Config) error { p := &plugin{ app: app, currentVersion: rootCmd.Version, + config: config, } - if options != nil { - p.options = options - } else { - p.options = &Options{} + if p.config.Owner == "" { + p.config.Owner = "pocketbase" } - if p.options.Owner == "" { - p.options.Owner = "pocketbase" + if p.config.Repo == "" { + p.config.Repo = "pocketbase" } - if p.options.Repo == "" { - p.options.Repo = "pocketbase" + if p.config.ArchiveExecutable == "" { + p.config.ArchiveExecutable = "pocketbase" } - if p.options.ArchiveExecutable == "" { - p.options.ArchiveExecutable = "pocketbase" + if p.config.HttpClient == nil { + p.config.HttpClient = http.DefaultClient } - if p.options.HttpClient == nil { - p.options.HttpClient = http.DefaultClient - } - - if p.options.Context == nil { - p.options.Context = context.Background() + if p.config.Context == nil { + p.config.Context = context.Background() } rootCmd.AddCommand(p.updateCmd()) @@ -98,7 +97,7 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error { type plugin struct { app core.App currentVersion string - options *Options + config Config } func (p *plugin) updateCmd() *cobra.Command { @@ -130,10 +129,10 @@ func (p *plugin) update(withBackup bool) error { color.Yellow("Fetching release information...") latest, err := fetchLatestRelease( - p.options.Context, - p.options.HttpClient, - p.options.Owner, - p.options.Repo, + p.config.Context, + p.config.HttpClient, + p.config.Owner, + p.config.Repo, ) if err != nil { return err @@ -161,7 +160,7 @@ func (p *plugin) update(withBackup bool) error { // download the release asset assetZip := filepath.Join(releaseDir, asset.Name) - if err := downloadFile(p.options.Context, p.options.HttpClient, asset.DownloadUrl, assetZip); err != nil { + if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil { return err } @@ -183,7 +182,7 @@ func (p *plugin) update(withBackup bool) error { renamedOldExec := oldExec + ".old" defer os.Remove(renamedOldExec) - newExec := filepath.Join(extractDir, p.options.ArchiveExecutable) + newExec := filepath.Join(extractDir, p.config.ArchiveExecutable) if _, err := os.Stat(newExec); err != nil { // try again with an .exe extension newExec = newExec + ".exe" @@ -213,7 +212,7 @@ func (p *plugin) update(withBackup bool) error { color.Yellow("Creating pb_data backup...") backupName := fmt.Sprintf("@update_%s.zip", latest.Tag) - if err := p.app.CreateBackup(p.options.Context, backupName); err != nil { + if err := p.app.CreateBackup(p.config.Context, backupName); err != nil { tryToRevertExecChanges() return err } diff --git a/plugins/jsvm/hooks.go b/plugins/jsvm/hooks.go new file mode 100644 index 00000000..71de62ca --- /dev/null +++ b/plugins/jsvm/hooks.go @@ -0,0 +1,165 @@ +package jsvm + +import ( + "path/filepath" + "runtime" + "time" + + "github.com/dop251/goja" + "github.com/dop251/goja_nodejs/console" + "github.com/dop251/goja_nodejs/eventloop" + "github.com/dop251/goja_nodejs/process" + "github.com/dop251/goja_nodejs/require" + "github.com/fatih/color" + "github.com/fsnotify/fsnotify" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" +) + +// HooksConfig defines the config options of the JS app hooks plugin. +type HooksConfig struct { + // Dir specifies the directory with the JS app hooks. + // + // If not set it fallbacks to a relative "pb_data/../pb_hooks" directory. + Dir string + + // Watch enables auto app restarts when a JS app hook file changes. + // + // Note that currently the application cannot be automatically restarted on Windows + // because the restart process relies on execve. + Watch bool +} + +// MustRegisterHooks registers the JS hooks plugin to +// the provided app instance and panics if it fails. +// +// Example usage: +// +// jsvm.MustRegisterHooks(app, jsvm.HooksConfig{}) +func MustRegisterHooks(app core.App, config HooksConfig) { + if err := RegisterHooks(app, config); err != nil { + panic(err) + } +} + +// RegisterHooks registers the JS hooks plugin to the provided app instance. +func RegisterHooks(app core.App, config HooksConfig) error { + p := &hooks{app: app, config: config} + + if p.config.Dir == "" { + p.config.Dir = filepath.Join(app.DataDir(), "../pb_hooks") + } + + // fetch all js hooks sorted by their filename + files, err := filesContent(p.config.Dir, `^.*\.pb\.js$`) + if err != nil { + return err + } + + dbx.HashExp{}.Build(app.DB(), nil) + + registry := new(require.Registry) // this can be shared by multiple runtimes + + loop := eventloop.NewEventLoop() + + loop.Run(func(vm *goja.Runtime) { + registry.Enable(vm) + console.Enable(vm) + process.Enable(vm) + baseBinds(vm) + dbxBinds(vm) + filesystemBinds(vm) + tokensBinds(vm) + securityBinds(vm) + formsBinds(vm) + apisBinds(vm) + + vm.Set("$app", app) + + for file, content := range files { + _, err := vm.RunString(string(content)) + if err != nil { + if p.config.Watch { + color.Red("Failed to execute %s: %v", file, err) + } else { + // return err + } + } + } + }) + + loop.Start() + + app.OnTerminate().Add(func(e *core.TerminateEvent) error { + loop.StopNoWait() + + return nil + }) + + if p.config.Watch { + return p.watchFiles() + } + + return nil +} + +type hooks struct { + app core.App + config HooksConfig +} + +func (h *hooks) watchFiles() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + h.app.OnTerminate().Add(func(e *core.TerminateEvent) error { + watcher.Close() + + return nil + }) + + var debounceTimer *time.Timer + + // start listening for events. + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(100*time.Millisecond, func() { + // app restart is currently not supported on Windows + if runtime.GOOS == "windows" { + color.Yellow("File %s changed, please restart the app", event.Name) + } else { + color.Yellow("File %s changed, restarting...", event.Name) + if err := h.app.Restart(); err != nil { + color.Red("Failed to restart the app:", err) + } + } + }) + case err, ok := <-watcher.Errors: + if !ok { + return + } + color.Red("Watch error:", err) + } + } + }() + + // add the directory to watch + err = watcher.Add(h.config.Dir) + if err != nil { + watcher.Close() + return err + } + + return nil +} diff --git a/plugins/jsvm/mapper.go b/plugins/jsvm/mapper.go new file mode 100644 index 00000000..febe8b12 --- /dev/null +++ b/plugins/jsvm/mapper.go @@ -0,0 +1,48 @@ +package jsvm + +import ( + "reflect" + "strings" + "unicode" + + "github.com/dop251/goja" +) + +var ( + _ goja.FieldNameMapper = (*FieldMapper)(nil) +) + +// FieldMapper provides custom mapping between Go and JavaScript property names. +// +// It is similar to the builtin "uncapFieldNameMapper" but also converts +// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get"). +type FieldMapper struct { +} + +// FieldName implements the [FieldNameMapper.FieldName] interface method. +func (u FieldMapper) FieldName(_ reflect.Type, f reflect.StructField) string { + return convertGoToJSName(f.Name) +} + +// MethodName implements the [FieldNameMapper.MethodName] interface method. +func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string { + return convertGoToJSName(m.Name) +} + +func convertGoToJSName(name string) string { + allUppercase := true + for _, c := range name { + if c != '_' && !unicode.IsUpper(c) { + allUppercase = false + break + } + } + + // eg. "JSON" -> "json" + if allUppercase { + return strings.ToLower(name) + } + + // eg. "GetField" -> "getField" + return strings.ToLower(name[0:1]) + name[1:] +} diff --git a/plugins/jsvm/mapper_test.go b/plugins/jsvm/mapper_test.go new file mode 100644 index 00000000..4377b317 --- /dev/null +++ b/plugins/jsvm/mapper_test.go @@ -0,0 +1,40 @@ +package jsvm_test + +import ( + "reflect" + "testing" + + "github.com/pocketbase/pocketbase/plugins/jsvm" +) + +func TestFieldMapper(t *testing.T) { + mapper := jsvm.FieldMapper{} + + scenarios := []struct { + name string + expected string + }{ + {"", ""}, + {"test", "test"}, + {"Test", "test"}, + {"miXeD", "miXeD"}, + {"MiXeD", "miXeD"}, + {"ResolveRequestAsJSON", "resolveRequestAsJSON"}, + {"Variable_with_underscore", "variable_with_underscore"}, + {"ALLCAPS", "allcaps"}, + {"NOTALLCAPs", "nOTALLCAPs"}, + {"ALL_CAPS_WITH_UNDERSCORE", "all_caps_with_underscore"}, + } + + for i, s := range scenarios { + field := reflect.StructField{Name: s.name} + if v := mapper.FieldName(nil, field); v != s.expected { + t.Fatalf("[%d] Expected FieldName %q, got %q", i, s.expected, v) + } + + method := reflect.Method{Name: s.name} + if v := mapper.MethodName(nil, method); v != s.expected { + t.Fatalf("[%d] Expected MethodName %q, got %q", i, s.expected, v) + } + } +} diff --git a/plugins/jsvm/migrations.go b/plugins/jsvm/migrations.go index 61cdf415..431b7b91 100644 --- a/plugins/jsvm/migrations.go +++ b/plugins/jsvm/migrations.go @@ -2,10 +2,9 @@ package jsvm import ( "fmt" - "os" "path/filepath" - "strings" + "github.com/dop251/goja" "github.com/dop251/goja_nodejs/console" "github.com/dop251/goja_nodejs/process" "github.com/dop251/goja_nodejs/require" @@ -14,52 +13,36 @@ import ( m "github.com/pocketbase/pocketbase/migrations" ) -// MigrationsOptions defines optional struct to customize the default migrations loader behavior. -type MigrationsOptions struct { +// MigrationsConfig defines the config options of the JS migrations loader plugin. +type MigrationsConfig struct { // Dir specifies the directory with the JS migrations. // // If not set it fallbacks to a relative "pb_data/../pb_migrations" directory. Dir string } -// migrations is the migrations loader plugin definition. -// Usually it is instantiated via RegisterMigrations or MustRegisterMigrations. -type migrations struct { - app core.App - options *MigrationsOptions -} - -// MustRegisterMigrations registers the migrations loader plugin to +// MustRegisterMigrations registers the JS migrations loader plugin to // the provided app instance and panics if it fails. // -// Internally it calls RegisterMigrations(app, options). +// Example usage: // -// If options is nil, by default the js files from pb_data/migrations are loaded. -// Set custom options.Dir if you want to change it to some other directory. -func MustRegisterMigrations(app core.App, options *MigrationsOptions) { - if err := RegisterMigrations(app, options); err != nil { +// jsvm.MustRegisterMigrations(app, jsvm.MigrationsConfig{}) +func MustRegisterMigrations(app core.App, config MigrationsConfig) { + if err := RegisterMigrations(app, config); err != nil { panic(err) } } -// RegisterMigrations registers the plugin to the provided app instance. -// -// If options is nil, by default the js files from pb_data/migrations are loaded. -// Set custom options.Dir if you want to change it to some other directory. -func RegisterMigrations(app core.App, options *MigrationsOptions) error { - l := &migrations{app: app} +// RegisterMigrations registers the JS migrations loader hooks plugin +// to the provided app instance. +func RegisterMigrations(app core.App, config MigrationsConfig) error { + l := &migrations{app: app, config: config} - if options != nil { - l.options = options - } else { - l.options = &MigrationsOptions{} + if l.config.Dir == "" { + l.config.Dir = filepath.Join(app.DataDir(), "../pb_migrations") } - if l.options.Dir == "" { - l.options.Dir = filepath.Join(app.DataDir(), "../pb_migrations") - } - - files, err := readDirFiles(l.options.Dir) + files, err := filesContent(l.config.Dir, `^.*\.js$`) if err != nil { return err } @@ -67,10 +50,13 @@ func RegisterMigrations(app core.App, options *MigrationsOptions) error { registry := new(require.Registry) // this can be shared by multiple runtimes for file, content := range files { - vm := NewBaseVM() + vm := goja.New() registry.Enable(vm) console.Enable(vm) process.Enable(vm) + dbxBinds(vm) + tokensBinds(vm) + securityBinds(vm) vm.Set("migrate", func(up, down func(db dbx.Builder) error) { m.AppMigrations.Register(up, down, file) @@ -85,30 +71,7 @@ func RegisterMigrations(app core.App, options *MigrationsOptions) error { return nil } -// readDirFiles returns a map with all directory files and their content. -// -// If directory with dirPath is missing, it returns an empty map and no error. -func readDirFiles(dirPath string) (map[string][]byte, error) { - files, err := os.ReadDir(dirPath) - if err != nil { - if os.IsNotExist(err) { - return map[string][]byte{}, nil - } - return nil, err - } - - result := map[string][]byte{} - - for _, f := range files { - if f.IsDir() || !strings.HasSuffix(f.Name(), ".js") { - continue // not a .js file - } - raw, err := os.ReadFile(filepath.Join(dirPath, f.Name())) - if err != nil { - return nil, err - } - result[f.Name()] = raw - } - - return result, nil +type migrations struct { + app core.App + config MigrationsConfig } diff --git a/plugins/jsvm/vm.go b/plugins/jsvm/vm.go index 5e99ed3f..c389e736 100644 --- a/plugins/jsvm/vm.go +++ b/plugins/jsvm/vm.go @@ -5,36 +5,42 @@ // // 1. JS Migrations loader: // -// jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{ -// Dir: "custom_js_migrations_dir_path", // default to "pb_data/../pb_migrations" +// jsvm.MustRegisterMigrations(app, jsvm.MigrationsConfig{ +// Dir: "/custom/js/migrations/dir", // default to "pb_data/../pb_migrations" +// }) +// +// 2. JS app hooks: +// +// jsvm.MustRegisterHooks(app, jsvm.HooksConfig{ +// Dir: "/custom/js/hooks/dir", // default to "pb_data/../pb_hooks" // }) package jsvm import ( "encoding/json" + "os" + "path/filepath" "reflect" - "strings" - "unicode" + "regexp" "github.com/dop251/goja" + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/labstack/echo/v5" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tokens" + "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/mailer" + "github.com/pocketbase/pocketbase/tools/security" ) -func NewBaseVM() *goja.Runtime { - vm := goja.New() +func baseBinds(vm *goja.Runtime) { vm.SetFieldNameMapper(FieldMapper{}) - baseBinds(vm) - dbxBinds(vm) - - return vm -} - -func baseBinds(vm *goja.Runtime) { vm.Set("unmarshal", func(src map[string]any, dest any) (any, error) { raw, err := json.Marshal(src) if err != nil { @@ -72,49 +78,57 @@ func baseBinds(vm *goja.Runtime) { vm.Set("Collection", func(call goja.ConstructorCall) *goja.Object { instance := &models.Collection{} - return defaultConstructor(vm, call, instance) + return structConstructor(vm, call, instance) }) vm.Set("Admin", func(call goja.ConstructorCall) *goja.Object { instance := &models.Admin{} - return defaultConstructor(vm, call, instance) + return structConstructor(vm, call, instance) }) vm.Set("Schema", func(call goja.ConstructorCall) *goja.Object { instance := &schema.Schema{} - return defaultConstructor(vm, call, instance) + return structConstructor(vm, call, instance) }) vm.Set("SchemaField", func(call goja.ConstructorCall) *goja.Object { instance := &schema.SchemaField{} - return defaultConstructor(vm, call, instance) + return structConstructor(vm, call, instance) }) - vm.Set("Dao", func(call goja.ConstructorCall) *goja.Object { - db, ok := call.Argument(0).Export().(dbx.Builder) - if !ok || db == nil { - panic("missing required Dao(db) argument") - } + vm.Set("Mail", func(call goja.ConstructorCall) *goja.Object { + instance := &mailer.Message{} + return structConstructor(vm, call, instance) + }) - instance := daos.New(db) + vm.Set("ValidationError", func(call goja.ConstructorCall) *goja.Object { + code, _ := call.Argument(0).Export().(string) + message, _ := call.Argument(1).Export().(string) + + instance := validation.NewError(code, message) instanceValue := vm.ToValue(instance).(*goja.Object) instanceValue.SetPrototype(call.This.Prototype()) return instanceValue }) -} -func defaultConstructor(vm *goja.Runtime, call goja.ConstructorCall, instance any) *goja.Object { - if data := call.Argument(0).Export(); data != nil { - if raw, err := json.Marshal(data); err == nil { - json.Unmarshal(raw, instance) + vm.Set("Dao", func(call goja.ConstructorCall) *goja.Object { + concurrentDB, _ := call.Argument(0).Export().(dbx.Builder) + if concurrentDB == nil { + panic("missing required Dao(concurrentDB, [nonconcurrentDB]) argument") } - } - instanceValue := vm.ToValue(instance).(*goja.Object) - instanceValue.SetPrototype(call.This.Prototype()) + nonConcurrentDB, _ := call.Argument(1).Export().(dbx.Builder) + if nonConcurrentDB == nil { + nonConcurrentDB = concurrentDB + } - return instanceValue + instance := daos.NewMultiDB(concurrentDB, nonConcurrentDB) + instanceValue := vm.ToValue(instance).(*goja.Object) + instanceValue.SetPrototype(call.This.Prototype()) + + return instanceValue + }) } func dbxBinds(vm *goja.Runtime) { @@ -123,11 +137,7 @@ func dbxBinds(vm *goja.Runtime) { obj.Set("exp", dbx.NewExp) obj.Set("hashExp", func(data map[string]any) dbx.HashExp { - exp := dbx.HashExp{} - for k, v := range data { - exp[k] = v - } - return exp + return dbx.HashExp(data) }) obj.Set("not", dbx.Not) obj.Set("and", dbx.And) @@ -144,8 +154,79 @@ func dbxBinds(vm *goja.Runtime) { obj.Set("notBetween", dbx.NotBetween) } -func apisBind(vm *goja.Runtime) { +func tokensBinds(vm *goja.Runtime) { obj := vm.NewObject() + vm.Set("$tokens", obj) + + // admin + obj.Set("adminAuthToken", tokens.NewAdminAuthToken) + obj.Set("adminResetPasswordToken", tokens.NewAdminResetPasswordToken) + obj.Set("adminFileToken", tokens.NewAdminFileToken) + + // record + obj.Set("recordAuthToken", tokens.NewRecordAuthToken) + obj.Set("recordVerifyToken", tokens.NewRecordVerifyToken) + obj.Set("recordResetPasswordToken", tokens.NewRecordResetPasswordToken) + obj.Set("recordChangeEmailToken", tokens.NewRecordChangeEmailToken) + obj.Set("recordFileToken", tokens.NewRecordFileToken) +} + +func securityBinds(vm *goja.Runtime) { + obj := vm.NewObject() + vm.Set("$security", obj) + + // random + obj.Set("randomString", security.RandomString) + obj.Set("randomStringWithAlphabet", security.RandomStringWithAlphabet) + obj.Set("pseudorandomString", security.PseudorandomString) + obj.Set("pseudorandomStringWithAlphabet", security.PseudorandomStringWithAlphabet) + + // jwt + obj.Set("parseUnverifiedToken", security.ParseUnverifiedJWT) + obj.Set("parseToken", security.ParseJWT) + obj.Set("createToken", security.NewToken) +} + +func filesystemBinds(vm *goja.Runtime) { + obj := vm.NewObject() + vm.Set("$filesystem", obj) + + obj.Set("fileFromPath", filesystem.NewFileFromPath) + obj.Set("fileFromBytes", filesystem.NewFileFromBytes) + obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart) +} + +func formsBinds(vm *goja.Runtime) { + registerFactoryAsConstructor(vm, "AdminLoginForm", forms.NewAdminLogin) + registerFactoryAsConstructor(vm, "AdminPasswordResetConfirmForm", forms.NewAdminPasswordResetConfirm) + registerFactoryAsConstructor(vm, "AdminPasswordResetRequestForm", forms.NewAdminPasswordResetRequest) + registerFactoryAsConstructor(vm, "AdminUpsertForm", forms.NewAdminUpsert) + registerFactoryAsConstructor(vm, "AppleClientSecretCreateForm", forms.NewAppleClientSecretCreate) + registerFactoryAsConstructor(vm, "CollectionUpsertForm", forms.NewCollectionUpsert) + registerFactoryAsConstructor(vm, "CollectionsImportForm", forms.NewCollectionsImport) + registerFactoryAsConstructor(vm, "RealtimeSubscribeForm", forms.NewRealtimeSubscribe) + registerFactoryAsConstructor(vm, "RecordEmailChangeConfirmForm", forms.NewRecordEmailChangeConfirm) + registerFactoryAsConstructor(vm, "RecordEmailChangeRequestForm", forms.NewRecordEmailChangeRequest) + registerFactoryAsConstructor(vm, "RecordOAuth2LoginForm", forms.NewRecordOAuth2Login) + registerFactoryAsConstructor(vm, "RecordPasswordLoginForm", forms.NewRecordPasswordLogin) + registerFactoryAsConstructor(vm, "RecordPasswordResetConfirmForm", forms.NewRecordPasswordResetConfirm) + registerFactoryAsConstructor(vm, "RecordPasswordResetRequestForm", forms.NewRecordPasswordResetRequest) + registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert) + registerFactoryAsConstructor(vm, "RecordVerificationConfirmForm", forms.NewRecordVerificationConfirm) + registerFactoryAsConstructor(vm, "RecordVerificationRequestForm", forms.NewRecordVerificationRequest) + registerFactoryAsConstructor(vm, "SettingsUpsertForm", forms.NewSettingsUpsert) + registerFactoryAsConstructor(vm, "TestEmailSendForm", forms.NewTestEmailSend) + registerFactoryAsConstructor(vm, "TestS3FilesystemForm", forms.NewTestS3Filesystem) +} + +func apisBinds(vm *goja.Runtime) { + obj := vm.NewObject() + + vm.Set("Route", func(call goja.ConstructorCall) *goja.Object { + instance := echo.Route{} + return structConstructor(vm, call, &instance) + }) + vm.Set("$apis", obj) // middlewares @@ -158,49 +239,107 @@ func apisBind(vm *goja.Runtime) { obj.Set("requireAdminOrOwnerAuth", apis.RequireAdminOrOwnerAuth) obj.Set("activityLogger", apis.ActivityLogger) + // record helpers + obj.Set("requestData", apis.RequestData) + obj.Set("recordAuthResponse", apis.RecordAuthResponse) + obj.Set("enrichRecord", apis.EnrichRecord) + obj.Set("enrichRecords", apis.EnrichRecords) + // api errors + vm.Set("ApiError", func(call goja.ConstructorCall) *goja.Object { + status, _ := call.Argument(0).Export().(int64) + message, _ := call.Argument(1).Export().(string) + data := call.Argument(2).Export() + + instance := apis.NewApiError(int(status), message, data) + instanceValue := vm.ToValue(instance).(*goja.Object) + instanceValue.SetPrototype(call.This.Prototype()) + + return instanceValue + }) obj.Set("notFoundError", apis.NewNotFoundError) obj.Set("badRequestError", apis.NewBadRequestError) obj.Set("forbiddenError", apis.NewForbiddenError) obj.Set("unauthorizedError", apis.NewUnauthorizedError) - - // record helpers - obj.Set("requestData", apis.RequestData) - obj.Set("enrichRecord", apis.EnrichRecord) - obj.Set("enrichRecords", apis.EnrichRecords) } -// FieldMapper provides custom mapping between Go and JavaScript property names. -// -// It is similar to the builtin "uncapFieldNameMapper" but also converts -// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get"). -type FieldMapper struct { +// ------------------------------------------------------------------- + +// registerFactoryAsConstructor registers the factory function as native JS constructor. +func registerFactoryAsConstructor(vm *goja.Runtime, constructorName string, factoryFunc any) { + vm.Set(constructorName, func(call goja.ConstructorCall) *goja.Object { + f := reflect.ValueOf(factoryFunc) + + args := []reflect.Value{} + + for _, v := range call.Arguments { + args = append(args, reflect.ValueOf(v.Export())) + } + + result := f.Call(args) + + if len(result) != 1 { + panic("the factory function should return only 1 item") + } + + value := vm.ToValue(result[0].Interface()).(*goja.Object) + value.SetPrototype(call.This.Prototype()) + + return value + }) } -// FieldName implements the [FieldNameMapper.FieldName] interface method. -func (u FieldMapper) FieldName(_ reflect.Type, f reflect.StructField) string { - return convertGoToJSName(f.Name) -} - -// MethodName implements the [FieldNameMapper.MethodName] interface method. -func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string { - return convertGoToJSName(m.Name) -} - -func convertGoToJSName(name string) string { - allUppercase := true - for _, c := range name { - if c != '_' && !unicode.IsUpper(c) { - allUppercase = false - break +// structConstructor wraps the provided struct with a native JS constructor. +func structConstructor(vm *goja.Runtime, call goja.ConstructorCall, instance any) *goja.Object { + if data := call.Argument(0).Export(); data != nil { + if raw, err := json.Marshal(data); err == nil { + json.Unmarshal(raw, instance) } } - // eg. "JSON" -> "json" - if allUppercase { - return strings.ToLower(name) + instanceValue := vm.ToValue(instance).(*goja.Object) + instanceValue.SetPrototype(call.This.Prototype()) + + return instanceValue +} + +// filesContent returns a map with all direct files within the specified dir and their content. +// +// If directory with dirPath is missing or no files matching the pattern were found, +// it returns an empty map and no error. +// +// If pattern is empty string it matches all root files. +func filesContent(dirPath string, pattern string) (map[string][]byte, error) { + files, err := os.ReadDir(dirPath) + if err != nil { + if os.IsNotExist(err) { + return map[string][]byte{}, nil + } + return nil, err } - // eg. "GetField" -> "getField" - return strings.ToLower(name[0:1]) + name[1:] + var exp *regexp.Regexp + if pattern != "" { + var err error + if exp, err = regexp.Compile(pattern); err != nil { + return nil, err + } + } + + result := map[string][]byte{} + + for _, f := range files { + if f.IsDir() || (exp != nil && !exp.MatchString(f.Name())) { + continue + } + + raw, err := os.ReadFile(filepath.Join(dirPath, f.Name())) + if err != nil { + return nil, err + } + + result[f.Name()] = raw + } + + return result, nil } diff --git a/plugins/jsvm/vm_test.go b/plugins/jsvm/vm_test.go index daa890bf..7641de8d 100644 --- a/plugins/jsvm/vm_test.go +++ b/plugins/jsvm/vm_test.go @@ -1,18 +1,35 @@ -package jsvm_test +package jsvm import ( - "reflect" + "encoding/json" + "mime/multipart" + "path/filepath" "testing" + "github.com/dop251/goja" + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" - "github.com/pocketbase/pocketbase/plugins/jsvm" "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/mailer" + "github.com/pocketbase/pocketbase/tools/security" ) -func TestBaseVMUnmarshal(t *testing.T) { - vm := jsvm.NewBaseVM() +// note: this test is useful as a reminder to update the tests in case +// a new base binding is added. +func TestBaseBindsCount(t *testing.T) { + vm := goja.New() + baseBinds(vm) + + testBindsCount(vm, "this", 9, t) +} + +func TestBaseBindsUnmarshal(t *testing.T) { + vm := goja.New() + baseBinds(vm) v, err := vm.RunString(`unmarshal({ name: "test" }, new Collection())`) if err != nil { @@ -29,7 +46,7 @@ func TestBaseVMUnmarshal(t *testing.T) { } } -func TestBaseVMRecordBind(t *testing.T) { +func TestBaseBindsRecord(t *testing.T) { app, _ := tests.NewTestApp() defer app.Cleanup() @@ -38,7 +55,8 @@ func TestBaseVMRecordBind(t *testing.T) { t.Fatal(err) } - vm := jsvm.NewBaseVM() + vm := goja.New() + baseBinds(vm) vm.Set("collection", collection) // without record data @@ -74,75 +92,9 @@ func TestBaseVMRecordBind(t *testing.T) { } } -// @todo enable after https://github.com/dop251/goja/issues/426 -// func TestBaseVMRecordGetAndSetBind(t *testing.T) { -// app, _ := tests.NewTestApp() -// defer app.Cleanup() - -// collection, err := app.Dao().FindCollectionByNameOrId("users") -// if err != nil { -// t.Fatal(err) -// } - -// vm := jsvm.NewBaseVM() -// vm.Set("collection", collection) -// vm.Set("getRecord", func() *models.Record { -// return models.NewRecord(collection) -// }) - -// _, runErr := vm.RunString(` -// const jsRecord = new Record(collection); -// jsRecord.email = "test@example.com"; // test js record setter -// const email = jsRecord.email; // test js record getter - -// const goRecord = getRecord() -// goRecord.name = "test" // test go record setter -// const name = goRecord.name; // test go record getter -// `) -// if runErr != nil { -// t.Fatal(runErr) -// } - -// expectedEmail := "test@example.com" -// expectedName := "test" - -// jsRecord, ok := vm.Get("jsRecord").Export().(*models.Record) -// if !ok { -// t.Fatalf("Failed to export jsRecord") -// } -// if v := jsRecord.Email(); v != expectedEmail { -// t.Fatalf("Expected the js created record to have email %q, got %q", expectedEmail, v) -// } - -// email := vm.Get("email").Export().(string) -// if email != expectedEmail { -// t.Fatalf("Expected exported email %q, got %q", expectedEmail, email) -// } - -// goRecord, ok := vm.Get("goRecord").Export().(*models.Record) -// if !ok { -// t.Fatalf("Failed to export goRecord") -// } -// if v := goRecord.GetString("name"); v != expectedName { -// t.Fatalf("Expected the go created record to have name %q, got %q", expectedName, v) -// } - -// name := vm.Get("name").Export().(string) -// if name != expectedName { -// t.Fatalf("Expected exported name %q, got %q", expectedName, name) -// } - -// // ensure that the two record instances are not mixed -// if v := goRecord.Email(); v != "" { -// t.Fatalf("Expected the go created record to not have an email, got %q", v) -// } -// if v := jsRecord.GetString("name"); v != "" { -// t.Fatalf("Expected the js created record to not have a name, got %q", v) -// } -// } - -func TestBaseVMCollectionBind(t *testing.T) { - vm := jsvm.NewBaseVM() +func TestBaseBindsCollection(t *testing.T) { + vm := goja.New() + baseBinds(vm) v, err := vm.RunString(`new Collection({ name: "test", schema: [{name: "title", "type": "text"}] })`) if err != nil { @@ -164,7 +116,8 @@ func TestBaseVMCollectionBind(t *testing.T) { } func TestBaseVMAdminBind(t *testing.T) { - vm := jsvm.NewBaseVM() + vm := goja.New() + baseBinds(vm) v, err := vm.RunString(`new Admin({ email: "test@example.com" })`) if err != nil { @@ -177,8 +130,9 @@ func TestBaseVMAdminBind(t *testing.T) { } } -func TestBaseVMSchemaBind(t *testing.T) { - vm := jsvm.NewBaseVM() +func TestBaseBindsSchema(t *testing.T) { + vm := goja.New() + baseBinds(vm) v, err := vm.RunString(`new Schema([{name: "title", "type": "text"}])`) if err != nil { @@ -195,8 +149,9 @@ func TestBaseVMSchemaBind(t *testing.T) { } } -func TestBaseVMSchemaFieldBind(t *testing.T) { - vm := jsvm.NewBaseVM() +func TestBaseBindsSchemaField(t *testing.T) { + vm := goja.New() + baseBinds(vm) v, err := vm.RunString(`new SchemaField({name: "title", "type": "text"})`) if err != nil { @@ -213,56 +168,461 @@ func TestBaseVMSchemaFieldBind(t *testing.T) { } } -func TestBaseVMDaoBind(t *testing.T) { - app, _ := tests.NewTestApp() - defer app.Cleanup() +func TestBaseBindsMail(t *testing.T) { + vm := goja.New() + baseBinds(vm) - vm := jsvm.NewBaseVM() - vm.Set("db", app.DB()) - - v, err := vm.RunString(`new Dao(db)`) + v, err := vm.RunString(`new Mail({ + from: {name: "test_from", address: "test_from@example.com"}, + to: [ + {name: "test_to1", address: "test_to1@example.com"}, + {name: "test_to2", address: "test_to2@example.com"}, + ], + bcc: [ + {name: "test_bcc1", address: "test_bcc1@example.com"}, + {name: "test_bcc2", address: "test_bcc2@example.com"}, + ], + cc: [ + {name: "test_cc1", address: "test_cc1@example.com"}, + {name: "test_cc2", address: "test_cc2@example.com"}, + ], + subject: "test_subject", + html: "test_html", + text: "test_text", + headers: { + header1: "a", + header2: "b", + } + })`) if err != nil { t.Fatal(err) } - d, ok := v.Export().(*daos.Dao) + m, ok := v.Export().(*mailer.Message) if !ok { - t.Fatalf("Expected daos.Dao, got %v", d) + t.Fatalf("Expected mailer.Message, got %v", m) } - if d.DB() != app.DB() { - t.Fatalf("The db instances doesn't match") + raw, err := json.Marshal(m) + + expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":null}` + + if string(raw) != expected { + t.Fatalf("Expected \n%s, \ngot \n%s", expected, raw) } } -func TestFieldMapper(t *testing.T) { - mapper := jsvm.FieldMapper{} +func TestBaseBindsValidationError(t *testing.T) { + vm := goja.New() + baseBinds(vm) scenarios := []struct { - name string - expected string + js string + expectCode string + expectMessage string }{ - {"", ""}, - {"test", "test"}, - {"Test", "test"}, - {"miXeD", "miXeD"}, - {"MiXeD", "miXeD"}, - {"ResolveRequestAsJSON", "resolveRequestAsJSON"}, - {"Variable_with_underscore", "variable_with_underscore"}, - {"ALLCAPS", "allcaps"}, - {"NOTALLCAPs", "nOTALLCAPs"}, - {"ALL_CAPS_WITH_UNDERSCORE", "all_caps_with_underscore"}, + { + `new ValidationError()`, + "", + "", + }, + { + `new ValidationError("test_code")`, + "test_code", + "", + }, + { + `new ValidationError("test_code", "test_message")`, + "test_code", + "test_message", + }, } - for i, s := range scenarios { - field := reflect.StructField{Name: s.name} - if v := mapper.FieldName(nil, field); v != s.expected { - t.Fatalf("[%d] Expected FieldName %q, got %q", i, s.expected, v) + for _, s := range scenarios { + v, err := vm.RunString(s.js) + if err != nil { + t.Fatal(err) } - method := reflect.Method{Name: s.name} - if v := mapper.MethodName(nil, method); v != s.expected { - t.Fatalf("[%d] Expected MethodName %q, got %q", i, s.expected, v) + m, ok := v.Export().(validation.Error) + if !ok { + t.Fatalf("[%s] Expected validation.Error, got %v", s.js, m) + } + + if m.Code() != s.expectCode { + t.Fatalf("[%s] Expected code %q, got %q", s.js, s.expectCode, m.Code()) + } + + if m.Message() != s.expectMessage { + t.Fatalf("[%s] Expected message %q, got %q", s.js, s.expectMessage, m.Message()) } } } + +func TestBaseBindsDao(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + baseBinds(vm) + vm.Set("db", app.Dao().ConcurrentDB()) + vm.Set("db2", app.Dao().NonconcurrentDB()) + + scenarios := []struct { + js string + concurrentDB dbx.Builder + nonconcurrentDB dbx.Builder + }{ + { + js: "new Dao(db)", + concurrentDB: app.Dao().ConcurrentDB(), + nonconcurrentDB: app.Dao().ConcurrentDB(), + }, + { + js: "new Dao(db, db2)", + concurrentDB: app.Dao().ConcurrentDB(), + nonconcurrentDB: app.Dao().NonconcurrentDB(), + }, + } + + for _, s := range scenarios { + v, err := vm.RunString(s.js) + if err != nil { + t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err) + } + + d, ok := v.Export().(*daos.Dao) + if !ok { + t.Fatalf("[%s] Expected daos.Dao, got %v", s.js, d) + } + + if d.ConcurrentDB() != s.concurrentDB { + t.Fatalf("[%s] The ConcurrentDB instances doesn't match", s.js) + } + + if d.NonconcurrentDB() != s.nonconcurrentDB { + t.Fatalf("[%s] The NonconcurrentDB instances doesn't match", s.js) + } + } +} + +func TestDbxBinds(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + vm.Set("db", app.Dao().DB()) + baseBinds(vm) + dbxBinds(vm) + + testBindsCount(vm, "$dbx", 15, t) + + sceneraios := []struct { + js string + expected string + }{ + { + `$dbx.exp("a = 1").build(db, {})`, + "a = 1", + }, + { + `$dbx.hashExp({ + "a": 1, + b: null, + c: [1, 2, 3], + }).build(db, {})`, + "`a`={:p0} AND `b` IS NULL AND `c` IN ({:p1}, {:p2}, {:p3})", + }, + { + `$dbx.not($dbx.exp("a = 1")).build(db, {})`, + "NOT (a = 1)", + }, + { + `$dbx.and($dbx.exp("a = 1"), $dbx.exp("b = 2")).build(db, {})`, + "(a = 1) AND (b = 2)", + }, + { + `$dbx.or($dbx.exp("a = 1"), $dbx.exp("b = 2")).build(db, {})`, + "(a = 1) OR (b = 2)", + }, + { + `$dbx.in("a", 1, 2, 3).build(db, {})`, + "`a` IN ({:p0}, {:p1}, {:p2})", + }, + { + `$dbx.notIn("a", 1, 2, 3).build(db, {})`, + "`a` NOT IN ({:p0}, {:p1}, {:p2})", + }, + { + `$dbx.like("a", "test1", "test2").match(true, false).build(db, {})`, + "`a` LIKE {:p0} AND `a` LIKE {:p1}", + }, + { + `$dbx.orLike("a", "test1", "test2").match(false, true).build(db, {})`, + "`a` LIKE {:p0} OR `a` LIKE {:p1}", + }, + { + `$dbx.notLike("a", "test1", "test2").match(true, false).build(db, {})`, + "`a` NOT LIKE {:p0} AND `a` NOT LIKE {:p1}", + }, + { + `$dbx.orNotLike("a", "test1", "test2").match(false, false).build(db, {})`, + "`a` NOT LIKE {:p0} OR `a` NOT LIKE {:p1}", + }, + { + `$dbx.exists($dbx.exp("a = 1")).build(db, {})`, + "EXISTS (a = 1)", + }, + { + `$dbx.notExists($dbx.exp("a = 1")).build(db, {})`, + "NOT EXISTS (a = 1)", + }, + { + `$dbx.between("a", 1, 2).build(db, {})`, + "`a` BETWEEN {:p0} AND {:p1}", + }, + { + `$dbx.notBetween("a", 1, 2).build(db, {})`, + "`a` NOT BETWEEN {:p0} AND {:p1}", + }, + } + + for _, s := range sceneraios { + result, err := vm.RunString(s.js) + if err != nil { + t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err) + } + + v, _ := result.Export().(string) + + if v != s.expected { + t.Fatalf("[%s] Expected \n%s, \ngot \n%s", s.js, s.expected, v) + } + } +} + +func TestTokensBinds(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + admin, err := app.Dao().FindAdminByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + + record, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com") + if err != nil { + t.Fatal(err) + } + + vm := goja.New() + vm.Set("$app", app) + vm.Set("admin", admin) + vm.Set("record", record) + baseBinds(vm) + tokensBinds(vm) + + testBindsCount(vm, "$tokens", 8, t) + + sceneraios := []struct { + js string + key string + }{ + { + `$tokens.adminAuthToken($app, admin)`, + admin.TokenKey + app.Settings().AdminAuthToken.Secret, + }, + { + `$tokens.adminResetPasswordToken($app, admin)`, + admin.TokenKey + app.Settings().AdminPasswordResetToken.Secret, + }, + { + `$tokens.adminFileToken($app, admin)`, + admin.TokenKey + app.Settings().AdminFileToken.Secret, + }, + { + `$tokens.recordAuthToken($app, record)`, + record.TokenKey() + app.Settings().RecordAuthToken.Secret, + }, + { + `$tokens.recordVerifyToken($app, record)`, + record.TokenKey() + app.Settings().RecordVerificationToken.Secret, + }, + { + `$tokens.recordResetPasswordToken($app, record)`, + record.TokenKey() + app.Settings().RecordPasswordResetToken.Secret, + }, + { + `$tokens.recordChangeEmailToken($app, record)`, + record.TokenKey() + app.Settings().RecordEmailChangeToken.Secret, + }, + { + `$tokens.recordFileToken($app, record)`, + record.TokenKey() + app.Settings().RecordFileToken.Secret, + }, + } + + for _, s := range sceneraios { + result, err := vm.RunString(s.js) + if err != nil { + t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err) + } + + v, _ := result.Export().(string) + + if _, err := security.ParseJWT(v, s.key); err != nil { + t.Fatalf("[%s] Failed to parse JWT %v, got %v", s.js, v, err) + } + } +} + +func TestSecurityRandomStringBinds(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + baseBinds(vm) + securityBinds(vm) + + testBindsCount(vm, "$security", 7, t) + + sceneraios := []struct { + js string + length int + }{ + {`$security.randomString(6)`, 6}, + {`$security.randomStringWithAlphabet(7, "abc")`, 7}, + {`$security.pseudorandomString(8)`, 8}, + {`$security.pseudorandomStringWithAlphabet(9, "abc")`, 9}, + } + + for _, s := range sceneraios { + result, err := vm.RunString(s.js) + if err != nil { + t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err) + } + + v, _ := result.Export().(string) + + if len(v) != s.length { + t.Fatalf("[%s] Expected %d length string, \ngot \n%v", s.js, s.length, v) + } + } +} + +func TestSecurityTokenBinds(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + baseBinds(vm) + securityBinds(vm) + + testBindsCount(vm, "$security", 7, t) + + sceneraios := []struct { + js string + expected string + }{ + { + `$security.parseUnverifiedToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY")`, + `{"name":"John Doe","sub":"1234567890"}`, + }, + { + `$security.parseToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.aXzC7q7z1lX_hxk5P0R368xEU7H1xRwnBQQcLAmG0EY", "test")`, + `{"name":"John Doe","sub":"1234567890"}`, + }, + { + `$security.createToken({"exp": 123}, "test", 0)`, // overwrite the exp claim for static token + `"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEyM30.7gbv7w672gApdBRASI6OniCtKwkKjhieSxsr6vxSrtw"`, + }, + } + + for _, s := range sceneraios { + result, err := vm.RunString(s.js) + if err != nil { + t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err) + } + + raw, _ := json.Marshal(result.Export()) + + if string(raw) != s.expected { + t.Fatalf("[%s] Expected \n%s, \ngot \n%s", s.js, s.expected, raw) + } + } +} + +func TestFilesystemBinds(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + vm.Set("mh", &multipart.FileHeader{Filename: "test"}) + vm.Set("testFile", filepath.Join(app.DataDir(), "data.db")) + baseBinds(vm) + filesystemBinds(vm) + + testBindsCount(vm, "$filesystem", 3, t) + + // fileFromPath + { + v, err := vm.RunString(`$filesystem.fileFromPath(testFile)`) + if err != nil { + t.Fatal(err) + } + + file, _ := v.Export().(*filesystem.File) + + if file == nil || file.OriginalName != "data.db" { + t.Fatalf("[fileFromPath] Expected file with name %q, got %v", file.OriginalName, file) + } + } + + // fileFromBytes + { + v, err := vm.RunString(`$filesystem.fileFromBytes([1, 2, 3], "test")`) + if err != nil { + t.Fatal(err) + } + + file, _ := v.Export().(*filesystem.File) + + if file == nil || file.OriginalName != "test" { + t.Fatalf("[fileFromBytes] Expected file with name %q, got %v", file.OriginalName, file) + } + } + + // fileFromMultipart + { + v, err := vm.RunString(`$filesystem.fileFromMultipart(mh)`) + if err != nil { + t.Fatal(err) + } + + file, _ := v.Export().(*filesystem.File) + + if file == nil || file.OriginalName != "test" { + t.Fatalf("[fileFromMultipart] Expected file with name %q, got %v", file.OriginalName, file) + } + } +} + +func TestFormsBinds(t *testing.T) { + vm := goja.New() + formsBinds(vm) + + testBindsCount(vm, "this", 20, t) +} + +func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T) { + v, err := vm.RunString(`Object.keys(` + namespace + `).length`) + if err != nil { + t.Fatal(err) + } + + total, _ := v.Export().(int64) + + if int(total) != count { + t.Fatalf("Expected %d %s binds, got %d", count, namespace, total) + } +} diff --git a/plugins/migratecmd/automigrate.go b/plugins/migratecmd/automigrate.go index 2853615c..3ed31003 100644 --- a/plugins/migratecmd/automigrate.go +++ b/plugins/migratecmd/automigrate.go @@ -40,7 +40,7 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error { var template string var templateErr error - if p.options.TemplateLang == TemplateLangJS { + if p.config.TemplateLang == TemplateLangJS { template, templateErr = p.jsDiffTemplate(new, old) } else { template, templateErr = p.goDiffTemplate(new, old) @@ -63,8 +63,8 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error { } appliedTime := time.Now().Unix() - name := fmt.Sprintf("%d_%s.%s", appliedTime, action, p.options.TemplateLang) - filePath := filepath.Join(p.options.Dir, name) + name := fmt.Sprintf("%d_%s.%s", appliedTime, action, p.config.TemplateLang) + filePath := filepath.Join(p.config.Dir, name) return p.app.Dao().RunInTransaction(func(txDao *daos.Dao) error { // insert the migration entry @@ -77,7 +77,7 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error { } // ensure that the local migrations dir exist - if err := os.MkdirAll(p.options.Dir, os.ModePerm); err != nil { + if err := os.MkdirAll(p.config.Dir, os.ModePerm); err != nil { return fmt.Errorf("failed to create migration dir: %w", err) } @@ -138,7 +138,7 @@ func (p *plugin) getCachedCollections() (map[string]*models.Collection, error) { } func (p *plugin) hasCustomMigrations() bool { - files, err := os.ReadDir(p.options.Dir) + files, err := os.ReadDir(p.config.Dir) if err != nil { return false } diff --git a/plugins/migratecmd/migratecmd.go b/plugins/migratecmd/migratecmd.go index 614d83d7..95bb68d0 100644 --- a/plugins/migratecmd/migratecmd.go +++ b/plugins/migratecmd/migratecmd.go @@ -5,10 +5,10 @@ // // Example usage: // -// migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{ +// migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ // TemplateLang: migratecmd.TemplateLangJS, // default to migratecmd.TemplateLangGo // Automigrate: true, -// Dir: "migrations_dir_path", // optional template migrations path; default to "pb_migrations" (for JS) and "migrations" (for Go) +// Dir: "/custom/migrations/dir", // optional template migrations path; default to "pb_migrations" (for JS) and "migrations" (for Go) // }) // // Note: To allow running JS migrations you'll need to enable first @@ -32,8 +32,8 @@ import ( "github.com/spf13/cobra" ) -// Options defines optional struct to customize the default plugin behavior. -type Options struct { +// Config defines the config options of the migratecmd plugin. +type Config struct { // Dir specifies the directory with the user defined migrations. // // If not set it fallbacks to a relative "pb_data/../pb_migrations" (for js) @@ -48,35 +48,31 @@ type Options struct { TemplateLang string } -type plugin struct { - app core.App - options *Options -} - -func MustRegister(app core.App, rootCmd *cobra.Command, options *Options) { - if err := Register(app, rootCmd, options); err != nil { +// MustRegister registers the migratecmd plugin to the provided app instance +// and panic if it fails. +// +// Example usage: +// +// migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{}) +func MustRegister(app core.App, rootCmd *cobra.Command, config Config) { + if err := Register(app, rootCmd, config); err != nil { panic(err) } } -func Register(app core.App, rootCmd *cobra.Command, options *Options) error { - p := &plugin{app: app} +// Register registers the migratecmd plugin to the provided app instance. +func Register(app core.App, rootCmd *cobra.Command, config Config) error { + p := &plugin{app: app, config: config} - if options != nil { - p.options = options - } else { - p.options = &Options{} + if p.config.TemplateLang == "" { + p.config.TemplateLang = TemplateLangGo } - if p.options.TemplateLang == "" { - p.options.TemplateLang = TemplateLangGo - } - - if p.options.Dir == "" { - if p.options.TemplateLang == TemplateLangJS { - p.options.Dir = filepath.Join(p.app.DataDir(), "../pb_migrations") + if p.config.Dir == "" { + if p.config.TemplateLang == TemplateLangJS { + p.config.Dir = filepath.Join(p.app.DataDir(), "../pb_migrations") } else { - p.options.Dir = filepath.Join(p.app.DataDir(), "../migrations") + p.config.Dir = filepath.Join(p.app.DataDir(), "../migrations") } } @@ -86,7 +82,7 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error { } // watch for collection changes - if p.options.Automigrate { + if p.config.Automigrate { // refresh the cache right after app bootstap p.app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error { p.refreshCachedCollections() @@ -129,6 +125,11 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error { return nil } +type plugin struct { + app core.App + config Config +} + func (p *plugin) createCommand() *cobra.Command { const cmdDesc = `Supported arguments are: - up - runs all available migrations @@ -185,9 +186,9 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv } name := args[0] - dir := p.options.Dir + dir := p.config.Dir - filename := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), inflector.Snakecase(name), p.options.TemplateLang) + filename := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), inflector.Snakecase(name), p.config.TemplateLang) resultFilePath := path.Join(dir, filename) @@ -206,7 +207,7 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv // get default create template if template == "" { var templateErr error - if p.options.TemplateLang == TemplateLangJS { + if p.config.TemplateLang == TemplateLangJS { template, templateErr = p.jsBlankTemplate() } else { template, templateErr = p.goBlankTemplate() @@ -244,7 +245,7 @@ func (p *plugin) migrateCollectionsHandler(args []string, interactive bool) (str var template string var templateErr error - if p.options.TemplateLang == TemplateLangJS { + if p.config.TemplateLang == TemplateLangJS { template, templateErr = p.jsSnapshotTemplate(collections) } else { template, templateErr = p.goSnapshotTemplate(collections) diff --git a/plugins/migratecmd/templates.go b/plugins/migratecmd/templates.go index f94dc989..b4ec26d4 100644 --- a/plugins/migratecmd/templates.go +++ b/plugins/migratecmd/templates.go @@ -343,7 +343,7 @@ func init() { } ` - return fmt.Sprintf(template, filepath.Base(p.options.Dir)), nil + return fmt.Sprintf(template, filepath.Base(p.config.Dir)), nil } func (p *plugin) goSnapshotTemplate(collections []*models.Collection) (string, error) { @@ -380,7 +380,7 @@ func init() { ` return fmt.Sprintf( template, - filepath.Base(p.options.Dir), + filepath.Base(p.config.Dir), escapeBacktick(string(jsonData)), ), nil } @@ -427,7 +427,7 @@ func init() { return fmt.Sprintf( template, - filepath.Base(p.options.Dir), + filepath.Base(p.config.Dir), escapeBacktick(string(jsonData)), collection.Id, ), nil @@ -475,7 +475,7 @@ func init() { return fmt.Sprintf( template, - filepath.Base(p.options.Dir), + filepath.Base(p.config.Dir), collection.Id, escapeBacktick(string(jsonData)), ), nil @@ -745,7 +745,7 @@ func init() { return fmt.Sprintf( template, - filepath.Base(p.options.Dir), + filepath.Base(p.config.Dir), imports, old.Id, strings.TrimSpace(up), new.Id, strings.TrimSpace(down), diff --git a/pocketbase.go b/pocketbase.go index eb1a489c..99f3d32e 100644 --- a/pocketbase.go +++ b/pocketbase.go @@ -68,7 +68,7 @@ type Config struct { func New() *PocketBase { _, isUsingGoRun := inspectRuntime() - return NewWithConfig(&Config{ + return NewWithConfig(Config{ DefaultDebug: isUsingGoRun, }) } @@ -80,11 +80,7 @@ func New() *PocketBase { // Everything will be initialized when [Start()] is executed. // If you want to initialize the application before calling [Start()], // then you'll have to manually call [Bootstrap()]. -func NewWithConfig(config *Config) *PocketBase { - if config == nil { - panic("missing config") - } - +func NewWithConfig(config Config) *PocketBase { // initialize a default data directory based on the executable baseDir if config.DefaultDataDir == "" { baseDir, _ := inspectRuntime() @@ -112,10 +108,10 @@ func NewWithConfig(config *Config) *PocketBase { // parse base flags // (errors are ignored, since the full flags parsing happens on Execute()) - pb.eagerParseFlags(config) + pb.eagerParseFlags(&config) // initialize the app instance - pb.appWrapper = &appWrapper{core.NewBaseApp(&core.BaseAppConfig{ + pb.appWrapper = &appWrapper{core.NewBaseApp(core.BaseAppConfig{ DataDir: pb.dataDirFlag, EncryptionEnv: pb.encryptionEnvFlag, IsDebug: pb.debugFlag, diff --git a/pocketbase_test.go b/pocketbase_test.go index d569d4d0..f24772ae 100644 --- a/pocketbase_test.go +++ b/pocketbase_test.go @@ -54,7 +54,7 @@ func TestNew(t *testing.T) { } func TestNewWithConfig(t *testing.T) { - app := NewWithConfig(&Config{ + app := NewWithConfig(Config{ DefaultDebug: true, DefaultDataDir: "test_dir", DefaultEncryptionEnv: "test_encryption_env", @@ -108,7 +108,7 @@ func TestNewWithConfigAndFlags(t *testing.T) { "--debug=false", ) - app := NewWithConfig(&Config{ + app := NewWithConfig(Config{ DefaultDebug: true, DefaultDataDir: "test_dir", DefaultEncryptionEnv: "test_encryption_env", @@ -157,7 +157,7 @@ func TestSkipBootstrap(t *testing.T) { defer os.RemoveAll(tempDir) // already bootstrapped - app0 := NewWithConfig(&Config{DefaultDataDir: tempDir}) + app0 := NewWithConfig(Config{DefaultDataDir: tempDir}) app0.Bootstrap() if v := app0.skipBootstrap(); !v { t.Fatal("[bootstrapped] Expected true, got false") @@ -166,7 +166,7 @@ func TestSkipBootstrap(t *testing.T) { // unknown command os.Args = os.Args[:1] os.Args = append(os.Args, "demo") - app1 := NewWithConfig(&Config{DefaultDataDir: tempDir}) + app1 := NewWithConfig(Config{DefaultDataDir: tempDir}) app1.RootCmd.AddCommand(&cobra.Command{Use: "test"}) if v := app1.skipBootstrap(); !v { t.Fatal("[unknown] Expected true, got false") @@ -185,7 +185,7 @@ func TestSkipBootstrap(t *testing.T) { // base flag os.Args = os.Args[:1] os.Args = append(os.Args, "--"+s.name) - app1 := NewWithConfig(&Config{DefaultDataDir: tempDir}) + app1 := NewWithConfig(Config{DefaultDataDir: tempDir}) if v := app1.skipBootstrap(); !v { t.Fatalf("[--%s] Expected true, got false", s.name) } @@ -193,7 +193,7 @@ func TestSkipBootstrap(t *testing.T) { // short flag os.Args = os.Args[:1] os.Args = append(os.Args, "-"+s.short) - app2 := NewWithConfig(&Config{DefaultDataDir: tempDir}) + app2 := NewWithConfig(Config{DefaultDataDir: tempDir}) if v := app2.skipBootstrap(); !v { t.Fatalf("[-%s] Expected true, got false", s.short) } @@ -205,7 +205,7 @@ func TestSkipBootstrap(t *testing.T) { os.Args = os.Args[:1] os.Args = append(os.Args, "custom") os.Args = append(os.Args, "--"+s.name) - app3 := NewWithConfig(&Config{DefaultDataDir: tempDir}) + app3 := NewWithConfig(Config{DefaultDataDir: tempDir}) app3.RootCmd.AddCommand(customCmd) if v := app3.skipBootstrap(); v { t.Fatalf("[--%s custom] Expected false, got true", s.name) @@ -215,7 +215,7 @@ func TestSkipBootstrap(t *testing.T) { os.Args = os.Args[:1] os.Args = append(os.Args, "custom") os.Args = append(os.Args, "-"+s.short) - app4 := NewWithConfig(&Config{DefaultDataDir: tempDir}) + app4 := NewWithConfig(Config{DefaultDataDir: tempDir}) app4.RootCmd.AddCommand(customCmd) if v := app4.skipBootstrap(); v { t.Fatalf("[-%s custom] Expected false, got true", s.short) diff --git a/tests/app.go b/tests/app.go index 39f9d945..4c5e101c 100644 --- a/tests/app.go +++ b/tests/app.go @@ -94,7 +94,7 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) { return nil, err } - app := core.NewBaseApp(&core.BaseAppConfig{ + app := core.NewBaseApp(core.BaseAppConfig{ DataDir: tempDir, EncryptionEnv: "pb_test_env", IsDebug: false, diff --git a/tools/mailer/mailer.go b/tools/mailer/mailer.go index eb9cb8dc..b64da898 100644 --- a/tools/mailer/mailer.go +++ b/tools/mailer/mailer.go @@ -7,15 +7,15 @@ import ( // Message defines a generic email message struct. type Message struct { - From mail.Address - To []mail.Address - Bcc []mail.Address - Cc []mail.Address - Subject string - HTML string - Text string - Headers map[string]string - Attachments map[string]io.Reader + From mail.Address `json:"from"` + To []mail.Address `json:"to"` + Bcc []mail.Address `json:"bcc"` + Cc []mail.Address `json:"cc"` + Subject string `json:"subject"` + HTML string `json:"html"` + Text string `json:"text"` + Headers map[string]string `json:"headers"` + Attachments map[string]io.Reader `json:"attachments"` } // Mailer defines a base mail client interface.