mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88bb8c406e | ||
|
|
2ab50cc77e | ||
|
|
0025ae80ad | ||
|
|
568c63b29f | ||
|
|
6e9a9489a7 | ||
|
|
e73077e7e7 | ||
|
|
0113fecca9 | ||
|
|
fbc378067d | ||
|
|
e80d64414b | ||
|
|
7ffe9f63a5 | ||
|
|
5dbd9821e8 | ||
|
|
87c6c5b483 | ||
|
|
836fc77ddc | ||
|
|
3ef752c232 | ||
|
|
eb8dd80859 | ||
|
|
d97b5b1f6c | ||
|
|
3885c93d59 | ||
|
|
5713cf422b | ||
|
|
c7c590bace | ||
|
|
fac0d5b899 | ||
|
|
902e07e724 | ||
|
|
dc350f0a3e | ||
|
|
1a3efe96ac | ||
|
|
18f152a0e7 | ||
|
|
9bee3bd0fd | ||
|
|
b31a0ddcd3 | ||
|
|
52dcb4192c | ||
|
|
2bffa0e2bb | ||
|
|
8c0ef15ec2 | ||
|
|
08b1b49e17 | ||
|
|
5d46fb054e | ||
|
|
f9b2842deb | ||
|
|
0426722e99 | ||
|
|
33abc0a802 | ||
|
|
51cbe437e5 | ||
|
|
a2b89d7344 | ||
|
|
6988bf0d7c | ||
|
|
02c975467c | ||
|
|
c7ebd68a92 | ||
|
|
0876413d87 | ||
|
|
c0fe600d14 | ||
|
|
aec4870acd | ||
|
|
bf9a7b1e3d | ||
|
|
46186f84f0 | ||
|
|
4cc797071b | ||
|
|
d9af1475ef | ||
|
|
3e3c316da1 | ||
|
|
0efbbb0d10 | ||
|
|
5d32d22ff5 | ||
|
|
e81d85ae7c | ||
|
|
b930de6ff5 | ||
|
|
628efb7a7f | ||
|
|
201e2ccb05 | ||
|
|
e49025c8e5 | ||
|
|
9f6010d38d | ||
|
|
409bcdaa96 | ||
|
|
2554192c06 | ||
|
|
d135b1e686 | ||
|
|
4c5abd5bd9 | ||
|
|
f3a836eb7c | ||
|
|
fb5070d6ab | ||
|
|
c9f39ba167 | ||
|
|
e29655aba9 | ||
|
|
32de8ed04a | ||
|
|
783a99d7cc | ||
|
|
42a008f828 | ||
|
|
d0d5f3d005 | ||
|
|
b5be7f2d3c | ||
|
|
334a81c837 | ||
|
|
d68786df9c | ||
|
|
70f0e7e141 | ||
|
|
75bba1d0f9 | ||
|
|
81cc276ab9 | ||
|
|
ffc848e92d | ||
|
|
778869c318 | ||
|
|
8e7e0aa5ff | ||
|
|
e718b88391 | ||
|
|
865408a9b4 | ||
|
|
7407bdcaf5 | ||
|
|
953feb47fd | ||
|
|
16539335be | ||
|
|
60a4e3f4d2 | ||
|
|
d61ec398a2 | ||
|
|
9f1946057f | ||
|
|
824c7db388 | ||
|
|
e6573d2549 | ||
|
|
8aa3545efe | ||
|
|
7f7d451b31 | ||
|
|
44007a7c8c | ||
|
|
613af90fac | ||
|
|
64e2931379 | ||
|
|
6e9ecb2b7a | ||
|
|
04a3dc7bb7 | ||
|
|
24c4b63960 | ||
|
|
4ced91f95d | ||
|
|
33613b27b0 | ||
|
|
4e148f7224 | ||
|
|
4a7a639df1 | ||
|
|
ed36add853 | ||
|
|
f799083c4f | ||
|
|
087eaa7ea4 | ||
|
|
803ebb8f40 | ||
|
|
58dab5bf70 | ||
|
|
856cc604a7 | ||
|
|
3912874517 | ||
|
|
5c58703a59 | ||
|
|
c054c3b0a5 | ||
|
|
3da16b2d74 | ||
|
|
dd5afd44d2 | ||
|
|
501c49012e | ||
|
|
48a9b82024 | ||
|
|
4155f50fe1 | ||
|
|
653f2d8b16 | ||
|
|
eb3129d1f3 | ||
|
|
4db497c5e1 | ||
|
|
973916bb48 | ||
|
|
d607695600 | ||
|
|
3f51fb941b | ||
|
|
4d40463d8d | ||
|
|
543b8f5e7a | ||
|
|
8e39998f63 | ||
|
|
e8a554b4ad | ||
|
|
5aa380927a | ||
|
|
aaa3d67659 | ||
|
|
5c41938cb9 | ||
|
|
f1d44c7847 | ||
|
|
c0b7762abd | ||
|
|
26f0df36bc | ||
|
|
6a7f3a21fb | ||
|
|
2e26f61cb7 | ||
|
|
59f98cac99 | ||
|
|
2a1fdc1613 | ||
|
|
f767af0ded | ||
|
|
920e893e11 | ||
|
|
048e534f0d | ||
|
|
9da7a8f72b | ||
|
|
9856c59de0 | ||
|
|
acd72101c6 | ||
|
|
fa343c37e6 | ||
|
|
d3aac570b2 | ||
|
|
b066d9c429 | ||
|
|
5d889d00ad | ||
|
|
49baa69e8d | ||
|
|
923b791675 | ||
|
|
4b489b511d | ||
|
|
9b901fcee8 | ||
|
|
c5bd42a23f | ||
|
|
1bc0b78c83 | ||
|
|
6f3abe7c2d | ||
|
|
33340a6977 | ||
|
|
c101798516 | ||
|
|
65440314ce | ||
|
|
91d4ca5c06 | ||
|
|
a4a228b368 | ||
|
|
0ebe9c4faa | ||
|
|
7da875be14 | ||
|
|
2124b77a2a | ||
|
|
b0e2f67733 | ||
|
|
baf4857bee | ||
|
|
37ff943f67 | ||
|
|
c7322eec66 | ||
|
|
7964669a20 | ||
|
|
b8ea953059 | ||
|
|
f935e2139e | ||
|
|
25dd858c18 | ||
|
|
c70ca97888 | ||
|
|
0798b5ccbb | ||
|
|
e4d637e6e0 | ||
|
|
f4108cb354 | ||
|
|
2317695011 | ||
|
|
8b89bce2a8 | ||
|
|
023428ce8f | ||
|
|
7175992ce4 | ||
|
|
63fb2b6506 | ||
|
|
81f4db855b | ||
|
|
e103d987ce | ||
|
|
18d0b47aeb | ||
|
|
1e9847e924 | ||
|
|
c1c499fc1f | ||
|
|
accc51bf65 | ||
|
|
0aa21c1d04 | ||
|
|
cad16fac6b | ||
|
|
b150a3a98a | ||
|
|
41f1ff2b5f | ||
|
|
1286b59f54 | ||
|
|
9414986ca0 | ||
|
|
6628cdf893 | ||
|
|
e521e5343a | ||
|
|
412341fd78 | ||
|
|
ff3d51ce30 | ||
|
|
dadbca5248 | ||
|
|
1e2a923433 | ||
|
|
9e46d811d6 | ||
|
|
a8b9aee24e | ||
|
|
36df16aaa8 | ||
|
|
44a3e8478d | ||
|
|
73f1b223ff | ||
|
|
c8b29edf9d | ||
|
|
a43f4bf155 | ||
|
|
3074ed3c5e | ||
|
|
26cb1cef37 | ||
|
|
0155e9333f | ||
|
|
118399cc12 | ||
|
|
b062bc6d16 | ||
|
|
a8952cfca2 | ||
|
|
2af9b554ad | ||
|
|
2ef5459698 | ||
|
|
074e977e90 | ||
|
|
07fb052da1 | ||
|
|
6c53352643 | ||
|
|
00372711fd | ||
|
|
d34c8ec048 | ||
|
|
d8c0b11271 | ||
|
|
56f951e5a2 | ||
|
|
ed1917b307 | ||
|
|
47bd4ca11e | ||
|
|
4824701b6c | ||
|
|
39df26ee21 | ||
|
|
e18116d859 | ||
|
|
a8dbca64b2 | ||
|
|
3f25c71780 | ||
|
|
bae5421d62 | ||
|
|
f27d9f1dc9 | ||
|
|
aa52711cde | ||
|
|
07552c2809 | ||
|
|
f6407b903b | ||
|
|
2ebc6aecac | ||
|
|
884a3dec4a | ||
|
|
cb0335c2b6 | ||
|
|
274d499279 | ||
|
|
a3377c992b | ||
|
|
7147633f96 | ||
|
|
c847a6bc88 | ||
|
|
78a35a339f | ||
|
|
c97af83ed1 | ||
|
|
803941705c | ||
|
|
ef0170cf0b | ||
|
|
76b9051011 | ||
|
|
0d720c3c9d | ||
|
|
3e5e02a32c | ||
|
|
9b4200a65c | ||
|
|
cb3936a499 | ||
|
|
e34c25858c | ||
|
|
fd9ae0fd1c | ||
|
|
7ddb0db9a4 | ||
|
|
011f323bcc | ||
|
|
d5d764f83e | ||
|
|
239daf2023 | ||
|
|
2b2dafaf88 | ||
|
|
3098c2dcd8 | ||
|
|
8f32825cff | ||
|
|
20d378cd76 | ||
|
|
8e63e81561 | ||
|
|
efe4ef500b | ||
|
|
09a24e1de6 | ||
|
|
9490a220bc | ||
|
|
4f35fb74c8 | ||
|
|
f533320722 | ||
|
|
e51456bce2 | ||
|
|
35196674e6 | ||
|
|
7481c3f7f4 | ||
|
|
3ec10a9c7d | ||
|
|
9efd68ff4c | ||
|
|
3634fd9c26 | ||
|
|
9747f46c1d | ||
|
|
88a1867169 | ||
|
|
f7c85940c4 | ||
|
|
545a4eb47c | ||
|
|
5f660d8671 | ||
|
|
85c31ba068 | ||
|
|
6edb344ab3 | ||
|
|
c91d889da3 | ||
|
|
6a4e04533c | ||
|
|
e8f49c31e4 | ||
|
|
1abd6ca5c0 | ||
|
|
6f55695fa1 | ||
|
|
7c01441392 | ||
|
|
c6695b6a75 | ||
|
|
b0276ad605 | ||
|
|
376627b4cc | ||
|
|
0661de0604 | ||
|
|
f80de83234 | ||
|
|
2d828ef9eb | ||
|
|
77ac44a49a | ||
|
|
fb2763a697 | ||
|
|
5835a51111 |
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '>=1.23.0'
|
||||
go-version: '>=1.23.9'
|
||||
|
||||
# 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.
|
||||
|
||||
+413
@@ -1,3 +1,416 @@
|
||||
## v0.28.2
|
||||
|
||||
- Loaded latin-ext charset for the default text fonts ([#6869](https://github.com/pocketbase/pocketbase/issues/6869)).
|
||||
|
||||
- Updated view query CAST regex to properly recognize multiline expressions ([#6860](https://github.com/pocketbase/pocketbase/pull/6860); thanks @azat-ismagilov).
|
||||
|
||||
- Updated Go and npm dependencies.
|
||||
|
||||
|
||||
## v0.28.1
|
||||
|
||||
- Fixed `json_each`/`json_array_length` normalizations to properly check for array values ([#6835](https://github.com/pocketbase/pocketbase/issues/6835)).
|
||||
|
||||
|
||||
## v0.28.0
|
||||
|
||||
- Write the default response body of `*Request` hooks that are wrapped in a transaction after the related transaction completes to allow propagating the transaction error ([#6462](https://github.com/pocketbase/pocketbase/discussions/6462#discussioncomment-12207818)).
|
||||
|
||||
- Updated `app.DB()` to automatically routes raw write SQL statements to the nonconcurrent db pool ([#6689](https://github.com/pocketbase/pocketbase/discussions/6689)).
|
||||
_For the rare cases when it is needed users still have the option to explicitly target the specific pool they want using `app.ConcurrentDB()`/`app.NonconcurrentDB()`._
|
||||
|
||||
- ⚠️ Changed the default `json` field max size to 1MB.
|
||||
_Users still have the option to adjust the default limit from the collection field options but keep in mind that storing large strings/blobs in the database is known to cause performance issues and should be avoided when possible._
|
||||
|
||||
- ⚠️ Soft-deprecated and replaced `filesystem.System.GetFile(fileKey)` with `filesystem.System.GetReader(fileKey)` to avoid the confusion with `filesystem.File`.
|
||||
_The old method will still continue to work for at least until v0.29.0 but you'll get a console warning to replace it with `GetReader`._
|
||||
|
||||
- Added new `filesystem.System.GetReuploadableFile(fileKey, preserveName)` method to return an existing blob as a `*filesystem.File` value ([#6792](https://github.com/pocketbase/pocketbase/discussions/6792)).
|
||||
_This method could be useful in case you want to clone an existing Record file and assign it to a new Record (e.g. in a Record duplicate action)._
|
||||
|
||||
- Other minor improvements (updated the GitHub release min Go version to 1.23.9, updated npm and Go deps, etc.)
|
||||
|
||||
|
||||
## v0.27.2
|
||||
|
||||
- Added workers pool when cascade deleting record files to minimize _"thread exhaustion"_ errors ([#6780](https://github.com/pocketbase/pocketbase/discussions/6780)).
|
||||
|
||||
- Updated the `:excerpt` fields modifier to properly account for multibyte characters ([#6778](https://github.com/pocketbase/pocketbase/issues/6778)).
|
||||
|
||||
- Use `rowid` as count column for non-view collections to minimize the need of having the id field in a covering index ([#6739](https://github.com/pocketbase/pocketbase/discussions/6739))
|
||||
|
||||
|
||||
## v0.27.1
|
||||
|
||||
- Updated example `geoPoint` API preview body data.
|
||||
|
||||
- Added JSVM `new GeoPointField({ ... })` constructor.
|
||||
|
||||
- Added _partial_ WebP thumbs generation (_the thumbs will be stored as PNG_; [#6744](https://github.com/pocketbase/pocketbase/pull/6744)).
|
||||
|
||||
- Updated npm dev dependencies.
|
||||
|
||||
|
||||
## v0.27.0
|
||||
|
||||
- ⚠️ Moved the Create and Manage API rule checks out of the `OnRecordCreateRequest` hook finalizer, **aka. now all CRUD API rules are checked BEFORE triggering their corresponding `*Request` hook**.
|
||||
This was done to minimize the confusion regarding the firing order of the request operations, making it more predictable and consistent with the other record List/View/Update/Delete request actions.
|
||||
It could be a minor breaking change if you are relying on the old behavior and have a Go `tests.ApiScenario` that is testing a Create API rule failure and expect `OnRecordCreateRequest` to be fired. In that case for example you may have to update your test scenario like:
|
||||
```go
|
||||
tests.ApiScenario{
|
||||
Name: "Example test that checks a Create API rule failure"
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/example/records",
|
||||
...
|
||||
// old:
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
// new:
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
}
|
||||
```
|
||||
If you are having difficulties adjusting your code, feel free to open a [Q&A discussion](https://github.com/pocketbase/pocketbase/discussions) with the failing/problematic code sample.
|
||||
|
||||
- Added [new `geoPoint` field](https://pocketbase.io/docs/collections/#geopoint) for storing `{"lon":x,"lat":y}` geographic coordinates.
|
||||
In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](htts://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points.
|
||||
|
||||
- Updated the `select` field UI to accommodate better larger lists and RTL languages ([#4674](https://github.com/pocketbase/pocketbase/issues/4674)).
|
||||
|
||||
- Updated the mail attachments auto MIME type detection to use `gabriel-vasile/mimetype` for consistency and broader sniffing signatures support.
|
||||
|
||||
- Forced `text/javascript` Content-Type when serving `.js`/`.mjs` collection uploaded files with the `/api/files/...` endpoint ([#6597](https://github.com/pocketbase/pocketbase/issues/6597)).
|
||||
|
||||
- Added second optional JSVM `DateTime` constructor argument for specifying a default timezone as TZ identifier when parsing the date string as alternative to a fixed offset in order to better handle daylight saving time nuances ([#6688](https://github.com/pocketbase/pocketbase/discussions/6688)):
|
||||
```js
|
||||
// the same as with CET offset: new DateTime("2025-10-26 03:00:00 +01:00")
|
||||
new DateTime("2025-10-26 03:00:00", "Europe/Amsterdam") // 2025-10-26 02:00:00.000Z
|
||||
|
||||
// the same as with CEST offset: new DateTime("2025-10-26 01:00:00 +02:00")
|
||||
new DateTime("2025-10-26 01:00:00", "Europe/Amsterdam") // 2025-10-25 23:00:00.000Z
|
||||
```
|
||||
|
||||
- Soft-deprecated the `$http.send`'s `result.raw` field in favor of `result.body` that contains the response body as plain bytes slice to avoid the discrepancies between Go and the JSVM when casting binary data to string.
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.37.0.
|
||||
|
||||
- Other minor improvements (_removed the superuser fields from the auth record create/update body examples, allowed programmatically updating the auth record password from the create/update hooks, fixed collections import error response, etc._).
|
||||
|
||||
|
||||
## v0.26.6
|
||||
|
||||
- Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.36.3.
|
||||
|
||||
|
||||
## v0.26.5
|
||||
|
||||
- Fixed canonical URI parts escaping when generating the S3 request signature ([#6654](https://github.com/pocketbase/pocketbase/issues/6654)).
|
||||
|
||||
|
||||
## v0.26.4
|
||||
|
||||
- Fixed `RecordErrorEvent.Error` and `CollectionErrorEvent.Error` sync with `ModelErrorEvent.Error` ([#6639](https://github.com/pocketbase/pocketbase/issues/6639)).
|
||||
|
||||
- Fixed logs details copy to clipboard action.
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.36.2.
|
||||
|
||||
|
||||
## v0.26.3
|
||||
|
||||
- Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
|
||||
|
||||
|
||||
## v0.26.2
|
||||
|
||||
- Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
|
||||
|
||||
|
||||
## v0.26.1
|
||||
|
||||
- Removed the wrapping of `io.EOF` error when reading files since currently `io.ReadAll` doesn't check for wrapped errors ([#6600](https://github.com/pocketbase/pocketbase/issues/6600)).
|
||||
|
||||
|
||||
## v0.26.0
|
||||
|
||||
- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation ([#6562](https://github.com/pocketbase/pocketbase/discussions/6562)).
|
||||
As a side-effect of the dependency removal, the binary size has been reduced with ~10MB and builds ~30% faster.
|
||||
_Although the change is expected to be backward-compatible, I'd recommend to test first locally the new version with your S3 provider (if you use S3 for files storage and backups)._
|
||||
|
||||
- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth.
|
||||
|
||||
- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)).
|
||||
This could be useful in case you want to target the auth method as part of the MFA and Auth API rules.
|
||||
For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule.
|
||||
|
||||
- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner.
|
||||
|
||||
- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_used mostly to assist with the unit testing_).
|
||||
|
||||
- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)).
|
||||
|
||||
- Added log warning for `async` marked JSVM handlers and resolve when possible the returned `Promise` as fallback ([#6476](https://github.com/pocketbase/pocketbase/issues/6476)).
|
||||
|
||||
- Allowed calling `cronAdd`, `cronRemove` from inside other JSVM handlers ([#6481](https://github.com/pocketbase/pocketbase/discussions/6481)).
|
||||
|
||||
- Bumped the default request read and write timeouts to 5mins (_old 3mins_) to accommodate slower internet connections and larger file uploads/downloads.
|
||||
_If you want to change them you can modify the `OnServe` hook's `ServeEvent.ReadTimeout/WriteTimeout` fields as shown in [#6550](https://github.com/pocketbase/pocketbase/discussions/6550#discussioncomment-12364515)._
|
||||
|
||||
- Normalized the `@request.auth.*` and `@request.body.*` back relations resolver to always return `null` when the relation field is pointing to a different collection ([#6590](https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581)).
|
||||
|
||||
- Other minor improvements (_fixed query dev log nested parameters output, reintroduced `DynamicModel` object/array props reflect types caching, updated Go and npm deps, etc._)
|
||||
|
||||
|
||||
## v0.25.9
|
||||
|
||||
- Fixed `DynamicModel` object/array props reflect type caching ([#6563](https://github.com/pocketbase/pocketbase/discussions/6563)).
|
||||
|
||||
|
||||
## v0.25.8
|
||||
|
||||
- Added a default leeway of 5 minutes for the Apple/OIDC `id_token` timestamp claims check to account for clock-skew ([#6529](https://github.com/pocketbase/pocketbase/issues/6529)).
|
||||
It can be further customized if needed with the `PB_ID_TOKEN_LEEWAY` env variable (_the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute_).
|
||||
|
||||
|
||||
## v0.25.7
|
||||
|
||||
- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)).
|
||||
|
||||
|
||||
## v0.25.6
|
||||
|
||||
- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)).
|
||||
|
||||
- Updated npm dependencies.
|
||||
|
||||
|
||||
## v0.25.5
|
||||
|
||||
- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
|
||||
|
||||
- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
|
||||
|
||||
|
||||
## v0.25.4
|
||||
|
||||
- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes.
|
||||
|
||||
While we try to enforce `when_required` by default, it is not enough to disable the new AWS SDK integrity checks entirely and some providers will require additional manual adjustments to make them compatible with the latest AWS SDK (e.g. removing the `x-aws-checksum-*` headers, unsetting the checksums calculation or reinstantiating the old MD5 checksums for some of the required operations, etc.) which as a result leads to a configuration mess that I'm not sure it would be a good idea to introduce.
|
||||
|
||||
This unfornuatelly is not a PocketBase or Go specific issue and the official AWS SDKs for other languages are in the same situation (even the latest aws-cli).
|
||||
|
||||
For those of you that extend PocketBase with Go: if your S3 vendor doesn't support the [AWS Data integrity checks](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html) and you are updating with `go get -u`, then make sure that the `aws-sdk-go-v2` dependencies in your `go.mod` are the same as in the repo:
|
||||
```
|
||||
// go.mod
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||
|
||||
// after that run
|
||||
go clean -modcache && go mod tidy
|
||||
```
|
||||
_The versions pinning is temporary until the non-AWS S3 vendors patch their implementation or until I manage to find time to remove/replace the `aws-sdk-go-v2` dependency (I'll consider prioritizing it for the v0.26 or v0.27 release)._
|
||||
|
||||
|
||||
## v0.25.3
|
||||
|
||||
- Added a temporary exception for Backblaze S3 endpoints to exclude the new `aws-sdk-go-v2` checksum headers ([#6440](https://github.com/pocketbase/pocketbase/discussions/6440)).
|
||||
|
||||
|
||||
## v0.25.2
|
||||
|
||||
- Fixed realtime delete event not being fired for `RecordProxy`-ies and added basic realtime record resolve automated tests ([#6433](https://github.com/pocketbase/pocketbase/issues/6433)).
|
||||
|
||||
|
||||
## v0.25.1
|
||||
|
||||
- Fixed the batch API Preview success sample response.
|
||||
|
||||
- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build.
|
||||
|
||||
|
||||
## v0.25.0
|
||||
|
||||
- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions.
|
||||
_For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_
|
||||
- `/v3/userinfo` auth response changes:
|
||||
```
|
||||
meta.rawUser.id => meta.rawUser.sub
|
||||
meta.rawUser.verified_email => meta.rawUser.email_verified
|
||||
```
|
||||
- `/v2/auth` query parameters changes:
|
||||
If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**.
|
||||
|
||||
- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-)
|
||||
|
||||
- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
|
||||
|
||||
- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)).
|
||||
_You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._
|
||||
|
||||
- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
|
||||
|
||||
- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper.
|
||||
|
||||
- Upgraded to `golang-jwt/jwt/v5`.
|
||||
|
||||
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
|
||||
|
||||
- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
|
||||
|
||||
- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved.
|
||||
|
||||
- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._)
|
||||
|
||||
|
||||
## v0.24.4
|
||||
|
||||
- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
|
||||
|
||||
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
|
||||
|
||||
|
||||
## v0.24.3
|
||||
|
||||
- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13).
|
||||
|
||||
- Reload the created/edited records data in the RecordsPicker UI.
|
||||
|
||||
- Updated Go dependencies.
|
||||
|
||||
|
||||
## v0.24.2
|
||||
|
||||
- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
|
||||
|
||||
|
||||
## v0.24.1
|
||||
|
||||
- Added missing time macros in the UI autocomplete.
|
||||
|
||||
- Fixed JSVM types for structs and functions with multiple generic parameters.
|
||||
|
||||
|
||||
## v0.24.0
|
||||
|
||||
- ⚠️ Removed the "dry submit" when executing the collections Create API rule
|
||||
(you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
|
||||
For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
|
||||
With this change the "multi-match" operators are also normalized in case the targeted collection doesn't have any records
|
||||
(_or in other words, `@collection.example.someField != "test"` will result to `true` if `example` collection has no records because it satisfies the condition that all available "example" records mustn't have `someField` equal to "test"_).
|
||||
As a side-effect of all of the above minor changes, the record create API performance has been also improved ~4x times in high concurrent scenarios (500 concurrent clients inserting total of 50k records - [old (58.409064001s)](https://github.com/pocketbase/benchmarks/blob/54140be5fb0102f90034e1370c7f168fbcf0ddf0/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestdatapublicisset--true) vs [new (13.580098262s)](https://github.com/pocketbase/benchmarks/blob/7df0466ac9bd62fe0a1056270d20ef82012f0234/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestbodypublicisset--true)).
|
||||
|
||||
- ⚠️ Changed the type definition of `store.Store[T any]` to `store.Store[K comparable, T any]` to allow support for custom store key types.
|
||||
For most users it should be non-breaking change, BUT if you are calling `store.New[any](nil)` instances you'll have to specify the store key type, aka. `store.New[string, any](nil)`.
|
||||
|
||||
- Added `@yesterday` and `@tomorrow` datetime filter macros.
|
||||
|
||||
- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`).
|
||||
|
||||
- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_).
|
||||
|
||||
- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types.
|
||||
|
||||
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
|
||||
|
||||
- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows.
|
||||
_The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._
|
||||
|
||||
- Added option to list and trigger the registered app level cron jobs via the Web API and UI.
|
||||
|
||||
- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1).
|
||||
|
||||
- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`.
|
||||
|
||||
- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections.
|
||||
|
||||
- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc.
|
||||
|
||||
- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)).
|
||||
|
||||
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
|
||||
|
||||
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule.
|
||||
|
||||
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
|
||||
|
||||
|
||||
## v0.23.12
|
||||
|
||||
- Added warning logs in case of mismatched `modernc.org/sqlite` and `modernc.org/libc` versions ([#6136](https://github.com/pocketbase/pocketbase/issues/6136#issuecomment-2556336962)).
|
||||
|
||||
- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)).
|
||||
|
||||
|
||||
## v0.23.11
|
||||
|
||||
- Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
|
||||
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
|
||||
|
||||
|
||||
## v0.23.10
|
||||
|
||||
- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
|
||||
|
||||
- Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
|
||||
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
|
||||
|
||||
|
||||
## v0.23.9
|
||||
|
||||
- Replaced `strconv.Itoa` with `strconv.FormatInt` to avoid the int64->int conversion overflow on 32-bit platforms ([#6132](https://github.com/pocketbase/pocketbase/discussions/6132)).
|
||||
|
||||
|
||||
## v0.23.8
|
||||
|
||||
- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
|
||||
|
||||
- Other minor improvements (updated Go and npm deps, added extra escaping for the default mail record params in case the emails are stored as html files, fixed code comment typos, etc.).
|
||||
|
||||
|
||||
## v0.23.7
|
||||
|
||||
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
|
||||
|
||||
|
||||
## v0.23.6
|
||||
|
||||
- Fixed `$filesystem.fileFromURL` documentation and generated type ([#6058](https://github.com/pocketbase/pocketbase/issues/6058)).
|
||||
|
||||
- Fixed `X-Forwarded-For` header typo in the suggested UI "Common trusted proxy" headers ([#6063](https://github.com/pocketbase/pocketbase/pull/6063)).
|
||||
|
||||
- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)).
|
||||
|
||||
- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.).
|
||||
|
||||
|
||||
## v0.23.5
|
||||
|
||||
- Fixed UI logs search not properly accounting for the "Include requests by superusers" toggle when multiple search expressions are used.
|
||||
|
||||
- Fixed `text` field max validation error message ([#6053](https://github.com/pocketbase/pocketbase/issues/6053)).
|
||||
|
||||
- Other minor fixes (comment typos, JSVM types update).
|
||||
|
||||
- Updated Go deps and the min Go release GitHub action version to 1.23.4.
|
||||
|
||||
|
||||
## v0.23.4
|
||||
|
||||
- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)).
|
||||
|
||||
- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)).
|
||||
|
||||
- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)).
|
||||
|
||||
- Updated Go and npm dependencies.
|
||||
|
||||
|
||||
## v0.23.3
|
||||
|
||||
- Fixed Gzip middleware not applying when serving static files.
|
||||
|
||||
+42
-1
@@ -2,6 +2,47 @@
|
||||
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
||||
---
|
||||
|
||||
## v0.22.34
|
||||
|
||||
- (_Backported from v0.26.6_) Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
|
||||
|
||||
|
||||
## v0.22.33
|
||||
|
||||
- (_Backported from v0.26.3_) Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
|
||||
|
||||
|
||||
## v0.22.32
|
||||
|
||||
- (_Backported from v0.26.2_) Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
|
||||
|
||||
|
||||
## v0.22.31
|
||||
|
||||
- (_Backported from v0.25.5_) Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
|
||||
|
||||
|
||||
## v0.22.30
|
||||
|
||||
- (_Backported from v0.24.4_) Fixed fields extraction for view queries with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
|
||||
|
||||
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
|
||||
|
||||
|
||||
## v0.22.29
|
||||
|
||||
- (_Backported from v0.23.11_) Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
|
||||
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
|
||||
|
||||
|
||||
## v0.22.28
|
||||
|
||||
- (_Backported from v0.23.10_) Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
|
||||
|
||||
- (_Backported from v0.23.10_) Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
|
||||
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
|
||||
|
||||
|
||||
## v0.22.27
|
||||
|
||||
- Instead of unregistering the realtime clients, we now just unset their auth state on delete of the related auth record so that the clients can receive the `delete` event ([#5898](https://github.com/pocketbase/pocketbase/issues/5898)).
|
||||
@@ -1143,7 +1184,7 @@
|
||||
|
||||
- Updated the schema fields Admin UI for "tidier" fields visualization.
|
||||
|
||||
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
|
||||
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forwarded-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
|
||||
|
||||
- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
|
||||
|
||||
|
||||
@@ -25,10 +25,13 @@
|
||||
|
||||
## API SDK clients
|
||||
|
||||
The easiest way to interact with the API is to use one of the official SDK clients:
|
||||
The easiest way to interact with the PocketBase Web APIs is to use one of the official SDK clients:
|
||||
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_Browser, Node.js, React Native_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_Web, Mobile, Desktop, CLI_)
|
||||
|
||||
You could also check the recommendations in https://pocketbase.io/docs/how-to-use/.
|
||||
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -54,7 +57,6 @@ Here is a minimal example:
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -65,7 +67,7 @@ Here is a minimal example:
|
||||
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
// registers new "GET /hello" route
|
||||
se.Router.Get("/hello", func(re *core.RequestEvent) error {
|
||||
se.Router.GET("/hello", func(re *core.RequestEvent) error {
|
||||
return re.String(200, "Hello world!")
|
||||
})
|
||||
|
||||
@@ -84,19 +86,13 @@ Here is a minimal example:
|
||||
|
||||
4. To build a statically linked executable, you can run `CGO_ENABLED=0 go build` and then start the created executable with `./myapp serve`.
|
||||
|
||||
> [!NOTE]
|
||||
> PocketBase embeds SQLite, but doesn't require CGO.
|
||||
>
|
||||
> 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.
|
||||
|
||||
_For more details please refer to [Extend with Go](https://pocketbase.io/docs/go-overview/)._
|
||||
|
||||
### Building and running the repo main.go example
|
||||
|
||||
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.21+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.23+](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`
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ func bindBackupApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
sub := rg.Group("/backups")
|
||||
sub.GET("", backupsList).Bind(RequireSuperuserAuth())
|
||||
sub.POST("", backupCreate).Bind(RequireSuperuserAuth())
|
||||
sub.POST("/upload", backupUpload).Bind(RequireSuperuserAuth())
|
||||
sub.POST("/upload", backupUpload).Bind(BodyLimit(0), RequireSuperuserAuth())
|
||||
sub.GET("/{key}", backupDownload) // relies on superuser file token
|
||||
sub.DELETE("/{key}", backupDelete).Bind(RequireSuperuserAuth())
|
||||
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuth())
|
||||
|
||||
+16
-2
@@ -10,9 +10,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"gocloud.dev/blob"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
|
||||
)
|
||||
|
||||
func TestBackupsList(t *testing.T) {
|
||||
@@ -346,6 +347,19 @@ func TestBackupUpload(t *testing.T) {
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "ensure that the default body limit is skipped",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups/upload",
|
||||
Body: bytes.NewBuffer(make([]byte, apis.DefaultMaxBodySize+100)),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400, // it doesn't matter as long as it is not 413
|
||||
ExpectedContent: []string{`"data":{`},
|
||||
NotExpectedContent: []string{"entity too large"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -476,7 +490,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
bindRecordAuthApi(app, apiGroup)
|
||||
bindLogsApi(app, apiGroup)
|
||||
bindBackupApi(app, apiGroup)
|
||||
bindCronApi(app, apiGroup)
|
||||
bindFileApi(app, apiGroup)
|
||||
bindBatchApi(app, apiGroup)
|
||||
bindRealtimeApi(app, apiGroup)
|
||||
|
||||
+5
-5
@@ -49,7 +49,7 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
|
||||
params["id"] = id // required for the path value
|
||||
ir.Method = "PATCH"
|
||||
ir.URL = "/api/collections/" + params["collection"] + "/records/" + id + params["query"]
|
||||
return recordUpdate(next)
|
||||
return recordUpdate(false, next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +57,16 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
|
||||
// ---
|
||||
ir.Method = "POST"
|
||||
ir.URL = "/api/collections/" + params["collection"] + "/records" + params["query"]
|
||||
return recordCreate(next)
|
||||
return recordCreate(false, next)
|
||||
},
|
||||
regexp.MustCompile(`^POST /api/collections/(?P<collection>[^\/\?]+)/records(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
|
||||
return recordCreate(next)
|
||||
return recordCreate(false, next)
|
||||
},
|
||||
regexp.MustCompile(`^PATCH /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
|
||||
return recordUpdate(next)
|
||||
return recordUpdate(false, next)
|
||||
},
|
||||
regexp.MustCompile(`^DELETE /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
|
||||
return recordDelete(next)
|
||||
return recordDelete(false, next)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -45,7 +45,9 @@ func collectionsList(e *core.RequestEvent) error {
|
||||
event.Result = result
|
||||
|
||||
return event.App.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListRequestEvent) error {
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +62,9 @@ func collectionView(e *core.RequestEvent) error {
|
||||
event.Collection = collection
|
||||
|
||||
return e.App.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,7 +102,9 @@ func collectionCreate(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Failed to create collection. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,7 +134,9 @@ func collectionUpdate(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Failed to update collection. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,7 +167,9 @@ func collectionDelete(e *core.RequestEvent) error {
|
||||
return e.BadRequestError(msg, err)
|
||||
}
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,14 @@ func collectionsImport(e *core.RequestEvent) error {
|
||||
return event.App.OnCollectionsImportRequest().Trigger(event, func(e *core.CollectionsImportRequestEvent) error {
|
||||
importErr := e.App.ImportCollections(e.CollectionsData, form.DeleteMissing)
|
||||
if importErr == nil {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
// validation failure
|
||||
var validationErrors validation.Errors
|
||||
if errors.As(err, &validationErrors) {
|
||||
if errors.As(importErr, &validationErrors) {
|
||||
return e.BadRequestError("Failed to import collections.", validationErrors)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -88,6 +89,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
`import2`,
|
||||
`fields`,
|
||||
},
|
||||
NotExpectedContent: []string{"Raw error:"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionsImportRequest": 1,
|
||||
@@ -109,6 +111,71 @@ func TestCollectionsImport(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser + non-validator failure",
|
||||
Method: http.MethodPut,
|
||||
URL: "/api/collections/import",
|
||||
Body: strings.NewReader(`{
|
||||
"collections":[
|
||||
{
|
||||
"name": "import1",
|
||||
"fields": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "import2",
|
||||
"fields": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test on import2 (test)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"collections":{"code":"validation_collections_import_failure"`,
|
||||
`Raw error:`,
|
||||
`custom_error`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionsImportRequest": 1,
|
||||
"OnCollectionCreate": 1,
|
||||
"OnCollectionAfterCreateError": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelAfterCreateError": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionCreate().BindFunc(func(e *core.CollectionEvent) error {
|
||||
return errors.New("custom_error")
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*core.Collection{}
|
||||
if err := app.CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := totalCollections
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser + successful collections create",
|
||||
Method: http.MethodPut,
|
||||
@@ -249,6 +316,51 @@ func TestCollectionsImport(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionsImportRequest tx body write check",
|
||||
Method: http.MethodPut,
|
||||
URL: "/api/collections/import",
|
||||
Body: strings.NewReader(`{
|
||||
"deleteMissing": true,
|
||||
"collections":[
|
||||
{"name": "test123"},
|
||||
{
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo1",
|
||||
"fields":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
"name":"title",
|
||||
"type":"text",
|
||||
"required":true
|
||||
}
|
||||
],
|
||||
"indexes": []
|
||||
}
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionsImportRequest().BindFunc(func(e *core.CollectionsImportRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnCollectionsImportRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+94
-22
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -130,6 +129,32 @@ func TestCollectionsList(t *testing.T) {
|
||||
"OnCollectionsListRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionsListRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionsListRequest().BindFunc(func(e *core.CollectionsListRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnCollectionsListRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -205,6 +230,32 @@ func TestCollectionView(t *testing.T) {
|
||||
"OnCollectionViewRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionViewRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/wsmn24bux7wo113",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionViewRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnCollectionViewRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -361,7 +412,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterDeleteSuccessRequest error response",
|
||||
Name: "OnCollectionDeleteRequest tx body write check",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/view2",
|
||||
Headers: map[string]string{
|
||||
@@ -369,15 +420,22 @@ func TestCollectionDelete(t *testing.T) {
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionDeleteRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionDeleteRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnCollectionDeleteRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -656,7 +714,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionCreateRequest error response",
|
||||
Name: "OnCollectionCreateRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections",
|
||||
Body: strings.NewReader(`{"name":"new","type":"base"}`),
|
||||
@@ -665,15 +723,22 @@ func TestCollectionCreate(t *testing.T) {
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionCreateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnCollectionCreateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// view
|
||||
@@ -978,7 +1043,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterUpdateSuccessRequest error response",
|
||||
Name: "OnCollectionUpdateRequest tx body write check",
|
||||
Method: http.MethodPatch,
|
||||
URL: "/api/collections/demo1",
|
||||
Body: strings.NewReader(`{}`),
|
||||
@@ -987,15 +1052,22 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionUpdateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionUpdateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnCollectionUpdateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser + invalid data (eg. existing name)",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/cron"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
)
|
||||
|
||||
// bindCronApi registers the crons api endpoint.
|
||||
func bindCronApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
subGroup := rg.Group("/crons").Bind(RequireSuperuserAuth())
|
||||
subGroup.GET("", cronsList)
|
||||
subGroup.POST("/{id}", cronRun)
|
||||
}
|
||||
|
||||
func cronsList(e *core.RequestEvent) error {
|
||||
jobs := e.App.Cron().Jobs()
|
||||
|
||||
slices.SortStableFunc(jobs, func(a, b *cron.Job) int {
|
||||
if strings.HasPrefix(a.Id(), "__pb") {
|
||||
return 1
|
||||
}
|
||||
if strings.HasPrefix(b.Id(), "__pb") {
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(a.Id(), b.Id())
|
||||
})
|
||||
|
||||
return e.JSON(http.StatusOK, jobs)
|
||||
}
|
||||
|
||||
func cronRun(e *core.RequestEvent) error {
|
||||
cronId := e.Request.PathValue("id")
|
||||
|
||||
var foundJob *cron.Job
|
||||
|
||||
jobs := e.App.Cron().Jobs()
|
||||
for _, j := range jobs {
|
||||
if j.Id() == cronId {
|
||||
foundJob = j
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundJob == nil {
|
||||
return e.NotFoundError("Missing or invalid cron job", nil)
|
||||
}
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
foundJob.Run()
|
||||
})
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func TestCronsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/crons",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as regular user",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/crons",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser (empty list)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/crons",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Cron().RemoveAll()
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{`[]`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/crons",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`{"id":"__pbLogsCleanup__","expression":"0 */6 * * *"}`,
|
||||
`{"id":"__pbDBOptimize__","expression":"0 0 * * *"}`,
|
||||
`{"id":"__pbMFACleanup__","expression":"0 * * * *"}`,
|
||||
`{"id":"__pbOTPCleanup__","expression":"0 * * * *"}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronsRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
beforeTestFunc := func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Cron().Add("test", "* * * * *", func() {
|
||||
app.Store().Set("testJobCalls", cast.ToInt(app.Store().Get("testJobCalls"))+1)
|
||||
})
|
||||
}
|
||||
|
||||
expectedCalls := func(expected int) func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
return func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
total := cast.ToInt(app.Store().Get("testJobCalls"))
|
||||
if total != expected {
|
||||
t.Fatalf("Expected total testJobCalls %d, got %d", expected, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/test",
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as regular user",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser (missing job)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/missing",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser (existing job)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(1),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -19,7 +19,7 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp"}
|
||||
var defaultThumbSizes = []string{"100x100"}
|
||||
|
||||
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
@@ -75,8 +75,8 @@ func (api *fileApi) fileToken(e *core.RequestEvent) error {
|
||||
event.Record = e.Auth
|
||||
|
||||
return e.App.OnFileTokenRequest().Trigger(event, func(e *core.FileTokenRequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"token": e.Token,
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"token": e.Token})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -192,7 +192,10 @@ func (api *fileApi) download(e *core.RequestEvent) error {
|
||||
e.Response.Header().Del("X-Frame-Options")
|
||||
|
||||
return e.App.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadRequestEvent) error {
|
||||
if err := fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName); err != nil {
|
||||
err = execAfterSuccessTx(true, e.App, func() error {
|
||||
return fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName)
|
||||
})
|
||||
if err != nil {
|
||||
return e.NotFoundError("", err)
|
||||
}
|
||||
|
||||
|
||||
+30
-36
@@ -5,37 +5,31 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
)
|
||||
|
||||
// @todo consider combining with the installer specific hooks after refactoring cmd
|
||||
func loadInstaller(app core.App, dashboardURL string) error {
|
||||
if !needInstallerSuperuser(app) {
|
||||
return nil
|
||||
}
|
||||
|
||||
installerRecord, err := findOrCreateInstallerSuperuser(app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := installerRecord.NewStaticAuthToken(30 * time.Minute)
|
||||
// DefaultInstallerFunc is the default PocketBase installer function.
|
||||
//
|
||||
// It will attempt to open a link in the browser (with a short-lived auth
|
||||
// token for the systemSuperuser) to the installer UI so that users can
|
||||
// create their own custom superuser record.
|
||||
//
|
||||
// See https://github.com/pocketbase/pocketbase/discussions/5814.
|
||||
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
|
||||
token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// launch url (ignore errors and always print a help text as fallback)
|
||||
url := fmt.Sprintf("%s/#/pbinstal/%s", strings.TrimRight(dashboardURL, "/"), token)
|
||||
_ = launchURL(url)
|
||||
url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token)
|
||||
_ = osutils.LaunchURL(url)
|
||||
color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
|
||||
color.New(color.Bold).Add(color.FgCyan).Println(url)
|
||||
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", os.Args[0])
|
||||
@@ -43,6 +37,23 @@ func loadInstaller(app core.App, dashboardURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadInstaller(
|
||||
app core.App,
|
||||
baseURL string,
|
||||
installerFunc func(app core.App, systemSuperuser *core.Record, baseURL string) error,
|
||||
) error {
|
||||
if installerFunc == nil || !needInstallerSuperuser(app) {
|
||||
return nil
|
||||
}
|
||||
|
||||
superuser, err := findOrCreateInstallerSuperuser(app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return installerFunc(app, superuser, baseURL)
|
||||
}
|
||||
|
||||
func needInstallerSuperuser(app core.App) bool {
|
||||
total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{
|
||||
"email": core.DefaultInstallerEmail,
|
||||
@@ -65,7 +76,7 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
|
||||
|
||||
record = core.NewRecord(col)
|
||||
record.SetEmail(core.DefaultInstallerEmail)
|
||||
record.SetPassword(security.RandomString(30))
|
||||
record.SetRandomPassword()
|
||||
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
@@ -75,20 +86,3 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func launchURL(url string) error {
|
||||
if err := is.URL.Validate(url); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
case "windows":
|
||||
// not sure if this is the best command but seems to be the most reliable based on the comments in
|
||||
// https://stackoverflow.com/questions/3739327/launching-a-website-via-the-windows-commandline#answer-49115945
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
default: // linux, freebsd, etc.
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -365,11 +365,25 @@ func logRequest(event *core.RequestEvent, err error) {
|
||||
|
||||
// parse the request error
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*router.ApiError); ok {
|
||||
status = apiErr.Status
|
||||
apiErr, isPlainApiError := err.(*router.ApiError)
|
||||
if isPlainApiError || errors.As(err, &apiErr) {
|
||||
// the status header wasn't written yet
|
||||
if status == 0 {
|
||||
status = apiErr.Status
|
||||
}
|
||||
|
||||
var errMsg string
|
||||
if isPlainApiError {
|
||||
errMsg = apiErr.Message
|
||||
} else {
|
||||
// wrapped ApiError -> add the full serialized version
|
||||
// of the original error since it could contain more information
|
||||
errMsg = err.Error()
|
||||
}
|
||||
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("error", apiErr.Message),
|
||||
slog.String("error", errMsg),
|
||||
slog.Any("details", apiErr.RawData()),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -111,7 +111,7 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
||||
//
|
||||
//nolint:unused
|
||||
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
|
||||
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[*rateLimiter])
|
||||
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
|
||||
if !ok || rateLimiters == nil {
|
||||
return false
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
|
||||
|
||||
rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
|
||||
return initRateLimitersStore(e.App)
|
||||
}).(*store.Store[*rateLimiter])
|
||||
}).(*store.Store[string, *rateLimiter])
|
||||
if rateLimiters == nil {
|
||||
e.App.Logger().Warn("Failed to retrieve app rate limiters store")
|
||||
return nil
|
||||
@@ -198,9 +198,9 @@ func destroyRateLimitersStore(app core.App) {
|
||||
app.Store().Remove(rateLimitersStoreKey)
|
||||
}
|
||||
|
||||
func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
|
||||
func initRateLimitersStore(app core.App) *store.Store[string, *rateLimiter] {
|
||||
app.Cron().Add(rateLimitersCronKey, "2 * * * *", func() { // offset a little since too many cleanup tasks execute at 00
|
||||
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[*rateLimiter])
|
||||
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
|
||||
},
|
||||
})
|
||||
|
||||
return store.New[*rateLimiter](nil)
|
||||
return store.New[string, *rateLimiter](nil)
|
||||
}
|
||||
|
||||
func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec int64) *rateLimiter {
|
||||
|
||||
+78
-44
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -83,11 +84,10 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
Data: []byte(`{"clientId":"` + ce.Client.Id() + `"}`),
|
||||
}
|
||||
connectMsgErr := ce.App.OnRealtimeMessageSend().Trigger(connectMsgEvent, func(me *core.RealtimeMessageEvent) error {
|
||||
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
|
||||
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
|
||||
me.Response.Write([]byte("data:"))
|
||||
me.Response.Write(me.Message.Data)
|
||||
me.Response.Write([]byte("\n\n"))
|
||||
err := me.Message.WriteSSE(me.Response, me.Client.Id())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return me.Flush()
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
@@ -122,11 +122,10 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
msgEvent.Client = ce.Client
|
||||
msgEvent.Message = &msg
|
||||
msgErr := ce.App.OnRealtimeMessageSend().Trigger(msgEvent, func(me *core.RealtimeMessageEvent) error {
|
||||
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
|
||||
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
|
||||
me.Response.Write([]byte("data:"))
|
||||
me.Response.Write(me.Message.Data)
|
||||
me.Response.Write([]byte("\n\n"))
|
||||
err := me.Message.WriteSSE(me.Response, me.Client.Id())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return me.Flush()
|
||||
})
|
||||
if msgErr != nil {
|
||||
@@ -214,7 +213,9 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
|
||||
slog.Any("subscriptions", e.Subscriptions),
|
||||
)
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -353,7 +354,10 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeBroadcastRecord(e.App, "delete", record, true)
|
||||
// note: use the outside scoped app instance for the access checks so that the API rules
|
||||
// are performed out of the delete transaction ensuring that they would still work even if
|
||||
// a cascade-deleted record's API rule relies on an already deleted parent record
|
||||
err := realtimeBroadcastRecord(e.App, "delete", record, true, app)
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to dry cache record delete",
|
||||
@@ -372,14 +376,17 @@ func bindRealtimeEvents(app core.App) {
|
||||
// delete: broadcast
|
||||
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeBroadcastDryCachedRecord(e.App, "delete", record)
|
||||
// note: only ensure that it is a collection record
|
||||
// and don't use realtimeResolveRecord because in case of a
|
||||
// custom model it'll fail to resolve since the record is already deleted
|
||||
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
||||
if collection != nil {
|
||||
err := realtimeBroadcastDryCacheKey(e.App, getDryCacheKey("delete", e.Model))
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to broadcast record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.Any("id", e.Model.PK()),
|
||||
slog.String("collectionName", collection.Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
@@ -395,7 +402,7 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelErrorEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeUnsetDryCachedRecord(e.App, "delete", record)
|
||||
err := realtimeUnsetDryCacheKey(e.App, getDryCacheKey("delete", record))
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to cleanup after broadcast record delete failure",
|
||||
@@ -415,7 +422,14 @@ func bindRealtimeEvents(app core.App) {
|
||||
// resolveRecord converts *if possible* the provided model interface to a Record.
|
||||
// This is usually helpful if the provided model is a custom Record model struct.
|
||||
func realtimeResolveRecord(app core.App, model core.Model, optCollectionType string) *core.Record {
|
||||
record, _ := model.(*core.Record)
|
||||
var record *core.Record
|
||||
switch m := model.(type) {
|
||||
case *core.Record:
|
||||
record = m
|
||||
case core.RecordProxy:
|
||||
record = m.ProxyRecord()
|
||||
}
|
||||
|
||||
if record != nil {
|
||||
if optCollectionType == "" || record.Collection().Type == optCollectionType {
|
||||
return record
|
||||
@@ -444,14 +458,20 @@ func realtimeResolveRecord(app core.App, model core.Model, optCollectionType str
|
||||
// realtimeResolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
|
||||
// This is usually helpful if the provided model is a custom Record model struct.
|
||||
func realtimeResolveRecordCollection(app core.App, model core.Model) (collection *core.Collection) {
|
||||
if record, ok := model.(*core.Record); ok {
|
||||
collection = record.Collection()
|
||||
} else {
|
||||
// check if it is custom Record model struct (ignore "private" tables)
|
||||
collection, _ = app.FindCachedCollectionByNameOrId(model.TableName())
|
||||
switch m := model.(type) {
|
||||
case *core.Record:
|
||||
return m.Collection()
|
||||
case core.RecordProxy:
|
||||
return m.ProxyRecord().Collection()
|
||||
default:
|
||||
// check if it is custom Record model struct
|
||||
collection, err := app.FindCachedCollectionByNameOrId(model.TableName())
|
||||
if err == nil {
|
||||
return collection
|
||||
}
|
||||
}
|
||||
|
||||
return collection
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordData represents the broadcasted record subscrition message data.
|
||||
@@ -460,7 +480,11 @@ type recordData struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool) error {
|
||||
// Note: the optAccessCheckApp is there in case you want the access check
|
||||
// to be performed against different db app context (e.g. out of a transaction).
|
||||
// If set, it is expected that optAccessCheckApp instance is used for read-only operations to avoid deadlocks.
|
||||
// If not set, it fallbacks to app.
|
||||
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool, optAccessCheckApp ...core.App) error {
|
||||
collection := record.Collection()
|
||||
if collection == nil {
|
||||
return errors.New("[broadcastRecord] Record collection not set")
|
||||
@@ -482,10 +506,15 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
(collection.Id + "?"): collection.ListRule,
|
||||
}
|
||||
|
||||
dryCacheKey := action + "/" + record.Id
|
||||
dryCacheKey := getDryCacheKey(action, record)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
accessCheckApp := app
|
||||
if len(optAccessCheckApp) > 0 {
|
||||
accessCheckApp = optAccessCheckApp[0]
|
||||
}
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
var clientAuth *core.Record
|
||||
@@ -502,10 +531,6 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
clientAuth, _ = client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
|
||||
for sub, options := range subs {
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know yet which exact fields the client subscription has permissions to access
|
||||
cleanRecord := record.Fresh()
|
||||
|
||||
// mock request data
|
||||
requestInfo := &core.RequestInfo{
|
||||
Context: core.RequestInfoContextRealtime,
|
||||
@@ -515,10 +540,14 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
Auth: clientAuth,
|
||||
}
|
||||
|
||||
if !realtimeCanAccessRecord(app, cleanRecord, requestInfo, rule) {
|
||||
if !realtimeCanAccessRecord(accessCheckApp, record, requestInfo, rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
// create a clean record copy without expand and unknown fields because we don't know yet
|
||||
// which exact fields the client subscription requested or has permissions to access
|
||||
cleanRecord := record.Fresh()
|
||||
|
||||
// trigger the enrich hooks
|
||||
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
|
||||
// apply expand
|
||||
@@ -541,7 +570,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
// for auth owner, superuser or manager
|
||||
if collection.IsAuth() {
|
||||
if isSameAuth(clientAuth, cleanRecord) ||
|
||||
realtimeCanAccessRecord(app, cleanRecord, requestInfo, collection.ManageRule) {
|
||||
realtimeCanAccessRecord(accessCheckApp, cleanRecord, requestInfo, collection.ManageRule) {
|
||||
cleanRecord.IgnoreEmailVisibility(true)
|
||||
}
|
||||
}
|
||||
@@ -622,15 +651,13 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeBroadcastDryCachedRecord broadcasts all cached record related messages.
|
||||
func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.Record) error {
|
||||
// realtimeBroadcastDryCacheKey broadcasts the dry cached key related messages.
|
||||
func realtimeBroadcastDryCacheKey(app core.App, key string) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
if len(chunks) == 0 {
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
key := action + "/" + record.Id
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
@@ -659,15 +686,13 @@ func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeUnsetDryCachedRecord removes the dry cached record related messages.
|
||||
func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Record) error {
|
||||
// realtimeUnsetDryCacheKey removes the dry cached key related messages.
|
||||
func realtimeUnsetDryCacheKey(app core.App, key string) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
if len(chunks) == 0 {
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
key := action + "/" + record.Id
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
@@ -685,6 +710,15 @@ func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Reco
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
func getDryCacheKey(action string, model core.Model) string {
|
||||
pkStr, ok := model.PK().(string)
|
||||
if !ok {
|
||||
pkStr = fmt.Sprintf("%v", model.PK())
|
||||
}
|
||||
|
||||
return action + "/" + model.TableName() + "/" + pkStr
|
||||
}
|
||||
|
||||
func isSameAuth(authA, authB *core.Record) bool {
|
||||
if authA == nil {
|
||||
return authB == nil
|
||||
@@ -722,9 +756,9 @@ func realtimeCanAccessRecord(
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
q := app.DB().Select("(1)").
|
||||
q := app.ConcurrentDB().Select("(1)").
|
||||
From(record.Collection().Name).
|
||||
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
|
||||
|
||||
@@ -739,5 +773,5 @@ func realtimeCanAccessRecord(
|
||||
|
||||
err = q.Limit(1).Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRealtimeConnect(t *testing.T) {
|
||||
@@ -632,3 +636,250 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ core.Model = (*CustomModelResolve)(nil)
|
||||
|
||||
type CustomModelResolve struct {
|
||||
core.BaseModel
|
||||
tableName string
|
||||
|
||||
Created string `db:"created"`
|
||||
}
|
||||
|
||||
func (m *CustomModelResolve) TableName() string {
|
||||
return m.tableName
|
||||
}
|
||||
|
||||
func TestRealtimeRecordResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const testCollectionName = "realtime_test_collection"
|
||||
|
||||
testRecordId := core.GenerateDefaultRandomId()
|
||||
|
||||
client0 := subscriptions.NewDefaultClient()
|
||||
client0.Subscribe(testCollectionName + "/*")
|
||||
client0.Discard()
|
||||
// ---
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Subscribe(testCollectionName + "/*")
|
||||
// ---
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Subscribe(testCollectionName + "/" + testRecordId)
|
||||
// ---
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Subscribe("demo1/*")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
op func(testApp core.App) error
|
||||
expected map[string][]string // clientId -> [events]
|
||||
}{
|
||||
{
|
||||
"core.Record",
|
||||
func(testApp core.App) error {
|
||||
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r := core.NewRecord(c)
|
||||
r.Id = testRecordId
|
||||
|
||||
// create
|
||||
err = testApp.Save(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
err = testApp.Save(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"core.RecordProxy",
|
||||
func(testApp core.App) error {
|
||||
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r := core.NewRecord(c)
|
||||
|
||||
proxy := &struct {
|
||||
core.BaseRecordProxy
|
||||
}{}
|
||||
proxy.SetProxyRecord(r)
|
||||
proxy.Id = testRecordId
|
||||
|
||||
// create
|
||||
err = testApp.Save(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
err = testApp.Save(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"custom model struct",
|
||||
func(testApp core.App) error {
|
||||
m := &CustomModelResolve{tableName: testCollectionName}
|
||||
m.Id = testRecordId
|
||||
|
||||
// create
|
||||
err := testApp.Save(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
m.Created = "123"
|
||||
err = testApp.Save(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
|
||||
// create new test collection with public read access
|
||||
testCollection := core.NewBaseCollection(testCollectionName)
|
||||
testCollection.Fields.Add(&core.AutodateField{Name: "created", OnCreate: true, OnUpdate: true})
|
||||
testCollection.ListRule = types.Pointer("")
|
||||
testCollection.ViewRule = types.Pointer("")
|
||||
err := testApp.Save(testCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testApp.SubscriptionsBroker().Register(client0)
|
||||
testApp.SubscriptionsBroker().Register(client1)
|
||||
testApp.SubscriptionsBroker().Register(client2)
|
||||
testApp.SubscriptionsBroker().Register(client3)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var notifications = map[string][]string{}
|
||||
|
||||
var mu sync.Mutex
|
||||
notify := func(clientId string, eventData []byte) {
|
||||
data := struct{ Action string }{}
|
||||
_ = json.Unmarshal(eventData, &data)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if notifications[clientId] == nil {
|
||||
notifications[clientId] = []string{}
|
||||
}
|
||||
notifications[clientId] = append(notifications[clientId], data.Action)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
timeout := time.After(250 * time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-client0.Channel():
|
||||
if ok {
|
||||
notify(client0.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client1.Channel():
|
||||
if ok {
|
||||
notify(client1.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client2.Channel():
|
||||
if ok {
|
||||
notify(client2.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client3.Channel():
|
||||
if ok {
|
||||
notify(client3.Id(), e.Data)
|
||||
}
|
||||
case <-timeout:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = s.op(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(s.expected) != len(notifications) {
|
||||
t.Fatalf("Expected %d notified clients, got %d:\n%v", len(s.expected), len(notifications), notifications)
|
||||
}
|
||||
|
||||
for id, events := range s.expected {
|
||||
if len(events) != len(notifications[id]) {
|
||||
t.Fatalf("[%s] Expected %d events, got %d:\n%v\n%v", id, len(events), len(notifications[id]), s.expected, notifications)
|
||||
}
|
||||
for _, event := range events {
|
||||
if !slices.Contains(notifications[id], event) {
|
||||
t.Fatalf("[%s] Missing expected event %q in %v", id, event, notifications[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,15 +38,16 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
|
||||
event.NewEmail = newEmail
|
||||
|
||||
return e.App.OnRecordConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeRequestEvent) error {
|
||||
authRecord.Set(core.FieldNameEmail, e.NewEmail)
|
||||
authRecord.Set(core.FieldNameVerified, true)
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
e.Record.SetEmail(e.NewEmail)
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
|
||||
}
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -136,7 +135,7 @@ func TestRecordConfirmEmailChange(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
|
||||
Name: "OnRecordConfirmEmailChangeRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -145,15 +144,22 @@ func TestRecordConfirmEmailChange(t *testing.T) {
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordConfirmEmailChangeRequest().BindFunc(func(e *core.RecordConfirmEmailChangeRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordConfirmEmailChangeRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordConfirmEmailChangeRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
|
||||
@@ -43,7 +43,9 @@ func recordRequestEmailChange(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("Failed to request email change.", err))
|
||||
}
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,33 @@ func TestRecordRequestEmailChange(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordRequestEmailChangeRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-email-change",
|
||||
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordRequestEmailChangeRequest().BindFunc(func(e *core.RecordRequestEmailChangeRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordRequestEmailChangeRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -26,10 +26,10 @@ func recordAuthImpersonate(e *core.RequestEvent) error {
|
||||
|
||||
form := &impersonateForm{}
|
||||
if err = e.BindBody(form); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
||||
return e.BadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
if err = form.validate(); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
return e.BadRequestError("An error occurred while validating the submitted data.", err)
|
||||
}
|
||||
|
||||
token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second)
|
||||
|
||||
@@ -108,8 +108,8 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"otpId": otp.Id,
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"otpId": otp.Id})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -247,6 +247,31 @@ func TestRecordRequestOTP(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordRequestOTPRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-otp",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordRequestOTPRequest().BindFunc(func(e *core.RecordCreateOTPRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordRequestOTPRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -47,14 +47,16 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
err = form.app.Save(authRecord)
|
||||
err = e.App.Save(authRecord)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to set new password.", err))
|
||||
}
|
||||
|
||||
form.app.Store().Remove(getPasswordResetResendKey(authRecord))
|
||||
e.App.Store().Remove(getPasswordResetResendKey(authRecord))
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -186,11 +185,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
t.Fatal("Expected the user to be unverified")
|
||||
}
|
||||
|
||||
oldTokenKey := user.TokenKey()
|
||||
|
||||
// manually change the email to check whether the verified state will be updated
|
||||
user.SetEmail("test_update@example.com")
|
||||
if err := app.Save(user); err != nil {
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to update user test email: %v", err)
|
||||
}
|
||||
|
||||
// resave with the old token key since the email change above
|
||||
// would change it and will make the password token invalid
|
||||
user.SetTokenKey(oldTokenKey)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to restore original user tokenKey: %v", err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
_, err := app.FindAuthRecordByToken(
|
||||
@@ -273,7 +281,7 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
|
||||
Name: "OnRecordConfirmPasswordResetRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -283,15 +291,22 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordConfirmPasswordResetRequest().BindFunc(func(e *core.RecordConfirmPasswordResetRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordConfirmPasswordResetRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
|
||||
@@ -65,7 +65,9 @@ func recordRequestPasswordReset(e *core.RequestEvent) error {
|
||||
})
|
||||
})
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,30 @@ func TestRecordRequestPasswordReset(t *testing.T) {
|
||||
app.Store().Set(resendKey, struct{}{})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordRequestPasswordResetRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordRequestPasswordResetRequest().BindFunc(func(e *core.RecordRequestPasswordResetRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordRequestPasswordResetRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -130,23 +129,30 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterAuthRefreshRequest error response",
|
||||
Name: "OnRecordAuthRefreshRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-refresh?expand=rel,missing",
|
||||
URL: "/api/collections/users/auth-refresh",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordAuthRefreshRequest().BindFunc(func(e *core.RecordAuthRefreshRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthRefreshRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordAuthRefreshRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
|
||||
@@ -42,19 +42,19 @@ func recordConfirmVerification(e *core.RequestEvent) error {
|
||||
event.Record = record
|
||||
|
||||
return e.App.OnRecordConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationRequestEvent) error {
|
||||
if wasVerified {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
}
|
||||
if !wasVerified {
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
|
||||
}
|
||||
}
|
||||
|
||||
e.App.Store().Remove(getVerificationResendKey(e.Record))
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -144,7 +143,7 @@ func TestRecordConfirmVerification(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmVerificationRequest error response",
|
||||
Name: "OnRecordConfirmVerificationRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/confirm-verification",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -152,15 +151,22 @@ func TestRecordConfirmVerification(t *testing.T) {
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordConfirmVerificationRequest().BindFunc(func(e *core.RecordConfirmVerificationRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordConfirmVerificationRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordConfirmVerificationRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
|
||||
@@ -68,7 +68,9 @@ func recordRequestVerification(e *core.RequestEvent) error {
|
||||
})
|
||||
})
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,30 @@ func TestRecordRequestVerification(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordRequestVerificationRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordRequestVerificationRequest().BindFunc(func(e *core.RecordRequestVerificationRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordRequestVerificationRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -3,20 +3,20 @@ package apis
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -35,6 +35,8 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
fallbackAuthRecord = e.Auth
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
|
||||
|
||||
form := new(recordOAuth2LoginForm)
|
||||
form.collection = collection
|
||||
if err = e.BindBody(form); err != nil {
|
||||
@@ -134,13 +136,17 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
|
||||
}
|
||||
|
||||
meta := struct {
|
||||
*auth.AuthUser
|
||||
IsNew bool `json:"isNew"`
|
||||
}{
|
||||
AuthUser: e.OAuth2User,
|
||||
IsNew: e.IsNewRecord,
|
||||
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
|
||||
meta := map[string]any{}
|
||||
rawOAuth2User, err := json.Marshal(e.OAuth2User)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.Unmarshal(rawOAuth2User, &meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta["isNew"] = e.IsNewRecord
|
||||
|
||||
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
|
||||
})
|
||||
@@ -195,10 +201,20 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
||||
|
||||
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
|
||||
// ensure that username is unique
|
||||
checkUnique := dbutils.HasSingleColumnUniqueIndex(collection.OAuth2.MappedFields.Username, collection.Indexes)
|
||||
if checkUnique {
|
||||
if _, err := txApp.FindFirstRecordByData(collection, collection.OAuth2.MappedFields.Username, username); err == nil {
|
||||
return false // already exist
|
||||
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
|
||||
if hasUniqueue {
|
||||
var expr dbx.Expression
|
||||
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
|
||||
} else {
|
||||
expr = dbx.HashExp{"username": username}
|
||||
}
|
||||
|
||||
var exists int
|
||||
_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
|
||||
if exists > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +238,10 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
payload = map[string]any{}
|
||||
}
|
||||
|
||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||
|
||||
// set a random password if none is set
|
||||
if v, _ := payload[core.FieldNamePassword].(string); v == "" {
|
||||
payload[core.FieldNamePassword] = security.RandomString(30)
|
||||
payload[core.FieldNamePassword+"Confirm"] = payload[core.FieldNamePassword]
|
||||
// assign the OAuth2 user email only if the user hasn't submitted one
|
||||
// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
|
||||
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
|
||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||
}
|
||||
|
||||
// map known fields (unless the field was explicitly submitted as part of CreateData)
|
||||
@@ -292,7 +306,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
// set random password for users with unverified email
|
||||
// (this is in case a malicious actor has registered previously with the user email)
|
||||
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
|
||||
e.Record.SetPassword(security.RandomString(30))
|
||||
e.Record.SetRandomPassword()
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -174,6 +175,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
if err := app.Save(ea); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextOAuth2 {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOAuth2, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -292,6 +307,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test2@example.com"`,
|
||||
`"id":"oap640cot4yru2s"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -383,6 +399,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -488,6 +505,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -558,12 +576,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
|
||||
oldTokenKey := user.TokenKey()
|
||||
|
||||
// manually unset the user email
|
||||
user.SetEmail("")
|
||||
if err := app.Save(user); err != nil {
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// resave with the old token key since the email change above
|
||||
// would change it and will make the password token invalid
|
||||
user.SetTokenKey(oldTokenKey)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to restore original user tokenKey: %v", err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
@@ -592,6 +619,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test_oauth2@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -697,6 +725,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -786,6 +815,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":true`,
|
||||
`"email":""`,
|
||||
`"id":"test_id"`,
|
||||
`"verified":true`,
|
||||
@@ -867,7 +897,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"verified":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"email":`, // the value is always overwritten with the OAuth2 user email
|
||||
`"email":`, // ignored because the record validator never ran
|
||||
`"rel":`, // ignored because the record validator never ran
|
||||
`"file":`, // ignored because the record validator never ran
|
||||
},
|
||||
@@ -920,12 +950,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"email":{"code":"validation_is_email"`,
|
||||
`"rel":{"code":"validation_missing_rel_records"`,
|
||||
`"file":{"code":"validation_invalid_file"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"email":`, // the value is always overwritten with the OAuth2 user email
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
@@ -939,7 +967,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (valid create data)",
|
||||
Name: "creating user (valid create data with empty submitted email)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -947,8 +975,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"email": "invalid",
|
||||
"email": "",
|
||||
"emailVisibility": true,
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
"name": "test_name",
|
||||
"username": "test_username",
|
||||
"rel": "0yxhwia2amd8gec"
|
||||
@@ -982,6 +1012,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":""`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
@@ -1018,6 +1049,105 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnModelValidate": 4,
|
||||
"OnRecordValidate": 4,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindFirstRecordByData("users", "username", "test_username")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (valid create data with non-empty valid submitted email)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "test",
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"email": "test_create@example.com",
|
||||
"emailVisibility": true,
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
"name": "test_name",
|
||||
"username": "test_username",
|
||||
"rel": "0yxhwia2amd8gec"
|
||||
}
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
usersCol, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "test_id",
|
||||
Email: "oauth2@example.com", // should be ignored because of the explicit submitted email
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
usersCol.MFA.Enabled = false
|
||||
usersCol.OAuth2.Enabled = true
|
||||
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: "test",
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
if err := app.Save(usersCol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test_create@example.com"`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
`"username":"test_username"`,
|
||||
`"verified":false`,
|
||||
`"rel":"0yxhwia2amd8gec"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnRecordEnrich": 2, // the auth response and from the create request
|
||||
// ---
|
||||
"OnModelCreate": 3, // record + authOrigins + externalAuths
|
||||
"OnModelCreateExecute": 3,
|
||||
"OnModelAfterCreateSuccess": 3,
|
||||
"OnRecordCreate": 3,
|
||||
"OnRecordCreateExecute": 3,
|
||||
"OnRecordAfterCreateSuccess": 3,
|
||||
// ---
|
||||
"OnModelValidate": 3,
|
||||
"OnRecordValidate": 3,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindFirstRecordByData("users", "username", "test_username")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
|
||||
@@ -1071,6 +1201,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
@@ -1153,6 +1284,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1189,7 +1321,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and avatarURL->non-file field)",
|
||||
Name: "creating user (with mapped OAuth2 fields, case-sensitive username and avatarURL->non-file field)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -1209,7 +1341,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "oauth2_username",
|
||||
Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
|
||||
Name: "oauth2_name",
|
||||
AvatarURL: server.URL + "/oauth2_avatar.png",
|
||||
},
|
||||
@@ -1235,9 +1367,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"username":"oauth2_username"`,
|
||||
`"username":"tESt2_username"`,
|
||||
`"name":"http://127.`,
|
||||
`"verified":true`,
|
||||
`"avatar":""`,
|
||||
@@ -1273,7 +1406,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and duplicated username)",
|
||||
Name: "creating user (with mapped OAuth2 fields and duplicated case-insensitive username)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -1293,13 +1426,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "test2_username",
|
||||
Username: "tESt2_username",
|
||||
Name: "oauth2_name",
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// make the username index case-insensitive to ensure that case-insensitive match is used
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(usersCol.Indexes, "username")
|
||||
if ok {
|
||||
index.Columns[0].Collate = "nocase"
|
||||
usersCol.RemoveIndex(index.IndexName)
|
||||
usersCol.Indexes = append(usersCol.Indexes, index.Build())
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
usersCol.MFA.Enabled = false
|
||||
usersCol.OAuth2.Enabled = true
|
||||
@@ -1317,6 +1458,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1398,6 +1540,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1434,6 +1577,69 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnRecordValidate": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAuthWithOAuth2Request tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "test",
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{Id: "test_id"},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
user.Collection().MFA.Enabled = false
|
||||
user.Collection().OAuth2.Enabled = true
|
||||
user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: "test",
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
if err := app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// stub linked provider
|
||||
ea := core.NewExternalAuth(app)
|
||||
ea.SetCollectionRef(user.Collection().Id)
|
||||
ea.SetRecordRef(user.Id)
|
||||
ea.SetProvider("test")
|
||||
ea.SetProviderId("test_id")
|
||||
if err := app.Save(ea); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.OnRecordAuthWithOAuth2Request().BindFunc(func(e *core.RecordAuthWithOAuth2RequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordAuthWithOAuth2Request": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -26,6 +26,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOTP)
|
||||
|
||||
event := new(core.RecordAuthWithOTPRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = collection
|
||||
@@ -85,12 +87,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
|
||||
}
|
||||
|
||||
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -269,6 +269,20 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
if err := app.Save(otp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextOTP {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOTP, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -405,6 +419,53 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAuthWithOTPRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-otp",
|
||||
Body: strings.NewReader(`{
|
||||
"otpId":"` + strings.Repeat("a", 15) + `",
|
||||
"password":"123456"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// disable MFA
|
||||
user.Collection().MFA.Enabled = false
|
||||
if err = app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
otp := core.NewOTP(app)
|
||||
otp.Id = strings.Repeat("a", 15)
|
||||
otp.SetCollectionRef(user.Collection().Id)
|
||||
otp.SetRecordRef(user.Id)
|
||||
otp.SetPassword("123456")
|
||||
if err := app.Save(otp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.OnRecordAuthWithOTPRequest().BindFunc(func(e *core.RecordAuthWithOTPRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordAuthWithOTPRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -3,10 +3,14 @@ package apis
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
@@ -28,16 +32,18 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextPasswordAuth)
|
||||
|
||||
var foundRecord *core.Record
|
||||
var foundErr error
|
||||
|
||||
if form.IdentityField != "" {
|
||||
foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, form.IdentityField, form.Identity)
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
|
||||
} else {
|
||||
// prioritize email lookup
|
||||
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
||||
if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
|
||||
foundRecord, foundErr = e.App.FindAuthRecordByEmail(collection.Id, form.Identity)
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
|
||||
}
|
||||
|
||||
// search by the other identity fields
|
||||
@@ -47,7 +53,7 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
||||
continue // no need to search by the email field if it is not an email
|
||||
}
|
||||
|
||||
foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, name, form.Identity)
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
|
||||
if foundErr == nil {
|
||||
break
|
||||
}
|
||||
@@ -92,6 +98,38 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.IdentityField, validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...)),
|
||||
validation.Field(
|
||||
&form.IdentityField,
|
||||
validation.Length(1, 255),
|
||||
validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
|
||||
if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
|
||||
return nil, errors.New("invalid identity field " + field)
|
||||
}
|
||||
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field)
|
||||
if !ok {
|
||||
return nil, errors.New("missing " + field + " unique index constraint")
|
||||
}
|
||||
|
||||
var expr dbx.Expression
|
||||
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("[["+field+"]] = {:identity} COLLATE NOCASE", dbx.Params{"identity": value})
|
||||
} else {
|
||||
expr = dbx.HashExp{field: value}
|
||||
}
|
||||
|
||||
record := &core.Record{}
|
||||
|
||||
err := app.RecordQuery(collection).AndWhere(expr).Limit(1).One(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
)
|
||||
|
||||
func TestRecordAuthWithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
updateIdentityIndex := func(collectionIdOrName string, fieldCollateMap map[string]string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
collection, err := app.FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for column, collate := range fieldCollateMap {
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, column)
|
||||
if !ok {
|
||||
t.Fatalf("Missing unique identityField index for column %q", column)
|
||||
}
|
||||
|
||||
index.Columns[0].Collate = collate
|
||||
|
||||
collection.RemoveIndex(index.IndexName)
|
||||
collection.Indexes = append(collection.Indexes, index.Build())
|
||||
}
|
||||
|
||||
err = app.Save(collection)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update identityField index: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "disabled password auth",
|
||||
@@ -55,7 +81,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAuthWithPasswordRequest error response",
|
||||
Name: "OnRecordAuthWithPasswordRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -64,15 +90,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordAuthWithPasswordRequest().BindFunc(func(e *core.RecordAuthWithPasswordRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordAuthWithPasswordRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
{
|
||||
Name: "valid identity field and invalid password",
|
||||
@@ -99,6 +132,21 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextPasswordAuth {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextPasswordAuth, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
@@ -164,6 +212,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unknown explicit identityField",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identityField": "created",
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"identityField":{"code":"validation_in_invalid"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid identity field and valid password with mismatched explicit identityField",
|
||||
Method: http.MethodPost,
|
||||
@@ -440,6 +504,141 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// case sensitivity checks
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "with explicit identityField (case-sensitive)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identityField": "username",
|
||||
"identity":"Clients57772",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": ""}),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with explicit identityField (case-insensitive)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identityField": "username",
|
||||
"identity":"Clients57772",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
`"username":"clients57772"`,
|
||||
`"token":`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// authOrigin track
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 1,
|
||||
"OnMailerSend": 1,
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "without explicit identityField and non-email field (case-insensitive)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"Clients57772",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
`"username":"clients57772"`,
|
||||
`"token":`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// authOrigin track
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 1,
|
||||
"OnMailerSend": 1,
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "without explicit identityField and email field (case-insensitive)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"tESt@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"email": "nocase"}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
`"username":"clients57772"`,
|
||||
`"token":`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// authOrigin track
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 1,
|
||||
"OnMailerSend": 1,
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
|
||||
+176
-80
@@ -13,9 +13,11 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// bindRecordCrudApi registers the record crud api endpoints and
|
||||
@@ -26,9 +28,9 @@ func bindRecordCrudApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
|
||||
subGroup := rg.Group("/collections/{collection}/records").Unbind(DefaultRateLimitMiddlewareId)
|
||||
subGroup.GET("", recordsList)
|
||||
subGroup.GET("/{id}", recordView)
|
||||
subGroup.POST("", recordCreate(nil)).Bind(dynamicCollectionBodyLimit(""))
|
||||
subGroup.PATCH("/{id}", recordUpdate(nil)).Bind(dynamicCollectionBodyLimit(""))
|
||||
subGroup.DELETE("/{id}", recordDelete(nil))
|
||||
subGroup.POST("", recordCreate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
|
||||
subGroup.PATCH("/{id}", recordUpdate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
|
||||
subGroup.DELETE("/{id}", recordDelete(true, nil))
|
||||
}
|
||||
|
||||
func recordsList(e *core.RequestEvent) error {
|
||||
@@ -57,23 +59,32 @@ func recordsList(e *core.RequestEvent) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fieldsResolver := core.NewRecordFieldResolver(
|
||||
e.App,
|
||||
collection,
|
||||
requestInfo,
|
||||
// hidden fields are searchable only by superusers
|
||||
requestInfo.HasSuperuserAuth(),
|
||||
)
|
||||
query := e.App.RecordQuery(collection)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(e.App.RecordQuery(collection))
|
||||
fieldsResolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil && *collection.ListRule != "" {
|
||||
expr, err := search.FilterData(*collection.ListRule).BuildExpr(fieldsResolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.AndWhere(expr)
|
||||
|
||||
// will be applied by the search provider right before executing the query
|
||||
// fieldsResolver.UpdateQuery(query)
|
||||
}
|
||||
|
||||
// hidden fields are searchable only by superusers
|
||||
fieldsResolver.SetAllowHiddenFields(requestInfo.HasSuperuserAuth())
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).Query(query)
|
||||
|
||||
// use rowid when available to minimize the need of a covering index with the "id" field
|
||||
if !collection.IsView() {
|
||||
searchProvider.CountCol("_rowid_")
|
||||
}
|
||||
|
||||
records := []*core.Record{}
|
||||
|
||||
result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("", err))
|
||||
@@ -92,9 +103,9 @@ func recordsList(e *core.RequestEvent) error {
|
||||
|
||||
// Add a randomized throttle in case of too many empty search filter attempts.
|
||||
//
|
||||
// This is just for extra precaution since security researches raised concern regarding the possibity of eventual
|
||||
// This is just for extra precaution since security researches raised concern regarding the possibility of eventual
|
||||
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
|
||||
// This is by design and it is an accepted tradeoff between performance, usability and correctness.
|
||||
// This is by design and it is an accepted trade off between performance, usability and correctness.
|
||||
//
|
||||
// While technically the below doesn't fully guarantee protection against filter timing attacks, in practice combined with the network latency it makes them even less feasible.
|
||||
// A properly configured rate limiter or individual fields Hidden checks are better suited if you are really concerned about eventual information disclosure by side-channel attacks.
|
||||
@@ -107,10 +118,12 @@ func recordsList(e *core.RequestEvent) error {
|
||||
len(e.Records) == 0 &&
|
||||
checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
|
||||
e.App.Logger().Debug("Randomized throttle because of too many failed searches", "collectionId", collection.Id)
|
||||
randomizedThrottle(100)
|
||||
randomizedThrottle(150)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -181,11 +194,13 @@ func recordView(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Record)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
func recordCreate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
if err != nil || collection == nil {
|
||||
@@ -218,6 +233,16 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
|
||||
}
|
||||
|
||||
// set a random password for the OAuth2 ignoring its plain password validators
|
||||
var skipPlainPasswordRecordValidators bool
|
||||
if requestInfo.Context == core.RequestInfoContextOAuth2 {
|
||||
if _, ok := data[core.FieldNamePassword]; !ok {
|
||||
data[core.FieldNamePassword] = security.RandomString(30)
|
||||
data[core.FieldNamePassword+"Confirm"] = data[core.FieldNamePassword]
|
||||
skipPlainPasswordRecordValidators = true
|
||||
}
|
||||
}
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestInfo.Body
|
||||
requestInfo.Body = data
|
||||
@@ -228,6 +253,80 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
form.Load(data)
|
||||
|
||||
if skipPlainPasswordRecordValidators {
|
||||
// unset the plain value to skip the plain password field validators
|
||||
if raw, ok := record.GetRaw(core.FieldNamePassword).(*core.PasswordFieldValue); ok {
|
||||
raw.Plain = ""
|
||||
}
|
||||
}
|
||||
|
||||
// check the request and record data against the create and manage rules
|
||||
if !hasSuperuserAuth && collection.CreateRule != nil {
|
||||
dummyRecord := record.Clone()
|
||||
|
||||
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
|
||||
|
||||
// set an id if it doesn't have already
|
||||
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
|
||||
if dummyRecord.Id == "" {
|
||||
dummyRecord.Id = "__temp_id__" + dummyRandomPart
|
||||
}
|
||||
|
||||
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
||||
dummyRecord.SetVerified(false)
|
||||
|
||||
// export the dummy record data into db params
|
||||
dummyExport, err := dummyRecord.DBExport(e.App)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
|
||||
}
|
||||
|
||||
dummyParams := make(dbx.Params, len(dummyExport))
|
||||
selects := make([]string, 0, len(dummyExport))
|
||||
var param string
|
||||
for k, v := range dummyExport {
|
||||
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
|
||||
param = "__pb_create__" + k
|
||||
dummyParams[param] = v
|
||||
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
|
||||
}
|
||||
|
||||
// shallow clone the current collection
|
||||
dummyCollection := *collection
|
||||
dummyCollection.Id += dummyRandomPart
|
||||
dummyCollection.Name += inflector.Columnify(dummyRandomPart)
|
||||
|
||||
withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
|
||||
|
||||
// check non-empty create rule
|
||||
if *dummyCollection.CreateRule != "" {
|
||||
ruleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
|
||||
|
||||
resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
|
||||
}
|
||||
ruleQuery.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(ruleQuery)
|
||||
|
||||
var exists int
|
||||
err = ruleQuery.Limit(1).Row(&exists)
|
||||
if err != nil || exists == 0 {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// check for manage rule access
|
||||
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
|
||||
if !form.HasManageAccess() &&
|
||||
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
}
|
||||
|
||||
var isOptFinalizerCalled bool
|
||||
|
||||
event := new(core.RecordRequestEvent)
|
||||
@@ -239,59 +338,9 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
form.SetApp(e.App)
|
||||
form.SetRecord(e.Record)
|
||||
|
||||
// temporary save the record and check it against the create and manage rules
|
||||
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
|
||||
// temporary grant manager access level
|
||||
form.GrantManagerAccess()
|
||||
|
||||
// manually unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
||||
initialVerified := e.Record.Verified()
|
||||
if initialVerified {
|
||||
e.Record.SetVerified(false)
|
||||
}
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *e.Collection.CreateRule == "" {
|
||||
return nil // no create rule to resolve
|
||||
}
|
||||
|
||||
resolver := core.NewRecordFieldResolver(e.App, e.Collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*e.Collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
testErr := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error {
|
||||
foundRecord, err := txApp.FindRecordById(drySavedRecord.Collection(), drySavedRecord.Id, createRuleFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
|
||||
// reset the form access level in case it satisfies the Manage API rule
|
||||
if !hasAuthManageAccess(txApp, requestInfo, foundRecord) {
|
||||
form.ResetAccess()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if testErr != nil {
|
||||
return e.BadRequestError("Failed to create record.", testErr)
|
||||
}
|
||||
|
||||
// restore initial verified state (it will be further validated on submit)
|
||||
if initialVerified != e.Record.Verified() {
|
||||
e.Record.SetVerified(initialVerified)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to create record.", err))
|
||||
return firstApiError(err, e.BadRequestError("Failed to create record", err))
|
||||
}
|
||||
|
||||
err = EnrichRecord(e.RequestEvent, e.Record)
|
||||
@@ -299,7 +348,9 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
|
||||
}
|
||||
|
||||
err = e.JSON(http.StatusOK, e.Record)
|
||||
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -329,7 +380,7 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
func recordUpdate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
if err != nil || collection == nil {
|
||||
@@ -401,6 +452,14 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
form.Load(data)
|
||||
|
||||
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").From(collection.Name).AndWhere(dbx.HashExp{
|
||||
collection.Name + ".id": record.Id,
|
||||
})
|
||||
if !form.HasManageAccess() &&
|
||||
hasAuthManageAccess(e.App, requestInfo, collection, manageRuleQuery) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
|
||||
var isOptFinalizerCalled bool
|
||||
|
||||
event := new(core.RecordRequestEvent)
|
||||
@@ -411,9 +470,6 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
hookErr := e.App.OnRecordUpdateRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
|
||||
form.SetApp(e.App)
|
||||
form.SetRecord(e.Record)
|
||||
if !form.HasManageAccess() && hasAuthManageAccess(e.App, requestInfo, e.Record) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
if err != nil {
|
||||
@@ -425,7 +481,9 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
|
||||
}
|
||||
|
||||
err = e.JSON(http.StatusOK, e.Record)
|
||||
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -455,7 +513,7 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
func recordDelete(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
if err != nil || collection == nil {
|
||||
@@ -515,7 +573,9 @@ func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
return firstApiError(err, e.BadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err))
|
||||
}
|
||||
|
||||
err = e.NoContent(http.StatusNoContent)
|
||||
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -644,3 +704,39 @@ func extractUploadedFiles(re *core.RequestEvent, collection *core.Collection, pr
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// hasAuthManageAccess checks whether the client is allowed to have
|
||||
// [forms.RecordUpsert] auth management permissions
|
||||
// (e.g. allowing to change system auth fields without oldPassword).
|
||||
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection *core.Collection, query *dbx.SelectQuery) bool {
|
||||
if !collection.IsAuth() {
|
||||
return false
|
||||
}
|
||||
|
||||
manageRule := collection.ManageRule
|
||||
|
||||
if manageRule == nil || *manageRule == "" {
|
||||
return false // only for superusers (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestInfo == nil || requestInfo.Auth == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
app.Logger().Error("Manage rule build expression error", "error", err, "collectionId", collection.Id)
|
||||
return false
|
||||
}
|
||||
query.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
var exists int
|
||||
|
||||
err = query.Limit(1).Row(&exists)
|
||||
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
+157
-47
@@ -2,7 +2,6 @@ package apis_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -259,6 +258,47 @@ func TestRecordCrudList(t *testing.T) {
|
||||
"OnRecordEnrich": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated regular record that matches the collection list rule with hidden field",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo3/records",
|
||||
Headers: map[string]string{
|
||||
// clients, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
col, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock hidden field
|
||||
col.Fields.GetByName("title").SetHidden(true)
|
||||
|
||||
col.ListRule = types.Pointer("title ~ 'test'")
|
||||
|
||||
if err = app.Save(col); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":4`,
|
||||
`"items":[{`,
|
||||
`"id":"1tmknxy2868d869"`,
|
||||
`"id":"lcl9d87w22ml6jy"`,
|
||||
`"id":"7nwo8tuiatetxdm"`,
|
||||
`"id":"mk5fmymtx4wsprk"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordsListRequest": 1,
|
||||
"OnRecordEnrich": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated regular record filtering with a hidden field",
|
||||
Method: http.MethodGet,
|
||||
@@ -377,6 +417,32 @@ func TestRecordCrudList(t *testing.T) {
|
||||
"OnRecordsListRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordsListRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordsListRequest().BindFunc(func(e *core.RecordsListRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// auth collection
|
||||
// -----------------------------------------------------------
|
||||
@@ -821,6 +887,32 @@ func TestRecordCrudView(t *testing.T) {
|
||||
"OnRecordEnrich": 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordViewRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1/records/al1h9ijdeojtsjy",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordViewRequest().BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// auth collection
|
||||
// -----------------------------------------------------------
|
||||
@@ -1168,7 +1260,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterDeleteSuccessRequest error response",
|
||||
Name: "OnRecordDeleteRequest tx body write check",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/clients/records/o1y0dd0spd786md",
|
||||
Headers: map[string]string{
|
||||
@@ -1176,15 +1268,22 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordDeleteRequest().BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordDeleteRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordDeleteRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that match the collection delete rule",
|
||||
@@ -1522,10 +1621,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
Body: strings.NewReader(`{"title":"test123"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "auth record submit in restricted collection (rule failure check)",
|
||||
@@ -1538,10 +1634,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "auth record submit in restricted collection (rule pass check) + expand relations",
|
||||
@@ -1690,10 +1783,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.body rule",
|
||||
@@ -1747,29 +1837,44 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
// validate events are not fired because the unique check will fail during dry submit
|
||||
// "OnModelValidate": 1,
|
||||
// "OnRecordValidate": 1,
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateError": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateError": 1,
|
||||
"OnRecordValidate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterCreateSuccessRequest error response",
|
||||
Name: "OnRecordCreateRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/demo2/records",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordCreateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// ID checks
|
||||
@@ -1928,14 +2033,9 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"total+":4,
|
||||
"total-":2
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "@request.body.field with compute modifers (rule pass check)",
|
||||
@@ -2767,21 +2867,31 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterUpdateSuccessRequest error response",
|
||||
Name: "OnRecordUpdateRequest tx body write check",
|
||||
Method: http.MethodPatch,
|
||||
URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordUpdateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordUpdateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
{
|
||||
Name: "try to change the id of an existing record",
|
||||
|
||||
+51
-40
@@ -6,11 +6,13 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -127,7 +129,9 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
|
||||
result.Meta = e.Meta
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, result)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,7 +148,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
return true, err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
query := e.App.RecordQuery(record.Collection()).
|
||||
Select("(1)").
|
||||
@@ -163,7 +167,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
return true, err
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
return exists > 0, nil
|
||||
}
|
||||
|
||||
// checkMFA handles any MFA auth checks that needs to be performed for the specified request event.
|
||||
@@ -453,8 +457,8 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
|
||||
managedIds := []string{}
|
||||
|
||||
query := app.RecordQuery(collection).
|
||||
Select(app.DB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(app.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
Select(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
|
||||
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.ManageRule).BuildExpr(resolver)
|
||||
@@ -479,40 +483,6 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAuthManageAccess checks whether the client is allowed to have
|
||||
// [forms.RecordUpsert] auth management permissions
|
||||
// (e.g. allowing to change system auth fields without oldPassword).
|
||||
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, record *core.Record) bool {
|
||||
if !record.Collection().IsAuth() {
|
||||
return false
|
||||
}
|
||||
|
||||
manageRule := record.Collection().ManageRule
|
||||
|
||||
if manageRule == nil || *manageRule == "" {
|
||||
return false // only for superusers (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestInfo == nil || requestInfo.Auth == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := core.NewRecordFieldResolver(app, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, findErr := app.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
|
||||
return findErr == nil
|
||||
}
|
||||
|
||||
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
|
||||
var superuserOnlyRuleFields = []string{"@collection.", "@request."}
|
||||
|
||||
@@ -567,6 +537,27 @@ func firstApiError(errs ...error) *router.ApiError {
|
||||
return router.NewInternalServerError("", errors.Join(errs...))
|
||||
}
|
||||
|
||||
// execAfterSuccessTx ensures that fn is executed only after a succesul transaction.
|
||||
//
|
||||
// If the current app instance is not a transactional or checkTx is false,
|
||||
// then fn is directly executed.
|
||||
//
|
||||
// It could be usually used to allow propagating an error or writing
|
||||
// custom response from within the wrapped transaction block.
|
||||
func execAfterSuccessTx(checkTx bool, app core.App, fn func() error) error {
|
||||
if txInfo := app.TxInfo(); txInfo != nil && checkTx {
|
||||
txInfo.OnComplete(func(txErr error) error {
|
||||
if txErr == nil {
|
||||
return fn()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const maxAuthOrigins = 5
|
||||
@@ -603,8 +594,28 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
|
||||
}
|
||||
|
||||
// send email alert for the new origin auth (skip first login)
|
||||
//
|
||||
// Note: The "fake" timeout is a temp solution to avoid blocking
|
||||
// for too long when the SMTP server is not accessible, due
|
||||
// to the lack of context cancellation support in the underlying
|
||||
// mailer and net/smtp package.
|
||||
// The goroutine technically "leaks" but we assume that the OS will
|
||||
// terminate the connection after some time (usually after 3-4 mins).
|
||||
if !isFirstLogin && currentOrigin.IsNew() && authRecord.Email() != "" {
|
||||
if err := mails.SendRecordAuthAlert(e.App, authRecord); err != nil {
|
||||
mailSent := make(chan error, 1)
|
||||
|
||||
timer := time.AfterFunc(15*time.Second, func() {
|
||||
mailSent <- errors.New("auth alert mail send wait timeout reached")
|
||||
})
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
err := mails.SendRecordAuthAlert(e.App, authRecord)
|
||||
timer.Stop()
|
||||
mailSent <- err
|
||||
})
|
||||
|
||||
err = <-mailSent
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
+51
-30
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
@@ -83,6 +83,12 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
if e.Request.PathValue(StaticWildcardParam) != "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
// add a default CSP
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
@@ -137,9 +143,9 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
// higher defaults to accommodate large file uploads/downloads
|
||||
WriteTimeout: 3 * time.Minute,
|
||||
ReadTimeout: 3 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
ReadHeaderTimeout: 1 * time.Minute,
|
||||
Addr: mainAddr,
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return baseCtx
|
||||
@@ -152,6 +158,7 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
serveEvent.Router = pbRouter
|
||||
serveEvent.Server = server
|
||||
serveEvent.CertManager = certManager
|
||||
serveEvent.InstallerFunc = DefaultInstallerFunc
|
||||
|
||||
var listener net.Listener
|
||||
|
||||
@@ -198,6 +205,8 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}()
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
var baseURL string
|
||||
|
||||
// trigger the OnServe hook and start the tcp listener
|
||||
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||
handler, err := e.Router.BuildMux()
|
||||
@@ -207,10 +216,20 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
|
||||
e.Server.Handler = handler
|
||||
|
||||
addr := e.Server.Addr
|
||||
if config.HttpsAddr == "" {
|
||||
baseURL = "http://" + serverAddrToHost(serveEvent.Server.Addr)
|
||||
} else {
|
||||
baseURL = "https://"
|
||||
if len(config.CertificateDomains) > 0 {
|
||||
baseURL += config.CertificateDomains[0]
|
||||
} else {
|
||||
baseURL += serverAddrToHost(serveEvent.Server.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
|
||||
addr := e.Server.Addr
|
||||
if addr == "" {
|
||||
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
|
||||
if config.HttpsAddr != "" {
|
||||
addr = ":https"
|
||||
} else {
|
||||
@@ -218,31 +237,32 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
var lnErr error
|
||||
listener, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, lnErr = net.Listen("tcp", addr)
|
||||
if e.InstallerFunc != nil {
|
||||
app := e.App
|
||||
installerFunc := e.InstallerFunc
|
||||
routine.FireAndForget(func() {
|
||||
if err := loadInstaller(app, baseURL, installerFunc); err != nil {
|
||||
app.Logger().Warn("Failed to initialize installer", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return lnErr
|
||||
return nil
|
||||
})
|
||||
if serveHookErr != nil {
|
||||
return serveHookErr
|
||||
}
|
||||
|
||||
if listener == nil {
|
||||
//nolint:staticcheck
|
||||
return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?")
|
||||
}
|
||||
|
||||
schema := "http"
|
||||
addr := server.Addr
|
||||
if config.HttpsAddr != "" {
|
||||
schema = "https"
|
||||
if len(config.CertificateDomains) > 0 {
|
||||
addr = config.CertificateDomains[0]
|
||||
}
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s", schema, addr)
|
||||
dashboardURL := fmt.Sprintf("%s/_", baseURL)
|
||||
|
||||
if config.ShowStartBanner {
|
||||
date := new(strings.Builder)
|
||||
log.New(date, "", log.LstdFlags).Print()
|
||||
@@ -256,16 +276,9 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
|
||||
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/", dashboardURL))
|
||||
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
|
||||
}
|
||||
|
||||
go func() {
|
||||
installerErr := loadInstaller(app, dashboardURL)
|
||||
if installerErr != nil {
|
||||
app.Logger().Warn("Failed to initialize installer", "error", installerErr)
|
||||
}
|
||||
}()
|
||||
|
||||
var serveErr error
|
||||
if config.HttpsAddr != "" {
|
||||
if config.HttpAddr != "" {
|
||||
@@ -274,10 +287,10 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}
|
||||
|
||||
// start HTTPS server
|
||||
serveErr = server.ServeTLS(listener, "", "")
|
||||
serveErr = serveEvent.Server.ServeTLS(listener, "", "")
|
||||
} else {
|
||||
// OR start HTTP server
|
||||
serveErr = server.Serve(listener)
|
||||
serveErr = serveEvent.Server.Serve(listener)
|
||||
}
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
return serveErr
|
||||
@@ -286,6 +299,14 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverAddrToHost loosely converts http.Server.Addr string into a host to print.
|
||||
func serverAddrToHost(addr string) string {
|
||||
if addr == "" || strings.HasSuffix(addr, ":http") || strings.HasSuffix(addr, ":https") {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
type serverErrorLogWriter struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
+6
-2
@@ -30,7 +30,9 @@ func settingsList(e *core.RequestEvent) error {
|
||||
event.Settings = clone
|
||||
|
||||
return e.App.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListRequestEvent) error {
|
||||
return e.JSON(http.StatusOK, e.Settings)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Settings)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,7 +67,9 @@ func settingsSet(e *core.RequestEvent) error {
|
||||
return e.InternalServerError("Failed to clone app settings.", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, appSettings)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, appSettings)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -58,6 +59,32 @@ func TestSettingsList(t *testing.T) {
|
||||
"OnSettingsListRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnSettingsListRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/settings",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnSettingsListRequest().BindFunc(func(e *core.SettingsListRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnSettingsListRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -176,6 +203,33 @@ func TestSettingsSet(t *testing.T) {
|
||||
"OnSettingsReload": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnSettingsUpdateRequest tx body write check",
|
||||
Method: http.MethodPatch,
|
||||
URL: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnSettingsUpdateRequest().BindFunc(func(e *core.SettingsUpdateRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnSettingsUpdateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+19
-19
@@ -36,16 +36,16 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Missing email and password arguments.")
|
||||
return errors.New("missing email and password arguments")
|
||||
}
|
||||
|
||||
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Missing or invalid email address.")
|
||||
return errors.New("missing or invalid email address")
|
||||
}
|
||||
|
||||
superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch %q collection: %w.", core.CollectionNameSuperusers, err)
|
||||
return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(superusersCol, args[0])
|
||||
@@ -57,7 +57,7 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
|
||||
superuser.SetPassword(args[1])
|
||||
|
||||
if err := app.Save(superuser); err != nil {
|
||||
return fmt.Errorf("Failed to upsert superuser account: %w.", err)
|
||||
return fmt.Errorf("failed to upsert superuser account: %w", err)
|
||||
}
|
||||
|
||||
color.Green("Successfully saved superuser %q!", superuser.Email())
|
||||
@@ -76,16 +76,16 @@ func superuserCreateCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Missing email and password arguments.")
|
||||
return errors.New("missing email and password arguments")
|
||||
}
|
||||
|
||||
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Missing or invalid email address.")
|
||||
return errors.New("missing or invalid email address")
|
||||
}
|
||||
|
||||
superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch %q collection: %w.", core.CollectionNameSuperusers, err)
|
||||
return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
|
||||
}
|
||||
|
||||
superuser := core.NewRecord(superusersCol)
|
||||
@@ -93,7 +93,7 @@ func superuserCreateCommand(app core.App) *cobra.Command {
|
||||
superuser.SetPassword(args[1])
|
||||
|
||||
if err := app.Save(superuser); err != nil {
|
||||
return fmt.Errorf("Failed to create new superuser account: %w.", err)
|
||||
return fmt.Errorf("failed to create new superuser account: %w", err)
|
||||
}
|
||||
|
||||
color.Green("Successfully created new superuser %q!", superuser.Email())
|
||||
@@ -112,22 +112,22 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Missing email and password arguments.")
|
||||
return errors.New("missing email and password arguments")
|
||||
}
|
||||
|
||||
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Missing or invalid email address.")
|
||||
return errors.New("missing or invalid email address")
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
|
||||
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
|
||||
}
|
||||
|
||||
superuser.SetPassword(args[1])
|
||||
|
||||
if err := app.Save(superuser); err != nil {
|
||||
return fmt.Errorf("Failed to change superuser %q password: %w.", superuser.Email(), err)
|
||||
return fmt.Errorf("failed to change superuser %q password: %w", superuser.Email(), err)
|
||||
}
|
||||
|
||||
color.Green("Successfully changed superuser %q password!", superuser.Email())
|
||||
@@ -146,17 +146,17 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Invalid or missing email address.")
|
||||
return errors.New("invalid or missing email address")
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
|
||||
if err != nil {
|
||||
color.Yellow("Superuser %q is missing or already deleted.", args[0])
|
||||
color.Yellow("superuser %q is missing or already deleted", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := app.Delete(superuser); err != nil {
|
||||
return fmt.Errorf("Failed to delete superuser %q: %w.", superuser.Email(), err)
|
||||
return fmt.Errorf("failed to delete superuser %q: %w", superuser.Email(), err)
|
||||
}
|
||||
|
||||
color.Green("Successfully deleted superuser %q!", superuser.Email())
|
||||
@@ -175,16 +175,16 @@ func superuserOTPCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Invalid or missing email address.")
|
||||
return errors.New("invalid or missing email address")
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
|
||||
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
|
||||
}
|
||||
|
||||
if !superuser.Collection().OTP.Enabled {
|
||||
return errors.New("OTP is not enabled for the _superusers collection.")
|
||||
return errors.New("OTP auth is not enabled for the _superusers collection")
|
||||
}
|
||||
|
||||
pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
|
||||
@@ -196,7 +196,7 @@ func superuserOTPCommand(app core.App) *cobra.Command {
|
||||
|
||||
err = app.Save(otp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create OTP: %w", err)
|
||||
return fmt.Errorf("failed to create OTP: %w", err)
|
||||
}
|
||||
|
||||
color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
|
||||
|
||||
+90
-31
@@ -45,6 +45,12 @@ type App interface {
|
||||
// IsTransactional checks if the current app instance is part of a transaction.
|
||||
IsTransactional() bool
|
||||
|
||||
// TxInfo returns the transaction associated with the current app instance (if any).
|
||||
//
|
||||
// Could be used if you want to execute indirectly a function after
|
||||
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
|
||||
TxInfo() *TxAppInfo
|
||||
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
@@ -71,7 +77,7 @@ type App interface {
|
||||
Settings() *Settings
|
||||
|
||||
// Store returns the app runtime store.
|
||||
Store() *store.Store[any]
|
||||
Store() *store.Store[string, any]
|
||||
|
||||
// Cron returns the app cron instance.
|
||||
Cron() *cron.Cron
|
||||
@@ -91,7 +97,7 @@ type App interface {
|
||||
// after you are done working with it.
|
||||
NewFilesystem() (*filesystem.System, error)
|
||||
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// NewBackupsFilesystem creates a new local or S3 filesystem instance
|
||||
// for managing app backups based on the current app settings.
|
||||
//
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
@@ -140,46 +146,82 @@ type App interface {
|
||||
// DB methods
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// DB returns the default app data db instance (pb_data/data.db).
|
||||
// DB returns the default app data.db builder instance.
|
||||
//
|
||||
// To minimize SQLITE_BUSY errors, it automatically routes the
|
||||
// SELECT queries to the underlying concurrent db pool and everything else
|
||||
// to the nonconcurrent one.
|
||||
//
|
||||
// For more finer control over the used connections pools you can
|
||||
// call directly ConcurrentDB() or NonconcurrentDB().
|
||||
DB() dbx.Builder
|
||||
|
||||
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
|
||||
// ConcurrentDB returns the concurrent app data.db builder instance.
|
||||
//
|
||||
// This method is used mainly internally for executing db read
|
||||
// operations in a concurrent/non-blocking manner.
|
||||
//
|
||||
// Most users should use simply DB() as it will automatically
|
||||
// route the query execution to ConcurrentDB() or NonconcurrentDB().
|
||||
//
|
||||
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
ConcurrentDB() dbx.Builder
|
||||
|
||||
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
|
||||
//
|
||||
// The returned db instance is limited only to a single open connection,
|
||||
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
|
||||
// meaning that it can process only 1 db operation at a time (other queries queue up).
|
||||
//
|
||||
// This method is used mainly internally and in the tests to execute write
|
||||
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
|
||||
//
|
||||
// For the majority of cases you would want to use the regular DB() method
|
||||
// since it allows concurrent db read operations.
|
||||
// Most users should use simply DB() as it will automatically
|
||||
// route the query execution to ConcurrentDB() or NonconcurrentDB().
|
||||
//
|
||||
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
NonconcurrentDB() dbx.Builder
|
||||
|
||||
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
|
||||
// AuxDB returns the app auxiliary.db builder instance.
|
||||
//
|
||||
// To minimize SQLITE_BUSY errors, it automatically routes the
|
||||
// SELECT queries to the underlying concurrent db pool and everything else
|
||||
// to the nonconcurrent one.
|
||||
//
|
||||
// For more finer control over the used connections pools you can
|
||||
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
AuxDB() dbx.Builder
|
||||
|
||||
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db)..
|
||||
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
|
||||
//
|
||||
// This method is used mainly internally for executing db read
|
||||
// operations in a concurrent/non-blocking manner.
|
||||
//
|
||||
// Most users should use simply AuxDB() as it will automatically
|
||||
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
//
|
||||
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
AuxConcurrentDB() dbx.Builder
|
||||
|
||||
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
|
||||
//
|
||||
// The returned db instance is limited only to a single open connection,
|
||||
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
|
||||
// meaning that it can process only 1 db operation at a time (other queries queue up).
|
||||
//
|
||||
// This method is used mainly internally and in the tests to execute write
|
||||
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
|
||||
//
|
||||
// For the majority of cases you would want to use the regular DB() method
|
||||
// since it allows concurrent db read operations.
|
||||
// Most users should use simply AuxDB() as it will automatically
|
||||
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
//
|
||||
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
AuxNonconcurrentDB() dbx.Builder
|
||||
|
||||
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
|
||||
// in the current app.DB() instance.
|
||||
// in the data.db.
|
||||
HasTable(tableName string) bool
|
||||
|
||||
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
|
||||
// in the current app.AuxDB() instance.
|
||||
// in the auxiliary.db.
|
||||
AuxHasTable(tableName string) bool
|
||||
|
||||
// TableColumns returns all column names of a single table by its name.
|
||||
@@ -225,21 +267,19 @@ type App interface {
|
||||
// FindRecordByViewFile returns the original Record of the provided view collection file.
|
||||
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
|
||||
|
||||
// Vacuum executes VACUUM on the current app.DB() instance
|
||||
// in order to reclaim unused data db disk space.
|
||||
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
|
||||
Vacuum() error
|
||||
|
||||
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
|
||||
// in order to reclaim unused auxiliary db disk space.
|
||||
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
|
||||
AuxVacuum() error
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// ModelQuery creates a new preconfigured select app.DB() query with preset
|
||||
// ModelQuery creates a new preconfigured select data.db query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
ModelQuery(model Model) *dbx.SelectQuery
|
||||
|
||||
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
|
||||
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
AuxModelQuery(model Model) *dbx.SelectQuery
|
||||
|
||||
@@ -323,10 +363,10 @@ type App interface {
|
||||
// FindLogById finds a single Log entry by its id.
|
||||
FindLogById(id string) (*Log, error)
|
||||
|
||||
// LogsStatsItem defines the total number of logs for a specific time period.
|
||||
// LogsStatsItem returns hourly grouped logs statistics.
|
||||
LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error)
|
||||
|
||||
// DeleteOldLogs delete all requests that are created before createdBefore.
|
||||
// DeleteOldLogs delete all logs that are created before createdBefore.
|
||||
DeleteOldLogs(createdBefore time.Time) error
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -369,16 +409,9 @@ type App interface {
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections()]
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections]
|
||||
FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error)
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
IsCollectionNameUnique(name string, excludeIds ...string) bool
|
||||
|
||||
// FindCollectionReferences returns information for all relation
|
||||
// fields referencing the provided collection.
|
||||
//
|
||||
@@ -387,6 +420,32 @@ type App interface {
|
||||
// as the excludeIds argument.
|
||||
FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
|
||||
|
||||
// FindCachedCollectionReferences is similar to [App.FindCollectionReferences]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [App.FindCollectionReferences] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
// - The returned Collection should be used only for read-only operations.
|
||||
// Avoid directly modifying the returned cached Collection as it will affect
|
||||
// the global cached value even if you don't persist the changes in the database!
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections].
|
||||
FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
IsCollectionNameUnique(name string, excludeIds ...string) bool
|
||||
|
||||
// TruncateCollection deletes all records associated with the provided collection.
|
||||
//
|
||||
// The truncate operation is executed in a single transaction,
|
||||
|
||||
+162
-44
@@ -26,6 +26,8 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -69,8 +71,8 @@ var _ App = (*BaseApp)(nil)
|
||||
// BaseApp implements core.App and defines the base PocketBase app structure.
|
||||
type BaseApp struct {
|
||||
config *BaseAppConfig
|
||||
txInfo *txAppInfo
|
||||
store *store.Store[any]
|
||||
txInfo *TxAppInfo
|
||||
store *store.Store[string, any]
|
||||
cron *cron.Cron
|
||||
settings *Settings
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
@@ -194,7 +196,7 @@ type BaseApp struct {
|
||||
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
settings: newDefaultSettings(),
|
||||
store: store.New[any](nil),
|
||||
store: store.New[string, any](nil),
|
||||
cron: cron.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
config: &config,
|
||||
@@ -360,9 +362,17 @@ func (app *BaseApp) Logger() *slog.Logger {
|
||||
return app.logger
|
||||
}
|
||||
|
||||
// TxInfo returns the transaction associated with the current app instance (if any).
|
||||
//
|
||||
// Could be used if you want to execute indirectly a function after
|
||||
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
|
||||
func (app *BaseApp) TxInfo() *TxAppInfo {
|
||||
return app.txInfo
|
||||
}
|
||||
|
||||
// IsTransactional checks if the current app instance is part of a transaction.
|
||||
func (app *BaseApp) IsTransactional() bool {
|
||||
return app.txInfo != nil
|
||||
return app.TxInfo() != nil
|
||||
}
|
||||
|
||||
// IsBootstrapped checks if the application was initialized
|
||||
@@ -466,44 +476,100 @@ func (app *BaseApp) ResetBootstrapState() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DB returns the default app data db instance (pb_data/data.db).
|
||||
// DB returns the default app data.db builder instance.
|
||||
//
|
||||
// To minimize SQLITE_BUSY errors, it automatically routes the
|
||||
// SELECT queries to the underlying concurrent db pool and everything
|
||||
// else to the nonconcurrent one.
|
||||
//
|
||||
// For more finer control over the used connections pools you can
|
||||
// call directly ConcurrentDB() or NonconcurrentDB().
|
||||
func (app *BaseApp) DB() dbx.Builder {
|
||||
// transactional or both are nil
|
||||
if app.concurrentDB == app.nonconcurrentDB {
|
||||
return app.concurrentDB
|
||||
}
|
||||
|
||||
return &dualDBBuilder{
|
||||
concurrentDB: app.concurrentDB,
|
||||
nonconcurrentDB: app.nonconcurrentDB,
|
||||
}
|
||||
}
|
||||
|
||||
// ConcurrentDB returns the concurrent app data.db builder instance.
|
||||
//
|
||||
// This method is used mainly internally for executing db read
|
||||
// operations in a concurrent/non-blocking manner.
|
||||
//
|
||||
// Most users should use simply DB() as it will automatically
|
||||
// route the query execution to ConcurrentDB() or NonconcurrentDB().
|
||||
//
|
||||
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
func (app *BaseApp) ConcurrentDB() dbx.Builder {
|
||||
return app.concurrentDB
|
||||
}
|
||||
|
||||
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
|
||||
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
|
||||
//
|
||||
// The returned db instance is limited only to a single open connection,
|
||||
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
|
||||
// meaning that it can process only 1 db operation at a time (other queries queue up).
|
||||
//
|
||||
// This method is used mainly internally and in the tests to execute write
|
||||
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
|
||||
//
|
||||
// For the majority of cases you would want to use the regular DB() method
|
||||
// since it allows concurrent db read operations.
|
||||
// Most users should use simply DB() as it will automatically
|
||||
// route the query execution to ConcurrentDB() or NonconcurrentDB().
|
||||
//
|
||||
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
func (app *BaseApp) NonconcurrentDB() dbx.Builder {
|
||||
return app.nonconcurrentDB
|
||||
}
|
||||
|
||||
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
|
||||
// AuxDB returns the app auxiliary.db builder instance.
|
||||
//
|
||||
// To minimize SQLITE_BUSY errors, it automatically routes the
|
||||
// SELECT queries to the underlying concurrent db pool and everything
|
||||
// else to the nonconcurrent one.
|
||||
//
|
||||
// For more finer control over the used connections pools you can
|
||||
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
func (app *BaseApp) AuxDB() dbx.Builder {
|
||||
// transactional or both are nil
|
||||
if app.auxConcurrentDB == app.auxNonconcurrentDB {
|
||||
return app.auxConcurrentDB
|
||||
}
|
||||
|
||||
return &dualDBBuilder{
|
||||
concurrentDB: app.auxConcurrentDB,
|
||||
nonconcurrentDB: app.auxNonconcurrentDB,
|
||||
}
|
||||
}
|
||||
|
||||
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
|
||||
//
|
||||
// This method is used mainly internally for executing db read
|
||||
// operations in a concurrent/non-blocking manner.
|
||||
//
|
||||
// Most users should use simply AuxDB() as it will automatically
|
||||
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
//
|
||||
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
func (app *BaseApp) AuxConcurrentDB() dbx.Builder {
|
||||
return app.auxConcurrentDB
|
||||
}
|
||||
|
||||
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db).
|
||||
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
|
||||
//
|
||||
// The returned db instance is limited only to a single open connection,
|
||||
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
|
||||
// meaning that it can process only 1 db operation at a time (other queries queue up).
|
||||
//
|
||||
// This method is used mainly internally and in the tests to execute write
|
||||
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
|
||||
//
|
||||
// For the majority of cases you would want to use the regular DB() method
|
||||
// since it allows concurrent db read operations.
|
||||
// Most users should use simply AuxDB() as it will automatically
|
||||
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
//
|
||||
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
func (app *BaseApp) AuxNonconcurrentDB() dbx.Builder {
|
||||
return app.auxNonconcurrentDB
|
||||
}
|
||||
@@ -532,7 +598,7 @@ func (app *BaseApp) Settings() *Settings {
|
||||
}
|
||||
|
||||
// Store returns the app runtime store.
|
||||
func (app *BaseApp) Store() *store.Store[any] {
|
||||
func (app *BaseApp) Store() *store.Store[string, any] {
|
||||
return app.store
|
||||
}
|
||||
|
||||
@@ -601,6 +667,14 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
|
||||
}
|
||||
|
||||
if len(ae.Message.InlineAttachments) > 0 {
|
||||
attachmentKeys := make([]string, 0, len(ae.Message.InlineAttachments))
|
||||
for k := range ae.Message.InlineAttachments {
|
||||
attachmentKeys = append(attachmentKeys, k)
|
||||
}
|
||||
fmt.Fprintf(mailLog, "├─ InlineAttachments: %v\n", attachmentKeys)
|
||||
}
|
||||
|
||||
const indentation = " "
|
||||
if ae.Message.Text != "" {
|
||||
textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
|
||||
@@ -651,7 +725,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
|
||||
}
|
||||
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// NewBackupsFilesystem creates a new local or S3 filesystem instance
|
||||
// for managing app backups based on the current app settings.
|
||||
//
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
@@ -1131,26 +1205,25 @@ func (app *BaseApp) initDataDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sqlLogReplacements = map[string]string{
|
||||
"{{": "`",
|
||||
"}}": "`",
|
||||
"[[": "`",
|
||||
"]]": "`",
|
||||
"<nil>": "NULL",
|
||||
var sqlLogReplacements = []struct {
|
||||
pattern *regexp.Regexp
|
||||
replacement string
|
||||
}{
|
||||
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
|
||||
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
|
||||
{regexp.MustCompile(`([^'"])?\{\{`), "$1`"},
|
||||
{regexp.MustCompile(`\}\}([^'"])?`), "`$1"},
|
||||
{regexp.MustCompile(`([^'"])?\[\[`), "$1`"},
|
||||
{regexp.MustCompile(`\]\]([^'"])?`), "`$1"},
|
||||
{regexp.MustCompile(`<nil>`), "NULL"},
|
||||
}
|
||||
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`)
|
||||
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`)
|
||||
|
||||
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
|
||||
// The query is still not suitable for execution and should be used only for log and debug purposes
|
||||
// (the normalization is done here to avoid breaking changes in dbx).
|
||||
func normalizeSQLLog(sql string) string {
|
||||
sql = sqlLogPrefixedTableIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
|
||||
|
||||
sql = sqlLogPrefixedColumnIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
|
||||
|
||||
for old, new := range sqlLogReplacements {
|
||||
sql = strings.ReplaceAll(sql, old, new)
|
||||
for _, item := range sqlLogReplacements {
|
||||
sql = item.pattern.ReplaceAllString(sql, item.replacement)
|
||||
}
|
||||
|
||||
return sql
|
||||
@@ -1183,6 +1256,33 @@ func (app *BaseApp) initAuxDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @todo remove after refactoring the FilesManager interface
|
||||
func supportFiles(m Model) bool {
|
||||
var collection *Collection
|
||||
switch v := m.(type) {
|
||||
case *Collection:
|
||||
collection = v
|
||||
case *Record:
|
||||
collection = v.Collection()
|
||||
case RecordProxy:
|
||||
if v.ProxyRecord() != nil {
|
||||
collection = v.ProxyRecord().Collection()
|
||||
}
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, f := range collection.Fields {
|
||||
if f.Type() == FieldTypeFile {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *BaseApp) registerBaseHooks() {
|
||||
deletePrefix := func(prefix string) error {
|
||||
fs, err := app.NewFilesystem()
|
||||
@@ -1199,26 +1299,44 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxFilesDeleteWorkers := cast.ToInt64(os.Getenv("PB_FILES_DELETE_MAX_WORKERS"))
|
||||
if maxFilesDeleteWorkers <= 0 {
|
||||
maxFilesDeleteWorkers = 2000 // the value is arbitrary chosen and may change in the future
|
||||
}
|
||||
|
||||
deleteSem := semaphore.NewWeighted(maxFilesDeleteWorkers)
|
||||
|
||||
// try to delete the storage files from deleted Collection, Records, etc. model
|
||||
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
|
||||
Id: "__pbFilesManagerDelete__",
|
||||
Func: func(e *ModelEvent) error {
|
||||
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" {
|
||||
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" && supportFiles(e.Model) {
|
||||
// ensure that there is a trailing slash so that the list iterator could start walking from the prefix dir
|
||||
// (https://github.com/pocketbase/pocketbase/discussions/5246#discussioncomment-10128955)
|
||||
prefix := strings.TrimRight(m.BaseFilesPath(), "/") + "/"
|
||||
|
||||
// run in the background for "optimistic" delete to avoid
|
||||
// blocking the delete transaction
|
||||
routine.FireAndForget(func() {
|
||||
if err := deletePrefix(prefix); err != nil {
|
||||
app.Logger().Error(
|
||||
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
|
||||
slog.String("prefix", prefix),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
// note: for now assume no context cancellation
|
||||
err := deleteSem.Acquire(context.Background(), 1)
|
||||
if err != nil {
|
||||
app.Logger().Error(
|
||||
"Failed to delete storage prefix (couldn't acquire a worker)",
|
||||
slog.String("prefix", prefix),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
} else {
|
||||
// run in the background for "optimistic" delete to avoid blocking the delete transaction
|
||||
routine.FireAndForget(func() {
|
||||
defer deleteSem.Release(1)
|
||||
|
||||
if err := deletePrefix(prefix); err != nil {
|
||||
app.Logger().Error(
|
||||
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
|
||||
slog.String("prefix", prefix),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
@@ -1247,7 +1365,7 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
|
||||
}
|
||||
|
||||
_, execErr = app.DB().NewQuery("PRAGMA optimize").Execute()
|
||||
_, execErr = app.ConcurrentDB().NewQuery("PRAGMA optimize").Execute()
|
||||
if execErr != nil {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
|
||||
}
|
||||
|
||||
+64
-39
@@ -142,6 +142,10 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
//
|
||||
// Note that if your pb_data has custom network mounts as subdirectories, then
|
||||
// it is possible the restore to fail during the `os.Rename` operations
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/4647).
|
||||
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
@@ -162,6 +166,13 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
return errors.New("restore is not supported on Windows")
|
||||
}
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
fsys, err := e.App.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -170,57 +181,71 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
fsys.SetContext(e.Context)
|
||||
|
||||
// fetch the backup file in a temp location
|
||||
br, err := fsys.GetFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
if ok, _ := fsys.Exists(name); !ok {
|
||||
return fmt.Errorf("missing or invalid backup file %q to restore", name)
|
||||
}
|
||||
|
||||
// create a temp zip file from the blob.Reader and try to extract it
|
||||
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tempZip.Name())
|
||||
|
||||
if _, err := io.Copy(tempZip, br); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(8))
|
||||
defer os.RemoveAll(extractedDataDir)
|
||||
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
|
||||
return err
|
||||
|
||||
// extract the zip
|
||||
if e.App.Settings().Backups.S3.Enabled {
|
||||
br, err := fsys.GetReader(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
// create a temp zip file from the blob.Reader and try to extract it
|
||||
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tempZip.Name())
|
||||
defer tempZip.Close() // note: this technically shouldn't be necessary but it is here to workaround platforms discrepancies
|
||||
|
||||
_, err = io.Copy(tempZip, br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = archive.Extract(tempZip.Name(), extractedDataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove the temp zip file since we no longer need it
|
||||
// (this is in case the app restarts and the defer calls are not called)
|
||||
_ = tempZip.Close()
|
||||
err = os.Remove(tempZip.Name())
|
||||
if err != nil {
|
||||
e.App.Logger().Warn(
|
||||
"[RestoreBackup] Failed to remove the temp zip backup file",
|
||||
slog.String("file", tempZip.Name()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// manually construct the local path to avoid creating a copy of the zip file
|
||||
// since the blob reader currently doesn't implement ReaderAt
|
||||
zipPath := filepath.Join(app.DataDir(), LocalBackupsDirName, filepath.Base(name))
|
||||
|
||||
err = archive.Extract(zipPath, extractedDataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that a database file exists
|
||||
// ensure that at least a database file exists
|
||||
extractedDB := filepath.Join(extractedDataDir, "data.db")
|
||||
if _, err := os.Stat(extractedDB); err != nil {
|
||||
return fmt.Errorf("data.db file is missing or invalid: %w", err)
|
||||
}
|
||||
|
||||
// remove the extracted zip file since we no longer need it
|
||||
// (this is in case the app restarts and the defer calls are not called)
|
||||
if err := os.Remove(tempZip.Name()); err != nil {
|
||||
e.App.Logger().Debug(
|
||||
"[RestoreBackup] Failed to remove the temp zip backup file",
|
||||
slog.String("file", tempZip.Name()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// move the current pb_data content to a special temp location
|
||||
// that will hold the old data between dirs replace
|
||||
// (the temp dir will be automatically removed on the next app start)
|
||||
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
|
||||
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
|
||||
if err := osutils.MoveDirContent(e.App.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
|
||||
}
|
||||
@@ -257,7 +282,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// registerAutobackupHooks registers the autobackup app serve hooks.
|
||||
func (app *BaseApp) registerAutobackupHooks() {
|
||||
const jobId = "__auto_pb_backup__"
|
||||
const jobId = "__pbAutoBackup__"
|
||||
|
||||
loadJob := func() {
|
||||
rawSchedule := app.Settings().Backups.Cron
|
||||
|
||||
+185
-11
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -99,9 +100,11 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
}
|
||||
|
||||
nilChecksBeforeReset := []nilCheck{
|
||||
{"[before] concurrentDB", app.DB(), false},
|
||||
{"[before] db", app.DB(), false},
|
||||
{"[before] concurrentDB", app.ConcurrentDB(), false},
|
||||
{"[before] nonconcurrentDB", app.NonconcurrentDB(), false},
|
||||
{"[before] auxConcurrentDB", app.AuxDB(), false},
|
||||
{"[before] auxDB", app.AuxDB(), false},
|
||||
{"[before] auxConcurrentDB", app.AuxConcurrentDB(), false},
|
||||
{"[before] auxNonconcurrentDB", app.AuxNonconcurrentDB(), false},
|
||||
{"[before] settings", app.Settings(), false},
|
||||
{"[before] logger", app.Logger(), false},
|
||||
@@ -116,9 +119,11 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
}
|
||||
|
||||
nilChecksAfterReset := []nilCheck{
|
||||
{"[after] concurrentDB", app.DB(), true},
|
||||
{"[after] db", app.DB(), true},
|
||||
{"[after] concurrentDB", app.ConcurrentDB(), true},
|
||||
{"[after] nonconcurrentDB", app.NonconcurrentDB(), true},
|
||||
{"[after] auxConcurrentDB", app.AuxDB(), true},
|
||||
{"[after] auxDB", app.AuxDB(), true},
|
||||
{"[after] auxConcurrentDB", app.AuxConcurrentDB(), true},
|
||||
{"[after] auxNonconcurrentDB", app.AuxNonconcurrentDB(), true},
|
||||
{"[after] settings", app.Settings(), false},
|
||||
{"[after] logger", app.Logger(), false},
|
||||
@@ -128,7 +133,7 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
runNilChecks(nilChecksAfterReset)
|
||||
}
|
||||
|
||||
func TestNewBaseAppIsTransactional(t *testing.T) {
|
||||
func TestNewBaseAppTx(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
@@ -141,17 +146,34 @@ func TestNewBaseAppIsTransactional(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if app.IsTransactional() {
|
||||
t.Fatalf("Didn't expect the app to be transactional")
|
||||
mustNotHaveTx := func(app core.App) {
|
||||
if app.IsTransactional() {
|
||||
t.Fatalf("Didn't expect the app to be transactional")
|
||||
}
|
||||
|
||||
if app.TxInfo() != nil {
|
||||
t.Fatalf("Didn't expect the app.txInfo to be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
app.RunInTransaction(func(txApp core.App) error {
|
||||
if !txApp.IsTransactional() {
|
||||
mustHaveTx := func(app core.App) {
|
||||
if !app.IsTransactional() {
|
||||
t.Fatalf("Expected the app to be transactional")
|
||||
}
|
||||
|
||||
if app.TxInfo() == nil {
|
||||
t.Fatalf("Expected the app.txInfo to be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
mustNotHaveTx(app)
|
||||
|
||||
app.RunInTransaction(func(txApp core.App) error {
|
||||
mustHaveTx(txApp)
|
||||
return nil
|
||||
})
|
||||
|
||||
mustNotHaveTx(app)
|
||||
}
|
||||
|
||||
func TestBaseAppNewMailClient(t *testing.T) {
|
||||
@@ -354,8 +376,8 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
// silence query logs
|
||||
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
|
||||
@@ -378,3 +400,155 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppDBDualBuilder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
concurrentQueries := []string{}
|
||||
nonconcurrentQueries := []string{}
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
concurrentQueries = append(concurrentQueries, sql)
|
||||
}
|
||||
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
concurrentQueries = append(concurrentQueries, sql)
|
||||
}
|
||||
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
nonconcurrentQueries = append(nonconcurrentQueries, sql)
|
||||
}
|
||||
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
nonconcurrentQueries = append(nonconcurrentQueries, sql)
|
||||
}
|
||||
|
||||
type testQuery struct {
|
||||
query string
|
||||
isConcurrent bool
|
||||
}
|
||||
|
||||
regularTests := []testQuery{
|
||||
{" \n sEleCt 1", true},
|
||||
{"With abc(x) AS (select 2) SELECT x FROM abc", true},
|
||||
{"create table t1(x int)", false},
|
||||
{"insert into t1(x) values(1)", false},
|
||||
{"update t1 set x = 2", false},
|
||||
{"delete from t1", false},
|
||||
}
|
||||
|
||||
txTests := []testQuery{
|
||||
{"select 3", false},
|
||||
{" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
|
||||
{"create table t2(x int)", false},
|
||||
{"insert into t2(x) values(1)", false},
|
||||
{"update t2 set x = 2", false},
|
||||
{"delete from t2", false},
|
||||
}
|
||||
|
||||
for _, item := range regularTests {
|
||||
_, err := app.DB().NewQuery(item.query).Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.RunInTransaction(func(txApp core.App) error {
|
||||
for _, item := range txTests {
|
||||
_, err := txApp.DB().NewQuery(item.query).Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
allTests := append(regularTests, txTests...)
|
||||
for _, item := range allTests {
|
||||
if item.isConcurrent {
|
||||
if !slices.Contains(concurrentQueries, item.query) {
|
||||
t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
|
||||
}
|
||||
} else {
|
||||
if !slices.Contains(nonconcurrentQueries, item.query) {
|
||||
t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppAuxDBDualBuilder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
concurrentQueries := []string{}
|
||||
nonconcurrentQueries := []string{}
|
||||
app.AuxConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
concurrentQueries = append(concurrentQueries, sql)
|
||||
}
|
||||
app.AuxConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
concurrentQueries = append(concurrentQueries, sql)
|
||||
}
|
||||
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
nonconcurrentQueries = append(nonconcurrentQueries, sql)
|
||||
}
|
||||
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
nonconcurrentQueries = append(nonconcurrentQueries, sql)
|
||||
}
|
||||
|
||||
type testQuery struct {
|
||||
query string
|
||||
isConcurrent bool
|
||||
}
|
||||
|
||||
regularTests := []testQuery{
|
||||
{" \n sEleCt 1", true},
|
||||
{"With abc(x) AS (select 2) SELECT x FROM abc", true},
|
||||
{"create table t1(x int)", false},
|
||||
{"insert into t1(x) values(1)", false},
|
||||
{"update t1 set x = 2", false},
|
||||
{"delete from t1", false},
|
||||
}
|
||||
|
||||
txTests := []testQuery{
|
||||
{"select 3", false},
|
||||
{" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
|
||||
{"create table t2(x int)", false},
|
||||
{"insert into t2(x) values(1)", false},
|
||||
{"update t2 set x = 2", false},
|
||||
{"delete from t2", false},
|
||||
}
|
||||
|
||||
for _, item := range regularTests {
|
||||
_, err := app.AuxDB().NewQuery(item.query).Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.AuxRunInTransaction(func(txApp core.App) error {
|
||||
for _, item := range txTests {
|
||||
_, err := txApp.AuxDB().NewQuery(item.query).Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
allTests := append(regularTests, txTests...)
|
||||
for _, item := range allTests {
|
||||
if item.isConcurrent {
|
||||
if !slices.Contains(concurrentQueries, item.query) {
|
||||
t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
|
||||
}
|
||||
} else {
|
||||
if !slices.Contains(nonconcurrentQueries, item.query) {
|
||||
t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func TestImportCollections(t *testing.T) {
|
||||
expectedCollectionFields := map[string]int{
|
||||
core.CollectionNameAuthOrigins: 6,
|
||||
"nologin": 10,
|
||||
"demo1": 18,
|
||||
"demo1": 19,
|
||||
"demo2": 5,
|
||||
"demo3": 5,
|
||||
"demo4": 16,
|
||||
|
||||
+60
-19
@@ -28,15 +28,20 @@ const (
|
||||
|
||||
const systemHookIdCollection = "__pbCollectionSystemHook__"
|
||||
|
||||
const defaultLowercaseRecordIdPattern = "^[a-z0-9]+$"
|
||||
|
||||
func (app *BaseApp) registerCollectionHooks() {
|
||||
app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -48,10 +53,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -63,10 +71,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -78,10 +89,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -93,10 +107,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -108,10 +125,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -123,10 +143,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -138,10 +161,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -153,10 +179,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -168,10 +197,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -183,10 +215,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -198,10 +233,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -213,10 +251,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -721,9 +762,9 @@ func (c *Collection) updateGeneratedIdIfExists(app App) {
|
||||
|
||||
// add a number to the current id (if already exists)
|
||||
for i := 2; i < 1000; i++ {
|
||||
var exists bool
|
||||
var exists int
|
||||
_ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
|
||||
if !exists {
|
||||
if exists == 0 {
|
||||
break
|
||||
}
|
||||
newId = c.idChecksum() + strconv.Itoa(i)
|
||||
@@ -890,7 +931,7 @@ func (c *Collection) initIdField() {
|
||||
Required: true,
|
||||
Min: 15,
|
||||
Max: 15,
|
||||
Pattern: `^[a-z0-9]+$`,
|
||||
Pattern: defaultLowercaseRecordIdPattern,
|
||||
AutogeneratePattern: `[a-z0-9]{15}`,
|
||||
}
|
||||
|
||||
@@ -903,7 +944,7 @@ func (c *Collection) initIdField() {
|
||||
field.PrimaryKey = true
|
||||
field.Hidden = false
|
||||
if field.Pattern == "" {
|
||||
field.Pattern = `^[a-z0-9]+$`
|
||||
field.Pattern = defaultLowercaseRecordIdPattern
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -948,7 +989,7 @@ func (c *Collection) initTokenKeyField() {
|
||||
}
|
||||
|
||||
// ensure that there is a unique index for the field
|
||||
if !dbutils.HasSingleColumnUniqueIndex(FieldNameTokenKey, c.Indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
|
||||
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
|
||||
c.fieldIndexName(FieldNameTokenKey),
|
||||
@@ -974,7 +1015,7 @@ func (c *Collection) initEmailField() {
|
||||
}
|
||||
|
||||
// ensure that there is a unique index for the email field
|
||||
if !dbutils.HasSingleColumnUniqueIndex(FieldNameEmail, c.Indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
|
||||
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
|
||||
c.fieldIndexName(FieldNameEmail),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -976,6 +980,280 @@ func TestCollectionDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionModelEventSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
testCollections := make([]*core.Collection, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
testCollections[i] = core.NewBaseCollection("sync_test_" + strconv.Itoa(i))
|
||||
if err := app.Save(testCollections[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
createModelEvent := func() *core.ModelEvent {
|
||||
event := new(core.ModelEvent)
|
||||
event.App = app
|
||||
event.Context = context.Background()
|
||||
event.Type = "test_a"
|
||||
event.Model = testCollections[0]
|
||||
return event
|
||||
}
|
||||
|
||||
createModelErrorEvent := func() *core.ModelErrorEvent {
|
||||
event := new(core.ModelErrorEvent)
|
||||
event.ModelEvent = *createModelEvent()
|
||||
event.Error = errors.New("error_a")
|
||||
return event
|
||||
}
|
||||
|
||||
changeCollectionEventBefore := func(e *core.CollectionEvent) {
|
||||
e.Type = "test_b"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 123)
|
||||
e.Collection = testCollections[1]
|
||||
}
|
||||
|
||||
modelEventFinalizerChange := func(e *core.ModelEvent) {
|
||||
e.Type = "test_c"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 456)
|
||||
e.Model = testCollections[2]
|
||||
}
|
||||
|
||||
changeCollectionEventAfter := func(e *core.CollectionEvent) {
|
||||
e.Type = "test_d"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 789)
|
||||
e.Collection = testCollections[3]
|
||||
}
|
||||
|
||||
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
|
||||
if e.Type != "test_a" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != nil {
|
||||
t.Fatalf("Expected context value %v, got %v", nil, v)
|
||||
}
|
||||
|
||||
if e.Model.PK() != testCollections[0].Id {
|
||||
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Model.PK(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
|
||||
if e.Type != "test_d" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != 789 {
|
||||
t.Fatalf("Expected context value %v, got %v", 789, v)
|
||||
}
|
||||
|
||||
if e.Model.PK() != testCollections[3].Id {
|
||||
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[3].Id, e.Model.PK(), 3)
|
||||
}
|
||||
}
|
||||
|
||||
expectedBeforeCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
|
||||
if e.Type != "test_a" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != nil {
|
||||
t.Fatalf("Expected context value %v, got %v", nil, v)
|
||||
}
|
||||
|
||||
if e.Collection.Id != testCollections[0].Id {
|
||||
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Collection.Id, 0)
|
||||
}
|
||||
}
|
||||
|
||||
expectedAfterCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
|
||||
if e.Type != "test_c" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != 456 {
|
||||
t.Fatalf("Expected context value %v, got %v", 456, v)
|
||||
}
|
||||
|
||||
if e.Collection.Id != testCollections[2].Id {
|
||||
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[2].Id, e.Collection.Id, 2)
|
||||
}
|
||||
}
|
||||
|
||||
modelEventFinalizer := func(e *core.ModelEvent) error {
|
||||
modelEventFinalizerChange(e)
|
||||
return nil
|
||||
}
|
||||
|
||||
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
|
||||
modelEventFinalizerChange(&e.ModelEvent)
|
||||
e.Error = errors.New("error_c")
|
||||
return nil
|
||||
}
|
||||
|
||||
modelEventHandler := &hook.Handler[*core.ModelEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
t.Run("before model", func(t *testing.T) {
|
||||
expectedBeforeModelEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after model", func(t *testing.T) {
|
||||
expectedAfterModelEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.ModelErrorEvent) error {
|
||||
t.Run("before model error", func(t *testing.T) {
|
||||
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
|
||||
if v := e.Error.Error(); v != "error_a" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_a", v)
|
||||
}
|
||||
})
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after model error", func(t *testing.T) {
|
||||
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
|
||||
if v := e.Error.Error(); v != "error_d" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_d", v)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
recordEventHandler := &hook.Handler[*core.CollectionEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.CollectionEvent) error {
|
||||
t.Run("before collection", func(t *testing.T) {
|
||||
expectedBeforeCollectionEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
changeCollectionEventBefore(e)
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after collection", func(t *testing.T) {
|
||||
expectedAfterCollectionEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
changeCollectionEventAfter(e)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
collectionErrorEventHandler := &hook.Handler[*core.CollectionErrorEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.CollectionErrorEvent) error {
|
||||
t.Run("before collection error", func(t *testing.T) {
|
||||
expectedBeforeCollectionEventHandlerChecks(t, &e.CollectionEvent)
|
||||
if v := e.Error.Error(); v != "error_a" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_c", v)
|
||||
}
|
||||
})
|
||||
|
||||
changeCollectionEventBefore(&e.CollectionEvent)
|
||||
e.Error = errors.New("error_b")
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after collection error", func(t *testing.T) {
|
||||
expectedAfterCollectionEventHandlerChecks(t, &e.CollectionEvent)
|
||||
if v := e.Error.Error(); v != "error_c" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_c", v)
|
||||
}
|
||||
})
|
||||
|
||||
changeCollectionEventAfter(&e.CollectionEvent)
|
||||
e.Error = errors.New("error_d")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// OnModelValidate
|
||||
app.OnCollectionValidate().Bind(recordEventHandler)
|
||||
app.OnModelValidate().Bind(modelEventHandler)
|
||||
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelCreate
|
||||
app.OnCollectionCreate().Bind(recordEventHandler)
|
||||
app.OnModelCreate().Bind(modelEventHandler)
|
||||
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelCreateExecute
|
||||
app.OnCollectionCreateExecute().Bind(recordEventHandler)
|
||||
app.OnModelCreateExecute().Bind(modelEventHandler)
|
||||
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterCreateSuccess
|
||||
app.OnCollectionAfterCreateSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterCreateError
|
||||
app.OnCollectionAfterCreateError().Bind(collectionErrorEventHandler)
|
||||
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
|
||||
// OnModelUpdate
|
||||
app.OnCollectionUpdate().Bind(recordEventHandler)
|
||||
app.OnModelUpdate().Bind(modelEventHandler)
|
||||
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelUpdateExecute
|
||||
app.OnCollectionUpdateExecute().Bind(recordEventHandler)
|
||||
app.OnModelUpdateExecute().Bind(modelEventHandler)
|
||||
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterUpdateSuccess
|
||||
app.OnCollectionAfterUpdateSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterUpdateError
|
||||
app.OnCollectionAfterUpdateError().Bind(collectionErrorEventHandler)
|
||||
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
|
||||
// OnModelDelete
|
||||
app.OnCollectionDelete().Bind(recordEventHandler)
|
||||
app.OnModelDelete().Bind(modelEventHandler)
|
||||
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelDeleteExecute
|
||||
app.OnCollectionDeleteExecute().Bind(recordEventHandler)
|
||||
app.OnModelDeleteExecute().Bind(modelEventHandler)
|
||||
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterDeleteSuccess
|
||||
app.OnCollectionAfterDeleteSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterDeleteError
|
||||
app.OnCollectionAfterDeleteError().Bind(collectionErrorEventHandler)
|
||||
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
}
|
||||
|
||||
func TestCollectionSaveModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1345,7 +1623,7 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
|
||||
|
||||
var sql string
|
||||
|
||||
rowErr := app.DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
|
||||
rowErr := app.ConcurrentDB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
|
||||
Bind(dbx.Params{"name": viewName}).
|
||||
Row(&sql)
|
||||
if rowErr != nil {
|
||||
|
||||
+73
-30
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -37,7 +38,7 @@ func (app *BaseApp) FindAllCollections(collectionTypes ...string) ([]*Collection
|
||||
q.AndWhere(dbx.In("type", list.ToInterfaceSlice(types)...))
|
||||
}
|
||||
|
||||
err := q.OrderBy("created ASC").All(&collections)
|
||||
err := q.OrderBy("rowid ASC").All(&collections)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -75,16 +76,16 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
|
||||
// FindCachedCollectionByNameOrId is similar to [BaseApp.FindCollectionByNameOrId]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// Returns [sql.ErrNoRows] if no Collection is found for consistency
|
||||
// with the [App.FindCollectionByNameOrId] method.
|
||||
// with the [BaseApp.FindCollectionByNameOrId] method.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [App.FindCollectionByNameOrId] instead.
|
||||
// use [BaseApp.FindCollectionByNameOrId] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
@@ -94,7 +95,7 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections()]
|
||||
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
|
||||
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
|
||||
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
||||
if collections == nil {
|
||||
@@ -111,30 +112,6 @@ func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
query := app.CollectionQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindCollectionReferences returns information for all relation fields
|
||||
// referencing the provided collection.
|
||||
//
|
||||
@@ -168,6 +145,72 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FindCachedCollectionReferences is similar to [BaseApp.FindCollectionReferences]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [BaseApp.FindCollectionReferences] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
// - The returned Collection should be used only for read-only operations.
|
||||
// Avoid directly modifying the returned cached Collection as it will affect
|
||||
// the global cached value even if you don't persist the changes in the database!
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
|
||||
func (app *BaseApp) FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
|
||||
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
||||
if collections == nil {
|
||||
// cache is not initialized yet (eg. run in a system migration)
|
||||
return app.FindCollectionReferences(collection, excludeIds...)
|
||||
}
|
||||
|
||||
result := map[*Collection][]Field{}
|
||||
|
||||
for _, c := range collections {
|
||||
if slices.Contains(excludeIds, c.Id) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rawField := range c.Fields {
|
||||
f, ok := rawField.(*RelationField)
|
||||
if ok && f.CollectionId == collection.Id {
|
||||
result[c] = append(result[c], f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
query := app.CollectionQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var total int
|
||||
|
||||
return query.Row(&total) == nil && total == 0
|
||||
}
|
||||
|
||||
// TruncateCollection deletes all records associated with the provided collection.
|
||||
//
|
||||
// The truncate operation is executed in a single transaction,
|
||||
@@ -177,7 +220,7 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
|
||||
// cascade and file delete actions.
|
||||
func (app *BaseApp) TruncateCollection(collection *Collection) error {
|
||||
if collection.IsView() {
|
||||
return errors.New("view collections cannot be truncated since they don't store their own records.")
|
||||
return errors.New("view collections cannot be truncated since they don't store their own records")
|
||||
}
|
||||
|
||||
return app.RunInTransaction(func(txApp App) error {
|
||||
|
||||
+110
-29
@@ -153,7 +153,7 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
totalQueries := 0
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
totalQueries++
|
||||
}
|
||||
|
||||
@@ -208,34 +208,6 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
|
||||
run(false)
|
||||
}
|
||||
|
||||
func TestIsCollectionNameUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
excludeId string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"demo1", "", false},
|
||||
{"Demo1", "", false},
|
||||
{"new", "", true},
|
||||
{"demo1", "wsmn24bux7wo113", true},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
|
||||
result := app.IsCollectionNameUnique(s.name, s.excludeId)
|
||||
if result != s.expected {
|
||||
t.Errorf("Expected %v, got %v", s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -288,6 +260,115 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCachedCollectionReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
totalQueries := 0
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
totalQueries++
|
||||
}
|
||||
|
||||
run := func(withCache bool) {
|
||||
var expectedTotalQueries int
|
||||
|
||||
if withCache {
|
||||
err := app.ReloadCachedCollections()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
app.Store().Reset(nil)
|
||||
expectedTotalQueries = 1
|
||||
}
|
||||
|
||||
totalQueries = 0
|
||||
|
||||
result, err := app.FindCachedCollectionReferences(
|
||||
collection,
|
||||
collection.Id,
|
||||
// test whether "nonempty" exclude ids condition will be skipped
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
|
||||
}
|
||||
|
||||
expectedFields := []string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_one_no_cascade_required",
|
||||
"rel_one_cascade",
|
||||
"rel_one_unique",
|
||||
"rel_many_no_cascade",
|
||||
"rel_many_no_cascade_required",
|
||||
"rel_many_cascade",
|
||||
"rel_many_unique",
|
||||
}
|
||||
|
||||
for col, fields := range result {
|
||||
if col.Name != "demo4" {
|
||||
t.Fatalf("Expected collection demo4, got %s", col.Name)
|
||||
}
|
||||
if len(fields) != len(expectedFields) {
|
||||
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
|
||||
}
|
||||
for i, f := range fields {
|
||||
if !slices.Contains(expectedFields, f.GetName()) {
|
||||
t.Fatalf("[%d] Didn't expect field %v", i, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalQueries != expectedTotalQueries {
|
||||
t.Fatalf("Expected %d totalQueries, got %d", expectedTotalQueries, totalQueries)
|
||||
}
|
||||
}
|
||||
|
||||
run(true)
|
||||
|
||||
run(false)
|
||||
}
|
||||
|
||||
func TestIsCollectionNameUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
excludeId string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"demo1", "", false},
|
||||
{"Demo1", "", false},
|
||||
{"new", "", true},
|
||||
{"demo1", "wsmn24bux7wo113", true},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
|
||||
result := app.IsCollectionNameUnique(s.name, s.excludeId)
|
||||
if result != s.expected {
|
||||
t.Errorf("Expected %v, got %v", s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionTruncate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ func (app *BaseApp) SyncRecordTableSchema(newCollection *Collection, oldCollecti
|
||||
|
||||
// run optimize per the SQLite recommendations
|
||||
// (https://www.sqlite.org/pragma.html#pragma_optimize)
|
||||
_, optimizeErr := app.DB().NewQuery("PRAGMA optimize").Execute()
|
||||
_, optimizeErr := app.ConcurrentDB().NewQuery("PRAGMA optimize").Execute()
|
||||
if optimizeErr != nil {
|
||||
app.Logger().Warn("Failed to run PRAGMA optimize after record table sync", slog.String("error", optimizeErr.Error()))
|
||||
}
|
||||
@@ -310,7 +310,8 @@ func dropCollectionIndexes(app App, collection *Collection) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := app.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
|
||||
_, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ func onCollectionValidate(e *CollectionEvent) error {
|
||||
original,
|
||||
)
|
||||
|
||||
return validator.run()
|
||||
if err := validator.run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
func newCollectionValidator(ctx context.Context, app App, new, original *Collection) *collectionValidator {
|
||||
@@ -83,7 +87,7 @@ func (validator *collectionValidator) run() error {
|
||||
validator.original.IsNew(),
|
||||
validation.Length(1, 100),
|
||||
validation.Match(DefaultIdRegex),
|
||||
validation.By(validators.UniqueId(validator.app.DB(), validator.new.TableName())),
|
||||
validation.By(validators.UniqueId(validator.app.ConcurrentDB(), validator.new.TableName())),
|
||||
).Else(
|
||||
validation.By(validators.Equal(validator.original.Id)),
|
||||
),
|
||||
@@ -456,7 +460,7 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
|
||||
SetParams(map[string]any{"fieldName": name})
|
||||
}
|
||||
|
||||
if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(cv.new.Indexes, name); !ok {
|
||||
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
|
||||
SetParams(map[string]any{"fieldName": name})
|
||||
}
|
||||
@@ -554,7 +558,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
|
||||
// ensure that the index name is not used in another collection
|
||||
var usedTblName string
|
||||
_ = cv.app.DB().Select("tbl_name").
|
||||
_ = cv.app.ConcurrentDB().Select("tbl_name").
|
||||
From("sqlite_master").
|
||||
AndWhere(dbx.HashExp{"type": "index"}).
|
||||
AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:oldName})", dbx.Params{"oldName": cv.original.Name})).
|
||||
@@ -666,7 +670,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
if cv.new.IsAuth() {
|
||||
requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
|
||||
for _, name := range requiredNames {
|
||||
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
|
||||
return validation.NewError(
|
||||
"validation_missing_required_unique_index",
|
||||
`Missing required unique index for field "{{.fieldName}}".`,
|
||||
|
||||
+12
-13
@@ -59,19 +59,19 @@ func GenerateDefaultRandomId() string {
|
||||
|
||||
// crc32Checksum generates a stringified crc32 checksum from the provided plain string.
|
||||
func crc32Checksum(str string) string {
|
||||
return strconv.Itoa(int(crc32.ChecksumIEEE([]byte(str))))
|
||||
return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
|
||||
}
|
||||
|
||||
// ModelQuery creates a new preconfigured select app.DB() query with preset
|
||||
// ModelQuery creates a new preconfigured select data.db query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
func (app *BaseApp) ModelQuery(m Model) *dbx.SelectQuery {
|
||||
return app.modelQuery(app.DB(), m)
|
||||
return app.modelQuery(app.ConcurrentDB(), m)
|
||||
}
|
||||
|
||||
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
|
||||
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
func (app *BaseApp) AuxModelQuery(m Model) *dbx.SelectQuery {
|
||||
return app.modelQuery(app.AuxDB(), m)
|
||||
return app.modelQuery(app.AuxConcurrentDB(), m)
|
||||
}
|
||||
|
||||
func (app *BaseApp) modelQuery(db dbx.Builder, m Model) *dbx.SelectQuery {
|
||||
@@ -116,8 +116,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
|
||||
|
||||
deleteErr := app.OnModelDelete().Trigger(event, func(e *ModelEvent) error {
|
||||
pk := cast.ToString(e.Model.LastSavedPK())
|
||||
|
||||
if cast.ToString(pk) == "" {
|
||||
if pk == "" {
|
||||
return errors.New("the model can be deleted only if it is existing and has a non-empty primary key")
|
||||
}
|
||||
|
||||
@@ -152,7 +151,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
|
||||
|
||||
if app.txInfo != nil {
|
||||
// execute later after the transaction has completed
|
||||
app.txInfo.onAfterFunc(func(txErr error) error {
|
||||
app.txInfo.OnComplete(func(txErr error) error {
|
||||
if app.txInfo != nil && app.txInfo.parent != nil {
|
||||
event.App = app.txInfo.parent
|
||||
}
|
||||
@@ -343,7 +342,7 @@ func (app *BaseApp) create(ctx context.Context, model Model, withValidations boo
|
||||
|
||||
if app.txInfo != nil {
|
||||
// execute later after the transaction has completed
|
||||
app.txInfo.onAfterFunc(func(txErr error) error {
|
||||
app.txInfo.OnComplete(func(txErr error) error {
|
||||
if app.txInfo != nil && app.txInfo.parent != nil {
|
||||
event.App = app.txInfo.parent
|
||||
}
|
||||
@@ -427,7 +426,7 @@ func (app *BaseApp) update(ctx context.Context, model Model, withValidations boo
|
||||
|
||||
if app.txInfo != nil {
|
||||
// execute later after the transaction has completed
|
||||
app.txInfo.onAfterFunc(func(txErr error) error {
|
||||
app.txInfo.OnComplete(func(txErr error) error {
|
||||
if app.txInfo != nil && app.txInfo.parent != nil {
|
||||
event.App = app.txInfo.parent
|
||||
}
|
||||
@@ -483,15 +482,15 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
|
||||
return validation.NewError("validation_invalid_collection", "Missing or invalid collection.")
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
rowErr := app.DB().Select("(1)").
|
||||
rowErr := app.ConcurrentDB().Select("(1)").
|
||||
From(collection.Name).
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
if rowErr != nil || !exists {
|
||||
if rowErr != nil || exists == 0 {
|
||||
return validation.NewError("validation_invalid_record", "Missing or invalid record.")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ dbx.Builder = (*dualDBBuilder)(nil)
|
||||
|
||||
// note: expects both builder to use the same driver
|
||||
type dualDBBuilder struct {
|
||||
concurrentDB dbx.Builder
|
||||
nonconcurrentDB dbx.Builder
|
||||
}
|
||||
|
||||
// Select implements the [dbx.Builder.Select] interface method.
|
||||
func (b *dualDBBuilder) Select(cols ...string) *dbx.SelectQuery {
|
||||
return b.concurrentDB.Select(cols...)
|
||||
}
|
||||
|
||||
// Model implements the [dbx.Builder.Model] interface method.
|
||||
func (b *dualDBBuilder) Model(data interface{}) *dbx.ModelQuery {
|
||||
return b.nonconcurrentDB.Model(data)
|
||||
}
|
||||
|
||||
// GeneratePlaceholder implements the [dbx.Builder.GeneratePlaceholder] interface method.
|
||||
func (b *dualDBBuilder) GeneratePlaceholder(i int) string {
|
||||
return b.concurrentDB.GeneratePlaceholder(i)
|
||||
}
|
||||
|
||||
// Quote implements the [dbx.Builder.Quote] interface method.
|
||||
func (b *dualDBBuilder) Quote(str string) string {
|
||||
return b.concurrentDB.Quote(str)
|
||||
}
|
||||
|
||||
// QuoteSimpleTableName implements the [dbx.Builder.QuoteSimpleTableName] interface method.
|
||||
func (b *dualDBBuilder) QuoteSimpleTableName(table string) string {
|
||||
return b.concurrentDB.QuoteSimpleTableName(table)
|
||||
}
|
||||
|
||||
// QuoteSimpleColumnName implements the [dbx.Builder.QuoteSimpleColumnName] interface method.
|
||||
func (b *dualDBBuilder) QuoteSimpleColumnName(col string) string {
|
||||
return b.concurrentDB.QuoteSimpleColumnName(col)
|
||||
}
|
||||
|
||||
// QueryBuilder implements the [dbx.Builder.QueryBuilder] interface method.
|
||||
func (b *dualDBBuilder) QueryBuilder() dbx.QueryBuilder {
|
||||
return b.concurrentDB.QueryBuilder()
|
||||
}
|
||||
|
||||
// Insert implements the [dbx.Builder.Insert] interface method.
|
||||
func (b *dualDBBuilder) Insert(table string, cols dbx.Params) *dbx.Query {
|
||||
return b.nonconcurrentDB.Insert(table, cols)
|
||||
}
|
||||
|
||||
// Upsert implements the [dbx.Builder.Upsert] interface method.
|
||||
func (b *dualDBBuilder) Upsert(table string, cols dbx.Params, constraints ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.Upsert(table, cols, constraints...)
|
||||
}
|
||||
|
||||
// Update implements the [dbx.Builder.Update] interface method.
|
||||
func (b *dualDBBuilder) Update(table string, cols dbx.Params, where dbx.Expression) *dbx.Query {
|
||||
return b.nonconcurrentDB.Update(table, cols, where)
|
||||
}
|
||||
|
||||
// Delete implements the [dbx.Builder.Delete] interface method.
|
||||
func (b *dualDBBuilder) Delete(table string, where dbx.Expression) *dbx.Query {
|
||||
return b.nonconcurrentDB.Delete(table, where)
|
||||
}
|
||||
|
||||
// CreateTable implements the [dbx.Builder.CreateTable] interface method.
|
||||
func (b *dualDBBuilder) CreateTable(table string, cols map[string]string, options ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.CreateTable(table, cols, options...)
|
||||
}
|
||||
|
||||
// RenameTable implements the [dbx.Builder.RenameTable] interface method.
|
||||
func (b *dualDBBuilder) RenameTable(oldName, newName string) *dbx.Query {
|
||||
return b.nonconcurrentDB.RenameTable(oldName, newName)
|
||||
}
|
||||
|
||||
// DropTable implements the [dbx.Builder.DropTable] interface method.
|
||||
func (b *dualDBBuilder) DropTable(table string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropTable(table)
|
||||
}
|
||||
|
||||
// TruncateTable implements the [dbx.Builder.TruncateTable] interface method.
|
||||
func (b *dualDBBuilder) TruncateTable(table string) *dbx.Query {
|
||||
return b.nonconcurrentDB.TruncateTable(table)
|
||||
}
|
||||
|
||||
// AddColumn implements the [dbx.Builder.AddColumn] interface method.
|
||||
func (b *dualDBBuilder) AddColumn(table, col, typ string) *dbx.Query {
|
||||
return b.nonconcurrentDB.AddColumn(table, col, typ)
|
||||
}
|
||||
|
||||
// DropColumn implements the [dbx.Builder.DropColumn] interface method.
|
||||
func (b *dualDBBuilder) DropColumn(table, col string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropColumn(table, col)
|
||||
}
|
||||
|
||||
// RenameColumn implements the [dbx.Builder.RenameColumn] interface method.
|
||||
func (b *dualDBBuilder) RenameColumn(table, oldName, newName string) *dbx.Query {
|
||||
return b.nonconcurrentDB.RenameColumn(table, oldName, newName)
|
||||
}
|
||||
|
||||
// AlterColumn implements the [dbx.Builder.AlterColumn] interface method.
|
||||
func (b *dualDBBuilder) AlterColumn(table, col, typ string) *dbx.Query {
|
||||
return b.nonconcurrentDB.AlterColumn(table, col, typ)
|
||||
}
|
||||
|
||||
// AddPrimaryKey implements the [dbx.Builder.AddPrimaryKey] interface method.
|
||||
func (b *dualDBBuilder) AddPrimaryKey(table, name string, cols ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.AddPrimaryKey(table, name, cols...)
|
||||
}
|
||||
|
||||
// DropPrimaryKey implements the [dbx.Builder.DropPrimaryKey] interface method.
|
||||
func (b *dualDBBuilder) DropPrimaryKey(table, name string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropPrimaryKey(table, name)
|
||||
}
|
||||
|
||||
// AddForeignKey implements the [dbx.Builder.AddForeignKey] interface method.
|
||||
func (b *dualDBBuilder) AddForeignKey(table, name string, cols, refCols []string, refTable string, options ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.AddForeignKey(table, name, cols, refCols, refTable, options...)
|
||||
}
|
||||
|
||||
// DropForeignKey implements the [dbx.Builder.DropForeignKey] interface method.
|
||||
func (b *dualDBBuilder) DropForeignKey(table, name string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropForeignKey(table, name)
|
||||
}
|
||||
|
||||
// CreateIndex implements the [dbx.Builder.CreateIndex] interface method.
|
||||
func (b *dualDBBuilder) CreateIndex(table, name string, cols ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.CreateIndex(table, name, cols...)
|
||||
}
|
||||
|
||||
// CreateUniqueIndex implements the [dbx.Builder.CreateUniqueIndex] interface method.
|
||||
func (b *dualDBBuilder) CreateUniqueIndex(table, name string, cols ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.CreateUniqueIndex(table, name, cols...)
|
||||
}
|
||||
|
||||
// DropIndex implements the [dbx.Builder.DropIndex] interface method.
|
||||
func (b *dualDBBuilder) DropIndex(table, name string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropIndex(table, name)
|
||||
}
|
||||
|
||||
// NewQuery implements the [dbx.Builder.NewQuery] interface method by
|
||||
// routing the SELECT queries to the concurrent builder instance.
|
||||
func (b *dualDBBuilder) NewQuery(str string) *dbx.Query {
|
||||
// note: technically INSERT/UPDATE/DELETE could also have CTE but since
|
||||
// it is rare for now this scase is ignored to avoid unnecessary complicating the checks
|
||||
trimmed := trimLeftSpaces(str)
|
||||
if hasPrefixFold(trimmed, "SELECT") || hasPrefixFold(trimmed, "WITH") {
|
||||
return b.concurrentDB.NewQuery(str)
|
||||
}
|
||||
|
||||
return b.nonconcurrentDB.NewQuery(str)
|
||||
}
|
||||
|
||||
var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1}
|
||||
|
||||
// note: similar to strings.Space() but without the right trim because it is not needed in our case
|
||||
func trimLeftSpaces(str string) string {
|
||||
start := 0
|
||||
for ; start < len(str); start++ {
|
||||
c := str[start]
|
||||
if c >= utf8.RuneSelf {
|
||||
return strings.TrimLeftFunc(str[start:], unicode.IsSpace)
|
||||
}
|
||||
if asciiSpace[c] == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return str[start:]
|
||||
}
|
||||
|
||||
// note: the prefix is expected to be ASCII
|
||||
func hasPrefixFold(str, prefix string) bool {
|
||||
if len(str) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(str[:len(prefix)], prefix)
|
||||
}
|
||||
+14
-16
@@ -11,7 +11,7 @@ import (
|
||||
func (app *BaseApp) TableColumns(tableName string) ([]string, error) {
|
||||
columns := []string{}
|
||||
|
||||
err := app.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
err := app.ConcurrentDB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
Bind(dbx.Params{"tableName": tableName}).
|
||||
Column(&columns)
|
||||
|
||||
@@ -34,7 +34,7 @@ type TableInfoRow struct {
|
||||
func (app *BaseApp) TableInfo(tableName string) ([]*TableInfoRow, error) {
|
||||
info := []*TableInfoRow{}
|
||||
|
||||
err := app.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
err := app.ConcurrentDB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
Bind(dbx.Params{"tableName": tableName}).
|
||||
All(&info)
|
||||
if err != nil {
|
||||
@@ -59,7 +59,7 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
|
||||
Sql string
|
||||
}{}
|
||||
|
||||
err := app.DB().Select("name", "sql").
|
||||
err := app.ConcurrentDB().Select("name", "sql").
|
||||
From("sqlite_master").
|
||||
AndWhere(dbx.NewExp("sql is not null")).
|
||||
AndWhere(dbx.HashExp{
|
||||
@@ -87,7 +87,7 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "tableName" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteTable(tableName string) error {
|
||||
_, err := app.DB().NewQuery(fmt.Sprintf(
|
||||
_, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
|
||||
"DROP TABLE IF EXISTS {{%s}}",
|
||||
tableName,
|
||||
)).Execute()
|
||||
@@ -96,19 +96,19 @@ func (app *BaseApp) DeleteTable(tableName string) error {
|
||||
}
|
||||
|
||||
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
|
||||
// in the current app.DB() instance.
|
||||
// in the data.db.
|
||||
func (app *BaseApp) HasTable(tableName string) bool {
|
||||
return app.hasTable(app.DB(), tableName)
|
||||
return app.hasTable(app.ConcurrentDB(), tableName)
|
||||
}
|
||||
|
||||
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
|
||||
// in the current app.AuxDB() instance.
|
||||
// in the auixiliary.db.
|
||||
func (app *BaseApp) AuxHasTable(tableName string) bool {
|
||||
return app.hasTable(app.AuxDB(), tableName)
|
||||
return app.hasTable(app.AuxConcurrentDB(), tableName)
|
||||
}
|
||||
|
||||
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
err := db.Select("(1)").
|
||||
From("sqlite_schema").
|
||||
@@ -117,19 +117,17 @@ func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
// Vacuum executes VACUUM on the current app.DB() instance
|
||||
// in order to reclaim unused data db disk space.
|
||||
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
|
||||
func (app *BaseApp) Vacuum() error {
|
||||
return app.vacuum(app.DB())
|
||||
return app.vacuum(app.NonconcurrentDB())
|
||||
}
|
||||
|
||||
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
|
||||
// in order to reclaim unused auxiliary db disk space.
|
||||
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
|
||||
func (app *BaseApp) AuxVacuum() error {
|
||||
return app.vacuum(app.AuxDB())
|
||||
return app.vacuum(app.AuxNonconcurrentDB())
|
||||
}
|
||||
|
||||
func (app *BaseApp) vacuum(db dbx.Builder) error {
|
||||
|
||||
@@ -202,10 +202,10 @@ func TestVacuum(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
calledQueries := []string{}
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
|
||||
@@ -229,10 +229,10 @@ func TestAuxVacuum(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
calledQueries := []string{}
|
||||
app.AuxDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.AuxDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -38,12 +38,12 @@ func TestModelQuery(t *testing.T) {
|
||||
modelsQuery := app.ModelQuery(&core.Collection{})
|
||||
logsModelQuery := app.AuxModelQuery(&core.Collection{})
|
||||
|
||||
if app.DB() == modelsQuery.Info().Builder {
|
||||
t.Fatalf("ModelQuery() is not using app.DB()")
|
||||
if app.ConcurrentDB() == modelsQuery.Info().Builder {
|
||||
t.Fatalf("ModelQuery() is not using app.ConcurrentDB()")
|
||||
}
|
||||
|
||||
if app.AuxDB() == logsModelQuery.Info().Builder {
|
||||
t.Fatalf("AuxModelQuery() is not using app.AuxDB()")
|
||||
if app.AuxConcurrentDB() == logsModelQuery.Info().Builder {
|
||||
t.Fatalf("AuxModelQuery() is not using app.AuxConcurrentDB()")
|
||||
}
|
||||
|
||||
expectedSQL := "SELECT {{_collections}}.* FROM `_collections`"
|
||||
|
||||
+12
-5
@@ -60,7 +60,7 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
|
||||
clone.nonconcurrentDB = tx
|
||||
}
|
||||
|
||||
clone.txInfo = &txAppInfo{
|
||||
clone.txInfo = &TxAppInfo{
|
||||
parent: app,
|
||||
isForAuxDB: isForAuxDB,
|
||||
}
|
||||
@@ -68,22 +68,29 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
|
||||
return &clone
|
||||
}
|
||||
|
||||
type txAppInfo struct {
|
||||
// TxAppInfo represents an active transaction context associated to an existing app instance.
|
||||
type TxAppInfo struct {
|
||||
parent *BaseApp
|
||||
afterFuncs []func(txErr error) error
|
||||
mu sync.Mutex
|
||||
isForAuxDB bool
|
||||
}
|
||||
|
||||
func (a *txAppInfo) onAfterFunc(fn func(txErr error) error) {
|
||||
// OnComplete registers the provided callback that will be invoked
|
||||
// once the related transaction ends (either completes successfully or rollbacked with an error).
|
||||
//
|
||||
// The callback receives the transaction error (if any) as its argument.
|
||||
// Any additional errors returned by the OnComplete callbacks will be
|
||||
// joined together with txErr when returning the final transaction result.
|
||||
func (a *TxAppInfo) OnComplete(fn func(txErr error) error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.afterFuncs = append(a.afterFuncs, fn)
|
||||
}
|
||||
|
||||
// note: can be called only once because txAppInfo is cleared
|
||||
func (a *txAppInfo) runAfterFuncs(txErr error) error {
|
||||
// note: can be called only once because TxAppInfo is cleared
|
||||
func (a *TxAppInfo) runAfterFuncs(txErr error) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
|
||||
@@ -168,16 +168,28 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
|
||||
})
|
||||
|
||||
app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
afterCreateHookCalls++
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
afterUpdateHookCalls++
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
afterDeleteHookCalls++
|
||||
return e.Next()
|
||||
})
|
||||
@@ -233,3 +245,169 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
|
||||
t.Errorf("Expected afterDeleteHookCalls to be called 1 time, got %d", afterDeleteHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionFromInnerCreateHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.OnRecordCreateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
originalApp := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() {
|
||||
e.App = originalApp
|
||||
}()
|
||||
|
||||
nextErr := e.Next()
|
||||
|
||||
return nextErr
|
||||
})
|
||||
})
|
||||
|
||||
app.OnRecordAfterCreateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
// perform a db query with the app instance to ensure that it is still valid
|
||||
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
|
||||
record.Set("title", "test_inner_tx")
|
||||
|
||||
if err = app.Save(record); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
expectedHookCalls := map[string]int{
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
}
|
||||
for k, total := range expectedHookCalls {
|
||||
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionFromInnerUpdateHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.OnRecordUpdateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
originalApp := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() {
|
||||
e.App = originalApp
|
||||
}()
|
||||
|
||||
nextErr := e.Next()
|
||||
|
||||
return nextErr
|
||||
})
|
||||
})
|
||||
|
||||
app.OnRecordAfterUpdateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
// perform a db query with the app instance to ensure that it is still valid
|
||||
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = app.Save(existingModel); err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
expectedHookCalls := map[string]int{
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
}
|
||||
for k, total := range expectedHookCalls {
|
||||
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionFromInnerDeleteHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.OnRecordDeleteExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
originalApp := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() {
|
||||
e.App = originalApp
|
||||
}()
|
||||
|
||||
nextErr := e.Next()
|
||||
|
||||
return nextErr
|
||||
})
|
||||
})
|
||||
|
||||
app.OnRecordAfterDeleteSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
// perform a db query with the app instance to ensure that it is still valid
|
||||
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = app.Delete(existingModel); err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
expectedHookCalls := map[string]int{
|
||||
"OnRecordDeleteExecute": 1,
|
||||
"OnRecordAfterDeleteSuccess": 1,
|
||||
}
|
||||
for k, total := range expectedHookCalls {
|
||||
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func (e *RequestEvent) HasSuperuserAuth() bool {
|
||||
// RequestInfo parses the current request into RequestInfo instance.
|
||||
//
|
||||
// Note that the returned result is cached to avoid copying the request data multiple times
|
||||
// but the auth state and other common store items are always refreshed in case they were changed my another handler.
|
||||
// but the auth state and other common store items are always refreshed in case they were changed by another handler.
|
||||
func (e *RequestEvent) RequestInfo() (*RequestInfo, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
@@ -154,8 +154,10 @@ const (
|
||||
RequestInfoContextExpand = "expand"
|
||||
RequestInfoContextRealtime = "realtime"
|
||||
RequestInfoContextProtectedFile = "protectedFile"
|
||||
RequestInfoContextOAuth2 = "oauth2"
|
||||
RequestInfoContextBatch = "batch"
|
||||
RequestInfoContextOAuth2 = "oauth2"
|
||||
RequestInfoContextOTP = "otp"
|
||||
RequestInfoContextPasswordAuth = "password"
|
||||
)
|
||||
|
||||
// RequestInfo defines a HTTP request data struct, usually used
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestEventRequestRealIP(t *testing.T) {
|
||||
"CF-Connecting-IP": {"1.2.3.4", "1.1.1.1"},
|
||||
"Fly-Client-IP": {"1.2.3.4", "1.1.1.2"},
|
||||
"X-Real-IP": {"1.2.3.4", "1.1.1.3,1.1.1.4"},
|
||||
"X-Forward-For": {"1.2.3.4", "invalid,1.1.1.5,1.1.1.6,invalid"},
|
||||
"X-Forwarded-For": {"1.2.3.4", "invalid,1.1.1.5,1.1.1.6,invalid"},
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
@@ -44,28 +44,28 @@ func TestEventRequestRealIP(t *testing.T) {
|
||||
{
|
||||
"trusted X-Real-IP (rightmost)",
|
||||
headers,
|
||||
[]string{"header1", "x-real-ip", "x-forward-for"},
|
||||
[]string{"header1", "x-real-ip", "x-forwarded-for"},
|
||||
false,
|
||||
"1.1.1.4",
|
||||
},
|
||||
{
|
||||
"trusted X-Real-IP (leftmost)",
|
||||
headers,
|
||||
[]string{"header1", "x-real-ip", "x-forward-for"},
|
||||
[]string{"header1", "x-real-ip", "x-forwarded-for"},
|
||||
true,
|
||||
"1.1.1.3",
|
||||
},
|
||||
{
|
||||
"trusted X-Forward-For (rightmost)",
|
||||
"trusted X-Forwarded-For (rightmost)",
|
||||
headers,
|
||||
[]string{"header1", "x-forward-for"},
|
||||
[]string{"header1", "x-forwarded-for"},
|
||||
false,
|
||||
"1.1.1.6",
|
||||
},
|
||||
{
|
||||
"trusted X-Forward-For (leftmost)",
|
||||
"trusted X-Forwarded-For (leftmost)",
|
||||
headers,
|
||||
[]string{"header1", "x-forward-for"},
|
||||
[]string{"header1", "x-forwarded-for"},
|
||||
true,
|
||||
"1.1.1.5",
|
||||
},
|
||||
|
||||
+44
-3
@@ -103,6 +103,22 @@ type ServeEvent struct {
|
||||
Router *router.Router[*RequestEvent]
|
||||
Server *http.Server
|
||||
CertManager *autocert.Manager
|
||||
|
||||
// InstallerFunc is the "installer" function that is called after
|
||||
// successful server tcp bind but only if there is no explicit
|
||||
// superuser record created yet.
|
||||
//
|
||||
// It runs in a separate goroutine and its default value is [apis.DefaultInstallerFunc].
|
||||
//
|
||||
// It receives a system superuser record as argument that you can use to generate
|
||||
// a short-lived auth token (e.g. systemSuperuser.NewStaticAuthToken(30 * time.Minute))
|
||||
// and concatenate it as query param for your installer page
|
||||
// (if you are using the client-side SDKs, you can then load the
|
||||
// token with pb.authStore.save(token) and perform any Web API request
|
||||
// e.g. creating a new superuser).
|
||||
//
|
||||
// Set it to nil if you want to skip the installer.
|
||||
InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -173,8 +189,8 @@ type ModelEvent struct {
|
||||
}
|
||||
|
||||
type ModelErrorEvent struct {
|
||||
ModelEvent
|
||||
Error error
|
||||
ModelEvent
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -196,8 +212,8 @@ type RecordEvent struct {
|
||||
}
|
||||
|
||||
type RecordErrorEvent struct {
|
||||
RecordEvent
|
||||
Error error
|
||||
RecordEvent
|
||||
}
|
||||
|
||||
func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
|
||||
@@ -216,6 +232,12 @@ func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
|
||||
// }
|
||||
}
|
||||
|
||||
func syncRecordEventWithModelEvent(re *RecordEvent, me *ModelEvent) {
|
||||
re.App = me.App
|
||||
re.Context = me.Context
|
||||
re.Type = me.Type
|
||||
}
|
||||
|
||||
func newRecordEventFromModelEvent(me *ModelEvent) (*RecordEvent, bool) {
|
||||
record, ok := me.Model.(*Record)
|
||||
if !ok {
|
||||
@@ -253,6 +275,11 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
|
||||
me.Error = re.Error
|
||||
}
|
||||
|
||||
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
|
||||
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
|
||||
re.Error = me.Error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Collection events data
|
||||
// -------------------------------------------------------------------
|
||||
@@ -272,8 +299,8 @@ type CollectionEvent struct {
|
||||
}
|
||||
|
||||
type CollectionErrorEvent struct {
|
||||
CollectionEvent
|
||||
Error error
|
||||
CollectionEvent
|
||||
}
|
||||
|
||||
func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
||||
@@ -283,6 +310,15 @@ func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
||||
me.Model = ce.Collection
|
||||
}
|
||||
|
||||
func syncCollectionEventWithModelEvent(ce *CollectionEvent, me *ModelEvent) {
|
||||
ce.App = me.App
|
||||
ce.Context = me.Context
|
||||
ce.Type = me.Type
|
||||
if c, ok := me.Model.(*Collection); ok {
|
||||
ce.Collection = c
|
||||
}
|
||||
}
|
||||
|
||||
func newCollectionEventFromModelEvent(me *ModelEvent) (*CollectionEvent, bool) {
|
||||
record, ok := me.Model.(*Collection)
|
||||
if !ok {
|
||||
@@ -316,6 +352,11 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
|
||||
me.Error = ce.Error
|
||||
}
|
||||
|
||||
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
|
||||
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
|
||||
ce.Error = me.Error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// File API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.DB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.ConcurrentDB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
|
||||
result, err := app.FindFirstExternalAuthByExpr(s.expr)
|
||||
|
||||
hasErr := err != nil
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
var fieldNameRegex = regexp.MustCompile(`^\w+$`)
|
||||
|
||||
const maxSafeJSONInt int64 = 1<<53 - 1
|
||||
|
||||
// Commonly used field names.
|
||||
const (
|
||||
FieldNameId = "id"
|
||||
|
||||
+41
-16
@@ -16,6 +16,9 @@ func init() {
|
||||
|
||||
const FieldTypeAutodate = "autodate"
|
||||
|
||||
// used to keep track of the last set autodate value
|
||||
const autodateLastKnownPrefix = internalCustomFieldKeyPrefix + "_last_autodate_"
|
||||
|
||||
var (
|
||||
_ Field = (*AutodateField)(nil)
|
||||
_ SetterFinder = (*AutodateField)(nil)
|
||||
@@ -167,24 +170,46 @@ func (f *AutodateField) Intercept(
|
||||
actionFunc func() error,
|
||||
) error {
|
||||
switch actionName {
|
||||
case InterceptorActionCreate:
|
||||
// ignore for custom date manually set with record.SetRaw()
|
||||
if f.OnCreate && !f.hasBeenManuallyChanged(record) {
|
||||
record.SetRaw(f.Name, types.NowDateTime())
|
||||
case InterceptorActionCreateExecute:
|
||||
// ignore if a date different from the old one was manually set with SetRaw
|
||||
if f.OnCreate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
|
||||
now := types.NowDateTime()
|
||||
record.SetRaw(f.Name, now)
|
||||
record.SetRaw(autodateLastKnownPrefix+f.Name, now) // eagerly set so that it can be renewed on resave after failure
|
||||
}
|
||||
case InterceptorActionUpdate:
|
||||
// ignore for custom date manually set with record.SetRaw()
|
||||
if f.OnUpdate && !f.hasBeenManuallyChanged(record) {
|
||||
record.SetRaw(f.Name, types.NowDateTime())
|
||||
|
||||
if err := actionFunc(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record.SetRaw(autodateLastKnownPrefix+f.Name, record.GetRaw(f.Name))
|
||||
|
||||
return nil
|
||||
case InterceptorActionUpdateExecute:
|
||||
// ignore if a date different from the old one was manually set with SetRaw
|
||||
if f.OnUpdate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
|
||||
now := types.NowDateTime()
|
||||
record.SetRaw(f.Name, now)
|
||||
record.SetRaw(autodateLastKnownPrefix+f.Name, now) // eagerly set so that it can be renewed on resave after failure
|
||||
}
|
||||
|
||||
if err := actionFunc(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record.SetRaw(autodateLastKnownPrefix+f.Name, record.GetRaw(f.Name))
|
||||
|
||||
return nil
|
||||
default:
|
||||
return actionFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *AutodateField) getLastKnownValue(record *Record) types.DateTime {
|
||||
v := record.GetDateTime(autodateLastKnownPrefix + f.Name)
|
||||
if !v.IsZero() {
|
||||
return v
|
||||
}
|
||||
|
||||
return actionFunc()
|
||||
}
|
||||
|
||||
func (f *AutodateField) hasBeenManuallyChanged(record *Record) bool {
|
||||
vNew, _ := record.GetRaw(f.Name).(types.DateTime)
|
||||
vOld, _ := record.Original().GetRaw(f.Name).(types.DateTime)
|
||||
|
||||
return vNew.String() != vOld.String()
|
||||
return record.Original().GetDateTime(f.Name)
|
||||
}
|
||||
|
||||
+102
-10
@@ -2,6 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -226,6 +228,13 @@ func TestAutodateFieldFindSetter(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func cutMilliseconds(datetime string) string {
|
||||
if len(datetime) > 19 {
|
||||
return datetime[:19]
|
||||
}
|
||||
return datetime
|
||||
}
|
||||
|
||||
func TestAutodateFieldIntercept(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -255,7 +264,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"create with zero value (disabled onCreate)",
|
||||
core.InterceptorActionCreate,
|
||||
core.InterceptorActionCreateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: false, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
return core.NewRecord(collection)
|
||||
@@ -264,7 +273,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"create with zero value",
|
||||
core.InterceptorActionCreate,
|
||||
core.InterceptorActionCreateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
return core.NewRecord(collection)
|
||||
@@ -273,7 +282,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"create with non-zero value",
|
||||
core.InterceptorActionCreate,
|
||||
core.InterceptorActionCreateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
@@ -284,7 +293,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"update with zero value (disabled onUpdate)",
|
||||
core.InterceptorActionUpdate,
|
||||
core.InterceptorActionUpdateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: false},
|
||||
func() *core.Record {
|
||||
return core.NewRecord(collection)
|
||||
@@ -293,7 +302,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"update with zero value",
|
||||
core.InterceptorActionUpdate,
|
||||
core.InterceptorActionUpdateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
return core.NewRecord(collection)
|
||||
@@ -302,7 +311,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"update with non-zero value",
|
||||
core.InterceptorActionUpdate,
|
||||
core.InterceptorActionUpdateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
@@ -341,9 +350,92 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func cutMilliseconds(datetime string) string {
|
||||
if len(datetime) > 19 {
|
||||
return datetime[:19]
|
||||
func TestAutodateRecordResave(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record, err := app.FindRecordById(collection, "llvuca81nly1qls")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lastUpdated := record.GetDateTime("updated")
|
||||
|
||||
// save with autogenerated date
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newUpdated := record.GetDateTime("updated")
|
||||
if newUpdated.Equal(lastUpdated) {
|
||||
t.Fatalf("[0] Expected updated to change, got %v", newUpdated)
|
||||
}
|
||||
lastUpdated = newUpdated
|
||||
|
||||
// save with custom date
|
||||
manualUpdated := lastUpdated.Add(-1 * time.Minute)
|
||||
record.SetRaw("updated", manualUpdated)
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newUpdated = record.GetDateTime("updated")
|
||||
if !newUpdated.Equal(manualUpdated) {
|
||||
t.Fatalf("[1] Expected updated to be the manual set date %v, got %v", manualUpdated, newUpdated)
|
||||
}
|
||||
lastUpdated = newUpdated
|
||||
|
||||
// save again with autogenerated date
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newUpdated = record.GetDateTime("updated")
|
||||
if newUpdated.Equal(lastUpdated) {
|
||||
t.Fatalf("[2] Expected updated to change, got %v", newUpdated)
|
||||
}
|
||||
lastUpdated = newUpdated
|
||||
|
||||
// simulate save failure
|
||||
app.OnRecordUpdateExecute(collection.Id).Bind(&hook.Handler[*core.RecordEvent]{
|
||||
Id: "test_failure",
|
||||
Func: func(*core.RecordEvent) error {
|
||||
return errors.New("test")
|
||||
},
|
||||
Priority: 9999999999, // as latest as possible
|
||||
})
|
||||
|
||||
// save again with autogenerated date (should fail)
|
||||
err = app.Save(record)
|
||||
if err == nil {
|
||||
t.Fatal("Expected save failure")
|
||||
}
|
||||
|
||||
// updated should still be set even after save failure
|
||||
newUpdated = record.GetDateTime("updated")
|
||||
if newUpdated.Equal(lastUpdated) {
|
||||
t.Fatalf("[3] Expected updated to change, got %v", newUpdated)
|
||||
}
|
||||
lastUpdated = newUpdated
|
||||
|
||||
// cleanup the error and resave again
|
||||
app.OnRecordUpdateExecute(collection.Id).Unbind("test_failure")
|
||||
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newUpdated = record.GetDateTime("updated")
|
||||
if newUpdated.Equal(lastUpdated) {
|
||||
t.Fatalf("[4] Expected updated to change, got %v", newUpdated)
|
||||
}
|
||||
return datetime
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ type EditorField struct {
|
||||
|
||||
// ---
|
||||
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes).
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||
//
|
||||
// If zero, a default limit of ~5MB is applied.
|
||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||
@@ -148,7 +148,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,17 @@ func TestEditorFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MaxSize > safe json int",
|
||||
func() *core.EditorField {
|
||||
return &core.EditorField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSize: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
+11
-6
@@ -5,11 +5,11 @@ import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
@@ -94,7 +94,7 @@ type FileField struct {
|
||||
|
||||
// ---
|
||||
|
||||
// MaxSize specifies the maximum size of a single uploaded file (in bytes).
|
||||
// MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
|
||||
//
|
||||
// If zero, a default limit of 5MB is applied.
|
||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||
@@ -223,8 +223,8 @@ func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.MaxSelect, validation.Min(0)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0)),
|
||||
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Thumbs, validation.Each(
|
||||
validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
|
||||
validation.Match(filesystem.ThumbSizeRegex),
|
||||
@@ -356,8 +356,7 @@ func (f *FileField) Intercept(
|
||||
return nil
|
||||
case InterceptorActionAfterCreateError, InterceptorActionAfterUpdateError:
|
||||
// when in transaction we assume that the error was handled by afterRecordExecuteFailure
|
||||
_, insideTransaction := app.DB().(*dbx.Tx)
|
||||
if insideTransaction {
|
||||
if app.IsTransactional() {
|
||||
return actionFunc()
|
||||
}
|
||||
|
||||
@@ -637,7 +636,13 @@ func (f *FileField) FindGetter(key string) GetterFunc {
|
||||
return func(record *Record) any {
|
||||
return record.GetRaw(f.Name)
|
||||
}
|
||||
case f.Name + ":unsaved":
|
||||
return func(record *Record) any {
|
||||
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
|
||||
}
|
||||
case f.Name + ":uploaded":
|
||||
// deprecated
|
||||
log.Println("[file field getter] please replace :uploaded with :unsaved")
|
||||
return func(record *Record) any {
|
||||
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
|
||||
}
|
||||
|
||||
+46
-2
@@ -539,6 +539,50 @@ func TestFileFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MaxSize > safe json int",
|
||||
func() *core.FileField {
|
||||
return &core.FileField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSize: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
{
|
||||
"MaxSize < 0",
|
||||
func() *core.FileField {
|
||||
return &core.FileField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSize: -1,
|
||||
}
|
||||
},
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
{
|
||||
"MaxSelect > safe json int",
|
||||
func() *core.FileField {
|
||||
return &core.FileField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSelect: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"maxSelect"},
|
||||
},
|
||||
{
|
||||
"MaxSelect < 0",
|
||||
func() *core.FileField {
|
||||
return &core.FileField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSelect: -1,
|
||||
}
|
||||
},
|
||||
[]string{"maxSelect"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -628,8 +672,8 @@ func TestFileFieldFindGetter(t *testing.T) {
|
||||
`["300_UhLKX91HVb.png",{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||
},
|
||||
{
|
||||
"uploaded",
|
||||
field.GetName() + ":uploaded",
|
||||
"unsaved",
|
||||
field.GetName() + ":unsaved",
|
||||
true,
|
||||
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Fields[FieldTypeGeoPoint] = func() Field {
|
||||
return &GeoPointField{}
|
||||
}
|
||||
}
|
||||
|
||||
const FieldTypeGeoPoint = "geoPoint"
|
||||
|
||||
var (
|
||||
_ Field = (*GeoPointField)(nil)
|
||||
)
|
||||
|
||||
// GeoPointField defines "geoPoint" type field for storing latitude and longitude GPS coordinates.
|
||||
//
|
||||
// You can set the record field value as [types.GeoPoint], map or serialized json object with lat-lon props.
|
||||
// The stored value is always converted to [types.GeoPoint].
|
||||
// Nil, empty map, empty bytes slice, etc. results in zero [types.GeoPoint].
|
||||
//
|
||||
// Examples of updating a record's GeoPointField value programmatically:
|
||||
//
|
||||
// record.Set("location", types.GeoPoint{Lat: 123, Lon: 456})
|
||||
// record.Set("location", map[string]any{"lat":123, "lon":456})
|
||||
// record.Set("location", []byte(`{"lat":123, "lon":456}`)
|
||||
type GeoPointField struct {
|
||||
// Name (required) is the unique name of the field.
|
||||
Name string `form:"name" json:"name"`
|
||||
|
||||
// Id is the unique stable field identifier.
|
||||
//
|
||||
// It is automatically generated from the name when adding to a collection FieldsList.
|
||||
Id string `form:"id" json:"id"`
|
||||
|
||||
// System prevents the renaming and removal of the field.
|
||||
System bool `form:"system" json:"system"`
|
||||
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
|
||||
// Required will require the field coordinates to be non-zero (aka. not "Null Island").
|
||||
Required bool `form:"required" json:"required"`
|
||||
}
|
||||
|
||||
// Type implements [Field.Type] interface method.
|
||||
func (f *GeoPointField) Type() string {
|
||||
return FieldTypeGeoPoint
|
||||
}
|
||||
|
||||
// GetId implements [Field.GetId] interface method.
|
||||
func (f *GeoPointField) GetId() string {
|
||||
return f.Id
|
||||
}
|
||||
|
||||
// SetId implements [Field.SetId] interface method.
|
||||
func (f *GeoPointField) SetId(id string) {
|
||||
f.Id = id
|
||||
}
|
||||
|
||||
// GetName implements [Field.GetName] interface method.
|
||||
func (f *GeoPointField) GetName() string {
|
||||
return f.Name
|
||||
}
|
||||
|
||||
// SetName implements [Field.SetName] interface method.
|
||||
func (f *GeoPointField) SetName(name string) {
|
||||
f.Name = name
|
||||
}
|
||||
|
||||
// GetSystem implements [Field.GetSystem] interface method.
|
||||
func (f *GeoPointField) GetSystem() bool {
|
||||
return f.System
|
||||
}
|
||||
|
||||
// SetSystem implements [Field.SetSystem] interface method.
|
||||
func (f *GeoPointField) SetSystem(system bool) {
|
||||
f.System = system
|
||||
}
|
||||
|
||||
// GetHidden implements [Field.GetHidden] interface method.
|
||||
func (f *GeoPointField) GetHidden() bool {
|
||||
return f.Hidden
|
||||
}
|
||||
|
||||
// SetHidden implements [Field.SetHidden] interface method.
|
||||
func (f *GeoPointField) SetHidden(hidden bool) {
|
||||
f.Hidden = hidden
|
||||
}
|
||||
|
||||
// ColumnType implements [Field.ColumnType] interface method.
|
||||
func (f *GeoPointField) ColumnType(app App) string {
|
||||
return `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
|
||||
}
|
||||
|
||||
// PrepareValue implements [Field.PrepareValue] interface method.
|
||||
func (f *GeoPointField) PrepareValue(record *Record, raw any) (any, error) {
|
||||
point := types.GeoPoint{}
|
||||
err := point.Scan(raw)
|
||||
return point, err
|
||||
}
|
||||
|
||||
// ValidateValue implements [Field.ValidateValue] interface method.
|
||||
func (f *GeoPointField) ValidateValue(ctx context.Context, app App, record *Record) error {
|
||||
val, ok := record.GetRaw(f.Name).(types.GeoPoint)
|
||||
if !ok {
|
||||
return validators.ErrUnsupportedValueType
|
||||
}
|
||||
|
||||
// zero value
|
||||
if val.Lat == 0 && val.Lon == 0 {
|
||||
if f.Required {
|
||||
return validation.ErrRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if val.Lat < -90 || val.Lat > 90 {
|
||||
return validation.NewError("validation_invalid_latitude", "Latitude must be between -90 and 90 degrees.")
|
||||
}
|
||||
|
||||
if val.Lon < -180 || val.Lon > 180 {
|
||||
return validation.NewError("validation_invalid_longitude", "Longitude must be between -180 and 180 degrees.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSettings implements [Field.ValidateSettings] interface method.
|
||||
func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestGeoPointFieldBaseMethods(t *testing.T) {
|
||||
testFieldBaseMethods(t, core.FieldTypeGeoPoint)
|
||||
}
|
||||
|
||||
func TestGeoPointFieldColumnType(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
f := &core.GeoPointField{}
|
||||
|
||||
expected := `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
|
||||
|
||||
if v := f.ColumnType(app); v != expected {
|
||||
t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoPointFieldPrepareValue(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
f := &core.GeoPointField{}
|
||||
record := core.NewRecord(core.NewBaseCollection("test"))
|
||||
|
||||
scenarios := []struct {
|
||||
raw any
|
||||
expected string
|
||||
}{
|
||||
{nil, `{"lon":0,"lat":0}`},
|
||||
{"", `{"lon":0,"lat":0}`},
|
||||
{[]byte{}, `{"lon":0,"lat":0}`},
|
||||
{map[string]any{}, `{"lon":0,"lat":0}`},
|
||||
{types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
|
||||
{&types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
|
||||
{[]byte(`{"lon": 10, "lat": 20}`), `{"lon":10,"lat":20}`},
|
||||
{map[string]any{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
|
||||
{map[string]float64{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
|
||||
v, err := f.PrepareValue(record, s.raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
if rawStr != s.expected {
|
||||
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, rawStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoPointFieldValidateValue(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := core.NewBaseCollection("test_collection")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
field *core.GeoPointField
|
||||
record func() *core.Record
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"invalid raw value",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", 123)
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"zero field value (non-required)",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{})
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"zero field value (required)",
|
||||
&core.GeoPointField{Name: "test", Required: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"non-zero Lat field value (required)",
|
||||
&core.GeoPointField{Name: "test", Required: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lat: 1})
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"non-zero Lon field value (required)",
|
||||
&core.GeoPointField{Name: "test", Required: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lon: 1})
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"non-zero Lat-Lon field value (required)",
|
||||
&core.GeoPointField{Name: "test", Required: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lon: -1, Lat: -2})
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"lat < -90",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lat: -90.1})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"lat > 90",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lat: 90.1})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"lon < -180",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lon: -180.1})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"lon > 180",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lon: 180.1})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
err := s.field.ValidateValue(context.Background(), app, s.record())
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoPointFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
|
||||
}
|
||||
+4
-4
@@ -20,7 +20,7 @@ func init() {
|
||||
|
||||
const FieldTypeJSON = "json"
|
||||
|
||||
const DefaultJSONFieldMaxSize int64 = 5 << 20
|
||||
const DefaultJSONFieldMaxSize int64 = 1 << 20
|
||||
|
||||
var (
|
||||
_ Field = (*JSONField)(nil)
|
||||
@@ -51,9 +51,9 @@ type JSONField struct {
|
||||
|
||||
// ---
|
||||
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes).
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||
//
|
||||
// If zero, a default limit of 5MB is applied.
|
||||
// If zero, a default limit of 1MB is applied.
|
||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||
|
||||
// Required will require the field value to be non-empty JSON value
|
||||
@@ -181,7 +181,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -146,7 +146,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
|
||||
&core.JSONField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (5<<20))+`"`))
|
||||
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (1<<20))+`"`))
|
||||
return record
|
||||
},
|
||||
true,
|
||||
@@ -200,7 +200,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
expectErrors []string
|
||||
}{
|
||||
{
|
||||
"< 0 MaxSize",
|
||||
"MaxSize < 0",
|
||||
func() *core.JSONField {
|
||||
return &core.JSONField{
|
||||
Id: "test",
|
||||
@@ -211,7 +211,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
{
|
||||
"= 0 MaxSize",
|
||||
"MaxSize = 0",
|
||||
func() *core.JSONField {
|
||||
return &core.JSONField{
|
||||
Id: "test",
|
||||
@@ -221,7 +221,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"> 0 MaxSize",
|
||||
"MaxSize > 0",
|
||||
func() *core.JSONField {
|
||||
return &core.JSONField{
|
||||
Id: "test",
|
||||
@@ -231,6 +231,17 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MaxSize > safe json int",
|
||||
func() *core.JSONField {
|
||||
return &core.JSONField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSize: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -219,7 +219,7 @@ func (f *RelationField) ValidateValue(ctx context.Context, app App, record *Reco
|
||||
}
|
||||
|
||||
var total int
|
||||
_ = app.DB().
|
||||
_ = app.ConcurrentDB().
|
||||
Select("count(*)").
|
||||
From(relCollection.Name).
|
||||
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
|
||||
|
||||
+17
-13
@@ -190,15 +190,19 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
|
||||
// this technically shouldn't be necessarily but again to
|
||||
// minimize misuse of the Pattern validator that could cause
|
||||
// side-effects on some platforms check for duplicates in a case-insensitive manner
|
||||
var exists bool
|
||||
err := app.DB().
|
||||
Select("(1)").
|
||||
From(record.TableName()).
|
||||
Where(dbx.NewExp("LOWER(id) = {:id}", dbx.Params{"id": strings.ToLower(newVal)})).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
if exists || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
|
||||
return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
|
||||
//
|
||||
// (@todo eventually may get replaced in the future with a system unique constraint to avoid races or wrapping the request in a transaction)
|
||||
if f.Pattern != defaultLowercaseRecordIdPattern {
|
||||
var exists int
|
||||
err := app.ConcurrentDB().
|
||||
Select("(1)").
|
||||
From(record.TableName()).
|
||||
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": newVal})).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
if exists > 0 || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
|
||||
return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,8 +236,8 @@ func (f *TextField) ValidatePlainValue(value string) error {
|
||||
}
|
||||
|
||||
if max > 0 && length > max {
|
||||
return validation.NewError("validation_max_text_constraint", "Must be less than {{.max}} character(s)").
|
||||
SetParams(map[string]any{"max": f.Max})
|
||||
return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s)").
|
||||
SetParams(map[string]any{"max": max})
|
||||
}
|
||||
|
||||
if f.Pattern != "" {
|
||||
@@ -255,8 +259,8 @@ func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)),
|
||||
),
|
||||
validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
|
||||
validation.Field(&f.Min, validation.Min(0)),
|
||||
validation.Field(&f.Max, validation.Min(f.Min)),
|
||||
validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Pattern, validation.When(f.PrimaryKey, validation.Required), validation.By(validators.IsRegex)),
|
||||
validation.Field(&f.Hidden, validation.When(f.PrimaryKey, validation.Empty)),
|
||||
validation.Field(&f.Required, validation.When(f.PrimaryKey, validation.Required)),
|
||||
|
||||
@@ -430,6 +430,50 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{"autogeneratePattern"},
|
||||
},
|
||||
{
|
||||
"Max > safe json int",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Max: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"max"},
|
||||
},
|
||||
{
|
||||
"Max < 0",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Max: -1,
|
||||
}
|
||||
},
|
||||
[]string{"max"},
|
||||
},
|
||||
{
|
||||
"Min > safe json int",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Min: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"min"},
|
||||
},
|
||||
{
|
||||
"Min < 0",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Min: -1,
|
||||
}
|
||||
},
|
||||
[]string{"min"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
var cachedColors = store.New[*color.Color](nil)
|
||||
var cachedColors = store.New[string, *color.Color](nil)
|
||||
|
||||
// getColor returns [color.Color] object and cache it (if not already).
|
||||
func getColor(attrs ...color.Attribute) (c *color.Color) {
|
||||
|
||||
@@ -55,10 +55,10 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
|
||||
}
|
||||
|
||||
// silence query logs
|
||||
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
app.concurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.concurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
app.nonconcurrentDB.(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.nonconcurrentDB.(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
|
||||
app.Settings().Logs.MinLevel = testLogLevel
|
||||
if err := app.Save(app.Settings()); err != nil {
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ type LogsStatsItem struct {
|
||||
Total int `db:"total" json:"total"`
|
||||
}
|
||||
|
||||
// LogsStats returns hourly grouped requests logs statistics.
|
||||
// LogsStats returns hourly grouped logs statistics.
|
||||
func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
|
||||
result := []*LogsStatsItem{}
|
||||
|
||||
@@ -51,7 +51,7 @@ func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteOldLogs delete all requests that are created before createdBefore.
|
||||
// DeleteOldLogs delete all logs that are created before createdBefore.
|
||||
//
|
||||
// For better performance the logs delete is executed as plain SQL statement,
|
||||
// aka. no delete model hook events will be fired.
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ func (app *BaseApp) registerMFAHooks() {
|
||||
recordRefHooks[*MFA](app, CollectionNameMFAs, CollectionTypeAuth)
|
||||
|
||||
// run on every hour to cleanup expired mfa sessions
|
||||
app.Cron().Add("__mfasCleanup__", "0 * * * *", func() {
|
||||
app.Cron().Add("__pbMFACleanup__", "0 * * * *", func() {
|
||||
if err := app.DeleteExpiredMFAs(); err != nil {
|
||||
app.Logger().Warn("Failed to delete expired MFA sessions", "error", err)
|
||||
}
|
||||
|
||||
+14
-18
@@ -5,9 +5,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
@@ -80,15 +80,11 @@ func (r *MigrationsRunner) Run(args ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
confirm := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf(
|
||||
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
|
||||
strings.Join(names, "\n"),
|
||||
toRevertCount,
|
||||
),
|
||||
}
|
||||
survey.AskOne(prompt, &confirm)
|
||||
confirm := osutils.YesNoPrompt(fmt.Sprintf(
|
||||
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
|
||||
strings.Join(names, "\n"),
|
||||
toRevertCount,
|
||||
), false)
|
||||
if !confirm {
|
||||
fmt.Println("The command has been cancelled")
|
||||
return nil
|
||||
@@ -116,7 +112,7 @@ func (r *MigrationsRunner) Run(args ...string) error {
|
||||
color.Green("The %s table was synced with the available migrations.", r.tableName)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("Unsupported command: %q\n", cmd)
|
||||
return fmt.Errorf("unsupported command: %q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +151,12 @@ func (r *MigrationsRunner) Up() ([]string, error) {
|
||||
// ignore empty Up action
|
||||
if m.Up != nil {
|
||||
if err := m.Up(txApp); err != nil {
|
||||
return fmt.Errorf("Failed to apply migration %s: %w", m.File, err)
|
||||
return fmt.Errorf("failed to apply migration %s: %w", m.File, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.saveAppliedMigration(txApp, m.File); err != nil {
|
||||
return fmt.Errorf("Failed to save applied migration info for %s: %w", m.File, err)
|
||||
return fmt.Errorf("failed to save applied migration info for %s: %w", m.File, err)
|
||||
}
|
||||
|
||||
applied = append(applied, m.File)
|
||||
@@ -208,12 +204,12 @@ func (r *MigrationsRunner) Down(toRevertCount int) ([]string, error) {
|
||||
// ignore empty Down action
|
||||
if m.Down != nil {
|
||||
if err := m.Down(txApp); err != nil {
|
||||
return fmt.Errorf("Failed to revert migration %s: %w", m.File, err)
|
||||
return fmt.Errorf("failed to revert migration %s: %w", m.File, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.saveRevertedMigration(txApp, m.File); err != nil {
|
||||
return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.File, err)
|
||||
return fmt.Errorf("failed to save reverted migration info for %s: %w", m.File, err)
|
||||
}
|
||||
|
||||
reverted = append(reverted, m.File)
|
||||
@@ -267,15 +263,15 @@ func (r *MigrationsRunner) initMigrationsTable() error {
|
||||
}
|
||||
|
||||
func (r *MigrationsRunner) isMigrationApplied(txApp App, file string) bool {
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
err := txApp.DB().Select("count(*)").
|
||||
err := txApp.DB().Select("(1)").
|
||||
From(r.tableName).
|
||||
Where(dbx.HashExp{"file": file}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
func (r *MigrationsRunner) saveAppliedMigration(txApp App, file string) error {
|
||||
|
||||
@@ -200,38 +200,13 @@ func TestMigrationsRunnerRemoveMissingAppliedMigrations(t *testing.T) {
|
||||
}
|
||||
|
||||
func isMigrationApplied(app core.App, file string) bool {
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
err := app.DB().Select("count(*)").
|
||||
err := app.DB().Select("(1)").
|
||||
From(core.DefaultMigrationsTable).
|
||||
Where(dbx.HashExp{"file": file}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
// // -------------------------------------------------------------------
|
||||
|
||||
// type testDB struct {
|
||||
// *dbx.DB
|
||||
// CalledQueries []string
|
||||
// }
|
||||
|
||||
// // NB! Don't forget to call `db.Close()` at the end of the test.
|
||||
// func createTestDB() (*testDB, error) {
|
||||
// sqlDB, err := sql.Open("sqlite", ":memory:")
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// db := testDB{DB: dbx.NewFromDB(sqlDB, "sqlite")}
|
||||
// db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
// db.CalledQueries = append(db.CalledQueries, sql)
|
||||
// }
|
||||
// db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
// db.CalledQueries = append(db.CalledQueries, sql)
|
||||
// }
|
||||
|
||||
// return &db, nil
|
||||
// }
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ func (app *BaseApp) registerOTPHooks() {
|
||||
recordRefHooks[*OTP](app, CollectionNameOTPs, CollectionTypeAuth)
|
||||
|
||||
// run on every hour to cleanup expired otp sessions
|
||||
app.Cron().Add("__otpsCleanup__", "0 * * * *", func() {
|
||||
app.Cron().Add("__pbOTPCleanup__", "0 * * * *", func() {
|
||||
if err := app.DeleteExpiredOTPs(); err != nil {
|
||||
app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,7 @@ const (
|
||||
eachModifier string = "each"
|
||||
issetModifier string = "isset"
|
||||
lengthModifier string = "length"
|
||||
lowerModifier string = "lower"
|
||||
)
|
||||
|
||||
// ensure that `search.FieldResolver` interface is implemented
|
||||
@@ -47,6 +50,26 @@ type RecordFieldResolver struct {
|
||||
allowHiddenFields bool
|
||||
}
|
||||
|
||||
// AllowedFields returns a copy of the resolver's allowed fields.
|
||||
func (r *RecordFieldResolver) AllowedFields() []string {
|
||||
return slices.Clone(r.allowedFields)
|
||||
}
|
||||
|
||||
// SetAllowedFields replaces the resolver's allowed fields with the new ones.
|
||||
func (r *RecordFieldResolver) SetAllowedFields(newAllowedFields []string) {
|
||||
r.allowedFields = slices.Clone(newAllowedFields)
|
||||
}
|
||||
|
||||
// AllowHiddenFields returns whether the current resolver allows filtering hidden fields.
|
||||
func (r *RecordFieldResolver) AllowHiddenFields() bool {
|
||||
return r.allowHiddenFields
|
||||
}
|
||||
|
||||
// SetAllowHiddenFields enables or disables hidden fields filtering.
|
||||
func (r *RecordFieldResolver) SetAllowHiddenFields(allowHiddenFields bool) {
|
||||
r.allowHiddenFields = allowHiddenFields
|
||||
}
|
||||
|
||||
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
|
||||
func NewRecordFieldResolver(
|
||||
app App,
|
||||
@@ -193,7 +216,14 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
||||
resultVal = val
|
||||
}
|
||||
|
||||
placeholder := "f" + security.PseudorandomString(6)
|
||||
placeholder := "f" + security.PseudorandomString(8)
|
||||
|
||||
if modifier == lowerModifier {
|
||||
return &search.ResolverResult{
|
||||
Identifier: "LOWER({:" + placeholder + "})",
|
||||
Params: dbx.Params{placeholder: resultVal},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &search.ResolverResult{
|
||||
Identifier: "{:" + placeholder + "}",
|
||||
@@ -271,6 +301,13 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
|
||||
return mapVal(m, keys...)
|
||||
case mapExtractor:
|
||||
return mapVal(m.AsMap(), keys...)
|
||||
case types.JSONRaw:
|
||||
var raw any
|
||||
err := json.Unmarshal(m, &raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal raw JSON in order extract nested value from: %w", err)
|
||||
}
|
||||
return extractNestedVal(raw, keys...)
|
||||
|
||||
// slices
|
||||
case []string:
|
||||
@@ -309,6 +346,8 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
|
||||
return arrVal(extracted, keys...)
|
||||
case []any:
|
||||
return arrVal(m, keys...)
|
||||
case []types.JSONRaw:
|
||||
return arrVal(m, keys...)
|
||||
default:
|
||||
return nil, fmt.Errorf("expected map or array, got %#v", rawData)
|
||||
}
|
||||
@@ -355,7 +394,8 @@ func splitModifier(combined string) (string, string, error) {
|
||||
switch parts[1] {
|
||||
case issetModifier,
|
||||
eachModifier,
|
||||
lengthModifier:
|
||||
lengthModifier,
|
||||
lowerModifier:
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// maxNestedRels defines the max allowed nested relations depth.
|
||||
@@ -113,6 +114,11 @@ func (r *runner) run() (*search.ResolverResult, error) {
|
||||
if modifier == lengthModifier && len(r.activeProps) == 3 {
|
||||
return r.processRequestInfoLengthModifier(bodyField)
|
||||
}
|
||||
|
||||
// check for body arrayble fields ":lower" modifier
|
||||
if modifier == lowerModifier && len(r.activeProps) == 3 {
|
||||
return r.processRequestInfoLowerModifier(bodyField)
|
||||
}
|
||||
}
|
||||
|
||||
// some other @request.* static field
|
||||
@@ -191,6 +197,10 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
|
||||
}
|
||||
|
||||
func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
|
||||
// plain auth field
|
||||
// ---
|
||||
if _, ok := plainRequestAuthFields[r.fieldName]; ok {
|
||||
@@ -199,10 +209,6 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
|
||||
// resolve the auth collection field
|
||||
// ---
|
||||
if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
|
||||
collection := r.resolver.requestInfo.Auth.Collection()
|
||||
|
||||
r.activeCollectionName = collection.Name
|
||||
@@ -262,6 +268,19 @@ func toSlice(value any) []any {
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *runner) processRequestInfoLowerModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
rawValue := cast.ToString(r.resolver.requestInfo.Body[bodyField.GetName()])
|
||||
|
||||
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(6)
|
||||
|
||||
result := &search.ResolverResult{
|
||||
Identifier: "LOWER({:" + placeholder + "})",
|
||||
Params: dbx.Params{placeholder: rawValue},
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
if _, ok := bodyField.(MultiValuer); !ok {
|
||||
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
|
||||
@@ -396,8 +415,13 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
|
||||
field := collection.Fields.GetByName(prop)
|
||||
|
||||
// json field -> treat the rest of the props as json path
|
||||
if field != nil && field.Type() == FieldTypeJSON {
|
||||
if field != nil && field.GetHidden() && !r.allowHiddenFields {
|
||||
return nil, fmt.Errorf("non-filterable field %q", prop)
|
||||
}
|
||||
|
||||
// json or geoPoint field -> treat the rest of the props as json path
|
||||
// @todo consider converting to "JSONExtractable" interface with optional extra validation for the remaining props?
|
||||
if field != nil && (field.Type() == FieldTypeJSON || field.Type() == FieldTypeGeoPoint) {
|
||||
var jsonPath strings.Builder
|
||||
for j, p := range r.activeProps[i+1:] {
|
||||
if _, err := strconv.Atoi(p); err == nil {
|
||||
@@ -456,16 +480,28 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("missing back relation field %q", parts[2])
|
||||
}
|
||||
|
||||
if backField.Type() != FieldTypeRelation {
|
||||
if r.nullifyMisingField {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
|
||||
}
|
||||
|
||||
if backField.GetHidden() && !r.allowHiddenFields {
|
||||
return nil, fmt.Errorf("non-filterable back relation field %q", backField.GetName())
|
||||
}
|
||||
|
||||
backRelField, ok := backField.(*RelationField)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName())
|
||||
}
|
||||
if backRelField.CollectionId != collection.Id {
|
||||
return nil, fmt.Errorf("invalid back relation field %q collection reference", backField.GetName())
|
||||
// https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581
|
||||
if r.nullifyMisingField {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid collection reference of a back relation field %q", backField.GetName())
|
||||
}
|
||||
|
||||
// join the back relation to the main query
|
||||
@@ -478,7 +514,8 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
isBackRelMultiple := backRelField.IsMultiple()
|
||||
if !isBackRelMultiple {
|
||||
// additionally check if the rel field has a single column unique index
|
||||
isBackRelMultiple = !dbutils.HasSingleColumnUniqueIndex(backRelField.Name, backCollection.Indexes)
|
||||
_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
|
||||
isBackRelMultiple = !hasUniqueIndex
|
||||
}
|
||||
|
||||
if !isBackRelMultiple {
|
||||
@@ -713,11 +750,11 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
// default
|
||||
// -------------------------------------------------------
|
||||
result := &search.ResolverResult{
|
||||
Identifier: fmt.Sprintf("[[%s.%s]]", r.activeTableAlias, cleanFieldName),
|
||||
Identifier: "[[" + r.activeTableAlias + "." + cleanFieldName + "]]",
|
||||
}
|
||||
|
||||
if r.withMultiMatch {
|
||||
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.%s]]", r.multiMatchActiveTableAlias, cleanFieldName)
|
||||
r.multiMatch.valueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
|
||||
result.MultiMatchSubQuery = r.multiMatch
|
||||
}
|
||||
|
||||
@@ -743,5 +780,13 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
}
|
||||
}
|
||||
|
||||
// account for the ":lower" modifier
|
||||
if modifier == lowerModifier {
|
||||
result.Identifier = "LOWER(" + result.Identifier + ")"
|
||||
if r.withMultiMatch {
|
||||
r.multiMatch.valueIdentifier = "LOWER(" + r.multiMatch.valueIdentifier + ")"
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+100
-35
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
@@ -37,9 +39,9 @@ var (
|
||||
type Record struct {
|
||||
collection *Collection
|
||||
originalData map[string]any
|
||||
customVisibility *store.Store[bool]
|
||||
data *store.Store[any]
|
||||
expand *store.Store[any]
|
||||
customVisibility *store.Store[string, bool]
|
||||
data *store.Store[string, any]
|
||||
expand *store.Store[string, any]
|
||||
|
||||
BaseModel
|
||||
|
||||
@@ -55,10 +57,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -70,10 +75,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -85,10 +93,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -100,10 +111,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -115,10 +129,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
err := me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -130,10 +147,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -145,10 +165,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -160,10 +183,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -175,10 +201,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
err := me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -190,10 +219,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -205,10 +237,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -220,10 +255,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -235,10 +273,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
err := me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -498,8 +539,8 @@ func newRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringM
|
||||
func NewRecord(collection *Collection) *Record {
|
||||
record := &Record{
|
||||
collection: collection,
|
||||
data: store.New[any](nil),
|
||||
customVisibility: store.New[bool](nil),
|
||||
data: store.New[string, any](nil),
|
||||
customVisibility: store.New[string, bool](nil),
|
||||
originalData: make(map[string]any, len(collection.Fields)),
|
||||
}
|
||||
|
||||
@@ -642,7 +683,7 @@ func (m *Record) Expand() map[string]any {
|
||||
// SetExpand replaces the current Record's expand with the provided expand arg data (shallow copied).
|
||||
func (m *Record) SetExpand(expand map[string]any) {
|
||||
if m.expand == nil {
|
||||
m.expand = store.New[any](nil)
|
||||
m.expand = store.New[string, any](nil)
|
||||
}
|
||||
|
||||
m.expand.Reset(expand)
|
||||
@@ -794,6 +835,10 @@ func (m *Record) IgnoreEmailVisibility(state bool) *Record {
|
||||
//
|
||||
// This could be used if you want to save only the record fields that you've changed
|
||||
// without overwrite other untouched fields in case of concurrent update.
|
||||
//
|
||||
// Note that the fields change comparison is based on the current fields against m.Original()
|
||||
// (aka. if you have performed save on the same Record instance multiple times you may have to refetch it,
|
||||
// so that m.Original() could reflect the last saved change).
|
||||
func (m *Record) IgnoreUnchangedFields(state bool) *Record {
|
||||
m.ignoreUnchangedFields = state
|
||||
return m
|
||||
@@ -925,25 +970,32 @@ func (m *Record) GetDateTime(key string) types.DateTime {
|
||||
return d
|
||||
}
|
||||
|
||||
// GetGeoPoint returns the data value for "key" as a GeoPoint instance.
|
||||
func (m *Record) GetGeoPoint(key string) types.GeoPoint {
|
||||
point := types.GeoPoint{}
|
||||
_ = point.Scan(m.Get(key))
|
||||
return point
|
||||
}
|
||||
|
||||
// GetStringSlice returns the data value for "key" as a slice of non-zero unique strings.
|
||||
func (m *Record) GetStringSlice(key string) []string {
|
||||
return list.ToUniqueStringSlice(m.Get(key))
|
||||
}
|
||||
|
||||
// GetUploadedFiles returns the uploaded files for the provided "file" field key,
|
||||
// GetUnsavedFiles returns the uploaded files for the provided "file" field key,
|
||||
// (aka. the current [*filesytem.File] values) so that you can apply further
|
||||
// validations or modifications (including changing the file name or content before persisting).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// files := record.GetUploadedFiles("documents")
|
||||
// files := record.GetUnsavedFiles("documents")
|
||||
// for _, f := range files {
|
||||
// f.Name = "doc_" + f.Name // add a prefix to each file name
|
||||
// }
|
||||
// app.Save(record) // the files are pointers so the applied changes will transparently reflect on the record value
|
||||
func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
|
||||
if !strings.HasSuffix(key, ":uploaded") {
|
||||
key += ":uploaded"
|
||||
func (m *Record) GetUnsavedFiles(key string) []*filesystem.File {
|
||||
if !strings.HasSuffix(key, ":unsaved") {
|
||||
key += ":unsaved"
|
||||
}
|
||||
|
||||
values, _ := m.Get(key).([]*filesystem.File)
|
||||
@@ -951,6 +1003,12 @@ func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
|
||||
return values
|
||||
}
|
||||
|
||||
// Deprecated: replaced with GetUnsavedFiles.
|
||||
func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
|
||||
log.Println("Please replace GetUploadedFiles with GetUnsavedFiles")
|
||||
return m.GetUnsavedFiles(key)
|
||||
}
|
||||
|
||||
// Retrieves the "key" json field value and unmarshals it into "result".
|
||||
//
|
||||
// Example
|
||||
@@ -1370,12 +1428,18 @@ func onRecordValidate(e *RecordEvent) error {
|
||||
|
||||
func onRecordSaveExecute(e *RecordEvent) error {
|
||||
if e.Record.Collection().IsAuth() {
|
||||
// ensure that the token key is different on password change
|
||||
old := e.Record.Original()
|
||||
if !e.Record.IsNew() &&
|
||||
old.TokenKey() == e.Record.TokenKey() &&
|
||||
old.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) {
|
||||
e.Record.RefreshTokenKey()
|
||||
// ensure that the token key is regenerated on password change or email change
|
||||
if !e.Record.IsNew() {
|
||||
lastSavedRecord, err := e.App.FindRecordById(e.Record.Collection(), e.Record.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lastSavedRecord.TokenKey() == e.Record.TokenKey() &&
|
||||
(lastSavedRecord.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) ||
|
||||
lastSavedRecord.Email() != e.Record.Email()) {
|
||||
e.Record.RefreshTokenKey()
|
||||
}
|
||||
}
|
||||
|
||||
// cross-check that the auth record id is unique across all auth collections.
|
||||
@@ -1413,7 +1477,7 @@ func onRecordDeleteExecute(e *RecordEvent) error {
|
||||
//
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||
refs, err := e.App.FindCollectionReferences(e.Record.Collection())
|
||||
refs, err := e.App.FindCachedCollectionReferences(e.Record.Collection())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1452,12 +1516,13 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
|
||||
for _, refCollection := range sortedRefKeys {
|
||||
fields, ok := refs[refCollection]
|
||||
|
||||
if refCollection.IsView() || !ok {
|
||||
if !ok || refCollection.IsView() {
|
||||
continue // skip missing or view collections
|
||||
}
|
||||
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
|
||||
for _, field := range fields {
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName())
|
||||
|
||||
query := app.RecordQuery(refCollection)
|
||||
@@ -1466,8 +1531,8 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
|
||||
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
|
||||
} else {
|
||||
query.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
|
||||
`SELECT 1 FROM json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
|
||||
prefixedFieldName, prefixedFieldName, prefixedFieldName,
|
||||
`SELECT 1 FROM %s {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
|
||||
dbutils.JSONEach(prefixedFieldName),
|
||||
), dbx.Params{
|
||||
"jevalue": mainRecord.Id,
|
||||
})))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package core
|
||||
|
||||
import "github.com/pocketbase/pocketbase/tools/security"
|
||||
|
||||
// Email returns the "email" record field value (usually available with Auth collections).
|
||||
func (m *Record) Email() string {
|
||||
return m.GetString(FieldNameEmail)
|
||||
@@ -51,6 +53,25 @@ func (m *Record) SetPassword(password string) {
|
||||
m.Set(FieldNamePassword, password)
|
||||
}
|
||||
|
||||
// SetRandomPassword sets the "password" auth record field to a random autogenerated value.
|
||||
//
|
||||
// The autogenerated password is ~30 characters and it is set directly as hash,
|
||||
// aka. the field plain password value validators (length, pattern, etc.) are ignored
|
||||
// (this is usually used as part of the auto created OTP or OAuth2 user flows).
|
||||
func (m *Record) SetRandomPassword() string {
|
||||
pass := security.RandomString(30)
|
||||
|
||||
m.Set(FieldNamePassword, pass)
|
||||
m.RefreshTokenKey() // manually refresh the token key because the plain password is resetted
|
||||
|
||||
// unset the plain value to skip the field validators
|
||||
if raw, ok := m.GetRaw(FieldNamePassword).(*PasswordFieldValue); ok {
|
||||
raw.Plain = ""
|
||||
}
|
||||
|
||||
return pass
|
||||
}
|
||||
|
||||
// ValidatePassword validates a plain password against the "password" record field.
|
||||
//
|
||||
// Returns false if the password is incorrect.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@@ -117,3 +119,42 @@ func TestRecordPassword(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSetRandomPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldTokenKey := "old_tokenKey"
|
||||
record := core.NewRecord(core.NewAuthCollection("test"))
|
||||
record.SetTokenKey(oldTokenKey)
|
||||
|
||||
pass := record.SetRandomPassword()
|
||||
|
||||
if pass == "" {
|
||||
t.Fatal("Expected non-empty generated random password")
|
||||
}
|
||||
|
||||
if !record.ValidatePassword(pass) {
|
||||
t.Fatal("Expected the generated random password to be valid")
|
||||
}
|
||||
|
||||
if record.TokenKey() == oldTokenKey {
|
||||
t.Fatal("Expected token key to change")
|
||||
}
|
||||
|
||||
f, ok := record.Collection().Fields.GetByName(core.FieldNamePassword).(*core.PasswordField)
|
||||
if !ok {
|
||||
t.Fatal("Expected *core.PasswordField")
|
||||
}
|
||||
|
||||
// ensure that the field validators will be ignored
|
||||
f.Min = 1
|
||||
f.Max = 2
|
||||
f.Pattern = `\d+`
|
||||
|
||||
if err := f.ValidateValue(context.Background(), app, record); err != nil {
|
||||
t.Fatalf("Expected password field plain value validators to be ignored, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+368
-31
@@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -1013,7 +1016,44 @@ func TestRecordGetStringSlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetUploadedFiles(t *testing.T) {
|
||||
func TestRecordGetGeoPoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
value any
|
||||
expected string
|
||||
}{
|
||||
{nil, `{"lon":0,"lat":0}`},
|
||||
{"", `{"lon":0,"lat":0}`},
|
||||
{0, `{"lon":0,"lat":0}`},
|
||||
{false, `{"lon":0,"lat":0}`},
|
||||
{"{}", `{"lon":0,"lat":0}`},
|
||||
{"[]", `{"lon":0,"lat":0}`},
|
||||
{[]int{1, 2}, `{"lon":0,"lat":0}`},
|
||||
{map[string]any{"lon": 1, "lat": 2}, `{"lon":1,"lat":2}`},
|
||||
{[]byte(`{"lon":1,"lat":2}`), `{"lon":1,"lat":2}`},
|
||||
{`{"lon":1,"lat":2}`, `{"lon":1,"lat":2}`},
|
||||
{types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
|
||||
{&types.GeoPoint{Lon: 1, Lat: 2}, `{"lon":1,"lat":2}`},
|
||||
}
|
||||
|
||||
collection := core.NewBaseCollection("test")
|
||||
record := core.NewRecord(collection)
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
|
||||
record.Set("test", s.value)
|
||||
|
||||
pointStr := record.GetGeoPoint("test").String()
|
||||
|
||||
if pointStr != s.expected {
|
||||
t.Fatalf("Expected %q, got %q", s.expected, pointStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetUnsavedFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
@@ -1054,14 +1094,14 @@ func TestRecordGetUploadedFiles(t *testing.T) {
|
||||
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||
},
|
||||
{
|
||||
"files:uploaded",
|
||||
"files:unsaved",
|
||||
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%#v", i, s.key), func(t *testing.T) {
|
||||
v := record.GetUploadedFiles(s.key)
|
||||
v := record.GetUnsavedFiles(s.key)
|
||||
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@@ -1618,6 +1658,288 @@ func TestRecordValidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordModelEventSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
col, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testRecords := make([]*core.Record, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
testRecords[i] = core.NewRecord(col)
|
||||
testRecords[i].Set("title", "sync_test_"+strconv.Itoa(i))
|
||||
if err := app.Save(testRecords[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
createModelEvent := func() *core.ModelEvent {
|
||||
event := new(core.ModelEvent)
|
||||
event.App = app
|
||||
event.Context = context.Background()
|
||||
event.Type = "test_a"
|
||||
event.Model = testRecords[0]
|
||||
return event
|
||||
}
|
||||
|
||||
createModelErrorEvent := func() *core.ModelErrorEvent {
|
||||
event := new(core.ModelErrorEvent)
|
||||
event.ModelEvent = *createModelEvent()
|
||||
event.Error = errors.New("error_a")
|
||||
return event
|
||||
}
|
||||
|
||||
changeRecordEventBefore := func(e *core.RecordEvent) {
|
||||
e.Type = "test_b"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 123)
|
||||
e.Record = testRecords[1]
|
||||
}
|
||||
|
||||
modelEventFinalizerChange := func(e *core.ModelEvent) {
|
||||
e.Type = "test_c"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 456)
|
||||
e.Model = testRecords[2]
|
||||
}
|
||||
|
||||
changeRecordEventAfter := func(e *core.RecordEvent) {
|
||||
e.Type = "test_d"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 789)
|
||||
e.Record = testRecords[3]
|
||||
}
|
||||
|
||||
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
|
||||
if e.Type != "test_a" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != nil {
|
||||
t.Fatalf("Expected context value %v, got %v", nil, v)
|
||||
}
|
||||
|
||||
if e.Model.PK() != testRecords[0].Id {
|
||||
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Model.PK(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
|
||||
if e.Type != "test_d" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != 789 {
|
||||
t.Fatalf("Expected context value %v, got %v", 789, v)
|
||||
}
|
||||
|
||||
// note: currently the Model and Record values are not synced due to performance consideration
|
||||
if e.Model.PK() != testRecords[2].Id {
|
||||
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[2].Id, e.Model.PK(), 2)
|
||||
}
|
||||
}
|
||||
|
||||
expectedBeforeRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
|
||||
if e.Type != "test_a" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != nil {
|
||||
t.Fatalf("Expected context value %v, got %v", nil, v)
|
||||
}
|
||||
|
||||
if e.Record.Id != testRecords[0].Id {
|
||||
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[0].Id, e.Record.Id, 2)
|
||||
}
|
||||
}
|
||||
|
||||
expectedAfterRecordEventHandlerChecks := func(t *testing.T, e *core.RecordEvent) {
|
||||
if e.Type != "test_c" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != 456 {
|
||||
t.Fatalf("Expected context value %v, got %v", 456, v)
|
||||
}
|
||||
|
||||
// note: currently the Model and Record values are not synced due to performance consideration
|
||||
if e.Record.Id != testRecords[1].Id {
|
||||
t.Fatalf("Expected record with id %q, got %q (%d)", testRecords[1].Id, e.Record.Id, 1)
|
||||
}
|
||||
}
|
||||
|
||||
modelEventFinalizer := func(e *core.ModelEvent) error {
|
||||
modelEventFinalizerChange(e)
|
||||
return nil
|
||||
}
|
||||
|
||||
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
|
||||
modelEventFinalizerChange(&e.ModelEvent)
|
||||
e.Error = errors.New("error_c")
|
||||
return nil
|
||||
}
|
||||
|
||||
modelEventHandler := &hook.Handler[*core.ModelEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
t.Run("before model", func(t *testing.T) {
|
||||
expectedBeforeModelEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after model", func(t *testing.T) {
|
||||
expectedAfterModelEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.ModelErrorEvent) error {
|
||||
t.Run("before model error", func(t *testing.T) {
|
||||
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
|
||||
if v := e.Error.Error(); v != "error_a" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_a", v)
|
||||
}
|
||||
})
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after model error", func(t *testing.T) {
|
||||
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
|
||||
if v := e.Error.Error(); v != "error_d" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_d", v)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
recordEventHandler := &hook.Handler[*core.RecordEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.RecordEvent) error {
|
||||
t.Run("before record", func(t *testing.T) {
|
||||
expectedBeforeRecordEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
changeRecordEventBefore(e)
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after record", func(t *testing.T) {
|
||||
expectedAfterRecordEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
changeRecordEventAfter(e)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
recordErrorEventHandler := &hook.Handler[*core.RecordErrorEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.RecordErrorEvent) error {
|
||||
t.Run("before record error", func(t *testing.T) {
|
||||
expectedBeforeRecordEventHandlerChecks(t, &e.RecordEvent)
|
||||
if v := e.Error.Error(); v != "error_a" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_c", v)
|
||||
}
|
||||
})
|
||||
|
||||
changeRecordEventBefore(&e.RecordEvent)
|
||||
e.Error = errors.New("error_b")
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after record error", func(t *testing.T) {
|
||||
expectedAfterRecordEventHandlerChecks(t, &e.RecordEvent)
|
||||
if v := e.Error.Error(); v != "error_c" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_c", v)
|
||||
}
|
||||
})
|
||||
|
||||
changeRecordEventAfter(&e.RecordEvent)
|
||||
e.Error = errors.New("error_d")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// OnModelValidate
|
||||
app.OnRecordValidate().Bind(recordEventHandler)
|
||||
app.OnModelValidate().Bind(modelEventHandler)
|
||||
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelCreate
|
||||
app.OnRecordCreate().Bind(recordEventHandler)
|
||||
app.OnModelCreate().Bind(modelEventHandler)
|
||||
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelCreateExecute
|
||||
app.OnRecordCreateExecute().Bind(recordEventHandler)
|
||||
app.OnModelCreateExecute().Bind(modelEventHandler)
|
||||
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterCreateSuccess
|
||||
app.OnRecordAfterCreateSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterCreateError
|
||||
app.OnRecordAfterCreateError().Bind(recordErrorEventHandler)
|
||||
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
|
||||
// OnModelUpdate
|
||||
app.OnRecordUpdate().Bind(recordEventHandler)
|
||||
app.OnModelUpdate().Bind(modelEventHandler)
|
||||
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelUpdateExecute
|
||||
app.OnRecordUpdateExecute().Bind(recordEventHandler)
|
||||
app.OnModelUpdateExecute().Bind(modelEventHandler)
|
||||
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterUpdateSuccess
|
||||
app.OnRecordAfterUpdateSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterUpdateError
|
||||
app.OnRecordAfterUpdateError().Bind(recordErrorEventHandler)
|
||||
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
|
||||
// OnModelDelete
|
||||
app.OnRecordDelete().Bind(recordEventHandler)
|
||||
app.OnModelDelete().Bind(modelEventHandler)
|
||||
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelDeleteExecute
|
||||
app.OnRecordDeleteExecute().Bind(recordEventHandler)
|
||||
app.OnModelDeleteExecute().Bind(modelEventHandler)
|
||||
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterDeleteSuccess
|
||||
app.OnRecordAfterDeleteSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterDeleteError
|
||||
app.OnRecordAfterDeleteError().Bind(recordErrorEventHandler)
|
||||
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
}
|
||||
|
||||
func TestRecordSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1822,44 +2144,59 @@ func TestRecordSaveIdUpdateNoValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSaveWithChangedPassword(t *testing.T) {
|
||||
func TestRecordSaveWithAutoTokenKeyRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
record, err := app.FindAuthRecordByEmail("nologin", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
scenarios := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
expectedChange bool
|
||||
}{
|
||||
{
|
||||
"no email or password change",
|
||||
map[string]any{"name": "example"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"password change",
|
||||
map[string]any{"password": "1234567890"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"email change",
|
||||
map[string]any{"email": "test_update@example.com"},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
originalTokenKey := record.TokenKey()
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
record, err := app.FindFirstRecordByFilter("nologin", "1=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("no password change shouldn't change the tokenKey", func(t *testing.T) {
|
||||
record.Set("name", "example")
|
||||
originalTokenKey := record.TokenKey()
|
||||
|
||||
if err := app.Save(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.Load(s.payload)
|
||||
|
||||
tokenKey := record.TokenKey()
|
||||
if tokenKey == "" || originalTokenKey != tokenKey {
|
||||
t.Fatalf("Expected tokenKey to not change, got %q VS %q", originalTokenKey, tokenKey)
|
||||
}
|
||||
})
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("password change should change the tokenKey", func(t *testing.T) {
|
||||
record.Set("password", "1234567890")
|
||||
newTokenKey := record.TokenKey()
|
||||
|
||||
if err := app.Save(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hasChange := originalTokenKey != newTokenKey
|
||||
|
||||
tokenKey := record.TokenKey()
|
||||
if tokenKey == "" || originalTokenKey == tokenKey {
|
||||
t.Fatalf("Expected tokenKey to change, got %q VS %q", originalTokenKey, tokenKey)
|
||||
}
|
||||
})
|
||||
if hasChange != s.expectedChange {
|
||||
t.Fatalf("Expected hasChange %v, got %v", s.expectedChange, hasChange)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDelete(t *testing.T) {
|
||||
@@ -1917,13 +2254,13 @@ func TestRecordDelete(t *testing.T) {
|
||||
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
rec3, _ := app.FindRecordById("users", "oap640cot4yru2s")
|
||||
@@ -1943,7 +2280,7 @@ func TestRecordDelete(t *testing.T) {
|
||||
}
|
||||
// ensure that the json rel fields were prefixed
|
||||
joinedQueries := strings.Join(calledQueries, " ")
|
||||
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
|
||||
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyPart) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
|
||||
}
|
||||
|
||||
+19
-4
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
@@ -34,7 +35,7 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
|
||||
tableName = "@@__invalidCollectionModelOrIdentifier"
|
||||
}
|
||||
|
||||
query := app.DB().Select(app.DB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
|
||||
query := app.ConcurrentDB().Select(app.ConcurrentDB().QuoteSimpleColumnName(tableName) + ".*").From(tableName)
|
||||
|
||||
// in case of an error attach a new context and cancel it immediately with the error
|
||||
if collectionErr != nil {
|
||||
@@ -527,20 +528,34 @@ func (app *BaseApp) FindAuthRecordByToken(token string, validTypes ...string) (*
|
||||
|
||||
// FindAuthRecordByEmail finds the auth record associated with the provided email.
|
||||
//
|
||||
// The email check would be case-insensitive if the related collection
|
||||
// email unique index has COLLATE NOCASE specified for the email column.
|
||||
//
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (app *BaseApp) FindAuthRecordByEmail(collectionModelOrIdentifier any, email string) (*Record, error) {
|
||||
collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection: %w", err)
|
||||
}
|
||||
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collection.Name)
|
||||
}
|
||||
|
||||
record := &Record{}
|
||||
|
||||
var expr dbx.Expression
|
||||
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, FieldNameEmail)
|
||||
if ok && strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("[["+FieldNameEmail+"]] = {:email} COLLATE NOCASE", dbx.Params{"email": email})
|
||||
} else {
|
||||
expr = dbx.HashExp{FieldNameEmail: email}
|
||||
}
|
||||
|
||||
err = app.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{FieldNameEmail: email}).
|
||||
AndWhere(expr).
|
||||
Limit(1).
|
||||
One(record)
|
||||
if err != nil {
|
||||
@@ -584,7 +599,7 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
query := app.RecordQuery(record.Collection()).
|
||||
Select("(1)").
|
||||
@@ -603,5 +618,5 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
|
||||
return false, err
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
return exists > 0, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user