mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
86
Commits
v0.8.0-rc2
...
v0.9.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
869d1cbcf7 | ||
|
|
451611776e | ||
|
|
9c7f48a66e | ||
|
|
59b41c8202 | ||
|
|
94658712c6 | ||
|
|
2c4ac070a3 | ||
|
|
e206e303ca | ||
|
|
e60f470188 | ||
|
|
9cf5e28700 | ||
|
|
7aefcd9bf6 | ||
|
|
693954cdcd | ||
|
|
355f7053fd | ||
|
|
808f5054d0 | ||
|
|
f1d546c845 | ||
|
|
dba66d4da1 | ||
|
|
4070a11660 | ||
|
|
45b72dd6b3 | ||
|
|
599c542c5a | ||
|
|
b8cd686b32 | ||
|
|
604009bd10 | ||
|
|
02f72638b8 | ||
|
|
23fbfab63a | ||
|
|
98cc8e2aee | ||
|
|
04018f931b | ||
|
|
d2028143df | ||
|
|
6400924d29 | ||
|
|
44a69eb4ba | ||
|
|
0fa5edb0b1 | ||
|
|
799e1d96f8 | ||
|
|
9ba710cdc5 | ||
|
|
a4953cbb4e | ||
|
|
647158f62d | ||
|
|
2deca759fa | ||
|
|
bd65125744 | ||
|
|
328b99a690 | ||
|
|
33539452de | ||
|
|
c6f03cda43 | ||
|
|
2d3531dd66 | ||
|
|
7ac3a74440 | ||
|
|
3bce173748 | ||
|
|
b024737ec8 | ||
|
|
675d459137 | ||
|
|
8c9b657132 | ||
|
|
d8963c6fc3 | ||
|
|
3e1a19685b | ||
|
|
c4a660d2d2 | ||
|
|
c12c873a65 | ||
|
|
550260b381 | ||
|
|
b9922e4843 | ||
|
|
3c3a61c457 | ||
|
|
aed8367231 | ||
|
|
341bcc4a0e | ||
|
|
3b9a9df171 | ||
|
|
a230cc1719 | ||
|
|
7dee9d5cc4 | ||
|
|
0b54d1736e | ||
|
|
39408f135b | ||
|
|
6e9cf986c5 | ||
|
|
4528f075dc | ||
|
|
f3566149b8 | ||
|
|
9322b13d15 | ||
|
|
77d295730e | ||
|
|
a998618d75 | ||
|
|
4c096fd745 | ||
|
|
c95e50c8a5 | ||
|
|
bac5d76725 | ||
|
|
50fce1f3cf | ||
|
|
521df149a2 | ||
|
|
39dc1d2795 | ||
|
|
3d14addfef | ||
|
|
bc519231d9 | ||
|
|
b1c7a012c5 | ||
|
|
01814067b1 | ||
|
|
7225b380d5 | ||
|
|
fa791b7e96 | ||
|
|
0ff5606d80 | ||
|
|
4cddb6b5cb | ||
|
|
46dc6cc47c | ||
|
|
65693d1916 | ||
|
|
a2abeb872a | ||
|
|
6115fb3874 | ||
|
|
cb6ffc1e7b | ||
|
|
152f6a9d1f | ||
|
|
fe656a1c42 | ||
|
|
099230a552 | ||
|
|
5e0718176d |
@@ -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.
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
# goreleaser builds folder
|
||||
/.builds/
|
||||
|
||||
# examples app directories
|
||||
pb_data
|
||||
pb_public
|
||||
|
||||
# tests coverage
|
||||
coverage.out
|
||||
|
||||
|
||||
+823
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# ignore everything
|
||||
/*
|
||||
|
||||
# exclude from the ignore filter
|
||||
!.gitignore
|
||||
!main.go
|
||||
+59
-14
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user