Compare commits

...
35 Commits
Author SHA1 Message Date
Gani Georgiev 808f5054d0 updated go deps 2022-12-06 07:21:26 +02:00
Gani Georgiev f1d546c845 truncate the original filename metadata 2022-12-06 07:17:59 +02:00
Gani Georgiev dba66d4da1 updated changelog and ui/dist 2022-12-06 00:32:10 +02:00
Gani Georgiev 4070a11660 updated changelog 2022-12-05 15:34:18 +02:00
Gani Georgiev 45b72dd6b3 use the executable name in the cmd usage doc 2022-12-05 15:24:02 +02:00
Gani Georgiev 599c542c5a store the original uploaded file name as metadata 2022-12-05 14:28:28 +02:00
Gani Georgiev b8cd686b32 updated automigrate templates, added js bindings tests and updated models IsNew behavior 2022-12-05 13:57:09 +02:00
Gani Georgiev 604009bd10 [#468] added record auth verification, password reset and email change request event hooks 2022-12-03 14:50:12 +02:00
Gani Georgiev 02f72638b8 added error event hooks 2022-12-02 16:36:15 +02:00
Gani Georgiev 23fbfab63a [#468] added additional realtime events 2022-12-02 14:25:55 +02:00
Gani Georgiev 98cc8e2aee added empty migrations template test and removed publicdir plugin 2022-12-02 12:36:57 +02:00
Gani Georgiev 04018f931b added record.OriginalCopy() to return a record model copy with the original/initial data 2022-12-02 11:37:11 +02:00
Gani Georgiev d2028143df skip empty automigrate templates 2022-12-02 11:36:13 +02:00
Gani Georgiev 6400924d29 updated ui/dist 2022-12-01 20:26:11 +02:00
Gani Georgiev 44a69eb4ba skip triggering the before hooks on record delete retry 2022-12-01 19:00:38 +02:00
Gani Georgiev 0fa5edb0b1 added custom goja field mapper to handle all caps identifiers and allowed errors unwrapping 2022-11-30 17:23:41 +02:00
Gani Georgiev 799e1d96f8 [#654] updated OAuth2 providers to return the access token and raw user data 2022-11-30 15:16:09 +02:00
Gani Georgiev 9ba710cdc5 removed unused automigrate methods and updated changelog 2022-11-29 22:28:38 +02:00
Gani Georgiev a4953cbb4e optimized record references lookups 2022-11-29 22:28:23 +02:00
Gani Georgiev 647158f62d [#1138] fixed concurrent cascade update/delete and added fail/retry because of SQLITE_BUSY 2022-11-29 18:14:09 +02:00
Gani Georgiev 2deca759fa added multipart range test 2022-11-29 18:12:40 +02:00
Gani Georgiev bd65125744 [#1125] added support for partial/range file requests 2022-11-29 15:52:37 +02:00
Gani Georgiev 328b99a690 updated WIP:v0.9.0 changelog 2022-11-28 21:56:49 +02:00
Gani Georgiev 33539452de added automigrate tests 2022-11-28 19:59:17 +02:00
Gani Georgiev c6f03cda43 updated go deps 2022-11-27 23:24:51 +02:00
Gani Georgiev 2d3531dd66 removed git path lookups and updated examples/base .gitignore 2022-11-27 23:21:42 +02:00
Gani Georgiev 7ac3a74440 refactored automigrate to be more granular 2022-11-27 23:01:27 +02:00
Gani Georgiev 3bce173748 fixed typo 2022-11-26 22:59:37 +02:00
Gani Georgiev b024737ec8 updated ui/dist 2022-11-26 22:37:14 +02:00
Gani Georgiev 675d459137 tweaked automigrate to check for git status and extracted the base flags from the plugins 2022-11-26 22:33:27 +02:00
Gani Georgiev 8c9b657132 moved settings under models and added settings dao helpers 2022-11-26 14:42:45 +02:00
Gani Georgiev d8963c6fc3 added plugins subpackage and added basic support for js migrations 2022-11-26 09:06:09 +02:00
Gani Georgiev 3e1a19685b [#1069] added default Message-ID and more options to customize the mail message 2022-11-21 17:51:44 +02:00
Gani Georgiev c4a660d2d2 [#1079] preserve new field options on drag&drop 2022-11-21 17:51:01 +02:00
Gani Georgiev c12c873a65 gitignore ui/.env.local and ui/.env.*.local files 2022-11-19 12:40:57 +02:00
132 changed files with 5470 additions and 1531 deletions
-4
View File
@@ -6,10 +6,6 @@
# goreleaser builds folder
/.builds/
# examples app directories
pb_data
pb_public
# tests coverage
coverage.out
+145 -1
View File
@@ -1,3 +1,147 @@
## v0.9.0
- Fixed concurrent multi-relation cascade update/delete ([#1138](https://github.com/pocketbase/pocketbase/issues/1138)).
- Added the raw OAuth2 user data (`meta.rawUser`) and OAuth2 access token (`meta.accessToken`) to the auth response ([#654](https://github.com/pocketbase/pocketbase/discussions/654)).
- `BaseModel.UnmarkAsNew()` method was renamed to `BaseModel.MarkAsNotNew()`.
Additionally, to simplify the insert model queries with custom IDs, it is no longer required to call `MarkAsNew()` for manually initialized models with set ID since now this is the default state.
When the model is populated with values from the database (eg. after row `Scan`) it will be marked automatically as "not new".
- Added `Record.OriginalCopy()` method that returns a new `Record` copy populated with the initially loaded record data (useful if you want to compare old and new field values).
- Added new event hooks:
```go
app.OnBeforeBootstrap()
app.OnAfterBootstrap()
app.OnBeforeApiError()
app.OnAfterApiError()
app.OnRealtimeDisconnectRequest()
app.OnRealtimeBeforeMessageSend()
app.OnRealtimeAfterMessageSend()
app.OnRecordBeforeRequestPasswordResetRequest()
app.OnRecordAfterRequestPasswordResetRequest()
app.OnRecordBeforeConfirmPasswordResetRequest()
app.OnRecordAfterConfirmPasswordResetRequest()
app.OnRecordBeforeRequestVerificationRequest()
app.OnRecordAfterRequestVerificationRequest()
app.OnRecordBeforeConfirmVerificationRequest()
app.OnRecordAfterConfirmVerificationRequest()
app.OnRecordBeforeRequestEmailChangeRequest()
app.OnRecordAfterRequestEmailChangeRequest()
app.OnRecordBeforeConfirmEmailChangeRequest()
app.OnRecordAfterConfirmEmailChangeRequest()
```
- The original uploaded file name is now stored as metadata under the `original_filename` key. It could be accessed via:
```go
fs, _ := app.NewFilesystem()
defer fs.Close()
attrs, _ := fs.Attributes(fikeKey)
attrs.Metadata["original_name"]
```
- Added support for `Partial/Range` file requests ([#1125](https://github.com/pocketbase/pocketbase/issues/1125)).
This is a minor breaking change if you are using `filesystem.Serve` (eg. as part of a custom `OnFileDownloadRequest` hook):
```go
// old
filesystem.Serve(res, e.ServedPath, e.ServedName)
// new
filesystem.Serve(res, req, e.ServedPath, e.ServedName)
```
- Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)).
This allow writting custom migration scripts such as programmatically creating collections,
initializing default settings, running data imports, etc., with a JavaScript API very similar to the Go one (_more documentation will be available soon_).
The `migrate` command is available by default for the prebult executable,
but if you use PocketBase as framework you need register it manually:
```go
migrationsDir := "" // default to "pb_migrations" (for js) and "migrations" (for go)
// load js files if you want to allow loading external JavaScript migrations
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
Dir: migrationsDir,
})
// register the `migrate` command
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
TemplateLang: migratecmd.TemplateLangJS, // or migratecmd.TemplateLangGo (default)
Dir: migrationsDir,
Automigrate: true,
})
```
**The refactoring also comes with automigrations support.**
If `Automigrate` is enabled (`true` by default for the prebuilt executable; can be disabled with `--automigrate=0`),
PocketBase will generate seamlessly in the background JS (or Go) migration file with your collection changes.
**The directory with the JS migrations can be committed to your git repo.**
All migrations (Go and JS) are automatically executed on server start.
Also note that the auto generated migrations are granural (in contrast to the `migrate collections` snapshot command)
and allow multiple developers to do changes on the collections independently (even editing the same collection) miniziming the eventual merge conflicts.
Here is a sample JS migration file that will be generated if you for example edit a single collection name:
```js
// pb_migrations/1669663597_updated_posts_old.js
migrate((db) => {
// up
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
collection.name = "posts_new"
return dao.saveCollection(collection)
}, (db) => {
// down
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("lngf8rb3dqu86r3")
collection.name = "posts_old"
return dao.saveCollection(collection)
})
```
- Added new `Dao` helpers to make it easier fetching and updating the app settings from a migration:
```go
dao.FindSettings([optEncryptionKey])
dao.SaveSettings(newSettings, [optEncryptionKey])
```
- Moved `core.Settings` to `models/settings.Settings`:
```
core.Settings{} -> settings.Settings{}
core.NewSettings() -> settings.New()
core.MetaConfig{} -> settings.MetaConfig{}
core.LogsConfig{} -> settings.LogsConfig{}
core.SmtpConfig{} -> settings.SmtpConfig{}
core.S3Config{} -> settings.S3Config{}
core.TokenConfig{} -> settings.TokenConfig{}
core.AuthProviderConfig{} -> settings.AuthProviderConfig{}
```
- Changed the `mailer.Mailer` interface (**minor breaking if you are sending custom emails**):
```go
// Old:
app.NewMailClient().Send(from, to, subject, html, attachments?)
// New:
app.NewMailClient().Send(&mailer.Message{
From: from,
To: to,
Subject: subject,
HTML: html,
Attachments: attachments,
// new configurable fields
Bcc: []string{"bcc1@example.com", "bcc2@example.com"},
Cc: []string{"cc1@example.com", "cc2@example.com"},
Headers: map[string]string{"Custom-Header": "test"},
Text: "custom plain text version",
})
```
The new `*mailer.Message` struct is also now a member of the `MailerRecordEvent` and `MailerAdminEvent` events.
- Other minor UI fixes and improvements
## v0.8.0
**⚠️ This release contains breaking changes and requires some manual migration steps!**
@@ -648,7 +792,7 @@ Please check the individual SDK package changelog and apply the necessary change
</tr>
</table>
- Marked as "Deprecated" and will be removed in v0.9:
- Marked as "Deprecated" and will be removed in v0.9+:
```
core.Settings.EmailAuth{}
core.EmailAuthConfig{}
+17 -9
View File
@@ -64,19 +64,27 @@ func InitApi(app core.App) (*echo.Echo, error) {
apiErr = NewBadRequestError("", err)
}
// Send response
var cErr error
if c.Request().Method == http.MethodHead {
// @see https://github.com/labstack/echo/issues/608
cErr = c.NoContent(apiErr.Code)
} else {
cErr = c.JSON(apiErr.Code, apiErr)
event := &core.ApiErrorEvent{
HttpContext: c,
Error: apiErr,
}
// send error response
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
// @see https://github.com/labstack/echo/issues/608
if e.HttpContext.Request().Method == http.MethodHead {
return e.HttpContext.NoContent(apiErr.Code)
}
return e.HttpContext.JSON(apiErr.Code, apiErr)
})
// truly rare case; eg. client already disconnected
if cErr != nil && app.IsDebug() {
log.Println(cErr)
if hookErr != nil && app.IsDebug() {
log.Println(hookErr)
}
app.OnAfterApiError().Trigger(event)
}
// admin ui routes
+3 -1
View File
@@ -94,7 +94,9 @@ func (api *fileApi) download(c echo.Context) error {
}
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil {
res := e.HttpContext.Response()
req := e.HttpContext.Request()
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
return NewNotFoundError("", err)
}
+58 -12
View File
@@ -42,7 +42,14 @@ func (api *realtimeApi) connect(c echo.Context) error {
// register new subscription client
client := subscriptions.NewDefaultClient()
api.app.SubscriptionsBroker().Register(client)
defer api.app.SubscriptionsBroker().Unregister(client.Id())
defer func() {
api.app.OnRealtimeDisconnectRequest().Trigger(&core.RealtimeDisconnectEvent{
HttpContext: c,
Client: client,
})
api.app.SubscriptionsBroker().Unregister(client.Id())
}()
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
c.Response().Header().Set("Cache-Control", "no-store")
@@ -51,12 +58,12 @@ func (api *realtimeApi) connect(c echo.Context) error {
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
c.Response().Header().Set("X-Accel-Buffering", "no")
event := &core.RealtimeConnectEvent{
connectEvent := &core.RealtimeConnectEvent{
HttpContext: c,
Client: client,
}
if err := api.app.OnRealtimeConnectRequest().Trigger(event); err != nil {
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
return err
}
@@ -65,10 +72,31 @@ func (api *realtimeApi) connect(c echo.Context) error {
}
// signalize established connection (aka. fire "connect" message)
fmt.Fprint(c.Response(), "id:"+client.Id()+"\n")
fmt.Fprint(c.Response(), "event:PB_CONNECT\n")
fmt.Fprint(c.Response(), "data:{\"clientId\":\""+client.Id()+"\"}\n\n")
c.Response().Flush()
connectMsgEvent := &core.RealtimeMessageEvent{
HttpContext: c,
Client: client,
Message: &subscriptions.Message{
Name: "PB_CONNECT",
Data: `{"clientId":"` + client.Id() + `"}`,
},
}
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
w := e.HttpContext.Response()
fmt.Fprint(w, "id:"+client.Id()+"\n")
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
w.Flush()
return nil
})
if connectMsgErr != nil {
if api.app.IsDebug() {
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
}
return nil
}
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
}
// start an idle timer to keep track of inactive/forgotten connections
idleDuration := 5 * time.Minute
@@ -88,11 +116,29 @@ func (api *realtimeApi) connect(c echo.Context) error {
return nil
}
w := c.Response()
fmt.Fprint(w, "id:"+client.Id()+"\n")
fmt.Fprint(w, "event:"+msg.Name+"\n")
fmt.Fprint(w, "data:"+msg.Data+"\n\n")
w.Flush()
msgEvent := &core.RealtimeMessageEvent{
HttpContext: c,
Client: client,
Message: &msg,
}
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
w := e.HttpContext.Response()
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
w.Flush()
return nil
})
if msgErr != nil {
if api.app.IsDebug() {
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
}
return nil
}
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
log.Println("OnRealtimeAfterMessageSend error:", err)
}
idleTimer.Stop()
idleTimer.Reset(idleDuration)
+52 -1
View File
@@ -1,6 +1,7 @@
package apis_test
import (
"errors"
"net/http"
"strings"
"testing"
@@ -10,6 +11,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
@@ -25,7 +27,56 @@ func TestRealtimeConnect(t *testing.T) {
`data:{"clientId":`,
},
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
"OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
},
},
{
Name: "PB_CONNECT interrupt",
Method: http.MethodGet,
Url: "/api/realtime",
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
if e.Message.Name == "PB_CONNECT" {
return errors.New("PB_CONNECT error")
}
return nil
})
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
}
},
},
{
Name: "Skipping/ignoring messages",
Method: http.MethodGet,
Url: "/api/realtime",
ExpectedStatus: 200,
ExpectedEvents: map[string]int{
"OnRealtimeConnectRequest": 1,
"OnRealtimeBeforeMessageSend": 1,
"OnRealtimeAfterMessageSend": 1,
"OnRealtimeDisconnectRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
return hook.StopPropagation
})
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if len(app.SubscriptionsBroker().Clients()) != 0 {
+144 -28
View File
@@ -231,7 +231,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
}
if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil {
return fmt.Errorf("Failed create rule constraint: %v", err)
return fmt.Errorf("Failed create rule constraint: %w", err)
}
return nil
@@ -283,15 +283,39 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
// the result to the user (prevents users enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
event := &core.RecordRequestPasswordResetEvent{
HttpContext: c,
}
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
return c.NoContent(http.StatusNoContent)
if submitErr == nil {
api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event)
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
}
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
@@ -305,12 +329,29 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
_, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to set new password.", submitErr)
event := &core.RecordConfirmPasswordResetEvent{
HttpContext: c,
}
return c.NoContent(http.StatusNoContent)
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to set new password.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) requestVerification(c echo.Context) error {
@@ -328,15 +369,39 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
// the result to the user (prevents users enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
event := &core.RecordRequestVerificationEvent{
HttpContext: c,
}
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
// run in background because we don't need to show the result to the client
routine.FireAndForget(func() {
if err := next(e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
return c.NoContent(http.StatusNoContent)
if submitErr == nil {
api.app.OnRecordAfterRequestVerificationRequest().Trigger(event)
} else if api.app.IsDebug() {
log.Println(submitErr)
}
// don't return the response error to prevent emails enumeration
if !c.Response().Committed {
c.NoContent(http.StatusNoContent)
}
return nil
}
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
@@ -350,12 +415,29 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
_, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("An error occurred while submitting the form.", submitErr)
event := &core.RecordConfirmVerificationEvent{
HttpContext: c,
}
return c.NoContent(http.StatusNoContent)
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("An error occurred while submitting the form.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
@@ -369,11 +451,28 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Submit(); err != nil {
return NewBadRequestError("Failed to request email change.", err)
event := &core.RecordRequestEmailChangeEvent{
HttpContext: c,
Record: record,
}
return c.NoContent(http.StatusNoContent)
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to request email change.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
@@ -387,12 +486,29 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
_, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to confirm email change.", submitErr)
event := &core.RecordConfirmEmailChangeEvent{
HttpContext: c,
}
return c.NoContent(http.StatusNoContent)
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
event.Record = record
return api.app.OnRecordBeforeConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
if err := next(e.Record); err != nil {
return NewBadRequestError("Failed to confirm email change.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event)
}
return submitErr
}
func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
+44 -19
View File
@@ -346,10 +346,12 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) {
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeRecordResetPasswordSend": 1,
"OnMailerAfterRecordResetPasswordSend": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnRecordBeforeRequestPasswordResetRequest": 1,
"OnRecordAfterRequestPasswordResetRequest": 1,
"OnMailerBeforeRecordResetPasswordSend": 1,
"OnMailerAfterRecordResetPasswordSend": 1,
},
},
{
@@ -466,8 +468,10 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
},
}
@@ -518,6 +522,10 @@ func TestRecordAuthRequestVerification(t *testing.T) {
Body: strings.NewReader(`{"email":"test2@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordBeforeRequestVerificationRequest": 1,
"OnRecordAfterRequestVerificationRequest": 1,
},
},
{
Name: "existing auth record",
@@ -527,10 +535,12 @@ func TestRecordAuthRequestVerification(t *testing.T) {
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnMailerBeforeRecordVerificationSend": 1,
"OnMailerAfterRecordVerificationSend": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnRecordBeforeRequestVerificationRequest": 1,
"OnRecordAfterRequestVerificationRequest": 1,
"OnMailerBeforeRecordVerificationSend": 1,
"OnMailerAfterRecordVerificationSend": 1,
},
},
{
@@ -540,6 +550,10 @@ func TestRecordAuthRequestVerification(t *testing.T) {
Body: strings.NewReader(`{"email":"test@example.com"}`),
Delay: 100 * time.Millisecond,
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
// "OnRecordBeforeRequestVerificationRequest": 1,
// "OnRecordAfterRequestVerificationRequest": 1,
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// simulate recent verification sent
authRecord, err := app.Dao().FindFirstRecordByData("users", "email", "test@example.com")
@@ -627,8 +641,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
{
@@ -639,7 +655,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{},
ExpectedEvents: map[string]int{
"OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
{
Name: "valid verification token from a collection without allowed login",
@@ -651,8 +670,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
}
@@ -751,8 +772,10 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnMailerBeforeRecordChangeEmailSend": 1,
"OnMailerAfterRecordChangeEmailSend": 1,
"OnMailerBeforeRecordChangeEmailSend": 1,
"OnMailerAfterRecordChangeEmailSend": 1,
"OnRecordBeforeRequestEmailChangeRequest": 1,
"OnRecordAfterRequestEmailChangeRequest": 1,
},
},
}
@@ -833,8 +856,10 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
}`),
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmEmailChangeRequest": 1,
"OnRecordAfterConfirmEmailChangeRequest": 1,
},
},
{
+1 -1
View File
@@ -191,7 +191,7 @@ func (api *recordApi) create(c echo.Context) error {
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
if err != nil {
return fmt.Errorf("DrySubmit create rule failure: %v", err)
return fmt.Errorf("DrySubmit create rule failure: %w", err)
}
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
return nil
+4 -4
View File
@@ -62,7 +62,7 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
requestData := RequestData(c)
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
return fmt.Errorf("Failed to resolve email visibility: %v", err)
return fmt.Errorf("Failed to resolve email visibility: %w", err)
}
expands := defaultExpands
@@ -138,10 +138,10 @@ func autoIgnoreAuthRecordsEmailVisibility(
collection := records[0].Collection()
mappedRecords := make(map[string]*models.Record, len(records))
recordIds := make([]any, 0, len(records))
for _, rec := range records {
recordIds := make([]any, len(records))
for i, rec := range records {
mappedRecords[rec.Id] = rec
recordIds = append(recordIds, rec.Id)
recordIds[i] = rec.Id
}
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
+13 -13
View File
@@ -127,7 +127,7 @@ func TestSettingsSet(t *testing.T) {
`"twitchAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"Acme"`,
`"appName":"acme_test"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -303,12 +303,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastToAddress.Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
}
if !strings.Contains(app.TestMailer.LastHtmlBody, "Verify") {
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
}
},
ExpectedStatus: 204,
@@ -334,12 +334,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastToAddress.Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
}
if !strings.Contains(app.TestMailer.LastHtmlBody, "Reset password") {
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
}
},
ExpectedStatus: 204,
@@ -365,12 +365,12 @@ func TestSettingsTestEmail(t *testing.T) {
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
}
if app.TestMailer.LastToAddress.Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
}
if !strings.Contains(app.TestMailer.LastHtmlBody, "Confirm new email") {
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
}
},
ExpectedStatus: 204,
-241
View File
@@ -1,241 +0,0 @@
package cmd
import (
"encoding/json"
"fmt"
"log"
"os"
"path"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
)
// NewMigrateCommand creates and returns new command for handling DB migrations.
func NewMigrateCommand(app core.App) *cobra.Command {
desc := `
Supported arguments are:
- up - runs all available migrations.
- down [number] - reverts the last [number] applied migrations.
- create name [folder] - creates new migration template file.
- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration.
`
var databaseFlag string
command := &cobra.Command{
Use: "migrate",
Short: "Executes DB migration scripts",
ValidArgs: []string{"up", "down", "create", "collections"},
Long: desc,
Run: func(command *cobra.Command, args []string) {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
// additional commands
// ---
if cmd == "create" {
if err := migrateCreateHandler(defaultMigrateCreateTemplate, args[1:]); err != nil {
log.Fatal(err)
}
return
}
if cmd == "collections" {
if err := migrateCollectionsHandler(app, args[1:]); err != nil {
log.Fatal(err)
}
return
}
// ---
// normalize
if databaseFlag != "logs" {
databaseFlag = "db"
}
connections := migrationsConnectionsMap(app)
runner, err := migrate.NewRunner(
connections[databaseFlag].DB,
connections[databaseFlag].MigrationsList,
)
if err != nil {
log.Fatal(err)
}
if err := runner.Run(args...); err != nil {
log.Fatal(err)
}
},
}
command.PersistentFlags().StringVar(
&databaseFlag,
"database",
"db",
"specify the database connection to use (db or logs)",
)
return command
}
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func migrationsConnectionsMap(app core.App) map[string]migrationsConnection {
return map[string]migrationsConnection{
"db": {
DB: app.DB(),
MigrationsList: migrations.AppMigrations,
},
"logs": {
DB: app.LogsDB(),
MigrationsList: logs.LogsMigrations,
},
}
}
// -------------------------------------------------------------------
// migrate create
// -------------------------------------------------------------------
const defaultMigrateCreateTemplate = `package migrations
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}
`
func migrateCreateHandler(template string, args []string) error {
if len(args) < 1 {
return fmt.Errorf("Missing migration file name")
}
name := args[0]
var dir string
if len(args) == 2 {
dir = args[1]
}
if dir == "" {
// If not specified, auto point to the default migrations folder.
//
// NB!
// Since the create command makes sense only during development,
// it is expected the user to be in the app working directory
// and to be using `go run`
wd, err := os.Getwd()
if err != nil {
return err
}
dir = path.Join(wd, "migrations")
}
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
)
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
}
// ensure that migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
}
fmt.Printf("Successfully created file %q\n", resultFilePath)
return nil
}
// -------------------------------------------------------------------
// migrate collections
// -------------------------------------------------------------------
const collectionsMigrateCreateTemplate = `package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
// Auto generated migration with the most recent collections configuration.
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := ` + "`" + `%s` + "`" + `
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
// no revert since the configuration on the environment, on which
// the migration was executed, could have changed via the UI/API
return nil
})
}
`
func migrateCollectionsHandler(app core.App, args []string) error {
createArgs := []string{"collections_snapshot"}
createArgs = append(createArgs, args...)
dao := daos.New(app.DB())
collections := []*models.Collection{}
if err := dao.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("Failed to fetch migrations list: %v", err)
}
serialized, err := json.MarshalIndent(collections, "\t\t", "\t")
if err != nil {
return fmt.Errorf("Failed to serialize collections list: %v", err)
}
return migrateCreateHandler(
fmt.Sprintf(collectionsMigrateCreateTemplate, string(serialized)),
createArgs,
)
}
+19 -2
View File
@@ -10,8 +10,11 @@ import (
"github.com/fatih/color"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
"golang.org/x/crypto/acme"
@@ -38,7 +41,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
// (or if this is the first time the init migration was executed)
if err := app.RefreshSettings(); err != nil {
color.Yellow("=====================================")
color.Yellow("WARNING - Settings load error! \n%v", err)
color.Yellow("WARNING: Settings load error! \n%v", err)
color.Yellow("Fallback to the application defaults.")
color.Yellow("=====================================")
}
@@ -137,8 +140,22 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
return command
}
type migrationsConnection struct {
DB *dbx.DB
MigrationsList migrate.MigrationsList
}
func runMigrations(app core.App) error {
connections := migrationsConnectionsMap(app)
connections := []migrationsConnection{
{
DB: app.DB(),
MigrationsList: migrations.AppMigrations,
},
{
DB: app.LogsDB(),
MigrationsList: logs.LogsMigrations,
},
}
for _, c := range connections {
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
+1 -1
View File
@@ -18,7 +18,7 @@ import (
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
//
// NB! It will be removed in v0.9.0!
// NB! It will be removed in v0.9+
func NewTempUpgradeCommand(app core.App) *cobra.Command {
command := &cobra.Command{
Use: "upgrade",
+103 -3
View File
@@ -6,6 +6,7 @@ package core
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
@@ -47,7 +48,7 @@ type App interface {
IsDebug() bool
// Settings returns the loaded app settings.
Settings() *Settings
Settings() *settings.Settings
// Cache returns the app internal cache store.
Cache() *store.Store[any]
@@ -79,10 +80,28 @@ type App interface {
// App event hooks
// ---------------------------------------------------------------
// OnBeforeBootstrap hook is triggered before initializing the base
// application resources (eg. before db open and initial settings load).
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
// OnAfterBootstrap hook is triggered after initializing the base
// application resources (eg. after db open and initial settings load).
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
// OnBeforeServe hook is triggered before serving the internal router (echo),
// allowing you to adjust its options and attach new routes.
OnBeforeServe() *hook.Hook[*ServeEvent]
// OnBeforeApiError hook is triggered right before sending an error API
// response to the client, allowing you to further modify the error data
// or to return a completely different API response (using [hook.StopPropagation]).
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
// OnAfterApiError hook is triggered right after sending an error API
// response to the client.
// It could be used to log the final API error in external services.
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
// ---------------------------------------------------------------
// Dao event hooks
// ---------------------------------------------------------------
@@ -167,6 +186,21 @@ type App interface {
// the SSE client connection.
OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
// OnRealtimeDisconnectRequest hook is triggered on disconnected/interrupted
// SSE client connection.
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
// OnRealtimeBeforeMessage hook is triggered right before sending
// an SSE message to a client.
//
// Returning [hook.StopPropagation] will prevent sending the message.
// Returning any other non-nil error will close the realtime connection.
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
// OnRealtimeBeforeMessage hook is triggered right after sending
// an SSE message to a client.
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
// OnRealtimeBeforeSubscribeRequest hook is triggered before changing
// the client subscriptions, allowing you to further validate and
// modify the submitted change.
@@ -264,7 +298,7 @@ type App interface {
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
// ---------------------------------------------------------------
// Auth Record API event hooks
// Record Auth API event hooks
// ---------------------------------------------------------------
// OnRecordAuthRequest hook is triggered on each successful API
@@ -274,6 +308,72 @@ type App interface {
// record data and token.
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
// request password reset API request (after request data load and before sending the reset email).
//
// Could be used to additionally validate the request data or implement
// completely different password reset behavior (returning [hook.StopPropagation]).
OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
// OnRecordAfterRequestPasswordResetRequest hook is triggered after each
// successful request password reset API request.
OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
// OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record
// confirm password reset API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
// OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
// successful confirm password reset API request.
OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
// OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
// request verification API request (after request data load and before sending the verification email).
//
// Could be used to additionally validate the loaded request data or implement
// completely different verification behavior (returning [hook.StopPropagation]).
OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
// OnRecordAfterRequestVerificationRequest hook is triggered after each
// successful request verification API request.
OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
// OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record
// confirm verification API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
// OnRecordAfterConfirmVerificationRequest hook is triggered after each
// successful confirm verification API request.
OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
// OnRecordBeforeRequestEmailChangeRequest hook is triggered before each Record request email change API request
// (after request data load and before sending the email link to confirm the change).
//
// Could be used to additionally validate the request data or implement
// completely different request email change behavior (returning [hook.StopPropagation]).
OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
// OnRecordAfterRequestEmailChangeRequest hook is triggered after each
// successful request email change API request.
OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
// OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record
// confirm email change API request (after request data load and before persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
// OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
// successful confirm email change API request.
OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
//
// Could be used to validate or modify the response before returning it to the client.
@@ -291,7 +391,7 @@ type App interface {
OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
// ---------------------------------------------------------------
// Record API event hooks
// Record CRUD API event hooks
// ---------------------------------------------------------------
// OnRecordsListRequest hook is triggered on each API Records list request.
+169 -83
View File
@@ -1,10 +1,8 @@
package core
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"os"
@@ -15,10 +13,10 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
@@ -34,15 +32,19 @@ type BaseApp struct {
// internals
cache *store.Store[any]
settings *Settings
settings *settings.Settings
db *dbx.DB
dao *daos.Dao
logsDB *dbx.DB
logsDao *daos.Dao
subscriptionsBroker *subscriptions.Broker
// serve event hooks
onBeforeServe *hook.Hook[*ServeEvent]
// app event hooks
onBeforeBootstrap *hook.Hook[*BootstrapEvent]
onAfterBootstrap *hook.Hook[*BootstrapEvent]
onBeforeServe *hook.Hook[*ServeEvent]
onBeforeApiError *hook.Hook[*ApiErrorEvent]
onAfterApiError *hook.Hook[*ApiErrorEvent]
// dao event hooks
onModelBeforeCreate *hook.Hook[*ModelEvent]
@@ -64,6 +66,9 @@ type BaseApp struct {
// realtime api event hooks
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectEvent]
onRealtimeDisconnectRequest *hook.Hook[*RealtimeDisconnectEvent]
onRealtimeBeforeMessageSend *hook.Hook[*RealtimeMessageEvent]
onRealtimeAfterMessageSend *hook.Hook[*RealtimeMessageEvent]
onRealtimeBeforeSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent]
onRealtimeAfterSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent]
@@ -86,13 +91,25 @@ type BaseApp struct {
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
// user api event hooks
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
// record auth API event hooks
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
onRecordAfterConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
onRecordBeforeRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
onRecordAfterRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
onRecordBeforeConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
onRecordAfterConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
onRecordBeforeRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
onRecordAfterRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
onRecordBeforeConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
onRecordAfterConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
// record api event hooks
// record crud API event hooks
onRecordsListRequest *hook.Hook[*RecordsListEvent]
onRecordViewRequest *hook.Hook[*RecordViewEvent]
onRecordBeforeCreateRequest *hook.Hook[*RecordCreateEvent]
@@ -102,7 +119,7 @@ type BaseApp struct {
onRecordBeforeDeleteRequest *hook.Hook[*RecordDeleteEvent]
onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent]
// collection api event hooks
// collection API event hooks
onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
@@ -125,11 +142,15 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
isDebug: isDebug,
encryptionEnv: encryptionEnv,
cache: store.New[any](nil),
settings: NewSettings(),
settings: settings.New(),
subscriptionsBroker: subscriptions.NewBroker(),
// serve event hooks
onBeforeServe: &hook.Hook[*ServeEvent]{},
// app event hooks
onBeforeBootstrap: &hook.Hook[*BootstrapEvent]{},
onAfterBootstrap: &hook.Hook[*BootstrapEvent]{},
onBeforeServe: &hook.Hook[*ServeEvent]{},
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
// dao event hooks
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
@@ -151,6 +172,9 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
// realtime API event hooks
onRealtimeConnectRequest: &hook.Hook[*RealtimeConnectEvent]{},
onRealtimeDisconnectRequest: &hook.Hook[*RealtimeDisconnectEvent]{},
onRealtimeBeforeMessageSend: &hook.Hook[*RealtimeMessageEvent]{},
onRealtimeAfterMessageSend: &hook.Hook[*RealtimeMessageEvent]{},
onRealtimeBeforeSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{},
onRealtimeAfterSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{},
@@ -173,13 +197,25 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
// user API event hooks
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{},
onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
// record auth API event hooks
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
onRecordBeforeRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
onRecordAfterRequestPasswordResetRequest: &hook.Hook[*RecordRequestPasswordResetEvent]{},
onRecordBeforeConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
onRecordAfterConfirmPasswordResetRequest: &hook.Hook[*RecordConfirmPasswordResetEvent]{},
onRecordBeforeRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{},
onRecordAfterRequestVerificationRequest: &hook.Hook[*RecordRequestVerificationEvent]{},
onRecordBeforeConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{},
onRecordAfterConfirmVerificationRequest: &hook.Hook[*RecordConfirmVerificationEvent]{},
onRecordBeforeRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{},
onRecordAfterRequestEmailChangeRequest: &hook.Hook[*RecordRequestEmailChangeEvent]{},
onRecordBeforeConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{},
onRecordAfterConfirmEmailChangeRequest: &hook.Hook[*RecordConfirmEmailChangeEvent]{},
onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{},
onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
// record API event hooks
// record crud API event hooks
onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
onRecordViewRequest: &hook.Hook[*RecordViewEvent]{},
onRecordBeforeCreateRequest: &hook.Hook[*RecordCreateEvent]{},
@@ -210,6 +246,12 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
// Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.)
func (app *BaseApp) Bootstrap() error {
event := &BootstrapEvent{app}
if err := app.OnBeforeBootstrap().Trigger(event); err != nil {
return err
}
// clear resources of previous core state (if any)
if err := app.ResetBootstrapState(); err != nil {
return err
@@ -228,10 +270,13 @@ func (app *BaseApp) Bootstrap() error {
return err
}
// we don't check for an error because the db migrations may
// have not been executed yet.
// we don't check for an error because the db migrations may have not been executed yet
app.RefreshSettings()
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
}
return nil
}
@@ -295,7 +340,7 @@ func (app *BaseApp) IsDebug() bool {
}
// Settings returns the loaded app settings.
func (app *BaseApp) Settings() *Settings {
func (app *BaseApp) Settings() *settings.Settings {
return app.settings
}
@@ -349,79 +394,53 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
// RefreshSettings reinitializes and reloads the stored application settings.
func (app *BaseApp) RefreshSettings() error {
if app.settings == nil {
app.settings = NewSettings()
app.settings = settings.New()
}
encryptionKey := os.Getenv(app.EncryptionEnv())
param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
storedSettings, err := app.Dao().FindSettings(encryptionKey)
if err != nil && err != sql.ErrNoRows {
return err
}
// no settings were previously stored
if param == nil {
return app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
if storedSettings == nil {
return app.Dao().SaveSettings(app.settings, encryptionKey)
}
// load the settings from the stored param into the app ones
// ---
newSettings := NewSettings()
// try first without decryption
plainDecodeErr := json.Unmarshal(param.Value, newSettings)
// failed, try to decrypt
if plainDecodeErr != nil {
// load without decrypt has failed and there is no encryption key to use for decrypt
if encryptionKey == "" {
return errors.New("Failed to load the stored app settings (missing or invalid encryption key).")
}
// decrypt
decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
if decryptErr != nil {
return decryptErr
}
// decode again
decryptedDecodeErr := json.Unmarshal(decrypted, newSettings)
if decryptedDecodeErr != nil {
return decryptedDecodeErr
}
}
if err := app.settings.Merge(newSettings); err != nil {
if err := app.settings.Merge(storedSettings); err != nil {
return err
}
afterMergeRaw, err := json.Marshal(app.settings)
if err != nil {
return err
}
if
// save because previously the settings weren't stored encrypted
(plainDecodeErr == nil && encryptionKey != "") ||
// or save because there are new fields after the merge
!bytes.Equal(param.Value, afterMergeRaw) {
saveErr := app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
if saveErr != nil {
return saveErr
}
}
return nil
}
// -------------------------------------------------------------------
// Serve event hooks
// App event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnBeforeBootstrap() *hook.Hook[*BootstrapEvent] {
return app.onBeforeBootstrap
}
func (app *BaseApp) OnAfterBootstrap() *hook.Hook[*BootstrapEvent] {
return app.onAfterBootstrap
}
func (app *BaseApp) OnBeforeServe() *hook.Hook[*ServeEvent] {
return app.onBeforeServe
}
func (app *BaseApp) OnBeforeApiError() *hook.Hook[*ApiErrorEvent] {
return app.onBeforeApiError
}
func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
return app.onAfterApiError
}
// -------------------------------------------------------------------
// Dao event hooks
// -------------------------------------------------------------------
@@ -494,6 +513,18 @@ func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
return app.onRealtimeConnectRequest
}
func (app *BaseApp) OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent] {
return app.onRealtimeDisconnectRequest
}
func (app *BaseApp) OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent] {
return app.onRealtimeBeforeMessageSend
}
func (app *BaseApp) OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent] {
return app.onRealtimeAfterMessageSend
}
func (app *BaseApp) OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] {
return app.onRealtimeBeforeSubscribeRequest
}
@@ -567,13 +598,61 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
}
// -------------------------------------------------------------------
// Auth Record API event hooks
// Record auth API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
return app.onRecordAuthRequest
}
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
return app.onRecordBeforeRequestPasswordResetRequest
}
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
return app.onRecordAfterRequestPasswordResetRequest
}
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
return app.onRecordBeforeConfirmPasswordResetRequest
}
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
return app.onRecordAfterConfirmPasswordResetRequest
}
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
return app.onRecordBeforeRequestVerificationRequest
}
func (app *BaseApp) OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
return app.onRecordAfterRequestVerificationRequest
}
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
return app.onRecordBeforeConfirmVerificationRequest
}
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
return app.onRecordAfterConfirmVerificationRequest
}
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
return app.onRecordBeforeRequestEmailChangeRequest
}
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
return app.onRecordAfterRequestEmailChangeRequest
}
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
return app.onRecordBeforeConfirmEmailChangeRequest
}
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
return app.onRecordAfterConfirmEmailChangeRequest
}
func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] {
return app.onRecordListExternalAuthsRequest
}
@@ -587,7 +666,7 @@ func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordU
}
// -------------------------------------------------------------------
// Record API event hooks
// Record CRUD API event hooks
// -------------------------------------------------------------------
func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] {
@@ -689,14 +768,12 @@ func (app *BaseApp) initDataDB() error {
return connectErr
}
app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
if app.IsDebug() {
if app.IsDebug() {
app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
}
app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
if app.IsDebug() {
app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
}
}
@@ -714,7 +791,10 @@ func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
}
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
}
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
@@ -722,7 +802,10 @@ func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
}
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
}
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
@@ -730,7 +813,10 @@ func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
}
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
if err != nil && app.isDebug {
log.Println(err)
}
}
return dao
@@ -1,7 +1,6 @@
package core_test
import (
"bytes"
"testing"
"github.com/pocketbase/pocketbase/models"
@@ -32,7 +31,7 @@ func TestBaseAppRefreshSettings(t *testing.T) {
t.Fatalf("Expected new settings to be persisted, got %v", err)
}
// change the db entry and refresh the app settings
// change the db entry and refresh the app settings (ensure that there was no db update)
param.Value = types.JsonRaw([]byte(`{"example": 123}`))
if err := app.Dao().SaveParam(param.Key, param.Value); err != nil {
t.Fatalf("Failed to update the test settings: %v", err)
@@ -41,21 +40,9 @@ func TestBaseAppRefreshSettings(t *testing.T) {
if err := app.RefreshSettings(); err != nil {
t.Fatalf("Failed to refresh the app settings: %v", err)
}
testEventCalls(t, app, map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
})
testEventCalls(t, app, nil)
// make sure that the newly merged settings were actually saved
newParam, err := app.Dao().FindParamByKey(models.ParamAppSettings)
if err != nil {
t.Fatalf("Failed to fetch new settings param: %v", err)
}
if bytes.Equal(param.Value, newParam.Value) {
t.Fatalf("Expected the new refreshed settings to be different, got: \n%v", string(newParam.Value))
}
// try to refresh again and ensure that there was no db update
// try to refresh again without doing any changes
app.ResetEventCalls()
if err := app.RefreshSettings(); err != nil {
t.Fatalf("Failed to refresh the app settings without change: %v", err)
+80 -27
View File
@@ -4,6 +4,7 @@ import (
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
@@ -15,11 +16,20 @@ import (
// Serve events data
// -------------------------------------------------------------------
type BootstrapEvent struct {
App App
}
type ServeEvent struct {
App App
Router *echo.Echo
}
type ApiErrorEvent struct {
HttpContext echo.Context
Error error
}
// -------------------------------------------------------------------
// Model DAO events data
// -------------------------------------------------------------------
@@ -35,12 +45,14 @@ type ModelEvent struct {
type MailerRecordEvent struct {
MailClient mailer.Mailer
Message *mailer.Message
Record *models.Record
Meta map[string]any
}
type MailerAdminEvent struct {
MailClient mailer.Mailer
Message *mailer.Message
Admin *models.Admin
Meta map[string]any
}
@@ -54,6 +66,17 @@ type RealtimeConnectEvent struct {
Client subscriptions.Client
}
type RealtimeDisconnectEvent struct {
HttpContext echo.Context
Client subscriptions.Client
}
type RealtimeMessageEvent struct {
HttpContext echo.Context
Client subscriptions.Client
Message *subscriptions.Message
}
type RealtimeSubscribeEvent struct {
HttpContext echo.Context
Client subscriptions.Client
@@ -66,17 +89,17 @@ type RealtimeSubscribeEvent struct {
type SettingsListEvent struct {
HttpContext echo.Context
RedactedSettings *Settings
RedactedSettings *settings.Settings
}
type SettingsUpdateEvent struct {
HttpContext echo.Context
OldSettings *Settings
NewSettings *Settings
OldSettings *settings.Settings
NewSettings *settings.Settings
}
// -------------------------------------------------------------------
// Record API events data
// Record CRUD API events data
// -------------------------------------------------------------------
type RecordsListEvent struct {
@@ -106,6 +129,59 @@ type RecordDeleteEvent struct {
Record *models.Record
}
// -------------------------------------------------------------------
// Auth Record API events data
// -------------------------------------------------------------------
type RecordAuthEvent struct {
HttpContext echo.Context
Record *models.Record
Token string
Meta any
}
type RecordRequestPasswordResetEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmPasswordResetEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordRequestVerificationEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmVerificationEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordRequestEmailChangeEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordConfirmEmailChangeEvent struct {
HttpContext echo.Context
Record *models.Record
}
type RecordListExternalAuthsEvent struct {
HttpContext echo.Context
Record *models.Record
ExternalAuths []*models.ExternalAuth
}
type RecordUnlinkExternalAuthEvent struct {
HttpContext echo.Context
Record *models.Record
ExternalAuth *models.ExternalAuth
}
// -------------------------------------------------------------------
// Admin API events data
// -------------------------------------------------------------------
@@ -142,29 +218,6 @@ type AdminAuthEvent struct {
Token string
}
// -------------------------------------------------------------------
// Auth Record API events data
// -------------------------------------------------------------------
type RecordAuthEvent struct {
HttpContext echo.Context
Record *models.Record
Token string
Meta any
}
type RecordListExternalAuthsEvent struct {
HttpContext echo.Context
Record *models.Record
ExternalAuths []*models.ExternalAuth
}
type RecordUnlinkExternalAuthEvent struct {
HttpContext echo.Context
Record *models.Record
ExternalAuth *models.ExternalAuth
}
// -------------------------------------------------------------------
// Collection API events data
// -------------------------------------------------------------------
+1 -1
View File
@@ -239,7 +239,7 @@ func (dao *Dao) create(m models.Model) error {
}
// clears the "new" model flag
m.UnmarkAsNew()
m.MarkAsNotNew()
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(dao, m)
+2
View File
@@ -212,6 +212,8 @@ func (dao *Dao) ImportCollections(
}
if existing, ok := mappedExisting[imported.GetId()]; ok {
imported.MarkAsNotNew()
// preserve original created date
if !existing.Created.IsZero() {
imported.Created = existing.Created
+58 -15
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
@@ -323,7 +324,7 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
// This is to make sure that the filter `@request.auth.id` always returns a unique id.
authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
if err != nil {
return fmt.Errorf("Unable to fetch the auth collections for cross-id unique check: %v", err)
return fmt.Errorf("Unable to fetch the auth collections for cross-id unique check: %w", err)
}
for _, collection := range authCollections {
if record.Collection().Id == collection.Id {
@@ -347,29 +348,71 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
// The delete operation may fail if the record is part of a required
// reference in another record (aka. cannot be deleted or set to NULL).
func (dao *Dao) DeleteRecord(record *models.Record) error {
// check for references
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction.
refs, err := dao.FindCollectionReferences(record.Collection())
if err != nil {
return err
const maxAttempts = 6
attempts := 1
Retry:
err := dao.deleteRecord(record, attempts)
if err != nil &&
attempts <= maxAttempts &&
// note: we are checking the error msg so that we can handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
time.Sleep(time.Duration(300*attempts) * time.Millisecond)
attempts++
goto Retry
}
// check if related records has to be deleted (if `CascadeDelete` is set)
// OR
// just unset the record id from any relation field values (if they are not required)
// -----------------------------------------------------------
return err
}
func (dao *Dao) deleteRecord(record *models.Record, attempts int) error {
return dao.RunInTransaction(func(txDao *Dao) error {
// delete/update references
// unset transaction dao before hook on retry to avoid
// triggering the same before callbacks multiple times
if attempts > 1 {
oldBeforeCreateFunc := txDao.BeforeCreateFunc
oldBeforeUpdateFunc := txDao.BeforeUpdateFunc
oldBeforeDeleteFunc := txDao.BeforeDeleteFunc
txDao.BeforeCreateFunc = nil
txDao.BeforeUpdateFunc = nil
txDao.BeforeDeleteFunc = nil
defer func() {
if txDao != nil {
txDao.BeforeCreateFunc = oldBeforeCreateFunc
txDao.BeforeUpdateFunc = oldBeforeUpdateFunc
txDao.BeforeDeleteFunc = oldBeforeDeleteFunc
}
}()
}
// check for references
refs, err := txDao.FindCollectionReferences(record.Collection())
if err != nil {
return err
}
// check if related records has to be deleted (if `CascadeDelete` is set)
// OR
// just unset the record id from any relation field values (if they are not required)
for refCollection, fields := range refs {
for _, field := range fields {
options, _ := field.Options.(*schema.RelationOptions)
rows := []dbx.NullStringMap{}
// note: the select is not using the transaction dao to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
err := dao.RecordQuery(refCollection).
AndWhere(dbx.Not(dbx.HashExp{"id": record.Id})).
AndWhere(dbx.Like(field.Name, record.Id).Match(true, true)).
// fetch all referenced records
recordTableName := inflector.Columnify(refCollection.Name)
fieldColumnName := inflector.Columnify(field.Name)
err := txDao.RecordQuery(refCollection).
Distinct(true).
LeftJoin(fmt.Sprintf(
// note: the case is used to normalize value access for single and multiple relations.
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{__je__}}`,
fieldColumnName, fieldColumnName, fieldColumnName,
), nil).
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": record.Id})).
AndWhere(dbx.HashExp{"__je__.value": record.Id}).
All(&rows)
if err != nil {
return err
+2 -2
View File
@@ -93,8 +93,8 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
}
recordIds := make([]any, len(records))
for _, record := range records {
recordIds = append(recordIds, record.Id)
for i, record := range records {
recordIds[i] = record.Id
}
indirectRecords, err := dao.FindRecordsByExpr(
+63
View File
@@ -0,0 +1,63 @@
package daos
import (
"encoding/json"
"errors"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/security"
)
// FindSettings returns and decode the serialized app settings param value.
//
// The method will first try to decode the param value without decryption.
// If it fails and optEncryptionKey is set, it will try again by first
// decrypting the value and then decode it again.
//
// Returns an error if it fails to decode the stored serialized param value.
func (dao *Dao) FindSettings(optEncryptionKey ...string) (*settings.Settings, error) {
param, err := dao.FindParamByKey(models.ParamAppSettings)
if err != nil {
return nil, err
}
result := settings.New()
// try first without decryption
plainDecodeErr := json.Unmarshal(param.Value, result)
// failed, try to decrypt
if plainDecodeErr != nil {
var encryptionKey string
if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
encryptionKey = optEncryptionKey[0]
}
// load without decrypt has failed and there is no encryption key to use for decrypt
if encryptionKey == "" {
return nil, errors.New("failed to load the stored app settings - missing or invalid encryption key")
}
// decrypt
decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
if decryptErr != nil {
return nil, decryptErr
}
// decode again
decryptedDecodeErr := json.Unmarshal(decrypted, result)
if decryptedDecodeErr != nil {
return nil, decryptedDecodeErr
}
}
return result, nil
}
// SaveSettings persists the specified settings configuration.
//
// If optEncryptionKey is set, then the stored serialized value will be encrypted with it.
func (dao *Dao) SaveSettings(newSettings *settings.Settings, optEncryptionKey ...string) error {
return dao.SaveParam(models.ParamAppSettings, newSettings, optEncryptionKey...)
}
+50
View File
@@ -0,0 +1,50 @@
package daos_test
import (
"testing"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
func TestSaveAndFindSettings(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
encryptionKey := security.PseudorandomString(32)
// change unencrypted app settings
app.Settings().Meta.AppName = "save_unencrypted"
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
t.Fatal(err)
}
// check if the change was persisted
s1, err := app.Dao().FindSettings()
if err != nil {
t.Fatalf("Failed to fetch settings: %v", err)
}
if s1.Meta.AppName != "save_unencrypted" {
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_unencrypted", s1)
}
// make another change but this time provide an encryption key
app.Settings().Meta.AppName = "save_encrypted"
if err := app.Dao().SaveSettings(app.Settings(), encryptionKey); err != nil {
t.Fatal(err)
}
// try to fetch the settings without encryption key (should fail)
if s2, err := app.Dao().FindSettings(); err == nil {
t.Fatalf("Expected FindSettings to fail without an encryption key, got \n%v", s2)
}
// try again but this time with an encryption key
s3, err := app.Dao().FindSettings(encryptionKey)
if err != nil {
t.Fatalf("Failed to fetch settings with an encryption key %s: %v", encryptionKey, err)
}
if s3.Meta.AppName != "save_encrypted" {
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_encrypted", s3)
}
}
+6
View File
@@ -0,0 +1,6 @@
# ignore everything
/*
# exclude from the ignore filter
!.gitignore
!main.go
+59 -14
View File
@@ -9,34 +9,70 @@ import (
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
)
func defaultPublicDir() string {
if strings.HasPrefix(os.Args[0], os.TempDir()) {
// most likely ran with go run
return "./pb_public"
}
return filepath.Join(os.Args[0], "../pb_public")
}
func main() {
app := pocketbase.New()
var publicDirFlag string
// ---------------------------------------------------------------
// Optional plugin flags:
// ---------------------------------------------------------------
// add "--publicDir" option flag
var migrationsDir string
app.RootCmd.PersistentFlags().StringVar(
&publicDirFlag,
&migrationsDir,
"migrationsDir",
"",
"the directory with the user defined migrations",
)
var automigrate bool
app.RootCmd.PersistentFlags().BoolVar(
&automigrate,
"automigrate",
true,
"enable/disable auto migrations",
)
var publicDir string
app.RootCmd.PersistentFlags().StringVar(
&publicDir,
"publicDir",
defaultPublicDir(),
"the directory to serve static files",
)
var indexFallback bool
app.RootCmd.PersistentFlags().BoolVar(
&indexFallback,
"indexFallback",
true,
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
)
app.RootCmd.ParseFlags(os.Args[1:])
// ---------------------------------------------------------------
// Plugins and hooks:
// ---------------------------------------------------------------
// load js pb_migrations
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
Dir: migrationsDir,
})
// migrate command (with js templates)
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// serves static files from the provided public dir (if exists)
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDirFlag), true))
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
return nil
})
@@ -44,3 +80,12 @@ func main() {
log.Fatal(err)
}
}
// the default pb_public dir location is relative to the executable
func defaultPublicDir() string {
if strings.HasPrefix(os.Args[0], os.TempDir()) {
// most likely ran with go run
return "./pb_public"
}
return filepath.Join(os.Args[0], "../pb_public")
}
+20 -2
View File
@@ -4,6 +4,8 @@ package forms
import (
"regexp"
"github.com/pocketbase/pocketbase/models"
)
// base ID value regex pattern
@@ -13,7 +15,8 @@ var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
// Usually used in combination with InterceptorFunc.
type InterceptorNextFunc = func() error
// InterceptorFunc defines a single interceptor function that will execute the provided next func handler.
// InterceptorFunc defines a single interceptor function that
// will execute the provided next func handler.
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
// runInterceptors executes the provided list of interceptors.
@@ -21,6 +24,21 @@ func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc)
for i := len(interceptors) - 1; i >= 0; i-- {
next = interceptors[i](next)
}
return next()
}
// InterceptorWithRecordNextFunc is a Record interceptor handler function.
// Usually used in combination with InterceptorWithRecordFunc.
type InterceptorWithRecordNextFunc = func(record *models.Record) error
// InterceptorWithRecordFunc defines a single Record interceptor function
// that will execute the provided next func handler.
type InterceptorWithRecordFunc func(next InterceptorWithRecordNextFunc) InterceptorWithRecordNextFunc
// runInterceptorsWithRecord executes the provided list of Record interceptors.
func runInterceptorsWithRecord(record *models.Record, next InterceptorWithRecordNextFunc, interceptors ...InterceptorWithRecordFunc) error {
for i := len(interceptors) - 1; i >= 0; i-- {
next = interceptors[i](next)
}
return next(record)
}
+4 -4
View File
@@ -367,12 +367,12 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
}
// check interceptor calls
expectInterceptorCall := 1
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCall = 0
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCall {
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCall, interceptorCalls)
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
}
// check errors
+1
View File
@@ -103,6 +103,7 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
if upsertModel == nil {
upsertModel = collection
}
upsertModel.MarkAsNotNew()
upsertForm := NewCollectionUpsert(form.app, upsertModel)
upsertForm.SetDao(txDao)
+10 -3
View File
@@ -113,7 +113,10 @@ func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record,
// Submit validates and submits the auth record email change confirmation form.
// On success returns the updated auth record associated to `form.Token`.
func (form *RecordEmailChangeConfirm) Submit() (*models.Record, error) {
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -127,8 +130,12 @@ func (form *RecordEmailChangeConfirm) Submit() (*models.Record, error) {
authRecord.SetVerified(true)
authRecord.RefreshTokenKey() // invalidate old tokens
if err := form.dao.SaveRecord(authRecord); err != nil {
return nil, err
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
return form.dao.SaveRecord(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return authRecord, nil
+75 -1
View File
@@ -2,10 +2,12 @@ package forms_test
import (
"encoding/json"
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -82,7 +84,24 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
continue
}
record, err := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
record, err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
// parse errors
errs, ok := err.(validation.Errors)
@@ -124,3 +143,58 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
}
}
}
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY"
form.Password = "1234567890"
interceptorEmail := authRecord.Email()
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptorEmail = record.Email()
interceptor2Called = true
return testErr
}
}
_, submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorEmail == authRecord.Email() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+7 -2
View File
@@ -61,10 +61,15 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
}
// Submit validates and sends the change email request.
func (form *RecordEmailChangeRequest) Submit() error {
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
if err := form.Validate(); err != nil {
return err
}
return mails.SendRecordChangeEmail(form.app, form.record, form.NewEmail)
return runInterceptorsWithRecord(form.record, func(m *models.Record) error {
return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
}, interceptors...)
}
+63 -1
View File
@@ -2,10 +2,12 @@ package forms_test
import (
"encoding/json"
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -57,7 +59,24 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
continue
}
err := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
// parse errors
errs, ok := err.(validation.Errors)
@@ -85,3 +104,46 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
}
}
}
func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordEmailChangeRequest(testApp, authRecord)
form.NewEmail = "test_new@example.com"
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
}
+10 -3
View File
@@ -71,7 +71,10 @@ func (form *RecordPasswordResetConfirm) checkToken(value any) error {
// Submit validates and submits the form.
// On success returns the updated auth record associated to `form.Token`.
func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) {
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -88,8 +91,12 @@ func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) {
return nil, err
}
if err := form.dao.SaveRecord(authRecord); err != nil {
return nil, err
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
return form.dao.SaveRecord(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return authRecord, nil
+76 -1
View File
@@ -2,10 +2,12 @@ package forms_test
import (
"encoding/json"
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -76,7 +78,15 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
continue
}
record, submitErr := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
record, submitErr := form.Submit(interceptor)
// parse errors
errs, ok := submitErr.(validation.Errors)
@@ -85,6 +95,15 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
continue
}
// check interceptor calls
expectInterceptorCalls := 1
if len(s.expectedErrors) > 0 {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
// check errors
if len(errs) > len(s.expectedErrors) {
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
@@ -115,3 +134,59 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
}
}
}
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
form.Password = "1234567890"
form.PasswordConfirm = "1234567890"
interceptorTokenKey := authRecord.TokenKey()
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptorTokenKey = record.TokenKey()
interceptor2Called = true
return testErr
}
}
_, submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorTokenKey == authRecord.TokenKey() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+12 -7
View File
@@ -59,12 +59,15 @@ func (form *RecordPasswordResetRequest) Validate() error {
// Submit validates and submits the form.
// On success, sends a password reset email to the `form.Email` auth record.
func (form *RecordPasswordResetRequest) Submit() error {
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
if err := form.Validate(); err != nil {
return err
}
authRecord, err := form.dao.FindFirstRecordByData(form.collection.Id, schema.FieldNameEmail, form.Email)
authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
if err != nil {
return err
}
@@ -75,12 +78,14 @@ func (form *RecordPasswordResetRequest) Submit() error {
return errors.New("You've already requested a password reset.")
}
if err := mails.SendRecordPasswordReset(form.app, authRecord); err != nil {
return err
}
// update last sent timestamp
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
return form.dao.SaveRecord(authRecord)
return runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
return err
}
return form.dao.SaveRecord(m)
}, interceptors...)
}
+74 -1
View File
@@ -2,10 +2,12 @@ package forms_test
import (
"encoding/json"
"errors"
"testing"
"time"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -64,7 +66,24 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
continue
}
err := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if s.expectError {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
hasErr := err != nil
if hasErr != s.expectError {
@@ -95,3 +114,57 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
}
}
}
func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
form.Email = authRecord.Email()
interceptorLastResetSentAt := authRecord.LastResetSentAt()
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptorLastResetSentAt = record.LastResetSentAt()
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorLastResetSentAt.String() == authRecord.LastResetSentAt().String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+2 -2
View File
@@ -651,12 +651,12 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
// persist record model
if err := txDao.SaveRecord(form.record); err != nil {
return fmt.Errorf("Failed to save the record: %v", err)
return fmt.Errorf("Failed to save the record: %w", err)
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return fmt.Errorf("Failed to process the upload files: %v", err)
return fmt.Errorf("Failed to process the upload files: %w", err)
}
// delete old files (if any)
+17 -6
View File
@@ -76,7 +76,10 @@ func (form *RecordVerificationConfirm) checkToken(value any) error {
// Submit validates and submits the form.
// On success returns the verified auth record associated to `form.Token`.
func (form *RecordVerificationConfirm) Submit() (*models.Record, error) {
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
if err := form.Validate(); err != nil {
return nil, err
}
@@ -89,14 +92,22 @@ func (form *RecordVerificationConfirm) Submit() (*models.Record, error) {
return nil, err
}
if record.Verified() {
return record, nil // already verified
wasVerified := record.Verified()
if !wasVerified {
record.SetVerified(true)
}
record.SetVerified(true)
interceptorsErr := runInterceptorsWithRecord(record, func(m *models.Record) error {
if wasVerified {
return nil // already verified
}
if err := form.dao.SaveRecord(record); err != nil {
return nil, err
return form.dao.SaveRecord(m)
}, interceptors...)
if interceptorsErr != nil {
return nil, interceptorsErr
}
return record, nil
+74 -1
View File
@@ -2,9 +2,11 @@ package forms_test
import (
"encoding/json"
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
)
@@ -54,7 +56,24 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
continue
}
record, err := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
record, err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if s.expectError {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
hasErr := err != nil
if hasErr != s.expectError {
@@ -77,3 +96,57 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
}
}
}
func TestRecordVerificationConfirmInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordVerificationConfirm(testApp, authCollection)
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
interceptorVerified := authRecord.Verified()
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptorVerified = record.Verified()
interceptor2Called = true
return testErr
}
}
_, submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorVerified == authRecord.Verified() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+22 -15
View File
@@ -59,7 +59,10 @@ func (form *RecordVerificationRequest) Validate() error {
// Submit validates and sends a verification request email
// to the `form.Email` auth record.
func (form *RecordVerificationRequest) Submit() error {
//
// You can optionally provide a list of InterceptorWithRecordFunc to
// further modify the form behavior before persisting it.
func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
if err := form.Validate(); err != nil {
return err
}
@@ -73,22 +76,26 @@ func (form *RecordVerificationRequest) Submit() error {
return err
}
if record.GetBool(schema.FieldNameVerified) {
return nil // already verified
if !record.Verified() {
now := time.Now().UTC()
lastVerificationSentAt := record.LastVerificationSentAt().Time()
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
return errors.New("A verification email was already sent.")
}
// update last sent timestamp
record.SetLastVerificationSentAt(types.NowDateTime())
}
now := time.Now().UTC()
lastVerificationSentAt := record.LastVerificationSentAt().Time()
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
return errors.New("A verification email was already sent.")
}
return runInterceptorsWithRecord(record, func(m *models.Record) error {
if m.Verified() {
return nil // already verified
}
if err := mails.SendRecordVerification(form.app, record); err != nil {
return err
}
if err := mails.SendRecordVerification(form.app, m); err != nil {
return err
}
// update last sent timestamp
record.Set(schema.FieldNameLastVerificationSentAt, types.NowDateTime())
return form.dao.SaveRecord(record)
return form.dao.SaveRecord(m)
}, interceptors...)
}
+79 -6
View File
@@ -2,10 +2,12 @@ package forms_test
import (
"encoding/json"
"errors"
"testing"
"time"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
@@ -78,15 +80,32 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
t.Errorf("[%d] Failed to load form data: %v", i, loadErr)
continue
}
err := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(r *models.Record) error {
interceptorCalls++
return next(r)
}
}
err := form.Submit(interceptor)
// check interceptor calls
expectInterceptorCalls := 1
if s.expectError {
expectInterceptorCalls = 0
}
if interceptorCalls != expectInterceptorCalls {
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
}
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
t.Errorf("[%d] Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
expectedMails := 0
@@ -94,7 +113,7 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
expectedMails = 1
}
if testApp.TestMailer.TotalSend != expectedMails {
t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
t.Errorf("[%d] Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend)
}
if s.expectError {
@@ -103,13 +122,67 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
if err != nil {
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
t.Errorf("[%d] Expected user with email %q to exist, got nil", i, form.Email)
continue
}
// check whether LastVerificationSentAt was updated
if !user.Verified() && user.LastVerificationSentAt().Time().Sub(now.Time()) < 0 {
t.Errorf("(%d) Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt())
t.Errorf("[%d] Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt())
}
}
}
func TestRecordVerificationRequestInterceptors(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordVerificationRequest(testApp, authCollection)
form.Email = authRecord.Email()
interceptorLastVerificationSentAt := authRecord.LastVerificationSentAt()
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptor1Called = true
return next(record)
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
return func(record *models.Record) error {
interceptorLastVerificationSentAt = record.LastVerificationSentAt()
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorLastVerificationSentAt.String() == authRecord.LastVerificationSentAt().String() {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
+7 -13
View File
@@ -6,12 +6,12 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/settings"
)
// SettingsUpsert is a [core.Settings] upsert (create/update) form.
// SettingsUpsert is a [settings.Settings] upsert (create/update) form.
type SettingsUpsert struct {
*core.Settings
*settings.Settings
app core.App
dao *daos.Dao
@@ -55,16 +55,10 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
encryptionKey := os.Getenv(form.app.EncryptionEnv())
return runInterceptors(func() error {
saveErr := form.dao.SaveParam(
models.ParamAppSettings,
form.Settings,
encryptionKey,
)
if saveErr != nil {
return saveErr
encryptionKey := os.Getenv(form.app.EncryptionEnv())
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
return err
}
// explicitly trigger old logs deletion
@@ -73,7 +67,7 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
)
if form.Settings.Logs.MaxDays == 0 {
// reclaim deleted logs disk space
// no logs are allowed -> reclaim preserved disk space after the previous delete operation
form.app.LogsDao().Vacuum()
}
+2 -2
View File
@@ -72,8 +72,8 @@ func TestEmailSendValidateAndSubmit(t *testing.T) {
expectedContent = "Confirm new email"
}
if !strings.Contains(app.TestMailer.LastHtmlBody, expectedContent) {
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastHtmlBody)
if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
}
}
}
+37 -33
View File
@@ -4,53 +4,57 @@ go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/aws/aws-sdk-go v1.44.141
github.com/aws/aws-sdk-go v1.44.153
github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.3.4
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
github.com/fatih/color v1.13.0
github.com/gabriel-vasile/mimetype v1.4.1
github.com/ganigeorgiev/fexpr v0.1.1
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/golang-jwt/jwt/v4 v4.4.2
github.com/golang-jwt/jwt/v4 v4.4.3
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
github.com/mattn/go-sqlite3 v1.14.16
github.com/pocketbase/dbx v1.7.0
github.com/pocketbase/dbx v1.8.0
github.com/spf13/cast v1.5.0
github.com/spf13/cobra v1.6.1
gocloud.dev v0.27.0
golang.org/x/crypto v0.3.0
golang.org/x/net v0.2.0
golang.org/x/net v0.3.0
golang.org/x/oauth2 v0.2.0
modernc.org/sqlite v1.19.4
modernc.org/sqlite v1.20.0
)
require (
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/aws/aws-sdk-go-v2 v1.17.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.2 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.41 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.17.4 // indirect
github.com/aws/smithy-go v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.17.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.4 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.43 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.11.26 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.17.6 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
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.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
@@ -61,25 +65,25 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.1.0 // indirect
golang.org/x/image v0.2.0 // indirect
golang.org/x/mod v0.7.0 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/term v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/time v0.2.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/term v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/tools v0.3.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.103.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect
google.golang.org/genproto v0.0.0-20221205194025-8222ab48f5fc // indirect
google.golang.org/grpc v1.51.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.21.4 // indirect
modernc.org/libc v1.21.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
+84 -115
View File
@@ -47,18 +47,16 @@ cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6m
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
cloud.google.com/go/compute v1.10.0 h1:aoLIYaA1fX3ywihqpBk2APQKOo20nXsp1GEZQbx5Jk4=
cloud.google.com/go/compute v1.12.1 h1:gKVJMEyqV5c/UnpzjjQbo3Rjvvqpr9B1DFSbJC4OXr0=
cloud.google.com/go/compute v1.13.0 h1:AYrLkB8NPdDRslNp4Jxmzrhdr03fUAIDbiGFjLWowoU=
cloud.google.com/go/compute/metadata v0.2.1 h1:efOwf5ymceDhK6PKMnnrTHP4pppY5L22mle96M1yP48=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c=
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
cloud.google.com/go/iam v0.6.0 h1:nsqQC88kT5Iwlm4MeNGTpfMWddp6NB/UOLFTH6m1QfQ=
cloud.google.com/go/iam v0.7.0 h1:k4MuwOsS7zGJJ+QfZ5vBK8SgHBAvYN/23BWsiihJ1vs=
cloud.google.com/go/iam v0.8.0 h1:E2osAkZzxI/+8pZcxVLcDtAQx/u+hZXVryUaYQ5O0Kk=
cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA=
cloud.google.com/go/longrunning v0.1.1 h1:y50CXG4j0+qvEukslYFBCrzaXX0qpFbBzc3PchSu/LE=
cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs=
cloud.google.com/go/monitoring v1.1.0/go.mod h1:L81pzz7HKn14QCMaCs6NTQkdBnE87TElyanS95vIcl4=
cloud.google.com/go/monitoring v1.5.0/go.mod h1:/o9y8NYX5j91JjD/JvGLYbi86kL11OjyJXq2XziLJu4=
@@ -208,86 +206,70 @@ github.com/aws/aws-sdk-go v1.43.11/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.45/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.68/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.126 h1:7HQJw2DNiwpxqMe2H7odGNT2rhO4SRrUe5/8dYXl0Jk=
github.com/aws/aws-sdk-go v1.44.126/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.127 h1:IoO2VfuIQg1aMXnl8l6OpNUKT4Qq5CnJMOyIWoTYXj0=
github.com/aws/aws-sdk-go v1.44.127/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.130 h1:a/qwOxmYJF47xTZvTjECSJXnfRbjegb3YxvCXfETtnY=
github.com/aws/aws-sdk-go v1.44.130/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.44.141 h1:kT/YIH9GUaKZa3yi8TEsZmVgvphwEzINJu19hYUzfGY=
github.com/aws/aws-sdk-go v1.44.141/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
github.com/aws/aws-sdk-go v1.44.153 h1:KfN5URb9O/Fk48xHrAinrPV2DzPcLa0cd9yo1ax5KGg=
github.com/aws/aws-sdk-go v1.44.153/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aws/aws-sdk-go-v2 v1.16.8/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw=
github.com/aws/aws-sdk-go-v2 v1.17.1 h1:02c72fDJr87N8RAC2s3Qu0YuvMRZKNZJ9F+lAehCazk=
github.com/aws/aws-sdk-go-v2 v1.17.1/go.mod h1:JLnGeGONAyi2lWXI1p0PCIOIy333JMVK1U7Hf0aRFLw=
github.com/aws/aws-sdk-go-v2 v1.17.2 h1:r0yRZInwiPBNpQ4aDy/Ssh3ROWsGtKDwar2JS8Lm+N8=
github.com/aws/aws-sdk-go-v2 v1.17.2/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3/go.mod h1:gNsR5CaXKmQSSzrmGxmwmct/r+ZBfbxorAuXYsj/M5Y=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9 h1:RKci2D7tMwpvGpDNZnGQw9wk6v7o/xSwFcUAuNPoB8k=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9/go.mod h1:vCmV1q1VK8eoQJ5+aYE7PkK1K6v41qJ5pJdK3ggCDvg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno=
github.com/aws/aws-sdk-go-v2/config v1.15.15/go.mod h1:A1Lzyy/o21I5/s2FbyX5AevQfSVXpvvIDCoVFD0BC4E=
github.com/aws/aws-sdk-go-v2/config v1.17.10 h1:zBy5QQ/mkvHElM1rygHPAzuH+sl8nsdSaxSWj0+rpdE=
github.com/aws/aws-sdk-go-v2/config v1.17.10/go.mod h1:/4np+UiJJKpWHN7Q+LZvqXYgyjgeXm5+lLfDI6TPZao=
github.com/aws/aws-sdk-go-v2/config v1.18.2 h1:tRhTb3xMZsB0gW0sXWpqs9FeIP8iQp5SvnvwiPXzHwo=
github.com/aws/aws-sdk-go-v2/config v1.18.2/go.mod h1:9XVoZTdD8ICjrgI5ddb8j918q6lEZkFYpb7uohgvU6c=
github.com/aws/aws-sdk-go-v2/config v1.18.4 h1:VZKhr3uAADXHStS/Gf9xSYVmmaluTUfkc0dcbPiDsKE=
github.com/aws/aws-sdk-go-v2/config v1.18.4/go.mod h1:EZxMPLSdGAZ3eAmkqXfYbRppZJTzFTkv8VyEzJhKko4=
github.com/aws/aws-sdk-go-v2/credentials v1.12.10/go.mod h1:g5eIM5XRs/OzIIK81QMBl+dAuDyoLN0VYaLP+tBqEOk=
github.com/aws/aws-sdk-go-v2/credentials v1.12.23 h1:LctvcJMIb8pxvk5hQhChpCu0WlU6oKQmcYb1HA4IZSA=
github.com/aws/aws-sdk-go-v2/credentials v1.12.23/go.mod h1:0awX9iRr/+UO7OwRQFpV1hNtXxOVuehpjVEzrIAYNcA=
github.com/aws/aws-sdk-go-v2/credentials v1.13.2 h1:F/v1w0XcFDZjL0bCdi9XWJenoPKjGbzljBhDKcryzEQ=
github.com/aws/aws-sdk-go-v2/credentials v1.13.2/go.mod h1:eAT5aj/WJ2UDIA0IVNFc2byQLeD89SDEi4cjzH/MKoQ=
github.com/aws/aws-sdk-go-v2/credentials v1.13.4 h1:nEbHIyJy7mCvQ/kzGG7VWHSBpRB4H6sJy3bWierWUtg=
github.com/aws/aws-sdk-go-v2/credentials v1.13.4/go.mod h1:/Cj5w9LRsNTLSwexsohwDME32OzJ6U81Zs33zr2ZWOM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.9/go.mod h1:KDCCm4ONIdHtUloDcFvK2+vshZvx4Zmj7UMDfusuz5s=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 h1:E3PXZSI3F2bzyj6XxUXdTIfvp425HHhwKsFvmzBwHgs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19/go.mod h1:VihW95zQpeKQWVPGkwT+2+WJNQV8UXFfMTWdU6VErL8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.20 h1:tpNOglTZ8kg9T38NpcGBxudqfUAwUzyUnLQ4XSd0CHE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.20/go.mod h1:d9xFpWd3qYwdIXM0fvu7deD08vvdRXyc/ueV+0SqaWE=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.21/go.mod h1:iIYPrQ2rYfZiB/iADYlhj9HHZ9TTi6PqKQPAqygohbE=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.37 h1:e1VtTBo+cLNjres0wTlMkmwCGGRjDEkkrz3frxxcaCs=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.37/go.mod h1:kdAV1UMnCkyG6tZJUC4mHbPoRjPA3dIK0L8mnsHERiM=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.41 h1:ssgdsNm11dvFtO7F/AeiW4dAO3eGsDeg5fwpag/JP/I=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.41/go.mod h1:CS+AbDFAaPU9TQOo7U6mVV23YvqCOElnqmh0XQjgJ1g=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.43 h1:+bkAMTd5OGyHu2nwNOangjEsP65fR0uhMbZJA52sZ64=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.43/go.mod h1:sS2tu0VEspKuY5eM1vQgy7P/hpZX8F62o6qsghZExWc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.15/go.mod h1:pWrr2OoHlT7M/Pd2y4HV3gJyPb3qj5qMmnPkKSNPYK4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 h1:nBO/RFxeq/IS5G9Of+ZrgucRciie2qpLy++3UGZ+q2E=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25/go.mod h1:Zb29PYkf42vVYQY6pvSyJCJcFHlPIiY+YKdPtwnvMkY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.26 h1:5WU31cY7m0tG+AiaXuXGoMzo2GBQ1IixtWa8Yywsgco=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.26/go.mod h1:2E0LdbJW6lbeU4uxjum99GZzI0ZjDpAb0CoSCM0oeEY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.9/go.mod h1:08tUpeSGN33QKSO7fwxXczNfiwCpbj+GxK6XKwqWVv0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 h1:oRHDrwCTVT8ZXi4sr9Ld+EXk7N/KGssOr2ygNeojEhw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19/go.mod h1:6Q0546uHDp421okhmmGfbxzq2hBqbXFNpi4k+Q1JnQA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.20 h1:WW0qSzDWoiWU2FS5DbKpxGilFVlCEJPwx4YtjdfI0Jw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.20/go.mod h1:/+6lSiby8TBFpTVXZgKiN/rCfkYXEGvhlM4zCgPpt7w=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.16/go.mod h1:CYmI+7x03jjJih8kBEEFKRQc40UjUokT0k7GbvrhhTc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 h1:Mza+vlnZr+fPKFKRq/lKGVvM6B/8ZZmNdEopOwSQLms=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26/go.mod h1:Y2OJ+P+MC1u1VKnavT+PshiEuGPyh/7DqxoDNij4/bg=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.27 h1:N2eKFw2S+JWRCtTt0IhIX7uoGGQciD4p6ba+SJv4WEU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.27/go.mod h1:RdwFVc7PBYWY33fa2+8T1mSqQ7ZEK4ILpM0wfioDC3w=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.6/go.mod h1:O7Oc4peGZDEKlddivslfYFvAbgzvl/GH3J8j3JIGBXc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16 h1:2EXB7dtGwRYIN3XQ9qwIW504DVbKIw3r89xQnonGdsQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16/go.mod h1:XH+3h395e3WVdd6T2Z3mPxuI+x/HVtdqVOREkTiyubs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.17 h1:5tXbMJ7Jq0iG65oiMg6tCLsHkSaO2xLXa2EmZ29vaTA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.17/go.mod h1:twV0fKMQuqLY4klyFH56aXNq3AFiA5LO0/frTczEOFE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3/go.mod h1:gkb2qADY+OHaGLKNTYxMaQNacfeyQpZ4csDTQMeFmcw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 h1:dpiPHgmFstgkLG07KaYAewvuptq5kvo52xn7tVSrtrQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10/go.mod h1:9cBNUHI2aW4ho0A5T87O294iPDuuUOSIEDjnd1Lq/z0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.10/go.mod h1:Qks+dxK3O+Z2deAhNo6cJ8ls1bam3tUGUAcgxQP1c70=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20 h1:KSvtm1+fPXE0swe9GPjc6msyrdTT0LB/BP8eLugL1FI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20/go.mod h1:Mp4XI/CkWGD79AQxZ5lIFlgvC0A+gl+4BmyG1F+SfNc=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.21 h1:77b1GfaSuIok5yB/3HYbG+ypWvOJDQ2rVdq943D17R4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.21/go.mod h1:sPOz31BVdqeeurKEuUpLNSve4tdCNPluE+070HNcEHI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.9/go.mod h1:yQowTpvdZkFVuHrLBXmczat4W+WJKg/PafBZnGBLga0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 h1:GE25AWCdNUPh9AOJzI9KIJnja7IwUc1WyUqz/JTyJ/I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19/go.mod h1:02CP6iuYP+IVnBX5HULVdSAku/85eHB2Y9EsFhrkEwU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.20 h1:jlgyHbkZQAgAc7VIxJDmtouH8eNjOk2REVAQfVhdaiQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.20/go.mod h1:Xs52xaLBqDEKRcAfX/hgjmD3YQ7c/W+BEyfamlO/W2E=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.9/go.mod h1:Rc5+wn2k8gFSi3V1Ch4mhxOzjMh+bYSXVFfVaqowQOY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19 h1:piDBAaWkaxkkVV3xJJbTehXCZRXYs49kvpi/LG6LR2o=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19/go.mod h1:BmQWRVkLTmyNzYPFAZgon53qKLWBNSvonugD1MrSWUs=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.20 h1:4K6dbmR0mlp3o4Bo78PnpvzHtYAqEeVMguvEenpMGsI=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.20/go.mod h1:1XpDcReIEOHsjwNToDKhIAO3qwLo1BnfbtSqWJa8j7g=
github.com/aws/aws-sdk-go-v2/service/kms v1.18.1/go.mod h1:4PZMUkc9rXHWGVB5J9vKaZy3D7Nai79ORworQ3ASMiM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.27.2/go.mod h1:u+566cosFI+d+motIz3USXEh6sN8Nq4GrNXSg2RXVMo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.1 h1:/EMdFPW/Ppieh0WUtQf1+qCGNLdsq5UWUyevBQ6vMVc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.1/go.mod h1:/NHbqPRiwxSPVOB2Xr+StDEH+GWV/64WwnUjv4KYzV0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.3 h1:F6wgg8aHGNyhaAy2ONnWBThiPdLa386qNA0j33FIuSM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.3/go.mod h1:/NHbqPRiwxSPVOB2Xr+StDEH+GWV/64WwnUjv4KYzV0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.5 h1:nRSEQj1JergKTVc8RGkhZvOEGgcvo4fWpDPwGDeg2ok=
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.5/go.mod h1:wcaJTmjKFDW0s+Se55HBNIds6ghdAGoDDw+SGUdrfAk=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.14/go.mod h1:xakbH8KMsQQKqzX87uyyzTHshc/0/Df8bsTneTS5pFU=
github.com/aws/aws-sdk-go-v2/service/sns v1.17.10/go.mod h1:uITsRNVMeCB3MkWpXxXw0eDz8pW4TYLzj+eyQtbhSxM=
github.com/aws/aws-sdk-go-v2/service/sqs v1.19.1/go.mod h1:A94o564Gj+Yn+7QO1eLFeI7UVv3riy/YBFOfICVqFvU=
github.com/aws/aws-sdk-go-v2/service/ssm v1.27.6/go.mod h1:fiFzQgj4xNOg4/wqmAiPvzgDMXPD+cUEplX/CYn+0j0=
github.com/aws/aws-sdk-go-v2/service/sso v1.11.13/go.mod h1:d7ptRksDDgvXaUvxyHZ9SYh+iMDymm94JbVcgvSYSzU=
github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 h1:GFZitO48N/7EsFDt8fMa5iYdmWqkUDDB3Eje6z3kbG0=
github.com/aws/aws-sdk-go-v2/service/sso v1.11.25/go.mod h1:IARHuzTXmj1C0KS35vboR0FeJ89OkEy1M9mWbK2ifCI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 h1:jcw6kKZrtNfBPJkaHrscDOZoe5gvi9wjudnxvozYFJo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8/go.mod h1:er2JHN+kBY6FcMfcBBKNGCT3CarImmdFzishsqBmSRI=
github.com/aws/aws-sdk-go-v2/service/sso v1.11.26 h1:ActQgdTNQej/RuUJjB9uxYVLDOvRGtUreXF8L3c8wyg=
github.com/aws/aws-sdk-go-v2/service/sso v1.11.26/go.mod h1:uB9tV79ULEZUXc6Ob18A46KSQ0JDlrplPni9XW6Ot60=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9 h1:wihKuqYUlA2T/Rx+yu2s6NDAns8B9DgnRooB1PVhY+Q=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9/go.mod h1:2E/3D/mB8/r2J7nK42daoKP/ooCwbf0q1PznNc+DZTU=
github.com/aws/aws-sdk-go-v2/service/sts v1.16.10/go.mod h1:cftkHYN6tCDNfkSasAmclSfl4l7cySoay8vz7p/ce0E=
github.com/aws/aws-sdk-go-v2/service/sts v1.17.1 h1:KRAix/KHvjGODaHAMXnxRk9t0D+4IJVUuS/uwXxngXk=
github.com/aws/aws-sdk-go-v2/service/sts v1.17.1/go.mod h1:bXcN3koeVYiJcdDU89n3kCYILob7Y34AeLopUbZgLT4=
github.com/aws/aws-sdk-go-v2/service/sts v1.17.4 h1:YNncBj5dVYd05i4ZQ+YicOotSXo0ufc9P8kTioi13EM=
github.com/aws/aws-sdk-go-v2/service/sts v1.17.4/go.mod h1:bXcN3koeVYiJcdDU89n3kCYILob7Y34AeLopUbZgLT4=
github.com/aws/aws-sdk-go-v2/service/sts v1.17.6 h1:VQFOLQVL3BrKM/NLO/7FiS4vcp5bqK0mGMyk09xLoAY=
github.com/aws/aws-sdk-go-v2/service/sts v1.17.6/go.mod h1:Az3OXXYGyfNwQNsK/31L4R75qFYnO641RZGAoV3uH1c=
github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/smithy-go v1.13.4 h1:/RN2z1txIJWeXeOkzX+Hk/4Uuvv7dWtCjbmVJcrskyk=
github.com/aws/smithy-go v1.13.4/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8=
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -498,6 +480,9 @@ github.com/digitalocean/godo v1.81.0/go.mod h1:BPCqvwbjbGqxuUnIKB4EvS/AX7IDnNmt5
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
@@ -521,6 +506,14 @@ github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZ
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/domodwyer/mailyak/v3 v3.3.4 h1:AG/pvcz2/ocFqZkPEG7lPAa0MhCq1warfUEKJt6Fagk=
github.com/domodwyer/mailyak/v3 v3.3.4/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-20220815083517-0c74f9139fd6/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs=
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86 h1:E2wycakfddWJ26v+ZyEY91Lb/HEZyaiZhbMX+KQcdmc=
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86/go.mod h1:yRkwfj0CBpOGre+TwBsqPV0IH0Pk73e4PXJOeNDboGs=
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-20221009164102-3aa5028e57f6 h1:p3QZwRRfCN7Qr3GNBTMKBkLFjEm3DHR4MaJABvsiqgk=
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6/go.mod h1:+CJy9V5cGycP5qwp6RM5jLg+TFEMyGtD7A9xUbU/BOQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@@ -640,6 +633,8 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -698,8 +693,9 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -796,6 +792,7 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20220318212150-b2ab0324ddda/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/pprof v0.0.0-20220608213341-c488b8fa1db3/go.mod h1:gSuNB+gJaOiQKLEZ+q+PK9Mq3SOzhRcw2GsGS/FhYDk=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -816,8 +813,6 @@ github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
github.com/googleapis/gax-go/v2 v2.6.0 h1:SXk3ABtQYDT/OH8jAyvEOQ58mgawq5C4o/4/89qN2ZU=
github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
@@ -915,8 +910,9 @@ github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
github.com/intel/goresctrl v0.2.0/go.mod h1:+CZdzouYFn5EsxgqAQTEzMfwKwuc0fVdMrT9FCCAVRQ=
github.com/ionos-cloud/sdk-go/v6 v6.1.0/go.mod h1:Ox3W0iiEz0GHnfY9e5LmAxwklsxguuNFEUSu0gVRTME=
@@ -1224,8 +1220,8 @@ github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6J
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.7.0 h1:MY6/up//aIeH6WA8VqYt3EeQt082bEdKcUDcEF4UrWw=
github.com/pocketbase/dbx v1.7.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/dbx v1.8.0 h1:kjf3mgmmE12t8IG48kJOeIyBmRi0A1sl6Hsezv4PoiA=
github.com/pocketbase/dbx v1.8.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
@@ -1379,7 +1375,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -1464,7 +1459,6 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
@@ -1567,8 +1561,6 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -1584,8 +1576,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ=
golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -1613,8 +1605,6 @@ golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1694,10 +1684,9 @@ golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220802222814-0bcc04d9c69b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.3.0 h1:VWL6FNY2bEEmsGVKabSlHu5Irp34xmMRoqb/9lF9lxk=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1723,8 +1712,6 @@ golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7Lm
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220628200809-02e64fa58f26/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.1.0 h1:isLCZuhj4v+tYv7eskaN4v/TM+A1begWWgyVJDdl1+Y=
golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A=
golang.org/x/oauth2 v0.2.0 h1:GtQkldQ9m7yvzCL1V+LrYow3Khe0eJH0w7RbX/VbaIU=
golang.org/x/oauth2 v0.2.0/go.mod h1:Cwn6afJ8jrQwYMxQDTpISoXmXW9I6qF6vDeuuoX3Ibs=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1741,6 +1728,7 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1883,20 +1871,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1906,8 +1892,9 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1920,10 +1907,8 @@ golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA=
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE=
golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -2007,8 +1992,6 @@ golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.2.0 h1:G6AHpWxTMGY1KyEYoAQ5WTtIekUUvDNjan3ugu60JvE=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2070,10 +2053,6 @@ google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6F
google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.91.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.101.0 h1:lJPPeEBIRxGpGLwnBTam1NPEM8Z2BmmXEd3z812pjwM=
google.golang.org/api v0.101.0/go.mod h1:CjxAAWWt3A3VrUE2IGDY2bgK5qhoG/OkyWVlYcP05MY=
google.golang.org/api v0.102.0 h1:JxJl2qQ85fRMPNvlZY/enexbxpCjLwGhZUtgfGeQ51I=
google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo=
google.golang.org/api v0.103.0 h1:9yuVqlu2JCvcLg9p8S3fcFLZij8EPSyvODIY1rkMizQ=
google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@@ -2184,10 +2163,8 @@ google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljW
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220802133213-ce4fa296bf78/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c h1:QgY/XxIAIeccR+Ca/rDdKubLIU9rcJ3xfy1DC/Wd2Oo=
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo=
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 h1:a2S6M0+660BgMNl++4JPlcAO/CjkqYItDEZwkoDQK7c=
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg=
google.golang.org/genproto v0.0.0-20221205194025-8222ab48f5fc h1:nUKKji0AarrQKh6XpFEpG3p1TNztxhe7C8TcUvDgXqw=
google.golang.org/genproto v0.0.0-20221205194025-8222ab48f5fc/go.mod h1:1dOng4TWOomJrDGhpXjfCD35wQC6jnC7HpRmOFRqEV0=
google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -2229,8 +2206,6 @@ google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACu
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.50.1 h1:DS/BukOZWp8s6p4Dt/tOaJaTQyPyOoCcrjroHuCeLzY=
google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
@@ -2365,29 +2340,23 @@ lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.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/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8 h1:0+dsXf0zeLx9ixj4nilg6jKe5Bg1ilzBwSFq4kJmIUc=
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
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.21.4 h1:CzTlumWeIbPV5/HVIMzYHNPCRP8uiU/CWiN2gtd/Qu8=
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
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.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.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.19.3 h1:dIoagx6yIQT3V/zOSeAyZ8OqQyEr17YTgETOXTZNJMA=
modernc.org/sqlite v1.19.3/go.mod h1:xiyJD7FY8mTZXnQwE/gEL1STtFrrnDx03V8KhVQmcr8=
modernc.org/sqlite v1.19.4 h1:nlPIDqumn6/mSvs7T5C8MNYEuN73sISzPdKtMdURpUI=
modernc.org/sqlite v1.19.4/go.mod h1:x/yZNb3h5+I3zGQSlwIv4REL5eJhiRkUH5MReogAeIc=
modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY=
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE=
+19 -16
View File
@@ -8,6 +8,7 @@ import (
"github.com/pocketbase/pocketbase/mails/templates"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/rest"
)
@@ -43,29 +44,31 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
mailClient := app.NewMailClient()
// resolve body template
body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody)
if renderErr != nil {
return renderErr
}
message := &mailer.Message{
From: mail.Address{
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: mail.Address{Address: admin.Email},
Subject: "Reset admin password",
HTML: body,
}
event := &core.MailerAdminEvent{
MailClient: mailClient,
Message: message,
Admin: admin,
Meta: map[string]any{"token": token},
}
sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
// resolve body template
body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody)
if renderErr != nil {
return renderErr
}
return e.MailClient.Send(
mail.Address{
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
mail.Address{Address: e.Admin.Email},
"Reset admin password",
body,
nil,
)
return e.MailClient.Send(e.Message)
})
if sendErr == nil {
+2 -2
View File
@@ -30,8 +30,8 @@ func TestSendAdminPasswordReset(t *testing.T) {
"http://localhost:8090/_/#/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody)
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
}
}
}
+56 -57
View File
@@ -7,8 +7,9 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/mails/templates"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/mailer"
)
// SendRecordPasswordReset sends a password reset request email to the specified user.
@@ -20,30 +21,30 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
mailClient := app.NewMailClient()
subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.ResetPasswordTemplate)
if err != nil {
return err
}
message := &mailer.Message{
From: mail.Address{
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: mail.Address{Address: authRecord.Email()},
Subject: subject,
HTML: body,
}
event := &core.MailerRecordEvent{
MailClient: mailClient,
Message: message,
Record: authRecord,
Meta: map[string]any{"token": token},
}
sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
settings := app.Settings()
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ResetPasswordTemplate)
if err != nil {
return err
}
return e.MailClient.Send(
mail.Address{
Name: settings.Meta.SenderName,
Address: settings.Meta.SenderAddress,
},
mail.Address{Address: e.Record.GetString(schema.FieldNameEmail)},
subject,
body,
nil,
)
return e.MailClient.Send(e.Message)
})
if sendErr == nil {
@@ -62,30 +63,30 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
mailClient := app.NewMailClient()
subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.VerificationTemplate)
if err != nil {
return err
}
message := &mailer.Message{
From: mail.Address{
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: mail.Address{Address: authRecord.Email()},
Subject: subject,
HTML: body,
}
event := &core.MailerRecordEvent{
MailClient: mailClient,
Message: message,
Record: authRecord,
Meta: map[string]any{"token": token},
}
sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
settings := app.Settings()
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.VerificationTemplate)
if err != nil {
return err
}
return e.MailClient.Send(
mail.Address{
Name: settings.Meta.SenderName,
Address: settings.Meta.SenderAddress,
},
mail.Address{Address: e.Record.GetString(schema.FieldNameEmail)},
subject,
body,
nil,
)
return e.MailClient.Send(e.Message)
})
if sendErr == nil {
@@ -104,8 +105,24 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
mailClient := app.NewMailClient()
subject, body, err := resolveEmailTemplate(app, token, app.Settings().Meta.ConfirmEmailChangeTemplate)
if err != nil {
return err
}
message := &mailer.Message{
From: mail.Address{
Name: app.Settings().Meta.SenderName,
Address: app.Settings().Meta.SenderAddress,
},
To: mail.Address{Address: newEmail},
Subject: subject,
HTML: body,
}
event := &core.MailerRecordEvent{
MailClient: mailClient,
Message: message,
Record: record,
Meta: map[string]any{
"token": token,
@@ -114,23 +131,7 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
}
sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
settings := app.Settings()
subject, body, err := resolveEmailTemplate(app, token, settings.Meta.ConfirmEmailChangeTemplate)
if err != nil {
return err
}
return e.MailClient.Send(
mail.Address{
Name: settings.Meta.SenderName,
Address: settings.Meta.SenderAddress,
},
mail.Address{Address: newEmail},
subject,
body,
nil,
)
return e.MailClient.Send(e.Message)
})
if sendErr == nil {
@@ -143,13 +144,11 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
func resolveEmailTemplate(
app core.App,
token string,
emailTemplate core.EmailTemplate,
emailTemplate settings.EmailTemplate,
) (subject string, body string, err error) {
settings := app.Settings()
subject, rawBody, _ := emailTemplate.Resolve(
settings.Meta.AppName,
settings.Meta.AppUrl,
app.Settings().Meta.AppName,
app.Settings().Meta.AppUrl,
token,
)
+6 -6
View File
@@ -30,8 +30,8 @@ func TestSendRecordPasswordReset(t *testing.T) {
"http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody)
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
}
}
}
@@ -55,8 +55,8 @@ func TestSendRecordVerification(t *testing.T) {
"http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody)
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
}
}
}
@@ -80,8 +80,8 @@ func TestSendRecordChangeEmail(t *testing.T) {
"http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
}
for _, part := range expectedParts {
if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) {
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody)
if !strings.Contains(testApp.TestMailer.LastMessage.HTML, part) {
t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastMessage.HTML)
}
}
}
-3
View File
@@ -12,13 +12,10 @@ package templates
const AdminPasswordResetBody = `
{{define "content"}}
<p>Hello,</p>
<p>Follow this link to reset your admin password for {{.AppName}}.</p>
<p>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Reset password</a>
</p>
<p><i>If you did not request to reset your password, please ignore this email and the link will expire on its own.</i></p>
{{end}}
`
+16 -11
View File
@@ -31,7 +31,7 @@ type Model interface {
TableName() string
IsNew() bool
MarkAsNew()
UnmarkAsNew()
MarkAsNotNew()
HasId() bool
GetId() string
SetId(id string)
@@ -48,7 +48,7 @@ type Model interface {
// BaseModel defines common fields and methods used by all other models.
type BaseModel struct {
isNewFlag bool
isNotNew bool
Id string `db:"id" json:"id"`
Created types.DateTime `db:"created" json:"created"`
@@ -70,20 +70,20 @@ func (m *BaseModel) SetId(id string) {
m.Id = id
}
// MarkAsNew sets the model isNewFlag enforcing [m.IsNew()] to be true.
// MarkAsNew marks the model as "new" (aka. enforces m.IsNew() to be true).
func (m *BaseModel) MarkAsNew() {
m.isNewFlag = true
m.isNotNew = false
}
// UnmarkAsNew resets the model isNewFlag.
func (m *BaseModel) UnmarkAsNew() {
m.isNewFlag = false
// MarkAsNotNew marks the model as "not new" (aka. enforces m.IsNew() to be false)
func (m *BaseModel) MarkAsNotNew() {
m.isNotNew = true
}
// IsNew indicates what type of db query (insert or update)
// should be used with the model instance.
func (m *BaseModel) IsNew() bool {
return m.isNewFlag || !m.HasId()
return !m.isNotNew
}
// GetCreated returns the model Created datetime.
@@ -100,9 +100,6 @@ func (m *BaseModel) GetUpdated() types.DateTime {
//
// The generated id is a cryptographically random 15 characters length string.
func (m *BaseModel) RefreshId() {
if m.Id == "" { // no previous id
m.MarkAsNew()
}
m.Id = security.RandomStringWithAlphabet(DefaultIdLength, DefaultIdAlphabet)
}
@@ -115,3 +112,11 @@ func (m *BaseModel) RefreshCreated() {
func (m *BaseModel) RefreshUpdated() {
m.Updated = types.NowDateTime()
}
// PostScan implements the [dbx.PostScanner] interface.
//
// It is executed right after the model was populated with the db row values.
func (m *BaseModel) PostScan() error {
m.MarkAsNotNew()
return nil
}
+13 -7
View File
@@ -58,15 +58,19 @@ func TestBaseModelIsNew(t *testing.T) {
m1 := models.BaseModel{Id: ""}
m2 := models.BaseModel{Id: "test"}
m3 := models.BaseModel{}
m3.MarkAsNew()
m3.MarkAsNotNew()
m4 := models.BaseModel{Id: "test"}
m4.MarkAsNew()
m4.MarkAsNotNew()
m5 := models.BaseModel{Id: "test"}
m5.MarkAsNew()
m5.UnmarkAsNew()
// check if MarkAsNew will be called on initial RefreshId()
m5.MarkAsNotNew()
m6 := models.BaseModel{}
m6.RefreshId()
m7 := models.BaseModel{}
m7.MarkAsNotNew()
m7.RefreshId()
m8 := models.BaseModel{}
m8.PostScan()
scenarios := []struct {
model models.BaseModel
@@ -74,11 +78,13 @@ func TestBaseModelIsNew(t *testing.T) {
}{
{m0, true},
{m1, true},
{m2, false},
{m3, true},
{m4, true},
{m2, true},
{m3, false},
{m4, false},
{m5, false},
{m6, true},
{m7, false},
{m8, false},
}
for i, s := range scenarios {
+23
View File
@@ -30,6 +30,8 @@ type Record struct {
ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
data map[string]any // any custom data in addition to the base model fields
expand map[string]any // expanded relations
loaded bool
originalData map[string]any // the original (aka. first loaded) model data
}
// NewRecord initializes a new empty Record model.
@@ -77,6 +79,7 @@ func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap)
record := NewRecord(collection)
record.Load(resultMap)
record.PostScan()
return record
}
@@ -103,6 +106,21 @@ func (m *Record) Collection() *Collection {
return m.collection
}
// OriginalCopy returns a copy of the current record model populated
// with its original (aka. the initially loaded) data state.
func (m *Record) OriginalCopy() *Record {
newRecord := NewRecord(m.collection)
newRecord.Load(m.originalData)
if m.IsNew() {
newRecord.MarkAsNew()
} else {
newRecord.MarkAsNotNew()
}
return newRecord
}
// Expand returns a shallow copy of the record.expand data
// attached to the current Record model.
func (m *Record) Expand() map[string]any {
@@ -270,6 +288,11 @@ func (m *Record) FindFileFieldByFile(filename string) *schema.SchemaField {
// Load bulk loads the provided data into the current Record model.
func (m *Record) Load(data map[string]any) {
if !m.loaded {
m.loaded = true
m.originalData = data
}
for k, v := range data {
m.Set(k, v)
}
+27
View File
@@ -331,6 +331,33 @@ func TestRecordCollection(t *testing.T) {
}
}
func TestRecordOriginalCopy(t *testing.T) {
m := models.NewRecord(&models.Collection{})
m.Load(map[string]any{"f": "123"})
// change the field
m.Set("f", "456")
if v := m.GetString("f"); v != "456" {
t.Fatalf("Expected f to be %q, got %q", "456", v)
}
if v := m.OriginalCopy().GetString("f"); v != "123" {
t.Fatalf("Expected the initial/original f to be %q, got %q", "123", v)
}
// Loading new data shouldn't affect the original state
m.Load(map[string]any{"f": "789"})
if v := m.GetString("f"); v != "789" {
t.Fatalf("Expected f to be %q, got %q", "789", v)
}
if v := m.OriginalCopy().GetString("f"); v != "123" {
t.Fatalf("Expected the initial/original f still to be %q, got %q", "123", v)
}
}
func TestRecordExpand(t *testing.T) {
collection := &models.Collection{}
m := models.NewRecord(collection)
+8 -1
View File
@@ -195,7 +195,14 @@ func (s *Schema) UnmarshalJSON(data []byte) error {
s.AddField(f)
}
return s.InitFieldsOptions()
for _, field := range s.fields {
if err := field.InitOptions(); err != nil {
// ignore the error and remove the invalid field
s.RemoveField(field.Id)
}
}
return nil
}
// Value implements the [driver.Valuer] interface.
+4 -4
View File
@@ -78,7 +78,7 @@ const (
FieldTypeFile string = "file"
FieldTypeRelation string = "relation"
// Deprecated: Will be removed in v0.9!
// Deprecated: Will be removed in v0.9+
FieldTypeUser string = "user"
)
@@ -235,7 +235,7 @@ func (f *SchemaField) InitOptions() error {
case FieldTypeRelation:
options = &RelationOptions{}
// Deprecated: Will be removed in v0.9!
// Deprecated: Will be removed in v0.9+
case FieldTypeUser:
options = &UserOptions{}
@@ -515,13 +515,13 @@ func (o RelationOptions) Validate() error {
// -------------------------------------------------------------------
// Deprecated: Will be removed in v0.9!
// Deprecated: Will be removed in v0.9+
type UserOptions struct {
MaxSelect int `form:"maxSelect" json:"maxSelect"`
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
}
// Deprecated: Will be removed in v0.9!
// Deprecated: Will be removed in v0.9+
func (o UserOptions) Validate() error {
return nil
}
@@ -1,4 +1,4 @@
package core
package settings
import (
"encoding/json"
@@ -30,7 +30,7 @@ type Settings struct {
RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
// Deprecated: Will be removed in v0.9!
// Deprecated: Will be removed in v0.9+
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
@@ -45,8 +45,8 @@ type Settings struct {
TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"`
}
// NewSettings creates and returns a new default Settings instance.
func NewSettings() *Settings {
// New creates and returns a new default Settings instance.
func New() *Settings {
return &Settings{
Meta: MetaConfig{
AppName: "Acme",
@@ -170,11 +170,11 @@ func (s *Settings) Merge(other *Settings) error {
// Clone creates a new deep copy of the current settings.
func (s *Settings) Clone() (*Settings, error) {
settings := &Settings{}
if err := settings.Merge(s); err != nil {
clone := &Settings{}
if err := clone.Merge(s); err != nil {
return nil, err
}
return settings, nil
return clone, nil
}
// RedactClone creates a new deep copy of the current settings,
@@ -476,7 +476,7 @@ func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
// -------------------------------------------------------------------
// Deprecated: Will be removed in v0.9!
// Deprecated: Will be removed in v0.9+
type EmailAuthConfig struct {
Enabled bool `form:"enabled" json:"enabled"`
ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
@@ -484,7 +484,7 @@ type EmailAuthConfig struct {
MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
}
// Deprecated: Will be removed in v0.9!
// Deprecated: Will be removed in v0.9+
func (c EmailAuthConfig) Validate() error {
return nil
}
@@ -1,4 +1,4 @@
package core
package settings
// Common settings placeholder tokens
const (
@@ -1,4 +1,4 @@
package core_test
package settings_test
import (
"encoding/json"
@@ -7,12 +7,12 @@ import (
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models/settings"
"github.com/pocketbase/pocketbase/tools/auth"
)
func TestSettingsValidate(t *testing.T) {
s := core.NewSettings()
s := settings.New()
// set invalid settings data
s.Meta.AppName = ""
@@ -87,10 +87,10 @@ func TestSettingsValidate(t *testing.T) {
}
func TestSettingsMerge(t *testing.T) {
s1 := core.NewSettings()
s1 := settings.New()
s1.Meta.AppUrl = "old_app_url"
s2 := core.NewSettings()
s2 := settings.New()
s2.Meta.AppName = "test"
s2.Logs.MaxDays = 123
s2.Smtp.Host = "test"
@@ -144,7 +144,7 @@ func TestSettingsMerge(t *testing.T) {
}
func TestSettingsClone(t *testing.T) {
s1 := core.NewSettings()
s1 := settings.New()
s2, err := s1.Clone()
if err != nil {
@@ -173,7 +173,7 @@ func TestSettingsClone(t *testing.T) {
}
func TestSettingsRedactClone(t *testing.T) {
s1 := core.NewSettings()
s1 := settings.New()
s1.Meta.AppName = "test123" // control field
s1.Smtp.Password = "test123"
s1.Smtp.Tls = true
@@ -213,7 +213,7 @@ func TestSettingsRedactClone(t *testing.T) {
}
func TestNamedAuthProviderConfigs(t *testing.T) {
s := core.NewSettings()
s := settings.New()
s.GoogleAuth.ClientId = "google_test"
s.FacebookAuth.ClientId = "facebook_test"
@@ -256,17 +256,17 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
func TestTokenConfigValidate(t *testing.T) {
scenarios := []struct {
config core.TokenConfig
config settings.TokenConfig
expectError bool
}{
// zero values
{
core.TokenConfig{},
settings.TokenConfig{},
true,
},
// invalid data
{
core.TokenConfig{
settings.TokenConfig{
Secret: strings.Repeat("a", 5),
Duration: 4,
},
@@ -274,7 +274,7 @@ func TestTokenConfigValidate(t *testing.T) {
},
// valid secret but invalid duration
{
core.TokenConfig{
settings.TokenConfig{
Secret: strings.Repeat("a", 30),
Duration: 63072000 + 1,
},
@@ -282,7 +282,7 @@ func TestTokenConfigValidate(t *testing.T) {
},
// valid data
{
core.TokenConfig{
settings.TokenConfig{
Secret: strings.Repeat("a", 30),
Duration: 100,
},
@@ -305,22 +305,22 @@ func TestTokenConfigValidate(t *testing.T) {
func TestSmtpConfigValidate(t *testing.T) {
scenarios := []struct {
config core.SmtpConfig
config settings.SmtpConfig
expectError bool
}{
// zero values (disabled)
{
core.SmtpConfig{},
settings.SmtpConfig{},
false,
},
// zero values (enabled)
{
core.SmtpConfig{Enabled: true},
settings.SmtpConfig{Enabled: true},
true,
},
// invalid data
{
core.SmtpConfig{
settings.SmtpConfig{
Enabled: true,
Host: "test:test:test",
Port: -10,
@@ -329,7 +329,7 @@ func TestSmtpConfigValidate(t *testing.T) {
},
// valid data
{
core.SmtpConfig{
settings.SmtpConfig{
Enabled: true,
Host: "example.com",
Port: 100,
@@ -354,22 +354,22 @@ func TestSmtpConfigValidate(t *testing.T) {
func TestS3ConfigValidate(t *testing.T) {
scenarios := []struct {
config core.S3Config
config settings.S3Config
expectError bool
}{
// zero values (disabled)
{
core.S3Config{},
settings.S3Config{},
false,
},
// zero values (enabled)
{
core.S3Config{Enabled: true},
settings.S3Config{Enabled: true},
true,
},
// invalid data
{
core.S3Config{
settings.S3Config{
Enabled: true,
Endpoint: "test:test:test",
},
@@ -377,7 +377,7 @@ func TestS3ConfigValidate(t *testing.T) {
},
// valid data (url endpoint)
{
core.S3Config{
settings.S3Config{
Enabled: true,
Endpoint: "https://localhost:8090",
Bucket: "test",
@@ -389,7 +389,7 @@ func TestS3ConfigValidate(t *testing.T) {
},
// valid data (hostname endpoint)
{
core.S3Config{
settings.S3Config{
Enabled: true,
Endpoint: "example.com",
Bucket: "test",
@@ -415,36 +415,36 @@ func TestS3ConfigValidate(t *testing.T) {
}
func TestMetaConfigValidate(t *testing.T) {
invalidTemplate := core.EmailTemplate{
invalidTemplate := settings.EmailTemplate{
Subject: "test",
ActionUrl: "test",
Body: "test",
}
noPlaceholdersTemplate := core.EmailTemplate{
noPlaceholdersTemplate := settings.EmailTemplate{
Subject: "test",
ActionUrl: "http://example.com",
Body: "test",
}
withPlaceholdersTemplate := core.EmailTemplate{
withPlaceholdersTemplate := settings.EmailTemplate{
Subject: "test",
ActionUrl: "http://example.com" + core.EmailPlaceholderToken,
Body: "test" + core.EmailPlaceholderActionUrl,
ActionUrl: "http://example.com" + settings.EmailPlaceholderToken,
Body: "test" + settings.EmailPlaceholderActionUrl,
}
scenarios := []struct {
config core.MetaConfig
config settings.MetaConfig
expectError bool
}{
// zero values
{
core.MetaConfig{},
settings.MetaConfig{},
true,
},
// invalid data
{
core.MetaConfig{
settings.MetaConfig{
AppName: strings.Repeat("a", 300),
AppUrl: "test",
SenderName: strings.Repeat("a", 300),
@@ -457,7 +457,7 @@ func TestMetaConfigValidate(t *testing.T) {
},
// invalid data (missing required placeholders)
{
core.MetaConfig{
settings.MetaConfig{
AppName: "test",
AppUrl: "https://example.com",
SenderName: "test",
@@ -470,7 +470,7 @@ func TestMetaConfigValidate(t *testing.T) {
},
// valid data
{
core.MetaConfig{
settings.MetaConfig{
AppName: "test",
AppUrl: "https://example.com",
SenderName: "test",
@@ -498,17 +498,17 @@ func TestMetaConfigValidate(t *testing.T) {
func TestEmailTemplateValidate(t *testing.T) {
scenarios := []struct {
emailTemplate core.EmailTemplate
emailTemplate settings.EmailTemplate
expectedErrors []string
}{
// require values
{
core.EmailTemplate{},
settings.EmailTemplate{},
[]string{"subject", "actionUrl", "body"},
},
// missing placeholders
{
core.EmailTemplate{
settings.EmailTemplate{
Subject: "test",
ActionUrl: "test",
Body: "test",
@@ -517,10 +517,10 @@ func TestEmailTemplateValidate(t *testing.T) {
},
// valid data
{
core.EmailTemplate{
settings.EmailTemplate{
Subject: "test",
ActionUrl: "test" + core.EmailPlaceholderToken,
Body: "test" + core.EmailPlaceholderActionUrl,
ActionUrl: "test" + settings.EmailPlaceholderToken,
Body: "test" + settings.EmailPlaceholderActionUrl,
},
[]string{},
},
@@ -549,17 +549,17 @@ func TestEmailTemplateValidate(t *testing.T) {
}
func TestEmailTemplateResolve(t *testing.T) {
allPlaceholders := core.EmailPlaceholderActionUrl + core.EmailPlaceholderToken + core.EmailPlaceholderAppName + core.EmailPlaceholderAppUrl
allPlaceholders := settings.EmailPlaceholderActionUrl + settings.EmailPlaceholderToken + settings.EmailPlaceholderAppName + settings.EmailPlaceholderAppUrl
scenarios := []struct {
emailTemplate core.EmailTemplate
emailTemplate settings.EmailTemplate
expectedSubject string
expectedBody string
expectedActionUrl string
}{
// no placeholders
{
emailTemplate: core.EmailTemplate{
emailTemplate: settings.EmailTemplate{
Subject: "subject:",
Body: "body:",
ActionUrl: "/actionUrl////",
@@ -570,7 +570,7 @@ func TestEmailTemplateResolve(t *testing.T) {
},
// with placeholders
{
emailTemplate: core.EmailTemplate{
emailTemplate: settings.EmailTemplate{
ActionUrl: "/actionUrl////" + allPlaceholders,
Subject: "subject:" + allPlaceholders,
Body: "body:" + allPlaceholders,
@@ -583,8 +583,8 @@ func TestEmailTemplateResolve(t *testing.T) {
),
expectedSubject: fmt.Sprintf(
"subject:%s%s%s%s",
core.EmailPlaceholderActionUrl,
core.EmailPlaceholderToken,
settings.EmailPlaceholderActionUrl,
settings.EmailPlaceholderToken,
"name_test",
"url_test",
),
@@ -622,22 +622,22 @@ func TestEmailTemplateResolve(t *testing.T) {
func TestLogsConfigValidate(t *testing.T) {
scenarios := []struct {
config core.LogsConfig
config settings.LogsConfig
expectError bool
}{
// zero values
{
core.LogsConfig{},
settings.LogsConfig{},
false,
},
// invalid data
{
core.LogsConfig{MaxDays: -10},
settings.LogsConfig{MaxDays: -10},
true,
},
// valid data
{
core.LogsConfig{MaxDays: 1},
settings.LogsConfig{MaxDays: 1},
false,
},
}
@@ -657,22 +657,22 @@ func TestLogsConfigValidate(t *testing.T) {
func TestAuthProviderConfigValidate(t *testing.T) {
scenarios := []struct {
config core.AuthProviderConfig
config settings.AuthProviderConfig
expectError bool
}{
// zero values (disabled)
{
core.AuthProviderConfig{},
settings.AuthProviderConfig{},
false,
},
// zero values (enabled)
{
core.AuthProviderConfig{Enabled: true},
settings.AuthProviderConfig{Enabled: true},
true,
},
// invalid data
{
core.AuthProviderConfig{
settings.AuthProviderConfig{
Enabled: true,
ClientId: "",
ClientSecret: "",
@@ -684,7 +684,7 @@ func TestAuthProviderConfigValidate(t *testing.T) {
},
// valid data (only the required)
{
core.AuthProviderConfig{
settings.AuthProviderConfig{
Enabled: true,
ClientId: "test",
ClientSecret: "test",
@@ -693,7 +693,7 @@ func TestAuthProviderConfigValidate(t *testing.T) {
},
// valid data (fill all fields)
{
core.AuthProviderConfig{
settings.AuthProviderConfig{
Enabled: true,
ClientId: "test",
ClientSecret: "test",
@@ -722,12 +722,12 @@ func TestAuthProviderConfigSetupProvider(t *testing.T) {
provider := auth.NewGithubProvider()
// disabled config
c1 := core.AuthProviderConfig{Enabled: false}
c1 := settings.AuthProviderConfig{Enabled: false}
if err := c1.SetupProvider(provider); err == nil {
t.Errorf("Expected error, got nil")
}
c2 := core.AuthProviderConfig{
c2 := settings.AuthProviderConfig{
Enabled: true,
ClientId: "test_ClientId",
ClientSecret: "test_ClientSecret",
+111
View File
@@ -0,0 +1,111 @@
package jsvm
import (
"os"
"path/filepath"
"github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/require"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
// MigrationsOptions defines optional struct to customize the default migrations loader behavior.
type MigrationsOptions 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
// the provided app instance and panics if it fails.
//
// Internally it calls RegisterMigrations(app, options).
//
// 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 {
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}
if options != nil {
l.options = options
} else {
l.options = &MigrationsOptions{}
}
if l.options.Dir == "" {
l.options.Dir = filepath.Join(app.DataDir(), "../pb_migrations")
}
files, err := readDirFiles(l.options.Dir)
if err != nil {
return err
}
registry := new(require.Registry) // this can be shared by multiple runtimes
for file, content := range files {
vm := NewBaseVM()
registry.Enable(vm)
console.Enable(vm)
vm.Set("migrate", func(up, down func(db dbx.Builder) error) {
m.AppMigrations.Register(up, down, file)
})
_, err := vm.RunString(string(content))
if err != nil {
return err
}
}
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() {
continue
}
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
if err != nil {
return nil, err
}
result[f.Name()] = raw
}
return result, nil
}
+206
View File
@@ -0,0 +1,206 @@
// Package jsvm implements optional utilities for binding a JS goja runtime
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.).
//
// Currently it provides the following plugins:
//
// 1. JS Migrations loader:
//
// jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
// Dir: "custom_js_migrations_dir_path", // default to "pb_data/../pb_migrations"
// })
package jsvm
import (
"encoding/json"
"reflect"
"strings"
"unicode"
"github.com/dop251/goja"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
)
func NewBaseVM() *goja.Runtime {
vm := goja.New()
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 {
return nil, err
}
if err := json.Unmarshal(raw, &dest); err != nil {
return nil, err
}
return dest, nil
})
vm.Set("Record", func(call goja.ConstructorCall) *goja.Object {
var instance *models.Record
collection, ok := call.Argument(0).Export().(*models.Collection)
if ok {
instance = models.NewRecord(collection)
data, ok := call.Argument(1).Export().(map[string]any)
if ok {
if raw, err := json.Marshal(data); err == nil {
json.Unmarshal(raw, instance)
}
}
} else {
instance = &models.Record{}
}
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
vm.Set("Collection", func(call goja.ConstructorCall) *goja.Object {
instance := &models.Collection{}
return defaultConstructor(vm, call, instance)
})
vm.Set("Admin", func(call goja.ConstructorCall) *goja.Object {
instance := &models.Admin{}
return defaultConstructor(vm, call, instance)
})
vm.Set("Schema", func(call goja.ConstructorCall) *goja.Object {
instance := &schema.Schema{}
return defaultConstructor(vm, call, instance)
})
vm.Set("SchemaField", func(call goja.ConstructorCall) *goja.Object {
instance := &schema.SchemaField{}
return defaultConstructor(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")
}
instance := daos.New(db)
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)
}
}
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
}
func dbxBinds(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$dbx", obj)
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
})
obj.Set("not", dbx.Not)
obj.Set("and", dbx.And)
obj.Set("or", dbx.Or)
obj.Set("in", dbx.In)
obj.Set("notIn", dbx.NotIn)
obj.Set("like", dbx.Like)
obj.Set("orLike", dbx.OrLike)
obj.Set("notLike", dbx.NotLike)
obj.Set("orNotLike", dbx.OrNotLike)
obj.Set("exists", dbx.Exists)
obj.Set("notExists", dbx.NotExists)
obj.Set("between", dbx.Between)
obj.Set("notBetween", dbx.NotBetween)
}
func apisBind(vm *goja.Runtime) {
obj := vm.NewObject()
vm.Set("$apis", obj)
// middlewares
obj.Set("requireRecordAuth", apis.RequireRecordAuth)
obj.Set("requireRecordAuth", apis.RequireRecordAuth)
obj.Set("requireSameContextRecordAuth", apis.RequireSameContextRecordAuth)
obj.Set("requireAdminAuth", apis.RequireAdminAuth)
obj.Set("requireAdminAuthOnlyIfAny", apis.RequireAdminAuthOnlyIfAny)
obj.Set("requireAdminOrRecordAuth", apis.RequireAdminOrRecordAuth)
obj.Set("requireAdminOrOwnerAuth", apis.RequireAdminOrOwnerAuth)
obj.Set("activityLogger", apis.ActivityLogger)
// api errors
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 {
}
// 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:]
}
+268
View File
@@ -0,0 +1,268 @@
package jsvm_test
import (
"reflect"
"testing"
"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"
)
func TestBaseVMUnmarshal(t *testing.T) {
vm := jsvm.NewBaseVM()
v, err := vm.RunString(`unmarshal({ name: "test" }, new Collection())`)
if err != nil {
t.Fatal(err)
}
m, ok := v.Export().(*models.Collection)
if !ok {
t.Fatalf("Expected models.Collection, got %v", m)
}
if m.Name != "test" {
t.Fatalf("Expected collection with name %q, got %q", "test", m.Name)
}
}
func TestBaseVMRecordBind(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)
// without record data
// ---
v1, err := vm.RunString(`new Record(collection)`)
if err != nil {
t.Fatal(err)
}
m1, ok := v1.Export().(*models.Record)
if !ok {
t.Fatalf("Expected m1 to be models.Record, got \n%v", m1)
}
// with record data
// ---
v2, err := vm.RunString(`new Record(collection, { email: "test@example.com" })`)
if err != nil {
t.Fatal(err)
}
m2, ok := v2.Export().(*models.Record)
if !ok {
t.Fatalf("Expected m2 to be models.Record, got \n%v", m2)
}
if m2.Collection().Name != "users" {
t.Fatalf("Expected record with collection %q, got \n%v", "users", m2.Collection())
}
if m2.Email() != "test@example.com" {
t.Fatalf("Expected record with email field set to %q, got \n%v", "test@example.com", m2)
}
}
// @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()
v, err := vm.RunString(`new Collection({ name: "test", schema: [{name: "title", "type": "text"}] })`)
if err != nil {
t.Fatal(err)
}
m, ok := v.Export().(*models.Collection)
if !ok {
t.Fatalf("Expected models.Collection, got %v", m)
}
if m.Name != "test" {
t.Fatalf("Expected collection with name %q, got %q", "test", m.Name)
}
if f := m.Schema.GetFieldByName("title"); f == nil {
t.Fatalf("Expected schema to be set, got %v", m.Schema)
}
}
func TestBaseVMAdminBind(t *testing.T) {
vm := jsvm.NewBaseVM()
v, err := vm.RunString(`new Admin({ email: "test@example.com" })`)
if err != nil {
t.Fatal(err)
}
m, ok := v.Export().(*models.Admin)
if !ok {
t.Fatalf("Expected models.Admin, got %v", m)
}
}
func TestBaseVMSchemaBind(t *testing.T) {
vm := jsvm.NewBaseVM()
v, err := vm.RunString(`new Schema([{name: "title", "type": "text"}])`)
if err != nil {
t.Fatal(err)
}
m, ok := v.Export().(*schema.Schema)
if !ok {
t.Fatalf("Expected schema.Schema, got %v", m)
}
if f := m.GetFieldByName("title"); f == nil {
t.Fatalf("Expected schema fields to be loaded, got %v", m.Fields())
}
}
func TestBaseVMSchemaFieldBind(t *testing.T) {
vm := jsvm.NewBaseVM()
v, err := vm.RunString(`new SchemaField({name: "title", "type": "text"})`)
if err != nil {
t.Fatal(err)
}
f, ok := v.Export().(*schema.SchemaField)
if !ok {
t.Fatalf("Expected schema.SchemaField, got %v", f)
}
if f.Name != "title" {
t.Fatalf("Expected field %q, got %v", "title", f)
}
}
func TestBaseVMDaoBind(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := jsvm.NewBaseVM()
vm.Set("db", app.DB())
v, err := vm.RunString(`new Dao(db)`)
if err != nil {
t.Fatal(err)
}
d, ok := v.Export().(*daos.Dao)
if !ok {
t.Fatalf("Expected daos.Dao, got %v", d)
}
if d.DB() != app.DB() {
t.Fatalf("The db instances doesn't match")
}
}
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)
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package migratecmd
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/migrate"
)
const collectionsCacheKey = "migratecmd_collections"
// onCollectionChange handles the automigration snapshot generation on
// collection change event (create/update/delete).
func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error {
return func(e *core.ModelEvent) error {
if e.Model.TableName() != "_collections" {
return nil // not a collection
}
// @todo replace with the OldModel when added to the ModelEvent
oldCollections, err := p.getCachedCollections()
if err != nil {
return err
}
old := oldCollections[e.Model.GetId()]
new, err := p.app.Dao().FindCollectionByNameOrId(e.Model.GetId())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
var template string
var templateErr error
if p.options.TemplateLang == TemplateLangJS {
template, templateErr = p.jsDiffTemplate(new, old)
} else {
template, templateErr = p.goDiffTemplate(new, old)
}
if templateErr != nil {
if errors.Is(templateErr, emptyTemplateErr) {
return nil // no changes
}
return fmt.Errorf("failed to resolve template: %w", templateErr)
}
var action string
switch {
case new == nil:
action = "deleted_" + old.Name
case old == nil:
action = "created_" + new.Name
default:
action = "updated_" + old.Name
}
appliedTime := time.Now().Unix()
name := fmt.Sprintf("%d_%s.%s", appliedTime, action, p.options.TemplateLang)
filePath := filepath.Join(p.options.Dir, name)
return p.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
// insert the migration entry
_, err := txDao.DB().Insert(migrate.DefaultMigrationsTable, dbx.Params{
"file": name,
"applied": appliedTime,
}).Execute()
if err != nil {
return err
}
// ensure that the local migrations dir exist
if err := os.MkdirAll(p.options.Dir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create migration dir: %w", err)
}
if err := os.WriteFile(filePath, []byte(template), 0644); err != nil {
return fmt.Errorf("failed to save automigrate file: %w", err)
}
p.updateSingleCachedCollection(new, old)
return nil
})
}
}
func (p *plugin) updateSingleCachedCollection(new, old *models.Collection) {
cached, _ := p.app.Cache().Get(collectionsCacheKey).(map[string]*models.Collection)
switch {
case new == nil:
delete(cached, old.Id)
default:
cached[new.Id] = new
}
p.app.Cache().Set(collectionsCacheKey, cached)
}
func (p *plugin) refreshCachedCollections() error {
if p.app.Dao() == nil {
return errors.New("app is not initialized yet")
}
var collections []*models.Collection
if err := p.app.Dao().CollectionQuery().All(&collections); err != nil {
return err
}
cached := map[string]*models.Collection{}
for _, c := range collections {
cached[c.Id] = c
}
p.app.Cache().Set(collectionsCacheKey, cached)
return nil
}
func (p *plugin) getCachedCollections() (map[string]*models.Collection, error) {
if !p.app.Cache().Has(collectionsCacheKey) {
if err := p.refreshCachedCollections(); err != nil {
return nil, err
}
}
result, _ := p.app.Cache().Get(collectionsCacheKey).(map[string]*models.Collection)
return result, nil
}
func (p *plugin) hasCustomMigrations() bool {
files, err := os.ReadDir(p.options.Dir)
if err != nil {
return false
}
for _, f := range files {
if f.IsDir() {
continue
}
return true
}
return false
}
+240
View File
@@ -0,0 +1,240 @@
// Package migratecmd adds a new "migrate" command support to a PocketBase instance.
//
// It also comes with automigrations support and templates generation
// (both for JS and GO migration files).
//
// Example usage:
//
// migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
// 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)
// })
//
// Note: To allow running JS migrations you'll need to enable first
// [jsvm.MustRegisterMigrations].
package migratecmd
import (
"fmt"
"log"
"os"
"path"
"path/filepath"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
)
// Options defines optional struct to customize the default plugin behavior.
type Options struct {
// Dir specifies the directory with the user defined migrations.
//
// If not set it fallbacks to a relative "pb_data/../pb_migrations" (for js)
// or "pb_data/../migrations" (for go) directory.
Dir string
// Automigrate specifies whether to enable automigrations.
Automigrate bool
// TemplateLang specifies the template language to use when
// generating migrations - js or go (default).
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 {
panic(err)
}
}
func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
p := &plugin{app: app}
if options != nil {
p.options = options
} else {
p.options = &Options{}
}
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")
} else {
p.options.Dir = filepath.Join(p.app.DataDir(), "../migrations")
}
}
// attach the migrate command
if rootCmd != nil {
rootCmd.AddCommand(p.createCommand())
}
// watch for collection changes
if p.options.Automigrate {
// refresh the cache right after app bootstap
p.app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
p.refreshCachedCollections()
return nil
})
// refresh the cache to ensure that it constains the latest changes
// when migrations are applied on server start
p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
p.refreshCachedCollections()
cachedCollections, _ := p.getCachedCollections()
// create a full initial snapshot, if there are no custom
// migrations but there is already at least 1 collection created,
// to ensure that the automigrate will work with up-to-date collections data
if !p.hasCustomMigrations() && len(cachedCollections) > 1 {
p.migrateCollectionsHandler(nil, false)
}
return nil
})
p.app.OnModelAfterCreate().Add(p.afterCollectionChange())
p.app.OnModelAfterUpdate().Add(p.afterCollectionChange())
p.app.OnModelAfterDelete().Add(p.afterCollectionChange())
}
return nil
}
func (p *plugin) createCommand() *cobra.Command {
const cmdDesc = `Supported arguments are:
- up - runs all available migrations
- down [number] - reverts the last [number] applied migrations
- create name - creates new blank migration template file
- collections - creates new migration file with snapshot of the local collections configuration
`
command := &cobra.Command{
Use: "migrate",
Short: "Executes app DB migration scripts",
ValidArgs: []string{"up", "down", "create", "collections"},
Long: cmdDesc,
Run: func(command *cobra.Command, args []string) {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
switch cmd {
case "create":
if err := p.migrateCreateHandler("", args[1:], true); err != nil {
log.Fatal(err)
}
case "collections":
if err := p.migrateCollectionsHandler(args[1:], true); err != nil {
log.Fatal(err)
}
default:
runner, err := migrate.NewRunner(p.app.DB(), migrations.AppMigrations)
if err != nil {
log.Fatal(err)
}
if err := runner.Run(args...); err != nil {
log.Fatal(err)
}
}
},
}
return command
}
func (p *plugin) migrateCreateHandler(template string, args []string, interactive bool) error {
if len(args) < 1 {
return fmt.Errorf("Missing migration file name")
}
name := args[0]
dir := p.options.Dir
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.%s", time.Now().Unix(), inflector.Snakecase(name), p.options.TemplateLang),
)
if interactive {
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
}
}
// get default create template
if template == "" {
var templateErr error
if p.options.TemplateLang == TemplateLangJS {
template, templateErr = p.jsBlankTemplate()
} else {
template, templateErr = p.goBlankTemplate()
}
if templateErr != nil {
return fmt.Errorf("Failed to resolve create template: %v\n", templateErr)
}
}
// ensure that the migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
// save the migration file
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q: %v\n", resultFilePath, err)
}
if interactive {
fmt.Printf("Successfully created file %q\n", resultFilePath)
}
return nil
}
func (p *plugin) migrateCollectionsHandler(args []string, interactive bool) error {
createArgs := []string{"collections_snapshot"}
createArgs = append(createArgs, args...)
collections := []*models.Collection{}
if err := p.app.Dao().CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("Failed to fetch migrations list: %v", err)
}
var template string
var templateErr error
if p.options.TemplateLang == TemplateLangJS {
template, templateErr = p.jsSnapshotTemplate(collections)
} else {
template, templateErr = p.goSnapshotTemplate(collections)
}
if templateErr != nil {
return fmt.Errorf("Failed to resolve template: %v", templateErr)
}
return p.migrateCreateHandler(template, createArgs, interactive)
}
+746
View File
@@ -0,0 +1,746 @@
package migratecmd_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAutomigrateCollectionCreate(t *testing.T) {
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
migrate((db) => {
const collection = new Collection({
"id": "new_id",
"created": "2022-01-01 00:00:00.000Z",
"updated": "2022-01-01 00:00:00.000Z",
"name": "new_name",
"type": "auth",
"system": true,
"schema": [],
"listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0",
"viewRule": "id = \"1\"",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": "created > 0",
"minPasswordLength": 20,
"onlyEmailDomains": null,
"requireEmail": false
}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("new_id");
return dao.deleteCollection(collection);
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := ` + "`" + `{
"id": "new_id",
"created": "2022-01-01 00:00:00.000Z",
"updated": "2022-01-01 00:00:00.000Z",
"name": "new_name",
"type": "auth",
"system": true,
"schema": [],
"listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `",
"viewRule": "id = \"1\"",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": "created > 0",
"minPasswordLength": 20,
"onlyEmailDomains": null,
"requireEmail": false
}
}` + "`" + `
collection := &models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("new_id")
if err != nil {
return err
}
return dao.DeleteCollection(collection)
})
}
`,
},
}
for i, s := range scenarios {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
migratecmd.MustRegister(app, nil, &migratecmd.Options{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
// @todo remove after collections cache is replaced
app.Bootstrap()
collection := &models.Collection{}
collection.Id = "new_id"
collection.Name = "new_name"
collection.Type = models.CollectionTypeAuth
collection.System = true
collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z")
collection.Updated = collection.Created
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.SetOptions(models.CollectionAuthOptions{
ManageRule: types.Pointer("created > 0"),
MinPasswordLength: 20,
})
collection.MarkAsNew()
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatalf("[%d] Failed to save collection, got %v", i, err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err)
}
if total := len(files); total != 1 {
t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total)
}
expectedName := "_created_new_name." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("[%d] Expected filename to contains %q, got %q", i, expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err)
}
if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) {
t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v)
}
}
}
func TestAutomigrateCollectionDelete(t *testing.T) {
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
migrate((db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("test123");
return dao.deleteCollection(collection);
}, (db) => {
const collection = new Collection({
"id": "test123",
"created": "2022-01-01 00:00:00.000Z",
"updated": "2022-01-01 00:00:00.000Z",
"name": "test456",
"type": "auth",
"system": false,
"schema": [],
"listRule": "@request.auth.id != '' && created > 0 || 'backtick` + "`" + `test' = 0",
"viewRule": "id = \"1\"",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": "created > 0",
"minPasswordLength": 20,
"onlyEmailDomains": null,
"requireEmail": false
}
});
return Dao(db).saveCollection(collection);
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("test123")
if err != nil {
return err
}
return dao.DeleteCollection(collection)
}, func(db dbx.Builder) error {
jsonData := ` + "`" + `{
"id": "test123",
"created": "2022-01-01 00:00:00.000Z",
"updated": "2022-01-01 00:00:00.000Z",
"name": "test456",
"type": "auth",
"system": false,
"schema": [],
"listRule": "@request.auth.id != '' && created > 0 || ` + "'backtick` + \"`\" + `test' = 0" + `",
"viewRule": "id = \"1\"",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": "created > 0",
"minPasswordLength": 20,
"onlyEmailDomains": null,
"requireEmail": false
}
}` + "`" + `
collection := &models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return daos.New(db).SaveCollection(collection)
})
}
`,
},
}
for i, s := range scenarios {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
migratecmd.MustRegister(app, nil, &migratecmd.Options{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
// create dummy collection
collection := &models.Collection{}
collection.Id = "test123"
collection.Name = "test456"
collection.Type = models.CollectionTypeAuth
collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z")
collection.Updated = collection.Created
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0 || 'backtick`test' = 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.SetOptions(models.CollectionAuthOptions{
ManageRule: types.Pointer("created > 0"),
MinPasswordLength: 20,
})
collection.MarkAsNew()
// use different dao to avoid triggering automigrate while saving the dummy collection
if err := daos.New(app.DB()).SaveCollection(collection); err != nil {
t.Fatalf("[%d] Failed to save dummy collection, got %v", i, err)
}
// @todo remove after collections cache is replaced
app.Bootstrap()
// delete the newly created dummy collection
if err := app.Dao().DeleteCollection(collection); err != nil {
t.Fatalf("[%d] Failed to delete dummy collection, got %v", i, err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err)
}
if total := len(files); total != 1 {
t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total)
}
expectedName := "_deleted_test456." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("[%d] Expected filename to contains %q, got %q", i, expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err)
}
if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) {
t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v)
}
}
}
func TestAutomigrateCollectionUpdate(t *testing.T) {
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("test123")
collection.name = "test456_update"
collection.type = "base"
collection.listRule = null
collection.deleteRule = "updated > 0 && @request.auth.id != ''"
collection.options = {}
// remove
collection.schema.removeField("f3_id")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "f4_id",
"name": "f4_name",
"type": "text",
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": "` + "`" + `test backtick` + "`" + `123"
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "f2_id",
"name": "f2_name_new",
"type": "number",
"required": false,
"unique": true,
"options": {
"min": 10,
"max": null
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("test123")
collection.name = "test456"
collection.type = "auth"
collection.listRule = "@request.auth.id != '' && created > 0"
collection.deleteRule = null
collection.options = {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": "created > 0",
"minPasswordLength": 20,
"onlyEmailDomains": null,
"requireEmail": false
}
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "f3_id",
"name": "f3_name",
"type": "bool",
"required": false,
"unique": false,
"options": {}
}))
// remove
collection.schema.removeField("f4_id")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "f2_id",
"name": "f2_name",
"type": "number",
"required": false,
"unique": true,
"options": {
"min": 10,
"max": null
}
}))
return dao.saveCollection(collection)
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/types"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("test123")
if err != nil {
return err
}
collection.Name = "test456_update"
collection.Type = "base"
collection.ListRule = nil
collection.DeleteRule = types.Pointer("updated > 0 && @request.auth.id != ''")
options := map[string]any{}
json.Unmarshal([]byte(` + "`" + `{}` + "`" + `), &options)
collection.SetOptions(options)
// remove
collection.Schema.RemoveField("f3_id")
// add
new_f4_name := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
"system": false,
"id": "f4_id",
"name": "f4_name",
"type": "text",
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ` + "\"` + \"`\" + `test backtick` + \"`\" + `123\"" + `
}
}` + "`" + `), new_f4_name)
collection.Schema.AddField(new_f4_name)
// update
edit_f2_name_new := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
"system": false,
"id": "f2_id",
"name": "f2_name_new",
"type": "number",
"required": false,
"unique": true,
"options": {
"min": 10,
"max": null
}
}` + "`" + `), edit_f2_name_new)
collection.Schema.AddField(edit_f2_name_new)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("test123")
if err != nil {
return err
}
collection.Name = "test456"
collection.Type = "auth"
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0")
collection.DeleteRule = nil
options := map[string]any{}
json.Unmarshal([]byte(` + "`" + `{
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": null,
"manageRule": "created > 0",
"minPasswordLength": 20,
"onlyEmailDomains": null,
"requireEmail": false
}` + "`" + `), &options)
collection.SetOptions(options)
// add
del_f3_name := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
"system": false,
"id": "f3_id",
"name": "f3_name",
"type": "bool",
"required": false,
"unique": false,
"options": {}
}` + "`" + `), del_f3_name)
collection.Schema.AddField(del_f3_name)
// remove
collection.Schema.RemoveField("f4_id")
// update
edit_f2_name_new := &schema.SchemaField{}
json.Unmarshal([]byte(` + "`" + `{
"system": false,
"id": "f2_id",
"name": "f2_name",
"type": "number",
"required": false,
"unique": true,
"options": {
"min": 10,
"max": null
}
}` + "`" + `), edit_f2_name_new)
collection.Schema.AddField(edit_f2_name_new)
return dao.SaveCollection(collection)
})
}
`,
},
}
for i, s := range scenarios {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
migratecmd.MustRegister(app, nil, &migratecmd.Options{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
// create dummy collection
collection := &models.Collection{}
collection.Id = "test123"
collection.Name = "test456"
collection.Type = models.CollectionTypeAuth
collection.Created, _ = types.ParseDateTime("2022-01-01 00:00:00.000Z")
collection.Updated = collection.Created
collection.ListRule = types.Pointer("@request.auth.id != '' && created > 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.SetOptions(models.CollectionAuthOptions{
ManageRule: types.Pointer("created > 0"),
MinPasswordLength: 20,
})
collection.MarkAsNew()
collection.Schema.AddField(&schema.SchemaField{
Id: "f1_id",
Name: "f1_name",
Type: schema.FieldTypeText,
Required: true,
})
collection.Schema.AddField(&schema.SchemaField{
Id: "f2_id",
Name: "f2_name",
Type: schema.FieldTypeNumber,
Unique: true,
Options: &schema.NumberOptions{
Min: types.Pointer(10.0),
},
})
collection.Schema.AddField(&schema.SchemaField{
Id: "f3_id",
Name: "f3_name",
Type: schema.FieldTypeBool,
})
// use different dao to avoid triggering automigrate while saving the dummy collection
if err := daos.New(app.DB()).SaveCollection(collection); err != nil {
t.Fatalf("[%d] Failed to save dummy collection, got %v", i, err)
}
// @todo remove after collections cache is replaced
app.Bootstrap()
collection.Name = "test456_update"
collection.Type = models.CollectionTypeBase
collection.DeleteRule = types.Pointer(`updated > 0 && @request.auth.id != ''`)
collection.ListRule = nil
collection.NormalizeOptions()
collection.Schema.RemoveField("f3_id")
collection.Schema.AddField(&schema.SchemaField{
Id: "f4_id",
Name: "f4_name",
Type: schema.FieldTypeText,
Options: &schema.TextOptions{
Pattern: "`test backtick`123",
},
})
f := collection.Schema.GetFieldById("f2_id")
f.Name = "f2_name_new"
// save the changes and trigger automigrate
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatalf("[%d] Failed to save dummy collection changes, got %v", i, err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("[%d] Expected migrationsDir to be created, got: %v", i, err)
}
if total := len(files); total != 1 {
t.Fatalf("[%d] Expected 1 file to be generated, got %d", i, total)
}
expectedName := "_updated_test456." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("[%d] Expected filename to contains %q, got %q", i, expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("[%d] Failed to read the generated migration file: %v", i, err)
}
if v := strings.TrimSpace(string(content)); v != strings.TrimSpace(s.expectedTemplate) {
t.Fatalf("[%d] Expected template \n%v \ngot \n%v", i, s.expectedTemplate, v)
}
}
}
func TestAutomigrateCollectionNoChanges(t *testing.T) {
scenarios := []struct {
lang string
}{
{
migratecmd.TemplateLangJS,
},
{
migratecmd.TemplateLangGo,
},
}
for i, s := range scenarios {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
migratecmd.MustRegister(app, nil, &migratecmd.Options{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
// create dummy collection
collection := &models.Collection{}
collection.Name = "test123"
collection.Type = models.CollectionTypeAuth
// use different dao to avoid triggering automigrate while saving the dummy collection
if err := daos.New(app.DB()).SaveCollection(collection); err != nil {
t.Fatalf("[%d] Failed to save dummy collection, got %v", i, err)
}
// @todo remove after collections cache is replaced
app.Bootstrap()
// resave without changes and trigger automigrate
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatalf("[%d] Failed to save dummy collection update, got %v", i, err)
}
files, _ := os.ReadDir(migrationsDir)
if total := len(files); total != 0 {
t.Fatalf("[%d] Expected 0 files to be generated, got %d", i, total)
}
}
}
+743
View File
@@ -0,0 +1,743 @@
package migratecmd
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/pocketbase/pocketbase/models"
)
const (
TemplateLangJS = "js"
TemplateLangGo = "go"
)
var emptyTemplateErr = errors.New("empty template")
// -------------------------------------------------------------------
// JavaScript templates
// -------------------------------------------------------------------
func (p *plugin) jsBlankTemplate() (string, error) {
const template = `migrate((db) => {
// add up queries...
}, (db) => {
// add down queries...
})
`
return template, nil
}
func (p *plugin) jsSnapshotTemplate(collections []*models.Collection) (string, error) {
jsonData, err := marhshalWithoutEscape(collections, " ", " ")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `migrate((db) => {
const snapshot = %s;
const collections = snapshot.map((item) => new Collection(item));
return Dao(db).importCollections(collections, true, null);
}, (db) => {
return null;
})
`
return fmt.Sprintf(template, string(jsonData)), nil
}
func (p *plugin) jsCreateTemplate(collection *models.Collection) (string, error) {
jsonData, err := marhshalWithoutEscape(collection, " ", " ")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `migrate((db) => {
const collection = new Collection(%s);
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId(%q);
return dao.deleteCollection(collection);
})
`
return fmt.Sprintf(template, string(jsonData), collection.Id), nil
}
func (p *plugin) jsDeleteTemplate(collection *models.Collection) (string, error) {
jsonData, err := marhshalWithoutEscape(collection, " ", " ")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `migrate((db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId(%q);
return dao.deleteCollection(collection);
}, (db) => {
const collection = new Collection(%s);
return Dao(db).saveCollection(collection);
})
`
return fmt.Sprintf(template, collection.Id, string(jsonData)), nil
}
func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection) (string, error) {
if new == nil && old == nil {
return "", errors.New("the diff template require at least one of the collection to be non-nil")
}
if new == nil {
return p.jsDeleteTemplate(old)
}
if old == nil {
return p.jsCreateTemplate(new)
}
upParts := []string{}
downParts := []string{}
varName := "collection"
if old.Name != new.Name {
upParts = append(upParts, fmt.Sprintf("%s.name = %q", varName, new.Name))
downParts = append(downParts, fmt.Sprintf("%s.name = %q", varName, old.Name))
}
if old.Type != new.Type {
upParts = append(upParts, fmt.Sprintf("%s.type = %q", varName, new.Type))
downParts = append(downParts, fmt.Sprintf("%s.type = %q", varName, old.Type))
}
if old.System != new.System {
upParts = append(upParts, fmt.Sprintf("%s.system = %t", varName, new.System))
downParts = append(downParts, fmt.Sprintf("%s.system = %t", varName, old.System))
}
// ---
// note: strconv.Quote is used because %q converts the rule operators in unicode char codes
// ---
if old.ListRule != new.ListRule {
if old.ListRule != nil && new.ListRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.listRule = null", varName))
downParts = append(downParts, fmt.Sprintf("%s.listRule = %s", varName, strconv.Quote(*old.ListRule)))
} else if old.ListRule == nil && new.ListRule != nil || *old.ListRule != *new.ListRule {
upParts = append(upParts, fmt.Sprintf("%s.listRule = %s", varName, strconv.Quote(*new.ListRule)))
downParts = append(downParts, fmt.Sprintf("%s.listRule = null", varName))
}
}
if old.ViewRule != new.ViewRule {
if old.ViewRule != nil && new.ViewRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.viewRule = null", varName))
downParts = append(downParts, fmt.Sprintf("%s.viewRule = %s", varName, strconv.Quote(*old.ViewRule)))
} else if old.ViewRule == nil && new.ViewRule != nil || *old.ViewRule != *new.ViewRule {
upParts = append(upParts, fmt.Sprintf("%s.viewRule = %s", varName, strconv.Quote(*new.ViewRule)))
downParts = append(downParts, fmt.Sprintf("%s.viewRule = null", varName))
}
}
if old.CreateRule != new.CreateRule {
if old.CreateRule != nil && new.CreateRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.createRule = null", varName))
downParts = append(downParts, fmt.Sprintf("%s.createRule = %s", varName, strconv.Quote(*old.CreateRule)))
} else if old.CreateRule == nil && new.CreateRule != nil || *old.CreateRule != *new.CreateRule {
upParts = append(upParts, fmt.Sprintf("%s.createRule = %s", varName, strconv.Quote(*new.CreateRule)))
downParts = append(downParts, fmt.Sprintf("%s.createRule = null", varName))
}
}
if old.UpdateRule != new.UpdateRule {
if old.UpdateRule != nil && new.UpdateRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.updateRule = null", varName))
downParts = append(downParts, fmt.Sprintf("%s.updateRule = %s", varName, strconv.Quote(*old.UpdateRule)))
} else if old.UpdateRule == nil && new.UpdateRule != nil || *old.UpdateRule != *new.UpdateRule {
upParts = append(upParts, fmt.Sprintf("%s.updateRule = %s", varName, strconv.Quote(*new.UpdateRule)))
downParts = append(downParts, fmt.Sprintf("%s.updateRule = null", varName))
}
}
if old.DeleteRule != new.DeleteRule {
if old.DeleteRule != nil && new.DeleteRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.deleteRule = null", varName))
downParts = append(downParts, fmt.Sprintf("%s.deleteRule = %s", varName, strconv.Quote(*old.DeleteRule)))
} else if old.DeleteRule == nil && new.DeleteRule != nil || *old.DeleteRule != *new.DeleteRule {
upParts = append(upParts, fmt.Sprintf("%s.deleteRule = %s", varName, strconv.Quote(*new.DeleteRule)))
downParts = append(downParts, fmt.Sprintf("%s.deleteRule = null", varName))
}
}
// Options
rawNewOptions, err := marhshalWithoutEscape(new.Options, " ", " ")
if err != nil {
return "", err
}
rawOldOptions, err := marhshalWithoutEscape(old.Options, " ", " ")
if err != nil {
return "", err
}
if !bytes.Equal(rawNewOptions, rawOldOptions) {
upParts = append(upParts, fmt.Sprintf("%s.options = %s", varName, rawNewOptions))
downParts = append(downParts, fmt.Sprintf("%s.options = %s", varName, rawOldOptions))
}
// ensure new line between regular and collection fields
if len(upParts) > 0 {
upParts[len(upParts)-1] += "\n"
}
if len(downParts) > 0 {
downParts[len(downParts)-1] += "\n"
}
// Schema
// -----------------------------------------------------------------
// deleted fields
for _, oldField := range old.Schema.Fields() {
if new.Schema.GetFieldById(oldField.Id) != nil {
continue // exist
}
rawOldField, err := marhshalWithoutEscape(oldField, " ", " ")
if err != nil {
return "", err
}
upParts = append(upParts, "// remove")
upParts = append(upParts, fmt.Sprintf("%s.schema.removeField(%q)\n", varName, oldField.Id))
downParts = append(downParts, "// add")
downParts = append(downParts, fmt.Sprintf("%s.schema.addField(new SchemaField(%s))\n", varName, rawOldField))
}
// created fields
for _, newField := range new.Schema.Fields() {
if old.Schema.GetFieldById(newField.Id) != nil {
continue // exist
}
rawNewField, err := marhshalWithoutEscape(newField, " ", " ")
if err != nil {
return "", err
}
upParts = append(upParts, "// add")
upParts = append(upParts, fmt.Sprintf("%s.schema.addField(new SchemaField(%s))\n", varName, rawNewField))
downParts = append(downParts, "// remove")
downParts = append(downParts, fmt.Sprintf("%s.schema.removeField(%q)\n", varName, newField.Id))
}
// modified fields
for _, newField := range new.Schema.Fields() {
oldField := old.Schema.GetFieldById(newField.Id)
if oldField == nil {
continue
}
rawNewField, err := marhshalWithoutEscape(newField, " ", " ")
if err != nil {
return "", err
}
rawOldField, err := marhshalWithoutEscape(oldField, " ", " ")
if err != nil {
return "", err
}
if bytes.Equal(rawNewField, rawOldField) {
continue // no change
}
upParts = append(upParts, "// update")
upParts = append(upParts, fmt.Sprintf("%s.schema.addField(new SchemaField(%s))\n", varName, rawNewField))
downParts = append(downParts, "// update")
downParts = append(downParts, fmt.Sprintf("%s.schema.addField(new SchemaField(%s))\n", varName, rawOldField))
}
// -----------------------------------------------------------------
if len(upParts) == 0 && len(downParts) == 0 {
return "", emptyTemplateErr
}
up := strings.Join(upParts, "\n ")
down := strings.Join(downParts, "\n ")
const template = `migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId(%q)
%s
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId(%q)
%s
return dao.saveCollection(collection)
})
`
return fmt.Sprintf(
template,
old.Id, strings.TrimSpace(up),
new.Id, strings.TrimSpace(down),
), nil
}
// -------------------------------------------------------------------
// Go templates
// -------------------------------------------------------------------
func (p *plugin) goBlankTemplate() (string, error) {
const template = `package %s
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}
`
return fmt.Sprintf(template, filepath.Base(p.options.Dir)), nil
}
func (p *plugin) goSnapshotTemplate(collections []*models.Collection) (string, error) {
jsonData, err := marhshalWithoutEscape(collections, "\t\t", "\t")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `package %s
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := ` + "`%s`" + `
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
return nil
})
}
`
return fmt.Sprintf(
template,
filepath.Base(p.options.Dir),
escapeBacktick(string(jsonData)),
), nil
}
func (p *plugin) goCreateTemplate(collection *models.Collection) (string, error) {
jsonData, err := marhshalWithoutEscape(collection, "\t\t", "\t")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `package %s
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := ` + "`%s`" + `
collection := &models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId(%q)
if err != nil {
return err
}
return dao.DeleteCollection(collection)
})
}
`
return fmt.Sprintf(
template,
filepath.Base(p.options.Dir),
escapeBacktick(string(jsonData)),
collection.Id,
), nil
}
func (p *plugin) goDeleteTemplate(collection *models.Collection) (string, error) {
jsonData, err := marhshalWithoutEscape(collection, "\t\t", "\t")
if err != nil {
return "", fmt.Errorf("failed to serialize collections list: %w", err)
}
const template = `package %s
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId(%q)
if err != nil {
return err
}
return dao.DeleteCollection(collection)
}, func(db dbx.Builder) error {
jsonData := ` + "`%s`" + `
collection := &models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return daos.New(db).SaveCollection(collection)
})
}
`
return fmt.Sprintf(
template,
filepath.Base(p.options.Dir),
collection.Id,
escapeBacktick(string(jsonData)),
), nil
}
func (p *plugin) goDiffTemplate(new *models.Collection, old *models.Collection) (string, error) {
if new == nil && old == nil {
return "", errors.New("the diff template require at least one of the collection to be non-nil")
}
if new == nil {
return p.goDeleteTemplate(old)
}
if old == nil {
return p.goCreateTemplate(new)
}
upParts := []string{}
downParts := []string{}
varName := "collection"
if old.Name != new.Name {
upParts = append(upParts, fmt.Sprintf("%s.Name = %q\n", varName, new.Name))
downParts = append(downParts, fmt.Sprintf("%s.Name = %q\n", varName, old.Name))
}
if old.Type != new.Type {
upParts = append(upParts, fmt.Sprintf("%s.Type = %q\n", varName, new.Type))
downParts = append(downParts, fmt.Sprintf("%s.Type = %q\n", varName, old.Type))
}
if old.System != new.System {
upParts = append(upParts, fmt.Sprintf("%s.System = %t\n", varName, new.System))
downParts = append(downParts, fmt.Sprintf("%s.System = %t\n", varName, old.System))
}
// ---
// note: strconv.Quote is used because %q converts the rule operators in unicode char codes
// ---
if old.ListRule != new.ListRule {
if old.ListRule != nil && new.ListRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.ListRule = nil\n", varName))
downParts = append(downParts, fmt.Sprintf("%s.ListRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.ListRule)))
} else if old.ListRule == nil && new.ListRule != nil || *old.ListRule != *new.ListRule {
upParts = append(upParts, fmt.Sprintf("%s.ListRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.ListRule)))
downParts = append(downParts, fmt.Sprintf("%s.ListRule = nil\n", varName))
}
}
if old.ViewRule != new.ViewRule {
if old.ViewRule != nil && new.ViewRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.ViewRule = nil\n", varName))
downParts = append(downParts, fmt.Sprintf("%s.ViewRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.ViewRule)))
} else if old.ViewRule == nil && new.ViewRule != nil || *old.ViewRule != *new.ViewRule {
upParts = append(upParts, fmt.Sprintf("%s.ViewRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.ViewRule)))
downParts = append(downParts, fmt.Sprintf("%s.ViewRule = nil\n", varName))
}
}
if old.CreateRule != new.CreateRule {
if old.CreateRule != nil && new.CreateRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.CreateRule = nil\n", varName))
downParts = append(downParts, fmt.Sprintf("%s.CreateRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.CreateRule)))
} else if old.CreateRule == nil && new.CreateRule != nil || *old.CreateRule != *new.CreateRule {
upParts = append(upParts, fmt.Sprintf("%s.CreateRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.CreateRule)))
downParts = append(downParts, fmt.Sprintf("%s.CreateRule = nil\n", varName))
}
}
if old.UpdateRule != new.UpdateRule {
if old.UpdateRule != nil && new.UpdateRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.UpdateRule = nil\n", varName))
downParts = append(downParts, fmt.Sprintf("%s.UpdateRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.UpdateRule)))
} else if old.UpdateRule == nil && new.UpdateRule != nil || *old.UpdateRule != *new.UpdateRule {
upParts = append(upParts, fmt.Sprintf("%s.UpdateRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.UpdateRule)))
downParts = append(downParts, fmt.Sprintf("%s.UpdateRule = nil\n", varName))
}
}
if old.DeleteRule != new.DeleteRule {
if old.DeleteRule != nil && new.DeleteRule == nil {
upParts = append(upParts, fmt.Sprintf("%s.DeleteRule = nil\n", varName))
downParts = append(downParts, fmt.Sprintf("%s.DeleteRule = types.Pointer(%s)\n", varName, strconv.Quote(*old.DeleteRule)))
} else if old.DeleteRule == nil && new.DeleteRule != nil || *old.DeleteRule != *new.DeleteRule {
upParts = append(upParts, fmt.Sprintf("%s.DeleteRule = types.Pointer(%s)\n", varName, strconv.Quote(*new.DeleteRule)))
downParts = append(downParts, fmt.Sprintf("%s.DeleteRule = nil\n", varName))
}
}
// Options
rawNewOptions, err := marhshalWithoutEscape(new.Options, "\t\t", "\t")
if err != nil {
return "", err
}
rawOldOptions, err := marhshalWithoutEscape(old.Options, "\t\t", "\t")
if err != nil {
return "", err
}
if !bytes.Equal(rawNewOptions, rawOldOptions) {
upParts = append(upParts, "options := map[string]any{}")
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawNewOptions))))
upParts = append(upParts, fmt.Sprintf("%s.SetOptions(options)\n", varName))
// ---
downParts = append(downParts, "options := map[string]any{}")
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), &options)", escapeBacktick(string(rawOldOptions))))
downParts = append(downParts, fmt.Sprintf("%s.SetOptions(options)\n", varName))
}
// Schema
// ---------------------------------------------------------------
// deleted fields
for _, oldField := range old.Schema.Fields() {
if new.Schema.GetFieldById(oldField.Id) != nil {
continue // exist
}
rawOldField, err := marhshalWithoutEscape(oldField, "\t\t", "\t")
if err != nil {
return "", err
}
fieldVar := fmt.Sprintf("del_%s", oldField.Name)
upParts = append(upParts, "// remove")
upParts = append(upParts, fmt.Sprintf("%s.Schema.RemoveField(%q)\n", varName, oldField.Id))
downParts = append(downParts, "// add")
downParts = append(downParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar))
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar))
downParts = append(downParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar))
}
// created fields
for _, newField := range new.Schema.Fields() {
if old.Schema.GetFieldById(newField.Id) != nil {
continue // exist
}
rawNewField, err := marhshalWithoutEscape(newField, "\t\t", "\t")
if err != nil {
return "", err
}
fieldVar := fmt.Sprintf("new_%s", newField.Name)
upParts = append(upParts, "// add")
upParts = append(upParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar))
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar))
upParts = append(upParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar))
downParts = append(downParts, "// remove")
downParts = append(downParts, fmt.Sprintf("%s.Schema.RemoveField(%q)\n", varName, newField.Id))
}
// modified fields
for _, newField := range new.Schema.Fields() {
oldField := old.Schema.GetFieldById(newField.Id)
if oldField == nil {
continue
}
rawNewField, err := marhshalWithoutEscape(newField, "\t\t", "\t")
if err != nil {
return "", err
}
rawOldField, err := marhshalWithoutEscape(oldField, "\t\t", "\t")
if err != nil {
return "", err
}
if bytes.Equal(rawNewField, rawOldField) {
continue // no change
}
fieldVar := fmt.Sprintf("edit_%s", newField.Name)
upParts = append(upParts, "// update")
upParts = append(upParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar))
upParts = append(upParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawNewField)), fieldVar))
upParts = append(upParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar))
downParts = append(downParts, "// update")
downParts = append(downParts, fmt.Sprintf("%s := &schema.SchemaField{}", fieldVar))
downParts = append(downParts, fmt.Sprintf("json.Unmarshal([]byte(`%s`), %s)", escapeBacktick(string(rawOldField)), fieldVar))
downParts = append(downParts, fmt.Sprintf("%s.Schema.AddField(%s)\n", varName, fieldVar))
}
// ---------------------------------------------------------------
if len(upParts) == 0 && len(downParts) == 0 {
return "", emptyTemplateErr
}
up := strings.Join(upParts, "\n\t\t")
down := strings.Join(downParts, "\n\t\t")
combined := up + down
// generate imports
// ---
var imports string
if strings.Contains(combined, "json.Unmarshal(") ||
strings.Contains(combined, "json.Marshal(") {
imports += "\n\t\"encoding/json\"\n"
}
imports += "\n\t\"github.com/pocketbase/dbx\""
imports += "\n\t\"github.com/pocketbase/pocketbase/daos\""
imports += "\n\tm \"github.com/pocketbase/pocketbase/migrations\""
if strings.Contains(combined, "schema.SchemaField{") {
imports += "\n\t\"github.com/pocketbase/pocketbase/models/schema\""
}
if strings.Contains(combined, "types.Pointer(") {
imports += "\n\t\"github.com/pocketbase/pocketbase/tools/types\""
}
// ---
const template = `package %s
import (%s
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId(%q)
if err != nil {
return err
}
%s
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId(%q)
if err != nil {
return err
}
%s
return dao.SaveCollection(collection)
})
}
`
return fmt.Sprintf(
template,
filepath.Base(p.options.Dir),
imports,
old.Id, strings.TrimSpace(up),
new.Id, strings.TrimSpace(down),
), nil
}
func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error) {
raw, err := json.MarshalIndent(v, prefix, indent)
if err != nil {
return nil, err
}
// unescape escaped unicode characters
unescaped, err := strconv.Unquote(strings.ReplaceAll(strconv.Quote(string(raw)), `\\u`, `\u`))
if err != nil {
return nil, err
}
return []byte(unescaped), nil
}
func escapeBacktick(v string) string {
return strings.ReplaceAll(v, "`", "` + \"`\" + `")
}
+1 -2
View File
@@ -83,7 +83,7 @@ func NewWithConfig(config Config) *PocketBase {
pb := &PocketBase{
RootCmd: &cobra.Command{
Use: "pocketbase",
Use: filepath.Base(os.Args[0]),
Short: "PocketBase CLI",
Version: Version,
FParseErrWhitelist: cobra.FParseErrWhitelist{
@@ -127,7 +127,6 @@ func NewWithConfig(config Config) *PocketBase {
func (pb *PocketBase) Start() error {
// register system commands
pb.RootCmd.AddCommand(cmd.NewServeCommand(pb, !pb.hideStartBanner))
pb.RootCmd.AddCommand(cmd.NewMigrateCommand(pb))
pb.RootCmd.AddCommand(cmd.NewTempUpgradeCommand(pb))
return pb.Execute()
+14
View File
@@ -134,6 +134,20 @@ func (scenario *ApiScenario) Test(t *testing.T) {
}
}
// to minimize the breaking changes we always expect the error
// events to be called on API error
if res.StatusCode >= 400 {
if scenario.ExpectedEvents == nil {
scenario.ExpectedEvents = map[string]int{}
}
if _, ok := scenario.ExpectedEvents["OnBeforeApiError"]; !ok {
scenario.ExpectedEvents["OnBeforeApiError"] = 1
}
if _, ok := scenario.ExpectedEvents["OnAfterApiError"]; !ok {
scenario.ExpectedEvents["OnAfterApiError"] = 1
}
}
if len(testApp.EventCalls) > len(scenario.ExpectedEvents) {
t.Errorf("[%s] Expected events %v, got %v", prefix, scenario.ExpectedEvents, testApp.EventCalls)
}
+90 -5
View File
@@ -18,7 +18,13 @@ type TestApp struct {
// EventCalls defines a map to inspect which app events
// (and how many times) were triggered.
//
// The following events are not counted because they execute always:
// - OnBeforeBootstrap
// - OnAfterBootstrap
// - OnBeforeServe
EventCalls map[string]int
TestMailer *TestMailer
}
@@ -81,11 +87,15 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
TestMailer: &TestMailer{},
}
// no need to count since this is executed always
// t.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// t.EventCalls["OnBeforeServe"]++
// return nil
// })
t.OnBeforeApiError().Add(func(e *core.ApiErrorEvent) error {
t.EventCalls["OnBeforeApiError"]++
return nil
})
t.OnAfterApiError().Add(func(e *core.ApiErrorEvent) error {
t.EventCalls["OnAfterApiError"]++
return nil
})
t.OnModelBeforeCreate().Add(func(e *core.ModelEvent) error {
t.EventCalls["OnModelBeforeCreate"]++
@@ -162,6 +172,66 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return nil
})
t.OnRecordBeforeRequestPasswordResetRequest().Add(func(e *core.RecordRequestPasswordResetEvent) error {
t.EventCalls["OnRecordBeforeRequestPasswordResetRequest"]++
return nil
})
t.OnRecordAfterRequestPasswordResetRequest().Add(func(e *core.RecordRequestPasswordResetEvent) error {
t.EventCalls["OnRecordAfterRequestPasswordResetRequest"]++
return nil
})
t.OnRecordBeforeConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error {
t.EventCalls["OnRecordBeforeConfirmPasswordResetRequest"]++
return nil
})
t.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error {
t.EventCalls["OnRecordAfterConfirmPasswordResetRequest"]++
return nil
})
t.OnRecordBeforeRequestVerificationRequest().Add(func(e *core.RecordRequestVerificationEvent) error {
t.EventCalls["OnRecordBeforeRequestVerificationRequest"]++
return nil
})
t.OnRecordAfterRequestVerificationRequest().Add(func(e *core.RecordRequestVerificationEvent) error {
t.EventCalls["OnRecordAfterRequestVerificationRequest"]++
return nil
})
t.OnRecordBeforeConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error {
t.EventCalls["OnRecordBeforeConfirmVerificationRequest"]++
return nil
})
t.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error {
t.EventCalls["OnRecordAfterConfirmVerificationRequest"]++
return nil
})
t.OnRecordBeforeRequestEmailChangeRequest().Add(func(e *core.RecordRequestEmailChangeEvent) error {
t.EventCalls["OnRecordBeforeRequestEmailChangeRequest"]++
return nil
})
t.OnRecordAfterRequestEmailChangeRequest().Add(func(e *core.RecordRequestEmailChangeEvent) error {
t.EventCalls["OnRecordAfterRequestEmailChangeRequest"]++
return nil
})
t.OnRecordBeforeConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error {
t.EventCalls["OnRecordBeforeConfirmEmailChangeRequest"]++
return nil
})
t.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error {
t.EventCalls["OnRecordAfterConfirmEmailChangeRequest"]++
return nil
})
t.OnRecordListExternalAuthsRequest().Add(func(e *core.RecordListExternalAuthsEvent) error {
t.EventCalls["OnRecordListExternalAuthsRequest"]++
return nil
@@ -222,6 +292,21 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return nil
})
t.OnRealtimeDisconnectRequest().Add(func(e *core.RealtimeDisconnectEvent) error {
t.EventCalls["OnRealtimeDisconnectRequest"]++
return nil
})
t.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
t.EventCalls["OnRealtimeBeforeMessageSend"]++
return nil
})
t.OnRealtimeAfterMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
t.EventCalls["OnRealtimeAfterMessageSend"]++
return nil
})
t.OnRealtimeBeforeSubscribeRequest().Add(func(e *core.RealtimeSubscribeEvent) error {
t.EventCalls["OnRealtimeBeforeSubscribeRequest"]++
return nil
Binary file not shown.
+7 -18
View File
@@ -1,9 +1,6 @@
package tests
import (
"io"
"net/mail"
"github.com/pocketbase/pocketbase/tools/mailer"
)
@@ -11,28 +8,20 @@ var _ mailer.Mailer = (*TestMailer)(nil)
// TestMailer is a mock `mailer.Mailer` implementation.
type TestMailer struct {
TotalSend int
LastFromAddress mail.Address
LastToAddress mail.Address
LastHtmlSubject string
LastHtmlBody string
TotalSend int
LastMessage mailer.Message
}
// Reset clears any previously test collected data.
func (m *TestMailer) Reset() {
m.TotalSend = 0
m.LastFromAddress = mail.Address{}
m.LastToAddress = mail.Address{}
m.LastHtmlSubject = ""
m.LastHtmlBody = ""
m.LastMessage = mailer.Message{}
}
// Send implements `mailer.Mailer` interface.
func (m *TestMailer) Send(fromEmail mail.Address, toEmail mail.Address, subject string, html string, attachments map[string]io.Reader) error {
m.LastFromAddress = fromEmail
m.LastToAddress = toEmail
m.LastHtmlSubject = subject
m.LastHtmlBody = html
m.TotalSend++
func (c *TestMailer) Send(m *mailer.Message) error {
c.TotalSend++
c.LastMessage = *m
return nil
}
+8 -6
View File
@@ -9,11 +9,13 @@ import (
// AuthUser defines a standardized oauth2 user data structure.
type AuthUser struct {
Id string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
Id string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
RawUser map[string]any `json:"rawUser"`
AccessToken string `json:"accessToken"`
}
// Provider defines a common interface for an OAuth2 client.
@@ -73,7 +75,7 @@ type Provider interface {
// FetchRawUserData requests and marshalizes into `result` the
// the OAuth user api response.
FetchRawUserData(token *oauth2.Token, result any) error
FetchRawUserData(token *oauth2.Token) ([]byte, error)
// FetchAuthUser is similar to FetchRawUserData, but normalizes and
// marshalizes the user api response into a standardized AuthUser struct.
+11 -13
View File
@@ -2,7 +2,6 @@ package auth
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
@@ -107,42 +106,41 @@ func (p *baseProvider) Client(token *oauth2.Token) *http.Client {
}
// FetchRawUserData implements Provider.FetchRawUserData interface.
func (p *baseProvider) FetchRawUserData(token *oauth2.Token, result any) error {
func (p *baseProvider) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
req, err := http.NewRequest("GET", p.userApiUrl, nil)
if err != nil {
return err
return nil, err
}
return p.sendRawUserDataRequest(req, token, result)
return p.sendRawUserDataRequest(req, token)
}
// sendRawUserDataRequest sends the specified request and
// unmarshal the response body into result.
func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.Token, result any) error {
// sendRawUserDataRequest sends the specified user data request and return its raw response body.
func (p *baseProvider) sendRawUserDataRequest(req *http.Request, token *oauth2.Token) ([]byte, error) {
client := p.Client(token)
response, err := client.Do(req)
if err != nil {
return err
return nil, err
}
defer response.Body.Close()
content, err := ioutil.ReadAll(response.Body)
result, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
return nil, err
}
// http.Client.Get doesn't treat non 2xx responses as error
if response.StatusCode >= 400 {
return fmt.Errorf(
return nil, fmt.Errorf(
"Failed to fetch OAuth2 user profile via %s (%d):\n%s",
p.userApiUrl,
response.StatusCode,
string(content),
string(result),
)
}
return json.Unmarshal(content, &result)
return result, nil
}
// oauth2Config constructs a oauth2.Config instance based on the provider settings.
+27 -11
View File
@@ -1,6 +1,7 @@
package auth
import (
"encoding/json"
"fmt"
"golang.org/x/oauth2"
@@ -29,33 +30,48 @@ func NewDiscordProvider() *Discord {
}
// FetchAuthUser returns an AuthUser instance from Discord's user api.
//
// API reference: https://discord.com/developers/docs/resources/user#user-object
func (p *Discord) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://discord.com/developers/docs/resources/user#user-object
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"id"`
Username string `json:"username"`
Discriminator string `json:"discriminator"`
Email string `json:"email"`
Verified bool `json:"verified"`
Avatar string `json:"avatar"`
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
// Build a full avatar URL using the avatar hash provided in the API response
// https://discord.com/developers/docs/reference#image-formatting
avatarUrl := fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", rawData.Id, rawData.Avatar)
avatarUrl := fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", extracted.Id, extracted.Avatar)
// Concatenate the user's username and discriminator into a single username string
username := fmt.Sprintf("%s#%s", rawData.Username, rawData.Discriminator)
username := fmt.Sprintf("%s#%s", extracted.Username, extracted.Discriminator)
user := &AuthUser{
Id: rawData.Id,
Name: username,
Username: rawData.Username,
Email: rawData.Email,
AvatarUrl: avatarUrl,
Id: extracted.Id,
Name: username,
Username: extracted.Username,
AvatarUrl: avatarUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
if extracted.Verified {
user.Email = extracted.Email
}
return user, nil
+22 -8
View File
@@ -1,6 +1,8 @@
package auth
import (
"encoding/json"
"golang.org/x/oauth2"
"golang.org/x/oauth2/facebook"
)
@@ -26,9 +28,20 @@ func NewFacebookProvider() *Facebook {
}
// FetchAuthUser returns an AuthUser instance based on the Facebook's user api.
//
// API reference: https://developers.facebook.com/docs/graph-api/reference/user/
func (p *Facebook) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://developers.facebook.com/docs/graph-api/reference/user/
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string
Name string
Email string
@@ -36,16 +49,17 @@ func (p *Facebook) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
Data struct{ Url string }
}
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: rawData.Id,
Name: rawData.Name,
Email: rawData.Email,
AvatarUrl: rawData.Picture.Data.Url,
Id: extracted.Id,
Name: extracted.Name,
Email: extracted.Email,
AvatarUrl: extracted.Picture.Data.Url,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
return user, nil
+21 -9
View File
@@ -30,26 +30,38 @@ func NewGithubProvider() *Github {
}
// FetchAuthUser returns an AuthUser instance based the Github's user api.
//
// API reference: https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://docs.github.com/en/rest/reference/users#get-the-authenticated-user
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Login string `json:"login"`
Id int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatar_url"`
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(rawData.Id),
Name: rawData.Name,
Username: rawData.Login,
Email: rawData.Email,
AvatarUrl: rawData.AvatarUrl,
Id: strconv.Itoa(extracted.Id),
Name: extracted.Name,
Username: extracted.Login,
Email: extracted.Email,
AvatarUrl: extracted.AvatarUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
// in case user set "Keep my email address private",
+22 -9
View File
@@ -1,6 +1,7 @@
package auth
import (
"encoding/json"
"strconv"
"golang.org/x/oauth2"
@@ -27,26 +28,38 @@ func NewGitlabProvider() *Gitlab {
}
// FetchAuthUser returns an AuthUser instance based the Gitlab's user api.
//
// API reference: https://docs.gitlab.com/ee/api/users.html#for-admin
func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://docs.gitlab.com/ee/api/users.html#for-admin
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id int `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarUrl string `json:"avatar_url"`
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(rawData.Id),
Name: rawData.Name,
Username: rawData.Username,
Email: rawData.Email,
AvatarUrl: rawData.AvatarUrl,
Id: strconv.Itoa(extracted.Id),
Name: extracted.Name,
Username: extracted.Username,
Email: extracted.Email,
AvatarUrl: extracted.AvatarUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
return user, nil
+24 -11
View File
@@ -1,6 +1,8 @@
package auth
import (
"encoding/json"
"golang.org/x/oauth2"
)
@@ -29,22 +31,33 @@ func NewGoogleProvider() *Google {
// FetchAuthUser returns an AuthUser instance based the Google's user api.
func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
rawData := struct {
Id string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Picture string `json:"picture"`
}{}
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
if err := p.FetchRawUserData(token, &rawData); err != nil {
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string
Name string
Email string
Picture string
}{}
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: rawData.Id,
Name: rawData.Name,
Email: rawData.Email,
AvatarUrl: rawData.Picture,
Id: extracted.Id,
Name: extracted.Name,
Email: extracted.Email,
AvatarUrl: extracted.Picture,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
return user, nil
+22 -9
View File
@@ -1,6 +1,7 @@
package auth
import (
"encoding/json"
"strconv"
"golang.org/x/oauth2"
@@ -28,9 +29,20 @@ func NewKakaoProvider() *Kakao {
}
// FetchAuthUser returns an AuthUser instance based on the Kakao's user api.
//
// API reference: https://developers.kakao.com/docs/latest/en/kakaologin/rest-api#req-user-info-response
func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://developers.kakao.com/docs/latest/en/kakaologin/rest-api#req-user-info-response
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id int `json:"id"`
Profile struct {
Nickname string `json:"nickname"`
@@ -42,18 +54,19 @@ func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
IsEmailValid bool `json:"is_email_valid"`
} `json:"kakao_account"`
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: strconv.Itoa(rawData.Id),
Username: rawData.Profile.Nickname,
AvatarUrl: rawData.Profile.ImageUrl,
Id: strconv.Itoa(extracted.Id),
Username: extracted.Profile.Nickname,
AvatarUrl: extracted.Profile.ImageUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
if rawData.KakaoAccount.IsEmailValid && rawData.KakaoAccount.IsEmailVerified {
user.Email = rawData.KakaoAccount.Email
if extracted.KakaoAccount.IsEmailValid && extracted.KakaoAccount.IsEmailVerified {
user.Email = extracted.KakaoAccount.Email
}
return user, nil
+22 -8
View File
@@ -1,6 +1,8 @@
package auth
import (
"encoding/json"
"golang.org/x/oauth2"
"golang.org/x/oauth2/microsoft"
)
@@ -27,23 +29,35 @@ func NewMicrosoftProvider() *Microsoft {
}
// FetchAuthUser returns an AuthUser instance based on the Microsoft's user api.
//
// API reference: https://learn.microsoft.com/en-us/azure/active-directory/develop/userinfo
// Graph explorer: https://developer.microsoft.com/en-us/graph/graph-explorer
func (p *Microsoft) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://learn.microsoft.com/en-us/azure/active-directory/develop/userinfo
// explore graph: https://developer.microsoft.com/en-us/graph/graph-explorer
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"id"`
Name string `json:"displayName"`
Email string `json:"mail"`
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: rawData.Id,
Name: rawData.Name,
Email: rawData.Email,
Id: extracted.Id,
Name: extracted.Name,
Email: extracted.Email,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
return user, nil
+22 -8
View File
@@ -1,6 +1,8 @@
package auth
import (
"encoding/json"
"golang.org/x/oauth2"
"golang.org/x/oauth2/spotify"
)
@@ -30,9 +32,20 @@ func NewSpotifyProvider() *Spotify {
}
// FetchAuthUser returns an AuthUser instance based on the Spotify's user api.
//
// API reference: https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile
func (p *Spotify) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://developer.spotify.com/documentation/web-api/reference/#/operations/get-current-users-profile
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Id string `json:"id"`
Name string `json:"display_name"`
Images []struct {
@@ -43,17 +56,18 @@ func (p *Spotify) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// that it actually belongs to the user
// Email string `json:"email"`
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: rawData.Id,
Name: rawData.Name,
Id: extracted.Id,
Name: extracted.Name,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
if len(rawData.Images) > 0 {
user.AvatarUrl = rawData.Images[0].Url
if len(extracted.Images) > 0 {
user.AvatarUrl = extracted.Images[0].Url
}
return user, nil
+26 -13
View File
@@ -1,6 +1,7 @@
package auth
import (
"encoding/json"
"errors"
"net/http"
@@ -29,9 +30,20 @@ func NewTwitchProvider() *Twitch {
}
// FetchAuthUser returns an AuthUser instance based the Twitch's user api.
//
// API reference: https://dev.twitch.tv/docs/api/reference#get-users
func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://dev.twitch.tv/docs/api/reference#get-users
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data []struct {
Id string `json:"id"`
Login string `json:"login"`
@@ -40,21 +52,22 @@ func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
ProfileImageUrl string `json:"profile_image_url"`
} `json:"data"`
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
if len(rawData.Data) == 0 {
if len(extracted.Data) == 0 {
return nil, errors.New("Failed to fetch AuthUser data")
}
user := &AuthUser{
Id: rawData.Data[0].Id,
Name: rawData.Data[0].DisplayName,
Username: rawData.Data[0].Login,
Email: rawData.Data[0].Email,
AvatarUrl: rawData.Data[0].ProfileImageUrl,
Id: extracted.Data[0].Id,
Name: extracted.Data[0].DisplayName,
Username: extracted.Data[0].Login,
Email: extracted.Data[0].Email,
AvatarUrl: extracted.Data[0].ProfileImageUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
return user, nil
@@ -63,13 +76,13 @@ func (p *Twitch) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// FetchRawUserData implements Provider.FetchRawUserData interface.
//
// This differ from baseProvider because Twitch requires the `Client-Id` header.
func (p *Twitch) FetchRawUserData(token *oauth2.Token, result any) error {
func (p *Twitch) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
req, err := http.NewRequest("GET", p.userApiUrl, nil)
if err != nil {
return err
return nil, err
}
req.Header.Set("Client-Id", p.clientId)
return p.sendRawUserDataRequest(req, token, result)
return p.sendRawUserDataRequest(req, token)
}
+23 -10
View File
@@ -1,6 +1,8 @@
package auth
import (
"encoding/json"
"golang.org/x/oauth2"
)
@@ -31,9 +33,20 @@ func NewTwitterProvider() *Twitter {
}
// FetchAuthUser returns an AuthUser instance based on the Twitter's user api.
//
// API reference: https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me
func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me
rawData := struct {
data, err := p.FetchRawUserData(token)
if err != nil {
return nil, err
}
rawUser := map[string]any{}
if err := json.Unmarshal(data, &rawUser); err != nil {
return nil, err
}
extracted := struct {
Data struct {
Id string `json:"id"`
Name string `json:"name"`
@@ -42,20 +55,20 @@ func (p *Twitter) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
// NB! At the time of writing, Twitter OAuth2 doesn't support returning the user email address
// (see https://twittercommunity.com/t/which-api-to-get-user-after-oauth2-authorization/162417/33)
Email string `json:"email"`
// Email string `json:"email"`
} `json:"data"`
}{}
if err := p.FetchRawUserData(token, &rawData); err != nil {
if err := json.Unmarshal(data, &extracted); err != nil {
return nil, err
}
user := &AuthUser{
Id: rawData.Data.Id,
Name: rawData.Data.Name,
Username: rawData.Data.Username,
Email: rawData.Data.Email,
AvatarUrl: rawData.Data.ProfileImageUrl,
Id: extracted.Data.Id,
Name: extracted.Data.Name,
Username: extracted.Data.Username,
AvatarUrl: extracted.Data.ProfileImageUrl,
RawUser: rawUser,
AccessToken: token.AccessToken,
}
return user, nil
+24 -16
View File
@@ -133,8 +133,17 @@ func (s *System) UploadMultipart(fh *multipart.FileHeader, fileKey string) error
// rewind
f.Seek(0, io.SeekStart)
originalName := fh.Filename
if len(originalName) > 255 {
// keep only the first 255 chars as a very rudimentary measure
// to prevent the metadata to grow too big in size
originalName = originalName[:255]
}
opts := &blob.WriterOptions{
ContentType: mt.String(),
Metadata: map[string]string{
"original_filename": originalName,
},
}
w, err := s.bucket.NewWriter(s.ctx, fileKey, opts)
@@ -237,15 +246,15 @@ var manualExtensionContentTypes = map[string]string{
}
// Serve serves the file at fileKey location to an HTTP response.
func (s *System) Serve(response http.ResponseWriter, fileKey string, name string) error {
r, readErr := s.bucket.NewReader(s.ctx, fileKey, nil)
func (s *System) Serve(res http.ResponseWriter, req *http.Request, fileKey string, name string) error {
br, readErr := s.bucket.NewReader(s.ctx, fileKey, nil)
if readErr != nil {
return readErr
}
defer r.Close()
defer br.Close()
disposition := "attachment"
realContentType := r.ContentType()
realContentType := br.ContentType()
if list.ExistInSlice(realContentType, inlineServeContentTypes) {
disposition = "inline"
}
@@ -260,12 +269,12 @@ func (s *System) Serve(response http.ResponseWriter, fileKey string, name string
// clickjacking shouldn't be a concern when serving uploaded files,
// so it safe to unset the global X-Frame-Options to allow files embedding
// (see https://github.com/pocketbase/pocketbase/issues/677)
response.Header().Del("X-Frame-Options")
res.Header().Del("X-Frame-Options")
response.Header().Set("Content-Disposition", disposition+"; filename="+name)
response.Header().Set("Content-Type", extContentType)
response.Header().Set("Content-Length", strconv.FormatInt(r.Size(), 10))
response.Header().Set("Content-Security-Policy", "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox")
res.Header().Set("Content-Disposition", disposition+"; filename="+name)
res.Header().Set("Content-Type", extContentType)
res.Header().Set("Content-Length", strconv.FormatInt(br.Size(), 10))
res.Header().Set("Content-Security-Policy", "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox")
// all HTTP date/time stamps MUST be represented in Greenwich Mean Time (GMT)
// (see https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1)
@@ -273,20 +282,19 @@ func (s *System) Serve(response http.ResponseWriter, fileKey string, name string
// NB! time.LoadLocation may fail on non-Unix systems (see https://github.com/pocketbase/pocketbase/issues/45)
location, locationErr := time.LoadLocation("GMT")
if locationErr == nil {
response.Header().Set("Last-Modified", r.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST"))
res.Header().Set("Last-Modified", br.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST"))
}
// set a default cache-control header
// (valid for 30 days but the cache is allowed to reuse the file for any requests
// that are made in the last day while revalidating the response in the background)
if response.Header().Get("Cache-Control") == "" {
response.Header().Set("Cache-Control", "max-age=2592000, stale-while-revalidate=86400")
// that are made in the last day while revalidating the res in the background)
if res.Header().Get("Cache-Control") == "" {
res.Header().Set("Cache-Control", "max-age=2592000, stale-while-revalidate=86400")
}
// copy from the read range to response.
_, err := io.Copy(response, r)
http.ServeContent(res, req, name, br.ModTime(), br)
return err
return nil
}
var ThumbSizeRegex = regexp.MustCompile(`^(\d+)x(\d+)(t|b|f)?$`)
+86 -11
View File
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tools/filesystem"
@@ -161,14 +162,24 @@ func TestFileSystemUploadMultipart(t *testing.T) {
}
defer fs.Close()
uploadErr := fs.UploadMultipart(fh, "newdir/newkey.txt")
fileKey := "newdir/newkey.txt"
uploadErr := fs.UploadMultipart(fh, fileKey)
if uploadErr != nil {
t.Fatal(uploadErr)
}
if exists, _ := fs.Exists("newdir/newkey.txt"); !exists {
if exists, _ := fs.Exists(fileKey); !exists {
t.Fatalf("Expected newdir/newkey.txt to exist")
}
attrs, err := fs.Attributes(fileKey)
if err != nil {
t.Fatalf("Failed to fetch file attributes: %v", err)
}
if name, ok := attrs.Metadata["original_filename"]; !ok || name != "test" {
t.Fatalf("Expected original_filename to be %q, got %q", "test", name)
}
}
func TestFileSystemUpload(t *testing.T) {
@@ -265,9 +276,10 @@ func TestFileSystemServe(t *testing.T) {
}
for _, scenario := range scenarios {
r := httptest.NewRecorder()
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
err := fs.Serve(r, scenario.path, scenario.name)
err := fs.Serve(res, req, scenario.path, scenario.name)
hasErr := err != nil
if hasErr != scenario.expectError {
@@ -279,7 +291,7 @@ func TestFileSystemServe(t *testing.T) {
continue
}
result := r.Result()
result := res.Result()
for hName, hValue := range scenario.expectHeaders {
v := result.Header.Get(hName)
@@ -298,6 +310,69 @@ func TestFileSystemServe(t *testing.T) {
}
}
func TestFileSystemServeSingleRange(t *testing.T) {
dir := createTestDir(t)
defer os.RemoveAll(dir)
fs, err := filesystem.NewLocal(dir)
if err != nil {
t.Fatal(err)
}
defer fs.Close()
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Add("Range", "bytes=0-20")
if err := fs.Serve(res, req, "image.png", "image.png"); err != nil {
t.Fatal(err)
}
result := res.Result()
if result.StatusCode != http.StatusPartialContent {
t.Fatalf("Expected StatusCode %d, got %d", http.StatusPartialContent, result.StatusCode)
}
expectedRange := "bytes 0-20/73"
if cr := result.Header.Get("Content-Range"); cr != expectedRange {
t.Fatalf("Expected Content-Range %q, got %q", expectedRange, cr)
}
if l := result.Header.Get("Content-Length"); l != "21" {
t.Fatalf("Expected Content-Length %v, got %v", 21, l)
}
}
func TestFileSystemServeMultiRange(t *testing.T) {
dir := createTestDir(t)
defer os.RemoveAll(dir)
fs, err := filesystem.NewLocal(dir)
if err != nil {
t.Fatal(err)
}
defer fs.Close()
res := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Add("Range", "bytes=0-20, 25-30")
if err := fs.Serve(res, req, "image.png", "image.png"); err != nil {
t.Fatal(err)
}
result := res.Result()
if result.StatusCode != http.StatusPartialContent {
t.Fatalf("Expected StatusCode %d, got %d", http.StatusPartialContent, result.StatusCode)
}
if ct := result.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/byteranges; boundary=") {
t.Fatalf("Expected Content-Type to be multipart/byteranges, got %v", ct)
}
}
func TestFileSystemCreateThumb(t *testing.T) {
dir := createTestDir(t)
defer os.RemoveAll(dir)
@@ -357,19 +432,19 @@ func createTestDir(t *testing.T) string {
t.Fatal(err)
}
file1, err := os.OpenFile(filepath.Join(dir, "test/sub1.txt"), os.O_WRONLY|os.O_CREATE, 0666)
file1, err := os.OpenFile(filepath.Join(dir, "test/sub1.txt"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
t.Fatal(err)
}
file1.Close()
file2, err := os.OpenFile(filepath.Join(dir, "test/sub2.txt"), os.O_WRONLY|os.O_CREATE, 0666)
file2, err := os.OpenFile(filepath.Join(dir, "test/sub2.txt"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
t.Fatal(err)
}
file2.Close()
file3, err := os.OpenFile(filepath.Join(dir, "image.png"), os.O_WRONLY|os.O_CREATE, 0666)
file3, err := os.OpenFile(filepath.Join(dir, "image.png"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
t.Fatal(err)
}
@@ -377,18 +452,18 @@ func createTestDir(t *testing.T) string {
imgRect := image.Rect(0, 0, 1, 1)
png.Encode(file3, imgRect)
file3.Close()
err2 := os.WriteFile(filepath.Join(dir, "image.png.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null}`), 0666)
err2 := os.WriteFile(filepath.Join(dir, "image.png.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null}`), 0644)
if err2 != nil {
t.Fatal(err2)
}
file4, err := os.OpenFile(filepath.Join(dir, "image.svg"), os.O_WRONLY|os.O_CREATE, 0666)
file4, err := os.OpenFile(filepath.Join(dir, "image.svg"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
t.Fatal(err)
}
file4.Close()
file5, err := os.OpenFile(filepath.Join(dir, "style.css"), os.O_WRONLY|os.O_CREATE, 0666)
file5, err := os.OpenFile(filepath.Join(dir, "style.css"), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
t.Fatal(err)
}
+15 -8
View File
@@ -5,14 +5,21 @@ import (
"net/mail"
)
// Message defines a generic email message struct.
type Message struct {
From mail.Address
To mail.Address
Bcc []string
Cc []string
Subject string
HTML string
Text string
Headers map[string]string
Attachments map[string]io.Reader
}
// Mailer defines a base mail client interface.
type Mailer interface {
// Send sends an email with HTML body to the specified recipient.
Send(
fromEmail mail.Address,
toEmail mail.Address,
subject string,
htmlContent string,
attachments map[string]io.Reader,
) error
// Send sends an email with the provided Message.
Send(message *Message) error
}
+13 -17
View File
@@ -3,10 +3,8 @@ package mailer
import (
"bytes"
"errors"
"io"
"mime"
"net/http"
"net/mail"
"os/exec"
)
@@ -20,19 +18,11 @@ type Sendmail struct {
}
// Send implements `mailer.Mailer` interface.
//
// Attachments are currently not supported.
func (m *Sendmail) Send(
fromEmail mail.Address,
toEmail mail.Address,
subject string,
htmlContent string,
attachments map[string]io.Reader,
) error {
func (c *Sendmail) Send(m *Message) error {
headers := make(http.Header)
headers.Set("Subject", mime.QEncoding.Encode("utf-8", subject))
headers.Set("From", fromEmail.String())
headers.Set("To", toEmail.String())
headers.Set("Subject", mime.QEncoding.Encode("utf-8", m.Subject))
headers.Set("From", m.From.String())
headers.Set("To", m.To.String())
headers.Set("Content-Type", "text/html; charset=UTF-8")
cmdPath, err := findSendmailPath()
@@ -50,12 +40,18 @@ func (m *Sendmail) Send(
if _, err := buffer.Write([]byte("\r\n")); err != nil {
return err
}
if _, err := buffer.Write([]byte(htmlContent)); err != nil {
return err
if m.HTML != "" {
if _, err := buffer.Write([]byte(m.HTML)); err != nil {
return err
}
} else {
if _, err := buffer.Write([]byte(m.Text)); err != nil {
return err
}
}
// ---
sendmail := exec.Command(cmdPath, toEmail.Address)
sendmail := exec.Command(cmdPath, m.To.Address)
sendmail.Stdin = &buffer
return sendmail.Run()
+50 -24
View File
@@ -2,11 +2,11 @@ package mailer
import (
"fmt"
"io"
"net/mail"
"net/smtp"
"strings"
"github.com/domodwyer/mailyak/v3"
"github.com/pocketbase/pocketbase/tools/security"
)
var _ Mailer = (*SmtpClient)(nil)
@@ -39,46 +39,72 @@ type SmtpClient struct {
}
// Send implements `mailer.Mailer` interface.
func (m *SmtpClient) Send(
fromEmail mail.Address,
toEmail mail.Address,
subject string,
htmlContent string,
attachments map[string]io.Reader,
) error {
func (c *SmtpClient) Send(m *Message) error {
var smtpAuth smtp.Auth
if m.username != "" || m.password != "" {
smtpAuth = smtp.PlainAuth("", m.username, m.password, m.host)
if c.username != "" || c.password != "" {
smtpAuth = smtp.PlainAuth("", c.username, c.password, c.host)
}
// create mail instance
var yak *mailyak.MailYak
if m.tls {
if c.tls {
var tlsErr error
yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", m.host, m.port), smtpAuth, nil)
yak, tlsErr = mailyak.NewWithTLS(fmt.Sprintf("%s:%d", c.host, c.port), smtpAuth, nil)
if tlsErr != nil {
return tlsErr
}
} else {
yak = mailyak.New(fmt.Sprintf("%s:%d", m.host, m.port), smtpAuth)
yak = mailyak.New(fmt.Sprintf("%s:%d", c.host, c.port), smtpAuth)
}
if fromEmail.Name != "" {
yak.FromName(fromEmail.Name)
if m.From.Name != "" {
yak.FromName(m.From.Name)
}
yak.From(fromEmail.Address)
yak.To(toEmail.Address)
yak.Subject(subject)
yak.HTML().Set(htmlContent)
yak.From(m.From.Address)
yak.To(m.To.Address)
yak.Subject(m.Subject)
yak.HTML().Set(m.HTML)
// try to generate a plain text version of the HTML
if plain, err := html2Text(htmlContent); err == nil {
yak.Plain().Set(plain)
if m.Text == "" {
// try to generate a plain text version of the HTML
if plain, err := html2Text(m.HTML); err == nil {
yak.Plain().Set(plain)
}
} else {
yak.Plain().Set(m.Text)
}
for name, data := range attachments {
if len(m.Bcc) > 0 {
yak.Bcc(m.Bcc...)
}
if len(m.Cc) > 0 {
yak.Cc(m.Cc...)
}
// add attachements (if any)
for name, data := range m.Attachments {
yak.Attach(name, data)
}
// add custom headers (if any)
var hasMessageId bool
for k, v := range m.Headers {
if strings.EqualFold(k, "Message-ID") {
hasMessageId = true
}
yak.AddHeader(k, v)
}
if !hasMessageId {
// add a default message id if missing
fromParts := strings.Split(m.From.Address, "@")
if len(fromParts) == 2 {
yak.AddHeader("Message-ID", fmt.Sprintf("<%s@%s>",
security.PseudorandomString(15),
fromParts[1],
))
}
}
return yak.Send()
}
+12 -12
View File
@@ -8,24 +8,24 @@ import (
"github.com/pocketbase/dbx"
)
type migration struct {
file string
up func(db dbx.Builder) error
down func(db dbx.Builder) error
type Migration struct {
File string
Up func(db dbx.Builder) error
Down func(db dbx.Builder) error
}
// MigrationsList defines a list with migration definitions
type MigrationsList struct {
list []*migration
list []*Migration
}
// Item returns a single migration from the list by its index.
func (l *MigrationsList) Item(index int) *migration {
func (l *MigrationsList) Item(index int) *Migration {
return l.list[index]
}
// Items returns the internal migrations list slice.
func (l *MigrationsList) Items() []*migration {
func (l *MigrationsList) Items() []*Migration {
return l.list
}
@@ -47,13 +47,13 @@ func (l *MigrationsList) Register(
file = filepath.Base(path)
}
l.list = append(l.list, &migration{
file: file,
up: up,
down: down,
l.list = append(l.list, &Migration{
File: file,
Up: up,
Down: down,
})
sort.Slice(l.list, func(i int, j int) bool {
return l.list[i].file < l.list[j].file
return l.list[i].File < l.list[j].File
})
}
+2 -2
View File
@@ -26,8 +26,8 @@ func TestMigrationsList(t *testing.T) {
for i, name := range expected {
item := l.Item(i)
if item.file != name {
t.Fatalf("Expected name %s for index %d, got %s", name, i, item.file)
if item.File != name {
t.Fatalf("Expected name %s for index %d, got %s", name, i, item.File)
}
}
}
+20 -14
View File
@@ -10,7 +10,7 @@ import (
"github.com/spf13/cast"
)
const migrationsTable = "_migrations"
const DefaultMigrationsTable = "_migrations"
// Runner defines a simple struct for managing the execution of db migrations.
type Runner struct {
@@ -24,7 +24,7 @@ func NewRunner(db *dbx.DB, migrationsList MigrationsList) (*Runner, error) {
runner := &Runner{
db: db,
migrationsList: migrationsList,
tableName: migrationsTable,
tableName: DefaultMigrationsTable,
}
if err := runner.createMigrationsTable(); err != nil {
@@ -111,19 +111,22 @@ func (r *Runner) Up() ([]string, error) {
err := r.db.Transactional(func(tx *dbx.Tx) error {
for _, m := range r.migrationsList.Items() {
// skip applied
if r.isMigrationApplied(tx, m.file) {
if r.isMigrationApplied(tx, m.File) {
continue
}
if err := m.up(tx); err != nil {
return fmt.Errorf("Failed to apply migration %s: %w", m.file, err)
// ignore empty Up action
if m.Up != nil {
if err := m.Up(tx); err != nil {
return fmt.Errorf("Failed to apply migration %s: %w", m.File, err)
}
}
if err := r.saveAppliedMigration(tx, m.file); err != nil {
return fmt.Errorf("Failed to save applied migration info for %s: %w", m.file, err)
if err := r.saveAppliedMigration(tx, m.File); err != nil {
return fmt.Errorf("Failed to save applied migration info for %s: %w", m.File, err)
}
applied = append(applied, m.file)
applied = append(applied, m.File)
}
return nil
@@ -146,7 +149,7 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) {
m := r.migrationsList.Item(i)
// skip unapplied
if !r.isMigrationApplied(tx, m.file) {
if !r.isMigrationApplied(tx, m.File) {
continue
}
@@ -155,15 +158,18 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) {
break
}
if err := m.down(tx); err != nil {
return fmt.Errorf("Failed to revert migration %s: %w", m.file, err)
// ignore empty Down action
if m.Down != nil {
if err := m.Down(tx); err != nil {
return fmt.Errorf("Failed to revert migration %s: %w", m.File, err)
}
}
if err := r.saveRevertedMigration(tx, m.file); err != nil {
return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.file, err)
if err := r.saveRevertedMigration(tx, m.File); err != nil {
return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.File, err)
}
reverted = append(reverted, m.file)
reverted = append(reverted, m.File)
}
return nil
+1 -1
View File
@@ -79,7 +79,7 @@ func TestRunnerUpAndDown(t *testing.T) {
}
// simulate partially run migration
r.saveAppliedMigration(testDB, r.migrationsList.Item(0).file)
r.saveAppliedMigration(testDB, r.migrationsList.Item(0).File)
// Up()
// ---
+1 -1
View File
@@ -77,7 +77,7 @@ func FindUploadedFiles(r *http.Request, key string) ([]*UploadedFile, error) {
sanitizedName := inflector.Snakecase(originalName)
if length := len(sanitizedName); length < 3 {
// the name is too short so we concatenate an additional random part
sanitizedName += ("_" + security.RandomString(10))
sanitizedName += security.RandomString(10)
} else if length > 100 {
// keep only the first 100 characters (it is multibyte safe after Snakecase)
sanitizedName = sanitizedName[:100]
+1 -1
View File
@@ -17,7 +17,7 @@ func TestFindUploadedFiles(t *testing.T) {
filename string
expectedPattern string
}{
{"ab.png", `^ab_\w{10}_\w{10}\.png$`},
{"ab.png", `^ab\w{10}_\w{10}\.png$`},
{"test", `^test_\w{10}\.txt$`},
{"a b c d!@$.j!@$pg", `^a_b_c_d_\w{10}\.jpg$`},
{strings.Repeat("a", 150), `^a{100}_\w{10}\.txt$`},
+1 -1
View File
@@ -7,4 +7,4 @@ PB_FILE_UPLOAD_DOCS = "https://pocketbase.io/docs/files-handling/"
PB_JS_SDK_URL = "https://github.com/pocketbase/js-sdk"
PB_DART_SDK_URL = "https://github.com/pocketbase/dart-sdk"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.8.0"
PB_VERSION = "v0.9.0"
+4
View File
@@ -1,3 +1,7 @@
/node_modules/
/.vscode/
.DS_Store
# exclude local env files
.env.local
.env.*.local
@@ -1,4 +1,4 @@
import{S as ke,i as be,s as ge,e as r,w as b,b as g,c as _e,f as k,g as h,h as n,m as me,x as G,N as re,O as we,k as ve,P as Ce,n as Pe,t as L,a as Y,o as _,d as pe,Q as Me,C as Se,p as $e,r as H,u as je,M as Ae}from"./index.159355d2.js";import{S as Be}from"./SdkTabs.f9798b59.js";function ue(a,l,o){const s=a.slice();return s[5]=l[o],s}function de(a,l,o){const s=a.slice();return s[5]=l[o],s}function fe(a,l){let o,s=l[5].code+"",m,f,i,u;function d(){return l[4](l[5])}return{key:a,first:null,c(){o=r("button"),m=b(s),f=g(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(v,C){h(v,o,C),n(o,m),n(o,f),i||(u=je(o,"click",d),i=!0)},p(v,C){l=v,C&4&&s!==(s=l[5].code+"")&&G(m,s),C&6&&H(o,"active",l[1]===l[5].code)},d(v){v&&_(o),i=!1,u()}}}function he(a,l){let o,s,m,f;return s=new Ae({props:{content:l[5].body}}),{key:a,first:null,c(){o=r("div"),_e(s.$$.fragment),m=g(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(i,u){h(i,o,u),me(s,o,null),n(o,m),f=!0},p(i,u){l=i;const d={};u&4&&(d.content=l[5].body),s.$set(d),(!f||u&6)&&H(o,"active",l[1]===l[5].code)},i(i){f||(L(s.$$.fragment,i),f=!0)},o(i){Y(s.$$.fragment,i),f=!1},d(i){i&&_(o),pe(s)}}}function Oe(a){var ae,ne;let l,o,s=a[0].name+"",m,f,i,u,d,v,C,F=a[0].name+"",U,X,q,P,D,j,W,M,K,R,Q,A,Z,V,y=a[0].name+"",I,x,E,B,J,S,O,w=[],ee=new Map,te,T,p=[],le=new Map,$;P=new Be({props:{js:`
import{S as ke,i as be,s as ge,e as r,w as b,b as g,c as _e,f as k,g as h,h as n,m as me,x as G,N as re,O as we,k as ve,P as Ce,n as Pe,t as L,a as Y,o as _,d as pe,Q as Me,C as Se,p as $e,r as H,u as je,M as Ae}from"./index.662e825a.js";import{S as Be}from"./SdkTabs.1e98a608.js";function ue(a,l,o){const s=a.slice();return s[5]=l[o],s}function de(a,l,o){const s=a.slice();return s[5]=l[o],s}function fe(a,l){let o,s=l[5].code+"",m,f,i,u;function d(){return l[4](l[5])}return{key:a,first:null,c(){o=r("button"),m=b(s),f=g(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(v,C){h(v,o,C),n(o,m),n(o,f),i||(u=je(o,"click",d),i=!0)},p(v,C){l=v,C&4&&s!==(s=l[5].code+"")&&G(m,s),C&6&&H(o,"active",l[1]===l[5].code)},d(v){v&&_(o),i=!1,u()}}}function he(a,l){let o,s,m,f;return s=new Ae({props:{content:l[5].body}}),{key:a,first:null,c(){o=r("div"),_e(s.$$.fragment),m=g(),k(o,"class","tab-item"),H(o,"active",l[1]===l[5].code),this.first=o},m(i,u){h(i,o,u),me(s,o,null),n(o,m),f=!0},p(i,u){l=i;const d={};u&4&&(d.content=l[5].body),s.$set(d),(!f||u&6)&&H(o,"active",l[1]===l[5].code)},i(i){f||(L(s.$$.fragment,i),f=!0)},o(i){Y(s.$$.fragment,i),f=!1},d(i){i&&_(o),pe(s)}}}function Oe(a){var ae,ne;let l,o,s=a[0].name+"",m,f,i,u,d,v,C,F=a[0].name+"",U,X,q,P,D,j,W,M,K,R,Q,A,Z,V,y=a[0].name+"",I,x,E,B,J,S,O,w=[],ee=new Map,te,T,p=[],le=new Map,$;P=new Be({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${a[3]}');
@@ -1,4 +1,4 @@
import{S as ze,i as Ue,s as je,M as Ve,e as a,w as k,b as p,c as ae,f as b,g as c,h as o,m as ne,x as re,N as qe,O as xe,k as Ie,P as Je,n as Ke,t as U,a as j,o as d,d as ie,Q as Qe,C as He,p as We,r as x,u as Ge}from"./index.159355d2.js";import{S as Xe}from"./SdkTabs.f9798b59.js";function Ee(r,l,s){const n=r.slice();return n[5]=l[s],n}function Fe(r,l,s){const n=r.slice();return n[5]=l[s],n}function Le(r,l){let s,n=l[5].code+"",m,_,i,f;function v(){return l[4](l[5])}return{key:r,first:null,c(){s=a("button"),m=k(n),_=p(),b(s,"class","tab-item"),x(s,"active",l[1]===l[5].code),this.first=s},m(g,w){c(g,s,w),o(s,m),o(s,_),i||(f=Ge(s,"click",v),i=!0)},p(g,w){l=g,w&4&&n!==(n=l[5].code+"")&&re(m,n),w&6&&x(s,"active",l[1]===l[5].code)},d(g){g&&d(s),i=!1,f()}}}function Ne(r,l){let s,n,m,_;return n=new Ve({props:{content:l[5].body}}),{key:r,first:null,c(){s=a("div"),ae(n.$$.fragment),m=p(),b(s,"class","tab-item"),x(s,"active",l[1]===l[5].code),this.first=s},m(i,f){c(i,s,f),ne(n,s,null),o(s,m),_=!0},p(i,f){l=i;const v={};f&4&&(v.content=l[5].body),n.$set(v),(!_||f&6)&&x(s,"active",l[1]===l[5].code)},i(i){_||(U(n.$$.fragment,i),_=!0)},o(i){j(n.$$.fragment,i),_=!1},d(i){i&&d(s),ie(n)}}}function Ye(r){var Ae,Be;let l,s,n=r[0].name+"",m,_,i,f,v,g,w,A,I,S,F,ce,L,B,de,J,N=r[0].name+"",K,ue,pe,V,Q,D,W,T,G,fe,X,C,Y,he,Z,be,h,me,R,_e,ke,ve,ee,ge,te,ye,Se,$e,oe,we,le,O,se,P,q,$=[],Te=new Map,Ce,H,y=[],Pe=new Map,M;g=new Xe({props:{js:`
import{S as ze,i as Ue,s as je,M as Ve,e as a,w as k,b as p,c as ae,f as b,g as c,h as o,m as ne,x as re,N as qe,O as xe,k as Ie,P as Je,n as Ke,t as U,a as j,o as d,d as ie,Q as Qe,C as He,p as We,r as x,u as Ge}from"./index.662e825a.js";import{S as Xe}from"./SdkTabs.1e98a608.js";function Ee(r,l,s){const n=r.slice();return n[5]=l[s],n}function Fe(r,l,s){const n=r.slice();return n[5]=l[s],n}function Le(r,l){let s,n=l[5].code+"",m,_,i,f;function v(){return l[4](l[5])}return{key:r,first:null,c(){s=a("button"),m=k(n),_=p(),b(s,"class","tab-item"),x(s,"active",l[1]===l[5].code),this.first=s},m(g,w){c(g,s,w),o(s,m),o(s,_),i||(f=Ge(s,"click",v),i=!0)},p(g,w){l=g,w&4&&n!==(n=l[5].code+"")&&re(m,n),w&6&&x(s,"active",l[1]===l[5].code)},d(g){g&&d(s),i=!1,f()}}}function Ne(r,l){let s,n,m,_;return n=new Ve({props:{content:l[5].body}}),{key:r,first:null,c(){s=a("div"),ae(n.$$.fragment),m=p(),b(s,"class","tab-item"),x(s,"active",l[1]===l[5].code),this.first=s},m(i,f){c(i,s,f),ne(n,s,null),o(s,m),_=!0},p(i,f){l=i;const v={};f&4&&(v.content=l[5].body),n.$set(v),(!_||f&6)&&x(s,"active",l[1]===l[5].code)},i(i){_||(U(n.$$.fragment,i),_=!0)},o(i){j(n.$$.fragment,i),_=!1},d(i){i&&d(s),ie(n)}}}function Ye(r){var Ae,Be;let l,s,n=r[0].name+"",m,_,i,f,v,g,w,A,I,S,F,ce,L,B,de,J,N=r[0].name+"",K,ue,pe,V,Q,D,W,T,G,fe,X,C,Y,he,Z,be,h,me,R,_e,ke,ve,ee,ge,te,ye,Se,$e,oe,we,le,O,se,P,q,$=[],Te=new Map,Ce,H,y=[],Pe=new Map,M;g=new Xe({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${r[3]}');
@@ -1,4 +1,4 @@
import{S as je,i as He,s as Je,M as We,e as s,w as v,b as p,c as re,f as h,g as r,h as a,m as ce,x as de,N as Ve,O as Ne,k as Qe,P as ze,n as Ke,t as j,a as H,o as c,d as ue,Q as Ye,C as Be,p as Ge,r as J,u as Xe}from"./index.159355d2.js";import{S as Ze}from"./SdkTabs.f9798b59.js";function Fe(i,l,o){const n=i.slice();return n[5]=l[o],n}function Le(i,l,o){const n=i.slice();return n[5]=l[o],n}function Me(i,l){let o,n=l[5].code+"",m,_,d,b;function g(){return l[4](l[5])}return{key:i,first:null,c(){o=s("button"),m=v(n),_=p(),h(o,"class","tab-item"),J(o,"active",l[1]===l[5].code),this.first=o},m(k,y){r(k,o,y),a(o,m),a(o,_),d||(b=Xe(o,"click",g),d=!0)},p(k,y){l=k,y&4&&n!==(n=l[5].code+"")&&de(m,n),y&6&&J(o,"active",l[1]===l[5].code)},d(k){k&&c(o),d=!1,b()}}}function xe(i,l){let o,n,m,_;return n=new We({props:{content:l[5].body}}),{key:i,first:null,c(){o=s("div"),re(n.$$.fragment),m=p(),h(o,"class","tab-item"),J(o,"active",l[1]===l[5].code),this.first=o},m(d,b){r(d,o,b),ce(n,o,null),a(o,m),_=!0},p(d,b){l=d;const g={};b&4&&(g.content=l[5].body),n.$set(g),(!_||b&6)&&J(o,"active",l[1]===l[5].code)},i(d){_||(j(n.$$.fragment,d),_=!0)},o(d){H(n.$$.fragment,d),_=!1},d(d){d&&c(o),ue(n)}}}function et(i){var qe,Ie;let l,o,n=i[0].name+"",m,_,d,b,g,k,y,C,N,O,L,pe,M,D,he,Q,x=i[0].name+"",z,be,K,q,Y,I,G,P,X,R,Z,fe,ee,$,te,me,ae,_e,f,ve,E,ge,ke,we,le,Se,oe,ye,Oe,Re,se,$e,ne,U,ie,A,V,S=[],Ae=new Map,Ee,B,w=[],Te=new Map,T;k=new Ze({props:{js:`
import{S as je,i as He,s as Je,M as We,e as s,w as v,b as p,c as re,f as h,g as r,h as a,m as ce,x as de,N as Ve,O as Ne,k as Qe,P as ze,n as Ke,t as j,a as H,o as c,d as ue,Q as Ye,C as Be,p as Ge,r as J,u as Xe}from"./index.662e825a.js";import{S as Ze}from"./SdkTabs.1e98a608.js";function Fe(i,l,o){const n=i.slice();return n[5]=l[o],n}function Le(i,l,o){const n=i.slice();return n[5]=l[o],n}function Me(i,l){let o,n=l[5].code+"",m,_,d,b;function g(){return l[4](l[5])}return{key:i,first:null,c(){o=s("button"),m=v(n),_=p(),h(o,"class","tab-item"),J(o,"active",l[1]===l[5].code),this.first=o},m(k,y){r(k,o,y),a(o,m),a(o,_),d||(b=Xe(o,"click",g),d=!0)},p(k,y){l=k,y&4&&n!==(n=l[5].code+"")&&de(m,n),y&6&&J(o,"active",l[1]===l[5].code)},d(k){k&&c(o),d=!1,b()}}}function xe(i,l){let o,n,m,_;return n=new We({props:{content:l[5].body}}),{key:i,first:null,c(){o=s("div"),re(n.$$.fragment),m=p(),h(o,"class","tab-item"),J(o,"active",l[1]===l[5].code),this.first=o},m(d,b){r(d,o,b),ce(n,o,null),a(o,m),_=!0},p(d,b){l=d;const g={};b&4&&(g.content=l[5].body),n.$set(g),(!_||b&6)&&J(o,"active",l[1]===l[5].code)},i(d){_||(j(n.$$.fragment,d),_=!0)},o(d){H(n.$$.fragment,d),_=!1},d(d){d&&c(o),ue(n)}}}function et(i){var qe,Ie;let l,o,n=i[0].name+"",m,_,d,b,g,k,y,C,N,O,L,pe,M,D,he,Q,x=i[0].name+"",z,be,K,q,Y,I,G,P,X,R,Z,fe,ee,$,te,me,ae,_e,f,ve,E,ge,ke,we,le,Se,oe,ye,Oe,Re,se,$e,ne,U,ie,A,V,S=[],Ae=new Map,Ee,B,w=[],Te=new Map,T;k=new Ze({props:{js:`
import PocketBase from 'pocketbase';
const pb = new PocketBase('${i[3]}');

Some files were not shown because too many files have changed in this diff Show More