Compare commits

...
86 Commits
Author SHA1 Message Date
Gani Georgiev 869d1cbcf7 fixed record delete test expected query strings 2022-12-09 19:15:24 +02:00
Gani Georgiev 451611776e updated ui/dist 2022-12-09 19:12:24 +02:00
Gani Georgiev 9c7f48a66e [#1220] fixed field column name conflict on cascade record deletion 2022-12-09 19:09:43 +02:00
Gani Georgiev 59b41c8202 updated changelog 2022-12-09 12:06:37 +02:00
Gani Georgiev 94658712c6 [#1219] fixed events when manual editing the datetime field and added clear button 2022-12-09 12:05:25 +02:00
Gani Georgiev 2c4ac070a3 fixed record delete tests 2022-12-09 11:07:43 +02:00
Gani Georgiev e206e303ca updated changelog 2022-12-09 10:27:54 +02:00
Gani Georgiev e60f470188 call root record delete first 2022-12-09 01:50:37 +02:00
Gani Georgiev 9cf5e28700 replaced QueryString() with QueryParams().Encode() 2022-12-08 13:35:56 +02:00
Gani Georgiev 7aefcd9bf6 updated changelog 2022-12-08 12:18:17 +02:00
Gani Georgiev 693954cdcd [#1187] added Dao query semaphore and base fail/retry 2022-12-08 10:40:42 +02:00
Gani Georgiev 355f7053fd [#1187] move file upload and delete out of the record save transaction 2022-12-06 12:26:29 +02:00
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
Gani Georgiev 550260b381 updated dependencies 2022-11-19 00:38:05 +02:00
Gani Georgiev b9922e4843 updated ui/dist 2022-11-18 23:33:13 +02:00
Gani Georgiev 3c3a61c457 added autocomplete keys refresh debounce 2022-11-18 14:48:57 +02:00
Gani Georgiev aed8367231 fixed autocomplete base collection keys caching 2022-11-18 13:32:32 +02:00
Gani Georgiev 341bcc4a0e skip number validator on zero-default 2022-11-17 22:06:31 +02:00
Gani Georgiev 3b9a9df171 don't resolve request and indirect collection keys if disabled 2022-11-17 19:03:31 +02:00
Gani Georgiev a230cc1719 [#1053] improved filter autocomplete performance 2022-11-17 18:59:25 +02:00
Gani Georgiev 7dee9d5cc4 [#1047] added .jfif to the image extensions list 2022-11-17 14:43:10 +02:00
Gani Georgiev 0b54d1736e modify a clone request data when resolving the auth record response 2022-11-17 14:27:54 +02:00
Gani Georgiev 39408f135b [#943] exposed apis.EnrichRecord and apis.EnrichRecords 2022-11-17 14:18:11 +02:00
Gani Georgiev 6e9cf986c5 [#872] changed the schema required validator to be optional for auth collections 2022-11-16 15:13:04 +02:00
Gani Georgiev 4528f075dc fixed auth collection rule check validator on create 2022-11-15 15:06:46 +02:00
Gani Georgiev f3566149b8 [#1030] fixed auth collection rules validator 2022-11-15 12:03:12 +02:00
Gani Georgiev 9322b13d15 [#1028] added case insensitive collection name lookup 2022-11-15 00:54:29 +02:00
Gani Georgiev 77d295730e changed the hook func argument to e for more consistent autocomplete 2022-11-14 19:30:13 +02:00
Gani Georgiev a998618d75 updated godoc comment 2022-11-14 14:43:20 +02:00
Gani Georgiev 4c096fd745 [#970] added Twitch OAuth2 provider 2022-11-13 14:20:11 +02:00
Gani Georgiev c95e50c8a5 updated the oauth2 providers to use the existing oauth2 endpoints and removed the email from spotify 2022-11-13 13:25:24 +02:00
Gani Georgiev bac5d76725 updated ui/dist 2022-11-13 13:09:32 +02:00
Gani Georgiev 50fce1f3cf [#979] added Kakao OAuth2 provider 2022-11-13 13:05:06 +02:00
Gani Georgiev 521df149a2 updated db pool limits, added logs VACUUM, updated api docs 2022-11-13 00:38:18 +02:00
Gani Georgiev 39dc1d2795 updated api preview docs 2022-11-08 20:53:31 +02:00
Gani Georgiev 3d14addfef changed the return result of the confirm api actions 2022-11-08 18:12:37 +02:00
Gani Georgiev bc519231d9 added wildcard realtime topic support 2022-11-08 12:57:50 +02:00
Gani Georgiev b1c7a012c5 [#961] updated min username length and added tests 2022-11-08 12:55:18 +02:00
Gani Georgiev 01814067b1 updated api preview dummy record date field layout 2022-11-06 15:48:27 +02:00
Gani Georgiev 7225b380d5 fixed PseudorandomString 2022-11-06 15:35:43 +02:00
Gani Georgiev fa791b7e96 init pseudorandom seed 2022-11-06 15:30:56 +02:00
Gani Georgiev 0ff5606d80 renamed PseudoRandom to Pseudorandom 2022-11-06 15:28:41 +02:00
Gani Georgiev 4cddb6b5cb added pseudorandom generator 2022-11-06 15:26:34 +02:00
Gani Georgiev 46dc6cc47c added record.PasswordHash() getter 2022-11-06 11:04:04 +02:00
Gani Georgiev 65693d1916 updated the random generator for more even distribution 2022-11-05 17:55:32 +02:00
Gani Georgiev a2abeb872a added option to toggle the system fields visibility 2022-11-05 13:22:08 +02:00
Gani Georgiev 6115fb3874 updated go deps and loaded auth collection fields for autocomplete 2022-11-04 15:55:25 +02:00
Gani Georgiev cb6ffc1e7b use param.Value when comparing with the refreshed settings state 2022-11-03 15:44:13 +02:00
Gani Georgiev 152f6a9d1f updated app.RefreshSettings and added more tests 2022-11-03 15:01:26 +02:00
Gani Georgiev fe656a1c42 updated api preview docs 2022-11-03 11:36:59 +02:00
Gani Georgiev 099230a552 added missing time import 2022-11-02 21:52:47 +02:00
Gani Georgiev 5e0718176d added db pool size limits and update the min go release action version to 1.19.3 2022-11-02 21:44:23 +02:00
207 changed files with 8863 additions and 3409 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.19.3'
go-version: '>=1.19.4'
# This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts.
-4
View File
@@ -6,10 +6,6 @@
# goreleaser builds folder
/.builds/
# examples app directories
pb_data
pb_public
# tests coverage
coverage.out
+823
View File
@@ -0,0 +1,823 @@
## v0.9.2
- Fixed field column name conflict on record deletion ([#1220](https://github.com/pocketbase/pocketbase/discussions/1220)).
## v0.9.1
- Moved the record file upload and delete out of the db transaction to minimize the locking times.
- Added `Dao` query semaphore and base fail/retry handling to improve the concurrent writes throughput ([#1187](https://github.com/pocketbase/pocketbase/issues/1187)).
- Fixed records cascade deletion when there are "A<->B" relation references.
- Replaced `c.QueryString()` with `c.QueryParams().Encode()` to allow loading middleware modified query parameters in the default crud actions ([#1210](https://github.com/pocketbase/pocketbase/discussions/1210)).
- Fixed the datetime field not triggerering the `onChange` event on manual field edit and added a "Clear" button ([#1219](https://github.com/pocketbase/pocketbase/issues/1219)).
- Updated the GitHub goreleaser action to use go 1.19.4 since it comes with [some security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.19.4+label%3ACherryPickApproved).
## 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!**
The biggest change is the merge of the `User` models and the `profiles` collection per [#376](https://github.com/pocketbase/pocketbase/issues/376).
There is no longer `user` type field and the users are just an "auth" collection (we now support **collection types**, currently only "base" and "auth").
This should simplify the users management and at the same time allow us to have unlimited multiple "auth" collections each with their own custom fields and authentication options (eg. staff, client, etc.).
In addition to the `Users` and `profiles` merge, this release comes with several other improvements:
- Added indirect expand support [#312](https://github.com/pocketbase/pocketbase/issues/312#issuecomment-1242893496).
- The `json` field type now supports filtering and sorting [#423](https://github.com/pocketbase/pocketbase/issues/423#issuecomment-1258302125).
- The `relation` field now allows unlimitted `maxSelect` (aka. without upper limit).
- Added support for combined email/username + password authentication (see below `authWithPassword()`).
- Added support for full _"manager-subordinate"_ users management, including a special API rule to allow directly changing system fields like email, password, etc. without requiring `oldPassword` or other user verification.
- Enabled OAuth2 account linking on authorized request from the same auth collection (_this is useful for example if the OAuth2 provider doesn't return an email and you want to associate it with the current logged in user_).
- Added option to toggle the record columns visibility from the table listing.
- Added support for collection schema fields reordering.
- Added several new OAuth2 providers (Microsoft Azure AD, Spotify, Twitch, Kakao).
- Improved memory usage on large file uploads [#835](https://github.com/pocketbase/pocketbase/discussions/835).
- More detailed API preview docs and site documentation (the repo is located at https://github.com/pocketbase/site).
- Other minor performance improvements (mostly related to the search apis).
### Migrate from v0.7.x
- **[Data](#data)**
- **[SDKs](#sdks)**
- **[API](#api)**
- **[Internals](#internals)**
#### Data
The merge of users and profiles comes with several required db changes.
The easiest way to apply them is to use the new temporary `upgrade` command:
```sh
# make sure to have a copy of your pb_data in case something fails
cp -r ./pb_data ./pb_data_backup
# run the upgrade command
./pocketbase08 upgrade
# start the application as usual
./pocketbase08 serve
```
The upgrade command:
- Creates a new `users` collection with merged fields from the `_users` table and the `profiles` collection.
The new user records will have the ids from the `profiles` collection.
- Changes all `user` type fields to `relation` and update the references to point to the new user ids.
- Renames all `@collection.profiles.*`, `@request.user.*` and `@request.user.profile.*` filters to `@collection.users.*` and `@request.auth.*`.
- Appends `2` to all **schema field names** and **api filter rules** that conflicts with the new system reserved ones:
```
collectionId => collectionId2
collectionName => collectionName2
expand => expand2
// only for the "profiles" collection fields:
username => username2
email => email2
emailVisibility => emailVisibility2
verified => verified2
tokenKey => tokenKey2
passwordHash => passwordHash2
lastResetSentAt => lastResetSentAt2
lastVerificationSentAt => lastVerificationSentAt2
```
#### SDKs
Please check the individual SDK package changelog and apply the necessary changes in your code:
- [**JavaScript SDK changelog**](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
```sh
npm install pocketbase@latest --save
```
- [**Dart SDK changelog**](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
```sh
dart pub add pocketbase:^0.5.0
# or with Flutter:
flutter pub add pocketbase:^0.5.0
```
#### API
> _**You don't have to read this if you are using an official SDK.**_
- The authorization schema is no longer necessary. Now it is auto detected from the JWT token payload:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>Authorization: Admin TOKEN</td>
<td>Authorization: TOKEN</td>
</tr>
<tr valign="top">
<td>Authorization: User TOKEN</td>
<td>Authorization: TOKEN</td>
</tr>
</table>
- All datetime stings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>2022-01-02 03:04:05.678</td>
<td>2022-01-02 03:04:05.678<strong>Z</strong></td>
</tr>
</table>
- Removed the `@` prefix from the system record fields for easier json parsing:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td><strong>@</strong>collectionId</td>
<td>collectionId</td>
</tr>
<tr valign="top">
<td><strong>@</strong>collectionName</td>
<td>collectionName</td>
</tr>
<tr valign="top">
<td><strong>@</strong>expand</td>
<td>expand</td>
</tr>
</table>
- All users api handlers are moved under `/api/collections/:collection/`:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>
<em>GET /api/<strong>users</strong>/auth-methods</em>
</td>
<td>
<em>GET /api/<strong>collections/:collection</strong>/auth-methods</em>
</td>
</tr>
<tr valign="top">
<td>
<em>POST /api/<strong>users/refresh</strong></em>
</td>
<td>
<em>POST /api/<strong>collections/:collection/auth-refresh</strong></em>
</td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users/auth-via-oauth2</strong></em></td>
<td>
<em>POST /api/<strong>collections/:collection/auth-with-oauth2</strong></em>
<br/>
<em>You can now also pass optional <code>createData</code> object on OAuth2 sign-up.</em>
<br/>
<em>Also please note that now required user/profile fields are properly validated when creating new auth model on OAuth2 sign-up.</em>
</td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users/auth-via-email</strong></em></td>
<td>
<em>POST /api/<strong>collections/:collection/auth-with-password</strong></em>
<br/>
<em>Handles username/email + password authentication.</em>
<br/>
<code>{"identity": "usernameOrEmail", "password": "123456"}</code>
</td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/request-password-reset</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/request-password-reset</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/confirm-password-reset</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/confirm-password-reset</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/request-verification</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/request-verification</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/confirm-verification</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/confirm-verification</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/request-email-change</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/request-email-change</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong>/confirm-email-change</em></td>
<td><em>POST /api/<strong>collections/:collection</strong>/confirm-email-change</em></td>
</tr>
<tr valign="top">
<td><em>GET /api/<strong>users</strong></em></td>
<td><em>GET /api/<strong>collections/:collection/records</strong></em></td>
</tr>
<tr valign="top">
<td><em>GET /api/<strong>users</strong>/:id</em></td>
<td><em>GET /api/<strong>collections/:collection/records</strong>/:id</em></td>
</tr>
<tr valign="top">
<td><em>POST /api/<strong>users</strong></em></td>
<td><em>POST /api/<strong>collections/:collection/records</strong></em></td>
</tr>
<tr valign="top">
<td><em>PATCH /api/<strong>users</strong>/:id</em></td>
<td><em>PATCH /api/<strong>collections/:collection/records</strong>/:id</em></td>
</tr>
<tr valign="top">
<td><em>DELETE /api/<strong>users</strong>/:id</em></td>
<td><em>DELETE /api/<strong>collections/:collection/records</strong>/:id</em></td>
</tr>
<tr valign="top">
<td><em>GET /api/<strong>users</strong>/:id/external-auths</em></td>
<td><em>GET /api/<strong>collections/:collection/records</strong>/:id/external-auths</em></td>
</tr>
<tr valign="top">
<td><em>DELETE /api/<strong>users</strong>/:id/external-auths/:provider</em></td>
<td><em>DELETE /api/<strong>collections/:collection/records</strong>/:id/external-auths/:provider</em></td>
</tr>
</table>
_In relation to the above changes, the `user` property in the auth response is renamed to `record`._
- The admins api was also updated for consistency with the users api changes:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>
<em>POST /api/admins/<strong>refresh</strong></em>
</td>
<td>
<em>POST /api/admins/<strong>auth-refresh</strong></em>
</td>
</tr>
<tr valign="top">
<td><em>POST /api/admins/<strong>auth-via-email</strong></em></td>
<td>
<em>POST /api/admins/<strong>auth-with-password</strong></em>
<br />
<code>{"identity": "test@example.com", "password": "123456"}</code>
<br />
(notice that the <code>email</code> body field was renamed to <code>identity</code>)
</td>
</tr>
</table>
- To prevent confusion with the auth method responses, the following endpoints now returns 204 with empty body (previously 200 with token and auth model):
```
POST /api/admins/confirm-password-reset
POST /api/collections/:collection/confirm-password-reset
POST /api/collections/:collection/confirm-verification
POST /api/collections/:collection/confirm-email-change
```
- Renamed the "user" related settings fields returned by `GET /api/settings`:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td><strong>user</strong>AuthToken</td>
<td><strong>record</strong>AuthToken</td>
</tr>
<tr valign="top">
<td><strong>user</strong>PasswordResetToken</td>
<td><strong>record</strong>PasswordResetToken</td>
</tr>
<tr valign="top">
<td><strong>user</strong>EmailChangeToken</td>
<td><strong>record</strong>EmailChangeToken</td>
</tr>
<tr valign="top">
<td><strong>user</strong>VerificationToken</td>
<td><strong>record</strong>VerificationToken</td>
</tr>
</table>
#### Internals
> _**You don't have to read this if you are not using PocketBase as framework.**_
- Removed `forms.New*WithConfig()` factories to minimize ambiguities.
If you need to pass a transaction Dao you can use the new `SetDao(dao)` method available to the form instances.
- `forms.RecordUpsert.LoadData(data map[string]any)` now can bulk load external data from a map.
To load data from a request instance, you could use `forms.RecordUpsert.LoadRequest(r, optKeysPrefix = "")`.
- `schema.RelationOptions.MaxSelect` has new type `*int` (_you can use the new `types.Pointer(123)` helper to assign pointer values_).
- Renamed the constant `apis.ContextUserKey` (_"user"_) to `apis.ContextAuthRecordKey` (_"authRecord"_).
- Replaced user related middlewares with their auth record alternative:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>apis.Require<strong>User</strong>Auth()</td>
<td>apis.Require<strong>Record</strong>Auth(<strong>optCollectionNames ...string</strong>)</td>
</tr>
<tr valign="top">
<td>apis.RequireAdminOr<strong>User</strong>Auth()</td>
<td>apis.RequireAdminOr<strong>Record</strong>Auth(<strong>optCollectionNames ...string</strong>)</td>
</tr>
<tr valign="top">
<td>N/A</td>
<td>
<strong>RequireSameContextRecordAuth()</strong>
<br/>
<em>(requires the auth record to be from the same context collection)</em>
</td>
</tr>
</table>
- The following record Dao helpers now uses the collection id or name instead of `*models.Collection` instance to reduce the verbosity when fetching records:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>dao.FindRecordById(<strong>collection</strong>, ...)</td>
<td>dao.FindRecordById(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
<tr valign="top">
<td>dao.FindRecordsByIds(<strong>collection</strong>, ...)</td>
<td>dao.FindRecordsByIds(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
<tr valign="top">
<td>dao.FindRecordsByExpr(<strong>collection</strong>, ...)</td>
<td>dao.FindRecordsByExpr(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
<tr valign="top">
<td>dao.FindFirstRecordByData(<strong>collection</strong>, ...)</td>
<td>dao.FindFirstRecordByData(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
<tr valign="top">
<td>dao.IsRecordValueUnique(<strong>collection</strong>, ...)</td>
<td>dao.IsRecordValueUnique(<strong>collectionNameOrId</strong>, ...)</td>
</tr>
</table>
- Replaced all User related Dao helpers with Record equivalents:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>dao.UserQuery()</td>
<td>dao.RecordQuery(collection)</td>
</tr>
<tr valign="top">
<td>dao.FindUserById(id)</td>
<td>dao.FindRecordById(collectionNameOrId, id)</td>
</tr>
<tr valign="top">
<td>dao.FindUserByToken(token, baseKey)</td>
<td>dao.FindAuthRecordByToken(token, baseKey)</td>
</tr>
<tr valign="top">
<td>dao.FindUserByEmail(email)</td>
<td>dao.FindAuthRecordByEmail(collectionNameOrId, email)</td>
</tr>
<tr valign="top">
<td>N/A</td>
<td>dao.FindAuthRecordByUsername(collectionNameOrId, username)</td>
</tr>
</table>
- Moved the formatted `ApiError` struct and factories to the `github.com/pocketbase/pocketbase/apis` subpackage:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td colspan="2"><em>Import path</em></td>
</tr>
<tr valign="top">
<td>github.com/pocketbase/pocketbase/<strong>tools/rest</strong></td>
<td>github.com/pocketbase/pocketbase/<strong>apis</strong></td>
</tr>
<tr valign="top">
<td colspan="2"><em>Fields</em></td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.ApiError{}</td>
<td><strong>apis</strong>.ApiError{}</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewNotFoundError()</td>
<td><strong>apis</strong>.NewNotFoundError()</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewBadRequestError()</td>
<td><strong>apis</strong>.NewBadRequestError()</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewForbiddenError()</td>
<td><strong>apis</strong>.NewForbiddenError()</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewUnauthorizedError()</td>
<td><strong>apis</strong>.NewUnauthorizedError()</td>
</tr>
<tr valign="top">
<td><strong>rest</strong>.NewApiError()</td>
<td><strong>apis</strong>.NewApiError()</td>
</tr>
</table>
- Renamed `models.Record` helper getters:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>Set<strong>DataValue</strong></td>
<td>Set</td>
</tr>
<tr valign="top">
<td>Get<strong>DataValue</strong></td>
<td>Get</td>
</tr>
<tr valign="top">
<td>GetBool<strong>DataValue</strong></td>
<td>GetBool</td>
</tr>
<tr valign="top">
<td>GetString<strong>DataValue</strong></td>
<td>GetString</td>
</tr>
<tr valign="top">
<td>GetInt<strong>DataValue</strong></td>
<td>GetInt</td>
</tr>
<tr valign="top">
<td>GetFloat<strong>DataValue</strong></td>
<td>GetFloat</td>
</tr>
<tr valign="top">
<td>GetTime<strong>DataValue</strong></td>
<td>GetTime</td>
</tr>
<tr valign="top">
<td>GetDateTime<strong>DataValue</strong></td>
<td>GetDateTime</td>
</tr>
<tr valign="top">
<td>GetStringSlice<strong>DataValue</strong></td>
<td>GetStringSlice</td>
</tr>
</table>
- Added new auth collection `models.Record` helpers:
```go
func (m *Record) Username() string
func (m *Record) SetUsername(username string) error
func (m *Record) Email() string
func (m *Record) SetEmail(email string) error
func (m *Record) EmailVisibility() bool
func (m *Record) SetEmailVisibility(visible bool) error
func (m *Record) IgnoreEmailVisibility(state bool)
func (m *Record) Verified() bool
func (m *Record) SetVerified(verified bool) error
func (m *Record) TokenKey() string
func (m *Record) SetTokenKey(key string) error
func (m *Record) RefreshTokenKey() error
func (m *Record) LastResetSentAt() types.DateTime
func (m *Record) SetLastResetSentAt(dateTime types.DateTime) error
func (m *Record) LastVerificationSentAt() types.DateTime
func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error
func (m *Record) ValidatePassword(password string) bool
func (m *Record) SetPassword(password string) error
```
- Added option to return serialized custom `models.Record` fields data:
```go
func (m *Record) UnknownData() map[string]any
func (m *Record) WithUnkownData(state bool)
```
- Deleted `model.User`. Now the user data is stored as an auth `models.Record`.
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>User.Email</td>
<td>Record.Email()</td>
</tr>
<tr valign="top">
<td>User.TokenKey</td>
<td>Record.TokenKey()</td>
</tr>
<tr valign="top">
<td>User.Verified</td>
<td>Record.Verified()</td>
</tr>
<tr valign="top">
<td>User.SetPassword()</td>
<td>Record.SetPassword()</td>
</tr>
<tr valign="top">
<td>User.RefreshTokenKey()</td>
<td>Record.RefreshTokenKey()</td>
</tr>
<tr valign="top">
<td colspan="2"><em>etc.</em></td>
</tr>
</table>
- Replaced `User` related event hooks with their `Record` alternative:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>OnMailerBefore<strong>User</strong>ResetPasswordSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerBefore<strong>Record</strong>ResetPasswordSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerAfter<strong>User</strong>ResetPasswordSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerAfter<strong>Record</strong>ResetPasswordSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerBefore<strong>User</strong>VerificationSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerBefore<strong>Record</strong>VerificationSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerAfter<strong>User</strong>VerificationSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerAfter<strong>Record</strong>VerificationSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerBefore<strong>User</strong>ChangeEmailSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerBefore<strong>Record</strong>ChangeEmailSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>OnMailerAfter<strong>User</strong>ChangeEmailSend() *hook.Hook[*Mailer<strong>User</strong>Event]</td>
<td>OnMailerAfter<strong>Record</strong>ChangeEmailSend() *hook.Hook[*Mailer<strong>Record</strong>Event]</td>
</tr>
<tr valign="top">
<td>On<strong>Users</strong>ListRequest() *hook.Hook[*<strong>User</strong>ListEvent]</td>
<td>On<strong>Records</strong>ListRequest() *hook.Hook[*<strong>Records</strong>ListEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>ViewRequest() *hook.Hook[*<strong>User</strong>ViewEvent]</td>
<td>On<strong>Record</strong>ViewRequest() *hook.Hook[*<strong>Record</strong>ViewEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>BeforeCreateRequest() *hook.Hook[*<strong>User</strong>CreateEvent]</td>
<td>On<strong>Record</strong>BeforeCreateRequest() *hook.Hook[*<strong>Record</strong>CreateEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AfterCreateRequest() *hook.Hook[*<strong>User</strong>CreateEvent]</td>
<td>On<strong>Record</strong>AfterCreateRequest() *hook.Hook[*<strong>Record</strong>CreateEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>BeforeUpdateRequest() *hook.Hook[*<strong>User</strong>UpdateEvent]</td>
<td>On<strong>Record</strong>BeforeUpdateRequest() *hook.Hook[*<strong>Record</strong>UpdateEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AfterUpdateRequest() *hook.Hook[*<strong>User</strong>UpdateEvent]</td>
<td>On<strong>Record</strong>AfterUpdateRequest() *hook.Hook[*<strong>Record</strong>UpdateEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>BeforeDeleteRequest() *hook.Hook[*<strong>User</strong>DeleteEvent]</td>
<td>On<strong>Record</strong>BeforeDeleteRequest() *hook.Hook[*<strong>Record</strong>DeleteEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AfterDeleteRequest() *hook.Hook[*<strong>User</strong>DeleteEvent]</td>
<td>On<strong>Record</strong>AfterDeleteRequest() *hook.Hook[*<strong>Record</strong>DeleteEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AuthRequest() *hook.Hook[*<strong>User</strong>AuthEvent]</td>
<td>On<strong>Record</strong>AuthRequest() *hook.Hook[*<strong>Record</strong>AuthEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>ListExternalAuths() *hook.Hook[*<strong>User</strong>ListExternalAuthsEvent]</td>
<td>On<strong>Record</strong>ListExternalAuths() *hook.Hook[*<strong>Record</strong>ListExternalAuthsEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>BeforeUnlinkExternalAuthRequest() *hook.Hook[*<strong>User</strong>UnlinkExternalAuthEvent]</td>
<td>On<strong>Record</strong>BeforeUnlinkExternalAuthRequest() *hook.Hook[*<strong>Record</strong>UnlinkExternalAuthEvent]</td>
</tr>
<tr valign="top">
<td>On<strong>User</strong>AfterUnlinkExternalAuthRequest() *hook.Hook[*<strong>User</strong>UnlinkExternalAuthEvent]</td>
<td>On<strong>Record</strong>AfterUnlinkExternalAuthRequest() *hook.Hook[*<strong>Record</strong>UnlinkExternalAuthEvent]</td>
</tr>
</table>
- Replaced `forms.UserEmailLogin{}` with `forms.RecordPasswordLogin{}` (for both username and email depending on which is enabled for the collection).
- Renamed user related `core.Settings` fields:
<table class="d-table" width="100%">
<tr>
<th>Old</th>
<th>New</th>
</tr>
<tr valign="top">
<td>core.Settings.<strong>User</strong>AuthToken{}</td>
<td>core.Settings.<strong>Record</strong>AuthToken{}</td>
</tr>
<tr valign="top">
<td>core.Settings.<strong>User</strong>PasswordResetToken{}</td>
<td>core.Settings.<strong>Record</strong>PasswordResetToken{}</td>
</tr>
<tr valign="top">
<td>core.Settings.<strong>User</strong>EmailChangeToken{}</td>
<td>core.Settings.<strong>Record</strong>EmailChangeToken{}</td>
</tr>
<tr valign="top">
<td>core.Settings.<strong>User</strong>VerificationToken{}</td>
<td>core.Settings.<strong>Record</strong>VerificationToken{}</td>
</tr>
</table>
- Marked as "Deprecated" and will be removed in v0.9+:
```
core.Settings.EmailAuth{}
core.EmailAuthConfig{}
schema.FieldTypeUser
schema.UserOptions{}
```
- The second argument of `apis.StaticDirectoryHandler(fileSystem, enableIndexFallback)` now is used to enable/disable index.html forwarding on missing file (eg. in case of SPA).
+1 -1
View File
@@ -26,7 +26,7 @@ git clone https://github.com/your_username/pocketbase.git
## Making changes in the Go code
PocketBase is a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
+16 -10
View File
@@ -1,6 +1,6 @@
<p align="center">
<a href="https://pocketbase.io" target="_blank" rel="noopener">
<img src="https://i.imgur.com/ZfD4BHO.png" alt="PocketBase - open source backend in 1 file" />
<img src="https://i.imgur.com/5qimnm5.png" alt="PocketBase - open source backend in 1 file" />
</a>
</p>
@@ -10,10 +10,6 @@
<a href="https://pkg.go.dev/github.com/pocketbase/pocketbase" target="_blank" rel="noopener"><img src="https://godoc.org/github.com/ganigeorgiev/fexpr?status.svg" alt="Go package documentation" /></a>
</p>
> **⚠️ This is a pre-release branch, contains breaking changes and works only with JS SDK v0.8+ and Dart SDK v0.5+!**
>
> **For more details on the changes please check the [v0.8.0-rc release notes](https://github.com/pocketbase/pocketbase/releases/tag/v0.8.0-rc1).**
[PocketBase](https://pocketbase.io) is an open source Go backend, consisting of:
- embedded database (_SQLite_) with **realtime subscriptions**
@@ -67,15 +63,15 @@ func main() {
app := pocketbase.New()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// add new "GET /api/hello" route to the app router (echo)
// add new "GET /hello" route to the app router (echo)
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/hello",
Path: "/hello",
Handler: func(c echo.Context) error {
return c.String(200, "Hello world!")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrRecordAuth(),
apis.ActivityLogger(app),
},
})
@@ -120,6 +116,7 @@ All reports will be promptly addressed and you'll be credited accordingly.
## Contributing
PocketBase is free and open source project licensed under the [MIT License](LICENSE.md).
You are free to do whatever you want with it, even offering it as a paid service.
You could help continuing its development by:
@@ -127,5 +124,14 @@ You could help continuing its development by:
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
- [Donate a small amount](https://pocketbase.io/support-us)
> Please note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
so all feature requests will be first aligned with what we need for Presentator v3.
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
But please refrain creating PRs for _big features_ without previously discussing the implementation details. Reviewing big PRs often requires a lot of time and tedious back-and-forth communication.
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2)
and I try to work on issues in a specific order and such PRs often come in out of nowhere and skew all initial planning.
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
_Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
so all feature requests will be first aligned with what we need for Presentator v3._
+3 -3
View File
@@ -103,12 +103,12 @@ func (api *adminApi) confirmPasswordReset(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
_, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to set new password.", submitErr)
}
return api.authResponse(c, admin)
return c.NoContent(http.StatusNoContent)
}
func (api *adminApi) list(c echo.Context) error {
@@ -120,7 +120,7 @@ func (api *adminApi) list(c echo.Context) error {
result, err := search.NewProvider(fieldResolver).
Query(api.app.Dao().AdminQuery()).
ParseAndExec(c.QueryString(), &admins)
ParseAndExec(c.QueryParams().Encode(), &admins)
if err != nil {
return NewBadRequestError("", err)
+1 -6
View File
@@ -198,15 +198,10 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
"password":"1234567891",
"passwordConfirm":"1234567891"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnAdminAuthRequest": 1,
},
},
}
+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
+1 -1
View File
@@ -36,7 +36,7 @@ func (api *collectionApi) list(c echo.Context) error {
result, err := search.NewProvider(fieldResolver).
Query(api.app.Dao().CollectionQuery()).
ParseAndExec(c.QueryString(), &collections)
ParseAndExec(c.QueryParams().Encode(), &collections)
if err != nil {
return NewBadRequestError("", err)
+8 -4
View File
@@ -845,7 +845,7 @@ func TestCollectionImport(t *testing.T) {
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"collections_import_failure"`,
`"collections":{"code":"collections_import_validate_failure"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
@@ -887,6 +887,10 @@ func TestCollectionImport(t *testing.T) {
"type": "text"
}
]
},
{
"name": "auth_without_schema",
"type": "auth"
}
]
}`),
@@ -897,15 +901,15 @@ func TestCollectionImport(t *testing.T) {
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnCollectionsAfterImportRequest": 1,
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
expected := 9
expected := 10
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
+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)
}
+1 -1
View File
@@ -35,7 +35,7 @@ func (api *logsApi) requestsList(c echo.Context) error {
result, err := search.NewProvider(fieldResolver).
Query(api.app.LogsDao().RequestQuery()).
ParseAndExec(c.QueryString(), &[]*models.Request{})
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
if err != nil {
return NewBadRequestError("", err)
+66 -23
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)
@@ -251,16 +297,10 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
}
// emulate request data
requestData := map[string]any{
"method": "GET",
"query": map[string]any{},
"data": map[string]any{},
"auth": nil,
}
authRecord, _ := client.Get(ContextAuthRecordKey).(*models.Record)
if authRecord != nil {
requestData["auth"] = authRecord.PublicExport()
requestData := &models.RequestData{
Method: "GET",
}
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
@@ -307,8 +347,11 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
collection.Name: collection.ListRule,
collection.Id: collection.ListRule,
(collection.Name + "/*"): collection.ListRule,
(collection.Id + "/*"): collection.ListRule,
// @deprecated: the same as the wildcard topic but kept for backward compatibility
collection.Name: collection.ListRule,
collection.Id: collection.ListRule,
}
data := &recordData{
+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 {
+150 -40
View File
@@ -65,20 +65,20 @@ func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record
}
return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
admin, _ := e.HttpContext.Get(ContextAdminKey).(*models.Admin)
// allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true)
// expand record relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
requestData := exportRequestData(e.HttpContext)
requestData["auth"] = e.Record.PublicExport()
// create a copy of the cached request data and adjust it to the current auth record
requestData := *RequestData(e.HttpContext)
requestData.Admin = nil
requestData.AuthRecord = e.Record
failed := api.app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(api.app.Dao(), admin != nil, requestData),
expandFetch(api.app.Dao(), &requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
@@ -204,8 +204,8 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := exportRequestData(c)
requestData["data"] = form.CreateData
requestData := RequestData(c)
requestData.Data = form.CreateData
createRuleFunc := func(q *dbx.SelectQuery) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -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)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to set new password.", submitErr)
event := &core.RecordConfirmPasswordResetEvent{
HttpContext: c,
}
return api.authResponse(c, record, nil)
_, 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,18 +415,29 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("An error occurred while submitting the form.", submitErr)
event := &core.RecordConfirmVerificationEvent{
HttpContext: c,
}
// don't return an auth response if the collection doesn't allow email or username authentication
authOptions := collection.AuthOptions()
if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
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 api.authResponse(c, record, nil)
return submitErr
}
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
@@ -375,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 {
@@ -393,12 +486,29 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to confirm email change.", submitErr)
event := &core.RecordConfirmEmailChangeEvent{
HttpContext: c,
}
return api.authResponse(c, record, nil)
_, 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 {
@@ -428,7 +538,7 @@ func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
ExternalAuths: externalAuths,
}
return api.app.OnRecordListExternalAuths().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
})
}
+54 -77
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,
},
},
{
@@ -458,29 +460,18 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
{
Name: "valid token and data",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-password-reset?expand=rel,missing",
Url: "/api/collections/users/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
"password":"12345678",
"passwordConfirm":"12345678"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"record":`,
`"id":"4q1xlclmfloku33"`,
`"email":"test@example.com"`,
`"expand":`,
`"rel":`,
`"id":"llvuca81nly1qls"`,
},
NotExpectedContent: []string{
`"missing":`,
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmPasswordResetRequest": 1,
"OnRecordAfterConfirmPasswordResetRequest": 1,
},
},
}
@@ -531,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",
@@ -540,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,
},
},
{
@@ -553,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")
@@ -634,65 +635,45 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
{
Name: "valid token",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-verification?expand=rel,missing",
Url: "/api/collections/users/confirm-verification",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"record":`,
`"id":"4q1xlclmfloku33"`,
`"email":"test@example.com"`,
`"verified":true`,
`"expand":`,
`"rel":`,
`"id":"llvuca81nly1qls"`,
},
NotExpectedContent: []string{
`"missing":`,
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
{
Name: "valid token (already verified)",
Method: http.MethodPost,
Url: "/api/collections/users/confirm-verification?expand=rel,missing",
Url: "/api/collections/users/confirm-verification",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"record":`,
`"id":"oap640cot4yru2s"`,
`"email":"test2@example.com"`,
`"verified":true`,
},
NotExpectedContent: []string{
`"expand":`, // no rel id attached
`"missing":`,
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
{
Name: "valid verification token from a collection without allowed login",
Method: http.MethodPost,
Url: "/api/collections/nologin/confirm-verification?expand=rel,missing",
Url: "/api/collections/nologin/confirm-verification",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRjNDlrNmpnZWpuNDBoMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6ImtwdjcwOXNrMmxxYnFrOCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.coREjeTDS3_Go7DP1nxHtevIX5rujwHU-_mRB6oOm3w"
}`),
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmVerificationRequest": 1,
"OnRecordAfterConfirmVerificationRequest": 1,
},
},
}
@@ -791,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,
},
},
}
@@ -871,18 +854,12 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
"password":"1234567890"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"token":`,
`"record":`,
`"id":"4q1xlclmfloku33"`,
`"email":"change@example.com"`,
`"verified":true`,
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnRecordAuthRequest": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
"OnRecordBeforeConfirmEmailChangeRequest": 1,
"OnRecordAfterConfirmEmailChangeRequest": 1,
},
},
{
@@ -934,7 +911,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{`[]`},
ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1},
ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
},
{
Name: "admin + existing user id and 2 external auths",
@@ -950,7 +927,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
`"recordId":"4q1xlclmfloku33"`,
`"collectionId":"_pb_users_auth_"`,
},
ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1},
ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
},
{
Name: "auth record + trying to list another user external auths",
@@ -981,7 +958,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{`[]`},
ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1},
ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
},
{
Name: "authorized as user - owner with 2 external auths",
@@ -997,7 +974,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
`"recordId":"4q1xlclmfloku33"`,
`"collectionId":"_pb_users_auth_"`,
},
ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1},
ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
},
}
+49 -117
View File
@@ -46,64 +46,41 @@ func (api *recordApi) list(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
// forbid users and guests to query special filter/sort fields
if err := api.checkForForbiddenQueryFields(c); err != nil {
return err
}
requestData := exportRequestData(c)
requestData := RequestData(c)
if requestData.Admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
fieldsResolver := resolvers.NewRecordFieldResolver(
api.app.Dao(),
collection,
requestData,
// hidden fields are searchable only by admins
admin != nil,
requestData.Admin != nil,
)
searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection))
if admin == nil && collection.ListRule != nil {
if requestData.Admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
}
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
if err != nil {
return NewBadRequestError("Invalid filter parameters.", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
// expand records relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
failed := api.app.Dao().ExpandRecords(
records,
expands,
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
if collection.IsAuth() {
err := autoIgnoreAuthRecordsEmailVisibility(
api.app.Dao(), records, admin != nil, requestData,
)
if err != nil && api.app.IsDebug() {
log.Println("IgnoreEmailVisibility failure:", err)
}
}
result.Items = records
event := &core.RecordsListEvent{
@@ -114,6 +91,10 @@ func (api *recordApi) list(c echo.Context) error {
}
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
@@ -124,21 +105,20 @@ func (api *recordApi) view(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ViewRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return NewNotFoundError("", nil)
}
requestData := exportRequestData(c)
requestData := RequestData(c)
if requestData.Admin == nil && collection.ViewRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
@@ -155,31 +135,16 @@ func (api *recordApi) view(c echo.Context) error {
return NewNotFoundError("", fetchErr)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
record,
strings.Split(c.QueryParam(expandQueryParam), ","),
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
if collection.IsAuth() {
err := autoIgnoreAuthRecordsEmailVisibility(
api.app.Dao(), []*models.Record{record}, admin != nil, requestData,
)
if err != nil && api.app.IsDebug() {
log.Println("IgnoreEmailVisibility failure:", err)
}
}
event := &core.RecordViewEvent{
HttpContext: c,
Record: record,
}
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -190,18 +155,17 @@ func (api *recordApi) create(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.CreateRule == nil {
requestData := RequestData(c)
if requestData.Admin == nil && collection.CreateRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
requestData := exportRequestData(c)
hasFullManageAccess := admin != nil
hasFullManageAccess := requestData.Admin != nil
// temporary save the record and check it against the create rule
if admin == nil && collection.CreateRule != nil {
if requestData.Admin == nil && collection.CreateRule != nil {
createRuleFunc := func(q *dbx.SelectQuery) error {
if *collection.CreateRule == "" {
return nil // no create rule to resolve
@@ -227,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
@@ -260,23 +224,8 @@ func (api *recordApi) create(c echo.Context) error {
return NewBadRequestError("Failed to create record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
if collection.IsAuth() {
err := autoIgnoreAuthRecordsEmailVisibility(
api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData,
)
if err != nil && api.app.IsDebug() {
log.Println("IgnoreEmailVisibility failure:", err)
}
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
@@ -297,21 +246,20 @@ func (api *recordApi) update(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.UpdateRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return NewNotFoundError("", nil)
}
requestData := exportRequestData(c)
requestData := RequestData(c)
if requestData.Admin == nil && collection.UpdateRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
@@ -330,7 +278,7 @@ func (api *recordApi) update(c echo.Context) error {
}
form := forms.NewRecordUpsert(api.app, record)
form.SetFullManageAccess(admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
// load request
if err := form.LoadRequest(c.Request(), ""); err != nil {
@@ -350,23 +298,8 @@ func (api *recordApi) update(c echo.Context) error {
return NewBadRequestError("Failed to update record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
if collection.IsAuth() {
err := autoIgnoreAuthRecordsEmailVisibility(
api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData,
)
if err != nil && api.app.IsDebug() {
log.Println("IgnoreEmailVisibility failure:", err)
}
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
log.Println(err)
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
@@ -387,21 +320,20 @@ func (api *recordApi) delete(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.DeleteRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return NewNotFoundError("", nil)
}
requestData := exportRequestData(c)
requestData := RequestData(c)
if requestData.Admin == nil && collection.DeleteRule == nil {
// only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil)
}
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
+1 -1
View File
@@ -731,7 +731,7 @@ func TestRecordCrudDelete(t *testing.T) {
ExpectedEvents: map[string]int{
"OnRecordBeforeDeleteRequest": 1,
"OnModelBeforeUpdate": 1, // self_rel_many update of test1 record
"OnModelBeforeDelete": 1, // rel_one_cascade of test1 record
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
},
},
{
+72 -35
View File
@@ -2,6 +2,7 @@ package apis
import (
"fmt"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
@@ -10,45 +11,82 @@ import (
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/spf13/cast"
)
// exportRequestData exports a map with common request fields.
//
// @todo consider changing the map to a typed struct after v0.8 and the
// IN operator support.
func exportRequestData(c echo.Context) map[string]any {
result := map[string]any{}
queryParams := map[string]any{}
bodyData := map[string]any{}
method := c.Request().Method
const ContextRequestDataKey = "requestData"
echo.BindQueryParams(c, &queryParams)
// Deprecated: Will be removed after v0.9. Use apis.RequestData(c) instead.
func GetRequestData(c echo.Context) *models.RequestData {
return RequestData(c)
}
rest.BindBody(c, &bodyData)
result["method"] = method
result["query"] = queryParams
result["data"] = bodyData
result["auth"] = nil
auth, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if auth != nil {
result["auth"] = auth.PublicExport()
// RequestData exports cached common request data fields
// (query, body, logged auth state, etc.) from the provided context.
func RequestData(c echo.Context) *models.RequestData {
// return cached to avoid copying the body multiple times
if v := c.Get(ContextRequestDataKey); v != nil {
if data, ok := v.(*models.RequestData); ok {
return data
}
}
result := &models.RequestData{
Method: c.Request().Method,
Query: map[string]any{},
Data: map[string]any{},
}
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
result.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
echo.BindQueryParams(c, &result.Query)
rest.BindBody(c, &result.Data)
c.Set(ContextRequestDataKey, result)
return result
}
// EnrichRecord parses the request context and enrich the provided record:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth record and its expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...)
}
// EnrichRecords parses the request context and enriches the provided records:
// - expands relations (if defaultExpands and/or ?expand query param is set)
// - ensures that the emails of the auth records and their expanded auth relations
// are visibe only for the current logged admin, record owner or record with manage access
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
requestData := RequestData(c)
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
return fmt.Errorf("Failed to resolve email visibility: %w", err)
}
expands := defaultExpands
expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...)
if len(expands) == 0 {
return nil // nothing to expand
}
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
if len(errs) > 0 {
return fmt.Errorf("Failed to expand: %v", errs)
}
return nil
}
// expandFetch is the records fetch function that is used to expand related records.
func expandFetch(
dao *daos.Dao,
isAdmin bool,
requestData map[string]any,
requestData *models.RequestData,
) daos.ExpandFetchFunc {
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
if isAdmin {
if requestData.Admin != nil {
return nil // admins can access everything
}
@@ -70,7 +108,7 @@ func expandFetch(
})
if err == nil && len(records) > 0 {
autoIgnoreAuthRecordsEmailVisibility(dao, records, isAdmin, requestData)
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
}
return records, err
@@ -84,14 +122,13 @@ func expandFetch(
func autoIgnoreAuthRecordsEmailVisibility(
dao *daos.Dao,
records []*models.Record,
isAdmin bool,
requestData map[string]any,
requestData *models.RequestData,
) error {
if len(records) == 0 || !records[0].Collection().IsAuth() {
return nil // nothing to check
}
if isAdmin {
if requestData.Admin != nil {
for _, rec := range records {
rec.IgnoreEmailVisibility(true)
}
@@ -101,14 +138,14 @@ 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 auth, ok := requestData["auth"].(map[string]any); ok && mappedRecords[cast.ToString(auth["id"])] != nil {
mappedRecords[cast.ToString(auth["id"])].IgnoreEmailVisibility(true)
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
}
authOptions := collection.AuthOptions()
@@ -153,7 +190,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
func hasAuthManageAccess(
dao *daos.Dao,
record *models.Record,
requestData map[string]any,
requestData *models.RequestData,
) bool {
if !record.Collection().IsAuth() {
return false
@@ -165,7 +202,7 @@ func hasAuthManageAccess(
return false // only for admins (manageRule can't be empty)
}
if auth, ok := requestData["auth"].(map[string]any); !ok || cast.ToString(auth["id"]) == "" {
if requestData == nil || requestData.AuthRecord == nil {
return false // no auth record
}
+101
View File
@@ -0,0 +1,101 @@
package apis_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestRequestData(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
dummyRecord := &models.Record{}
dummyRecord.Id = "id1"
c.Set(apis.ContextAuthRecordKey, dummyRecord)
dummyAdmin := &models.Admin{}
dummyAdmin.Id = "id2"
c.Set(apis.ContextAdminKey, dummyAdmin)
result := apis.RequestData(c)
if result == nil {
t.Fatal("Expected *models.RequestData instance, got nil")
}
if result.Method != http.MethodPost {
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
}
rawQuery, _ := json.Marshal(result.Query)
expectedQuery := `{"test":"123"}`
if v := string(rawQuery); v != expectedQuery {
t.Fatalf("Expected Query %v, got %v", expectedQuery, v)
}
rawData, _ := json.Marshal(result.Data)
expectedData := `{"test":456}`
if v := string(rawData); v != expectedData {
t.Fatalf("Expected Data %v, got %v", expectedData, v)
}
if result.AuthRecord == nil || result.AuthRecord.Id != dummyRecord.Id {
t.Fatalf("Expected AuthRecord %v, got %v", dummyRecord, result.AuthRecord)
}
if result.Admin == nil || result.Admin.Id != dummyAdmin.Id {
t.Fatalf("Expected Admin %v, got %v", dummyAdmin, result.Admin)
}
}
func TestEnrichRecords(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
dummyAdmin := &models.Admin{}
dummyAdmin.Id = "test_id"
c.Set(apis.ContextAdminKey, dummyAdmin)
app, _ := tests.NewTestApp()
defer app.Cleanup()
records, err := app.Dao().FindRecordsByIds("demo1", []string{"al1h9ijdeojtsjy", "84nmscqy84lsi1t"})
if err != nil {
t.Fatal(err)
}
apis.EnrichRecords(c, app.Dao(), records, "rel_one")
for _, record := range records {
expand := record.Expand()
if len(expand) == 0 {
t.Fatalf("Expected non-empty expand, got nil for record %v", record)
}
if len(record.GetStringSlice("rel_one")) != 0 {
if _, ok := expand["rel_one"]; !ok {
t.Fatalf("Expected rel_one to be expanded for record %v, got \n%v", record, expand)
}
}
if len(record.GetStringSlice("rel_many")) != 0 {
if _, ok := expand["rel_many"]; !ok {
t.Fatalf("Expected rel_many to be expanded for record %v, got \n%v", record, expand)
}
}
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ func (api *settingsApi) testS3(c echo.Context) error {
}
defer fs.Close()
testFileKey := "pb_test_" + security.RandomString(5) + "/test.txt"
testFileKey := "pb_test_" + security.PseudorandomString(5) + "/test.txt"
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
+19 -13
View File
@@ -56,6 +56,8 @@ func TestSettingsList(t *testing.T) {
`"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
@@ -121,9 +123,11 @@ func TestSettingsSet(t *testing.T) {
`"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"Acme"`,
`"appName":"acme_test"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -175,6 +179,8 @@ func TestSettingsSet(t *testing.T) {
`"discordAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
@@ -297,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,
@@ -328,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,
@@ -359,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",
+105 -5
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,10 +308,76 @@ type App interface {
// record data and token.
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
// OnRecordListExternalAuths hook is triggered on each API record external auths list request.
// 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.
OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent]
OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent]
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion).
@@ -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.
+172 -77
View File
@@ -3,7 +3,6 @@ package core
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"os"
@@ -14,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"
)
@@ -33,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]
@@ -63,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]
@@ -85,13 +91,25 @@ type BaseApp struct {
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
// user api event hooks
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
onRecordListExternalAuths *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]
@@ -101,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]
@@ -124,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]{},
@@ -150,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]{},
@@ -172,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]{},
onRecordListExternalAuths: &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]{},
@@ -209,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
@@ -227,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
}
@@ -294,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
}
@@ -348,71 +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
}
if param == nil {
// no settings were previously stored
return app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
// no settings were previously stored
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
}
if plainDecodeErr == nil && encryptionKey != "" {
// save because previously the settings weren't stored encrypted
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
// -------------------------------------------------------------------
@@ -485,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
}
@@ -558,15 +598,63 @@ 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) OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent] {
return app.onRecordListExternalAuths
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
}
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
@@ -578,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] {
@@ -680,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)
}
}
@@ -705,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 {
@@ -713,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 {
@@ -721,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
+63
View File
@@ -0,0 +1,63 @@
package core_test
import (
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestBaseAppRefreshSettings(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
// cleanup all stored settings
if _, err := app.DB().NewQuery("DELETE from _params;").Execute(); err != nil {
t.Fatalf("Failed to delete all test settings: %v", err)
}
// check if the new settings are saved in the db
app.ResetEventCalls()
if err := app.RefreshSettings(); err != nil {
t.Fatal("Failed to refresh the settings after delete")
}
testEventCalls(t, app, map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
})
param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
if err != nil {
t.Fatalf("Expected new settings to be persisted, got %v", err)
}
// 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)
}
app.ResetEventCalls()
if err := app.RefreshSettings(); err != nil {
t.Fatalf("Failed to refresh the app settings: %v", err)
}
testEventCalls(t, app, nil)
// 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)
}
testEventCalls(t, app, nil)
}
func testEventCalls(t *testing.T, app *tests.TestApp, events map[string]int) {
if len(events) != len(app.EventCalls) {
t.Fatalf("Expected events doesn't match: \n%v, \ngot \n%v", events, app.EventCalls)
}
for name, total := range events {
if v, ok := app.EventCalls[name]; !ok || v != total {
t.Fatalf("Expected events doesn't exist or match: \n%v, \ngot \n%v", events, app.EventCalls)
}
}
}
+2 -2
View File
@@ -319,8 +319,8 @@ func TestBaseAppGetters(t *testing.T) {
t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest)
}
if app.onRecordListExternalAuths != app.OnRecordListExternalAuths() || app.OnRecordListExternalAuths() == nil {
t.Fatalf("Getter app.OnRecordListExternalAuths does not match or nil (%v vs %v)", app.OnRecordListExternalAuths(), app.onRecordListExternalAuths)
if app.onRecordListExternalAuthsRequest != app.OnRecordListExternalAuthsRequest() || app.OnRecordListExternalAuthsRequest() == nil {
t.Fatalf("Getter app.OnRecordListExternalAuthsRequest does not match or nil (%v vs %v)", app.OnRecordListExternalAuthsRequest(), app.onRecordListExternalAuthsRequest)
}
if app.onRecordBeforeUnlinkExternalAuthRequest != app.OnRecordBeforeUnlinkExternalAuthRequest() || app.OnRecordBeforeUnlinkExternalAuthRequest() == nil {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
_ "github.com/mattn/go-sqlite3"
)
func connectDB(dbPath string) (*dbx.DB, error) {
@@ -24,7 +24,7 @@ func connectDB(dbPath string) (*dbx.DB, error) {
// use a fixed connection pool to limit the SQLITE_BUSY errors
// and reduce the open file descriptors
// (the limits are arbitrary and may change in the future)
db.DB().SetMaxOpenConns(500)
db.DB().SetMaxOpenConns(800)
db.DB().SetMaxIdleConns(30)
db.DB().SetConnMaxIdleTime(5 * time.Minute)
+1 -1
View File
@@ -26,7 +26,7 @@ func connectDB(dbPath string) (*dbx.DB, error) {
// (the limits are arbitrary and may change in the future)
//
// @see https://gitlab.com/cznic/sqlite/-/issues/115
db.DB().SetMaxOpenConns(500)
db.DB().SetMaxOpenConns(800)
db.DB().SetMaxIdleConns(30)
db.DB().SetConnMaxIdleTime(5 * time.Minute)
+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
// -------------------------------------------------------------------
+114 -18
View File
@@ -4,13 +4,19 @@
package daos
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"golang.org/x/sync/semaphore"
)
const DefaultMaxFailRetries = 5
// New creates a new Dao instance with the provided db builder.
func New(db dbx.Builder) *Dao {
return &Dao{
@@ -21,7 +27,9 @@ func New(db dbx.Builder) *Dao {
// Dao handles various db operations.
// Think of Dao as a repository and service layer in one.
type Dao struct {
db dbx.Builder
db dbx.Builder
sem *semaphore.Weighted
mux sync.RWMutex
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
AfterCreateFunc func(eventDao *Dao, m models.Model)
@@ -36,11 +44,51 @@ func (dao *Dao) DB() dbx.Builder {
return dao.db
}
// Block acquires a lock and blocks all other go routines that uses
// the Dao instance until dao.Continue() is called, effectively making
// the concurrent requests to perform synchronous db operations.
//
// This method should be used only as a last resort and as a workaround
// for the SQLITE_BUSY error when mixing read&write in a transaction.
//
// Example:
//
// func someLongRunningTransaction() error {
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// defer cancel()
// if err := app.Dao().Block(ctx); err != nil {
// return err
// }
// defer app.Dao().Continue()
//
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
// // some long running read&write transaction...
// })
// }
func (dao *Dao) Block(ctx context.Context) error {
if dao.sem == nil {
dao.mux.Lock()
dao.sem = semaphore.NewWeighted(1)
dao.mux.Unlock()
}
return dao.sem.Acquire(ctx, 1)
}
// Continue releases the previously acquired Block() lock.
func (dao *Dao) Continue() {
if dao.sem == nil {
return
}
dao.sem.Release(1)
}
// ModelQuery creates a new query with preset Select and From fields
// based on the provided model argument.
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
tableName := m.TableName()
return dao.db.Select(fmt.Sprintf("{{%s}}.*", tableName)).From(tableName)
return dao.db.Select("{{" + tableName + "}}.*").From(tableName)
}
// FindById finds a single db record with the specified id and
@@ -63,7 +111,17 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
case *dbx.Tx:
// nested transactions are not supported by default
// so execute the function within the current transaction
return fn(dao)
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
txDao := New(txOrDB)
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
txDao.AfterCreateFunc = dao.AfterCreateFunc
txDao.AfterUpdateFunc = dao.AfterUpdateFunc
txDao.AfterDeleteFunc = dao.AfterDeleteFunc
return fn(txDao)
case *dbx.DB:
afterCalls := []afterCallGroup{}
@@ -131,30 +189,36 @@ func (dao *Dao) Delete(m models.Model) error {
return errors.New("ID is not set")
}
if dao.BeforeDeleteFunc != nil {
if err := dao.BeforeDeleteFunc(dao, m); err != nil {
return dao.failRetry(func(retryDao *Dao) error {
if retryDao.BeforeDeleteFunc != nil {
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
return err
}
}
if err := retryDao.db.Model(m).Delete(); err != nil {
return err
}
}
if err := dao.db.Model(m).Delete(); err != nil {
return err
}
if retryDao.AfterDeleteFunc != nil {
retryDao.AfterDeleteFunc(retryDao, m)
}
if dao.AfterDeleteFunc != nil {
dao.AfterDeleteFunc(dao, m)
}
return nil
return nil
}, DefaultMaxFailRetries)
}
// Save upserts (update or create if primary key is not set) the provided model.
func (dao *Dao) Save(m models.Model) error {
if m.IsNew() {
return dao.create(m)
return dao.failRetry(func(retryDao *Dao) error {
return retryDao.create(m)
}, DefaultMaxFailRetries)
}
return dao.update(m)
return dao.failRetry(func(retryDao *Dao) error {
return retryDao.update(m)
}, DefaultMaxFailRetries)
}
func (dao *Dao) update(m models.Model) error {
@@ -239,7 +303,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)
@@ -247,3 +311,35 @@ func (dao *Dao) create(m models.Model) error {
return nil
}
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
retryDao := dao
attempts := 1
Retry:
if attempts == 2 {
// assign new Dao without the before hooks to avoid triggering
// the already fired before callbacks multiple times
retryDao = &Dao{
db: dao.db,
AfterCreateFunc: dao.AfterCreateFunc,
AfterUpdateFunc: dao.AfterUpdateFunc,
AfterDeleteFunc: dao.AfterDeleteFunc,
}
}
// execute
err := op(retryDao)
if err != nil &&
attempts < maxRetries &&
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
strings.Contains(err.Error(), "database is locked") {
// wait and retry
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
attempts++
goto Retry
}
return err
}
+164
View File
@@ -289,6 +289,170 @@ func TestDaoDelete(t *testing.T) {
}
}
func TestDaoRetryCreate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init mock retry dao
retryBeforeCreateHookCalls := 0
retryAfterCreateHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeCreateHookCalls++
return errors.New("database is locked")
}
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
retryAfterCreateHookCalls++
}
model := &models.Admin{Email: "new@example.com"}
if err := retryDao.Save(model); err != nil {
t.Fatalf("Expected nil after retry, got error: %v", err)
}
// the before hook is expected to be called only once because
// it is ignored after the first "database is locked" error
if retryBeforeCreateHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
}
if retryAfterCreateHookCalls != 1 {
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterCreateHookCalls)
}
// with non-locking error
retryBeforeCreateHookCalls = 0
retryAfterCreateHookCalls = 0
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeCreateHookCalls++
return errors.New("non-locking error")
}
dummy := &models.Admin{Email: "test@example.com"}
if err := retryDao.Save(dummy); err == nil {
t.Fatal("Expected error, got nil")
}
if retryBeforeCreateHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
}
if retryAfterCreateHookCalls != 0 {
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterCreateHookCalls)
}
}
func TestDaoRetryUpdate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
model, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
// init mock retry dao
retryBeforeUpdateHookCalls := 0
retryAfterUpdateHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeUpdateHookCalls++
return errors.New("database is locked")
}
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
retryAfterUpdateHookCalls++
}
if err := retryDao.Save(model); err != nil {
t.Fatalf("Expected nil after retry, got error: %v", err)
}
// the before hook is expected to be called only once because
// it is ignored after the first "database is locked" error
if retryBeforeUpdateHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
}
if retryAfterUpdateHookCalls != 1 {
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterUpdateHookCalls)
}
// with non-locking error
retryBeforeUpdateHookCalls = 0
retryAfterUpdateHookCalls = 0
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeUpdateHookCalls++
return errors.New("non-locking error")
}
if err := retryDao.Save(model); err == nil {
t.Fatal("Expected error, got nil")
}
if retryBeforeUpdateHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
}
if retryAfterUpdateHookCalls != 0 {
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterUpdateHookCalls)
}
}
func TestDaoRetryDelete(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
// init mock retry dao
retryBeforeDeleteHookCalls := 0
retryAfterDeleteHookCalls := 0
retryDao := daos.New(testApp.DB())
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeDeleteHookCalls++
return errors.New("database is locked")
}
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
retryAfterDeleteHookCalls++
}
model, _ := retryDao.FindAdminByEmail("test@example.com")
if err := retryDao.Delete(model); err != nil {
t.Fatalf("Expected nil after retry, got error: %v", err)
}
// the before hook is expected to be called only once because
// it is ignored after the first "database is locked" error
if retryBeforeDeleteHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
}
if retryAfterDeleteHookCalls != 1 {
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterDeleteHookCalls)
}
// with non-locking error
retryBeforeDeleteHookCalls = 0
retryAfterDeleteHookCalls = 0
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
retryBeforeDeleteHookCalls++
return errors.New("non-locking error")
}
dummy := &models.Admin{}
dummy.RefreshId()
dummy.MarkAsNotNew()
if err := retryDao.Delete(dummy); err == nil {
t.Fatal("Expected error, got nil")
}
if retryBeforeDeleteHookCalls != 1 {
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
}
if retryAfterDeleteHookCalls != 0 {
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterDeleteHookCalls)
}
}
func TestDaoBeforeHooksError(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+12 -10
View File
@@ -16,31 +16,31 @@ func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
return dao.ModelQuery(&models.Collection{})
}
// FindCollectionsByType finds all collections by the given type
// FindCollectionsByType finds all collections by the given type.
func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) {
models := []*models.Collection{}
collections := []*models.Collection{}
err := dao.CollectionQuery().
AndWhere(dbx.HashExp{"type": collectionType}).
OrderBy("created ASC").
All(&models)
All(&collections)
if err != nil {
return nil, err
}
return models, nil
return collections, nil
}
// FindCollectionByNameOrId finds the first collection by its name or id.
// FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.
func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) {
model := &models.Collection{}
err := dao.CollectionQuery().
AndWhere(dbx.Or(
dbx.HashExp{"id": nameOrId},
dbx.HashExp{"name": nameOrId},
)).
AndWhere(dbx.NewExp("[[id]] = {:id} OR LOWER([[name]])={:name}", dbx.Params{
"id": nameOrId,
"name": strings.ToLower(nameOrId),
})).
Limit(1).
One(model)
@@ -54,7 +54,7 @@ func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, e
// IsCollectionNameUnique checks that there is no existing collection
// with the provided name (case insensitive!).
//
// Note: case sensitive check because the name is used also as a table name for the records.
// Note: case insensitive check because the name is used also as a table name for the records.
func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
if name == "" {
return false
@@ -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
+6 -12
View File
@@ -3,6 +3,7 @@ package daos_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/pocketbase/pocketbase/daos"
@@ -71,6 +72,7 @@ func TestFindCollectionByNameOrId(t *testing.T) {
{"missing", true},
{"wsmn24bux7wo113", false},
{"demo1", false},
{"DEMO1", false}, // case insensitive check
}
for i, scenario := range scenarios {
@@ -81,7 +83,7 @@ func TestFindCollectionByNameOrId(t *testing.T) {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
}
if model != nil && model.Id != scenario.nameOrId && model.Name != scenario.nameOrId {
if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
}
}
@@ -296,23 +298,15 @@ func TestImportCollections(t *testing.T) {
expectError: true,
expectCollectionsCount: 7,
},
{
name: "check db constraints",
jsonData: `[
{"name": "import_test", "schema": []}
]`,
deleteMissing: false,
expectError: true,
expectCollectionsCount: 7,
},
{
name: "minimal collection import",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
{"name": "import_test1", "schema": [{"name":"test", "type": "text"}]},
{"name": "import_test2", "type": "auth"}
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 8,
expectCollectionsCount: 9,
},
{
name: "minimal collection import + failed beforeRecordsSync",
+47 -19
View File
@@ -1,9 +1,11 @@
package daos
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
@@ -99,7 +101,7 @@ func (dao *Dao) FindRecordsByIds(
//
// Example:
// expr1 := dbx.HashExp{"email": "test@example.com"}
// expr2 := dbx.HashExp{"status": "active"}
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
// dao.FindRecordsByExpr("example", expr1, expr2)
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
@@ -316,14 +318,14 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
func (dao *Dao) SaveRecord(record *models.Record) error {
if record.Collection().IsAuth() {
if record.Username() == "" {
return errors.New("Unable to save auth record without username.")
return errors.New("unable to save auth record without username")
}
// Cross-check that the auth record id is unique for all auth collections.
// 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 {
@@ -331,7 +333,7 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
}
isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
if !isUnique {
return errors.New("The auth record ID must be unique across all auth collections.")
return errors.New("the auth record ID must be unique across all auth collections")
}
}
}
@@ -347,29 +349,55 @@ 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.
// fetch rel references (if any)
//
// note: the select is outside of the transaction to minimize
// SQLITE_BUSY errors when mixing read&write in a single transaction
refs, err := dao.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)
// -----------------------------------------------------------
// run all consequent DeleteRecord requests synchroniously
// to minimize SQLITE_BUSY errors
if len(refs) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := dao.Block(ctx); err != nil {
return err
}
defer dao.Continue()
}
return dao.RunInTransaction(func(txDao *Dao) error {
// delete/update references
// always delete the record first to ensure that there will be no "A<->B"
// relations to prevent deadlock when calling DeleteRecord recursively
if err := txDao.Delete(record); 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)
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(5)
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)
prefixedFieldName := recordTableName + "." + 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 {{%s}}`,
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
), nil).
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": record.Id})).
AndWhere(dbx.HashExp{uniqueJsonEachAlias + ".value": record.Id}).
All(&rows)
if err != nil {
return err
@@ -393,7 +421,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
if err := txDao.DeleteRecord(refRecord); err != nil {
return err
}
// no further action are needed (the reference is deleted)
// no further actions are needed (the reference is deleted)
continue
}
@@ -421,7 +449,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
}
}
return txDao.Delete(record)
return nil
})
}
@@ -530,7 +558,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
// This way we are always doing 1 more rename operation but it provides better dev experience.
if oldField == nil {
tempName := field.Name + security.RandomString(5)
tempName := field.Name + security.PseudorandomString(5)
toRename[tempName] = field.Name
// add
@@ -539,7 +567,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
return err
}
} else if oldField.Name != field.Name {
tempName := field.Name + security.RandomString(5)
tempName := field.Name + security.PseudorandomString(5)
toRename[tempName] = field.Name
// rename
+3 -3
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(
@@ -129,7 +129,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
}
// indirect relation
relField = &schema.SchemaField{
Id: "indirect_" + security.RandomString(3),
Id: "indirect_" + security.PseudorandomString(5),
Type: schema.FieldTypeRelation,
Name: parts[0],
Options: relFieldOptions,
+21
View File
@@ -1,11 +1,14 @@
package daos_test
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
@@ -628,7 +631,15 @@ func TestDeleteRecord(t *testing.T) {
// delete existing record + cascade
// ---
calledQueries := []string{}
app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
// delete
if err := app.Dao().DeleteRecord(rec3); err != nil {
t.Fatalf("(rec3) Expected nil, got error %v", err)
}
@@ -642,6 +653,16 @@ func TestDeleteRecord(t *testing.T) {
if rel != nil {
t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
}
// ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
}
expectedRelOneJoin := "`demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
}
}
func TestSyncRecordTableSchema(t *testing.T) {
+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)
}
}
+8
View File
@@ -35,3 +35,11 @@ func (dao *Dao) DeleteTable(tableName string) error {
return err
}
// Vacuum executes VACUUM on the current dao.DB() instance in order to
// reclaim unused db disk space.
func (dao *Dao) Vacuum() error {
_, err := dao.DB().NewQuery("VACUUM").Execute()
return err
}
+28
View File
@@ -1,7 +1,10 @@
package daos_test
import (
"context"
"database/sql"
"testing"
"time"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
@@ -79,3 +82,28 @@ func TestDeleteTable(t *testing.T) {
}
}
}
func TestVacuum(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
calledQueries := []string{}
app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
calledQueries = append(calledQueries, sql)
}
app.DB().ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
calledQueries = append(calledQueries, sql)
}
if err := app.Dao().Vacuum(); err != nil {
t.Fatal(err)
}
if total := len(calledQueries); total != 1 {
t.Fatalf("Expected 1 query, got %d", total)
}
if calledQueries[0] != "VACUUM" {
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
}
}
+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)
}
+21 -2
View File
@@ -116,6 +116,7 @@ func (form *CollectionUpsert) Validate() error {
// validates using the type's own validation rules + some collection's specific
validation.Field(
&form.Schema,
validation.By(form.checkMinSchemaFields),
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureExistingRelationCollectionId),
@@ -255,6 +256,19 @@ func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
return nil
}
func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
if form.Type == models.CollectionTypeAuth {
return nil // auth collections doesn't require having additional schema fields
}
v, ok := value.(schema.Schema)
if !ok || len(v.Fields()) == 0 {
return validation.ErrRequired
}
return nil
}
func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
v, _ := value.(schema.Schema)
@@ -279,8 +293,13 @@ func (form *CollectionUpsert) checkRule(value any) error {
return nil // nothing to check
}
dummy := &models.Collection{Schema: form.Schema}
r := resolvers.NewRecordFieldResolver(form.dao, dummy, nil, true)
dummy := *form.collection
dummy.Type = form.Type
dummy.Schema = form.Schema
dummy.System = form.System
dummy.Options = form.Options
r := resolvers.NewRecordFieldResolver(form.dao, &dummy, nil, true)
_, err := search.FilterData(*v).BuildExpr(r)
if err != nil {
+18 -16
View File
@@ -96,7 +96,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
jsonData string
expectedErrors []string
}{
{"empty create", "", "{}", []string{"name", "schema"}},
{"empty create (base)", "", "{}", []string{"name", "schema"}},
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
{"empty update", "demo2", "{}", []string{}},
{
"create failure",
@@ -185,16 +186,17 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"",
`{
"name": "test_new",
"type": "auth",
"system": true,
"schema": [
{"id":"a123456","name":"test1","type":"text"},
{"id":"b123456","name":"test2","type":"email"}
],
"listRule": "test1='123'",
"viewRule": "test1='123'",
"createRule": "test1='123'",
"updateRule": "test1='123'",
"deleteRule": "test1='123'"
"listRule": "test1='123' && verified = true",
"viewRule": "test1='123' && emailVisibility = true",
"createRule": "test1='123' && email != ''",
"updateRule": "test1='123' && username != ''",
"deleteRule": "test1='123' && id != ''"
}`,
[]string{},
},
@@ -279,11 +281,11 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
"schema": [
{"id":"_2hlxbmp","name":"test","type":"text"}
],
"listRule": "test='123'",
"viewRule": "test='123'",
"createRule": "test='123'",
"updateRule": "test='123'",
"deleteRule": "test='123'",
"listRule": "test='123' && verified = true",
"viewRule": "test='123' && emailVisibility = true",
"createRule": "test='123' && email != ''",
"updateRule": "test='123' && username != ''",
"deleteRule": "test='123' && id != ''",
"options": {"minPasswordLength": 10}
}`,
[]string{},
@@ -365,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
@@ -494,7 +496,7 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
newCollection := func() *models.Collection {
return &models.Collection{
Name: "c_" + security.RandomString(4),
Name: "c_" + security.PseudorandomString(4),
Schema: existingCollection.Schema,
}
}
+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)
+26 -3
View File
@@ -87,6 +87,25 @@ func TestCollectionsImportSubmit(t *testing.T) {
"OnModelBeforeCreate": 2,
},
},
{
name: "test empty base collection schema",
jsonData: `{
"collections": [
{
"name": "import1"
},
{
"name": "import2",
"type": "auth"
}
]
}`,
expectError: true,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
},
{
name: "all imported collections has valid data",
jsonData: `{
@@ -110,14 +129,18 @@ func TestCollectionsImportSubmit(t *testing.T) {
"type":"bool"
}
]
},
{
"name": "import3",
"type": "auth"
}
]
}`,
expectError: false,
expectCollectionsCount: 9,
expectCollectionsCount: 10,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
"OnModelBeforeCreate": 3,
"OnModelAfterCreate": 3,
},
},
{
+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")
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ func (form *RecordOAuth2Login) Submit(
createForm := NewRecordUpsert(form.app, authRecord)
createForm.SetFullManageAccess(true)
createForm.SetDao(txDao)
if authUser.Username != "" {
if authUser.Username != "" && usernameRegex.MatchString(authUser.Username) {
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(form.collection.Id, authUser.Username)
}
+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")
}
}
+72 -40
View File
@@ -34,8 +34,8 @@ type RecordUpsert struct {
manageAccess bool
record *models.Record
filesToDelete []string // names list
filesToUpload map[string][]*rest.UploadedFile
filesToDelete []string // names list
// base model fields
Id string `json:"id"`
@@ -397,7 +397,7 @@ func (form *RecordUpsert) Validate() error {
&form.Username,
// require only on update, because on create we fallback to auto generated username
validation.When(!form.record.IsNew(), validation.Required),
validation.Length(4, 100),
validation.Length(3, 100),
validation.Match(usernameRegex),
validation.By(form.checkUniqueUsername),
),
@@ -648,35 +648,57 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
}
return runInterceptors(func() 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)
if !form.record.HasId() {
form.record.RefreshId()
form.record.MarkAsNew()
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return fmt.Errorf("failed to process the uploaded files: %w", err)
}
// persist the record model
if saveErr := form.dao.SaveRecord(form.record); saveErr != nil {
// try to cleanup the successfully uploaded files
if _, err := form.deleteFilesByNamesList(form.getFilesToUploadNames()); err != nil && form.app.IsDebug() {
log.Println(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 save the record: %w", saveErr)
}
// delete old files (if any)
if err := form.processFilesToDelete(); err != nil { //nolint:staticcheck
// for now fail silently to avoid reupload when `form.Submit()`
// is called manually (aka. not from an api request)...
}
// delete old files (if any)
//
// for now fail silently to avoid reupload when `form.Submit()`
// is called manually (aka. not from an api request)...
if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() {
log.Println(err)
}
return nil
})
return nil
}, interceptors...)
}
func (form *RecordUpsert) getFilesToUploadNames() []string {
names := []string{}
for fieldKey := range form.filesToUpload {
for _, file := range form.filesToUpload[fieldKey] {
names = append(names, file.Name())
}
}
return names
}
func (form *RecordUpsert) processFilesToUpload() error {
if len(form.filesToUpload) == 0 {
return nil // no parsed file fields
}
if !form.record.HasId() {
return errors.New("The record is not persisted yet.")
return errors.New("the record is not persisted yet")
}
fs, err := form.app.NewFilesystem()
@@ -685,65 +707,75 @@ func (form *RecordUpsert) processFilesToUpload() error {
}
defer fs.Close()
var uploadErrors []error
var uploadErrors []error // list of upload errors
var uploaded []string // list of uploaded file paths
for fieldKey := range form.filesToUpload {
for i := len(form.filesToUpload[fieldKey]) - 1; i >= 0; i-- {
file := form.filesToUpload[fieldKey][i]
for i, file := range form.filesToUpload[fieldKey] {
path := form.record.BaseFilesPath() + "/" + file.Name()
if err := fs.UploadMultipart(file.Header(), path); err == nil {
// remove the uploaded file from the list
form.filesToUpload[fieldKey] = append(form.filesToUpload[fieldKey][:i], form.filesToUpload[fieldKey][i+1:]...)
// keep track of the already uploaded file
uploaded = append(uploaded, path)
} else {
// store the upload error
uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
uploadErrors = append(uploadErrors, fmt.Errorf("file %d: %v", i, err))
}
}
}
if len(uploadErrors) > 0 {
return fmt.Errorf("Failed to upload all files: %v", uploadErrors)
// cleanup - try to delete the successfully uploaded files (if any)
form.deleteFilesByNamesList(uploaded)
return fmt.Errorf("failed to upload all files: %v", uploadErrors)
}
return nil
}
func (form *RecordUpsert) processFilesToDelete() error {
if len(form.filesToDelete) == 0 {
return nil // nothing to delete
func (form *RecordUpsert) processFilesToDelete() (err error) {
form.filesToDelete, err = form.deleteFilesByNamesList(form.filesToDelete)
return
}
// deleteFiles deletes a list of record files by their names.
// Returns the failed/remaining files.
func (form *RecordUpsert) deleteFilesByNamesList(filenames []string) ([]string, error) {
if len(filenames) == 0 {
return filenames, nil // nothing to delete
}
if !form.record.HasId() {
return errors.New("The record is not persisted yet.")
return filenames, errors.New("the record doesn't have a unique ID")
}
fs, err := form.app.NewFilesystem()
if err != nil {
return err
return filenames, err
}
defer fs.Close()
var deleteErrors []error
for i := len(form.filesToDelete) - 1; i >= 0; i-- {
filename := form.filesToDelete[i]
for i := len(filenames) - 1; i >= 0; i-- {
filename := filenames[i]
path := form.record.BaseFilesPath() + "/" + filename
if err := fs.Delete(path); err == nil {
// remove the deleted file from the list
form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...)
filenames = append(filenames[:i], filenames[i+1:]...)
// try to delete the related file thumbs (if any)
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
} else {
// store the delete error
deleteErrors = append(deleteErrors, fmt.Errorf("File %d: %v", i, err))
deleteErrors = append(deleteErrors, fmt.Errorf("file %d: %v", i, err))
}
// try to delete the related file thumbs (if any)
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
}
if len(deleteErrors) > 0 {
return fmt.Errorf("Failed to delete all files: %v", deleteErrors)
return filenames, fmt.Errorf("failed to delete all files: %v", deleteErrors)
}
return nil
return filenames, nil
}
+35
View File
@@ -694,6 +694,41 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
false,
},
// username
{
"invalid username characters",
"",
map[string]any{
"username": "test abc!@#",
"password": "12345678",
"passwordConfirm": "12345678",
},
false,
true,
},
{
"invalid username length (less than 3)",
"",
map[string]any{
"username": "ab",
"password": "12345678",
"passwordConfirm": "12345678",
},
false,
true,
},
{
"invalid username length (more than 100)",
"",
map[string]any{
"username": strings.Repeat("a", 101),
"password": "12345678",
"passwordConfirm": "12345678",
},
false,
true,
},
// verified
{
"try to set verified without managed access",
+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")
}
}
+11 -12
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
@@ -72,6 +66,11 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
)
if form.Settings.Logs.MaxDays == 0 {
// no logs are allowed -> reclaim preserved disk space after the previous delete operation
form.app.LogsDao().Vacuum()
}
// merge the application settings with the form ones
return form.app.Settings().Merge(form.Settings)
}, interceptors...)
+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)
}
}
}
+4 -4
View File
@@ -135,7 +135,7 @@ func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField,
func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField, value any) error {
val, _ := value.(string)
if val == "" {
return nil // nothing to check
return nil // nothing to check (skip zero-defaults)
}
options, _ := field.Options.(*schema.TextOptions)
@@ -159,11 +159,11 @@ func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField,
}
func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField, value any) error {
if value == nil {
return nil // nothing to check
val, _ := value.(float64)
if val == 0 {
return nil // nothing to check (skip zero-defaults)
}
val, _ := value.(float64)
options, _ := field.Options.(*schema.NumberOptions)
if options.Min != nil && val < *options.Min {
+10 -1
View File
@@ -168,7 +168,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
// create new test collection
collection := &models.Collection{}
collection.Name = "validate_test"
min := 0.0
min := 2.0
max := 150.0
collection.Schema = schema.NewSchema(
&schema.SchemaField{
@@ -244,6 +244,15 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
nil,
[]string{"field3"},
},
{
"(number) check min with zero-default",
map[string]any{
"field2": 1,
"field3": 0,
},
nil,
[]string{},
},
{
"(number) check max constraint",
map[string]any{
+47 -42
View File
@@ -4,53 +4,58 @@ go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/aws/aws-sdk-go v1.44.127
github.com/aws/aws-sdk-go v1.44.156
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.1.0
golang.org/x/net v0.1.0
golang.org/x/oauth2 v0.1.0
modernc.org/sqlite v1.19.3
golang.org/x/crypto v0.4.0
golang.org/x/net v0.4.0
golang.org/x/oauth2 v0.3.0
golang.org/x/sync v0.1.0
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.17.10 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.12.23 // 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.37 // 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.1 // 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.1 // 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.6.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // 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
@@ -60,27 +65,27 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/image v0.1.0 // indirect
golang.org/x/mod v0.6.0 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/term v0.1.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/time v0.1.0 // indirect
golang.org/x/tools v0.2.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/image v0.2.0 // indirect
golang.org/x/mod v0.7.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.4.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/api v0.101.0 // indirect
google.golang.org/api v0.104.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c // indirect
google.golang.org/grpc v1.50.1 // indirect
google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37 // 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.0.1 // indirect
modernc.org/token v1.1.0 // indirect
)
+107 -85
View File
@@ -47,16 +47,17 @@ 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.13.0 h1:AYrLkB8NPdDRslNp4Jxmzrhdr03fUAIDbiGFjLWowoU=
cloud.google.com/go/compute/metadata v0.2.2 h1:aWKAjYaBaOSrpKl57+jnS/3fJRQnxL7TvR/u1VVbt6k=
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.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=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
@@ -205,72 +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.156 h1:3RhbBTZ87HoI5OP2JjcKdd5qOnyo9YOAW8+Bb/h0vSE=
github.com/aws/aws-sdk-go v1.44.156/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.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.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.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.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.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=
@@ -481,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=
@@ -504,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=
@@ -623,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=
@@ -681,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=
@@ -779,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=
@@ -799,8 +813,8 @@ 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=
github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU=
github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
@@ -896,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=
@@ -1205,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=
@@ -1350,6 +1365,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -1359,8 +1375,9 @@ 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=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
@@ -1442,8 +1459,9 @@ 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=
go.opentelemetry.io/contrib v0.20.0/go.mod h1:G/EtFaa6qaN7+LxqfIAT3GiZa7Wv5DTBUzl5H4LY0Kc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0/go.mod h1:oVGt1LRbBOBq1A5BQLlUg9UaU/54aiHw8cgjV3aWZ/E=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.28.0/go.mod h1:vEhqr0m4eTc+DWxfsXoXue2GBgV2uUwVznkGIHW/e5w=
@@ -1543,8 +1561,8 @@ 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.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1558,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=
@@ -1587,8 +1605,8 @@ 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=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1666,8 +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.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
golang.org/x/net v0.4.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=
@@ -1693,8 +1712,8 @@ 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.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8=
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1709,6 +1728,8 @@ 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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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=
@@ -1851,16 +1872,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.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.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=
@@ -1870,8 +1893,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=
@@ -1884,8 +1908,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.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=
@@ -1969,8 +1993,8 @@ 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.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2030,8 +2054,8 @@ 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.104.0 h1:KBfmLRqdZEbwQleFlSLnzpQJwhjpmNOk4cKQIBDZ9mg=
google.golang.org/api v0.104.0/go.mod h1:JCspTXJbBxa5ySXw4UgUqVer7DfVxbvc/CTUFqAED5U=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -2140,8 +2164,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-20221207170731-23e4bf6bdc37 h1:jmIfw8+gSvXcZSgaFAGyInDXeWzUhvYH57G/5GKMn70=
google.golang.org/genproto v0.0.0-20221207170731-23e4bf6bdc37/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
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=
@@ -2183,8 +2207,8 @@ 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=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@@ -2317,27 +2341,25 @@ 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.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=
nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+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}}
`
+17 -12
View File
@@ -1,4 +1,4 @@
// Package models implements all PocketBase DB models.
// Package models implements all PocketBase DB models and DTOs.
package models
import (
@@ -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 {
+30 -4
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)
}
@@ -586,6 +609,11 @@ func (m *Record) SetLastVerificationSentAt(dateTime types.DateTime) error {
return nil
}
// PasswordHash returns the "passwordHash" auth record data value.
func (m *Record) PasswordHash() string {
return m.GetString(schema.FieldNamePasswordHash)
}
// ValidatePassword validates a plain password against the auth record password.
//
// Returns false if the password is incorrect or record is not from an auth collection.
@@ -594,10 +622,8 @@ func (m *Record) ValidatePassword(password string) bool {
return false
}
err := bcrypt.CompareHashAndPassword(
[]byte(m.GetString(schema.FieldNamePasswordHash)),
[]byte(password),
)
err := bcrypt.CompareHashAndPassword([]byte(m.PasswordHash()), []byte(password))
return err == nil
}
+41
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)
@@ -1603,6 +1630,20 @@ func TestRecordLastVerificationSentAt(t *testing.T) {
}
}
func TestRecordPasswordHash(t *testing.T) {
m := models.NewRecord(&models.Collection{})
if v := m.PasswordHash(); v != "" {
t.Errorf("Expected PasswordHash() to be empty, got %v", v)
}
m.Set(schema.FieldNamePasswordHash, "test")
if v := m.PasswordHash(); v != "test" {
t.Errorf("Expected PasswordHash() to be 'test', got %v", v)
}
}
func TestRecordValidatePassword(t *testing.T) {
// 123456
hash := "$2a$10$YKU8mPP8sTE3xZrpuM.xQuq27KJ7aIJB2oUeKPsDDqZshbl5g5cDK"
+11
View File
@@ -0,0 +1,11 @@
package models
// RequestData defines a HTTP request data struct, usually used
// as part of the `@request.*` filter resolver.
type RequestData struct {
Method string `json:"method"`
Query map[string]any `json:"query"`
Data map[string]any `json:"data"`
AuthRecord *Record `json:"authRecord"`
Admin *Admin `json:"admin"`
}
+13 -9
View File
@@ -113,7 +113,7 @@ func (s *Schema) RemoveField(id string) {
func (s *Schema) AddField(newField *SchemaField) {
if newField.Id == "" {
// set default id
newField.Id = strings.ToLower(security.RandomString(8))
newField.Id = strings.ToLower(security.PseudorandomString(8))
}
for i, field := range s.fields {
@@ -133,13 +133,9 @@ func (s *Schema) AddField(newField *SchemaField) {
// Internally calls each individual field's validator and additionally
// checks for invalid renamed fields and field name duplications.
func (s Schema) Validate() error {
return validation.Validate(&s.fields, validation.Required, validation.By(func(value any) error {
return validation.Validate(&s.fields, validation.By(func(value any) error {
fields := s.fields // use directly the schema value to avoid unnecessary interface casting
if len(fields) == 0 {
return validation.NewError("validation_invalid_schema", "Invalid schema format.")
}
ids := []string{}
names := []string{}
for i, field := range fields {
@@ -199,13 +195,21 @@ 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.
func (s Schema) Value() (driver.Value, error) {
if len(s.fields) == 0 {
return nil, nil
if s.fields == nil {
// initialize an empty slice to ensure that `[]` is returned
s.fields = []*SchemaField{}
}
data, err := json.Marshal(s.fields)
+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
}
+2 -2
View File
@@ -215,7 +215,7 @@ func TestSchemaValidate(t *testing.T) {
// no fields
{
schema.NewSchema(),
true,
false,
},
// duplicated field ids
{
@@ -342,7 +342,7 @@ func TestSchemaValue(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if v1 != nil {
if v1 != "[]" {
t.Fatalf("Expected nil, got %v", v1)
}
@@ -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"`
@@ -41,10 +41,12 @@ type Settings struct {
TwitterAuth AuthProviderConfig `form:"twitterAuth" json:"twitterAuth"`
MicrosoftAuth AuthProviderConfig `form:"microsoftAuth" json:"microsoftAuth"`
SpotifyAuth AuthProviderConfig `form:"spotifyAuth" json:"spotifyAuth"`
KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"`
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",
@@ -115,6 +117,12 @@ func NewSettings() *Settings {
SpotifyAuth: AuthProviderConfig{
Enabled: false,
},
KakaoAuth: AuthProviderConfig{
Enabled: false,
},
TwitchAuth: AuthProviderConfig{
Enabled: false,
},
}
}
@@ -142,6 +150,8 @@ func (s *Settings) Validate() error {
validation.Field(&s.TwitterAuth),
validation.Field(&s.MicrosoftAuth),
validation.Field(&s.SpotifyAuth),
validation.Field(&s.KakaoAuth),
validation.Field(&s.TwitchAuth),
)
}
@@ -160,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,
@@ -194,6 +204,8 @@ func (s *Settings) RedactClone() (*Settings, error) {
&clone.TwitterAuth.ClientSecret,
&clone.MicrosoftAuth.ClientSecret,
&clone.SpotifyAuth.ClientSecret,
&clone.KakaoAuth.ClientSecret,
&clone.TwitchAuth.ClientSecret,
}
// mask all sensitive fields
@@ -221,6 +233,8 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
auth.NameTwitter: s.TwitterAuth,
auth.NameMicrosoft: s.MicrosoftAuth,
auth.NameSpotify: s.SpotifyAuth,
auth.NameKakao: s.KakaoAuth,
auth.NameTwitch: s.TwitchAuth,
}
}
@@ -462,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"`
@@ -470,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 = ""
@@ -43,6 +43,10 @@ func TestSettingsValidate(t *testing.T) {
s.MicrosoftAuth.ClientId = ""
s.SpotifyAuth.Enabled = true
s.SpotifyAuth.ClientId = ""
s.KakaoAuth.Enabled = true
s.KakaoAuth.ClientId = ""
s.TwitchAuth.Enabled = true
s.TwitchAuth.ClientId = ""
// check if Validate() is triggering the members validate methods.
err := s.Validate()
@@ -69,6 +73,8 @@ func TestSettingsValidate(t *testing.T) {
`"twitterAuth":{`,
`"microsoftAuth":{`,
`"spotifyAuth":{`,
`"kakaoAuth":{`,
`"twitchAuth":{`,
}
errBytes, _ := json.Marshal(err)
@@ -81,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"
@@ -113,6 +119,10 @@ func TestSettingsMerge(t *testing.T) {
s2.MicrosoftAuth.ClientId = "microsoft_test"
s2.SpotifyAuth.Enabled = true
s2.SpotifyAuth.ClientId = "spotify_test"
s2.KakaoAuth.Enabled = true
s2.KakaoAuth.ClientId = "kakao_test"
s2.TwitchAuth.Enabled = true
s2.TwitchAuth.ClientId = "twitch_test"
if err := s1.Merge(s2); err != nil {
t.Fatal(err)
@@ -134,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 {
@@ -163,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
@@ -182,6 +192,8 @@ func TestSettingsRedactClone(t *testing.T) {
s1.TwitterAuth.ClientSecret = "test123"
s1.MicrosoftAuth.ClientSecret = "test123"
s1.SpotifyAuth.ClientSecret = "test123"
s1.KakaoAuth.ClientSecret = "test123"
s1.TwitchAuth.ClientSecret = "test123"
s2, err := s1.RedactClone()
if err != nil {
@@ -193,7 +205,7 @@ func TestSettingsRedactClone(t *testing.T) {
t.Fatal(err)
}
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"}}`
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"}}`
if encodedStr := string(encoded); encodedStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr)
@@ -201,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"
@@ -212,6 +224,8 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
s.TwitterAuth.ClientId = "twitter_test"
s.MicrosoftAuth.ClientId = "microsoft_test"
s.SpotifyAuth.ClientId = "spotify_test"
s.KakaoAuth.ClientId = "kakao_test"
s.TwitchAuth.ClientId = "twitch_test"
result := s.NamedAuthProviderConfigs()
@@ -219,27 +233,40 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
if err != nil {
t.Fatal(err)
}
encodedStr := string(encoded)
expected := `{"discord":{"enabled":false,"clientId":"discord_test"},"facebook":{"enabled":false,"clientId":"facebook_test"},"github":{"enabled":false,"clientId":"github_test"},"gitlab":{"enabled":true,"clientId":"gitlab_test"},"google":{"enabled":false,"clientId":"google_test"},"microsoft":{"enabled":false,"clientId":"microsoft_test"},"spotify":{"enabled":false,"clientId":"spotify_test"},"twitter":{"enabled":false,"clientId":"twitter_test"}}`
if encodedStr := string(encoded); encodedStr != expected {
t.Fatalf("Expected the same serialization, got \n%v", encodedStr)
expectedParts := []string{
`"discord":{"enabled":false,"clientId":"discord_test"}`,
`"facebook":{"enabled":false,"clientId":"facebook_test"}`,
`"github":{"enabled":false,"clientId":"github_test"}`,
`"gitlab":{"enabled":true,"clientId":"gitlab_test"}`,
`"google":{"enabled":false,"clientId":"google_test"}`,
`"microsoft":{"enabled":false,"clientId":"microsoft_test"}`,
`"spotify":{"enabled":false,"clientId":"spotify_test"}`,
`"twitter":{"enabled":false,"clientId":"twitter_test"}`,
`"kakao":{"enabled":false,"clientId":"kakao_test"}`,
`"twitch":{"enabled":false,"clientId":"twitch_test"}`,
}
for _, p := range expectedParts {
if !strings.Contains(encodedStr, p) {
t.Fatalf("Expected \n%s \nin \n%s", p, encodedStr)
}
}
}
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,
},
@@ -247,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,
},
@@ -255,7 +282,7 @@ func TestTokenConfigValidate(t *testing.T) {
},
// valid data
{
core.TokenConfig{
settings.TokenConfig{
Secret: strings.Repeat("a", 30),
Duration: 100,
},
@@ -278,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,
@@ -302,7 +329,7 @@ func TestSmtpConfigValidate(t *testing.T) {
},
// valid data
{
core.SmtpConfig{
settings.SmtpConfig{
Enabled: true,
Host: "example.com",
Port: 100,
@@ -327,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",
},
@@ -350,7 +377,7 @@ func TestS3ConfigValidate(t *testing.T) {
},
// valid data (url endpoint)
{
core.S3Config{
settings.S3Config{
Enabled: true,
Endpoint: "https://localhost:8090",
Bucket: "test",
@@ -362,7 +389,7 @@ func TestS3ConfigValidate(t *testing.T) {
},
// valid data (hostname endpoint)
{
core.S3Config{
settings.S3Config{
Enabled: true,
Endpoint: "example.com",
Bucket: "test",
@@ -388,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),
@@ -430,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",
@@ -443,7 +470,7 @@ func TestMetaConfigValidate(t *testing.T) {
},
// valid data
{
core.MetaConfig{
settings.MetaConfig{
AppName: "test",
AppUrl: "https://example.com",
SenderName: "test",
@@ -471,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",
@@ -490,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{},
},
@@ -522,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////",
@@ -543,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,
@@ -556,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",
),
@@ -595,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,
},
}
@@ -630,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: "",
@@ -657,7 +684,7 @@ func TestAuthProviderConfigValidate(t *testing.T) {
},
// valid data (only the required)
{
core.AuthProviderConfig{
settings.AuthProviderConfig{
Enabled: true,
ClientId: "test",
ClientSecret: "test",
@@ -666,7 +693,7 @@ func TestAuthProviderConfigValidate(t *testing.T) {
},
// valid data (fill all fields)
{
core.AuthProviderConfig{
settings.AuthProviderConfig{
Enabled: true,
ClientId: "test",
ClientSecret: "test",
@@ -695,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()
+39 -27
View File
@@ -44,7 +44,12 @@ type join struct {
// managing Record model search fields.
//
// Usually used together with `search.Provider`. Example:
// resolver := resolvers.NewRecordFieldResolver(app.Dao(), myCollection, map[string]any{"test": 123})
// resolver := resolvers.NewRecordFieldResolver(
// app.Dao(),
// myCollection,
// &models.RequestData{...},
// true,
// )
// provider := search.NewProvider(resolver)
// ...
type RecordFieldResolver struct {
@@ -52,24 +57,21 @@ type RecordFieldResolver struct {
baseCollection *models.Collection
allowHiddenFields bool
allowedFields []string
requestData map[string]any
loadedCollections []*models.Collection
joins []join // we cannot use a map because the insertion order is not preserved
exprs []dbx.Expression
requestData *models.RequestData
staticRequestData map[string]any
}
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
//
// @todo consider changing in v0.8+:
// - requestData to a typed struct when introducing the "IN" operator
// - allowHiddenFields -> isSystemAdmin
func NewRecordFieldResolver(
dao *daos.Dao,
baseCollection *models.Collection,
requestData map[string]any,
requestData *models.RequestData,
allowHiddenFields bool,
) *RecordFieldResolver {
return &RecordFieldResolver{
r := &RecordFieldResolver{
dao: dao,
baseCollection: baseCollection,
requestData: requestData,
@@ -86,6 +88,22 @@ func NewRecordFieldResolver(
`^\@collection\.\w+\.\w+[\w\.]*$`,
},
}
// @todo remove after IN operator and multi-match filter enhancements
r.staticRequestData = map[string]any{}
if r.requestData != nil {
r.staticRequestData["method"] = r.requestData.Method
r.staticRequestData["query"] = r.requestData.Query
r.staticRequestData["data"] = r.requestData.Data
r.staticRequestData["auth"] = nil
if r.requestData.AuthRecord != nil {
r.requestData.AuthRecord.IgnoreEmailVisibility(true)
r.staticRequestData["auth"] = r.requestData.AuthRecord.PublicExport()
r.requestData.AuthRecord.IgnoreEmailVisibility(false)
}
}
return r
}
// UpdateQuery implements `search.FieldResolver` interface.
@@ -159,6 +177,10 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
}
if r.requestData == nil {
return "NULL", nil, nil
}
// plain @request.* field
if !strings.HasPrefix(fieldName, "@request.auth.") || list.ExistInSlice(fieldName, plainRequestAuthFields) {
return r.resolveStaticRequestField(props[1:]...)
@@ -173,28 +195,18 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
// resolve the auth collection fields
// ---
rawAuthRecordId, _ := extractNestedMapVal(r.requestData, "auth", "id")
authRecordId := cast.ToString(rawAuthRecordId)
if authRecordId == "" {
if r.requestData == nil || r.requestData.AuthRecord == nil || r.requestData.AuthRecord.Collection() == nil {
return "NULL", nil, nil
}
rawAuthCollectionId, _ := extractNestedMapVal(r.requestData, "auth", schema.FieldNameCollectionId)
authCollectionId := cast.ToString(rawAuthCollectionId)
if authCollectionId == "" {
return "NULL", nil, nil
}
collection, err := r.loadCollection(authCollectionId)
if err != nil {
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
}
collection := r.requestData.AuthRecord.Collection()
r.loadedCollections = append(r.loadedCollections, collection)
currentCollectionName = collection.Name
currentTableAlias = "__auth_" + inflector.Columnify(currentCollectionName)
authIdParamKey := "auth" + security.RandomString(5)
authIdParams := dbx.Params{authIdParamKey: authRecordId}
authIdParamKey := "auth" + security.PseudorandomString(5)
authIdParams := dbx.Params{authIdParamKey: r.requestData.AuthRecord.Id}
// ---
// join the auth collection
@@ -331,7 +343,7 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
// ignore error because requestData is dynamic and some of the
// lookup keys may not be defined for the request
resultVal, _ := extractNestedMapVal(r.requestData, path...)
resultVal, _ := extractNestedMapVal(r.staticRequestData, path...)
switch v := resultVal.(type) {
case nil:
@@ -354,7 +366,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (resultN
resultVal = val
}
placeholder := "f" + security.RandomString(7)
placeholder := "f" + security.PseudorandomString(5)
name := fmt.Sprintf("{:%s}", placeholder)
params := dbx.Params{placeholder: resultVal}
@@ -387,7 +399,7 @@ func extractNestedMapVal(m map[string]any, keys ...string) (result any, err erro
func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models.Collection, error) {
// return already loaded
for _, collection := range r.loadedCollections {
if collection.Name == collectionNameOrId || collection.Id == collectionNameOrId {
if collection.Id == collectionNameOrId || strings.EqualFold(collection.Name, collectionNameOrId) {
return collection, nil
}
}
@@ -403,7 +415,7 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models
}
func (r *RecordFieldResolver) registerJoin(tableName string, tableAlias string, on dbx.Expression) {
tableExpr := fmt.Sprintf("%s %s", tableName, tableAlias)
tableExpr := (tableName + " " + tableAlias)
join := join{
id: tableAlias,
+18 -13
View File
@@ -5,6 +5,7 @@ import (
"regexp"
"testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
@@ -19,8 +20,8 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
t.Fatal(err)
}
requestData := map[string]any{
"auth": authRecord.PublicExport(),
requestData := &models.RequestData{
AuthRecord: authRecord,
}
scenarios := []struct {
@@ -181,8 +182,8 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
t.Fatal(err)
}
requestData := map[string]any{
"auth": authRecord.PublicExport(),
requestData := &models.RequestData{
AuthRecord: authRecord,
}
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true)
@@ -262,16 +263,16 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
t.Fatal(err)
}
requestData := map[string]any{
"method": "get",
"query": map[string]any{
requestData := &models.RequestData{
Method: "get",
Query: map[string]any{
"a": 123,
},
"data": map[string]any{
Data: map[string]any{
"b": 456,
"c": map[string]int{"sub": 1},
},
"user": authRecord.PublicExport(),
AuthRecord: authRecord,
}
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true)
@@ -295,7 +296,11 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
{"@request.data.c", false, `"{\"sub\":1}"`},
{"@request.auth", true, ""},
{"@request.auth.id", false, `"4q1xlclmfloku33"`},
{"@request.auth.file", false, `"[]"`},
{"@request.auth.email", false, `"test@example.com"`},
{"@request.auth.username", false, `"users75657"`},
{"@request.auth.verified", false, `false`},
{"@request.auth.emailVisibility", false, `false`},
{"@request.auth.missing", false, `NULL`},
}
for i, s := range scenarios {
@@ -315,7 +320,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
// ---
if len(params) == 0 {
if name != "NULL" {
t.Errorf("(%d) Expected 0 placeholder parameters, got %v", i, params)
t.Errorf("(%d) Expected 0 placeholder parameters for %v, got %v", i, name, params)
}
continue
}
@@ -323,7 +328,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
// existing key
// ---
if len(params) != 1 {
t.Errorf("(%d) Expected 1 placeholder parameter, got %v", i, params)
t.Errorf("(%d) Expected 1 placeholder parameter for %v, got %v", i, name, params)
continue
}
@@ -340,7 +345,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
encodedParamValue, _ := json.Marshal(paramValue)
if string(encodedParamValue) != s.expectParamValue {
t.Errorf("(%d) Expected params %v, got %v", i, s.expectParamValue, string(encodedParamValue))
t.Errorf("(%d) Expected params %v for %v, got %v", i, s.expectParamValue, name, string(encodedParamValue))
}
}
}
+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)
}
+92 -7
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,8 +172,68 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return nil
})
t.OnRecordListExternalAuths().Add(func(e *core.RecordListExternalAuthsEvent) error {
t.EventCalls["OnRecordListExternalAuths"]++
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.

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