mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcaacd0d28 | ||
|
|
21d7cff43d | ||
|
|
2aec49a1f1 | ||
|
|
17b7ee5200 | ||
|
|
2b5f00b62e | ||
|
|
8b93aac8e3 | ||
|
|
eb51cdf1aa | ||
|
|
647997517f | ||
|
|
0e1f6b69d0 | ||
|
|
6718f4469b | ||
|
|
c27349905c | ||
|
|
1b48bdb81a | ||
|
|
bb801e7de0 | ||
|
|
adf902cae7 | ||
|
|
d2a617848d | ||
|
|
7ae2a7e846 | ||
|
|
448120bf18 | ||
|
|
667bcac680 | ||
|
|
64fd347628 | ||
|
|
0340d8add8 | ||
|
|
2c9e8995f9 | ||
|
|
f01f1df07a | ||
|
|
f4533f3d26 | ||
|
|
7a47a8a979 | ||
|
|
deccb3dbdb | ||
|
|
c51148e4d7 | ||
|
|
51ee1b5367 | ||
|
|
b8d7609e9e | ||
|
|
32af49dbec | ||
|
|
cf9e2a33bb | ||
|
|
a27298d1ef | ||
|
|
536707bfe7 | ||
|
|
2128b15541 | ||
|
|
eb1246fc41 | ||
|
|
ae371e8481 | ||
|
|
39df263a03 | ||
|
|
b3fa1f0fea | ||
|
|
ecfae2e5c9 | ||
|
|
e5477961ad | ||
|
|
2d40487b21 | ||
|
|
8564a69a94 | ||
|
|
3f58908734 | ||
|
|
4c010847e3 | ||
|
|
4c73e16f54 | ||
|
|
2a34eca07a | ||
|
|
a74d227418 | ||
|
|
7001a22d92 | ||
|
|
a7e3f08df0 | ||
|
|
15583ba718 | ||
|
|
e25c252fc2 | ||
|
|
a15b192a42 | ||
|
|
2a4b3315c6 | ||
|
|
6d08a5f36f | ||
|
|
fd97732d4d | ||
|
|
36ab3fd162 | ||
|
|
8f6f87902a | ||
|
|
55c6bed57f | ||
|
|
ba7c8e2108 | ||
|
|
c1921aeef8 | ||
|
|
012546e838 | ||
|
|
f792a9e08d | ||
|
|
5fb1e85372 | ||
|
|
a5ceee33df | ||
|
|
59e4939e1d | ||
|
|
1f46b30895 | ||
|
|
a8b2f0f6f1 | ||
|
|
d37bf6452c | ||
|
|
c26ac2d53f | ||
|
|
e1c751a7e7 | ||
|
|
f7d4722052 | ||
|
|
7459c9208f | ||
|
|
c1ff1c6155 | ||
|
|
3dc1bf6fa7 | ||
|
|
d3cc87abee | ||
|
|
19ad827302 | ||
|
|
40830b6c43 | ||
|
|
f234132629 | ||
|
|
9b880f5ab4 | ||
|
|
d5775ff657 | ||
|
|
41ba7e12e1 | ||
|
|
5ef6b3a8be | ||
|
|
c673d9d314 | ||
|
|
a7aa3da67e | ||
|
|
0439af458f | ||
|
|
6f3241399c | ||
|
|
19ccc70fe5 | ||
|
|
9033cd109e | ||
|
|
e1773eead0 | ||
|
|
f6fff85d07 | ||
|
|
166d2eafc7 | ||
|
|
d710446c71 | ||
|
|
8f6cb5ad2d | ||
|
|
775417ac2b | ||
|
|
079616ee8e | ||
|
|
26b794aa08 | ||
|
|
d55610511d | ||
|
|
a00606d969 | ||
|
|
4abc8ae021 | ||
|
|
f91f009fce | ||
|
|
7fc1d979dd | ||
|
|
ede7804a80 | ||
|
|
cea287a2c1 | ||
|
|
9e03ac4dc4 | ||
|
|
e713de1e44 | ||
|
|
233ab62f8e | ||
|
|
3449084e54 | ||
|
|
15c05b9679 | ||
|
|
fb57c8091d | ||
|
|
ca528cef03 | ||
|
|
4ceab4e7ed | ||
|
|
e8fa51526a | ||
|
|
a43713ce14 | ||
|
|
7d7d20744e | ||
|
|
bd16680548 | ||
|
|
84ba89d5af | ||
|
|
bf5b1db672 | ||
|
|
71d3f8f4c7 | ||
|
|
687a79b450 | ||
|
|
738f71f244 | ||
|
|
64f3c5a604 | ||
|
|
a8c996c93d | ||
|
|
9ae8536515 | ||
|
|
5f6b7f6cc0 | ||
|
|
89de29fc84 | ||
|
|
c25e67e13d | ||
|
|
8e582acbee | ||
|
|
b9e257d2b1 | ||
|
|
e964b019c2 | ||
|
|
4cbb7f58cd | ||
|
|
8815f6060c | ||
|
|
5183280c39 | ||
|
|
1f45b858a6 | ||
|
|
6d46cefd1f | ||
|
|
b1a63bb98d | ||
|
|
1248421703 | ||
|
|
be3dd42eac | ||
|
|
0eeae9de80 | ||
|
|
55b439cb1c | ||
|
|
0696a252cc | ||
|
|
54c52f696c | ||
|
|
21f442293f | ||
|
|
18d6a1c529 | ||
|
|
ad321c01e0 | ||
|
|
29c99319dc | ||
|
|
18285e7505 | ||
|
|
846b56d393 | ||
|
|
f30c9f263f | ||
|
|
b63268559f | ||
|
|
5c899a4cf0 | ||
|
|
506bfca8b2 | ||
|
|
007fcd36b8 | ||
|
|
707f35f461 | ||
|
|
972b06c708 | ||
|
|
37bac5cc50 | ||
|
|
aa6eaa7319 | ||
|
|
68a9782c03 | ||
|
|
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 |
@@ -0,0 +1,5 @@
|
||||
# Security
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
|
||||
All reports will be promptly addressed, and you'll be credited accordingly.
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.19.3'
|
||||
go-version: '>=1.20.0'
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
+479
-2
@@ -1,3 +1,480 @@
|
||||
## v0.12.2
|
||||
|
||||
- Fixed the "Clear" button of the datepicker component not clearing the value ([#1730](https://github.com/pocketbase/pocketbase/discussions/1730)).
|
||||
|
||||
- Increased slightly the fields contrast ([#1742](https://github.com/pocketbase/pocketbase/issues/1742)).
|
||||
|
||||
- Auto close the multi-select dropdown if "Max select" is reached.
|
||||
|
||||
|
||||
## v0.12.1
|
||||
|
||||
- Fixed js error on empty relation save.
|
||||
|
||||
- Fixed `overlay-active` css class not being removed on nested overlay panel close ([#1718](https://github.com/pocketbase/pocketbase/issues/1718)).
|
||||
|
||||
- Added the collection name in the page title ([#1711](https://github.com/pocketbase/pocketbase/issues/1711)).
|
||||
|
||||
|
||||
## v0.12.0
|
||||
|
||||
- Refactored the relation picker UI to allow server-side search, sort, create, update and delete of relation records ([#976](https://github.com/pocketbase/pocketbase/issues/976)).
|
||||
|
||||
- Added new `RelationOptions.DisplayFields` option to specify custom relation field(s) visualization in the Admin UI.
|
||||
|
||||
- Added Authentik OAuth2 provider ([#1377](https://github.com/pocketbase/pocketbase/pull/1377); thanks @pr0ton11).
|
||||
|
||||
- Added LiveChat OAuth2 provider ([#1573](https://github.com/pocketbase/pocketbase/pull/1573); thanks @mariosant).
|
||||
|
||||
- Added Gitea OAuth2 provider ([#1643](https://github.com/pocketbase/pocketbase/pull/1643); thanks @hlanderdev).
|
||||
|
||||
- Added PDF file previews ([#1548](https://github.com/pocketbase/pocketbase/pull/1548); thanks @mjadobson).
|
||||
|
||||
- Added video and audio file previews.
|
||||
|
||||
- Added rich text editor (`editor`) field for HTML content based on TinyMCE ([#370](https://github.com/pocketbase/pocketbase/issues/370)).
|
||||
_Currently the new field doesn't have any configuration options or validations but this may change in the future depending on how devs ended up using it._
|
||||
|
||||
- Added "Duplicate" Collection and Record options in the Admin UI ([#1656](https://github.com/pocketbase/pocketbase/issues/1656)).
|
||||
|
||||
- Added `filesystem.GetFile()` helper to read files through the FileSystem abstraction ([#1578](https://github.com/pocketbase/pocketbase/pull/1578); thanks @avarabyeu).
|
||||
|
||||
- Added new auth event hooks for finer control and more advanced auth scenarios handling:
|
||||
|
||||
```go
|
||||
// auth record
|
||||
OnRecordBeforeAuthWithPasswordRequest()
|
||||
OnRecordAfterAuthWithPasswordRequest()
|
||||
OnRecordBeforeAuthWithOAuth2Request()
|
||||
OnRecordAfterAuthWithOAuth2Request()
|
||||
OnRecordBeforeAuthRefreshRequest()
|
||||
OnRecordAfterAuthRefreshRequest()
|
||||
|
||||
// admin
|
||||
OnAdminBeforeAuthWithPasswordRequest()
|
||||
OnAdminAfterAuthWithPasswordRequest()
|
||||
OnAdminBeforeAuthRefreshRequest()
|
||||
OnAdminAfterAuthRefreshRequest()
|
||||
OnAdminBeforeRequestPasswordResetRequest()
|
||||
OnAdminAfterRequestPasswordResetRequest()
|
||||
OnAdminBeforeConfirmPasswordResetRequest()
|
||||
OnAdminAfterConfirmPasswordResetRequest()
|
||||
```
|
||||
|
||||
- Added `models.Record.CleanCopy()` helper that creates a new record copy with only the latest data state of the existing one and all other options reset to their defaults.
|
||||
|
||||
- Added new helper `apis.RecordAuthResponse(app, httpContext, record, meta)` to return a standard Record auth API response ([#1623](https://github.com/pocketbase/pocketbase/issues/1623)).
|
||||
|
||||
- Refactored `models.Record` expand and data change operations to be concurrent safe.
|
||||
|
||||
- Refactored all `forms` Submit interceptors to use a generic data type as their payload.
|
||||
|
||||
- Added several `store.Store` helpers:
|
||||
```go
|
||||
store.Reset(newData map[string]T)
|
||||
store.Length() int
|
||||
store.GetAll() map[string]T
|
||||
```
|
||||
|
||||
- Added "tags" support for all Record and Model related event hooks.
|
||||
|
||||
The "tags" allow registering event handlers that will be called only on matching table name(s) or colleciton id(s)/name(s).
|
||||
For example:
|
||||
```go
|
||||
app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error {
|
||||
// called only on "articles" record creation
|
||||
log.Println(e.Record)
|
||||
return nil
|
||||
})
|
||||
```
|
||||
For all those event hooks `*hook.Hook` was replaced with `*hooks.TaggedHook`, but the hook methods signatures are the same so it should behave as it was previously if no tags were specified.
|
||||
|
||||
- **!** Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
|
||||
|
||||
In order to support seamlessly both `application/json` and `multipart/form-data`
|
||||
requests, the following normalization rules are applied if the `json` field is a
|
||||
**plain string value**:
|
||||
|
||||
- "true" is converted to the json `true`
|
||||
- "false" is converted to the json `false`
|
||||
- "null" is converted to the json `null`
|
||||
- "[1,2,3]" is converted to the json `[1,2,3]`
|
||||
- "{\"a\":1,\"b\":2}" is converted to the json `{"a":1,"b":2}`
|
||||
- numeric strings are converted to json number
|
||||
- double quoted strings are left as they are (aka. without normalizations)
|
||||
- any other string (empty string too) is double quoted
|
||||
|
||||
Additionally, the "Nonempty" `json` field constraint now checks for `null`, `[]`, `{}` and `""` (empty string).
|
||||
|
||||
- Added `aria-label` to some of the buttons in the Admin UI for better accessibility ([#1702](https://github.com/pocketbase/pocketbase/pull/1702); thanks @ndarilek).
|
||||
|
||||
- Updated the filename extension checks in the Admin UI to be case-insensitive ([#1707](https://github.com/pocketbase/pocketbase/pull/1707); thanks @hungcrush).
|
||||
|
||||
- Other minor improvements (more detailed API file upload errors, UI optimizations, docs improvements, etc.)
|
||||
|
||||
|
||||
## v0.11.4
|
||||
|
||||
- Fixed cascade delete for rel records with the same id as the main record ([#1689](https://github.com/pocketbase/pocketbase/issues/1689)).
|
||||
|
||||
|
||||
## v0.11.3
|
||||
|
||||
- Fix realtime API panic on concurrent clients iteration ([#1628](https://github.com/pocketbase/pocketbase/issues/1628))
|
||||
|
||||
- `app.SubscriptionsBroker().Clients()` now returns a shallow copy of the underlying map.
|
||||
|
||||
- Added `Discard()` and `IsDiscarded()` helper methods to the `subscriptions.Client` interface.
|
||||
|
||||
- Slow clients should no longer "block" the main action completion.
|
||||
|
||||
|
||||
## v0.11.2
|
||||
|
||||
- Fixed `fs.DeleteByPrefix()` hang on invalid S3 settings ([#1575](https://github.com/pocketbase/pocketbase/discussions/1575#discussioncomment-4661089)).
|
||||
|
||||
- Updated file(s) delete to run in the background on record/collection delete to avoid blocking the delete model transaction.
|
||||
_Currently the cascade files delete operation is treated as "non-critical" and in case of an error it is just logged during debug._
|
||||
_This will be improved in the near future with the planned async job queue implementation._
|
||||
|
||||
|
||||
## v0.11.1
|
||||
|
||||
- Unescaped path parameter values ([#1552](https://github.com/pocketbase/pocketbase/issues/1552)).
|
||||
|
||||
|
||||
## v0.11.0
|
||||
|
||||
- Added `+` and `-` body field modifiers for `number`, `files`, `select` and `relation` fields.
|
||||
```js
|
||||
{
|
||||
// oldValue + 2
|
||||
"someNumber+": 2,
|
||||
|
||||
// oldValue + ["id1", "id2"] - ["id3"]
|
||||
"someRelation+": ["id1", "id2"],
|
||||
"someRelation-": ["id3"],
|
||||
|
||||
// delete single file by its name (file fields supports only the "-" modifier!)
|
||||
"someFile-": "filename.png",
|
||||
}
|
||||
```
|
||||
_Note1: `@request.data.someField` will contain the final resolved value._
|
||||
|
||||
_Note2: The old index (`"field.0":null`) and filename (`"field.filename.png":null`) based suffixed syntax for deleting files is still supported._
|
||||
|
||||
- ! Added support for multi-match/match-all request data and collection multi-valued fields (`select`, `relation`) conditions.
|
||||
If you want a "at least one of" type of condition, you can prefix the operator with `?`.
|
||||
```js
|
||||
// for each someRelA.someRelB record require the "status" field to be "active"
|
||||
someRelA.someRelB.status = "active"
|
||||
|
||||
// OR for "at least one of" condition
|
||||
someRelA.someRelB.status ?= "active"
|
||||
```
|
||||
_**Note: Previously the behavior for multi-valued fields was as the "at least one of" type.
|
||||
The release comes with system db migration that will update your existing API rules (if needed) to preserve the compatibility.
|
||||
If you have multi-select or multi-relation filter checks in your client-side code and want to preserve the old behavior, you'll have to prefix with `?` your operators.**_
|
||||
|
||||
- Added support for querying `@request.data.someRelField.*` relation fields.
|
||||
```js
|
||||
// example submitted data: {"someRel": "REL_RECORD_ID"}
|
||||
@request.data.someRel.status = "active"
|
||||
```
|
||||
|
||||
- Added `:isset` modifier for the static request data fields.
|
||||
```js
|
||||
// prevent changing the "role" field
|
||||
@request.data.role:isset = false
|
||||
```
|
||||
|
||||
- Added `:length` modifier for the arrayable request data and collection fields (`select`, `file`, `relation`).
|
||||
```js
|
||||
// example submitted data: {"someSelectField": ["val1", "val2"]}
|
||||
@request.data.someSelectField:length = 2
|
||||
|
||||
// check existing record field length
|
||||
someSelectField:length = 2
|
||||
```
|
||||
|
||||
- Added `:each` modifier support for the multi-`select` request data and collection field.
|
||||
```js
|
||||
// check if all selected rows has "pb_" prefix
|
||||
roles:each ~ 'pb_%'
|
||||
```
|
||||
|
||||
- Improved the Admin UI filters autocomplete.
|
||||
|
||||
- Added `@random` sort key for `RANDOM()` sorted list results.
|
||||
|
||||
- Added Strava OAuth2 provider ([#1443](https://github.com/pocketbase/pocketbase/pull/1443); thanks @szsascha).
|
||||
|
||||
- Added Gitee OAuth2 provider ([#1448](https://github.com/pocketbase/pocketbase/pull/1448); thanks @yuxiang-gao).
|
||||
|
||||
- Added IME status check to the textarea keydown handler ([#1370](https://github.com/pocketbase/pocketbase/pull/1370); thanks @tenthree).
|
||||
|
||||
- Added `filesystem.NewFileFromBytes()` helper ([#1420](https://github.com/pocketbase/pocketbase/pull/1420); thanks @dschissler).
|
||||
|
||||
- Added support for reordering uploaded multiple files.
|
||||
|
||||
- Added `webp` to the default images mime type presets list ([#1469](https://github.com/pocketbase/pocketbase/pull/1469); thanks @khairulhaaziq).
|
||||
|
||||
- Added the OAuth2 refresh token to the auth meta response ([#1487](https://github.com/pocketbase/pocketbase/issues/1487)).
|
||||
|
||||
- Fixed the text wrapping in the Admin UI listing searchbar ([#1416](https://github.com/pocketbase/pocketbase/issues/1416)).
|
||||
|
||||
- Fixed number field value output in the records listing ([#1447](https://github.com/pocketbase/pocketbase/issues/1447)).
|
||||
|
||||
- Fixed duplicated settings view pages caused by uncompleted transitions ([#1498](https://github.com/pocketbase/pocketbase/issues/1498)).
|
||||
|
||||
- Allowed sending `Authorization` header with the `/auth-with-password` record and admin login requests ([#1494](https://github.com/pocketbase/pocketbase/discussions/1494)).
|
||||
|
||||
- `migrate down` now reverts migrations in the applied order.
|
||||
|
||||
- Added additional list-bucket check in the S3 config test API.
|
||||
|
||||
- Other minor improvements.
|
||||
|
||||
|
||||
## v0.10.4
|
||||
|
||||
- Fixed `Record.MergeExpand` panic when the main model expand map is not initialized ([#1365](https://github.com/pocketbase/pocketbase/issues/1365)).
|
||||
|
||||
|
||||
## v0.10.3
|
||||
|
||||
- ! Renamed the metadata key `original_filename` to `original-filename` due to an S3 file upload error caused by the underscore character ([#1343](https://github.com/pocketbase/pocketbase/pull/1343); thanks @yuxiang-gao).
|
||||
|
||||
- Fixed request verification docs api url ([#1332](https://github.com/pocketbase/pocketbase/pull/1332); thanks @JoyMajumdar2001)
|
||||
|
||||
- Excluded `collectionId` and `collectionName` from the displayable relation props list ([1322](https://github.com/pocketbase/pocketbase/issues/1322); thanks @dhall2).
|
||||
|
||||
|
||||
## v0.10.2
|
||||
|
||||
- Fixed nested multiple expands with shared path ([#586](https://github.com/pocketbase/pocketbase/issues/586#issuecomment-1357784227)).
|
||||
A new helper method `models.Record.MergeExpand(map[string]any)` was also added to simplify the expand handling and unit testing.
|
||||
|
||||
|
||||
## v0.10.1
|
||||
|
||||
- Fixed nested transactions deadlock when authenticating with OAuth2 ([#1291](https://github.com/pocketbase/pocketbase/issues/1291)).
|
||||
|
||||
|
||||
## v0.10.0
|
||||
|
||||
- Added `/api/health` endpoint (thanks @MarvinJWendt).
|
||||
|
||||
- Added support for SMTP `LOGIN` auth for Microsoft/Outlook and other providers that don't support the `PLAIN` auth method ([#1217](https://github.com/pocketbase/pocketbase/discussions/1217#discussioncomment-4387970)).
|
||||
|
||||
- Reduced memory consumption (you can expect ~20% less allocated memory).
|
||||
|
||||
- Added support for split (concurrent and nonconcurrent) DB connections pool increasing even further the concurrent throughput without blocking reads on heavy write load.
|
||||
|
||||
- Improved record references delete performance.
|
||||
|
||||
- Removed the unnecessary parenthesis in the generated filter SQL query, reducing the "_parse stack overflow_" errors.
|
||||
|
||||
- Fixed `~` expressions backslash literal escaping ([#1231](https://github.com/pocketbase/pocketbase/discussions/1231)).
|
||||
|
||||
- Refactored the `core.app.Bootstrap()` to be called before starting the cobra commands ([#1267](https://github.com/pocketbase/pocketbase/discussions/1267)).
|
||||
|
||||
- ! Changed `pocketbase.NewWithConfig(config Config)` to `pocketbase.NewWithConfig(config *Config)` and added 4 new config settings:
|
||||
```go
|
||||
DataMaxOpenConns int // default to core.DefaultDataMaxOpenConns
|
||||
DataMaxIdleConns int // default to core.DefaultDataMaxIdleConns
|
||||
LogsMaxOpenConns int // default to core.DefaultLogsMaxOpenConns
|
||||
LogsMaxIdleConns int // default to core.DefaultLogsMaxIdleConns
|
||||
```
|
||||
|
||||
- Added new helper method `core.App.IsBootstrapped()` to check the current app bootstrap state.
|
||||
|
||||
- ! Changed `core.NewBaseApp(dir, encryptionEnv, isDebug)` to `NewBaseApp(config *BaseAppConfig)`.
|
||||
|
||||
- ! Removed `rest.UploadedFile` struct (see below `filesystem.File`).
|
||||
|
||||
- Added generic file resource struct that allows loading and uploading file content from
|
||||
different sources (at the moment multipart/form-data requests and from the local filesystem).
|
||||
```
|
||||
filesystem.File{}
|
||||
filesystem.NewFileFromPath(path)
|
||||
filesystem.NewFileFromMultipart(multipartHeader)
|
||||
filesystem/System.UploadFile(file)
|
||||
```
|
||||
|
||||
- Refactored `forms.RecordUpsert` to allow more easily loading and removing files programmatically.
|
||||
```
|
||||
forms.RecordUpsert.AddFiles(key, filesystem.File...) // add new filesystem.File to the form for upload
|
||||
forms.RecordUpsert.RemoveFiles(key, filenames...) // marks the filenames for deletion
|
||||
```
|
||||
|
||||
- Trigger the `password` validators if any of the others password change fields is set.
|
||||
|
||||
|
||||
## 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 triggering 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 writing 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 prebuilt 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!**
|
||||
@@ -12,7 +489,7 @@ In addition to the `Users` and `profiles` merge, this release comes with several
|
||||
|
||||
- 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).
|
||||
- The `relation` field now allows unlimited `maxSelect` (aka. without upper limit).
|
||||
|
||||
- Added support for combined email/username + password authentication (see below `authWithPassword()`).
|
||||
|
||||
@@ -648,7 +1125,7 @@ Please check the individual SDK package changelog and apply the necessary change
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- Marked as "Deprecated" and will be removed in v0.9:
|
||||
- Marked as "Deprecated" and will be removed in v0.9+:
|
||||
```
|
||||
core.Settings.EmailAuth{}
|
||||
core.EmailAuthConfig{}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2022, Gani Georgiev
|
||||
Copyright (c) 2022 - present, Gani Georgiev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
|
||||
@@ -90,11 +90,33 @@ Running/building the application is the same as for any other Go program, aka. j
|
||||
|
||||
**PocketBase embeds SQLite, but doesn't require CGO.**
|
||||
|
||||
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` environment variable to `1` or `0` respectively.
|
||||
|
||||
If CGO is enabled (aka. `CGO_ENABLED=1`), it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
|
||||
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory.
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
|
||||
|
||||
0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
1. Clone/download the repo
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
(_https://go.dev/doc/install/source#environment_)
|
||||
4. Start the created executable by running `./base serve`.
|
||||
|
||||
The supported build targets by the non-cgo driver at the moment are:
|
||||
```
|
||||
darwin amd64
|
||||
darwin arm64
|
||||
freebsd amd64
|
||||
freebsd arm64
|
||||
linux 386
|
||||
linux amd64
|
||||
linux arm
|
||||
linux arm64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -110,7 +132,7 @@ Check also the [Testing guide](http://pocketbase.io/docs/testing) to learn how t
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
|
||||
All reports will be promptly addressed and you'll be credited accordingly.
|
||||
All reports will be promptly addressed, and you'll be credited accordingly.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
+135
-54
@@ -18,7 +18,7 @@ func bindAdminApi(app core.App, rg *echo.Group) {
|
||||
api := adminApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/admins", ActivityLogger(app))
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword)
|
||||
subGroup.POST("/request-password-reset", api.requestPasswordReset)
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
|
||||
@@ -39,11 +39,10 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
event := &core.AdminAuthEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
Token: token,
|
||||
}
|
||||
event := new(core.AdminAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
event.Token = token
|
||||
|
||||
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
|
||||
return e.HttpContext.JSON(200, map[string]any{
|
||||
@@ -59,21 +58,55 @@ func (api *adminApi) authRefresh(c echo.Context) error {
|
||||
return NewNotFoundError("Missing auth admin context.", nil)
|
||||
}
|
||||
|
||||
return api.authResponse(c, admin)
|
||||
event := new(core.AdminAuthRefreshEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
form := forms.NewAdminLogin(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
admin, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", submitErr)
|
||||
event := new(core.AdminAuthWithPasswordEvent)
|
||||
event.HttpContext = c
|
||||
event.Password = form.Password
|
||||
event.Identity = form.Identity
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return api.authResponse(c, admin)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) requestPasswordReset(c echo.Context) error {
|
||||
@@ -86,15 +119,40 @@ func (api *adminApi) 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
|
||||
// (prevents admins enumeration)
|
||||
routine.FireAndForget(func() {
|
||||
if err := form.Submit(); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
event := new(core.AdminRequestPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(Admin *models.Admin) error {
|
||||
event.Admin = Admin
|
||||
|
||||
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Admin); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} 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 *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
@@ -103,12 +161,30 @@ func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("Failed to set new password.", submitErr)
|
||||
event := new(core.AdminConfirmPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) list(c echo.Context) error {
|
||||
@@ -120,17 +196,16 @@ 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)
|
||||
}
|
||||
|
||||
event := &core.AdminsListEvent{
|
||||
HttpContext: c,
|
||||
Admins: admins,
|
||||
Result: result,
|
||||
}
|
||||
event := new(core.AdminsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Admins = admins
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -148,10 +223,9 @@ func (api *adminApi) view(c echo.Context) error {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.AdminViewEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
@@ -168,16 +242,17 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.AdminCreateEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
// create the admin
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
event.Admin = m
|
||||
|
||||
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to create admin.", err)
|
||||
}
|
||||
|
||||
@@ -187,7 +262,9 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnAdminAfterCreateRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -211,16 +288,17 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.AdminUpdateEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
// update the admin
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
event.Admin = m
|
||||
|
||||
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to update admin.", err)
|
||||
}
|
||||
|
||||
@@ -230,7 +308,9 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnAdminAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -247,10 +327,9 @@ func (api *adminApi) delete(c echo.Context) error {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.AdminDeleteEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
|
||||
@@ -261,7 +340,9 @@ func (api *adminApi) delete(c echo.Context) error {
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnAdminAfterDeleteRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
|
||||
+41
-19
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestAdminAuthWithEmail(t *testing.T) {
|
||||
func TestAdminAuthWithPassword(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -39,6 +39,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
|
||||
Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "wrong password",
|
||||
@@ -47,17 +50,9 @@ func TestAdminAuthWithEmail(t *testing.T) {
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (already authorized)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (guest)",
|
||||
@@ -70,7 +65,28 @@ func TestAdminAuthWithEmail(t *testing.T) {
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (already authorized)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -114,10 +130,12 @@ func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeAdminResetPasswordSend": 1,
|
||||
"OnMailerAfterAdminResetPasswordSend": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeAdminResetPasswordSend": 1,
|
||||
"OnMailerAfterAdminResetPasswordSend": 1,
|
||||
"OnAdminBeforeRequestPasswordResetRequest": 1,
|
||||
"OnAdminAfterRequestPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -200,8 +218,10 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -253,7 +273,9 @@ func TestAdminRefresh(t *testing.T) {
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminBeforeAuthRefreshRequest": 1,
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+24
-9
@@ -26,6 +26,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e := echo.New()
|
||||
e.Debug = app.IsDebug()
|
||||
|
||||
// configure a custom router
|
||||
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
|
||||
return echo.NewRouter(echo.RouterConfig{
|
||||
UnescapePathParamValues: true,
|
||||
})
|
||||
})
|
||||
|
||||
// default middlewares
|
||||
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
@@ -64,19 +71,26 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
// Send response
|
||||
var cErr error
|
||||
if c.Request().Method == http.MethodHead {
|
||||
event := new(core.ApiErrorEvent)
|
||||
event.HttpContext = c
|
||||
event.Error = apiErr
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
// @see https://github.com/labstack/echo/issues/608
|
||||
cErr = c.NoContent(apiErr.Code)
|
||||
} else {
|
||||
cErr = c.JSON(apiErr.Code, apiErr)
|
||||
}
|
||||
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
|
||||
@@ -92,6 +106,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
bindFileApi(app, api)
|
||||
bindRealtimeApi(app, api)
|
||||
bindLogsApi(app, api)
|
||||
bindHealthApi(app, api)
|
||||
|
||||
// trigger the custom BeforeServe hook for the created api router
|
||||
// allowing users to further adjust its options or register new routes
|
||||
|
||||
@@ -66,6 +66,22 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "custom route with url encoded parameter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/a%2Bb%2Bc",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:param",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, c.PathParam("param"))
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"a+b+c"},
|
||||
},
|
||||
{
|
||||
Name: "route with HTTPError",
|
||||
Method: http.MethodGet,
|
||||
|
||||
+48
-41
@@ -1,6 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -36,17 +37,16 @@ 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)
|
||||
}
|
||||
|
||||
event := &core.CollectionsListEvent{
|
||||
HttpContext: c,
|
||||
Collections: collections,
|
||||
Result: result,
|
||||
}
|
||||
event := new(core.CollectionsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Collections = collections
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -59,10 +59,9 @@ func (api *collectionApi) view(c echo.Context) error {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionViewEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
@@ -79,16 +78,17 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionCreateEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
// create the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
if err := next(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to create the collection.", err)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionAfterCreateRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -117,16 +119,17 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionUpdateEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
// update the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
if err := next(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to update the collection.", err)
|
||||
}
|
||||
|
||||
@@ -136,7 +139,9 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -148,10 +153,9 @@ func (api *collectionApi) delete(c echo.Context) error {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionDeleteEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
|
||||
@@ -162,7 +166,9 @@ func (api *collectionApi) delete(c echo.Context) error {
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnCollectionAfterDeleteRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
@@ -176,18 +182,17 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionsImportEvent{
|
||||
HttpContext: c,
|
||||
Collections: form.Collections,
|
||||
}
|
||||
event := new(core.CollectionsImportEvent)
|
||||
event.HttpContext = c
|
||||
event.Collections = form.Collections
|
||||
|
||||
// import collections
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
form.Collections = e.Collections // ensures that the form always has the latest changes
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
event.Collections = imports
|
||||
|
||||
if err := next(); err != nil {
|
||||
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
if err := next(e.Collections); err != nil {
|
||||
return NewBadRequestError("Failed to import the submitted collections.", err)
|
||||
}
|
||||
|
||||
@@ -197,7 +202,9 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionsAfterImportRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
|
||||
+98
-10
@@ -6,10 +6,13 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestCollectionsList(t *testing.T) {
|
||||
@@ -42,7 +45,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":7`,
|
||||
`"totalItems":8`,
|
||||
`"items":[{`,
|
||||
`"id":"_pb_users_auth_"`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
@@ -51,6 +54,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
`"id":"sz5l5z67tg7gku0"`,
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
`"type":"auth"`,
|
||||
`"type":"base"`,
|
||||
},
|
||||
@@ -69,10 +73,10 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":2`,
|
||||
`"totalItems":7`,
|
||||
`"totalItems":8`,
|
||||
`"items":[{`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsListRequest": 1,
|
||||
@@ -99,12 +103,13 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":4`,
|
||||
`"totalItems":5`,
|
||||
`"items":[{`,
|
||||
`"id":"wsmn24bux7wo113"`,
|
||||
`"id":"sz5l5z67tg7gku0"`,
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsListRequest": 1,
|
||||
@@ -230,6 +235,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
@@ -248,6 +254,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
@@ -742,6 +749,87 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
`"options":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required"`,
|
||||
},
|
||||
},
|
||||
|
||||
// rel field change displayFields propagation
|
||||
{
|
||||
Name: "renaming a display field should also update the referenced displayFields value",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo3",
|
||||
Body: strings.NewReader(`{
|
||||
"schema":[
|
||||
{
|
||||
"id": "w5z2x0nq",
|
||||
"type": "text",
|
||||
"name": "title_change"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"title_change"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
|
||||
options := relField.Options.(*schema.RelationOptions)
|
||||
expectedDisplayFields := []string{"title_change", "id"}
|
||||
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
|
||||
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "deleting a display field should also update the referenced displayFields value",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo3",
|
||||
Body: strings.NewReader(`{
|
||||
"schema":[
|
||||
{
|
||||
"type": "text",
|
||||
"name": "new_field"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"new_field"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
|
||||
options := relField.Options.(*schema.RelationOptions)
|
||||
expectedDisplayFields := []string{"id"}
|
||||
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
|
||||
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -786,7 +874,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 7
|
||||
expected := 8
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -814,7 +902,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 7
|
||||
expected := 8
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -856,7 +944,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 7
|
||||
expected := 8
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -909,7 +997,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 10
|
||||
expected := 11
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
@@ -996,8 +1084,8 @@ func TestCollectionImport(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 5,
|
||||
"OnModelAfterDelete": 5,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelAfterDelete": 6,
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
|
||||
+10
-9
@@ -84,17 +84,18 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
event := &core.FileDownloadEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
Collection: collection,
|
||||
FileField: fileField,
|
||||
ServedPath: servedPath,
|
||||
ServedName: servedName,
|
||||
}
|
||||
event := new(core.FileDownloadEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.FileField = fileField
|
||||
event.ServedPath = servedPath
|
||||
event.ServedName = servedName
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// bindHealthApi registers the health api endpoint.
|
||||
func bindHealthApi(app core.App, rg *echo.Group) {
|
||||
api := healthApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/health")
|
||||
subGroup.GET("", api.healthCheck)
|
||||
}
|
||||
|
||||
type healthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
// healthCheck returns a 200 OK response if the server is healthy.
|
||||
func (api *healthApi) healthCheck(c echo.Context) error {
|
||||
payload := map[string]any{
|
||||
"code": http.StatusOK,
|
||||
"message": "API is healthy.",
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestHealthAPI(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "health status returns 200",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/health",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"code":200`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
+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)
|
||||
|
||||
+2
-13
@@ -333,19 +333,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
model.RefreshUpdated()
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
attempts := 1
|
||||
|
||||
BeginSave:
|
||||
logErr := app.LogsDao().SaveRequest(model)
|
||||
if logErr != nil {
|
||||
// try one more time after 10s in case of SQLITE_BUSY or "database is locked" error
|
||||
if attempts <= 2 {
|
||||
attempts++
|
||||
time.Sleep(10 * time.Second)
|
||||
goto BeginSave
|
||||
} else if app.IsDebug() {
|
||||
log.Println("Log save failed:", logErr)
|
||||
}
|
||||
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
|
||||
log.Println("Log save failed:", err)
|
||||
}
|
||||
|
||||
// Delete old request logs
|
||||
|
||||
+83
-23
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
)
|
||||
@@ -42,7 +43,18 @@ 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() {
|
||||
disconnectEvent := &core.RealtimeDisconnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
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 +63,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 +77,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 +121,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)
|
||||
@@ -213,21 +264,27 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("create", record)
|
||||
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("update", record)
|
||||
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("delete", record)
|
||||
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -291,12 +348,9 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
// remove the expand from the broadcasted record because we don't
|
||||
// know if the clients have access to view the expanded records
|
||||
cleanRecord := *record
|
||||
cleanRecord.SetExpand(nil)
|
||||
cleanRecord.WithUnkownData(false)
|
||||
cleanRecord.IgnoreEmailVisibility(false)
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know if the clients have permissions to view them
|
||||
cleanRecord := record.CleanCopy()
|
||||
|
||||
subscriptionRuleMap := map[string]*string{
|
||||
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
@@ -310,7 +364,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: &cleanRecord,
|
||||
Record: cleanRecord,
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
@@ -324,6 +378,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
encodedData := string(dataBytes)
|
||||
|
||||
for _, client := range clients {
|
||||
client := client
|
||||
|
||||
for subscription, rule := range subscriptionRuleMap {
|
||||
if !client.HasSubscription(subscription) {
|
||||
continue
|
||||
@@ -352,7 +408,11 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
}
|
||||
}
|
||||
|
||||
client.Channel() <- msg
|
||||
routine.FireAndForget(func() {
|
||||
if !client.IsDiscarded() {
|
||||
client.Channel() <- msg
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
+244
-98
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
@@ -35,8 +33,8 @@ func bindRecordAuthApi(app core.App, rg *echo.Group) {
|
||||
|
||||
subGroup.GET("/auth-methods", api.authMethods)
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
|
||||
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2) // allow anyone so that we can link the OAuth2 profile with the authenticated record
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
|
||||
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword)
|
||||
subGroup.POST("/request-password-reset", api.requestPasswordReset)
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/request-verification", api.requestVerification)
|
||||
@@ -51,60 +49,28 @@ type recordAuthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record, meta any) error {
|
||||
token, tokenErr := tokens.NewRecordAuthToken(api.app, authRecord)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
event := &core.RecordAuthEvent{
|
||||
HttpContext: c,
|
||||
Record: authRecord,
|
||||
Token: token,
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
|
||||
// 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 {
|
||||
// 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(), &requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"token": e.Token,
|
||||
"record": e.Record,
|
||||
}
|
||||
|
||||
if e.Meta != nil {
|
||||
result["meta"] = e.Meta
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authRefresh(c echo.Context) error {
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewNotFoundError("Missing auth record context.", nil)
|
||||
}
|
||||
|
||||
return api.authResponse(c, record, nil)
|
||||
event := new(core.RecordAuthRefreshEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = record.Collection()
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
type providerInfo struct {
|
||||
@@ -202,7 +168,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
|
||||
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
|
||||
return createForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
requestData := RequestData(c)
|
||||
requestData.Data = form.CreateData
|
||||
@@ -231,17 +197,45 @@ 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
|
||||
})
|
||||
})
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", submitErr)
|
||||
|
||||
event := new(core.RecordAuthWithOAuth2Event)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
|
||||
return func(data *forms.RecordOAuth2LoginData) error {
|
||||
event.Record = data.Record
|
||||
event.OAuth2User = data.OAuth2User
|
||||
|
||||
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
|
||||
data.Record = e.Record
|
||||
data.OAuth2User = e.OAuth2User
|
||||
|
||||
if err := next(data); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
e.Record = data.Record
|
||||
e.OAuth2User = data.OAuth2User
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return api.authResponse(c, record, authData)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authWithPassword(c echo.Context) error {
|
||||
@@ -255,12 +249,33 @@ func (api *recordAuthApi) authWithPassword(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 authenticate.", submitErr)
|
||||
event := new(core.RecordAuthWithPasswordEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Password = form.Password
|
||||
event.Identity = form.Identity
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return api.authResponse(c, record, nil)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
@@ -283,15 +298,41 @@ 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 := new(core.RecordRequestPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 {
|
||||
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} 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 +346,31 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("Failed to set new password.", submitErr)
|
||||
event := new(core.RecordConfirmPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 {
|
||||
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
@@ -328,15 +388,41 @@ 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 := new(core.RecordRequestVerificationEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 {
|
||||
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} 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,15 +436,39 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", submitErr)
|
||||
event := new(core.RecordConfirmVerificationEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 {
|
||||
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires valid auth record.", nil)
|
||||
@@ -369,11 +479,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 := new(core.RecordRequestEmailChangeEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 c.NoContent(http.StatusNoContent)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
@@ -387,12 +514,31 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return NewBadRequestError("Failed to confirm email change.", submitErr)
|
||||
event := new(core.RecordConfirmEmailChangeEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 {
|
||||
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
|
||||
@@ -416,11 +562,11 @@ func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordListExternalAuthsEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
ExternalAuths: externalAuths,
|
||||
}
|
||||
event := new(core.RecordListExternalAuthsEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.ExternalAuths = externalAuths
|
||||
|
||||
return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
|
||||
@@ -449,11 +595,11 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
|
||||
return NewNotFoundError("Missing external auth provider relation.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordUnlinkExternalAuthEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
ExternalAuth: externalAuth,
|
||||
}
|
||||
event := new(core.RecordUnlinkExternalAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.ExternalAuth = externalAuth
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
|
||||
|
||||
+121
-42
@@ -66,26 +66,6 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
|
||||
func TestRecordAuthWithPassword(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "authenticated record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authenticated admin",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "invalid body format",
|
||||
Method: http.MethodPost,
|
||||
@@ -120,6 +100,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid username and invalid password",
|
||||
@@ -133,6 +116,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid username and valid password in restricted collection",
|
||||
@@ -146,6 +132,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid username and valid password in allowed collection",
|
||||
@@ -163,7 +152,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
`"email":"test2@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -180,6 +171,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email and invalid password",
|
||||
@@ -193,6 +187,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email and valid password in restricted collection",
|
||||
@@ -206,6 +203,9 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email and valid password in allowed collection",
|
||||
@@ -223,7 +223,59 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// with already authenticated record or admin
|
||||
{
|
||||
Name: "authenticated record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated admin",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -294,7 +346,9 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
`"missing":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -346,10 +400,12 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) {
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeRecordResetPasswordSend": 1,
|
||||
"OnMailerAfterRecordResetPasswordSend": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnRecordBeforeRequestPasswordResetRequest": 1,
|
||||
"OnRecordAfterRequestPasswordResetRequest": 1,
|
||||
"OnMailerBeforeRecordResetPasswordSend": 1,
|
||||
"OnMailerAfterRecordResetPasswordSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -466,8 +522,10 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -518,6 +576,10 @@ func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
Body: strings.NewReader(`{"email":"test2@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeRequestVerificationRequest": 1,
|
||||
"OnRecordAfterRequestVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing auth record",
|
||||
@@ -527,10 +589,12 @@ func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeRecordVerificationSend": 1,
|
||||
"OnMailerAfterRecordVerificationSend": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnRecordBeforeRequestVerificationRequest": 1,
|
||||
"OnRecordAfterRequestVerificationRequest": 1,
|
||||
"OnMailerBeforeRecordVerificationSend": 1,
|
||||
"OnMailerAfterRecordVerificationSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -540,6 +604,10 @@ func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
// "OnRecordBeforeRequestVerificationRequest": 1,
|
||||
// "OnRecordAfterRequestVerificationRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// simulate recent verification sent
|
||||
authRecord, err := app.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
@@ -627,8 +695,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -639,7 +709,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid verification token from a collection without allowed login",
|
||||
@@ -651,8 +724,10 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
ExpectedStatus: 204,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -751,8 +826,10 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeRecordChangeEmailSend": 1,
|
||||
"OnMailerAfterRecordChangeEmailSend": 1,
|
||||
"OnMailerBeforeRecordChangeEmailSend": 1,
|
||||
"OnMailerAfterRecordChangeEmailSend": 1,
|
||||
"OnRecordBeforeRequestEmailChangeRequest": 1,
|
||||
"OnRecordAfterRequestEmailChangeRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -833,8 +910,10 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmEmailChangeRequest": 1,
|
||||
"OnRecordAfterConfirmEmailChangeRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+66
-40
@@ -74,7 +74,7 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -83,12 +83,11 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
|
||||
result.Items = records
|
||||
|
||||
event := &core.RecordsListEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
Records: records,
|
||||
Result: result,
|
||||
}
|
||||
event := new(core.RecordsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Records = records
|
||||
event.Result = result
|
||||
|
||||
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() {
|
||||
@@ -135,10 +134,10 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
return NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
event := &core.RecordViewEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
event := new(core.RecordViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.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() {
|
||||
@@ -166,6 +165,20 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
|
||||
// temporary save the record and check it against the create rule
|
||||
if requestData.Admin == nil && collection.CreateRule != nil {
|
||||
testRecord := models.NewRecord(collection)
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
|
||||
}
|
||||
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
testForm.SetFullManageAccess(true)
|
||||
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *collection.CreateRule == "" {
|
||||
return nil // no create rule to resolve
|
||||
@@ -181,17 +194,10 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
testRecord := models.NewRecord(collection)
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
testForm.SetFullManageAccess(true)
|
||||
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -211,16 +217,18 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordCreateEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
event := new(core.RecordCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
|
||||
@@ -234,7 +242,9 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterCreateRequest().Trigger(event)
|
||||
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -258,6 +268,16 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// eager fetch the record so that the modifier field values are replaced
|
||||
// and available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
requestData.Data = record.ReplaceModifers(requestData.Data)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
@@ -285,16 +305,18 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordUpdateEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
event := new(core.RecordUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
|
||||
@@ -308,7 +330,9 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -350,10 +374,10 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
return NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
event := &core.RecordDeleteEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
event := new(core.RecordDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
// delete the record
|
||||
@@ -365,7 +389,9 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRecordAfterDeleteRequest().Trigger(event)
|
||||
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
|
||||
+166
-5
@@ -2,10 +2,12 @@ package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -209,6 +211,50 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: ":rule modifer",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo5/records",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":1`,
|
||||
`"items":[{`,
|
||||
`"id":"qjeql998mtp1azp"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - at least one of",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":1`,
|
||||
`"items":[{`,
|
||||
`"id":"qzaqccwrmva4o1n"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - all",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":0`,
|
||||
`"totalItems":0`,
|
||||
`"items":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
|
||||
// auth collection checks
|
||||
// -----------------------------------------------------------
|
||||
@@ -684,18 +730,19 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that does match the collection delete rule",
|
||||
Name: "authenticated record that match the collection delete rule",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/users/records/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
// users, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelAfterDelete": 3, // +2 because of the external auths
|
||||
"OnModelBeforeDelete": 3, // +2 because of the external auths
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
@@ -716,6 +763,25 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "@request :isset (rule failure check)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "@request :isset (rule pass check)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/demo5/records/la4y2w4o98acwuj?test=1",
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// cascade delete checks
|
||||
// -----------------------------------------------------------
|
||||
@@ -730,8 +796,8 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
"OnModelBeforeUpdate": 1, // self_rel_many update of test1 record
|
||||
"OnModelBeforeDelete": 1, // rel_one_cascade of test1 record
|
||||
"OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
|
||||
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -758,6 +824,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 2,
|
||||
@@ -1092,6 +1159,63 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// fields modifier checks
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "trying to delete a record while being part of a non-cascade required relation",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/demo3/records/7nwo8tuiatetxdm",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
"OnModelBeforeUpdate": 2, // self_rel_many update of test1 record + rel_one_cascade demo4 cascaded in demo5
|
||||
"OnModelBeforeDelete": 2, // the record itself + rel_one_cascade of test1 record
|
||||
},
|
||||
},
|
||||
|
||||
// check whether if @request.data modifer fields are properly resolved
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "@request.data.field with compute modifers (rule failure check)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo5/records",
|
||||
Body: strings.NewReader(`{
|
||||
"total":1,
|
||||
"total+":4,
|
||||
"total-":1
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "@request.data.field with compute modifers (rule pass check)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo5/records",
|
||||
Body: strings.NewReader(`{
|
||||
"total":1,
|
||||
"total+":3,
|
||||
"total-":1
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"`,
|
||||
`"collectionName":"demo5"`,
|
||||
`"total":3`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnRecordAfterCreateRequest": 1,
|
||||
"OnRecordBeforeCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// auth records
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
@@ -1501,6 +1625,43 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// check whether if @request.data modifer fields are properly resolved
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "@request.data.field with compute modifers (rule failure check)",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
|
||||
Body: strings.NewReader(`{
|
||||
"total+":3,
|
||||
"total-":1
|
||||
}`),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "@request.data.field with compute modifers (rule pass check)",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo5/records/la4y2w4o98acwuj",
|
||||
Body: strings.NewReader(`{
|
||||
"total+":2,
|
||||
"total-":1
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"la4y2w4o98acwuj"`,
|
||||
`"collectionName":"demo5"`,
|
||||
`"total":3`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// auth records
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
|
||||
+61
-15
@@ -2,24 +2,23 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const ContextRequestDataKey = "requestData"
|
||||
|
||||
// Deprecated: Will be removed after v0.9. Use apis.RequestData(c) instead.
|
||||
func GetRequestData(c echo.Context) *models.RequestData {
|
||||
return RequestData(c)
|
||||
}
|
||||
|
||||
// RequestData exports cached common request data fields
|
||||
// (query, body, logged auth state, etc.) from the provided context.
|
||||
func RequestData(c echo.Context) *models.RequestData {
|
||||
@@ -46,23 +45,70 @@ func RequestData(c echo.Context) *models.RequestData {
|
||||
return result
|
||||
}
|
||||
|
||||
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
|
||||
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = authRecord.Collection()
|
||||
event.Record = authRecord
|
||||
event.Token = token
|
||||
event.Meta = meta
|
||||
|
||||
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
|
||||
// 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 {
|
||||
// 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 := app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
expands,
|
||||
expandFetch(app.Dao(), &requestData),
|
||||
)
|
||||
if len(failed) > 0 && app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"token": e.Token,
|
||||
"record": e.Record,
|
||||
}
|
||||
|
||||
if e.Meta != nil {
|
||||
result["meta"] = e.Meta
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, 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
|
||||
// - 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
|
||||
// - 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: %v", err)
|
||||
return fmt.Errorf("Failed to resolve email visibility: %w", err)
|
||||
}
|
||||
|
||||
expands := defaultExpands
|
||||
@@ -138,10 +184,10 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
collection := records[0].Collection()
|
||||
|
||||
mappedRecords := make(map[string]*models.Record, len(records))
|
||||
recordIds := make([]any, 0, len(records))
|
||||
for _, rec := range records {
|
||||
recordIds := make([]any, len(records))
|
||||
for i, rec := range records {
|
||||
mappedRecords[rec.Id] = rec
|
||||
recordIds = append(recordIds, rec.Id)
|
||||
recordIds[i] = rec.Id
|
||||
}
|
||||
|
||||
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
|
||||
|
||||
@@ -59,6 +59,118 @@ func TestRequestData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAuthResponse(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
dummyAdmin := &models.Admin{}
|
||||
dummyAdmin.Id = "id1"
|
||||
|
||||
nonAuthRecord, err := app.Dao().FindRecordById("demo1", "al1h9ijdeojtsjy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
meta any
|
||||
expectError bool
|
||||
expectedContent []string
|
||||
notExpectedContent []string
|
||||
expectedEvents map[string]int
|
||||
}{
|
||||
{
|
||||
name: "non auth record",
|
||||
record: nonAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record - without meta",
|
||||
record: authRecord,
|
||||
expectError: false,
|
||||
expectedContent: []string{
|
||||
`"token":"`,
|
||||
`"record":{`,
|
||||
`"id":"`,
|
||||
`"expand":{"rel":{`,
|
||||
},
|
||||
notExpectedContent: []string{
|
||||
`"meta":`,
|
||||
},
|
||||
expectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid auth record - with meta",
|
||||
record: authRecord,
|
||||
meta: map[string]any{"meta_test": 123},
|
||||
expectError: false,
|
||||
expectedContent: []string{
|
||||
`"token":"`,
|
||||
`"record":{`,
|
||||
`"id":"`,
|
||||
`"expand":{"rel":{`,
|
||||
`"meta":{"meta_test":123`,
|
||||
},
|
||||
expectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
app.ResetEventCalls()
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?expand=rel", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
responseErr := apis.RecordAuthResponse(app, c, s.record, s.meta)
|
||||
|
||||
hasErr := responseErr != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, responseErr)
|
||||
}
|
||||
|
||||
if len(app.EventCalls) != len(s.expectedEvents) {
|
||||
t.Fatalf("[%s] Expected events \n%v, \ngot \n%v", s.name, s.expectedEvents, app.EventCalls)
|
||||
}
|
||||
for k, v := range s.expectedEvents {
|
||||
if app.EventCalls[k] != v {
|
||||
t.Fatalf("[%s] Expected event %s to be called %d times, got %d", s.name, k, v, app.EventCalls[k])
|
||||
}
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
response := rec.Body.String()
|
||||
|
||||
for _, v := range s.expectedContent {
|
||||
if !strings.Contains(response, v) {
|
||||
t.Fatalf("[%s] Missing %v in response \n%v", s.name, v, response)
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range s.notExpectedContent {
|
||||
if strings.Contains(response, v) {
|
||||
t.Fatalf("[%s] Unexpected %v in response \n%v", s.name, v, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRecords(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
|
||||
|
||||
+23
-16
@@ -1,12 +1,15 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@@ -31,10 +34,9 @@ func (api *settingsApi) list(c echo.Context) error {
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.SettingsListEvent{
|
||||
HttpContext: c,
|
||||
RedactedSettings: settings,
|
||||
}
|
||||
event := new(core.SettingsListEvent)
|
||||
event.HttpContext = c
|
||||
event.RedactedSettings = settings
|
||||
|
||||
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
|
||||
@@ -49,17 +51,17 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
event := &core.SettingsUpdateEvent{
|
||||
HttpContext: c,
|
||||
OldSettings: api.app.Settings(),
|
||||
NewSettings: form.Settings,
|
||||
}
|
||||
event := new(core.SettingsUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.OldSettings = api.app.Settings()
|
||||
|
||||
// update the settings
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
event.NewSettings = s
|
||||
|
||||
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
if err := next(e.NewSettings); err != nil {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
@@ -74,7 +76,9 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnSettingsAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -91,14 +95,17 @@ func (api *settingsApi) testS3(c echo.Context) error {
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
testFileKey := "pb_test_" + security.PseudorandomString(5) + "/test.txt"
|
||||
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
|
||||
testFileKey := testPrefix + "/test.txt"
|
||||
|
||||
// try to upload a test file
|
||||
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
|
||||
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
if err := fs.Delete(testFileKey); err != nil {
|
||||
return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
|
||||
// test prefix deletion (ensures that both bucket list and delete works)
|
||||
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
|
||||
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
|
||||
+28
-13
@@ -58,6 +58,11 @@ func TestSettingsList(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -125,9 +130,14 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"Acme"`,
|
||||
`"appName":"acme_test"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
@@ -181,6 +191,11 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -303,12 +318,12 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastToAddress.Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, "Verify") {
|
||||
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
|
||||
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
@@ -334,12 +349,12 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastToAddress.Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, "Reset password") {
|
||||
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
|
||||
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
@@ -365,12 +380,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,
|
||||
)
|
||||
}
|
||||
+21
-3
@@ -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("=====================================")
|
||||
}
|
||||
@@ -75,7 +78,8 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
ReadTimeout: 60 * time.Second,
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
@@ -137,8 +141,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",
|
||||
|
||||
+393
-29
@@ -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"
|
||||
@@ -15,6 +16,11 @@ import (
|
||||
|
||||
// App defines the main PocketBase app interface.
|
||||
type App interface {
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the app db instance from app.Dao().DB() or
|
||||
// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
|
||||
//
|
||||
// DB returns the default app database instance.
|
||||
DB() *dbx.DB
|
||||
|
||||
@@ -25,6 +31,11 @@ type App interface {
|
||||
// trying to access the request logs table will result in error.
|
||||
Dao() *daos.Dao
|
||||
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the logs db instance from app.LogsDao().DB() or
|
||||
// if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
|
||||
//
|
||||
// LogsDB returns the app logs database instance.
|
||||
LogsDB() *dbx.DB
|
||||
|
||||
@@ -47,7 +58,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]
|
||||
@@ -67,8 +78,14 @@ type App interface {
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
RefreshSettings() error
|
||||
|
||||
// IsBootstrapped checks if the application was initialized
|
||||
// (aka. whether Bootstrap() was called).
|
||||
IsBootstrapped() bool
|
||||
|
||||
// Bootstrap takes care for initializing the application
|
||||
// (open db connections, load settings, etc.)
|
||||
// (open db connections, load settings, etc.).
|
||||
//
|
||||
// It will call ResetBootstrapState() if the application was already bootstrapped.
|
||||
Bootstrap() error
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
@@ -79,37 +96,79 @@ 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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnModelBeforeCreate hook is triggered before inserting a new
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
OnModelBeforeCreate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterCreate hook is triggered after successfully
|
||||
// inserting a new entry in the DB.
|
||||
OnModelAfterCreate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeUpdate hook is triggered before updating existing
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
OnModelBeforeUpdate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterUpdate hook is triggered after successfully updating
|
||||
// existing entry in the DB.
|
||||
OnModelAfterUpdate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeDelete hook is triggered before deleting an
|
||||
// existing entry from the DB.
|
||||
OnModelBeforeDelete() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterDelete is triggered after successfully deleting an
|
||||
// existing entry from the DB.
|
||||
OnModelAfterDelete() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Mailer event hooks
|
||||
@@ -131,33 +190,57 @@ type App interface {
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterRecordResetPasswordSend hook is triggered after
|
||||
// an auth record password reset email was successfully sent.
|
||||
OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right before
|
||||
// sending a verification email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterRecordVerificationSend hook is triggered after a
|
||||
// verification email was successfully sent to an auth record.
|
||||
OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterRecordChangeEmailSend hook is triggered after a
|
||||
// verification email was successfully sent to an auth record.
|
||||
OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Realtime API event hooks
|
||||
@@ -167,6 +250,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.
|
||||
@@ -207,7 +305,7 @@ type App interface {
|
||||
//
|
||||
// Could be used to validate or modify the file response before
|
||||
// returning it to the client.
|
||||
OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent]
|
||||
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Admin API event hooks
|
||||
@@ -263,8 +361,52 @@ type App interface {
|
||||
// authenticated admin data and token.
|
||||
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
|
||||
|
||||
// OnAdminBeforeAuthWithPasswordRequest hook is triggered before each Admin
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Admin identity (by assigning [AdminAuthWithPasswordEvent.Admin]).
|
||||
OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
|
||||
|
||||
// OnAdminAfterAuthWithPasswordRequest hook is triggered after each
|
||||
// successful Admin auth with password API request.
|
||||
OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
|
||||
|
||||
// OnAdminBeforeAuthRefreshRequest hook is triggered before each Admin
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnAdminAfterAuthRefreshRequest hook is triggered after each
|
||||
// successful auth refresh API request (right after generating a new auth token).
|
||||
OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnAdminBeforeRequestPasswordResetRequest hook is triggered before each Admin
|
||||
// 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]).
|
||||
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
|
||||
// successful request password reset API request.
|
||||
OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminBeforeConfirmPasswordResetRequest hook is triggered before each Admin
|
||||
// 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]).
|
||||
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
// successful confirm password reset API request.
|
||||
OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Auth Record API event hooks
|
||||
// Record Auth API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordAuthRequest hook is triggered on each successful API
|
||||
@@ -272,70 +414,292 @@ type App interface {
|
||||
//
|
||||
// Could be used to additionally validate or modify the authenticated
|
||||
// record data and token.
|
||||
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent]
|
||||
|
||||
// OnRecordBeforeAuthWithPasswordRequest hook is triggered before each Record
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
|
||||
|
||||
// OnRecordAfterAuthWithPasswordRequest hook is triggered after each
|
||||
// successful Record auth with password API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
|
||||
|
||||
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
|
||||
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
|
||||
//
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
|
||||
// request will try to create a new auth Record.
|
||||
//
|
||||
// To assign or link a different existing record model you can
|
||||
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
|
||||
|
||||
// OnRecordAfterAuthWithOAuth2Request hook is triggered after each
|
||||
// successful Record OAuth2 API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
|
||||
|
||||
// OnRecordBeforeAuthRefreshRequest hook is triggered before each Record
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// OnRecordAfterAuthRefreshRequest hook is triggered after each
|
||||
// successful auth refresh API request (right after generating a new auth token).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// 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]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
|
||||
|
||||
// OnRecordAfterRequestPasswordResetRequest hook is triggered after each
|
||||
// successful request password reset API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*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]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
|
||||
|
||||
// OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
// successful confirm password reset API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*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]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
|
||||
|
||||
// OnRecordAfterRequestVerificationRequest hook is triggered after each
|
||||
// successful request verification API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*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]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
|
||||
|
||||
// OnRecordAfterConfirmVerificationRequest hook is triggered after each
|
||||
// successful confirm verification API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*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]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
|
||||
|
||||
// OnRecordAfterRequestEmailChangeRequest hook is triggered after each
|
||||
// successful request email change API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*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]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
|
||||
// successful confirm email change API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*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.
|
||||
OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record API event hooks
|
||||
// Record CRUD API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordsListRequest hook is triggered on each API Records list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnRecordsListRequest() *hook.Hook[*RecordsListEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent]
|
||||
|
||||
// OnRecordViewRequest hook is triggered on each API Record view request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnRecordViewRequest() *hook.Hook[*RecordViewEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent]
|
||||
|
||||
// OnRecordBeforeCreateRequest hook is triggered before each API Record
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
|
||||
|
||||
// OnRecordAfterCreateRequest hook is triggered after each
|
||||
// successful API Record create request.
|
||||
OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
|
||||
|
||||
// OnRecordBeforeUpdateRequest hook is triggered before each API Record
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
|
||||
|
||||
// OnRecordAfterUpdateRequest hook is triggered after each
|
||||
// successful API Record update request.
|
||||
OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
|
||||
|
||||
// OnRecordBeforeDeleteRequest hook is triggered before each API Record
|
||||
// delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
|
||||
|
||||
// OnRecordAfterDeleteRequest hook is triggered after each
|
||||
// successful API Record delete request.
|
||||
OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Collection API event hooks
|
||||
|
||||
+483
-196
@@ -1,10 +1,8 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
@@ -15,34 +13,48 @@ 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/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDataMaxOpenConns int = 100
|
||||
DefaultDataMaxIdleConns int = 20
|
||||
DefaultLogsMaxOpenConns int = 10
|
||||
DefaultLogsMaxIdleConns int = 2
|
||||
)
|
||||
|
||||
var _ App = (*BaseApp)(nil)
|
||||
|
||||
// BaseApp implements core.App and defines the base PocketBase app structure.
|
||||
type BaseApp struct {
|
||||
// configurable parameters
|
||||
isDebug bool
|
||||
dataDir string
|
||||
encryptionEnv string
|
||||
isDebug bool
|
||||
dataDir string
|
||||
encryptionEnv string
|
||||
dataMaxOpenConns int
|
||||
dataMaxIdleConns int
|
||||
logsMaxOpenConns int
|
||||
logsMaxIdleConns int
|
||||
|
||||
// internals
|
||||
cache *store.Store[any]
|
||||
settings *Settings
|
||||
db *dbx.DB
|
||||
settings *settings.Settings
|
||||
dao *daos.Dao
|
||||
logsDB *dbx.DB
|
||||
logsDao *daos.Dao
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
|
||||
// serve event hooks
|
||||
onBeforeServe *hook.Hook[*ServeEvent]
|
||||
// app event hooks
|
||||
onBeforeBootstrap *hook.Hook[*BootstrapEvent]
|
||||
onAfterBootstrap *hook.Hook[*BootstrapEvent]
|
||||
onBeforeServe *hook.Hook[*ServeEvent]
|
||||
onBeforeApiError *hook.Hook[*ApiErrorEvent]
|
||||
onAfterApiError *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// dao event hooks
|
||||
onModelBeforeCreate *hook.Hook[*ModelEvent]
|
||||
@@ -64,6 +76,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]
|
||||
|
||||
@@ -76,23 +91,49 @@ type BaseApp struct {
|
||||
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
|
||||
|
||||
// admin api event hooks
|
||||
onAdminsListRequest *hook.Hook[*AdminsListEvent]
|
||||
onAdminViewRequest *hook.Hook[*AdminViewEvent]
|
||||
onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent]
|
||||
onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent]
|
||||
onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent]
|
||||
onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent]
|
||||
onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent]
|
||||
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
|
||||
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
|
||||
onAdminsListRequest *hook.Hook[*AdminsListEvent]
|
||||
onAdminViewRequest *hook.Hook[*AdminViewEvent]
|
||||
onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent]
|
||||
onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent]
|
||||
onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent]
|
||||
onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent]
|
||||
onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent]
|
||||
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
|
||||
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
|
||||
onAdminBeforeAuthWithPasswordRequest *hook.Hook[*AdminAuthWithPasswordEvent]
|
||||
onAdminAfterAuthWithPasswordRequest *hook.Hook[*AdminAuthWithPasswordEvent]
|
||||
onAdminBeforeAuthRefreshRequest *hook.Hook[*AdminAuthRefreshEvent]
|
||||
onAdminAfterAuthRefreshRequest *hook.Hook[*AdminAuthRefreshEvent]
|
||||
onAdminBeforeRequestPasswordResetRequest *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
onAdminAfterRequestPasswordResetRequest *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
onAdminBeforeConfirmPasswordResetRequest *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
onAdminAfterConfirmPasswordResetRequest *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// user api event hooks
|
||||
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
|
||||
onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
|
||||
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
// record auth API event hooks
|
||||
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
|
||||
onRecordBeforeAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordEvent]
|
||||
onRecordAfterAuthWithPasswordRequest *hook.Hook[*RecordAuthWithPasswordEvent]
|
||||
onRecordBeforeAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2Event]
|
||||
onRecordAfterAuthWithOAuth2Request *hook.Hook[*RecordAuthWithOAuth2Event]
|
||||
onRecordBeforeAuthRefreshRequest *hook.Hook[*RecordAuthRefreshEvent]
|
||||
onRecordAfterAuthRefreshRequest *hook.Hook[*RecordAuthRefreshEvent]
|
||||
onRecordBeforeRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
|
||||
onRecordAfterRequestPasswordResetRequest *hook.Hook[*RecordRequestPasswordResetEvent]
|
||||
onRecordBeforeConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
|
||||
onRecordAfterConfirmPasswordResetRequest *hook.Hook[*RecordConfirmPasswordResetEvent]
|
||||
onRecordBeforeRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
|
||||
onRecordAfterRequestVerificationRequest *hook.Hook[*RecordRequestVerificationEvent]
|
||||
onRecordBeforeConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
|
||||
onRecordAfterConfirmVerificationRequest *hook.Hook[*RecordConfirmVerificationEvent]
|
||||
onRecordBeforeRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
|
||||
onRecordAfterRequestEmailChangeRequest *hook.Hook[*RecordRequestEmailChangeEvent]
|
||||
onRecordBeforeConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
|
||||
onRecordAfterConfirmEmailChangeRequest *hook.Hook[*RecordConfirmEmailChangeEvent]
|
||||
onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
|
||||
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// record api event hooks
|
||||
// record crud API event hooks
|
||||
onRecordsListRequest *hook.Hook[*RecordsListEvent]
|
||||
onRecordViewRequest *hook.Hook[*RecordViewEvent]
|
||||
onRecordBeforeCreateRequest *hook.Hook[*RecordCreateEvent]
|
||||
@@ -102,7 +143,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]
|
||||
@@ -115,21 +156,40 @@ type BaseApp struct {
|
||||
onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent]
|
||||
}
|
||||
|
||||
// BaseAppConfig defines a BaseApp configuration option
|
||||
type BaseAppConfig struct {
|
||||
DataDir string
|
||||
EncryptionEnv string
|
||||
IsDebug bool
|
||||
DataMaxOpenConns int // default to 500
|
||||
DataMaxIdleConns int // default 20
|
||||
LogsMaxOpenConns int // default to 100
|
||||
LogsMaxIdleConns int // default to 5
|
||||
}
|
||||
|
||||
// NewBaseApp creates and returns a new BaseApp instance
|
||||
// configured with the provided arguments.
|
||||
//
|
||||
// To initialize the app, you need to call `app.Bootstrap()`.
|
||||
func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
dataDir: dataDir,
|
||||
isDebug: isDebug,
|
||||
encryptionEnv: encryptionEnv,
|
||||
dataDir: config.DataDir,
|
||||
isDebug: config.IsDebug,
|
||||
encryptionEnv: config.EncryptionEnv,
|
||||
dataMaxOpenConns: config.DataMaxOpenConns,
|
||||
dataMaxIdleConns: config.DataMaxIdleConns,
|
||||
logsMaxOpenConns: config.LogsMaxOpenConns,
|
||||
logsMaxIdleConns: config.LogsMaxIdleConns,
|
||||
cache: store.New[any](nil),
|
||||
settings: NewSettings(),
|
||||
settings: settings.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
|
||||
// serve event hooks
|
||||
onBeforeServe: &hook.Hook[*ServeEvent]{},
|
||||
// app event hooks
|
||||
onBeforeBootstrap: &hook.Hook[*BootstrapEvent]{},
|
||||
onAfterBootstrap: &hook.Hook[*BootstrapEvent]{},
|
||||
onBeforeServe: &hook.Hook[*ServeEvent]{},
|
||||
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
|
||||
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
|
||||
|
||||
// dao event hooks
|
||||
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
|
||||
@@ -151,6 +211,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]{},
|
||||
|
||||
@@ -163,23 +226,49 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
|
||||
|
||||
// admin API event hooks
|
||||
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
|
||||
onAdminViewRequest: &hook.Hook[*AdminViewEvent]{},
|
||||
onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{},
|
||||
onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{},
|
||||
onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
|
||||
onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
|
||||
onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
|
||||
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
|
||||
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
|
||||
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
|
||||
onAdminViewRequest: &hook.Hook[*AdminViewEvent]{},
|
||||
onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{},
|
||||
onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{},
|
||||
onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
|
||||
onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{},
|
||||
onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
|
||||
onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{},
|
||||
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
|
||||
onAdminBeforeAuthWithPasswordRequest: &hook.Hook[*AdminAuthWithPasswordEvent]{},
|
||||
onAdminAfterAuthWithPasswordRequest: &hook.Hook[*AdminAuthWithPasswordEvent]{},
|
||||
onAdminBeforeAuthRefreshRequest: &hook.Hook[*AdminAuthRefreshEvent]{},
|
||||
onAdminAfterAuthRefreshRequest: &hook.Hook[*AdminAuthRefreshEvent]{},
|
||||
onAdminBeforeRequestPasswordResetRequest: &hook.Hook[*AdminRequestPasswordResetEvent]{},
|
||||
onAdminAfterRequestPasswordResetRequest: &hook.Hook[*AdminRequestPasswordResetEvent]{},
|
||||
onAdminBeforeConfirmPasswordResetRequest: &hook.Hook[*AdminConfirmPasswordResetEvent]{},
|
||||
onAdminAfterConfirmPasswordResetRequest: &hook.Hook[*AdminConfirmPasswordResetEvent]{},
|
||||
|
||||
// user API event hooks
|
||||
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
|
||||
onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{},
|
||||
onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
|
||||
onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
|
||||
// record auth API event hooks
|
||||
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
|
||||
onRecordBeforeAuthWithPasswordRequest: &hook.Hook[*RecordAuthWithPasswordEvent]{},
|
||||
onRecordAfterAuthWithPasswordRequest: &hook.Hook[*RecordAuthWithPasswordEvent]{},
|
||||
onRecordBeforeAuthWithOAuth2Request: &hook.Hook[*RecordAuthWithOAuth2Event]{},
|
||||
onRecordAfterAuthWithOAuth2Request: &hook.Hook[*RecordAuthWithOAuth2Event]{},
|
||||
onRecordBeforeAuthRefreshRequest: &hook.Hook[*RecordAuthRefreshEvent]{},
|
||||
onRecordAfterAuthRefreshRequest: &hook.Hook[*RecordAuthRefreshEvent]{},
|
||||
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]{},
|
||||
@@ -207,9 +296,23 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
return app
|
||||
}
|
||||
|
||||
// IsBootstrapped checks if the application was initialized
|
||||
// (aka. whether Bootstrap() was called).
|
||||
func (app *BaseApp) IsBootstrapped() bool {
|
||||
return app.dao != nil && app.logsDao != nil && app.settings != nil
|
||||
}
|
||||
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.)
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
// It will call ResetBootstrapState() if the application was already bootstrapped.
|
||||
func (app *BaseApp) Bootstrap() error {
|
||||
event := &BootstrapEvent{app}
|
||||
|
||||
if err := app.OnBeforeBootstrap().Trigger(event); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// clear resources of previous core state (if any)
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
return err
|
||||
@@ -228,24 +331,33 @@ 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
|
||||
}
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
// (eg. closing db connections).
|
||||
func (app *BaseApp) ResetBootstrapState() error {
|
||||
if app.db != nil {
|
||||
if err := app.db.Close(); err != nil {
|
||||
if app.Dao() != nil {
|
||||
if err := app.Dao().ConcurrentDB().(*dbx.DB).Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Dao().NonconcurrentDB().(*dbx.DB).Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if app.logsDB != nil {
|
||||
if err := app.logsDB.Close(); err != nil {
|
||||
if app.LogsDao() != nil {
|
||||
if err := app.LogsDao().ConcurrentDB().(*dbx.DB).Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.LogsDao().NonconcurrentDB().(*dbx.DB).Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -257,9 +369,23 @@ func (app *BaseApp) ResetBootstrapState() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the db instance from app.Dao().DB() or
|
||||
// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
|
||||
//
|
||||
// DB returns the default app database instance.
|
||||
func (app *BaseApp) DB() *dbx.DB {
|
||||
return app.db
|
||||
if app.Dao() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
db, ok := app.Dao().DB().(*dbx.DB)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// Dao returns the default app Dao instance.
|
||||
@@ -267,9 +393,23 @@ func (app *BaseApp) Dao() *daos.Dao {
|
||||
return app.dao
|
||||
}
|
||||
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the logs db instance from app.LogsDao().DB() or
|
||||
// if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
|
||||
//
|
||||
// LogsDB returns the app logs database instance.
|
||||
func (app *BaseApp) LogsDB() *dbx.DB {
|
||||
return app.logsDB
|
||||
if app.LogsDao() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
db, ok := app.LogsDao().DB().(*dbx.DB)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// LogsDao returns the app logs Dao instance.
|
||||
@@ -295,7 +435,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
|
||||
}
|
||||
|
||||
@@ -313,13 +453,14 @@ func (app *BaseApp) SubscriptionsBroker() *subscriptions.Broker {
|
||||
// based on the current app settings.
|
||||
func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
if app.Settings().Smtp.Enabled {
|
||||
return mailer.NewSmtpClient(
|
||||
app.Settings().Smtp.Host,
|
||||
app.Settings().Smtp.Port,
|
||||
app.Settings().Smtp.Username,
|
||||
app.Settings().Smtp.Password,
|
||||
app.Settings().Smtp.Tls,
|
||||
)
|
||||
return &mailer.SmtpClient{
|
||||
Host: app.Settings().Smtp.Host,
|
||||
Port: app.Settings().Smtp.Port,
|
||||
Username: app.Settings().Smtp.Username,
|
||||
Password: app.Settings().Smtp.Password,
|
||||
Tls: app.Settings().Smtp.Tls,
|
||||
AuthMethod: app.Settings().Smtp.AuthMethod,
|
||||
}
|
||||
}
|
||||
|
||||
return &mailer.Sendmail{}
|
||||
@@ -349,105 +490,79 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
func (app *BaseApp) RefreshSettings() error {
|
||||
if app.settings == nil {
|
||||
app.settings = NewSettings()
|
||||
app.settings = settings.New()
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv(app.EncryptionEnv())
|
||||
|
||||
param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
|
||||
storedSettings, err := app.Dao().FindSettings(encryptionKey)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
// no settings were previously stored
|
||||
if param == nil {
|
||||
return app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
|
||||
if storedSettings == nil {
|
||||
return app.Dao().SaveSettings(app.settings, encryptionKey)
|
||||
}
|
||||
|
||||
// load the settings from the stored param into the app ones
|
||||
// ---
|
||||
newSettings := NewSettings()
|
||||
|
||||
// try first without decryption
|
||||
plainDecodeErr := json.Unmarshal(param.Value, newSettings)
|
||||
|
||||
// failed, try to decrypt
|
||||
if plainDecodeErr != nil {
|
||||
// load without decrypt has failed and there is no encryption key to use for decrypt
|
||||
if encryptionKey == "" {
|
||||
return errors.New("Failed to load the stored app settings (missing or invalid encryption key).")
|
||||
}
|
||||
|
||||
// decrypt
|
||||
decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey)
|
||||
if decryptErr != nil {
|
||||
return decryptErr
|
||||
}
|
||||
|
||||
// decode again
|
||||
decryptedDecodeErr := json.Unmarshal(decrypted, newSettings)
|
||||
if decryptedDecodeErr != nil {
|
||||
return decryptedDecodeErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.settings.Merge(newSettings); err != nil {
|
||||
if err := app.settings.Merge(storedSettings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
afterMergeRaw, err := json.Marshal(app.settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if
|
||||
// save because previously the settings weren't stored encrypted
|
||||
(plainDecodeErr == nil && encryptionKey != "") ||
|
||||
// or save because there are new fields after the merge
|
||||
!bytes.Equal(param.Value, afterMergeRaw) {
|
||||
saveErr := app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Serve event hooks
|
||||
// App event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnBeforeBootstrap() *hook.Hook[*BootstrapEvent] {
|
||||
return app.onBeforeBootstrap
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAfterBootstrap() *hook.Hook[*BootstrapEvent] {
|
||||
return app.onAfterBootstrap
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnBeforeServe() *hook.Hook[*ServeEvent] {
|
||||
return app.onBeforeServe
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnBeforeApiError() *hook.Hook[*ApiErrorEvent] {
|
||||
return app.onBeforeApiError
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
|
||||
return app.onAfterApiError
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Dao event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnModelBeforeCreate() *hook.Hook[*ModelEvent] {
|
||||
return app.onModelBeforeCreate
|
||||
func (app *BaseApp) OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
|
||||
return hook.NewTaggedHook(app.onModelBeforeCreate, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnModelAfterCreate() *hook.Hook[*ModelEvent] {
|
||||
return app.onModelAfterCreate
|
||||
func (app *BaseApp) OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent] {
|
||||
return hook.NewTaggedHook(app.onModelAfterCreate, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnModelBeforeUpdate() *hook.Hook[*ModelEvent] {
|
||||
return app.onModelBeforeUpdate
|
||||
func (app *BaseApp) OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
|
||||
return hook.NewTaggedHook(app.onModelBeforeUpdate, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnModelAfterUpdate() *hook.Hook[*ModelEvent] {
|
||||
return app.onModelAfterUpdate
|
||||
func (app *BaseApp) OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent] {
|
||||
return hook.NewTaggedHook(app.onModelAfterUpdate, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnModelBeforeDelete() *hook.Hook[*ModelEvent] {
|
||||
return app.onModelBeforeDelete
|
||||
func (app *BaseApp) OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
|
||||
return hook.NewTaggedHook(app.onModelBeforeDelete, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnModelAfterDelete() *hook.Hook[*ModelEvent] {
|
||||
return app.onModelAfterDelete
|
||||
func (app *BaseApp) OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent] {
|
||||
return hook.NewTaggedHook(app.onModelAfterDelete, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -462,28 +577,28 @@ func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdmi
|
||||
return app.onMailerAfterAdminResetPasswordSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerBeforeRecordResetPasswordSend
|
||||
func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
|
||||
return hook.NewTaggedHook(app.onMailerBeforeRecordResetPasswordSend, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerAfterRecordResetPasswordSend
|
||||
func (app *BaseApp) OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
|
||||
return hook.NewTaggedHook(app.onMailerAfterRecordResetPasswordSend, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerBeforeRecordVerificationSend
|
||||
func (app *BaseApp) OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
|
||||
return hook.NewTaggedHook(app.onMailerBeforeRecordVerificationSend, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerAfterRecordVerificationSend
|
||||
func (app *BaseApp) OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
|
||||
return hook.NewTaggedHook(app.onMailerAfterRecordVerificationSend, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerBeforeRecordChangeEmailSend
|
||||
func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
|
||||
return hook.NewTaggedHook(app.onMailerBeforeRecordChangeEmailSend, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerAfterRecordChangeEmailSend
|
||||
func (app *BaseApp) OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] {
|
||||
return hook.NewTaggedHook(app.onMailerAfterRecordChangeEmailSend, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -494,6 +609,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
|
||||
}
|
||||
@@ -522,8 +649,8 @@ func (app *BaseApp) OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEve
|
||||
// File API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent] {
|
||||
return app.onFileDownloadRequest
|
||||
func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent] {
|
||||
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -566,60 +693,164 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
|
||||
return app.onAdminAuthRequest
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth Record API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
|
||||
return app.onRecordAuthRequest
|
||||
func (app *BaseApp) OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
|
||||
return app.onAdminBeforeAuthWithPasswordRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] {
|
||||
return app.onRecordListExternalAuthsRequest
|
||||
func (app *BaseApp) OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
|
||||
return app.onAdminAfterAuthWithPasswordRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
|
||||
return app.onRecordBeforeUnlinkExternalAuthRequest
|
||||
func (app *BaseApp) OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
|
||||
return app.onAdminBeforeAuthRefreshRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
|
||||
return app.onRecordAfterUnlinkExternalAuthRequest
|
||||
func (app *BaseApp) OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
|
||||
return app.onAdminAfterAuthRefreshRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent] {
|
||||
return app.onAdminBeforeRequestPasswordResetRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent] {
|
||||
return app.onAdminAfterRequestPasswordResetRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent] {
|
||||
return app.onAdminBeforeConfirmPasswordResetRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent] {
|
||||
return app.onAdminAfterConfirmPasswordResetRequest
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Record API event hooks
|
||||
// Record auth API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] {
|
||||
return app.onRecordsListRequest
|
||||
func (app *BaseApp) OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAuthRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordViewRequest() *hook.Hook[*RecordViewEvent] {
|
||||
return app.onRecordViewRequest
|
||||
func (app *BaseApp) OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeAuthWithPasswordRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] {
|
||||
return app.onRecordBeforeCreateRequest
|
||||
func (app *BaseApp) OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterAuthWithPasswordRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] {
|
||||
return app.onRecordAfterCreateRequest
|
||||
func (app *BaseApp) OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeAuthWithOAuth2Request, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] {
|
||||
return app.onRecordBeforeUpdateRequest
|
||||
func (app *BaseApp) OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterAuthWithOAuth2Request, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] {
|
||||
return app.onRecordAfterUpdateRequest
|
||||
func (app *BaseApp) OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeAuthRefreshRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] {
|
||||
return app.onRecordBeforeDeleteRequest
|
||||
func (app *BaseApp) OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterAuthRefreshRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] {
|
||||
return app.onRecordAfterDeleteRequest
|
||||
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeRequestPasswordResetRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterRequestPasswordResetRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeConfirmPasswordResetRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterConfirmPasswordResetRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeRequestVerificationRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterRequestVerificationRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeConfirmVerificationRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterConfirmVerificationRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeRequestEmailChangeRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterRequestEmailChangeRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeConfirmEmailChangeRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterConfirmEmailChangeRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordListExternalAuthsRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeUnlinkExternalAuthRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterUnlinkExternalAuthRequest, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Record CRUD API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordsListRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordViewRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeCreateRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterCreateRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeUpdateRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterUpdateRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeDeleteRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterDeleteRequest, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -671,50 +902,91 @@ func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImp
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) initLogsDB() error {
|
||||
var connectErr error
|
||||
app.logsDB, connectErr = connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if connectErr != nil {
|
||||
return connectErr
|
||||
maxOpenConns := DefaultLogsMaxOpenConns
|
||||
maxIdleConns := DefaultLogsMaxIdleConns
|
||||
if app.logsMaxOpenConns > 0 {
|
||||
maxOpenConns = app.logsMaxOpenConns
|
||||
}
|
||||
if app.logsMaxIdleConns > 0 {
|
||||
maxIdleConns = app.logsMaxIdleConns
|
||||
}
|
||||
|
||||
app.logsDao = daos.New(app.logsDB)
|
||||
concurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *BaseApp) initDataDB() error {
|
||||
var connectErr error
|
||||
app.db, connectErr = connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if connectErr != nil {
|
||||
return connectErr
|
||||
maxOpenConns := DefaultDataMaxOpenConns
|
||||
maxIdleConns := DefaultDataMaxIdleConns
|
||||
if app.dataMaxOpenConns > 0 {
|
||||
maxOpenConns = app.dataMaxOpenConns
|
||||
}
|
||||
if app.dataMaxIdleConns > 0 {
|
||||
maxIdleConns = app.dataMaxIdleConns
|
||||
}
|
||||
|
||||
app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
if app.IsDebug() {
|
||||
concurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
|
||||
if app.IsDebug() {
|
||||
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
}
|
||||
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
|
||||
|
||||
app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
if app.IsDebug() {
|
||||
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
|
||||
}
|
||||
|
||||
app.dao = app.createDaoWithHooks(app.db)
|
||||
app.dao = app.createDaoWithHooks(concurrentDB, nonconcurrentDB)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
|
||||
dao := daos.New(db)
|
||||
func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
|
||||
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
}
|
||||
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
@@ -722,7 +994,10 @@ func (app *BaseApp) createDaoWithHooks(db dbx.Builder) *daos.Dao {
|
||||
}
|
||||
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
|
||||
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
@@ -730,7 +1005,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
|
||||
@@ -746,20 +1024,29 @@ func (app *BaseApp) registerDefaultHooks() {
|
||||
|
||||
failed := fs.DeletePrefix(prefix)
|
||||
if len(failed) > 0 {
|
||||
return errors.New("Failed to delete the files at " + prefix)
|
||||
return errors.New("failed to delete the files at " + prefix)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// delete storage files from deleted Collection, Records, etc.
|
||||
// try to delete the storage files from deleted Collection, Records, etc. model
|
||||
app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
|
||||
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
|
||||
if err := deletePrefix(m.BaseFilesPath()); err != nil && app.IsDebug() {
|
||||
// non critical error - only log for debug
|
||||
// (usually could happen because of S3 api limits)
|
||||
log.Println(err)
|
||||
}
|
||||
prefix := m.BaseFilesPath()
|
||||
|
||||
// run in the background for "optimistic" delete to avoid
|
||||
// blocking the delete transaction
|
||||
//
|
||||
// @todo consider creating a bg process queue so that the
|
||||
// call could be "retried" in case of a failure.
|
||||
routine.FireAndForget(func() {
|
||||
if err := deletePrefix(prefix); err != nil && app.IsDebug() {
|
||||
// non critical error - only log for debug
|
||||
// (usually could happen because of S3 api limits)
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -32,7 +31,7 @@ func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
t.Fatalf("Expected new settings to be persisted, got %v", err)
|
||||
}
|
||||
|
||||
// change the db entry and refresh the app settings
|
||||
// change the db entry and refresh the app settings (ensure that there was no db update)
|
||||
param.Value = types.JsonRaw([]byte(`{"example": 123}`))
|
||||
if err := app.Dao().SaveParam(param.Key, param.Value); err != nil {
|
||||
t.Fatalf("Failed to update the test settings: %v", err)
|
||||
@@ -41,21 +40,9 @@ func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh the app settings: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
})
|
||||
testEventCalls(t, app, nil)
|
||||
|
||||
// make sure that the newly merged settings were actually saved
|
||||
newParam, err := app.Dao().FindParamByKey(models.ParamAppSettings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch new settings param: %v", err)
|
||||
}
|
||||
if bytes.Equal(param.Value, newParam.Value) {
|
||||
t.Fatalf("Expected the new refreshed settings to be different, got: \n%v", string(newParam.Value))
|
||||
}
|
||||
|
||||
// try to refresh again and ensure that there was no db update
|
||||
// try to refresh again without doing any changes
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh the app settings without change: %v", err)
|
||||
+39
-243
@@ -11,7 +11,11 @@ func TestNewBaseApp(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "test_env", true)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "test_env",
|
||||
IsDebug: true,
|
||||
})
|
||||
|
||||
if app.dataDir != testDataDir {
|
||||
t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir)
|
||||
@@ -42,14 +46,26 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if app.IsBootstrapped() {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
// bootstrap
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !app.IsBootstrapped() {
|
||||
t.Fatal("Expected the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() {
|
||||
t.Fatal("Expected test data directory to be created.")
|
||||
}
|
||||
@@ -112,29 +128,33 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if app.db != app.DB() {
|
||||
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.db)
|
||||
}
|
||||
|
||||
if app.dao != app.Dao() {
|
||||
t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao)
|
||||
}
|
||||
|
||||
if app.logsDB != app.LogsDB() {
|
||||
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDB)
|
||||
if app.dao.ConcurrentDB() != app.DB() {
|
||||
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.dao.ConcurrentDB())
|
||||
}
|
||||
|
||||
if app.logsDao != app.LogsDao() {
|
||||
t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao)
|
||||
}
|
||||
|
||||
if app.logsDao.ConcurrentDB() != app.LogsDB() {
|
||||
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDao.ConcurrentDB())
|
||||
}
|
||||
|
||||
if app.dataDir != app.DataDir() {
|
||||
t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir)
|
||||
}
|
||||
@@ -162,245 +182,17 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
if app.onBeforeServe != app.OnBeforeServe() || app.OnBeforeServe() == nil {
|
||||
t.Fatalf("Getter app.OnBeforeServe does not match or nil (%v vs %v)", app.OnBeforeServe(), app.onBeforeServe)
|
||||
}
|
||||
|
||||
if app.onModelBeforeCreate != app.OnModelBeforeCreate() || app.OnModelBeforeCreate() == nil {
|
||||
t.Fatalf("Getter app.OnModelBeforeCreate does not match or nil (%v vs %v)", app.OnModelBeforeCreate(), app.onModelBeforeCreate)
|
||||
}
|
||||
|
||||
if app.onModelAfterCreate != app.OnModelAfterCreate() || app.OnModelAfterCreate() == nil {
|
||||
t.Fatalf("Getter app.OnModelAfterCreate does not match or nil (%v vs %v)", app.OnModelAfterCreate(), app.onModelAfterCreate)
|
||||
}
|
||||
|
||||
if app.onModelBeforeUpdate != app.OnModelBeforeUpdate() || app.OnModelBeforeUpdate() == nil {
|
||||
t.Fatalf("Getter app.OnModelBeforeUpdate does not match or nil (%v vs %v)", app.OnModelBeforeUpdate(), app.onModelBeforeUpdate)
|
||||
}
|
||||
|
||||
if app.onModelAfterUpdate != app.OnModelAfterUpdate() || app.OnModelAfterUpdate() == nil {
|
||||
t.Fatalf("Getter app.OnModelAfterUpdate does not match or nil (%v vs %v)", app.OnModelAfterUpdate(), app.onModelAfterUpdate)
|
||||
}
|
||||
|
||||
if app.onModelBeforeDelete != app.OnModelBeforeDelete() || app.OnModelBeforeDelete() == nil {
|
||||
t.Fatalf("Getter app.OnModelBeforeDelete does not match or nil (%v vs %v)", app.OnModelBeforeDelete(), app.onModelBeforeDelete)
|
||||
}
|
||||
|
||||
if app.onModelAfterDelete != app.OnModelAfterDelete() || app.OnModelAfterDelete() == nil {
|
||||
t.Fatalf("Getter app.OnModelAfterDelete does not match or nil (%v vs %v)", app.OnModelAfterDelete(), app.onModelAfterDelete)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeAdminResetPasswordSend != app.OnMailerBeforeAdminResetPasswordSend() || app.OnMailerBeforeAdminResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeAdminResetPasswordSend(), app.onMailerBeforeAdminResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterAdminResetPasswordSend != app.OnMailerAfterAdminResetPasswordSend() || app.OnMailerAfterAdminResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeRecordResetPasswordSend != app.OnMailerBeforeRecordResetPasswordSend() || app.OnMailerBeforeRecordResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordResetPasswordSend(), app.onMailerBeforeRecordResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterRecordResetPasswordSend != app.OnMailerAfterRecordResetPasswordSend() || app.OnMailerAfterRecordResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordResetPasswordSend(), app.onMailerAfterRecordResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeRecordVerificationSend != app.OnMailerBeforeRecordVerificationSend() || app.OnMailerBeforeRecordVerificationSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordVerificationSend(), app.onMailerBeforeRecordVerificationSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterRecordVerificationSend != app.OnMailerAfterRecordVerificationSend() || app.OnMailerAfterRecordVerificationSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordVerificationSend(), app.onMailerAfterRecordVerificationSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeRecordChangeEmailSend != app.OnMailerBeforeRecordChangeEmailSend() || app.OnMailerBeforeRecordChangeEmailSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordChangeEmailSend(), app.onMailerBeforeRecordChangeEmailSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterRecordChangeEmailSend != app.OnMailerAfterRecordChangeEmailSend() || app.OnMailerAfterRecordChangeEmailSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordChangeEmailSend(), app.onMailerAfterRecordChangeEmailSend)
|
||||
}
|
||||
|
||||
if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRealtimeConnectRequest does not match or nil (%v vs %v)", app.OnRealtimeConnectRequest(), app.onRealtimeConnectRequest)
|
||||
}
|
||||
|
||||
if app.onRealtimeBeforeSubscribeRequest != app.OnRealtimeBeforeSubscribeRequest() || app.OnRealtimeBeforeSubscribeRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRealtimeBeforeSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeBeforeSubscribeRequest(), app.onRealtimeBeforeSubscribeRequest)
|
||||
}
|
||||
|
||||
if app.onRealtimeAfterSubscribeRequest != app.OnRealtimeAfterSubscribeRequest() || app.OnRealtimeAfterSubscribeRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRealtimeAfterSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeAfterSubscribeRequest(), app.onRealtimeAfterSubscribeRequest)
|
||||
}
|
||||
|
||||
if app.onSettingsListRequest != app.OnSettingsListRequest() || app.OnSettingsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnSettingsListRequest does not match or nil (%v vs %v)", app.OnSettingsListRequest(), app.onSettingsListRequest)
|
||||
}
|
||||
|
||||
if app.onSettingsBeforeUpdateRequest != app.OnSettingsBeforeUpdateRequest() || app.OnSettingsBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnSettingsBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsBeforeUpdateRequest(), app.onSettingsBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onSettingsAfterUpdateRequest != app.OnSettingsAfterUpdateRequest() || app.OnSettingsAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnSettingsAfterUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsAfterUpdateRequest(), app.onSettingsAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onFileDownloadRequest != app.OnFileDownloadRequest() || app.OnFileDownloadRequest() == nil {
|
||||
t.Fatalf("Getter app.OnFileDownloadRequest does not match or nil (%v vs %v)", app.OnFileDownloadRequest(), app.onFileDownloadRequest)
|
||||
}
|
||||
|
||||
if app.onAdminsListRequest != app.OnAdminsListRequest() || app.OnAdminsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminsListRequest does not match or nil (%v vs %v)", app.OnAdminsListRequest(), app.onAdminsListRequest)
|
||||
}
|
||||
|
||||
if app.onAdminViewRequest != app.OnAdminViewRequest() || app.OnAdminViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminViewRequest does not match or nil (%v vs %v)", app.OnAdminViewRequest(), app.onAdminViewRequest)
|
||||
}
|
||||
|
||||
if app.onAdminBeforeCreateRequest != app.OnAdminBeforeCreateRequest() || app.OnAdminBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminBeforeCreateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeCreateRequest(), app.onAdminBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onAdminAfterCreateRequest != app.OnAdminAfterCreateRequest() || app.OnAdminAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminAfterCreateRequest does not match or nil (%v vs %v)", app.OnAdminAfterCreateRequest(), app.onAdminAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onAdminBeforeUpdateRequest != app.OnAdminBeforeUpdateRequest() || app.OnAdminBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeUpdateRequest(), app.onAdminBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onAdminAfterUpdateRequest != app.OnAdminAfterUpdateRequest() || app.OnAdminAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminAfterUpdateRequest does not match or nil (%v vs %v)", app.OnAdminAfterUpdateRequest(), app.onAdminAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onAdminBeforeDeleteRequest != app.OnAdminBeforeDeleteRequest() || app.OnAdminBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnAdminBeforeDeleteRequest(), app.onAdminBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onAdminAfterDeleteRequest != app.OnAdminAfterDeleteRequest() || app.OnAdminAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminAfterDeleteRequest does not match or nil (%v vs %v)", app.OnAdminAfterDeleteRequest(), app.onAdminAfterDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onAdminAuthRequest != app.OnAdminAuthRequest() || app.OnAdminAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest)
|
||||
}
|
||||
|
||||
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest)
|
||||
}
|
||||
|
||||
if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAuthRequest != app.OnRecordAuthRequest() || app.OnRecordAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest)
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("Getter app.OnRecordBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUnlinkExternalAuthRequest(), app.onRecordBeforeUnlinkExternalAuthRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterUnlinkExternalAuthRequest != app.OnRecordAfterUnlinkExternalAuthRequest() || app.OnRecordAfterUnlinkExternalAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordAfterUnlinkExternalAuthRequest(), app.onRecordAfterUnlinkExternalAuthRequest)
|
||||
}
|
||||
|
||||
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest)
|
||||
}
|
||||
|
||||
if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionsListRequest != app.OnCollectionsListRequest() || app.OnCollectionsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionsListRequest does not match or nil (%v vs %v)", app.OnCollectionsListRequest(), app.onCollectionsListRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionViewRequest != app.OnCollectionViewRequest() || app.OnCollectionViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionViewRequest does not match or nil (%v vs %v)", app.OnCollectionViewRequest(), app.onCollectionViewRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionBeforeCreateRequest != app.OnCollectionBeforeCreateRequest() || app.OnCollectionBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionBeforeCreateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeCreateRequest(), app.onCollectionBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionAfterCreateRequest != app.OnCollectionAfterCreateRequest() || app.OnCollectionAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionAfterCreateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterCreateRequest(), app.onCollectionAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionBeforeUpdateRequest != app.OnCollectionBeforeUpdateRequest() || app.OnCollectionBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeUpdateRequest(), app.onCollectionBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionAfterUpdateRequest != app.OnCollectionAfterUpdateRequest() || app.OnCollectionAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionAfterUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterUpdateRequest(), app.onCollectionAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionBeforeDeleteRequest != app.OnCollectionBeforeDeleteRequest() || app.OnCollectionBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeDeleteRequest(), app.onCollectionBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onCollectionAfterDeleteRequest != app.OnCollectionAfterDeleteRequest() || app.OnCollectionAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnCollectionAfterDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionAfterDeleteRequest(), app.onCollectionAfterDeleteRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppNewMailClient(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
if val, ok := client1.(*mailer.Sendmail); !ok {
|
||||
@@ -419,7 +211,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func initPragmas(db *dbx.DB) error {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
_, err := db.NewQuery(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = TRUE;
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
+8
-23
@@ -3,35 +3,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
pragmas := "_busy_timeout=10000&_journal_mode=WAL&_foreign_keys=1&_synchronous=NORMAL"
|
||||
|
||||
db, openErr := dbx.MustOpen("sqlite3", fmt.Sprintf("%s?%s", dbPath, pragmas))
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
db, err := dbx.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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(1000)
|
||||
db.DB().SetMaxIdleConns(30)
|
||||
db.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// additional pragmas not supported through the dsn string
|
||||
_, err := db.NewQuery(`
|
||||
pragma journal_size_limit = 100000000;
|
||||
`).Execute()
|
||||
|
||||
return db, err
|
||||
return db, nil
|
||||
}
|
||||
|
||||
+5
-17
@@ -3,32 +3,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
pragmas := "_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_pragma=journal_size_limit(100000000)"
|
||||
|
||||
db, err := dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas))
|
||||
db, err := dbx.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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)
|
||||
//
|
||||
// @see https://gitlab.com/cznic/sqlite/-/issues/115
|
||||
db.DB().SetMaxOpenConns(1000)
|
||||
db.DB().SetMaxIdleConns(30)
|
||||
db.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
+197
-26
@@ -4,6 +4,9 @@ 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/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
@@ -11,36 +14,85 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type BaseCollectionEvent struct {
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
func (e *BaseCollectionEvent) Tags() []string {
|
||||
if e.Collection == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]string, 0, 2)
|
||||
|
||||
if e.Collection.Id != "" {
|
||||
tags = append(tags, e.Collection.Id)
|
||||
}
|
||||
|
||||
if e.Collection.Name != "" {
|
||||
tags = append(tags, e.Collection.Name)
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 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
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ hook.Tagger = (*ModelEvent)(nil)
|
||||
|
||||
type ModelEvent struct {
|
||||
Dao *daos.Dao
|
||||
Model models.Model
|
||||
}
|
||||
|
||||
func (e *ModelEvent) Tags() []string {
|
||||
if e.Model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
|
||||
return []string{r.Collection().Id, r.Collection().Name}
|
||||
}
|
||||
|
||||
return []string{e.Model.TableName()}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Mailer events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type MailerRecordEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
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 +106,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,46 +129,150 @@ 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 {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
Records []*models.Record
|
||||
Result *search.Result
|
||||
}
|
||||
|
||||
type RecordViewEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordCreateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordUpdateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordDeleteEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth Record API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordAuthEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Token string
|
||||
Meta any
|
||||
}
|
||||
|
||||
type RecordAuthWithPasswordEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Identity string
|
||||
Password string
|
||||
}
|
||||
|
||||
type RecordAuthWithOAuth2Event struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
}
|
||||
|
||||
type RecordAuthRefreshEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestPasswordResetEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmPasswordResetEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestVerificationEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmVerificationEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestEmailChangeEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmEmailChangeEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordListExternalAuthsEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuths []*models.ExternalAuth
|
||||
}
|
||||
|
||||
type RecordUnlinkExternalAuthEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuth *models.ExternalAuth
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Admin API events data
|
||||
// -------------------------------------------------------------------
|
||||
@@ -142,27 +309,26 @@ type AdminAuthEvent struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth Record API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordAuthEvent struct {
|
||||
type AdminAuthWithPasswordEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Token string
|
||||
Meta any
|
||||
Admin *models.Admin
|
||||
Identity string
|
||||
Password string
|
||||
}
|
||||
|
||||
type RecordListExternalAuthsEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuths []*models.ExternalAuth
|
||||
type AdminAuthRefreshEvent struct {
|
||||
HttpContext echo.Context
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
type RecordUnlinkExternalAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuth *models.ExternalAuth
|
||||
type AdminRequestPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
type AdminConfirmPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -176,23 +342,27 @@ type CollectionsListEvent struct {
|
||||
}
|
||||
|
||||
type CollectionViewEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionCreateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionUpdateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionDeleteEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionsImportEvent struct {
|
||||
@@ -205,8 +375,9 @@ type CollectionsImportEvent struct {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type FileDownloadEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
Record *models.Record
|
||||
FileField *schema.SchemaField
|
||||
ServedPath string
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestBaseCollectionEventTags(t *testing.T) {
|
||||
c1 := new(models.Collection)
|
||||
|
||||
c2 := new(models.Collection)
|
||||
c2.Id = "a"
|
||||
|
||||
c3 := new(models.Collection)
|
||||
c3.Name = "b"
|
||||
|
||||
c4 := new(models.Collection)
|
||||
c4.Id = "a"
|
||||
c4.Name = "b"
|
||||
|
||||
scenarios := []struct {
|
||||
collection *models.Collection
|
||||
expectedTags []string
|
||||
}{
|
||||
{c1, []string{}},
|
||||
{c2, []string{"a"}},
|
||||
{c3, []string{"b"}},
|
||||
{c4, []string{"a", "b"}},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
event := new(core.BaseCollectionEvent)
|
||||
event.Collection = s.collection
|
||||
|
||||
tags := event.Tags()
|
||||
|
||||
if len(s.expectedTags) != len(tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
|
||||
for _, tag := range s.expectedTags {
|
||||
if !list.ExistInSlice(tag, tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEventTags(t *testing.T) {
|
||||
m1 := new(models.Admin)
|
||||
|
||||
c := new(models.Collection)
|
||||
c.Id = "a"
|
||||
c.Name = "b"
|
||||
m2 := models.NewRecord(c)
|
||||
|
||||
scenarios := []struct {
|
||||
model models.Model
|
||||
expectedTags []string
|
||||
}{
|
||||
{m1, []string{"_admins"}},
|
||||
{m2, []string{"a", "b"}},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
event := new(core.ModelEvent)
|
||||
event.Model = s.model
|
||||
|
||||
tags := event.Tags()
|
||||
|
||||
if len(s.expectedTags) != len(tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
|
||||
for _, tag := range s.expectedTags {
|
||||
if !list.ExistInSlice(tag, tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -97,8 +97,8 @@ func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1)
|
||||
|
||||
if len(excludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...))
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
+161
-32
@@ -4,24 +4,44 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
// New creates a new Dao instance with the provided db builder.
|
||||
const DefaultMaxFailRetries = 5
|
||||
|
||||
// New creates a new Dao instance with the provided db builder
|
||||
// (for both async and sync db operations).
|
||||
func New(db dbx.Builder) *Dao {
|
||||
return NewMultiDB(db, db)
|
||||
}
|
||||
|
||||
// New creates a new Dao instance with the provided dedicated
|
||||
// async and sync db builders.
|
||||
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
return &Dao{
|
||||
db: db,
|
||||
concurrentDB: concurrentDB,
|
||||
nonconcurrentDB: nonconcurrentDB,
|
||||
}
|
||||
}
|
||||
|
||||
// Dao handles various db operations.
|
||||
// Think of Dao as a repository and service layer in one.
|
||||
type Dao struct {
|
||||
db dbx.Builder
|
||||
// in a transaction both refer to the same *dbx.TX instance
|
||||
concurrentDB dbx.Builder
|
||||
nonconcurrentDB dbx.Builder
|
||||
|
||||
// @todo delete after removing Block and Continue
|
||||
sem *semaphore.Weighted
|
||||
mux sync.RWMutex
|
||||
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model)
|
||||
@@ -31,16 +51,78 @@ type Dao struct {
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model)
|
||||
}
|
||||
|
||||
// DB returns the internal db builder (*dbx.DB or *dbx.TX).
|
||||
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// Currently the default db builder is dao.concurrentDB but that may change in the future.
|
||||
func (dao *Dao) DB() dbx.Builder {
|
||||
return dao.db
|
||||
return dao.ConcurrentDB()
|
||||
}
|
||||
|
||||
// ConcurrentDB returns the dao concurrent (aka. multiple open connections)
|
||||
// db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
|
||||
func (dao *Dao) ConcurrentDB() dbx.Builder {
|
||||
return dao.concurrentDB
|
||||
}
|
||||
|
||||
// NonconcurrentDB returns the dao nonconcurrent (aka. single open connection)
|
||||
// db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
|
||||
func (dao *Dao) NonconcurrentDB() dbx.Builder {
|
||||
return dao.nonconcurrentDB
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// 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
|
||||
@@ -57,13 +139,23 @@ type afterCallGroup struct {
|
||||
|
||||
// RunInTransaction wraps fn into a transaction.
|
||||
//
|
||||
// It is safe to nest RunInTransaction calls.
|
||||
// It is safe to nest RunInTransaction calls as long as you use the txDao.
|
||||
func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
switch txOrDB := dao.db.(type) {
|
||||
switch txOrDB := dao.NonconcurrentDB().(type) {
|
||||
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{}
|
||||
|
||||
@@ -107,14 +199,15 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
|
||||
if txError == nil {
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
for _, call := range afterCalls {
|
||||
switch call.Action {
|
||||
case "create":
|
||||
dao.AfterCreateFunc(call.EventDao, call.Model)
|
||||
dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
dao.AfterUpdateFunc(call.EventDao, call.Model)
|
||||
dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
dao.AfterDeleteFunc(call.EventDao, call.Model)
|
||||
dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +215,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
return txError
|
||||
}
|
||||
|
||||
return errors.New("Failed to start transaction (unknown dao.db)")
|
||||
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
|
||||
}
|
||||
|
||||
// Delete deletes the provided model.
|
||||
@@ -131,30 +224,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.NonconcurrentDB().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 {
|
||||
@@ -177,7 +276,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.db.Update(
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
@@ -187,7 +286,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.db.Model(m).Update(); err != nil {
|
||||
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -228,18 +327,18 @@ func (dao *Dao) create(m models.Model) error {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.db.Insert(m.TableName(), dataMap).Execute()
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.db.Model(m).Insert(); err != nil {
|
||||
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.UnmarkAsNew()
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
dao.AfterCreateFunc(dao, m)
|
||||
@@ -247,3 +346,33 @@ 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 = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
|
||||
retryDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
retryDao.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
|
||||
}
|
||||
|
||||
@@ -20,6 +20,25 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMultiDB(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
|
||||
if dao.DB() != testApp.Dao().ConcurrentDB() {
|
||||
t.Fatal("[db-concurrentDB] The 2 db instances are different")
|
||||
}
|
||||
|
||||
if dao.ConcurrentDB() != testApp.Dao().ConcurrentDB() {
|
||||
t.Fatal("[concurrentDB-concurrentDB] The 2 db instances are different")
|
||||
}
|
||||
|
||||
if dao.NonconcurrentDB() != testApp.Dao().NonconcurrentDB() {
|
||||
t.Fatal("[nonconcurrentDB-nonconcurrentDB] The 2 db instances are different")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQuery(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
@@ -289,6 +308,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()
|
||||
|
||||
+7
-4
@@ -65,8 +65,7 @@ func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1)
|
||||
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
@@ -85,15 +84,17 @@ func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeI
|
||||
collections := []*models.Collection{}
|
||||
|
||||
query := dao.CollectionQuery()
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
if err := query.All(&collections); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[*models.Collection][]*schema.SchemaField{}
|
||||
|
||||
for _, c := range collections {
|
||||
for _, f := range c.Schema.Fields() {
|
||||
if f.Type != schema.FieldTypeRelation {
|
||||
@@ -212,6 +213,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
|
||||
|
||||
+15
-8
@@ -37,7 +37,7 @@ func TestFindCollectionsByType(t *testing.T) {
|
||||
{"", false, 0},
|
||||
{"unknown", false, 0},
|
||||
{models.CollectionTypeAuth, false, 3},
|
||||
{models.CollectionTypeBase, false, 4},
|
||||
{models.CollectionTypeBase, false, 5},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -122,7 +122,13 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := app.Dao().FindCollectionReferences(collection, collection.Id)
|
||||
result, err := app.Dao().FindCollectionReferences(
|
||||
collection,
|
||||
collection.Id,
|
||||
// test whether "nonempty" exclude ids condition will be skipped
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -296,7 +302,7 @@ func TestImportCollections(t *testing.T) {
|
||||
name: "empty collections",
|
||||
jsonData: `[]`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import",
|
||||
@@ -306,7 +312,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 9,
|
||||
expectCollectionsCount: 10,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + failed beforeRecordsSync",
|
||||
@@ -318,7 +324,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + successful beforeRecordsSync",
|
||||
@@ -330,7 +336,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: 9,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete system collection",
|
||||
@@ -366,7 +372,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: true,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete non-system collection",
|
||||
@@ -495,7 +501,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 8,
|
||||
expectCollectionsCount: 9,
|
||||
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
|
||||
expectedCollectionFields := map[string]int{
|
||||
"nologin": 1,
|
||||
@@ -503,6 +509,7 @@ func TestImportCollections(t *testing.T) {
|
||||
"demo2": 2,
|
||||
"demo3": 2,
|
||||
"demo4": 11,
|
||||
"demo5": 5,
|
||||
"new_import": 1,
|
||||
}
|
||||
for name, expectedCount := range expectedCollectionFields {
|
||||
|
||||
+128
-199
@@ -83,7 +83,7 @@ func (dao *Dao) FindRecordsByIds(
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
rows := make([]dbx.NullStringMap, 0, len(recordIds))
|
||||
if err := query.All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -98,6 +98,7 @@ func (dao *Dao) FindRecordsByIds(
|
||||
// Returns an empty slice if no records are found.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// expr1 := dbx.HashExp{"email": "test@example.com"}
|
||||
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
|
||||
// dao.FindRecordsByExpr("example", expr1, expr2)
|
||||
@@ -191,8 +192,7 @@ func (dao *Dao) IsRecordValueUnique(
|
||||
AndWhere(expr).
|
||||
Limit(1)
|
||||
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*model
|
||||
id, _ := unverifiedClaims["id"].(string)
|
||||
collectionId, _ := unverifiedClaims["collectionId"].(string)
|
||||
if id == "" || collectionId == "" {
|
||||
return nil, errors.New("Missing or invalid token claims.")
|
||||
return nil, errors.New("missing or invalid token claims")
|
||||
}
|
||||
|
||||
record, err := dao.FindRecordById(collectionId, id)
|
||||
@@ -241,8 +241,11 @@ func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*model
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil || !collection.IsAuth() {
|
||||
return nil, errors.New("Missing or not an auth collection.")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
|
||||
}
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
@@ -264,8 +267,11 @@ func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil || !collection.IsAuth() {
|
||||
return nil, errors.New("Missing or not an auth collection.")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
|
||||
}
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
@@ -316,14 +322,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 +337,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,222 +348,145 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
// DeleteRecord deletes the provided Record model.
|
||||
//
|
||||
// This method will also cascade the delete operation to all linked
|
||||
// relational records (delete or set to NULL, depending on the rel settings).
|
||||
// relational records (delete or unset, depending on the rel settings).
|
||||
//
|
||||
// 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).
|
||||
// reference in another record (aka. cannot be deleted or unset).
|
||||
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)
|
||||
// -----------------------------------------------------------
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// delete/update references
|
||||
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)).
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
// manually trigger delete on any linked external auth to ensure
|
||||
// that the `OnModel*` hooks are triggered
|
||||
if record.Collection().IsAuth() {
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||
externalAuths, err := dao.FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, auth := range externalAuths {
|
||||
if err := txDao.DeleteExternalAuth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete the record before the relation references 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
|
||||
}
|
||||
|
||||
return txDao.cascadeRecordDelete(record, refs)
|
||||
})
|
||||
}
|
||||
|
||||
// cascadeRecordDelete triggers cascade deletion for the provided references.
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
|
||||
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
||||
|
||||
for refCollection, fields := range refs {
|
||||
for _, field := range fields {
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
||||
|
||||
// @todo optimize single relation lookup in v0.12+
|
||||
query := dao.RecordQuery(refCollection).
|
||||
Distinct(true).
|
||||
InnerJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize the value access
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
|
||||
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
|
||||
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
|
||||
|
||||
if refCollection.Id == mainRecord.Collection().Id {
|
||||
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
|
||||
}
|
||||
|
||||
// trigger cascade for each batchSize rel items until there is none
|
||||
batchSize := 4000
|
||||
rows := make([]dbx.NullStringMap, 0, batchSize)
|
||||
for {
|
||||
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total := len(rows)
|
||||
if total == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
|
||||
for _, refRecord := range refRecords {
|
||||
ids := refRecord.GetStringSlice(field.Name)
|
||||
|
||||
// unset the record id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == record.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// cascade delete the reference
|
||||
// (only if there are no other active references in case of multiple select)
|
||||
if options.CascadeDelete && len(ids) == 0 {
|
||||
if err := txDao.DeleteRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
// no further action are needed (the reference is deleted)
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Required && len(ids) == 0 {
|
||||
return fmt.Errorf("The record cannot be deleted because it is part of a required reference in record %s (%s collection).", refRecord.Id, refCollection.Name)
|
||||
}
|
||||
|
||||
// save the reference changes
|
||||
refRecord.Set(field.Name, field.PrepareValue(ids))
|
||||
if err := txDao.SaveRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
err := dao.deleteRefRecords(mainRecord, refRecords, field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if total < batchSize {
|
||||
break // no more items
|
||||
}
|
||||
|
||||
rows = rows[:0] // keep allocated memory
|
||||
}
|
||||
}
|
||||
|
||||
// delete linked external auths
|
||||
if record.Collection().IsAuth() {
|
||||
_, err = txDao.DB().Delete((&models.ExternalAuth{}).TableName(), dbx.HashExp{
|
||||
"collectionId": record.Collection().Id,
|
||||
"recordId": record.Id,
|
||||
}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.Delete(record)
|
||||
})
|
||||
}
|
||||
|
||||
// SyncRecordTableSchema compares the two provided collections
|
||||
// and applies the necessary related record table changes.
|
||||
//
|
||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
newSchema := newCollection.Schema
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRefRecords checks 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).
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
return errors.New("relation field options are not initialized")
|
||||
}
|
||||
|
||||
for _, refRecord := range refRecords {
|
||||
ids := refRecord.GetStringSlice(field.Name)
|
||||
|
||||
// unset the record id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == mainRecord.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// check for deleted columns
|
||||
for _, oldField := range oldSchema.Fields() {
|
||||
if f := newSchema.GetFieldById(oldField.Id); f != nil {
|
||||
continue // exist
|
||||
}
|
||||
|
||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||
if err != nil {
|
||||
// cascade delete the reference
|
||||
// (only if there are no other active references in case of multiple select)
|
||||
if options.CascadeDelete && len(ids) == 0 {
|
||||
if err := dao.DeleteRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
// no further actions are needed (the reference is deleted)
|
||||
continue
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if field.Required && len(ids) == 0 {
|
||||
return fmt.Errorf("the record cannot be deleted because it is part of a required reference in record %s (%s collection)", refRecord.Id, refRecord.Collection().Name)
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// save the reference changes
|
||||
refRecord.Set(field.Name, field.PrepareValue(ids))
|
||||
if err := dao.SaveRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -93,10 +93,12 @@ 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
|
||||
}
|
||||
|
||||
// @todo after the index optimizations consider allowing
|
||||
// indirect expand for multi-relation fields
|
||||
indirectRecords, err := dao.FindRecordsByExpr(
|
||||
indirectRel.Id,
|
||||
dbx.In(inflector.Columnify(matches[2]), recordIds...),
|
||||
@@ -176,7 +178,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
}
|
||||
|
||||
// reindex with the rel id
|
||||
indexedRels := map[string]*models.Record{}
|
||||
indexedRels := make(map[string]*models.Record, len(rels))
|
||||
for _, rel := range rels {
|
||||
indexedRels[rel.GetId()] = rel
|
||||
}
|
||||
@@ -216,12 +218,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
continue
|
||||
}
|
||||
|
||||
oldRelExpand := oldExpandedRel.Expand()
|
||||
newRelExpand := rel.Expand()
|
||||
for k, v := range oldRelExpand {
|
||||
newRelExpand[k] = v
|
||||
}
|
||||
rel.SetExpand(newRelExpand)
|
||||
rel.MergeExpand(oldExpandedRel.Expand())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,22 @@ func TestExpandRecords(t *testing.T) {
|
||||
5,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"expand multiple relations sharing a common path",
|
||||
"demo4",
|
||||
[]string{"qzaqccwrmva4o1n"},
|
||||
[]string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_many_no_cascade",
|
||||
"self_rel_many.self_rel_one.rel_many_cascade",
|
||||
"self_rel_many.self_rel_one.rel_many_no_cascade_required",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// SyncRecordTableSchema compares the two provided collections
|
||||
// and applies the necessary related record table changes.
|
||||
//
|
||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
newSchema := newCollection.Schema
|
||||
deletedFieldNames := []string{}
|
||||
renamedFieldNames := map[string]string{}
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// check for deleted columns
|
||||
for _, oldField := range oldSchema.Fields() {
|
||||
if f := newSchema.GetFieldById(oldField.Id); f != nil {
|
||||
continue // exist
|
||||
}
|
||||
|
||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deletedFieldNames = append(deletedFieldNames, oldField.Name)
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
renamedFieldNames[oldField.Name] = field.Name
|
||||
}
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
|
||||
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
|
||||
return nil // nothing to sync
|
||||
}
|
||||
|
||||
refs, err := dao.FindCollectionReferences(collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for refCollection, refFields := range refs {
|
||||
for _, refField := range refFields {
|
||||
options, _ := refField.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// remove deleted (if any)
|
||||
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
|
||||
|
||||
for old, new := range renamedFieldNames {
|
||||
for i, name := range newDisplayFields {
|
||||
if name == old {
|
||||
newDisplayFields[i] = new
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// has changes
|
||||
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
|
||||
options.DisplayFields = newDisplayFields
|
||||
|
||||
// direct collection save to prevent self-referencing
|
||||
// recursion and unnecessary records table sync checks
|
||||
if err := dao.Save(refCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Name: "new_field",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Id: updatedCollection.Schema.GetFieldByName("title").Id,
|
||||
Name: "title_renamed",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
updatedCollection,
|
||||
oldCollection,
|
||||
"demo_renamed",
|
||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
}
|
||||
|
||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||
if len(cols) != len(scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
-93
@@ -1,17 +1,22 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordQuery(t *testing.T) {
|
||||
@@ -628,7 +633,21 @@ func TestDeleteRecord(t *testing.T) {
|
||||
|
||||
// delete existing record + cascade
|
||||
// ---
|
||||
calledQueries := []string{}
|
||||
app.Dao().NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().ConcurrentDB().(*dbx.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,113 +661,164 @@ 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` INNER 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` INNER 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) {
|
||||
func TestDeleteRecordBatchProcessing(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
if err := createMockBatchProcessingData(app.Dao()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
|
||||
// find and delete the first c1 record to trigger cascade
|
||||
mainRecord, _ := app.Dao().FindRecordById("c1", "a")
|
||||
if err := app.Dao().DeleteRecord(mainRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Name: "new_field",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Id: updatedCollection.Schema.GetFieldByName("title").Id,
|
||||
Name: "title_renamed",
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
updatedCollection,
|
||||
oldCollection,
|
||||
"demo_renamed",
|
||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||
},
|
||||
// check if the main record was deleted
|
||||
_, err := app.Dao().FindRecordById(mainRecord.Collection().Id, mainRecord.Id)
|
||||
if err == nil {
|
||||
t.Fatal("The main record wasn't deleted")
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
continue
|
||||
}
|
||||
// check if the c1 b rel field were updated
|
||||
c1RecordB, err := app.Dao().FindRecordById("c1", "b")
|
||||
if err != nil || c1RecordB.GetString("rel") != "" {
|
||||
t.Fatalf("Expected c1RecordB.rel to be nil, got %v", c1RecordB.GetString("rel"))
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
// check if the c2 rel fields were updated
|
||||
c2Records, err := app.Dao().FindRecordsByExpr("c2", nil)
|
||||
if err != nil || len(c2Records) == 0 {
|
||||
t.Fatalf("Failed to fetch c2 records: %v", err)
|
||||
}
|
||||
for _, r := range c2Records {
|
||||
ids := r.GetStringSlice("rel")
|
||||
if len(ids) != 1 || ids[0] != "b" {
|
||||
t.Fatalf("Expected only 'b' rel id, got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||
if len(cols) != len(scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||
}
|
||||
}
|
||||
// check if all c3 relations were deleted
|
||||
c3Records, err := app.Dao().FindRecordsByExpr("c3", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch c3 records: %v", err)
|
||||
}
|
||||
if total := len(c3Records); total != 0 {
|
||||
t.Fatalf("Expected c3 records to be deleted, found %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func createMockBatchProcessingData(dao *daos.Dao) error {
|
||||
// create mock collection without relation
|
||||
c1 := &models.Collection{}
|
||||
c1.Id = "c1"
|
||||
c1.Name = c1.Id
|
||||
c1.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "text",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
// self reference
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(1),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: false, // should unset all rel fields
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create mock collection with a multi-rel field
|
||||
c2 := &models.Collection{}
|
||||
c2.Id = "c2"
|
||||
c2.Name = c2.Id
|
||||
c2.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(10),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: false, // should unset all rel fields
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create mock collection with a single-rel field
|
||||
c3 := &models.Collection{}
|
||||
c3.Id = "c3"
|
||||
c3.Name = c3.Id
|
||||
c3.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(1),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: true, // should delete all c3 records
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c3); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert mock records
|
||||
c1RecordA := models.NewRecord(c1)
|
||||
c1RecordA.Id = "a"
|
||||
c1RecordA.Set("rel", c1RecordA.Id) // self reference
|
||||
if err := dao.Save(c1RecordA); err != nil {
|
||||
return err
|
||||
}
|
||||
c1RecordB := models.NewRecord(c1)
|
||||
c1RecordB.Id = "b"
|
||||
c1RecordB.Set("rel", c1RecordA.Id) // rel to another record from the same collection
|
||||
if err := dao.Save(c1RecordB); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < 4500; i++ {
|
||||
c2Record := models.NewRecord(c2)
|
||||
c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
|
||||
if err := dao.Save(c2Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c3Record := models.NewRecord(c3)
|
||||
c3Record.Set("rel", c1RecordA.Id)
|
||||
if err := dao.Save(c3Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// set the same id as the relation for at least 1 record
|
||||
// to check whether the correct condition will be added
|
||||
c3Record := models.NewRecord(c3)
|
||||
c3Record.Set("rel", c1RecordA.Id)
|
||||
c3Record.Id = c1RecordA.Id
|
||||
if err := dao.Save(c3Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+23
-7
@@ -1,6 +1,7 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -46,19 +47,34 @@ func (form *AdminLogin) Validate() error {
|
||||
|
||||
// Submit validates and submits the admin form.
|
||||
// On success returns the authorized admin model.
|
||||
func (form *AdminLogin) Submit() (*models.Admin, error) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *AdminLogin) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.dao.FindAdminByEmail(form.Identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
admin, fetchErr := form.dao.FindAdminByEmail(form.Identity)
|
||||
|
||||
// ignore not found errors to allow custom fetch implementations
|
||||
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
|
||||
return nil, fetchErr
|
||||
}
|
||||
|
||||
if admin.ValidatePassword(form.Password) {
|
||||
return admin, nil
|
||||
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
|
||||
admin = m
|
||||
|
||||
if admin == nil || !admin.ValidatePassword(form.Password) {
|
||||
return errors.New("Invalid login credentials.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return nil, errors.New("Invalid login credentials.")
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -47,3 +49,48 @@ func TestAdminLoginValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewAdminLogin(testApp)
|
||||
form.Identity = "test@example.com"
|
||||
form.Password = "123456"
|
||||
var interceptorAdmin *models.Admin
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorAdmin = admin
|
||||
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 interceptorAdmin == nil || interceptorAdmin.Email != form.Identity {
|
||||
t.Fatalf("Expected Admin model with email %s, got %v", form.Identity, interceptorAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,10 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
|
||||
|
||||
// Submit validates and submits the admin password reset confirmation form.
|
||||
// On success returns the updated admin model associated to `form.Token`.
|
||||
func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,8 +83,13 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.dao.SaveAdmin(admin); err != nil {
|
||||
return nil, err
|
||||
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
|
||||
admin = m
|
||||
return form.dao.SaveAdmin(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return admin, nil
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"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 TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
admin, err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
|
||||
admin, 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 {
|
||||
@@ -78,3 +97,54 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewAdminPasswordResetConfirm(testApp)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc"
|
||||
form.Password = "1234567891"
|
||||
form.PasswordConfirm = "1234567891"
|
||||
interceptorTokenKey := admin.TokenKey
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorTokenKey = admin.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 == admin.TokenKey {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -55,7 +56,10 @@ func (form *AdminPasswordResetRequest) Validate() error {
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success sends a password reset email to the `form.Email` admin.
|
||||
func (form *AdminPasswordResetRequest) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -71,12 +75,14 @@ func (form *AdminPasswordResetRequest) Submit() error {
|
||||
return errors.New("You have already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendAdminPasswordReset(form.app, admin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
admin.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.dao.SaveAdmin(admin)
|
||||
return runInterceptors(admin, func(m *models.Admin) error {
|
||||
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.dao.SaveAdmin(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -31,7 +33,24 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
|
||||
adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email)
|
||||
|
||||
err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -53,3 +72,52 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewAdminPasswordResetRequest(testApp)
|
||||
form.Email = admin.Email
|
||||
interceptorLastResetSentAt := admin.LastResetSentAt
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorLastResetSentAt = admin.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() == admin.LastResetSentAt.String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ func (form *AdminUpsert) Validate() error {
|
||||
form.admin.IsNew(),
|
||||
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
|
||||
validation.Match(idRegex),
|
||||
validation.By(validators.UniqueId(form.dao, form.admin.TableName())),
|
||||
).Else(validation.In(form.admin.Id)),
|
||||
),
|
||||
validation.Field(
|
||||
@@ -98,7 +99,7 @@ func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -116,7 +117,7 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
form.admin.SetPassword(form.Password)
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.dao.SaveAdmin(form.admin)
|
||||
return runInterceptors(form.admin, func(admin *models.Admin) error {
|
||||
return form.dao.SaveAdmin(admin)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -137,10 +137,10 @@ func TestAdminUpsertValidateAndSubmit(t *testing.T) {
|
||||
|
||||
interceptorCalls := 0
|
||||
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(m)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -196,16 +196,16 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
|
||||
interceptorAdminEmail := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorAdminEmail = admin.Email // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
|
||||
+10
-5
@@ -11,16 +11,21 @@ var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
|
||||
|
||||
// InterceptorNextFunc is a interceptor handler function.
|
||||
// Usually used in combination with InterceptorFunc.
|
||||
type InterceptorNextFunc = func() error
|
||||
type InterceptorNextFunc[T any] func(t T) error
|
||||
|
||||
// InterceptorFunc defines a single interceptor function that will execute the provided next func handler.
|
||||
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
|
||||
// InterceptorFunc defines a single interceptor function that
|
||||
// will execute the provided next func handler.
|
||||
type InterceptorFunc[T any] func(next InterceptorNextFunc[T]) InterceptorNextFunc[T]
|
||||
|
||||
// runInterceptors executes the provided list of interceptors.
|
||||
func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) error {
|
||||
func runInterceptors[T any](
|
||||
data T,
|
||||
next InterceptorNextFunc[T],
|
||||
interceptors ...InterceptorFunc[T],
|
||||
) error {
|
||||
for i := len(interceptors) - 1; i >= 0; i-- {
|
||||
next = interceptors[i](next)
|
||||
}
|
||||
|
||||
return next()
|
||||
return next(data)
|
||||
}
|
||||
|
||||
+41
-10
@@ -9,6 +9,7 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
@@ -93,6 +94,7 @@ func (form *CollectionUpsert) Validate() error {
|
||||
form.collection.IsNew(),
|
||||
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
|
||||
validation.Match(idRegex),
|
||||
validation.By(validators.UniqueId(form.dao, form.collection.TableName())),
|
||||
).Else(validation.In(form.collection.Id)),
|
||||
),
|
||||
validation.Field(
|
||||
@@ -119,7 +121,7 @@ func (form *CollectionUpsert) Validate() error {
|
||||
validation.By(form.checkMinSchemaFields),
|
||||
validation.By(form.ensureNoSystemFieldsChange),
|
||||
validation.By(form.ensureNoFieldsTypeChange),
|
||||
validation.By(form.ensureExistingRelationCollectionId),
|
||||
validation.By(form.checkRelationFields),
|
||||
validation.When(
|
||||
isAuth,
|
||||
validation.By(form.ensureNoAuthFieldName),
|
||||
@@ -202,9 +204,17 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
|
||||
func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
systemDisplayFields := schema.BaseModelFieldNames()
|
||||
systemDisplayFields = append(systemDisplayFields,
|
||||
schema.FieldNameUsername,
|
||||
schema.FieldNameEmail,
|
||||
schema.FieldNameEmailVisibility,
|
||||
schema.FieldNameVerified,
|
||||
)
|
||||
|
||||
for i, field := range v.Fields() {
|
||||
if field.Type != schema.FieldTypeRelation {
|
||||
continue
|
||||
@@ -215,11 +225,32 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := form.dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
|
||||
return validation.Errors{fmt.Sprint(i): validation.NewError(
|
||||
"validation_field_invalid_relation",
|
||||
"The relation collection doesn't exist.",
|
||||
)}
|
||||
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
|
||||
// validate collectionId
|
||||
if err != nil || collection.Id != options.CollectionId {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
"validation_field_invalid_relation",
|
||||
"The relation collection doesn't exist.",
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// validate displayFields (if any)
|
||||
for _, name := range options.DisplayFields {
|
||||
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"displayFields": validation.NewError(
|
||||
"validation_field_invalid_relation_displayFields",
|
||||
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +374,7 @@ func (form *CollectionUpsert) checkOptions(value any) error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Collection]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -375,7 +406,7 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
form.collection.DeleteRule = form.DeleteRule
|
||||
form.collection.SetOptions(form.Options)
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.dao.SaveCollection(form.collection)
|
||||
return runInterceptors(form.collection, func(collection *models.Collection) error {
|
||||
return form.dao.SaveCollection(collection)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -168,6 +168,25 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - missing relation display field",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{
|
||||
"name":"test",
|
||||
"type":"relation",
|
||||
"options":{
|
||||
"collectionId":"wsmn24bux7wo113",
|
||||
"displayFields":["text", "missing"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - check type options validators",
|
||||
"",
|
||||
@@ -190,7 +209,15 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"id":"a123456","name":"test1","type":"text"},
|
||||
{"id":"b123456","name":"test2","type":"email"}
|
||||
{"id":"b123456","name":"test2","type":"email"},
|
||||
{
|
||||
"name":"test3",
|
||||
"type":"relation",
|
||||
"options":{
|
||||
"collectionId":"v851q4r790rhknl",
|
||||
"displayFields":["name","id","created","updated","username","email","emailVisibility","verified"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"listRule": "test1='123' && verified = true",
|
||||
"viewRule": "test1='123' && emailVisibility = true",
|
||||
@@ -351,10 +378,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,12 +394,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
|
||||
@@ -451,16 +478,16 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
interceptorCollectionName := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCollectionName = collection.Name // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
|
||||
@@ -56,15 +56,15 @@ func (form *CollectionsImport) Validate() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.Collection]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return runInterceptors(form.Collections, func(collections []*models.Collection) error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
importErr := txDao.ImportCollections(
|
||||
form.Collections,
|
||||
collections,
|
||||
form.DeleteMissing,
|
||||
form.beforeRecordsSync,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"collections": []
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: nil,
|
||||
},
|
||||
{
|
||||
@@ -82,7 +82,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@@ -101,7 +101,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@@ -137,7 +137,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 10,
|
||||
expectCollectionsCount: 11,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
@@ -160,7 +160,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
@@ -202,7 +202,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 5,
|
||||
},
|
||||
@@ -253,7 +253,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 9,
|
||||
expectCollectionsCount: 10,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
@@ -341,8 +341,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnModelBeforeDelete": 5,
|
||||
"OnModelAfterDelete": 5,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelAfterDelete": 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -404,16 +404,16 @@ func TestCollectionsImportSubmitInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(imports)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
|
||||
@@ -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 InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -127,8 +130,13 @@ 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 := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mails.SendRecordChangeEmail(form.app, form.record, form.NewEmail)
|
||||
return runInterceptors(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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,25 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// RecordOAuth2LoginData defines the OA
|
||||
type RecordOAuth2LoginData struct {
|
||||
ExternalAuth *models.ExternalAuth
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
}
|
||||
|
||||
// BeforeOAuth2RecordCreateFunc defines a callback function that will
|
||||
// be called before OAuth2 new Record creation.
|
||||
type BeforeOAuth2RecordCreateFunc func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error
|
||||
|
||||
// RecordOAuth2Login is an auth record OAuth2 login form.
|
||||
type RecordOAuth2Login struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
beforeOAuth2RecordCreateFunc BeforeOAuth2RecordCreateFunc
|
||||
|
||||
// Optional auth record that will be used if no external
|
||||
// auth relation is found (if it is from the same collection)
|
||||
loggedAuthRecord *models.Record
|
||||
@@ -62,6 +75,11 @@ func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
|
||||
func (form *RecordOAuth2Login) SetBeforeNewRecordCreateFunc(f BeforeOAuth2RecordCreateFunc) {
|
||||
form.beforeOAuth2RecordCreateFunc = f
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordOAuth2Login) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
@@ -87,11 +105,14 @@ func (form *RecordOAuth2Login) checkProviderName(value any) error {
|
||||
//
|
||||
// If an auth record doesn't exist, it will make an attempt to create it
|
||||
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
|
||||
// You can intercept/modify the create form by setting the optional beforeCreateFuncs argument.
|
||||
// You can intercept/modify the Record create form with [form.SetBeforeNewRecordCreateFunc()].
|
||||
//
|
||||
// You can also optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
//
|
||||
// On success returns the authorized record model and the fetched provider's data.
|
||||
func (form *RecordOAuth2Login) Submit(
|
||||
beforeCreateFuncs ...func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error,
|
||||
interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
|
||||
) (*models.Record, *auth.AuthUser, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
@@ -147,16 +168,40 @@ func (form *RecordOAuth2Login) Submit(
|
||||
authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
}
|
||||
|
||||
saveErr := form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if authRecord == nil {
|
||||
authRecord = models.NewRecord(form.collection)
|
||||
authRecord.RefreshId()
|
||||
authRecord.MarkAsNew()
|
||||
createForm := NewRecordUpsert(form.app, authRecord)
|
||||
interceptorData := &RecordOAuth2LoginData{
|
||||
ExternalAuth: rel,
|
||||
Record: authRecord,
|
||||
OAuth2User: authUser,
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
|
||||
return form.submit(newData)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorData.OAuth2User, interceptorsErr
|
||||
}
|
||||
|
||||
return interceptorData.Record, interceptorData.OAuth2User, nil
|
||||
}
|
||||
|
||||
func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if data.Record == nil {
|
||||
data.Record = models.NewRecord(form.collection)
|
||||
data.Record.RefreshId()
|
||||
data.Record.MarkAsNew()
|
||||
createForm := NewRecordUpsert(form.app, data.Record)
|
||||
createForm.SetFullManageAccess(true)
|
||||
createForm.SetDao(txDao)
|
||||
if authUser.Username != "" && usernameRegex.MatchString(authUser.Username) {
|
||||
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(form.collection.Id, authUser.Username)
|
||||
if data.OAuth2User.Username != "" &&
|
||||
len(data.OAuth2User.Username) >= 3 &&
|
||||
len(data.OAuth2User.Username) <= 150 &&
|
||||
usernameRegex.MatchString(data.OAuth2User.Username) {
|
||||
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(
|
||||
form.collection.Id,
|
||||
data.OAuth2User.Username,
|
||||
)
|
||||
}
|
||||
|
||||
// load custom data
|
||||
@@ -164,10 +209,10 @@ func (form *RecordOAuth2Login) Submit(
|
||||
|
||||
// load the OAuth2 profile data as fallback
|
||||
if createForm.Email == "" {
|
||||
createForm.Email = authUser.Email
|
||||
createForm.Email = data.OAuth2User.Email
|
||||
}
|
||||
createForm.Verified = false
|
||||
if createForm.Email == authUser.Email {
|
||||
if createForm.Email == data.OAuth2User.Email {
|
||||
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
|
||||
createForm.Verified = true
|
||||
}
|
||||
@@ -176,11 +221,8 @@ func (form *RecordOAuth2Login) Submit(
|
||||
createForm.PasswordConfirm = createForm.Password
|
||||
}
|
||||
|
||||
for _, f := range beforeCreateFuncs {
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
if err := f(createForm, authRecord, authUser); err != nil {
|
||||
if form.beforeOAuth2RecordCreateFunc != nil {
|
||||
if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -190,45 +232,39 @@ func (form *RecordOAuth2Login) Submit(
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// update the existing auth record empty email if the authUser has one
|
||||
// update the existing auth record empty email if the data.OAuth2User has one
|
||||
// (this is in case previously the auth record was created
|
||||
// with an OAuth2 provider that didn't return an email address)
|
||||
if authRecord.Email() == "" && authUser.Email != "" {
|
||||
authRecord.SetEmail(authUser.Email)
|
||||
if err := txDao.SaveRecord(authRecord); err != nil {
|
||||
if data.Record.Email() == "" && data.OAuth2User.Email != "" {
|
||||
data.Record.SetEmail(data.OAuth2User.Email)
|
||||
if err := txDao.SaveRecord(data.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update the existing auth record verified state
|
||||
// (only if the auth record doesn't have an email or the auth record email match with the one in authUser)
|
||||
if !authRecord.Verified() && (authRecord.Email() == "" || authRecord.Email() == authUser.Email) {
|
||||
authRecord.SetVerified(true)
|
||||
if err := txDao.SaveRecord(authRecord); err != nil {
|
||||
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
|
||||
if !data.Record.Verified() && (data.Record.Email() == "" || data.Record.Email() == data.OAuth2User.Email) {
|
||||
data.Record.SetVerified(true)
|
||||
if err := txDao.SaveRecord(data.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create ExternalAuth relation if missing
|
||||
if rel == nil {
|
||||
rel = &models.ExternalAuth{
|
||||
CollectionId: authRecord.Collection().Id,
|
||||
RecordId: authRecord.Id,
|
||||
if data.ExternalAuth == nil {
|
||||
data.ExternalAuth = &models.ExternalAuth{
|
||||
CollectionId: data.Record.Collection().Id,
|
||||
RecordId: data.Record.Id,
|
||||
Provider: form.Provider,
|
||||
ProviderId: authUser.Id,
|
||||
ProviderId: data.OAuth2User.Id,
|
||||
}
|
||||
if err := txDao.SaveExternalAuth(rel); err != nil {
|
||||
if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if saveErr != nil {
|
||||
return nil, authUser, saveErr
|
||||
}
|
||||
|
||||
return authRecord, authUser, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -48,30 +49,47 @@ func (form *RecordPasswordLogin) Validate() error {
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the authorized record model.
|
||||
func (form *RecordPasswordLogin) Submit() (*models.Record, error) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordLogin) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authOptions := form.collection.AuthOptions()
|
||||
|
||||
if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
|
||||
return nil, errors.New("Password authentication is not allowed for the collection.")
|
||||
}
|
||||
|
||||
var record *models.Record
|
||||
var authRecord *models.Record
|
||||
var fetchErr error
|
||||
|
||||
if authOptions.AllowEmailAuth &&
|
||||
(!authOptions.AllowUsernameAuth || is.EmailFormat.Validate(form.Identity) == nil) {
|
||||
record, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
|
||||
} else {
|
||||
record, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
|
||||
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
||||
|
||||
if isEmail {
|
||||
if authOptions.AllowEmailAuth {
|
||||
authRecord, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
|
||||
}
|
||||
} else if authOptions.AllowUsernameAuth {
|
||||
authRecord, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
|
||||
}
|
||||
|
||||
if fetchErr != nil || !record.ValidatePassword(form.Password) {
|
||||
return nil, errors.New("Invalid login credentials.")
|
||||
// ignore not found errors to allow custom fetch implementations
|
||||
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
|
||||
return nil, fetchErr
|
||||
}
|
||||
|
||||
return record, nil
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
|
||||
if authRecord == nil || !authRecord.ValidatePassword(form.Password) {
|
||||
return errors.New("Invalid login credentials.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
|
||||
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -128,3 +130,53 @@ func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPasswordLoginInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordLogin(testApp, authCollection)
|
||||
form.Identity = "test@example.com"
|
||||
form.Password = "123456"
|
||||
var interceptorRecord *models.Record
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorRecord = record
|
||||
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 interceptorRecord == nil || interceptorRecord.Email() != form.Identity {
|
||||
t.Fatalf("Expected auth Record model with email %s, got %v", form.Identity, interceptorRecord)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,8 +91,13 @@ func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.dao.SaveRecord(authRecord); err != nil {
|
||||
return nil, err
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) 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 runInterceptors(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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+296
-186
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -18,6 +17,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -34,8 +34,8 @@ type RecordUpsert struct {
|
||||
manageAccess bool
|
||||
record *models.Record
|
||||
|
||||
filesToUpload map[string][]*filesystem.File
|
||||
filesToDelete []string // names list
|
||||
filesToUpload map[string][]*rest.UploadedFile
|
||||
|
||||
// base model fields
|
||||
Id string `json:"id"`
|
||||
@@ -51,7 +51,7 @@ type RecordUpsert struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
// ---
|
||||
|
||||
Data map[string]any `json:"data"`
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
|
||||
@@ -66,7 +66,7 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||
dao: app.Dao(),
|
||||
record: record,
|
||||
filesToDelete: []string{},
|
||||
filesToUpload: map[string][]*rest.UploadedFile{},
|
||||
filesToUpload: map[string][]*filesystem.File{},
|
||||
}
|
||||
|
||||
form.loadFormDefaults()
|
||||
@@ -74,6 +74,11 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||
return form
|
||||
}
|
||||
|
||||
// Data returns the loaded form's data.
|
||||
func (form *RecordUpsert) Data() map[string]any {
|
||||
return form.data
|
||||
}
|
||||
|
||||
// SetFullManageAccess sets the manageAccess bool flag of the current
|
||||
// form to enable/disable directly changing some system record fields
|
||||
// (often used with auth collection records).
|
||||
@@ -96,9 +101,9 @@ func (form *RecordUpsert) loadFormDefaults() {
|
||||
form.Verified = form.record.Verified()
|
||||
}
|
||||
|
||||
form.Data = map[string]any{}
|
||||
form.data = map[string]any{}
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
form.Data[field.Name] = form.record.Get(field.Name)
|
||||
form.data[field.Name] = form.record.Get(field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,49 +117,56 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
|
||||
return t
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractRequestData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
func (form *RecordUpsert) extractRequestData(
|
||||
r *http.Request,
|
||||
keyPrefix string,
|
||||
) (map[string]any, map[string][]*filesystem.File, error) {
|
||||
switch form.getContentType(r) {
|
||||
case "application/json":
|
||||
return form.extractJsonData(r, keyPrefix)
|
||||
case "multipart/form-data":
|
||||
return form.extractMultipartFormData(r, keyPrefix)
|
||||
default:
|
||||
return nil, errors.New("Unsupported request Content-Type.")
|
||||
return nil, nil, errors.New("unsupported request content-type")
|
||||
}
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractJsonData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
func (form *RecordUpsert) extractJsonData(
|
||||
r *http.Request,
|
||||
keyPrefix string,
|
||||
) (map[string]any, map[string][]*filesystem.File, error) {
|
||||
data := map[string]any{}
|
||||
|
||||
err := rest.CopyJsonBody(r, &result)
|
||||
err := rest.CopyJsonBody(r, &data)
|
||||
|
||||
if keyPrefix != "" {
|
||||
parts := strings.Split(keyPrefix, ".")
|
||||
for _, part := range parts {
|
||||
if result[part] == nil {
|
||||
if data[part] == nil {
|
||||
break
|
||||
}
|
||||
if v, ok := result[part].(map[string]any); ok {
|
||||
result = v
|
||||
if v, ok := data[part].(map[string]any); ok {
|
||||
data = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, err
|
||||
return data, nil, err
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
|
||||
func (form *RecordUpsert) extractMultipartFormData(
|
||||
r *http.Request,
|
||||
keyPrefix string,
|
||||
) (map[string]any, map[string][]*filesystem.File, error) {
|
||||
// parse form data (if not already)
|
||||
if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil {
|
||||
return result, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
data := map[string]any{}
|
||||
filesToUpload := map[string][]*filesystem.File{}
|
||||
arrayValueSupportTypes := schema.ArraybleFieldTypes()
|
||||
|
||||
form.filesToUpload = map[string][]*rest.UploadedFile{}
|
||||
|
||||
for fullKey, values := range r.PostForm {
|
||||
key := fullKey
|
||||
if keyPrefix != "" {
|
||||
@@ -162,15 +174,15 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix st
|
||||
}
|
||||
|
||||
if len(values) == 0 {
|
||||
result[key] = nil
|
||||
data[key] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) {
|
||||
result[key] = values
|
||||
data[key] = values
|
||||
} else {
|
||||
result[key] = values[0]
|
||||
data[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,76 +208,155 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix st
|
||||
continue
|
||||
}
|
||||
|
||||
options, ok := field.Options.(*schema.FileOptions)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if form.filesToUpload[key] == nil {
|
||||
form.filesToUpload[key] = []*rest.UploadedFile{}
|
||||
}
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key], files[0])
|
||||
} else if options.MaxSelect > 1 {
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key], files...)
|
||||
}
|
||||
filesToUpload[key] = append(filesToUpload[key], files...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) normalizeData() error {
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
if v, ok := form.Data[field.Name]; ok {
|
||||
form.Data[field.Name] = field.PrepareValue(v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return data, filesToUpload, nil
|
||||
}
|
||||
|
||||
// LoadRequest extracts the json or multipart/form-data request data
|
||||
// and lods it into the form.
|
||||
//
|
||||
// File upload is supported only via multipart/form-data.
|
||||
//
|
||||
// To DELETE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// reset the field using its field name (eg. `myfile = null`).
|
||||
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
|
||||
requestData, err := form.extractRequestData(r, keyPrefix)
|
||||
requestData, uploadedFiles, err := form.extractRequestData(r, keyPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.LoadData(requestData)
|
||||
if err := form.LoadData(requestData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, files := range uploadedFiles {
|
||||
form.AddFiles(key, files...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes the provided data into the form.
|
||||
// AddFiles adds the provided file(s) to the specified file field.
|
||||
//
|
||||
// To DELETE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// reset the field using its field name (eg. `myfile = null`).
|
||||
// If the file field is a SINGLE-value file field (aka. "Max Select = 1"),
|
||||
// then the newly added file will REPLACE the existing one.
|
||||
// In this case if you pass more than 1 files only the first one will be assigned.
|
||||
//
|
||||
// If the file field is a MULTI-value file field (aka. "Max Select > 1"),
|
||||
// then the newly added file(s) will be APPENDED to the existing one(s).
|
||||
//
|
||||
// Example
|
||||
//
|
||||
// f1, _ := filesystem.NewFileFromPath("/path/to/file1.txt")
|
||||
// f2, _ := filesystem.NewFileFromPath("/path/to/file2.txt")
|
||||
// form.AddFiles("documents", f1, f2)
|
||||
func (form *RecordUpsert) AddFiles(key string, files ...*filesystem.File) error {
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field == nil || field.Type != schema.FieldTypeFile {
|
||||
return errors.New("invalid field key")
|
||||
}
|
||||
|
||||
options, ok := field.Options.(*schema.FileOptions)
|
||||
if !ok {
|
||||
return errors.New("failed to initilize field options")
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return nil // nothing to upload
|
||||
}
|
||||
|
||||
if form.filesToUpload == nil {
|
||||
form.filesToUpload = map[string][]*filesystem.File{}
|
||||
}
|
||||
|
||||
oldNames := list.ToUniqueStringSlice(form.data[key])
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
// mark previous file(s) for deletion before replacing
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||
}
|
||||
|
||||
// replace
|
||||
form.filesToUpload[key] = []*filesystem.File{files[0]}
|
||||
form.data[key] = field.PrepareValue(files[0].Name)
|
||||
} else {
|
||||
// append
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key], files...)
|
||||
for _, f := range files {
|
||||
oldNames = append(oldNames, f.Name)
|
||||
}
|
||||
form.data[key] = field.PrepareValue(oldNames)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFiles removes a single or multiple file from the specified file field.
|
||||
//
|
||||
// NB! If filesToDelete is not set it will remove all existing files
|
||||
// assigned to the file field (including those assigned with AddFiles)!
|
||||
//
|
||||
// Example
|
||||
//
|
||||
// // mark only only 2 files for removal
|
||||
// form.AddFiles("documents", "file1_aw4bdrvws6.txt", "file2_xwbs36bafv.txt")
|
||||
//
|
||||
// // mark all "documents" files for removal
|
||||
// form.AddFiles("documents")
|
||||
func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
|
||||
field := form.record.Collection().Schema.GetFieldByName(key)
|
||||
if field == nil || field.Type != schema.FieldTypeFile {
|
||||
return errors.New("invalid field key")
|
||||
}
|
||||
|
||||
existing := list.ToUniqueStringSlice(form.data[key])
|
||||
|
||||
// mark all files for deletion
|
||||
if len(toDelete) == 0 {
|
||||
toDelete = make([]string, len(existing))
|
||||
copy(toDelete, existing)
|
||||
}
|
||||
|
||||
// check for existing files
|
||||
for i := len(existing) - 1; i >= 0; i-- {
|
||||
if list.ExistInSlice(existing[i], toDelete) {
|
||||
form.filesToDelete = append(form.filesToDelete, existing[i])
|
||||
existing = append(existing[:i], existing[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
// check for newly uploaded files
|
||||
for i := len(form.filesToUpload[key]) - 1; i >= 0; i-- {
|
||||
f := form.filesToUpload[key][i]
|
||||
if list.ExistInSlice(f.Name, toDelete) {
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key][:i], form.filesToUpload[key][i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
form.data[key] = field.PrepareValue(existing)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes the provided regular record data fields into the form.
|
||||
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
// load base system fields
|
||||
if v, ok := requestData["id"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameId]; ok {
|
||||
form.Id = cast.ToString(v)
|
||||
}
|
||||
|
||||
// load auth system fields
|
||||
if form.record.Collection().IsAuth() {
|
||||
if v, ok := requestData["username"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameUsername]; ok {
|
||||
form.Username = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["email"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameEmail]; ok {
|
||||
form.Email = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["emailVisibility"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameEmailVisibility]; ok {
|
||||
form.EmailVisibility = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestData["verified"]; ok {
|
||||
if v, ok := requestData[schema.FieldNameVerified]; ok {
|
||||
form.Verified = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestData["password"]; ok {
|
||||
@@ -279,8 +370,16 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
}
|
||||
}
|
||||
|
||||
// extend the record schema data with the request data
|
||||
extendedData := form.record.SchemaData()
|
||||
// replace modifiers (if any)
|
||||
requestData = form.record.ReplaceModifers(requestData)
|
||||
|
||||
// create a shallow copy of form.data
|
||||
var extendedData = make(map[string]any, len(form.data))
|
||||
for k, v := range form.data {
|
||||
extendedData[k] = v
|
||||
}
|
||||
|
||||
// extend form.data with the request data
|
||||
rawData, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -291,89 +390,59 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
key := field.Name
|
||||
value := extendedData[key]
|
||||
value = field.PrepareValue(value)
|
||||
value := field.PrepareValue(extendedData[key])
|
||||
|
||||
if field.Type != schema.FieldTypeFile {
|
||||
form.Data[key] = value
|
||||
form.data[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.FileOptions)
|
||||
oldNames := list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Delete previously uploaded file(s)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
oldNames := form.record.GetStringSlice(key)
|
||||
submittedNames := list.ToUniqueStringSlice(value)
|
||||
|
||||
// ensure that all submitted names are existing to prevent accidental files deletions
|
||||
if len(submittedNames) > len(oldNames) || len(list.SubtractSlice(submittedNames, oldNames)) != 0 {
|
||||
return validation.Errors{
|
||||
key: validation.NewError(
|
||||
"validation_unknown_filenames",
|
||||
"The field contains unknown filenames.",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// if empty value was set, mark all previously uploaded files for deletion
|
||||
if len(list.ToUniqueStringSlice(value)) == 0 && len(oldNames) > 0 {
|
||||
form.filesToDelete = append(form.filesToDelete, oldNames...)
|
||||
form.Data[key] = []string{}
|
||||
// otherwise check for "deleted" (aka. unsubmitted) file names
|
||||
if len(submittedNames) == 0 && len(oldNames) > 0 {
|
||||
form.RemoveFiles(key)
|
||||
} else if len(oldNames) > 0 {
|
||||
indexesToDelete := make([]int, 0, len(extendedData))
|
||||
|
||||
// search for individual file name to delete (eg. "file.test.png = null")
|
||||
for i, name := range oldNames {
|
||||
if v, ok := extendedData[key+"."+name]; ok && cast.ToString(v) == "" {
|
||||
indexesToDelete = append(indexesToDelete, i)
|
||||
}
|
||||
}
|
||||
toDelete := []string{}
|
||||
|
||||
// search for individual file index to delete (eg. "file.0 = null")
|
||||
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
|
||||
for indexedKey := range extendedData {
|
||||
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
|
||||
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
|
||||
if indexErr != nil || index >= len(oldNames) {
|
||||
continue
|
||||
}
|
||||
indexesToDelete = append(indexesToDelete, index)
|
||||
}
|
||||
}
|
||||
|
||||
// slice to fill only with the non-deleted indexes
|
||||
nonDeleted := make([]string, 0, len(oldNames))
|
||||
for i, name := range oldNames {
|
||||
// not marked for deletion
|
||||
if !list.ExistInSlice(i, indexesToDelete) {
|
||||
nonDeleted = append(nonDeleted, name)
|
||||
for _, name := range oldNames {
|
||||
// submitted as a modifier or a new array
|
||||
if !list.ExistInSlice(name, submittedNames) {
|
||||
toDelete = append(toDelete, name)
|
||||
continue
|
||||
}
|
||||
|
||||
// store the id to actually delete the file later
|
||||
form.filesToDelete = append(form.filesToDelete, name)
|
||||
}
|
||||
form.Data[key] = nonDeleted
|
||||
|
||||
if len(toDelete) > 0 {
|
||||
form.RemoveFiles(key, toDelete...)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Check for new uploaded file
|
||||
// -----------------------------------------------------------
|
||||
|
||||
if len(form.filesToUpload[key]) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// refresh oldNames list
|
||||
oldNames = list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
// delete previous file(s) before replacing
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||
}
|
||||
form.Data[key] = form.filesToUpload[key][0].Name()
|
||||
} else if options.MaxSelect > 1 {
|
||||
// append the id of each uploaded file instance
|
||||
for _, file := range form.filesToUpload[key] {
|
||||
oldNames = append(oldNames, file.Name())
|
||||
}
|
||||
form.Data[key] = oldNames
|
||||
// allow file key reasignments for file names sorting
|
||||
// (only if all submitted values already exists)
|
||||
if len(submittedNames) > 0 && len(list.SubtractSlice(submittedNames, oldNames)) == 0 {
|
||||
form.data[key] = submittedNames
|
||||
}
|
||||
}
|
||||
|
||||
return form.normalizeData()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
@@ -386,6 +455,7 @@ func (form *RecordUpsert) Validate() error {
|
||||
form.record.IsNew(),
|
||||
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
|
||||
validation.Match(idRegex),
|
||||
validation.By(validators.UniqueId(form.dao, form.record.TableName())),
|
||||
).Else(validation.In(form.record.Id)),
|
||||
),
|
||||
}
|
||||
@@ -397,7 +467,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(3, 100),
|
||||
validation.Length(3, 150),
|
||||
validation.Match(usernameRegex),
|
||||
validation.By(form.checkUniqueUsername),
|
||||
),
|
||||
@@ -429,13 +499,16 @@ func (form *RecordUpsert) Validate() error {
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.record.IsNew(), validation.Required),
|
||||
validation.When(
|
||||
(form.record.IsNew() || form.PasswordConfirm != "" || form.OldPassword != ""),
|
||||
validation.Required,
|
||||
),
|
||||
validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
validation.When(
|
||||
(form.record.IsNew() || form.Password != ""),
|
||||
(form.record.IsNew() || form.Password != "" || form.OldPassword != ""),
|
||||
validation.Required,
|
||||
),
|
||||
validation.By(validators.Compare(form.Password)),
|
||||
@@ -446,7 +519,7 @@ func (form *RecordUpsert) Validate() error {
|
||||
// - form.manageAccess is not set
|
||||
// - changing the existing password
|
||||
validation.When(
|
||||
!form.record.IsNew() && !form.manageAccess && form.Password != "",
|
||||
!form.record.IsNew() && !form.manageAccess && (form.Password != "" || form.PasswordConfirm != ""),
|
||||
validation.Required,
|
||||
validation.By(form.checkOldPassword),
|
||||
),
|
||||
@@ -463,7 +536,7 @@ func (form *RecordUpsert) Validate() error {
|
||||
form.dao,
|
||||
form.record,
|
||||
form.filesToUpload,
|
||||
).Validate(form.Data)
|
||||
).Validate(form.data)
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) checkUniqueUsername(value any) error {
|
||||
@@ -583,7 +656,7 @@ func (form *RecordUpsert) ValidateAndFill() error {
|
||||
}
|
||||
}
|
||||
|
||||
if form.Password != "" {
|
||||
if form.Password != "" && form.Password == form.PasswordConfirm {
|
||||
if err := form.record.SetPassword(form.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -591,7 +664,7 @@ func (form *RecordUpsert) ValidateAndFill() error {
|
||||
}
|
||||
|
||||
// bulk load the remaining form data
|
||||
form.record.Load(form.Data)
|
||||
form.record.Load(form.data)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -607,20 +680,23 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
|
||||
return err
|
||||
}
|
||||
|
||||
// use the default app.Dao to prevent changing the transaction form.Dao
|
||||
// and causing "transaction has already been committed or rolled back" error
|
||||
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
var dryDao *daos.Dao
|
||||
if form.dao.ConcurrentDB() == form.dao.NonconcurrentDB() {
|
||||
// it is already in a transaction and therefore use the app concurrent db pool
|
||||
// to prevent "transaction has already been committed or rolled back" error
|
||||
dryDao = daos.New(form.app.Dao().ConcurrentDB())
|
||||
} else {
|
||||
// otherwise use the form noncurrent dao db pool
|
||||
dryDao = daos.New(form.dao.NonconcurrentDB())
|
||||
}
|
||||
|
||||
return dryDao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
tx, ok := txDao.DB().(*dbx.Tx)
|
||||
if !ok {
|
||||
return errors.New("failed to get transaction db")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
txDao.BeforeCreateFunc = nil
|
||||
txDao.AfterCreateFunc = nil
|
||||
txDao.BeforeUpdateFunc = nil
|
||||
txDao.AfterUpdateFunc = nil
|
||||
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -642,41 +718,65 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
|
||||
if err := form.ValidateAndFill(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
return runInterceptors(form.record, func(record *models.Record) error {
|
||||
form.record = record
|
||||
|
||||
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 +785,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]
|
||||
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:]...)
|
||||
for i, file := range form.filesToUpload[fieldKey] {
|
||||
path := form.record.BaseFilesPath() + "/" + file.Name
|
||||
if err := fs.UploadFile(file, path); err == nil {
|
||||
// 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
|
||||
}
|
||||
|
||||
+193
-41
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
@@ -44,9 +46,9 @@ func TestNewRecordUpsert(t *testing.T) {
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
|
||||
val := form.Data["title"]
|
||||
val := form.Data()["title"]
|
||||
if val != "test_value" {
|
||||
t.Errorf("Expected record data to be loaded, got %v", form.Data)
|
||||
t.Errorf("Expected record data to be loaded, got %v", form.Data())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +89,10 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
|
||||
"unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"file_one": nil,
|
||||
"file_many.0": "", // delete by index
|
||||
"file_many.1": "test.png", // should be ignored
|
||||
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
|
||||
"file_many.0": "", // delete by index
|
||||
"file_many-": []string{"test_MaWC6mWyrP.txt", "test_tC1Yc87DfC.txt"}, // multiple delete with modifier
|
||||
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
|
||||
"file_many.2": "test.png", // should be ignored
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -107,15 +110,15 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
|
||||
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["text"]; !ok || v != "test123" {
|
||||
if v, ok := form.Data()["text"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["unknown"]; ok {
|
||||
if v, ok := form.Data()["unknown"]; ok {
|
||||
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
|
||||
}
|
||||
|
||||
fileOne, ok := form.Data["file_one"]
|
||||
fileOne, ok := form.Data()["file_one"]
|
||||
if !ok {
|
||||
t.Fatal("Expect file_one field to be set")
|
||||
}
|
||||
@@ -123,7 +126,7 @@ func TestRecordUpsertLoadRequestJson(t *testing.T) {
|
||||
t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
|
||||
}
|
||||
|
||||
fileMany, ok := form.Data["file_many"]
|
||||
fileMany, ok := form.Data()["file_many"]
|
||||
if !ok || fileMany == nil {
|
||||
t.Fatal("Expect file_many field to be set")
|
||||
}
|
||||
@@ -147,11 +150,12 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
|
||||
"a.b.text": "test123",
|
||||
"a.b.unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"a.b.file_one": "",
|
||||
"a.b.file_many.0": "",
|
||||
"a.b.file_many.300_WlbFWSGmW9.png": "test.png", // delete by name
|
||||
"a.b.file_many.1": "test.png", // should be ignored
|
||||
}, "file_many")
|
||||
"a.b.file_one-": "test_d61b33QdDU.txt", // delete with modifier
|
||||
"a.b.file_many.0": "", // delete by index
|
||||
"a.b.file_many-": "test_tC1Yc87DfC.txt", // delete with modifier
|
||||
"a.b.file_many.300_WlbFWSGmW9.png": "", // delete by filename
|
||||
"a.b.file_many.2": "test.png", // should be ignored
|
||||
}, "a.b.file_many")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -168,15 +172,15 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
|
||||
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["text"]; !ok || v != "test123" {
|
||||
if v, ok := form.Data()["text"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect text field to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["unknown"]; ok {
|
||||
if v, ok := form.Data()["unknown"]; ok {
|
||||
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
|
||||
}
|
||||
|
||||
fileOne, ok := form.Data["file_one"]
|
||||
fileOne, ok := form.Data()["file_one"]
|
||||
if !ok {
|
||||
t.Fatal("Expect file_one field to be set")
|
||||
}
|
||||
@@ -184,12 +188,12 @@ func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
|
||||
t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
|
||||
}
|
||||
|
||||
fileMany, ok := form.Data["file_many"]
|
||||
fileMany, ok := form.Data()["file_many"]
|
||||
if !ok || fileMany == nil {
|
||||
t.Fatal("Expect file_many field to be set")
|
||||
}
|
||||
manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
|
||||
expectedRemains := 2 // -2 from 3 removed + 1 new upload
|
||||
expectedRemains := 3 // 5 old; 3 deleted and 1 new uploaded
|
||||
if manyfilesRemains != expectedRemains {
|
||||
t.Fatalf("Expect file_many to be %d, got %d (%v)", expectedRemains, manyfilesRemains, fileMany)
|
||||
}
|
||||
@@ -214,11 +218,11 @@ func TestRecordUpsertLoadData(t *testing.T) {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["title"]; !ok || v != "test_new" {
|
||||
if v, ok := form.Data()["title"]; !ok || v != "test_new" {
|
||||
t.Fatalf("Expect title field to be %v, got %v", "test_new", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["active"]; !ok || v != true {
|
||||
if v, ok := form.Data()["active"]; !ok || v != true {
|
||||
t.Fatalf("Expect active field to be %v, got %v", true, v)
|
||||
}
|
||||
}
|
||||
@@ -316,12 +320,10 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetString("title") == "dry_test" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "dry_test")
|
||||
}
|
||||
@@ -335,6 +337,66 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertDrySubmitWithNestedTx(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "dry_test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
txErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
form.SetDao(txDao)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
callbackCalls := 0
|
||||
|
||||
result := form.DrySubmit(func(innerTxDao *daos.Dao) error {
|
||||
callbackCalls++
|
||||
return nil
|
||||
})
|
||||
if result != nil {
|
||||
t.Fatalf("Expected nil, got error %v", result)
|
||||
}
|
||||
|
||||
// ensure callback was called
|
||||
if callbackCalls != 1 {
|
||||
t.Fatalf("Expected callbackCalls to be 1, got %d", callbackCalls)
|
||||
}
|
||||
|
||||
// ensure that the original txDao can still be used after the DrySubmit rollback
|
||||
if _, err := txDao.FindRecordById(collection.Id, recordBefore.Id); err != nil {
|
||||
t.Fatalf("Expected the dry submit rollback to not affect the outer tx context, got %v", err)
|
||||
}
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recordAfter.GetString("title") == "dry_test" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordBefore.GetString("title"), "dry_test")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
t.Fatalf("Nested transactions failure: %v", txErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -366,10 +428,10 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +467,7 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
if v := recordAfter.Get("email"); v == "invalid" {
|
||||
t.Fatalf("Expected record.email not to change, got %v", v)
|
||||
}
|
||||
if v := recordAfter.GetStringSlice("file_many"); len(v) != 3 {
|
||||
if v := recordAfter.GetStringSlice("file_many"); len(v) != 5 {
|
||||
t.Fatalf("Expected record.file_many not to change, got %v", v)
|
||||
}
|
||||
|
||||
@@ -443,10 +505,10 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,8 +539,8 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
fileMany := (recordAfter.GetStringSlice("file_many"))
|
||||
if len(fileMany) != 4 { // 1 replace + 1 new
|
||||
t.Fatalf("Expected 4 record.file_many, got %d (%v)", len(fileMany), fileMany)
|
||||
if len(fileMany) != 6 { // 1 replace + 1 new
|
||||
t.Fatalf("Expected 6 record.file_many, got %d (%v)", len(fileMany), fileMany)
|
||||
}
|
||||
for _, f := range fileMany {
|
||||
if !hasRecordFile(app, recordAfter, f) {
|
||||
@@ -498,22 +560,22 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
form.Data["title"] = "test_new"
|
||||
form.Data()["title"] = "test_new"
|
||||
|
||||
testErr := errors.New("test_error")
|
||||
interceptorRecordTitle := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorRecordTitle = record.GetString("title") // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
@@ -533,7 +595,7 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorRecordTitle != form.Data["title"].(string) {
|
||||
if interceptorRecordTitle != form.Data()["title"].(string) {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
@@ -718,10 +780,10 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid username length (more than 100)",
|
||||
"invalid username length (more than 150)",
|
||||
"",
|
||||
map[string]any{
|
||||
"username": strings.Repeat("a", 101),
|
||||
"username": strings.Repeat("a", 151),
|
||||
"password": "12345678",
|
||||
"passwordConfirm": "12345678",
|
||||
},
|
||||
@@ -792,6 +854,24 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
},
|
||||
|
||||
// password
|
||||
{
|
||||
"trigger the password validations if only oldPassword is set",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"oldPassword": "1234567890",
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"trigger the password validations if only passwordConfirm is set",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"passwordConfirm": "1234567890",
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"try to update password without managed access",
|
||||
"4q1xlclmfloku33",
|
||||
@@ -863,3 +943,75 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertAddAndRemoveFiles(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
recordBefore, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// create test temp files
|
||||
tempDir := filepath.Join(app.DataDir(), "temp")
|
||||
if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
tmpFile, _ := os.CreateTemp(os.TempDir(), "tmpfile1-*.txt")
|
||||
tmpFile.Close()
|
||||
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
|
||||
f1, err := filesystem.NewFileFromPath(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f2, err := filesystem.NewFileFromPath(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f3, err := filesystem.NewFileFromPath(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form.AddFiles("file_one", f1) // should replace the existin file
|
||||
|
||||
form.AddFiles("file_many", f2, f3) // should append
|
||||
|
||||
form.RemoveFiles("file_many", "300_WlbFWSGmW9.png", "logo_vcfJJG5TAh.svg") // should remove
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
t.Fatalf("Failed to submit the RecordUpsert form, got %v", err)
|
||||
}
|
||||
|
||||
recordAfter, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ensure files deletion
|
||||
if hasRecordFile(app, recordAfter, "test_d61b33QdDU.txt") {
|
||||
t.Fatalf("Expected the old file_one file to be deleted")
|
||||
}
|
||||
if hasRecordFile(app, recordAfter, "300_WlbFWSGmW9.png") {
|
||||
t.Fatalf("Expected 300_WlbFWSGmW9.png to be deleted")
|
||||
}
|
||||
if hasRecordFile(app, recordAfter, "logo_vcfJJG5TAh.svg") {
|
||||
t.Fatalf("Expected logo_vcfJJG5TAh.svg to be deleted")
|
||||
}
|
||||
|
||||
fileOne := recordAfter.GetStringSlice("file_one")
|
||||
if len(fileOne) == 0 {
|
||||
t.Fatalf("Expected new file_one file to be uploaded")
|
||||
}
|
||||
|
||||
fileMany := recordAfter.GetStringSlice("file_many")
|
||||
if len(fileMany) != 5 {
|
||||
t.Fatalf("Expected file_many to be 5, got %v", fileMany)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,14 +92,24 @@ 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 := runInterceptors(record, func(m *models.Record) error {
|
||||
record = m
|
||||
|
||||
if err := form.dao.SaveRecord(record); err != nil {
|
||||
return nil, err
|
||||
if wasVerified {
|
||||
return nil // already verified
|
||||
}
|
||||
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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 InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) 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 runInterceptors(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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+10
-14
@@ -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
|
||||
@@ -50,21 +50,17 @@ func (form *SettingsUpsert) Validate() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Settings]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
return runInterceptors(form.Settings, func(s *settings.Settings) error {
|
||||
form.Settings = s
|
||||
|
||||
return runInterceptors(func() error {
|
||||
saveErr := form.dao.SaveParam(
|
||||
models.ParamAppSettings,
|
||||
form.Settings,
|
||||
encryptionKey,
|
||||
)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// explicitly trigger old logs deletion
|
||||
@@ -73,7 +69,7 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
)
|
||||
|
||||
if form.Settings.Logs.MaxDays == 0 {
|
||||
// reclaim deleted logs disk space
|
||||
// no logs are allowed -> reclaim preserved disk space after the previous delete operation
|
||||
form.app.LogsDao().Vacuum()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -78,10 +79,10 @@ func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,16 +136,16 @@ func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(s)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-13
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
// UploadedFileSize checks whether the validated `rest.UploadedFile`
|
||||
@@ -16,13 +16,16 @@ import (
|
||||
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
|
||||
func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(*rest.UploadedFile)
|
||||
v, _ := value.(*filesystem.File)
|
||||
if v == nil {
|
||||
return nil // nothing to validate
|
||||
}
|
||||
|
||||
if int(v.Header().Size) > maxBytes {
|
||||
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
|
||||
if int(v.Size) > maxBytes {
|
||||
return validation.NewError(
|
||||
"validation_file_size_limit",
|
||||
fmt.Sprintf("Failed to upload %q - the maximum allowed file size is %v bytes.", v.OriginalName, maxBytes),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -37,24 +40,29 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
|
||||
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
|
||||
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(*rest.UploadedFile)
|
||||
v, _ := value.(*filesystem.File)
|
||||
if v == nil {
|
||||
return nil // nothing to validate
|
||||
}
|
||||
|
||||
baseErr := validation.NewError(
|
||||
"validation_invalid_mime_type",
|
||||
fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
|
||||
)
|
||||
|
||||
if len(validTypes) == 0 {
|
||||
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
||||
return baseErr
|
||||
}
|
||||
|
||||
f, err := v.Header().Open()
|
||||
f, err := v.Reader.Open()
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
||||
return baseErr
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
filetype, err := mimetype.DetectReader(f)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
|
||||
return baseErr
|
||||
}
|
||||
|
||||
for _, t := range validTypes {
|
||||
@@ -63,9 +71,13 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
return validation.NewError("validation_invalid_mime_type", fmt.Sprintf(
|
||||
"The following mime types are only allowed: %s.",
|
||||
strings.Join(validTypes, ","),
|
||||
))
|
||||
return validation.NewError(
|
||||
"validation_invalid_mime_type",
|
||||
fmt.Sprintf(
|
||||
"%q mime type must be one of: %s.",
|
||||
v.Name,
|
||||
strings.Join(validTypes, ", "),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func TestUploadedFileSize(t *testing.T) {
|
||||
|
||||
scenarios := []struct {
|
||||
maxBytes int
|
||||
file *rest.UploadedFile
|
||||
file *filesystem.File
|
||||
expectError bool
|
||||
}{
|
||||
{0, nil, false},
|
||||
@@ -70,7 +71,7 @@ func TestUploadedFileMimeType(t *testing.T) {
|
||||
|
||||
scenarios := []struct {
|
||||
types []string
|
||||
file *rest.UploadedFile
|
||||
file *filesystem.File
|
||||
expectError bool
|
||||
}{
|
||||
{nil, nil, false},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
)
|
||||
|
||||
// Compare checks whether the provided model id exists.
|
||||
//
|
||||
// Example:
|
||||
// validation.Field(&form.Id, validation.By(validators.UniqueId(form.dao, tableName)))
|
||||
func UniqueId(dao *daos.Dao, tableName string) validation.RuleFunc {
|
||||
return func(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
var foundId string
|
||||
|
||||
err := dao.DB().
|
||||
Select("id").
|
||||
From(tableName).
|
||||
Where(dbx.HashExp{"id": v}).
|
||||
Limit(1).
|
||||
Row(&foundId)
|
||||
|
||||
if (err != nil && !errors.Is(err, sql.ErrNoRows)) || foundId != "" {
|
||||
return validation.NewError("validation_invalid_id", "The model id is invalid or already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package validators_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUniqueId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
tableName string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"test", "", true},
|
||||
{"wsmn24bux7wo113", "_collections", true},
|
||||
{"test_unique_id", "unknown_table", true},
|
||||
{"test_unique_id", "_collections", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
err := validators.UniqueId(app.Dao(), s.tableName)(s.id)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -23,12 +23,13 @@ var requiredErr = validation.NewError("validation_required", "Missing required v
|
||||
// using the provided record constraints and schema.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// validator := NewRecordDataValidator(app.Dao(), record, nil)
|
||||
// err := validator.Validate(map[string]any{"test":123})
|
||||
func NewRecordDataValidator(
|
||||
dao *daos.Dao,
|
||||
record *models.Record,
|
||||
uploadedFiles map[string][]*rest.UploadedFile,
|
||||
uploadedFiles map[string][]*filesystem.File,
|
||||
) *RecordDataValidator {
|
||||
return &RecordDataValidator{
|
||||
dao: dao,
|
||||
@@ -42,7 +43,7 @@ func NewRecordDataValidator(
|
||||
type RecordDataValidator struct {
|
||||
dao *daos.Dao
|
||||
record *models.Record
|
||||
uploadedFiles map[string][]*rest.UploadedFile
|
||||
uploadedFiles map[string][]*filesystem.File
|
||||
}
|
||||
|
||||
// Validate validates the provided `data` by checking it against
|
||||
@@ -117,6 +118,8 @@ func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField,
|
||||
return validator.checkEmailValue(field, value)
|
||||
case schema.FieldTypeUrl:
|
||||
return validator.checkUrlValue(field, value)
|
||||
case schema.FieldTypeEditor:
|
||||
return validator.checkEditorValue(field, value)
|
||||
case schema.FieldTypeDate:
|
||||
return validator.checkDateValue(field, value)
|
||||
case schema.FieldTypeSelect:
|
||||
@@ -236,6 +239,10 @@ func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, v
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkEditorValue(field *schema.SchemaField, value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error {
|
||||
val, _ := value.(types.DateTime)
|
||||
if val.IsZero() {
|
||||
@@ -288,16 +295,22 @@ func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField
|
||||
return nil
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error {
|
||||
raw, _ := types.ParseJsonRaw(value)
|
||||
if len(raw) == 0 {
|
||||
return nil // nothing to check
|
||||
}
|
||||
var emptyJsonValues = []string{
|
||||
"null", `""`, "[]", "{}",
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error {
|
||||
if is.JSON.Validate(value) != nil {
|
||||
return validation.NewError("validation_invalid_json", "Must be a valid json value")
|
||||
}
|
||||
|
||||
raw, _ := types.ParseJsonRaw(value)
|
||||
rawStr := strings.TrimSpace(raw.String())
|
||||
|
||||
if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) {
|
||||
return requiredErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -314,9 +327,9 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
|
||||
}
|
||||
|
||||
// extract the uploaded files
|
||||
files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles[field.Name]))
|
||||
files := make([]*filesystem.File, 0, len(validator.uploadedFiles[field.Name]))
|
||||
for _, file := range validator.uploadedFiles[field.Name] {
|
||||
if list.ExistInSlice(file.Name(), names) {
|
||||
if list.ExistInSlice(file.Name, names) {
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
type testDataFieldScenario struct {
|
||||
name string
|
||||
data map[string]any
|
||||
files map[string][]*rest.UploadedFile
|
||||
files map[string][]*filesystem.File
|
||||
expectedErrors []string
|
||||
}
|
||||
|
||||
@@ -964,7 +965,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
"field3": []string{},
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(json) check required constraint - zero map",
|
||||
@@ -974,7 +975,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
"field3": map[string]string{},
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(json) check unique constraint",
|
||||
@@ -987,14 +988,24 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(json) check json text validator",
|
||||
"(json) check json text invalid obj, array and number normalizations",
|
||||
map[string]any{
|
||||
"field1": `[1, 2, 3`,
|
||||
"field2": `invalid`,
|
||||
"field3": `null`, // valid
|
||||
"field1": `[1 2 3]`,
|
||||
"field2": `{a: 123}`,
|
||||
"field3": `123.456 abc`,
|
||||
},
|
||||
nil,
|
||||
[]string{"field1", "field2"},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"(json) check json text reserved literals normalizations",
|
||||
map[string]any{
|
||||
"field1": `true`,
|
||||
"field2": `false`,
|
||||
"field3": `null`,
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"(json) valid data - only required fields",
|
||||
@@ -1086,10 +1097,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
"check MaxSelect constraint",
|
||||
map[string]any{
|
||||
"field1": "test1",
|
||||
"field2": []string{"test1", testFiles[0].Name(), testFiles[3].Name()},
|
||||
"field2": []string{"test1", testFiles[0].Name, testFiles[3].Name},
|
||||
"field3": []string{"test1", "test2", "test3", "test4"},
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field2": {testFiles[0], testFiles[3]},
|
||||
},
|
||||
[]string{"field2", "field3"},
|
||||
@@ -1097,11 +1108,11 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
{
|
||||
"check MaxSize constraint",
|
||||
map[string]any{
|
||||
"field1": testFiles[0].Name(),
|
||||
"field2": []string{"test1", testFiles[0].Name()},
|
||||
"field1": testFiles[0].Name,
|
||||
"field2": []string{"test1", testFiles[0].Name},
|
||||
"field3": []string{"test1", "test2", "test3"},
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field1": {testFiles[0]},
|
||||
"field2": {testFiles[0]},
|
||||
},
|
||||
@@ -1111,10 +1122,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
"check MimeTypes constraint",
|
||||
map[string]any{
|
||||
"field1": "test1",
|
||||
"field2": []string{"test1", testFiles[0].Name()},
|
||||
"field3": []string{testFiles[1].Name(), testFiles[2].Name()},
|
||||
"field2": []string{"test1", testFiles[0].Name},
|
||||
"field3": []string{testFiles[1].Name, testFiles[2].Name},
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field2": {testFiles[0], testFiles[1], testFiles[2]},
|
||||
"field3": {testFiles[1], testFiles[2]},
|
||||
},
|
||||
@@ -1134,10 +1145,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
"valid data - just new files",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": []string{testFiles[0].Name(), testFiles[1].Name()},
|
||||
"field2": []string{testFiles[0].Name, testFiles[1].Name},
|
||||
"field3": nil,
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field2": {testFiles[0], testFiles[1]},
|
||||
},
|
||||
[]string{},
|
||||
@@ -1146,10 +1157,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
|
||||
"valid data - mixed existing and new files",
|
||||
map[string]any{
|
||||
"field1": "test1",
|
||||
"field2": []string{"test1", testFiles[0].Name()},
|
||||
"field2": []string{"test1", testFiles[0].Name},
|
||||
"field3": "test1", // will be casted
|
||||
},
|
||||
map[string][]*rest.UploadedFile{
|
||||
map[string][]*filesystem.File{
|
||||
"field2": {testFiles[0], testFiles[1], testFiles[2]},
|
||||
},
|
||||
[]string{},
|
||||
|
||||
@@ -4,82 +4,88 @@ go 1.18
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.6
|
||||
github.com/aws/aws-sdk-go v1.44.141
|
||||
github.com/aws/aws-sdk-go v1.44.187
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.3.4
|
||||
github.com/fatih/color v1.13.0
|
||||
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
|
||||
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
|
||||
github.com/fatih/color v1.14.1
|
||||
github.com/gabriel-vasile/mimetype v1.4.1
|
||||
github.com/ganigeorgiev/fexpr v0.1.1
|
||||
github.com/ganigeorgiev/fexpr v0.3.0
|
||||
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.9.0
|
||||
github.com/spf13/cast v1.5.0
|
||||
github.com/spf13/cobra v1.6.1
|
||||
gocloud.dev v0.27.0
|
||||
golang.org/x/crypto v0.3.0
|
||||
golang.org/x/net v0.2.0
|
||||
golang.org/x/oauth2 v0.2.0
|
||||
modernc.org/sqlite v1.19.4
|
||||
gocloud.dev v0.28.0
|
||||
golang.org/x/crypto v0.5.0
|
||||
golang.org/x/net v0.5.0
|
||||
golang.org/x/oauth2 v0.4.0
|
||||
golang.org/x/sync v0.1.0
|
||||
modernc.org/sqlite v1.20.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.41 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.11.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.17.4 // indirect
|
||||
github.com/aws/smithy-go v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.3 // 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.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.49 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 // 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.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.30.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.2 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/wire v0.5.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // indirect
|
||||
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.24.0 // indirect
|
||||
golang.org/x/image v0.1.0 // indirect
|
||||
golang.org/x/image v0.3.0 // indirect
|
||||
golang.org/x/mod v0.7.0 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/term v0.2.0 // indirect
|
||||
golang.org/x/text v0.4.0 // indirect
|
||||
golang.org/x/time v0.2.0 // indirect
|
||||
golang.org/x/tools v0.3.0 // indirect
|
||||
golang.org/x/sys v0.4.0 // indirect
|
||||
golang.org/x/term v0.4.0 // indirect
|
||||
golang.org/x/text v0.6.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.5.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.103.0 // indirect
|
||||
google.golang.org/api v0.108.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect
|
||||
google.golang.org/grpc v1.51.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa // indirect
|
||||
google.golang.org/grpc v1.52.3 // 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.22.2 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.4.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
|
||||
@@ -7,10 +7,8 @@ linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- asciicheck
|
||||
- deadcode
|
||||
- depguard
|
||||
- exportloopref
|
||||
- gocritic
|
||||
- gofmt
|
||||
- goimports
|
||||
- gomodguard
|
||||
@@ -26,5 +24,4 @@ linters:
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
|
||||
+26
-21
@@ -2,12 +2,14 @@ package mails
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"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,33 +45,36 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
|
||||
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
event := &core.MailerAdminEvent{
|
||||
MailClient: mailClient,
|
||||
Admin: admin,
|
||||
Meta: map[string]any{"token": token},
|
||||
// resolve body template
|
||||
body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody)
|
||||
if renderErr != nil {
|
||||
return renderErr
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
event := new(core.MailerAdminEvent)
|
||||
event.MailClient = mailClient
|
||||
event.Message = message
|
||||
event.Admin = admin
|
||||
event.Meta = map[string]any{"token": token}
|
||||
|
||||
sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
app.OnMailerAfterAdminResetPasswordSend().Trigger(event)
|
||||
if err := app.OnMailerAfterAdminResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
-74
@@ -2,13 +2,15 @@ package mails
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"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,34 +22,36 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
|
||||
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Record: authRecord,
|
||||
Meta: map[string]any{"token": token},
|
||||
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 := new(core.MailerRecordEvent)
|
||||
event.MailClient = mailClient
|
||||
event.Message = message
|
||||
event.Collection = authRecord.Collection()
|
||||
event.Record = authRecord
|
||||
event.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 {
|
||||
app.OnMailerAfterRecordResetPasswordSend().Trigger(event)
|
||||
if err := app.OnMailerAfterRecordResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
@@ -62,34 +66,36 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
|
||||
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Record: authRecord,
|
||||
Meta: map[string]any{"token": token},
|
||||
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 := new(core.MailerRecordEvent)
|
||||
event.MailClient = mailClient
|
||||
event.Message = message
|
||||
event.Collection = authRecord.Collection()
|
||||
event.Record = authRecord
|
||||
event.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 {
|
||||
app.OnMailerAfterRecordVerificationSend().Trigger(event)
|
||||
if err := app.OnMailerAfterRecordVerificationSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
@@ -104,37 +110,39 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
|
||||
mailClient := app.NewMailClient()
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Record: record,
|
||||
Meta: map[string]any{
|
||||
"token": token,
|
||||
"newEmail": newEmail,
|
||||
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 := new(core.MailerRecordEvent)
|
||||
event.MailClient = mailClient
|
||||
event.Message = message
|
||||
event.Collection = record.Collection()
|
||||
event.Record = record
|
||||
event.Meta = map[string]any{
|
||||
"token": token,
|
||||
"newEmail": newEmail,
|
||||
}
|
||||
|
||||
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 {
|
||||
app.OnMailerAfterRecordChangeEmailSend().Trigger(event)
|
||||
if err := app.OnMailerAfterRecordChangeEmailSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
@@ -143,13 +151,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user