mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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.19.5'
|
||||
|
||||
# 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.
|
||||
|
||||
+318
-3
@@ -1,3 +1,318 @@
|
||||
## v0.12.0
|
||||
|
||||
- Refactored the relation picker UI to allowing server-side search, sorting, 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)).
|
||||
@@ -53,10 +368,10 @@
|
||||
```
|
||||
|
||||
- Refactored the `migrate` command to support **external JavaScript migration files** using an embedded JS interpreter ([goja](https://github.com/dop251/goja)).
|
||||
This allow writting custom migration scripts such as programmatically creating collections,
|
||||
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 prebult executable,
|
||||
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)
|
||||
@@ -156,7 +471,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()`).
|
||||
|
||||
|
||||
+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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+11
-4
@@ -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,10 +71,9 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.ApiErrorEvent{
|
||||
HttpContext: c,
|
||||
Error: apiErr,
|
||||
}
|
||||
event := new(core.ApiErrorEvent)
|
||||
event.HttpContext = c
|
||||
event.Error = apiErr
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
@@ -100,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,
|
||||
|
||||
+7
-8
@@ -84,14 +84,13 @@ 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 {
|
||||
res := e.HttpContext.Response()
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-13
@@ -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"
|
||||
)
|
||||
@@ -43,10 +44,14 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
client := subscriptions.NewDefaultClient()
|
||||
api.app.SubscriptionsBroker().Register(client)
|
||||
defer func() {
|
||||
api.app.OnRealtimeDisconnectRequest().Trigger(&core.RealtimeDisconnectEvent{
|
||||
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())
|
||||
}()
|
||||
@@ -259,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
|
||||
})
|
||||
@@ -337,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,
|
||||
@@ -356,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)
|
||||
@@ -370,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
|
||||
@@ -398,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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+130
-100
@@ -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
|
||||
@@ -237,11 +203,39 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
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,11 +298,11 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordRequestPasswordResetEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
event := new(core.RecordRequestPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
@@ -305,7 +320,9 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event)
|
||||
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
@@ -329,11 +346,11 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := &core.RecordConfirmPasswordResetEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
event := new(core.RecordConfirmPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
@@ -348,7 +365,9 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event)
|
||||
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -369,11 +388,11 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordRequestVerificationEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
event := new(core.RecordRequestVerificationEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
@@ -391,7 +410,9 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestVerificationRequest().Trigger(event)
|
||||
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
@@ -415,11 +436,11 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := &core.RecordConfirmVerificationEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
event := new(core.RecordConfirmVerificationEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
@@ -434,13 +455,20 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event)
|
||||
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -451,12 +479,12 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordRequestEmailChangeEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
event := new(core.RecordRequestEmailChangeEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
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 {
|
||||
@@ -486,11 +514,11 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := &core.RecordConfirmEmailChangeEvent{
|
||||
HttpContext: c,
|
||||
}
|
||||
event := new(core.RecordConfirmEmailChangeEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
@@ -505,7 +533,9 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event)
|
||||
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -532,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)
|
||||
@@ -565,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 {
|
||||
|
||||
+77
-23
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+65
-39
@@ -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,13 +194,6 @@ 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 {
|
||||
@@ -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
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
|
||||
+57
-11
@@ -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,18 +45,65 @@ 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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -58,6 +58,11 @@ func TestSettingsList(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -125,6 +130,11 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"acme_test"`,
|
||||
@@ -181,6 +191,11 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
|
||||
+2
-1
@@ -78,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,
|
||||
|
||||
+302
-38
@@ -16,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
|
||||
|
||||
@@ -26,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
|
||||
|
||||
@@ -68,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
|
||||
@@ -108,27 +124,51 @@ type App interface {
|
||||
|
||||
// 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
|
||||
@@ -150,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
|
||||
@@ -241,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
|
||||
@@ -297,6 +361,50 @@ 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]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record Auth API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
@@ -306,89 +414,213 @@ 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]).
|
||||
OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
|
||||
//
|
||||
// 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.
|
||||
OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent]
|
||||
//
|
||||
// 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]).
|
||||
OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
|
||||
//
|
||||
// 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.
|
||||
OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent]
|
||||
//
|
||||
// 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]).
|
||||
OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
|
||||
//
|
||||
// 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.
|
||||
OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent]
|
||||
//
|
||||
// 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]).
|
||||
OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
|
||||
//
|
||||
// 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.
|
||||
OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent]
|
||||
//
|
||||
// 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]).
|
||||
OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
|
||||
//
|
||||
// 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.
|
||||
OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent]
|
||||
//
|
||||
// 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]).
|
||||
OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
|
||||
//
|
||||
// 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.
|
||||
OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent]
|
||||
//
|
||||
// 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 CRUD API event hooks
|
||||
@@ -397,45 +629,77 @@ type App interface {
|
||||
// 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
|
||||
|
||||
+337
-136
@@ -17,25 +17,35 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"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.Settings
|
||||
db *dbx.DB
|
||||
dao *daos.Dao
|
||||
logsDB *dbx.DB
|
||||
logsDao *daos.Dao
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
|
||||
@@ -81,18 +91,32 @@ 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]
|
||||
|
||||
// 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]
|
||||
@@ -132,15 +156,30 @@ 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: settings.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
@@ -187,18 +226,32 @@ 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]{},
|
||||
|
||||
// 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]{},
|
||||
@@ -243,8 +296,16 @@ 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}
|
||||
|
||||
@@ -283,14 +344,20 @@ func (app *BaseApp) Bootstrap() error {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -302,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.
|
||||
@@ -312,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.
|
||||
@@ -358,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{}
|
||||
@@ -445,28 +541,28 @@ func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
|
||||
// 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...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -481,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...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -553,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...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -597,108 +693,164 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
|
||||
return app.onAdminAuthRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
|
||||
return app.onAdminBeforeAuthWithPasswordRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent] {
|
||||
return app.onAdminAfterAuthWithPasswordRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent] {
|
||||
return app.onAdminBeforeAuthRefreshRequest
|
||||
}
|
||||
|
||||
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 auth API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
|
||||
return app.onRecordAuthRequest
|
||||
func (app *BaseApp) OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAuthRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
|
||||
return app.onRecordBeforeRequestPasswordResetRequest
|
||||
func (app *BaseApp) OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeAuthWithPasswordRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest() *hook.Hook[*RecordRequestPasswordResetEvent] {
|
||||
return app.onRecordAfterRequestPasswordResetRequest
|
||||
func (app *BaseApp) OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterAuthWithPasswordRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
|
||||
return app.onRecordBeforeConfirmPasswordResetRequest
|
||||
func (app *BaseApp) OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeAuthWithOAuth2Request, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest() *hook.Hook[*RecordConfirmPasswordResetEvent] {
|
||||
return app.onRecordAfterConfirmPasswordResetRequest
|
||||
func (app *BaseApp) OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterAuthWithOAuth2Request, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
|
||||
return app.onRecordBeforeRequestVerificationRequest
|
||||
func (app *BaseApp) OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeAuthRefreshRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestVerificationRequest() *hook.Hook[*RecordRequestVerificationEvent] {
|
||||
return app.onRecordAfterRequestVerificationRequest
|
||||
func (app *BaseApp) OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterAuthRefreshRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
|
||||
return app.onRecordBeforeConfirmVerificationRequest
|
||||
func (app *BaseApp) OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeRequestPasswordResetRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest() *hook.Hook[*RecordConfirmVerificationEvent] {
|
||||
return app.onRecordAfterConfirmVerificationRequest
|
||||
func (app *BaseApp) OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterRequestPasswordResetRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
|
||||
return app.onRecordBeforeRequestEmailChangeRequest
|
||||
func (app *BaseApp) OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeConfirmPasswordResetRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterRequestEmailChangeRequest() *hook.Hook[*RecordRequestEmailChangeEvent] {
|
||||
return app.onRecordAfterRequestEmailChangeRequest
|
||||
func (app *BaseApp) OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterConfirmPasswordResetRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
|
||||
return app.onRecordBeforeConfirmEmailChangeRequest
|
||||
func (app *BaseApp) OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeRequestVerificationRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterConfirmEmailChangeRequest() *hook.Hook[*RecordConfirmEmailChangeEvent] {
|
||||
return app.onRecordAfterConfirmEmailChangeRequest
|
||||
func (app *BaseApp) OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterRequestVerificationRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] {
|
||||
return app.onRecordListExternalAuthsRequest
|
||||
func (app *BaseApp) OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeConfirmVerificationRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
|
||||
return app.onRecordBeforeUnlinkExternalAuthRequest
|
||||
func (app *BaseApp) OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterConfirmVerificationRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
|
||||
return app.onRecordAfterUnlinkExternalAuthRequest
|
||||
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() *hook.Hook[*RecordsListEvent] {
|
||||
return app.onRecordsListRequest
|
||||
func (app *BaseApp) OnRecordsListRequest(tags ...string) *hook.TaggedHook[*RecordsListEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordsListRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordViewRequest() *hook.Hook[*RecordViewEvent] {
|
||||
return app.onRecordViewRequest
|
||||
func (app *BaseApp) OnRecordViewRequest(tags ...string) *hook.TaggedHook[*RecordViewEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordViewRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] {
|
||||
return app.onRecordBeforeCreateRequest
|
||||
func (app *BaseApp) OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeCreateRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] {
|
||||
return app.onRecordAfterCreateRequest
|
||||
func (app *BaseApp) OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterCreateRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] {
|
||||
return app.onRecordBeforeUpdateRequest
|
||||
func (app *BaseApp) OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeUpdateRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] {
|
||||
return app.onRecordAfterUpdateRequest
|
||||
func (app *BaseApp) OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterUpdateRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] {
|
||||
return app.onRecordBeforeDeleteRequest
|
||||
func (app *BaseApp) OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordBeforeDeleteRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] {
|
||||
return app.onRecordAfterDeleteRequest
|
||||
func (app *BaseApp) OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent] {
|
||||
return hook.NewTaggedHook(app.onRecordAfterDeleteRequest, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -750,41 +902,81 @@ 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
|
||||
}
|
||||
|
||||
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() {
|
||||
app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
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) {
|
||||
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})
|
||||
@@ -832,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
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
+124
-6
@@ -5,6 +5,8 @@ import (
|
||||
"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"
|
||||
@@ -12,6 +14,28 @@ 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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -34,16 +58,32 @@ type ApiErrorEvent struct {
|
||||
// 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
|
||||
@@ -103,28 +143,37 @@ type SettingsUpdateEvent struct {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
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
|
||||
}
|
||||
@@ -134,49 +183,91 @@ type RecordDeleteEvent struct {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
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
|
||||
@@ -218,6 +309,28 @@ type AdminAuthEvent struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
type AdminAuthWithPasswordEvent struct {
|
||||
HttpContext echo.Context
|
||||
Admin *models.Admin
|
||||
Identity string
|
||||
Password string
|
||||
}
|
||||
|
||||
type AdminAuthRefreshEvent struct {
|
||||
HttpContext echo.Context
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
type AdminRequestPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
type AdminConfirmPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Collection API events data
|
||||
// -------------------------------------------------------------------
|
||||
@@ -229,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 {
|
||||
@@ -258,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
|
||||
|
||||
+160
-31
@@ -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,12 +327,12 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+5
-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 {
|
||||
|
||||
+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 {
|
||||
|
||||
+123
-237
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -84,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
|
||||
}
|
||||
@@ -99,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)
|
||||
@@ -192,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)...))
|
||||
}
|
||||
|
||||
@@ -215,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)
|
||||
@@ -242,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{}
|
||||
@@ -265,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{}
|
||||
@@ -317,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: %w", 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 {
|
||||
@@ -332,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,264 +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 {
|
||||
const maxAttempts = 6
|
||||
|
||||
attempts := 1
|
||||
|
||||
Retry:
|
||||
err := dao.deleteRecord(record, attempts)
|
||||
if err != nil &&
|
||||
attempts <= maxAttempts &&
|
||||
// note: we are checking the error msg so that we can handle both the cgo and noncgo errors
|
||||
strings.Contains(err.Error(), "database is locked") {
|
||||
time.Sleep(time.Duration(300*attempts) * time.Millisecond)
|
||||
attempts++
|
||||
goto Retry
|
||||
// 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
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (dao *Dao) deleteRecord(record *models.Record, attempts int) error {
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// unset transaction dao before hook on retry to avoid
|
||||
// triggering the same before callbacks multiple times
|
||||
if attempts > 1 {
|
||||
oldBeforeCreateFunc := txDao.BeforeCreateFunc
|
||||
oldBeforeUpdateFunc := txDao.BeforeUpdateFunc
|
||||
oldBeforeDeleteFunc := txDao.BeforeDeleteFunc
|
||||
txDao.BeforeCreateFunc = nil
|
||||
txDao.BeforeUpdateFunc = nil
|
||||
txDao.BeforeDeleteFunc = nil
|
||||
defer func() {
|
||||
if txDao != nil {
|
||||
txDao.BeforeCreateFunc = oldBeforeCreateFunc
|
||||
txDao.BeforeUpdateFunc = oldBeforeUpdateFunc
|
||||
txDao.BeforeDeleteFunc = oldBeforeDeleteFunc
|
||||
// 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
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// check for references
|
||||
refs, err := txDao.FindCollectionReferences(record.Collection())
|
||||
if err != nil {
|
||||
// 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
|
||||
}
|
||||
|
||||
// check if related records has to be deleted (if `CascadeDelete` is set)
|
||||
// OR
|
||||
// just unset the record id from any relation field values (if they are not required)
|
||||
for refCollection, fields := range refs {
|
||||
for _, field := range fields {
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
return txDao.cascadeRecordDelete(record, refs)
|
||||
})
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
// 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)
|
||||
|
||||
// fetch all referenced records
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
fieldColumnName := inflector.Columnify(field.Name)
|
||||
err := txDao.RecordQuery(refCollection).
|
||||
Distinct(true).
|
||||
LeftJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize value access for single and multiple relations.
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{__je__}}`,
|
||||
fieldColumnName, fieldColumnName, fieldColumnName,
|
||||
), nil).
|
||||
AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": record.Id})).
|
||||
AndWhere(dbx.HashExp{"__je__.value": record.Id}).
|
||||
All(&rows)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
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
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+8
-21
@@ -4,8 +4,6 @@ package forms
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// base ID value regex pattern
|
||||
@@ -13,32 +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
|
||||
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()
|
||||
}
|
||||
|
||||
// InterceptorWithRecordNextFunc is a Record interceptor handler function.
|
||||
// Usually used in combination with InterceptorWithRecordFunc.
|
||||
type InterceptorWithRecordNextFunc = func(record *models.Record) error
|
||||
|
||||
// InterceptorWithRecordFunc defines a single Record interceptor function
|
||||
// that will execute the provided next func handler.
|
||||
type InterceptorWithRecordFunc func(next InterceptorWithRecordNextFunc) InterceptorWithRecordNextFunc
|
||||
|
||||
// runInterceptorsWithRecord executes the provided list of Record interceptors.
|
||||
func runInterceptorsWithRecord(record *models.Record, next InterceptorWithRecordNextFunc, interceptors ...InterceptorWithRecordFunc) error {
|
||||
for i := len(interceptors) - 1; i >= 0; i-- {
|
||||
next = interceptors[i](next)
|
||||
}
|
||||
return next(record)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -114,9 +114,9 @@ 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`.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
|
||||
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,7 +130,8 @@ func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorWithReco
|
||||
authRecord.SetVerified(true)
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
@@ -165,7 +165,7 @@ func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
@@ -173,7 +173,7 @@ func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorEmail = record.Email()
|
||||
interceptor2Called = true
|
||||
|
||||
@@ -62,14 +62,14 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
|
||||
// Submit validates and sends the change email request.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorWithRecordFunc) error {
|
||||
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptorsWithRecord(form.record, func(m *models.Record) error {
|
||||
return runInterceptors(form.record, func(m *models.Record) error {
|
||||
return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
@@ -119,7 +119,7 @@ func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
@@ -127,7 +127,7 @@ func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,9 @@ func (form *RecordPasswordResetConfirm) checkToken(value any) error {
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
|
||||
// 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
|
||||
}
|
||||
@@ -91,7 +91,8 @@ func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorWithRe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
@@ -157,7 +157,7 @@ func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
@@ -165,7 +165,7 @@ func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorTokenKey = record.TokenKey()
|
||||
interceptor2Called = true
|
||||
|
||||
@@ -60,9 +60,9 @@ func (form *RecordPasswordResetRequest) Validate() error {
|
||||
// Submit validates and submits the form.
|
||||
// On success, sends a password reset email to the `form.Email` auth record.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRecordFunc) 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
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorWithRe
|
||||
// update last sent timestamp
|
||||
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
|
||||
|
||||
return runInterceptorsWithRecord(authRecord, func(m *models.Record) error {
|
||||
return runInterceptors(authRecord, func(m *models.Record) error {
|
||||
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
@@ -135,7 +135,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
@@ -143,7 +143,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorLastResetSentAt = record.LastResetSentAt()
|
||||
interceptor2Called = true
|
||||
|
||||
+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: %w", 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: %w", 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +77,9 @@ func (form *RecordVerificationConfirm) checkToken(value any) error {
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the verified auth record associated to `form.Token`.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRecordFunc) (*models.Record, error) {
|
||||
// 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
|
||||
}
|
||||
@@ -98,7 +98,9 @@ func (form *RecordVerificationConfirm) Submit(interceptors ...InterceptorWithRec
|
||||
record.SetVerified(true)
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptorsWithRecord(record, func(m *models.Record) error {
|
||||
interceptorsErr := runInterceptors(record, func(m *models.Record) error {
|
||||
record = m
|
||||
|
||||
if wasVerified {
|
||||
return nil // already verified
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
@@ -117,7 +117,7 @@ func TestRecordVerificationConfirmInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
@@ -125,7 +125,7 @@ func TestRecordVerificationConfirmInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorVerified = record.Verified()
|
||||
interceptor2Called = true
|
||||
|
||||
@@ -60,9 +60,9 @@ func (form *RecordVerificationRequest) Validate() error {
|
||||
// Submit validates and sends a verification request email
|
||||
// to the `form.Email` auth record.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorWithRecordFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRecordFunc) 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
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorWithRec
|
||||
record.SetLastVerificationSentAt(types.NowDateTime())
|
||||
}
|
||||
|
||||
return runInterceptorsWithRecord(record, func(m *models.Record) error {
|
||||
return runInterceptors(record, func(m *models.Record) error {
|
||||
if m.Verified() {
|
||||
return nil // already verified
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
@@ -153,7 +153,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
@@ -161,7 +161,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorWithRecordNextFunc) forms.InterceptorWithRecordNextFunc {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorLastVerificationSentAt = record.LastVerificationSentAt()
|
||||
interceptor2Called = true
|
||||
|
||||
@@ -50,12 +50,14 @@ 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
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return runInterceptors(form.Settings, func(s *settings.Settings) error {
|
||||
form.Settings = s
|
||||
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
|
||||
return err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+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,50 +4,52 @@ go 1.18
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.6
|
||||
github.com/aws/aws-sdk-go v1.44.153
|
||||
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/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
|
||||
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
|
||||
github.com/fatih/color v1.13.0
|
||||
github.com/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.3
|
||||
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/pocketbase/dbx v1.8.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.3.0
|
||||
golang.org/x/oauth2 v0.2.0
|
||||
modernc.org/sqlite v1.20.0
|
||||
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.2 // 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.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.43 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/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.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.29.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.11.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.17.6 // indirect
|
||||
github.com/aws/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
|
||||
@@ -58,30 +60,30 @@ require (
|
||||
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.2.0 // indirect
|
||||
golang.org/x/image v0.3.0 // indirect
|
||||
golang.org/x/mod v0.7.0 // indirect
|
||||
golang.org/x/sys v0.3.0 // indirect
|
||||
golang.org/x/term v0.3.0 // indirect
|
||||
golang.org/x/text v0.5.0 // indirect
|
||||
golang.org/x/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.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-20221205194025-8222ab48f5fc // 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.5 // indirect
|
||||
modernc.org/libc v1.22.2 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/opt v0.1.3 // 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
|
||||
|
||||
+9
-7
@@ -2,6 +2,7 @@ package mails
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -60,19 +61,20 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
event := &core.MailerAdminEvent{
|
||||
MailClient: mailClient,
|
||||
Message: message,
|
||||
Admin: admin,
|
||||
Meta: map[string]any{"token": token},
|
||||
}
|
||||
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
|
||||
|
||||
+30
-23
@@ -2,6 +2,7 @@ package mails
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -36,19 +37,21 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Message: message,
|
||||
Record: authRecord,
|
||||
Meta: map[string]any{"token": token},
|
||||
}
|
||||
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 {
|
||||
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
|
||||
@@ -78,19 +81,21 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Message: message,
|
||||
Record: authRecord,
|
||||
Meta: map[string]any{"token": token},
|
||||
}
|
||||
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 {
|
||||
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
|
||||
@@ -120,14 +125,14 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
HTML: body,
|
||||
}
|
||||
|
||||
event := &core.MailerRecordEvent{
|
||||
MailClient: mailClient,
|
||||
Message: message,
|
||||
Record: record,
|
||||
Meta: map[string]any{
|
||||
"token": token,
|
||||
"newEmail": newEmail,
|
||||
},
|
||||
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 {
|
||||
@@ -135,7 +140,9 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
app.OnMailerAfterRecordChangeEmailSend().Trigger(event)
|
||||
if err := app.OnMailerAfterRecordChangeEmailSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
|
||||
@@ -36,7 +36,7 @@ func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
_, tablesErr := db.NewQuery(`
|
||||
CREATE TABLE {{_admins}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[avatar]] INTEGER DEFAULT 0 NOT NULL,
|
||||
[[email]] TEXT UNIQUE NOT NULL,
|
||||
[[tokenKey]] TEXT UNIQUE NOT NULL,
|
||||
@@ -47,7 +47,7 @@ func init() {
|
||||
);
|
||||
|
||||
CREATE TABLE {{_collections}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[system]] BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
[[type]] TEXT DEFAULT "base" NOT NULL,
|
||||
[[name]] TEXT UNIQUE NOT NULL,
|
||||
@@ -63,7 +63,7 @@ func init() {
|
||||
);
|
||||
|
||||
CREATE TABLE {{_params}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[key]] TEXT UNIQUE NOT NULL,
|
||||
[[value]] JSON DEFAULT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
@@ -71,7 +71,7 @@ func init() {
|
||||
);
|
||||
|
||||
CREATE TABLE {{_externalAuths}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[collectionId]] TEXT NOT NULL,
|
||||
[[recordId]] TEXT NOT NULL,
|
||||
[[provider]] TEXT NOT NULL,
|
||||
@@ -128,11 +128,11 @@ func init() {
|
||||
MaxSelect: 1,
|
||||
MaxSize: 5242880,
|
||||
MimeTypes: []string{
|
||||
"image/jpg",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// This migration replaces for backward compatibility the default operators
|
||||
// (=, !=, >, etc.) with their any/opt equivalent (?=, ?=, ?>, etc.)
|
||||
// in any muli-rel expression collection rule.
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
exprRegex := regexp.MustCompile(`([\@\'\"\w\.]+)\s*(=|!=|~|!~|>|>=|<|<=)\s*([\@\'\"\w\.]+)`)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
findCollection := func(nameOrId string) *models.Collection {
|
||||
for _, c := range collections {
|
||||
if c.Id == nameOrId || c.Name == nameOrId {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var isMultiRelLiteral func(mainCollection *models.Collection, literal string) bool
|
||||
isMultiRelLiteral = func(mainCollection *models.Collection, literal string) bool {
|
||||
if strings.HasPrefix(literal, "@collection.") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(literal, `"`) ||
|
||||
strings.HasPrefix(literal, `'`) ||
|
||||
strings.HasPrefix(literal, "@request.method") ||
|
||||
strings.HasPrefix(literal, "@request.data") ||
|
||||
strings.HasPrefix(literal, "@request.query") {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(literal, ".")
|
||||
if len(parts) <= 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(literal, "@request.auth") && len(parts) >= 4 {
|
||||
// check each auth collection
|
||||
for _, c := range collections {
|
||||
if c.IsAuth() && isMultiRelLiteral(c, strings.Join(parts[2:], ".")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
activeCollection := mainCollection
|
||||
|
||||
for i, p := range parts {
|
||||
f := activeCollection.Schema.GetFieldByName(p)
|
||||
if f == nil || f.Type != schema.FieldTypeRelation {
|
||||
return false // not a relation field
|
||||
}
|
||||
|
||||
// is multi-relation and not the last prop
|
||||
opt, ok := f.Options.(*schema.RelationOptions)
|
||||
if ok && (opt.MaxSelect == nil || *opt.MaxSelect != 1) && i != len(parts)-1 {
|
||||
return true
|
||||
}
|
||||
|
||||
activeCollection = findCollection(opt.CollectionId)
|
||||
if activeCollection == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// replace all multi-match operators to their any/opt equivalent, eg. "=" => "?="
|
||||
migrateRule := func(collection *models.Collection, rule *string) (*string, error) {
|
||||
if rule == nil || *rule == "" {
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
newRule := *rule
|
||||
parts := exprRegex.FindAllStringSubmatch(newRule, -1)
|
||||
|
||||
for _, p := range parts {
|
||||
if isMultiRelLiteral(collection, p[1]) || isMultiRelLiteral(collection, p[3]) {
|
||||
newRule = strings.ReplaceAll(newRule, p[0], p[1]+" ?"+p[2]+" "+p[3])
|
||||
}
|
||||
}
|
||||
|
||||
return &newRule, nil
|
||||
}
|
||||
|
||||
var ruleErr error
|
||||
for _, c := range collections {
|
||||
c.ListRule, ruleErr = migrateRule(c, c.ListRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.ViewRule, ruleErr = migrateRule(c, c.ViewRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.CreateRule, ruleErr = migrateRule(c, c.CreateRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.UpdateRule, ruleErr = migrateRule(c, c.UpdateRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.DeleteRule, ruleErr = migrateRule(c, c.DeleteRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
if c.IsAuth() {
|
||||
opt := c.AuthOptions()
|
||||
opt.ManageRule, ruleErr = migrateRule(c, opt.ManageRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
c.SetOptions(opt)
|
||||
}
|
||||
|
||||
if err := dao.Save(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
anyOpRegex := regexp.MustCompile(`\?(=|!=|~|!~|>|>=|<|<=)`)
|
||||
|
||||
// replace any/opt operators to their old versions, eg. "?=" => "="
|
||||
revertRule := func(rule *string) (*string, error) {
|
||||
if rule == nil || *rule == "" {
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
newRule := *rule
|
||||
newRule = anyOpRegex.ReplaceAllString(newRule, "${1}")
|
||||
|
||||
return &newRule, nil
|
||||
}
|
||||
|
||||
var ruleErr error
|
||||
for _, c := range collections {
|
||||
c.ListRule, ruleErr = revertRule(c.ListRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.ViewRule, ruleErr = revertRule(c.ViewRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.CreateRule, ruleErr = revertRule(c.CreateRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.UpdateRule, ruleErr = revertRule(c.UpdateRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
c.DeleteRule, ruleErr = revertRule(c.DeleteRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
|
||||
if c.IsAuth() {
|
||||
opt := c.AuthOptions()
|
||||
opt.ManageRule, ruleErr = revertRule(opt.ManageRule)
|
||||
if ruleErr != nil {
|
||||
return ruleErr
|
||||
}
|
||||
c.SetOptions(opt)
|
||||
}
|
||||
|
||||
if err := dao.Save(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,7 @@ func init() {
|
||||
LogsMigrations.Register(func(db dbx.Builder) (err error) {
|
||||
_, err = db.NewQuery(`
|
||||
CREATE TABLE {{_requests}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[id]] TEXT PRIMARY KEY NOT NULL,
|
||||
[[url]] TEXT DEFAULT "" NOT NULL,
|
||||
[[method]] TEXT DEFAULT "get" NOT NULL,
|
||||
[[status]] INTEGER DEFAULT 200 NOT NULL,
|
||||
|
||||
+244
-32
@@ -4,12 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -26,19 +29,19 @@ type Record struct {
|
||||
|
||||
collection *Collection
|
||||
|
||||
exportUnknown bool // whether to export unknown fields
|
||||
ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
|
||||
data map[string]any // any custom data in addition to the base model fields
|
||||
expand map[string]any // expanded relations
|
||||
exportUnknown bool // whether to export unknown fields
|
||||
ignoreEmailVisibility bool // whether to ignore the emailVisibility flag for auth collections
|
||||
loaded bool
|
||||
originalData map[string]any // the original (aka. first loaded) model data
|
||||
originalData map[string]any // the original (aka. first loaded) model data
|
||||
expand *store.Store[any] // expanded relations
|
||||
data *store.Store[any] // any custom data in addition to the base model fields
|
||||
}
|
||||
|
||||
// NewRecord initializes a new empty Record model.
|
||||
func NewRecord(collection *Collection) *Record {
|
||||
return &Record{
|
||||
collection: collection,
|
||||
data: map[string]any{},
|
||||
data: store.New[any](nil),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +60,7 @@ func nullStringMapValue(data dbx.NullStringMap, key string) any {
|
||||
// NewRecordFromNullStringMap initializes a single new Record model
|
||||
// with data loaded from the provided NullStringMap.
|
||||
func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap) *Record {
|
||||
resultMap := map[string]any{}
|
||||
resultMap := make(map[string]any, len(data))
|
||||
|
||||
// load schema fields
|
||||
for _, field := range collection.Schema.Fields() {
|
||||
@@ -107,7 +110,8 @@ func (m *Record) Collection() *Collection {
|
||||
}
|
||||
|
||||
// OriginalCopy returns a copy of the current record model populated
|
||||
// with its original (aka. the initially loaded) data state.
|
||||
// with its ORIGINAL data state (aka. the initially loaded) and
|
||||
// everything else reset to the defaults.
|
||||
func (m *Record) OriginalCopy() *Record {
|
||||
newRecord := NewRecord(m.collection)
|
||||
newRecord.Load(m.originalData)
|
||||
@@ -121,23 +125,131 @@ func (m *Record) OriginalCopy() *Record {
|
||||
return newRecord
|
||||
}
|
||||
|
||||
// Expand returns a shallow copy of the record.expand data
|
||||
// attached to the current Record model.
|
||||
func (m *Record) Expand() map[string]any {
|
||||
return shallowCopy(m.expand)
|
||||
// CleanCopy returns a copy of the current record model populated only
|
||||
// with its LATEST data state and everything else reset to the defaults.
|
||||
func (m *Record) CleanCopy() *Record {
|
||||
newRecord := NewRecord(m.collection)
|
||||
newRecord.Load(m.data.GetAll())
|
||||
newRecord.Id = m.Id
|
||||
newRecord.Created = m.Created
|
||||
newRecord.Updated = m.Updated
|
||||
|
||||
if m.IsNew() {
|
||||
newRecord.MarkAsNew()
|
||||
} else {
|
||||
newRecord.MarkAsNotNew()
|
||||
}
|
||||
|
||||
return newRecord
|
||||
}
|
||||
|
||||
// SetExpand assigns the provided data to record.expand.
|
||||
// Expand returns a shallow copy of the current Record model expand data.
|
||||
func (m *Record) Expand() map[string]any {
|
||||
if m.expand == nil {
|
||||
m.expand = store.New[any](nil)
|
||||
}
|
||||
|
||||
return m.expand.GetAll()
|
||||
}
|
||||
|
||||
// SetExpand shallow copies the provided data to the current Record model's expand.
|
||||
func (m *Record) SetExpand(expand map[string]any) {
|
||||
m.expand = shallowCopy(expand)
|
||||
if m.expand == nil {
|
||||
m.expand = store.New[any](nil)
|
||||
}
|
||||
|
||||
m.expand.Reset(expand)
|
||||
}
|
||||
|
||||
// MergeExpand merges recursively the provided expand data into
|
||||
// the current model's expand (if any).
|
||||
//
|
||||
// Note that if an expanded prop with the same key is a slice (old or new expand)
|
||||
// then both old and new records will be merged into a new slice (aka. a :merge: [b,c] => [a,b,c]).
|
||||
// Otherwise the "old" expanded record will be replace with the "new" one (aka. a :merge: aNew => aNew).
|
||||
func (m *Record) MergeExpand(expand map[string]any) {
|
||||
// nothing to merge
|
||||
if len(expand) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// no old expand
|
||||
if m.expand == nil {
|
||||
m.expand = store.New(expand)
|
||||
return
|
||||
}
|
||||
|
||||
oldExpand := m.expand.GetAll()
|
||||
|
||||
for key, new := range expand {
|
||||
old, ok := oldExpand[key]
|
||||
if !ok {
|
||||
oldExpand[key] = new
|
||||
continue
|
||||
}
|
||||
|
||||
var wasOldSlice bool
|
||||
var oldSlice []*Record
|
||||
switch v := old.(type) {
|
||||
case *Record:
|
||||
oldSlice = []*Record{v}
|
||||
case []*Record:
|
||||
wasOldSlice = true
|
||||
oldSlice = v
|
||||
default:
|
||||
// invalid old expand data -> assign directly the new
|
||||
// (no matter whether new is valid or not)
|
||||
oldExpand[key] = new
|
||||
continue
|
||||
}
|
||||
|
||||
var wasNewSlice bool
|
||||
var newSlice []*Record
|
||||
switch v := new.(type) {
|
||||
case *Record:
|
||||
newSlice = []*Record{v}
|
||||
case []*Record:
|
||||
wasNewSlice = true
|
||||
newSlice = v
|
||||
default:
|
||||
// invalid new expand data -> skip
|
||||
continue
|
||||
}
|
||||
|
||||
oldIndexed := make(map[string]*Record, len(oldSlice))
|
||||
for _, oldRecord := range oldSlice {
|
||||
oldIndexed[oldRecord.Id] = oldRecord
|
||||
}
|
||||
|
||||
for _, newRecord := range newSlice {
|
||||
oldRecord := oldIndexed[newRecord.Id]
|
||||
if oldRecord != nil {
|
||||
// note: there is no need to update oldSlice since oldRecord is a reference
|
||||
oldRecord.MergeExpand(newRecord.Expand())
|
||||
} else {
|
||||
// missing new entry
|
||||
oldSlice = append(oldSlice, newRecord)
|
||||
}
|
||||
}
|
||||
|
||||
if wasOldSlice || wasNewSlice || len(oldSlice) == 0 {
|
||||
oldExpand[key] = oldSlice
|
||||
} else {
|
||||
oldExpand[key] = oldSlice[0]
|
||||
}
|
||||
}
|
||||
|
||||
m.expand.Reset(oldExpand)
|
||||
}
|
||||
|
||||
// SchemaData returns a shallow copy ONLY of the defined record schema fields data.
|
||||
func (m *Record) SchemaData() map[string]any {
|
||||
result := map[string]any{}
|
||||
result := make(map[string]any, len(m.collection.Schema.Fields()))
|
||||
|
||||
data := m.data.GetAll()
|
||||
|
||||
for _, field := range m.collection.Schema.Fields() {
|
||||
if v, ok := m.data[field.Name]; ok {
|
||||
if v, ok := data[field.Name]; ok {
|
||||
result[field.Name] = v
|
||||
}
|
||||
}
|
||||
@@ -149,7 +261,11 @@ func (m *Record) SchemaData() map[string]any {
|
||||
// aka. fields that are neither one of the base and special system ones,
|
||||
// nor defined by the collection schema.
|
||||
func (m *Record) UnknownData() map[string]any {
|
||||
return m.extractUnknownData(m.data)
|
||||
if m.data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.extractUnknownData(m.data.GetAll())
|
||||
}
|
||||
|
||||
// IgnoreEmailVisibility toggles the flag to ignore the auth record email visibility check.
|
||||
@@ -195,10 +311,10 @@ func (m *Record) Set(key string, value any) {
|
||||
}
|
||||
|
||||
if m.data == nil {
|
||||
m.data = map[string]any{}
|
||||
m.data = store.New[any](nil)
|
||||
}
|
||||
|
||||
m.data[key] = v
|
||||
m.data.Set(key, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,11 +328,11 @@ func (m *Record) Get(key string) any {
|
||||
case schema.FieldNameUpdated:
|
||||
return m.Updated
|
||||
default:
|
||||
if v, ok := m.data[key]; ok {
|
||||
return v
|
||||
if m.data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return m.data.Get(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,10 +375,11 @@ func (m *Record) GetStringSlice(key string) []string {
|
||||
// Retrieves the "key" json field value and unmarshals it into "result".
|
||||
//
|
||||
// Example
|
||||
// result := struct {
|
||||
// FirstName string `json:"first_name"`
|
||||
// }{}
|
||||
// err := m.UnmarshalJSONField("my_field_name", &result)
|
||||
//
|
||||
// result := struct {
|
||||
// FirstName string `json:"first_name"`
|
||||
// }{}
|
||||
// err := m.UnmarshalJSONField("my_field_name", &result)
|
||||
func (m *Record) UnmarshalJSONField(key string, result any) error {
|
||||
return json.Unmarshal([]byte(m.GetString(key)), &result)
|
||||
}
|
||||
@@ -300,7 +417,7 @@ func (m *Record) Load(data map[string]any) {
|
||||
|
||||
// ColumnValueMap implements [ColumnValueMapper] interface.
|
||||
func (m *Record) ColumnValueMap() map[string]any {
|
||||
result := map[string]any{}
|
||||
result := make(map[string]any, len(m.collection.Schema.Fields())+3)
|
||||
|
||||
// export schema field values
|
||||
for _, field := range m.collection.Schema.Fields() {
|
||||
@@ -326,7 +443,7 @@ func (m *Record) ColumnValueMap() map[string]any {
|
||||
//
|
||||
// Fields marked as hidden will be exported only if `m.IgnoreEmailVisibility(true)` is set.
|
||||
func (m *Record) PublicExport() map[string]any {
|
||||
result := map[string]any{}
|
||||
result := make(map[string]any, len(m.collection.Schema.Fields())+5)
|
||||
|
||||
// export unknown data fields if allowed
|
||||
if m.exportUnknown {
|
||||
@@ -360,8 +477,8 @@ func (m *Record) PublicExport() map[string]any {
|
||||
result[schema.FieldNameCollectionName] = m.collection.Name
|
||||
|
||||
// add expand (if set)
|
||||
if m.expand != nil {
|
||||
result[schema.FieldNameExpand] = m.expand
|
||||
if m.expand != nil && m.expand.Length() > 0 {
|
||||
result[schema.FieldNameExpand] = m.expand.GetAll()
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -387,6 +504,101 @@ func (m *Record) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceModifers returns a new map with applied modifier
|
||||
// values based on the current record and the specified data.
|
||||
//
|
||||
// The resolved modifier keys will be removed.
|
||||
//
|
||||
// Multiple modifiers will be applied one after another,
|
||||
// while reusing the previous base key value result (eg. 1; -5; +2 => -2).
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// newData := record.ReplaceModifers(data)
|
||||
// // record: {"field": 10}
|
||||
// // data: {"field+": 5}
|
||||
// // newData: {"field": 15}
|
||||
func (m *Record) ReplaceModifers(data map[string]any) map[string]any {
|
||||
var clone = shallowCopy(data)
|
||||
if len(clone) == 0 {
|
||||
return clone
|
||||
}
|
||||
|
||||
var recordDataCache map[string]any
|
||||
|
||||
// export recordData lazily
|
||||
recordData := func() map[string]any {
|
||||
if recordDataCache == nil {
|
||||
recordDataCache = m.SchemaData()
|
||||
}
|
||||
return recordDataCache
|
||||
}
|
||||
|
||||
modifiers := schema.FieldValueModifiers()
|
||||
|
||||
for _, field := range m.Collection().Schema.Fields() {
|
||||
key := field.Name
|
||||
|
||||
for _, m := range modifiers {
|
||||
if mv, mOk := clone[key+m]; mOk {
|
||||
if _, ok := clone[key]; !ok {
|
||||
// get base value from the merged data
|
||||
clone[key] = recordData()[key]
|
||||
}
|
||||
|
||||
clone[key] = field.PrepareValueWithModifier(clone[key], m, mv)
|
||||
delete(clone, key+m)
|
||||
}
|
||||
}
|
||||
|
||||
if field.Type != schema.FieldTypeFile {
|
||||
continue
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// legacy file field modifiers (kept for backward compatability)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
var oldNames []string
|
||||
var toDelete []string
|
||||
if _, ok := clone[key]; ok {
|
||||
oldNames = list.ToUniqueStringSlice(clone[key])
|
||||
} else {
|
||||
// get oldNames from the model
|
||||
oldNames = list.ToUniqueStringSlice(recordData()[key])
|
||||
}
|
||||
|
||||
// search for individual file name to delete (eg. "file.test.png = null")
|
||||
for _, name := range oldNames {
|
||||
suffixedKey := key + "." + name
|
||||
if v, ok := clone[suffixedKey]; ok && cast.ToString(v) == "" {
|
||||
toDelete = append(toDelete, name)
|
||||
delete(clone, suffixedKey)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// search for individual file index to delete (eg. "file.0 = null")
|
||||
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
|
||||
for indexedKey := range clone {
|
||||
if keyExp.MatchString(indexedKey) && cast.ToString(clone[indexedKey]) == "" {
|
||||
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
|
||||
if indexErr != nil || index < 0 || index >= len(oldNames) {
|
||||
continue
|
||||
}
|
||||
toDelete = append(toDelete, oldNames[index])
|
||||
delete(clone, indexedKey)
|
||||
}
|
||||
}
|
||||
|
||||
if toDelete != nil {
|
||||
clone[key] = field.PrepareValue(list.SubtractSlice(oldNames, toDelete))
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// getNormalizeDataValueForDB returns the "key" data value formatted for db storage.
|
||||
func (m *Record) getNormalizeDataValueForDB(key string) any {
|
||||
var val any
|
||||
@@ -426,7 +638,7 @@ func (m *Record) getNormalizeDataValueForDB(key string) any {
|
||||
|
||||
// shallowCopy shallow copy data into a new map.
|
||||
func shallowCopy(data map[string]any) map[string]any {
|
||||
result := map[string]any{}
|
||||
result := make(map[string]any, len(data))
|
||||
|
||||
for k, v := range data {
|
||||
result[k] = v
|
||||
@@ -457,7 +669,7 @@ func (m *Record) extractUnknownData(data map[string]any) map[string]any {
|
||||
|
||||
result := map[string]any{}
|
||||
|
||||
for k, v := range m.data {
|
||||
for k, v := range data {
|
||||
if _, ok := knownFields[k]; !ok {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
+275
-6
@@ -1,6 +1,7 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
@@ -138,7 +139,7 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
|
||||
Valid: true,
|
||||
},
|
||||
"field5": sql.NullString{
|
||||
String: `["test1","test2"]`, // will select only the first elem
|
||||
String: `["test1","test2"]`, // will select only the last elem
|
||||
Valid: true,
|
||||
},
|
||||
"field6": sql.NullString{
|
||||
@@ -157,11 +158,11 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
models.CollectionTypeBase,
|
||||
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z"}`,
|
||||
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test2","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z"}`,
|
||||
},
|
||||
{
|
||||
models.CollectionTypeAuth,
|
||||
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z","username":"test_username","verified":false}`,
|
||||
`{"collectionId":"","collectionName":"test","created":"2022-01-01 10:00:00.123Z","email":"test_email","emailVisibility":true,"field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test2","field6":["test"],"id":"test_id","updated":"2022-01-01 10:00:00.456Z","username":"test_username","verified":false}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -346,7 +347,7 @@ func TestRecordOriginalCopy(t *testing.T) {
|
||||
t.Fatalf("Expected the initial/original f to be %q, got %q", "123", v)
|
||||
}
|
||||
|
||||
// Loading new data shouldn't affect the original state
|
||||
// loading new data shouldn't affect the original state
|
||||
m.Load(map[string]any{"f": "789"})
|
||||
|
||||
if v := m.GetString("f"); v != "789" {
|
||||
@@ -358,7 +359,39 @@ func TestRecordOriginalCopy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordExpand(t *testing.T) {
|
||||
func TestRecordCleanCopy(t *testing.T) {
|
||||
m := models.NewRecord(&models.Collection{
|
||||
Name: "cname",
|
||||
Type: models.CollectionTypeAuth,
|
||||
})
|
||||
m.Load(map[string]any{
|
||||
"id": "id1",
|
||||
"created": "2023-01-01 00:00:00.000Z",
|
||||
"updated": "2023-01-02 00:00:00.000Z",
|
||||
"username": "test",
|
||||
"verified": true,
|
||||
"email": "test@example.com",
|
||||
"unknown": "456",
|
||||
})
|
||||
|
||||
// make a change to ensure that the latest data is targeted
|
||||
m.Set("id", "id2")
|
||||
|
||||
// allow the special flags and options to check whether they will be ignored
|
||||
m.SetExpand(map[string]any{"test": 123})
|
||||
m.IgnoreEmailVisibility(true)
|
||||
m.WithUnkownData(true)
|
||||
|
||||
copy := m.CleanCopy()
|
||||
copyExport, _ := copy.MarshalJSON()
|
||||
|
||||
expectedExport := []byte(`{"collectionId":"","collectionName":"cname","created":"2023-01-01 00:00:00.000Z","emailVisibility":false,"id":"id2","updated":"2023-01-02 00:00:00.000Z","username":"test","verified":true}`)
|
||||
if !bytes.Equal(copyExport, expectedExport) {
|
||||
t.Fatalf("Expected clean export \n%s, \ngot \n%s", expectedExport, copyExport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSetAndGetExpand(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
m := models.NewRecord(collection)
|
||||
|
||||
@@ -375,6 +408,139 @@ func TestRecordExpand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMergeExpand(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
m := models.NewRecord(collection)
|
||||
m.Id = "m"
|
||||
|
||||
// a
|
||||
a := models.NewRecord(collection)
|
||||
a.Id = "a"
|
||||
a1 := models.NewRecord(collection)
|
||||
a1.Id = "a1"
|
||||
a2 := models.NewRecord(collection)
|
||||
a2.Id = "a2"
|
||||
a3 := models.NewRecord(collection)
|
||||
a3.Id = "a3"
|
||||
a31 := models.NewRecord(collection)
|
||||
a31.Id = "a31"
|
||||
a32 := models.NewRecord(collection)
|
||||
a32.Id = "a32"
|
||||
a.SetExpand(map[string]any{
|
||||
"a1": a1,
|
||||
"a23": []*models.Record{a2, a3},
|
||||
})
|
||||
a3.SetExpand(map[string]any{
|
||||
"a31": a31,
|
||||
"a32": []*models.Record{a32},
|
||||
})
|
||||
|
||||
// b
|
||||
b := models.NewRecord(collection)
|
||||
b.Id = "b"
|
||||
b1 := models.NewRecord(collection)
|
||||
b1.Id = "b1"
|
||||
b.SetExpand(map[string]any{
|
||||
"b1": b1,
|
||||
})
|
||||
|
||||
// c
|
||||
c := models.NewRecord(collection)
|
||||
c.Id = "c"
|
||||
|
||||
// load initial expand
|
||||
m.SetExpand(map[string]any{
|
||||
"a": a,
|
||||
"b": b,
|
||||
"c": []*models.Record{c},
|
||||
})
|
||||
|
||||
// a (new)
|
||||
aNew := models.NewRecord(collection)
|
||||
aNew.Id = a.Id
|
||||
a3New := models.NewRecord(collection)
|
||||
a3New.Id = a3.Id
|
||||
a32New := models.NewRecord(collection)
|
||||
a32New.Id = "a32New"
|
||||
a33New := models.NewRecord(collection)
|
||||
a33New.Id = "a33New"
|
||||
a3New.SetExpand(map[string]any{
|
||||
"a32": []*models.Record{a32New},
|
||||
"a33New": a33New,
|
||||
})
|
||||
aNew.SetExpand(map[string]any{
|
||||
"a23": []*models.Record{a2, a3New},
|
||||
})
|
||||
|
||||
// b (new)
|
||||
bNew := models.NewRecord(collection)
|
||||
bNew.Id = "bNew"
|
||||
dNew := models.NewRecord(collection)
|
||||
dNew.Id = "dNew"
|
||||
|
||||
// merge expands
|
||||
m.MergeExpand(map[string]any{
|
||||
"a": aNew,
|
||||
"b": []*models.Record{bNew},
|
||||
"dNew": dNew,
|
||||
})
|
||||
|
||||
result := m.Expand()
|
||||
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
expected := `{"a":{"collectionId":"","collectionName":"","created":"","expand":{"a1":{"collectionId":"","collectionName":"","created":"","id":"a1","updated":""},"a23":[{"collectionId":"","collectionName":"","created":"","id":"a2","updated":""},{"collectionId":"","collectionName":"","created":"","expand":{"a31":{"collectionId":"","collectionName":"","created":"","id":"a31","updated":""},"a32":[{"collectionId":"","collectionName":"","created":"","id":"a32","updated":""},{"collectionId":"","collectionName":"","created":"","id":"a32New","updated":""}],"a33New":{"collectionId":"","collectionName":"","created":"","id":"a33New","updated":""}},"id":"a3","updated":""}]},"id":"a","updated":""},"b":[{"collectionId":"","collectionName":"","created":"","expand":{"b1":{"collectionId":"","collectionName":"","created":"","id":"b1","updated":""}},"id":"b","updated":""},{"collectionId":"","collectionName":"","created":"","id":"bNew","updated":""}],"c":[{"collectionId":"","collectionName":"","created":"","id":"c","updated":""}],"dNew":{"collectionId":"","collectionName":"","created":"","id":"dNew","updated":""}}`
|
||||
|
||||
if expected != rawStr {
|
||||
t.Fatalf("Expected \n%v, \ngot \n%v", expected, rawStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMergeExpandNilCheck(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
expand map[string]any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"nil expand",
|
||||
nil,
|
||||
`{"collectionId":"","collectionName":"","created":"","id":"","updated":""}`,
|
||||
},
|
||||
{
|
||||
"empty expand",
|
||||
map[string]any{},
|
||||
`{"collectionId":"","collectionName":"","created":"","id":"","updated":""}`,
|
||||
},
|
||||
{
|
||||
"non-empty expand",
|
||||
map[string]any{"test": models.NewRecord(collection)},
|
||||
`{"collectionId":"","collectionName":"","created":"","expand":{"test":{"collectionId":"","collectionName":"","created":"","id":"","updated":""}},"id":"","updated":""}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
m := models.NewRecord(collection)
|
||||
m.MergeExpand(s.expand)
|
||||
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
if rawStr != s.expected {
|
||||
t.Fatalf("[%s] Expected \n%v, \ngot \n%v", s.name, s.expected, rawStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSchemaData(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Type: models.CollectionTypeAuth,
|
||||
@@ -795,7 +961,7 @@ func TestRecordUnmarshalJSONField(t *testing.T) {
|
||||
expectedJson string
|
||||
}{
|
||||
{nil, testStr, true, `""`},
|
||||
{"", testStr, true, `""`},
|
||||
{"", testStr, false, `""`},
|
||||
{1, testInt, false, `1`},
|
||||
{true, testBool, false, `true`},
|
||||
{[]int{1, 2, 3}, testSlice, false, `[1,2,3]`},
|
||||
@@ -1292,6 +1458,109 @@ func TestRecordUnmarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReplaceModifers(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "text",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "number",
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "rel_one",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "rel_many",
|
||||
Type: schema.FieldTypeRelation,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "select_one",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{MaxSelect: 1},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "select_many",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{MaxSelect: 10},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "file_one",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{MaxSelect: 1},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "file_one_index",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{MaxSelect: 1},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "file_one_name",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{MaxSelect: 1},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "file_many",
|
||||
Type: schema.FieldTypeFile,
|
||||
Options: &schema.FileOptions{MaxSelect: 10},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
record := models.NewRecord(collection)
|
||||
|
||||
record.Load(map[string]any{
|
||||
"text": "test",
|
||||
"number": 10,
|
||||
"rel_one": "a",
|
||||
"rel_many": []string{"a", "b"},
|
||||
"select_one": "a",
|
||||
"select_many": []string{"a", "b", "c"},
|
||||
"file_one": "a",
|
||||
"file_one_index": "b",
|
||||
"file_one_name": "c",
|
||||
"file_many": []string{"a", "b", "c", "d", "e", "f"},
|
||||
})
|
||||
|
||||
result := record.ReplaceModifers(map[string]any{
|
||||
"text-": "m-",
|
||||
"text+": "m+",
|
||||
"number-": 3,
|
||||
"number+": 5,
|
||||
"rel_one-": "a",
|
||||
"rel_one+": "b",
|
||||
"rel_many-": []string{"a"},
|
||||
"rel_many+": []string{"c", "d", "e"},
|
||||
"select_one-": "a",
|
||||
"select_one+": "c",
|
||||
"select_many-": []string{"b", "c"},
|
||||
"select_many+": []string{"d", "e"},
|
||||
"file_one+": "skip", // should be ignored
|
||||
"file_one-": "a",
|
||||
"file_one_index.0": "",
|
||||
"file_one_name.c": "",
|
||||
"file_many+": []string{"e", "f"}, // should be ignored
|
||||
"file_many-": []string{"c", "d"},
|
||||
"file_many.f": nil,
|
||||
"file_many.0": nil,
|
||||
})
|
||||
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"file_many":["b","e"],"file_one":"","file_one_index":"","file_one_name":"","number":12,"rel_many":["b","c","d","e"],"rel_one":"b","select_many":["a","d","e"],"select_one":"c","text":"test"}`
|
||||
|
||||
if v := string(raw); v != expected {
|
||||
t.Fatalf("Expected \n%s, \ngot \n%s", expected, v)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth helpers:
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// RequestData defines a HTTP request data struct, usually used
|
||||
// as part of the `@request.*` filter resolver.
|
||||
type RequestData struct {
|
||||
@@ -9,3 +15,18 @@ type RequestData struct {
|
||||
AuthRecord *Record `json:"authRecord"`
|
||||
Admin *Admin `json:"admin"`
|
||||
}
|
||||
|
||||
// HasModifierDataKeys loosely checks if the current struct has any modifier Data keys.
|
||||
func (r *RequestData) HasModifierDataKeys() bool {
|
||||
allModifiers := schema.FieldValueModifiers()
|
||||
|
||||
for key := range r.Data {
|
||||
for _, m := range allModifiers {
|
||||
if strings.HasSuffix(key, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
func TestRequestDataHasModifierDataKeys(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
requestData *models.RequestData
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&models.RequestData{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Data with regular fields",
|
||||
&models.RequestData{
|
||||
Query: map[string]any{"data+": "demo"}, // should be ignored
|
||||
Data: map[string]any{"a": 123, "b": "test", "c.d": false},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Data with +modifier fields",
|
||||
&models.RequestData{
|
||||
Data: map[string]any{"a+": 123, "b": "test", "c.d": false},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Data with -modifier fields",
|
||||
&models.RequestData{
|
||||
Data: map[string]any{"a": 123, "b-": "test", "c.d": false},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Data with mixed modifier fields",
|
||||
&models.RequestData{
|
||||
Data: map[string]any{"a": 123, "b-": "test", "c.d+": false},
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := s.requestData.HasModifierDataKeys()
|
||||
|
||||
if result != s.expected {
|
||||
t.Fatalf("[%s] Expected %v, got %v", s.name, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
-23
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
@@ -15,22 +16,36 @@ import (
|
||||
|
||||
var schemaFieldNameRegex = regexp.MustCompile(`^\w+$`)
|
||||
|
||||
// field value modifiers
|
||||
const (
|
||||
FieldValueModifierAdd string = "+"
|
||||
FieldValueModifierSubtract string = "-"
|
||||
)
|
||||
|
||||
// FieldValueModifiers returns a list with all available field modifier tokens.
|
||||
func FieldValueModifiers() []string {
|
||||
return []string{
|
||||
FieldValueModifierAdd,
|
||||
FieldValueModifierSubtract,
|
||||
}
|
||||
}
|
||||
|
||||
// commonly used field names
|
||||
const (
|
||||
FieldNameId = "id"
|
||||
FieldNameCreated = "created"
|
||||
FieldNameUpdated = "updated"
|
||||
FieldNameCollectionId = "collectionId"
|
||||
FieldNameCollectionName = "collectionName"
|
||||
FieldNameExpand = "expand"
|
||||
FieldNameUsername = "username"
|
||||
FieldNameEmail = "email"
|
||||
FieldNameEmailVisibility = "emailVisibility"
|
||||
FieldNameVerified = "verified"
|
||||
FieldNameTokenKey = "tokenKey"
|
||||
FieldNamePasswordHash = "passwordHash"
|
||||
FieldNameLastResetSentAt = "lastResetSentAt"
|
||||
FieldNameLastVerificationSentAt = "lastVerificationSentAt"
|
||||
FieldNameId string = "id"
|
||||
FieldNameCreated string = "created"
|
||||
FieldNameUpdated string = "updated"
|
||||
FieldNameCollectionId string = "collectionId"
|
||||
FieldNameCollectionName string = "collectionName"
|
||||
FieldNameExpand string = "expand"
|
||||
FieldNameUsername string = "username"
|
||||
FieldNameEmail string = "email"
|
||||
FieldNameEmailVisibility string = "emailVisibility"
|
||||
FieldNameVerified string = "verified"
|
||||
FieldNameTokenKey string = "tokenKey"
|
||||
FieldNamePasswordHash string = "passwordHash"
|
||||
FieldNameLastResetSentAt string = "lastResetSentAt"
|
||||
FieldNameLastVerificationSentAt string = "lastVerificationSentAt"
|
||||
)
|
||||
|
||||
// BaseModelFieldNames returns the field names that all models have (id, created, updated).
|
||||
@@ -72,6 +87,7 @@ const (
|
||||
FieldTypeBool string = "bool"
|
||||
FieldTypeEmail string = "email"
|
||||
FieldTypeUrl string = "url"
|
||||
FieldTypeEditor string = "editor"
|
||||
FieldTypeDate string = "date"
|
||||
FieldTypeSelect string = "select"
|
||||
FieldTypeJson string = "json"
|
||||
@@ -90,6 +106,7 @@ func FieldTypes() []string {
|
||||
FieldTypeBool,
|
||||
FieldTypeEmail,
|
||||
FieldTypeUrl,
|
||||
FieldTypeEditor,
|
||||
FieldTypeDate,
|
||||
FieldTypeSelect,
|
||||
FieldTypeJson,
|
||||
@@ -168,7 +185,7 @@ func (f SchemaField) Validate() error {
|
||||
f.InitOptions()
|
||||
|
||||
excludeNames := BaseModelFieldNames()
|
||||
// exclude filter literals
|
||||
// exclude special filter literals
|
||||
excludeNames = append(excludeNames, "null", "true", "false")
|
||||
// exclude system literals
|
||||
excludeNames = append(excludeNames, SystemFieldNames()...)
|
||||
@@ -224,6 +241,8 @@ func (f *SchemaField) InitOptions() error {
|
||||
options = &EmailOptions{}
|
||||
case FieldTypeUrl:
|
||||
options = &UrlOptions{}
|
||||
case FieldTypeEditor:
|
||||
options = &EditorOptions{}
|
||||
case FieldTypeDate:
|
||||
options = &DateOptions{}
|
||||
case FieldTypeSelect:
|
||||
@@ -258,10 +277,38 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
f.InitOptions()
|
||||
|
||||
switch f.Type {
|
||||
case FieldTypeText, FieldTypeEmail, FieldTypeUrl:
|
||||
case FieldTypeText, FieldTypeEmail, FieldTypeUrl, FieldTypeEditor:
|
||||
return cast.ToString(value)
|
||||
case FieldTypeJson:
|
||||
val, _ := types.ParseJsonRaw(value)
|
||||
val := value
|
||||
|
||||
if str, ok := val.(string); ok {
|
||||
// in order to support seamlessly both json and multipart/form-data requests,
|
||||
// the following normalization rules are applied for plain string values:
|
||||
// - "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
|
||||
if str == "" {
|
||||
val = strconv.Quote(str)
|
||||
} else if str == "null" || str == "true" || str == "false" {
|
||||
val = str
|
||||
} else if ((str[0] >= '0' && str[0] <= '9') ||
|
||||
str[0] == '"' ||
|
||||
str[0] == '[' ||
|
||||
str[0] == '{') &&
|
||||
is.JSON.Validate(str) == nil {
|
||||
val = str
|
||||
} else {
|
||||
val = strconv.Quote(str)
|
||||
}
|
||||
}
|
||||
|
||||
val, _ = types.ParseJsonRaw(val)
|
||||
return val
|
||||
case FieldTypeNumber:
|
||||
return cast.ToFloat64(value)
|
||||
@@ -276,7 +323,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
options, _ := f.Options.(*SelectOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if len(val) > 0 {
|
||||
return val[0]
|
||||
return val[len(val)-1] // the last selected
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -288,7 +335,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
options, _ := f.Options.(*FileOptions)
|
||||
if options.MaxSelect <= 1 {
|
||||
if len(val) > 0 {
|
||||
return val[0]
|
||||
return val[len(val)-1] // the last selected
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -300,7 +347,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
options, _ := f.Options.(*RelationOptions)
|
||||
if options.MaxSelect != nil && *options.MaxSelect <= 1 {
|
||||
if len(ids) > 0 {
|
||||
return ids[0]
|
||||
return ids[len(ids)-1] // the last selected
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -311,6 +358,46 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareValueWithModifier returns normalized and properly formatted field value
|
||||
// by "merging" baseValue with the modifierValue based on the specified modifier (+ or -).
|
||||
func (f *SchemaField) PrepareValueWithModifier(baseValue any, modifier string, modifierValue any) any {
|
||||
resolvedValue := baseValue
|
||||
|
||||
switch f.Type {
|
||||
case FieldTypeNumber:
|
||||
switch modifier {
|
||||
case FieldValueModifierAdd:
|
||||
resolvedValue = cast.ToFloat64(baseValue) + cast.ToFloat64(modifierValue)
|
||||
case FieldValueModifierSubtract:
|
||||
resolvedValue = cast.ToFloat64(baseValue) - cast.ToFloat64(modifierValue)
|
||||
}
|
||||
case FieldTypeSelect, FieldTypeRelation:
|
||||
switch modifier {
|
||||
case FieldValueModifierAdd:
|
||||
resolvedValue = append(
|
||||
list.ToUniqueStringSlice(baseValue),
|
||||
list.ToUniqueStringSlice(modifierValue)...,
|
||||
)
|
||||
case FieldValueModifierSubtract:
|
||||
resolvedValue = list.SubtractSlice(
|
||||
list.ToUniqueStringSlice(baseValue),
|
||||
list.ToUniqueStringSlice(modifierValue),
|
||||
)
|
||||
}
|
||||
case FieldTypeFile:
|
||||
// note: file for now supports only the subtract modifier
|
||||
switch modifier {
|
||||
case FieldValueModifierSubtract:
|
||||
resolvedValue = list.SubtractSlice(
|
||||
list.ToUniqueStringSlice(baseValue),
|
||||
list.ToUniqueStringSlice(modifierValue),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return f.PrepareValue(resolvedValue)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// FieldOptions interfaces that defines common methods that every field options struct has.
|
||||
@@ -419,6 +506,15 @@ func (o UrlOptions) Validate() error {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type EditorOptions struct {
|
||||
}
|
||||
|
||||
func (o EditorOptions) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type DateOptions struct {
|
||||
Min types.DateTime `form:"min" json:"min"`
|
||||
Max types.DateTime `form:"max" json:"max"`
|
||||
@@ -501,9 +597,21 @@ func (o FileOptions) Validate() error {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RelationOptions struct {
|
||||
MaxSelect *int `form:"maxSelect" json:"maxSelect"`
|
||||
CollectionId string `form:"collectionId" json:"collectionId"`
|
||||
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
|
||||
// CollectionId is the id of the related collection.
|
||||
CollectionId string `form:"collectionId" json:"collectionId"`
|
||||
|
||||
// CascadeDelete indicates whether the root model should be deleted
|
||||
// in case of delete of all linked relations.
|
||||
CascadeDelete bool `form:"cascadeDelete" json:"cascadeDelete"`
|
||||
|
||||
// MaxSelect indicates the max number of allowed relation records
|
||||
// that could be linked to the main model.
|
||||
//
|
||||
// If nil no limits are applied.
|
||||
MaxSelect *int `form:"maxSelect" json:"maxSelect"`
|
||||
|
||||
// DisplayFields is optional slice of collection field names used for UI purposes.
|
||||
DisplayFields []string `form:"displayFields" json:"displayFields"`
|
||||
}
|
||||
|
||||
func (o RelationOptions) Validate() error {
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestAuthFieldNames(t *testing.T) {
|
||||
|
||||
func TestFieldTypes(t *testing.T) {
|
||||
result := schema.FieldTypes()
|
||||
expected := 10
|
||||
expected := 11
|
||||
|
||||
if len(result) != expected {
|
||||
t.Fatalf("Expected %d types, got %d (%v)", expected, len(result), result)
|
||||
@@ -81,6 +81,10 @@ func TestSchemaFieldColDefinition(t *testing.T) {
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeEditor, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeDate, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
@@ -477,6 +481,11 @@ func TestSchemaFieldInitOptions(t *testing.T) {
|
||||
false,
|
||||
`{"system":false,"id":"","name":"","type":"url","required":false,"unique":false,"options":{"exceptDomains":null,"onlyDomains":null}}`,
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeEditor},
|
||||
false,
|
||||
`{"system":false,"id":"","name":"","type":"editor","required":false,"unique":false,"options":{}}`,
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
false,
|
||||
@@ -500,7 +509,7 @@ func TestSchemaFieldInitOptions(t *testing.T) {
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
false,
|
||||
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"maxSelect":null,"collectionId":"","cascadeDelete":false}}`,
|
||||
`{"system":false,"id":"","name":"","type":"relation","required":false,"unique":false,"options":{"collectionId":"","cascadeDelete":false,"maxSelect":null,"displayFields":null}}`,
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeUser},
|
||||
@@ -562,12 +571,38 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{schema.SchemaField{Type: schema.FieldTypeUrl}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUrl}, 123, `"123"`},
|
||||
|
||||
// editor
|
||||
{schema.SchemaField{Type: schema.FieldTypeEditor}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEditor}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEditor}, []int{1, 2}, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEditor}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEditor}, 123, `"123"`},
|
||||
|
||||
// json
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, nil, "null"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, "null", "null"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, 123, "123"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, "123", "123"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, 123.456, "123.456"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, "123.456", "123.456"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, "123.456 abc", `"123.456 abc"`}, // invalid numeric string
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, true, "true"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, "true", "true"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, false, "false"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, "false", "false"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, `test`, `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, `"test"`, `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, `{test":1}`, `"{test\":1}"`}, // invalid object string
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, `[1 2 3]`, `"[1 2 3]"`}, // invalid array string
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, map[string]int{}, `{}`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, `{}`, `{}`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, map[string]int{"test": 123}, `{"test":123}`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, `{"test":123}`, `{"test":123}`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, []int{}, `[]`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, `[]`, `[]`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, []int{1, 2, 1}, `[1,2,1]`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, `[1,2,1]`, `[1,2,1]`},
|
||||
|
||||
// number
|
||||
{schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "0"},
|
||||
@@ -603,7 +638,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, 123, `"123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test1"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test2"`},
|
||||
{
|
||||
// no values validation/filtering
|
||||
schema.SchemaField{
|
||||
@@ -680,7 +715,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, 123, `"123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test1"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test2"`},
|
||||
// file (multiple)
|
||||
{
|
||||
schema.SchemaField{
|
||||
@@ -785,7 +820,7 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
[]string{"1ba88b4f-e9da-42f0-9764-9a55c953e724", "2ba88b4f-e9da-42f0-9764-9a55c953e724"},
|
||||
`"1ba88b4f-e9da-42f0-9764-9a55c953e724"`,
|
||||
`"2ba88b4f-e9da-42f0-9764-9a55c953e724"`,
|
||||
},
|
||||
// relation (multiple)
|
||||
{
|
||||
@@ -863,6 +898,730 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaFieldPrepareValueWithModifier(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
field schema.SchemaField
|
||||
baseValue any
|
||||
modifier string
|
||||
modifierValue any
|
||||
expectJson string
|
||||
}{
|
||||
// text
|
||||
{
|
||||
"text with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeText},
|
||||
"base",
|
||||
"+",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"text with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeText},
|
||||
"base",
|
||||
"-",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"text with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeText},
|
||||
"base",
|
||||
"?",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"text cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeText},
|
||||
123,
|
||||
"?",
|
||||
"new",
|
||||
`"123"`,
|
||||
},
|
||||
|
||||
// number
|
||||
{
|
||||
"number with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber},
|
||||
1,
|
||||
"+",
|
||||
4,
|
||||
`5`,
|
||||
},
|
||||
{
|
||||
"number with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber},
|
||||
1,
|
||||
"-",
|
||||
4,
|
||||
`-3`,
|
||||
},
|
||||
{
|
||||
"number with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber},
|
||||
"1",
|
||||
"?",
|
||||
4,
|
||||
`1`,
|
||||
},
|
||||
{
|
||||
"number cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber},
|
||||
"test",
|
||||
"+",
|
||||
"4",
|
||||
`4`,
|
||||
},
|
||||
|
||||
// bool
|
||||
{
|
||||
"bool with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeBool},
|
||||
true,
|
||||
"+",
|
||||
false,
|
||||
`true`,
|
||||
},
|
||||
{
|
||||
"bool with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeBool},
|
||||
true,
|
||||
"-",
|
||||
false,
|
||||
`true`,
|
||||
},
|
||||
{
|
||||
"bool with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeBool},
|
||||
true,
|
||||
"?",
|
||||
false,
|
||||
`true`,
|
||||
},
|
||||
{
|
||||
"bool cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeBool},
|
||||
"true",
|
||||
"?",
|
||||
false,
|
||||
`true`,
|
||||
},
|
||||
|
||||
// email
|
||||
{
|
||||
"email with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail},
|
||||
"base",
|
||||
"+",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"email with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail},
|
||||
"base",
|
||||
"-",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"email with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail},
|
||||
"base",
|
||||
"?",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"email cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail},
|
||||
123,
|
||||
"?",
|
||||
"new",
|
||||
`"123"`,
|
||||
},
|
||||
|
||||
// url
|
||||
{
|
||||
"url with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl},
|
||||
"base",
|
||||
"+",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"url with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl},
|
||||
"base",
|
||||
"-",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"url with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl},
|
||||
"base",
|
||||
"?",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"url cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl},
|
||||
123,
|
||||
"-",
|
||||
"new",
|
||||
`"123"`,
|
||||
},
|
||||
|
||||
// editor
|
||||
{
|
||||
"editor with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEditor},
|
||||
"base",
|
||||
"+",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"editor with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEditor},
|
||||
"base",
|
||||
"-",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"editor with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeEditor},
|
||||
"base",
|
||||
"?",
|
||||
"new",
|
||||
`"base"`,
|
||||
},
|
||||
{
|
||||
"editor cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeEditor},
|
||||
123,
|
||||
"-",
|
||||
"new",
|
||||
`"123"`,
|
||||
},
|
||||
|
||||
// date
|
||||
{
|
||||
"date with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
"2023-01-01 00:00:00.123",
|
||||
"+",
|
||||
"2023-02-01 00:00:00.456",
|
||||
`"2023-01-01 00:00:00.123Z"`,
|
||||
},
|
||||
{
|
||||
"date with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
"2023-01-01 00:00:00.123Z",
|
||||
"-",
|
||||
"2023-02-01 00:00:00.456Z",
|
||||
`"2023-01-01 00:00:00.123Z"`,
|
||||
},
|
||||
{
|
||||
"date with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
"2023-01-01 00:00:00.123",
|
||||
"?",
|
||||
"2023-01-01 00:00:00.456",
|
||||
`"2023-01-01 00:00:00.123Z"`,
|
||||
},
|
||||
{
|
||||
"date cast check",
|
||||
schema.SchemaField{Type: schema.FieldTypeDate},
|
||||
1672524000, // 2022-12-31 22:00:00.000Z
|
||||
"+",
|
||||
100,
|
||||
`"2022-12-31 22:00:00.000Z"`,
|
||||
},
|
||||
|
||||
// json
|
||||
{
|
||||
"json with '+' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
10,
|
||||
"+",
|
||||
5,
|
||||
`10`,
|
||||
},
|
||||
{
|
||||
"json with '+' modifier (slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
[]string{"a", "b"},
|
||||
"+",
|
||||
"c",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"json with '-' modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
10,
|
||||
"-",
|
||||
5,
|
||||
`10`,
|
||||
},
|
||||
{
|
||||
"json with '-' modifier (slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
`["a","b"]`,
|
||||
"-",
|
||||
"c",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"json with unknown modifier",
|
||||
schema.SchemaField{Type: schema.FieldTypeJson},
|
||||
`"base"`,
|
||||
"?",
|
||||
`"new"`,
|
||||
`"base"`,
|
||||
},
|
||||
|
||||
// single select
|
||||
{
|
||||
"single select with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"+",
|
||||
"b",
|
||||
`"b"`,
|
||||
},
|
||||
{
|
||||
"single select with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"+",
|
||||
"b",
|
||||
`"b"`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single select with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
[]string{"b", "a", "c", "123"},
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single select with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"?",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
|
||||
// multi select
|
||||
{
|
||||
"multi select with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
nil,
|
||||
"+",
|
||||
"b",
|
||||
`["b"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a"},
|
||||
"+",
|
||||
[]string{"b", "c"},
|
||||
`["a","b","c"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '+' modifier (nonempty base; already existing value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b"},
|
||||
"+",
|
||||
"b",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
nil,
|
||||
"-",
|
||||
[]string{"a"},
|
||||
`[]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
"c",
|
||||
`["a","b","d"]`,
|
||||
},
|
||||
{
|
||||
"multi select with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
[]string{"b", "a", "123"},
|
||||
`["c","d"]`,
|
||||
},
|
||||
{
|
||||
"multi select with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Options: &schema.SelectOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b"},
|
||||
"?",
|
||||
"a",
|
||||
`["a","b"]`,
|
||||
},
|
||||
|
||||
// single relation
|
||||
{
|
||||
"single relation with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"",
|
||||
"+",
|
||||
"b",
|
||||
`"b"`,
|
||||
},
|
||||
{
|
||||
"single relation with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"+",
|
||||
"b",
|
||||
`"b"`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single relation with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"a",
|
||||
"-",
|
||||
[]string{"b", "a", "c", "123"},
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single relation with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"",
|
||||
"?",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
|
||||
// multi relation
|
||||
{
|
||||
"multi relation with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
nil,
|
||||
"+",
|
||||
"b",
|
||||
`["b"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a"},
|
||||
"+",
|
||||
[]string{"b", "c"},
|
||||
`["a","b","c"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '+' modifier (nonempty base; already existing value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a", "b"},
|
||||
"+",
|
||||
"b",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
nil,
|
||||
"-",
|
||||
[]string{"a"},
|
||||
`[]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
"c",
|
||||
`["a","b","d"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
[]string{"b", "a", "123"},
|
||||
`["c","d"]`,
|
||||
},
|
||||
{
|
||||
"multi relation with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation},
|
||||
[]string{"a", "b"},
|
||||
"?",
|
||||
"a",
|
||||
`["a","b"]`,
|
||||
},
|
||||
|
||||
// single file
|
||||
{
|
||||
"single file with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"+",
|
||||
"b",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single file with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"+",
|
||||
"b",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`"a"`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single file with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"a",
|
||||
"-",
|
||||
[]string{"b", "a", "c", "123"},
|
||||
`""`,
|
||||
},
|
||||
{
|
||||
"single file with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 1}},
|
||||
"",
|
||||
"?",
|
||||
"a",
|
||||
`""`,
|
||||
},
|
||||
|
||||
// multi file
|
||||
{
|
||||
"multi file with '+' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
nil,
|
||||
"+",
|
||||
"b",
|
||||
`[]`,
|
||||
},
|
||||
{
|
||||
"multi file with '+' modifier (nonempty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a"},
|
||||
"+",
|
||||
[]string{"b", "c"},
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '+' modifier (nonempty base; already existing value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b"},
|
||||
"+",
|
||||
"b",
|
||||
`["a","b"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (empty base)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
nil,
|
||||
"-",
|
||||
[]string{"a"},
|
||||
`[]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (nonempty base and empty modifier value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
"a",
|
||||
"-",
|
||||
"",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (nonempty base and different value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
"a",
|
||||
"-",
|
||||
"b",
|
||||
`["a"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (nonempty base and matching value)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
"c",
|
||||
`["a","b","d"]`,
|
||||
},
|
||||
{
|
||||
"multi file with '-' modifier (nonempty base and matching value in a slice)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b", "c", "d"},
|
||||
"-",
|
||||
[]string{"b", "a", "123"},
|
||||
`["c","d"]`,
|
||||
},
|
||||
{
|
||||
"multi file with unknown modifier (nonempty)",
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Options: &schema.FileOptions{MaxSelect: 10}},
|
||||
[]string{"a", "b"},
|
||||
"?",
|
||||
"a",
|
||||
`["a","b"]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := s.field.PrepareValueWithModifier(s.baseValue, s.modifier, s.modifierValue)
|
||||
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] %v", s.name, err)
|
||||
}
|
||||
|
||||
if string(encoded) != s.expectJson {
|
||||
t.Fatalf("[%s], Expected %v, got %v", s.name, s.expectJson, string(encoded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type fieldOptionsScenario struct {
|
||||
@@ -1107,6 +1866,18 @@ func TestUrlOptionsValidate(t *testing.T) {
|
||||
checkFieldOptionsScenarios(t, scenarios)
|
||||
}
|
||||
|
||||
func TestEditorOptionsValidate(t *testing.T) {
|
||||
scenarios := []fieldOptionsScenario{
|
||||
{
|
||||
"empty",
|
||||
schema.EditorOptions{},
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
checkFieldOptionsScenarios(t, scenarios)
|
||||
}
|
||||
|
||||
func TestDateOptionsValidate(t *testing.T) {
|
||||
date1 := types.NowDateTime()
|
||||
date2, _ := types.ParseDateTime(date1.Time().AddDate(1, 0, 0))
|
||||
@@ -1306,7 +2077,7 @@ func TestRelationOptionsValidate(t *testing.T) {
|
||||
[]string{"maxSelect"},
|
||||
},
|
||||
{
|
||||
"MaxSelect > 0 && non-empty CollectionId",
|
||||
"MaxSelect > 0 && nonempty CollectionId",
|
||||
schema.RelationOptions{
|
||||
CollectionId: "abc",
|
||||
MaxSelect: types.Pointer(1),
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -43,6 +44,11 @@ type Settings struct {
|
||||
SpotifyAuth AuthProviderConfig `form:"spotifyAuth" json:"spotifyAuth"`
|
||||
KakaoAuth AuthProviderConfig `form:"kakaoAuth" json:"kakaoAuth"`
|
||||
TwitchAuth AuthProviderConfig `form:"twitchAuth" json:"twitchAuth"`
|
||||
StravaAuth AuthProviderConfig `form:"stravaAuth" json:"stravaAuth"`
|
||||
GiteeAuth AuthProviderConfig `form:"giteeAuth" json:"giteeAuth"`
|
||||
LivechatAuth AuthProviderConfig `form:"livechatAuth" json:"livechatAuth"`
|
||||
AuthentikAuth AuthProviderConfig `form:"authentikAuth" json:"authentikAuth"`
|
||||
GiteaAuth AuthProviderConfig `form:"giteaAuth" json:"giteaAuth"`
|
||||
}
|
||||
|
||||
// New creates and returns a new default Settings instance.
|
||||
@@ -123,6 +129,21 @@ func New() *Settings {
|
||||
TwitchAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
StravaAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
GiteeAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
LivechatAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
AuthentikAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
GiteaAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +173,11 @@ func (s *Settings) Validate() error {
|
||||
validation.Field(&s.SpotifyAuth),
|
||||
validation.Field(&s.KakaoAuth),
|
||||
validation.Field(&s.TwitchAuth),
|
||||
validation.Field(&s.StravaAuth),
|
||||
validation.Field(&s.GiteeAuth),
|
||||
validation.Field(&s.LivechatAuth),
|
||||
validation.Field(&s.AuthentikAuth),
|
||||
validation.Field(&s.GiteaAuth),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,6 +232,11 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
&clone.SpotifyAuth.ClientSecret,
|
||||
&clone.KakaoAuth.ClientSecret,
|
||||
&clone.TwitchAuth.ClientSecret,
|
||||
&clone.StravaAuth.ClientSecret,
|
||||
&clone.GiteeAuth.ClientSecret,
|
||||
&clone.LivechatAuth.ClientSecret,
|
||||
&clone.AuthentikAuth.ClientSecret,
|
||||
&clone.GiteaAuth.ClientSecret,
|
||||
}
|
||||
|
||||
// mask all sensitive fields
|
||||
@@ -235,6 +266,11 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
|
||||
auth.NameSpotify: s.SpotifyAuth,
|
||||
auth.NameKakao: s.KakaoAuth,
|
||||
auth.NameTwitch: s.TwitchAuth,
|
||||
auth.NameStrava: s.StravaAuth,
|
||||
auth.NameGitee: s.GiteeAuth,
|
||||
auth.NameLivechat: s.LivechatAuth,
|
||||
auth.NameAuthentik: s.AuthentikAuth,
|
||||
auth.NameGitea: s.GiteaAuth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +298,9 @@ type SmtpConfig struct {
|
||||
Username string `form:"username" json:"username"`
|
||||
Password string `form:"password" json:"password"`
|
||||
|
||||
// SMTP AUTH - PLAIN (default) or LOGIN
|
||||
AuthMethod string `form:"authMethod" json:"authMethod"`
|
||||
|
||||
// Whether to enforce TLS encryption for the mail server connection.
|
||||
//
|
||||
// When set to false StartTLS command is send, leaving the server
|
||||
@@ -272,8 +311,23 @@ type SmtpConfig struct {
|
||||
// Validate makes SmtpConfig validatable by implementing [validation.Validatable] interface.
|
||||
func (c SmtpConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.Host, is.Host, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Port, validation.When(c.Enabled, validation.Required), validation.Min(0)),
|
||||
validation.Field(
|
||||
&c.Host,
|
||||
validation.When(c.Enabled, validation.Required),
|
||||
is.Host,
|
||||
),
|
||||
validation.Field(
|
||||
&c.Port,
|
||||
validation.When(c.Enabled, validation.Required),
|
||||
validation.Min(0),
|
||||
),
|
||||
validation.Field(
|
||||
&c.AuthMethod,
|
||||
// don't require it for backward compatibility
|
||||
// (fallback internally to PLAIN)
|
||||
// validation.When(c.Enabled, validation.Required),
|
||||
validation.In(mailer.SmtpAuthLogin, mailer.SmtpAuthPlain),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
)
|
||||
|
||||
func TestSettingsValidate(t *testing.T) {
|
||||
@@ -47,6 +48,16 @@ func TestSettingsValidate(t *testing.T) {
|
||||
s.KakaoAuth.ClientId = ""
|
||||
s.TwitchAuth.Enabled = true
|
||||
s.TwitchAuth.ClientId = ""
|
||||
s.StravaAuth.Enabled = true
|
||||
s.StravaAuth.ClientId = ""
|
||||
s.GiteeAuth.Enabled = true
|
||||
s.GiteeAuth.ClientId = ""
|
||||
s.LivechatAuth.Enabled = true
|
||||
s.LivechatAuth.ClientId = ""
|
||||
s.AuthentikAuth.Enabled = true
|
||||
s.AuthentikAuth.ClientId = ""
|
||||
s.GiteaAuth.Enabled = true
|
||||
s.GiteaAuth.ClientId = ""
|
||||
|
||||
// check if Validate() is triggering the members validate methods.
|
||||
err := s.Validate()
|
||||
@@ -75,6 +86,11 @@ func TestSettingsValidate(t *testing.T) {
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
}
|
||||
|
||||
errBytes, _ := json.Marshal(err)
|
||||
@@ -123,6 +139,16 @@ func TestSettingsMerge(t *testing.T) {
|
||||
s2.KakaoAuth.ClientId = "kakao_test"
|
||||
s2.TwitchAuth.Enabled = true
|
||||
s2.TwitchAuth.ClientId = "twitch_test"
|
||||
s2.StravaAuth.Enabled = true
|
||||
s2.StravaAuth.ClientId = "strava_test"
|
||||
s2.GiteeAuth.Enabled = true
|
||||
s2.GiteeAuth.ClientId = "gitee_test"
|
||||
s2.LivechatAuth.Enabled = true
|
||||
s2.LivechatAuth.ClientId = "livechat_test"
|
||||
s2.AuthentikAuth.Enabled = true
|
||||
s2.AuthentikAuth.ClientId = "authentik_test"
|
||||
s2.GiteaAuth.Enabled = true
|
||||
s2.GiteaAuth.ClientId = "gitea_test"
|
||||
|
||||
if err := s1.Merge(s2); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -194,6 +220,11 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
s1.SpotifyAuth.ClientSecret = "test123"
|
||||
s1.KakaoAuth.ClientSecret = "test123"
|
||||
s1.TwitchAuth.ClientSecret = "test123"
|
||||
s1.StravaAuth.ClientSecret = "test123"
|
||||
s1.GiteeAuth.ClientSecret = "test123"
|
||||
s1.LivechatAuth.ClientSecret = "test123"
|
||||
s1.AuthentikAuth.ClientSecret = "test123"
|
||||
s1.GiteaAuth.ClientSecret = "test123"
|
||||
|
||||
s2, err := s1.RedactClone()
|
||||
if err != nil {
|
||||
@@ -205,7 +236,7 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"}}`
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","authMethod":"","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"},"microsoftAuth":{"enabled":false,"clientSecret":"******"},"spotifyAuth":{"enabled":false,"clientSecret":"******"},"kakaoAuth":{"enabled":false,"clientSecret":"******"},"twitchAuth":{"enabled":false,"clientSecret":"******"},"stravaAuth":{"enabled":false,"clientSecret":"******"},"giteeAuth":{"enabled":false,"clientSecret":"******"},"livechatAuth":{"enabled":false,"clientSecret":"******"},"authentikAuth":{"enabled":false,"clientSecret":"******"},"giteaAuth":{"enabled":false,"clientSecret":"******"}}`
|
||||
|
||||
if encodedStr := string(encoded); encodedStr != expected {
|
||||
t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr)
|
||||
@@ -226,6 +257,11 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
s.SpotifyAuth.ClientId = "spotify_test"
|
||||
s.KakaoAuth.ClientId = "kakao_test"
|
||||
s.TwitchAuth.ClientId = "twitch_test"
|
||||
s.StravaAuth.ClientId = "strava_test"
|
||||
s.GiteeAuth.ClientId = "gitee_test"
|
||||
s.LivechatAuth.ClientId = "livechat_test"
|
||||
s.AuthentikAuth.ClientId = "authentik_test"
|
||||
s.GiteaAuth.ClientId = "gitea_test"
|
||||
|
||||
result := s.NamedAuthProviderConfigs()
|
||||
|
||||
@@ -246,6 +282,11 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
`"twitter":{"enabled":false,"clientId":"twitter_test"}`,
|
||||
`"kakao":{"enabled":false,"clientId":"kakao_test"}`,
|
||||
`"twitch":{"enabled":false,"clientId":"twitch_test"}`,
|
||||
`"strava":{"enabled":false,"clientId":"strava_test"}`,
|
||||
`"gitee":{"enabled":false,"clientId":"gitee_test"}`,
|
||||
`"livechat":{"enabled":false,"clientId":"livechat_test"}`,
|
||||
`"authentik":{"enabled":false,"clientId":"authentik_test"}`,
|
||||
`"gitea":{"enabled":false,"clientId":"gitea_test"}`,
|
||||
}
|
||||
for _, p := range expectedParts {
|
||||
if !strings.Contains(encodedStr, p) {
|
||||
@@ -327,7 +368,17 @@ func TestSmtpConfigValidate(t *testing.T) {
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
// invalid auth method
|
||||
{
|
||||
settings.SmtpConfig{
|
||||
Enabled: true,
|
||||
Host: "example.com",
|
||||
Port: 100,
|
||||
AuthMethod: "example",
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data (no explicit auth method)
|
||||
{
|
||||
settings.SmtpConfig{
|
||||
Enabled: true,
|
||||
@@ -337,6 +388,16 @@ func TestSmtpConfigValidate(t *testing.T) {
|
||||
},
|
||||
false,
|
||||
},
|
||||
// valid data (explicit auth method - login)
|
||||
{
|
||||
settings.SmtpConfig{
|
||||
Enabled: true,
|
||||
Host: "example.com",
|
||||
Port: 100,
|
||||
AuthMethod: mailer.SmtpAuthLogin,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja_nodejs/console"
|
||||
"github.com/dop251/goja_nodejs/require"
|
||||
@@ -75,7 +77,7 @@ func RegisterMigrations(app core.App, options *MigrationsOptions) error {
|
||||
|
||||
_, err := vm.RunString(string(content))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to run migration %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,8 +99,8 @@ func readDirFiles(dirPath string) (map[string][]byte, error) {
|
||||
result := map[string][]byte{}
|
||||
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
if f.IsDir() || !strings.HasSuffix(f.Name(), ".js") {
|
||||
continue // not a .js file
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
|
||||
if err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user