mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-10-05 14:01:43 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3912874517 | ||
|
|
5c58703a59 | ||
|
|
4155f50fe1 | ||
|
|
653f2d8b16 | ||
|
|
d607695600 | ||
|
|
3f51fb941b | ||
|
|
4d40463d8d | ||
|
|
5aa380927a | ||
|
|
c0b7762abd | ||
|
|
2e26f61cb7 | ||
|
|
59f98cac99 | ||
|
|
2a1fdc1613 | ||
|
|
f767af0ded | ||
|
|
920e893e11 | ||
|
|
048e534f0d | ||
|
|
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 | ||
|
|
8f32825cff | ||
|
|
20d378cd76 | ||
|
|
8e63e81561 | ||
|
|
efe4ef500b | ||
|
|
09a24e1de6 | ||
|
|
9490a220bc | ||
|
|
e51456bce2 | ||
|
|
35196674e6 | ||
|
|
7481c3f7f4 | ||
|
|
3ec10a9c7d | ||
|
|
3634fd9c26 | ||
|
|
f7c85940c4 | ||
|
|
6edb344ab3 | ||
|
|
c91d889da3 | ||
|
|
6a4e04533c | ||
|
|
e8f49c31e4 | ||
|
|
1abd6ca5c0 | ||
|
|
7c01441392 |
No files matched your search
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '>=1.23.4'
|
go-version: '>=1.23.6'
|
||||||
|
|
||||||
# This step usually is not needed because the /ui/dist is pregenerated locally
|
# 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.
|
# but its here to ensure that each release embeds the latest admin ui artifacts.
|
||||||
|
|||||||
+210
-1
@@ -1,3 +1,212 @@
|
|||||||
|
## 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
|
## v0.23.7
|
||||||
|
|
||||||
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
|
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
|
||||||
@@ -22,7 +231,7 @@
|
|||||||
|
|
||||||
- Other minor fixes (comment typos, JSVM types update).
|
- Other minor fixes (comment typos, JSVM types update).
|
||||||
|
|
||||||
- Updated Go deps and the min Go releleaser GitHub action version to 1.23.4.
|
- Updated Go deps and the min Go release GitHub action version to 1.23.4.
|
||||||
|
|
||||||
|
|
||||||
## v0.23.4
|
## v0.23.4
|
||||||
|
|||||||
@@ -2,6 +2,32 @@
|
|||||||
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## 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)).
|
- 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)).
|
||||||
|
|||||||
@@ -25,10 +25,13 @@
|
|||||||
|
|
||||||
## API SDK clients
|
## 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
|
## Overview
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ func bindBackupApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
|||||||
sub := rg.Group("/backups")
|
sub := rg.Group("/backups")
|
||||||
sub.GET("", backupsList).Bind(RequireSuperuserAuth())
|
sub.GET("", backupsList).Bind(RequireSuperuserAuth())
|
||||||
sub.POST("", backupCreate).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.GET("/{key}", backupDownload) // relies on superuser file token
|
||||||
sub.DELETE("/{key}", backupDelete).Bind(RequireSuperuserAuth())
|
sub.DELETE("/{key}", backupDelete).Bind(RequireSuperuserAuth())
|
||||||
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuth())
|
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuth())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
"gocloud.dev/blob"
|
"gocloud.dev/blob"
|
||||||
@@ -346,6 +347,19 @@ func TestBackupUpload(t *testing.T) {
|
|||||||
ExpectedStatus: 204,
|
ExpectedStatus: 204,
|
||||||
ExpectedEvents: map[string]int{"*": 0},
|
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 {
|
for _, scenario := range scenarios {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
|||||||
bindRecordAuthApi(app, apiGroup)
|
bindRecordAuthApi(app, apiGroup)
|
||||||
bindLogsApi(app, apiGroup)
|
bindLogsApi(app, apiGroup)
|
||||||
bindBackupApi(app, apiGroup)
|
bindBackupApi(app, apiGroup)
|
||||||
|
bindCronApi(app, apiGroup)
|
||||||
bindFileApi(app, apiGroup)
|
bindFileApi(app, apiGroup)
|
||||||
bindBatchApi(app, apiGroup)
|
bindBatchApi(app, apiGroup)
|
||||||
bindRealtimeApi(app, apiGroup)
|
bindRealtimeApi(app, apiGroup)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-36
@@ -5,37 +5,31 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"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
|
// DefaultInstallerFunc is the default PocketBase installer function.
|
||||||
func loadInstaller(app core.App, dashboardURL string) error {
|
//
|
||||||
if !needInstallerSuperuser(app) {
|
// It will attempt to open a link in the browser (with a short-lived auth
|
||||||
return nil
|
// token for the systemSuperuser) to the installer UI so that users can
|
||||||
}
|
// create their own custom superuser record.
|
||||||
|
//
|
||||||
installerRecord, err := findOrCreateInstallerSuperuser(app)
|
// See https://github.com/pocketbase/pocketbase/discussions/5814.
|
||||||
if err != nil {
|
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
|
||||||
return err
|
token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
|
||||||
}
|
|
||||||
|
|
||||||
token, err := installerRecord.NewStaticAuthToken(30 * time.Minute)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// launch url (ignore errors and always print a help text as fallback)
|
// launch url (ignore errors and always print a help text as fallback)
|
||||||
url := fmt.Sprintf("%s/#/pbinstal/%s", strings.TrimRight(dashboardURL, "/"), token)
|
url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token)
|
||||||
_ = launchURL(url)
|
_ = 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.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.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])
|
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
|
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 {
|
func needInstallerSuperuser(app core.App) bool {
|
||||||
total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{
|
total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{
|
||||||
"email": core.DefaultInstallerEmail,
|
"email": core.DefaultInstallerEmail,
|
||||||
@@ -65,7 +76,7 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
|
|||||||
|
|
||||||
record = core.NewRecord(col)
|
record = core.NewRecord(col)
|
||||||
record.SetEmail(core.DefaultInstallerEmail)
|
record.SetEmail(core.DefaultInstallerEmail)
|
||||||
record.SetPassword(security.RandomString(30))
|
record.SetRandomPassword()
|
||||||
|
|
||||||
err = app.Save(record)
|
err = app.Save(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,20 +86,3 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
|
|||||||
|
|
||||||
return record, nil
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -111,7 +111,7 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
|||||||
//
|
//
|
||||||
//nolint:unused
|
//nolint:unused
|
||||||
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
|
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 {
|
if !ok || rateLimiters == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
|
|||||||
|
|
||||||
rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
|
rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
|
||||||
return initRateLimitersStore(e.App)
|
return initRateLimitersStore(e.App)
|
||||||
}).(*store.Store[*rateLimiter])
|
}).(*store.Store[string, *rateLimiter])
|
||||||
if rateLimiters == nil {
|
if rateLimiters == nil {
|
||||||
e.App.Logger().Warn("Failed to retrieve app rate limiters store")
|
e.App.Logger().Warn("Failed to retrieve app rate limiters store")
|
||||||
return nil
|
return nil
|
||||||
@@ -198,9 +198,9 @@ func destroyRateLimitersStore(app core.App) {
|
|||||||
app.Store().Remove(rateLimitersStoreKey)
|
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
|
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 {
|
if !ok {
|
||||||
return
|
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 {
|
func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec int64) *rateLimiter {
|
||||||
|
|||||||
+66
-32
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -353,7 +354,10 @@ func bindRealtimeEvents(app core.App) {
|
|||||||
Func: func(e *core.ModelEvent) error {
|
Func: func(e *core.ModelEvent) error {
|
||||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||||
if record != nil {
|
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 {
|
if err != nil {
|
||||||
app.Logger().Debug(
|
app.Logger().Debug(
|
||||||
"Failed to dry cache record delete",
|
"Failed to dry cache record delete",
|
||||||
@@ -372,14 +376,17 @@ func bindRealtimeEvents(app core.App) {
|
|||||||
// delete: broadcast
|
// delete: broadcast
|
||||||
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
||||||
Func: func(e *core.ModelEvent) error {
|
Func: func(e *core.ModelEvent) error {
|
||||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
// note: only ensure that it is a collection record
|
||||||
if record != nil {
|
// and don't use realtimeResolveRecord because in case of a
|
||||||
err := realtimeBroadcastDryCachedRecord(e.App, "delete", record)
|
// 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 {
|
if err != nil {
|
||||||
app.Logger().Debug(
|
app.Logger().Debug(
|
||||||
"Failed to broadcast record delete",
|
"Failed to broadcast record delete",
|
||||||
slog.String("id", record.Id),
|
slog.Any("id", e.Model.PK()),
|
||||||
slog.String("collectionName", record.Collection().Name),
|
slog.String("collectionName", collection.Name),
|
||||||
slog.String("error", err.Error()),
|
slog.String("error", err.Error()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -395,7 +402,7 @@ func bindRealtimeEvents(app core.App) {
|
|||||||
Func: func(e *core.ModelErrorEvent) error {
|
Func: func(e *core.ModelErrorEvent) error {
|
||||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||||
if record != nil {
|
if record != nil {
|
||||||
err := realtimeUnsetDryCachedRecord(e.App, "delete", record)
|
err := realtimeUnsetDryCacheKey(e.App, getDryCacheKey("delete", record))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Logger().Debug(
|
app.Logger().Debug(
|
||||||
"Failed to cleanup after broadcast record delete failure",
|
"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.
|
// 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.
|
// 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 {
|
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 record != nil {
|
||||||
if optCollectionType == "" || record.Collection().Type == optCollectionType {
|
if optCollectionType == "" || record.Collection().Type == optCollectionType {
|
||||||
return record
|
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.
|
// 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.
|
// 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) {
|
func realtimeResolveRecordCollection(app core.App, model core.Model) (collection *core.Collection) {
|
||||||
if record, ok := model.(*core.Record); ok {
|
switch m := model.(type) {
|
||||||
collection = record.Collection()
|
case *core.Record:
|
||||||
} else {
|
return m.Collection()
|
||||||
// check if it is custom Record model struct (ignore "private" tables)
|
case core.RecordProxy:
|
||||||
collection, _ = app.FindCachedCollectionByNameOrId(model.TableName())
|
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.
|
// recordData represents the broadcasted record subscrition message data.
|
||||||
@@ -460,7 +480,11 @@ type recordData struct {
|
|||||||
Action string `json:"action"`
|
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()
|
collection := record.Collection()
|
||||||
if collection == nil {
|
if collection == nil {
|
||||||
return errors.New("[broadcastRecord] Record collection not set")
|
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,
|
(collection.Id + "?"): collection.ListRule,
|
||||||
}
|
}
|
||||||
|
|
||||||
dryCacheKey := action + "/" + record.Id
|
dryCacheKey := getDryCacheKey(action, record)
|
||||||
|
|
||||||
group := new(errgroup.Group)
|
group := new(errgroup.Group)
|
||||||
|
|
||||||
|
accessCheckApp := app
|
||||||
|
if len(optAccessCheckApp) > 0 {
|
||||||
|
accessCheckApp = optAccessCheckApp[0]
|
||||||
|
}
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
group.Go(func() error {
|
group.Go(func() error {
|
||||||
var clientAuth *core.Record
|
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)
|
clientAuth, _ = client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||||
|
|
||||||
for sub, options := range subs {
|
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
|
// mock request data
|
||||||
requestInfo := &core.RequestInfo{
|
requestInfo := &core.RequestInfo{
|
||||||
Context: core.RequestInfoContextRealtime,
|
Context: core.RequestInfoContextRealtime,
|
||||||
@@ -515,10 +540,14 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
|||||||
Auth: clientAuth,
|
Auth: clientAuth,
|
||||||
}
|
}
|
||||||
|
|
||||||
if !realtimeCanAccessRecord(app, cleanRecord, requestInfo, rule) {
|
if !realtimeCanAccessRecord(accessCheckApp, record, requestInfo, rule) {
|
||||||
continue
|
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
|
// trigger the enrich hooks
|
||||||
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
|
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
|
||||||
// apply expand
|
// apply expand
|
||||||
@@ -541,7 +570,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
|||||||
// for auth owner, superuser or manager
|
// for auth owner, superuser or manager
|
||||||
if collection.IsAuth() {
|
if collection.IsAuth() {
|
||||||
if isSameAuth(clientAuth, cleanRecord) ||
|
if isSameAuth(clientAuth, cleanRecord) ||
|
||||||
realtimeCanAccessRecord(app, cleanRecord, requestInfo, collection.ManageRule) {
|
realtimeCanAccessRecord(accessCheckApp, cleanRecord, requestInfo, collection.ManageRule) {
|
||||||
cleanRecord.IgnoreEmailVisibility(true)
|
cleanRecord.IgnoreEmailVisibility(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,15 +651,13 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
|||||||
return group.Wait()
|
return group.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// realtimeBroadcastDryCachedRecord broadcasts all cached record related messages.
|
// realtimeBroadcastDryCacheKey broadcasts the dry cached key related messages.
|
||||||
func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.Record) error {
|
func realtimeBroadcastDryCacheKey(app core.App, key string) error {
|
||||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||||
if len(chunks) == 0 {
|
if len(chunks) == 0 {
|
||||||
return nil // no subscribers
|
return nil // no subscribers
|
||||||
}
|
}
|
||||||
|
|
||||||
key := action + "/" + record.Id
|
|
||||||
|
|
||||||
group := new(errgroup.Group)
|
group := new(errgroup.Group)
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
@@ -659,15 +686,13 @@ func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.
|
|||||||
return group.Wait()
|
return group.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// realtimeUnsetDryCachedRecord removes the dry cached record related messages.
|
// realtimeUnsetDryCacheKey removes the dry cached key related messages.
|
||||||
func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Record) error {
|
func realtimeUnsetDryCacheKey(app core.App, key string) error {
|
||||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||||
if len(chunks) == 0 {
|
if len(chunks) == 0 {
|
||||||
return nil // no subscribers
|
return nil // no subscribers
|
||||||
}
|
}
|
||||||
|
|
||||||
key := action + "/" + record.Id
|
|
||||||
|
|
||||||
group := new(errgroup.Group)
|
group := new(errgroup.Group)
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
@@ -685,6 +710,15 @@ func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Reco
|
|||||||
return group.Wait()
|
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 {
|
func isSameAuth(authA, authB *core.Record) bool {
|
||||||
if authA == nil {
|
if authA == nil {
|
||||||
return authB == nil
|
return authB == nil
|
||||||
@@ -722,7 +756,7 @@ func realtimeCanAccessRecord(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists bool
|
var exists int
|
||||||
|
|
||||||
q := app.DB().Select("(1)").
|
q := app.DB().Select("(1)").
|
||||||
From(record.Collection().Name).
|
From(record.Collection().Name).
|
||||||
@@ -739,5 +773,5 @@ func realtimeCanAccessRecord(
|
|||||||
|
|
||||||
err = q.Limit(1).Row(&exists)
|
err = q.Limit(1).Row(&exists)
|
||||||
|
|
||||||
return err == nil && exists
|
return err == nil && exists > 0
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,13 @@ package apis_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRealtimeConnect(t *testing.T) {
|
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())
|
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,9 +38,8 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
|
|||||||
event.NewEmail = newEmail
|
event.NewEmail = newEmail
|
||||||
|
|
||||||
return e.App.OnRecordConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeRequestEvent) error {
|
return e.App.OnRecordConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeRequestEvent) error {
|
||||||
authRecord.Set(core.FieldNameEmail, e.NewEmail)
|
e.Record.SetEmail(e.NewEmail)
|
||||||
authRecord.Set(core.FieldNameVerified, true)
|
e.Record.SetVerified(true)
|
||||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
|
||||||
|
|
||||||
if err := e.App.Save(e.Record); err != nil {
|
if err := e.App.Save(e.Record); err != nil {
|
||||||
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
|
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = form.app.Save(authRecord)
|
err = e.App.Save(authRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return firstApiError(err, e.BadRequestError("Failed to set new password.", err))
|
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 e.NoContent(http.StatusNoContent)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -186,11 +186,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
|||||||
t.Fatal("Expected the user to be unverified")
|
t.Fatal("Expected the user to be unverified")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldTokenKey := user.TokenKey()
|
||||||
|
|
||||||
// manually change the email to check whether the verified state will be updated
|
// manually change the email to check whether the verified state will be updated
|
||||||
user.SetEmail("test_update@example.com")
|
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)
|
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) {
|
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||||
_, err := app.FindAuthRecordByToken(
|
_, err := app.FindAuthRecordByToken(
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ package apis
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/auth"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,13 +134,17 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
|||||||
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
|
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := struct {
|
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
|
||||||
*auth.AuthUser
|
meta := map[string]any{}
|
||||||
IsNew bool `json:"isNew"`
|
rawOAuth2User, err := json.Marshal(e.OAuth2User)
|
||||||
}{
|
if err != nil {
|
||||||
AuthUser: e.OAuth2User,
|
return err
|
||||||
IsNew: e.IsNewRecord,
|
|
||||||
}
|
}
|
||||||
|
err = json.Unmarshal(rawOAuth2User, &meta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
meta["isNew"] = e.IsNewRecord
|
||||||
|
|
||||||
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
|
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
|
||||||
})
|
})
|
||||||
@@ -195,10 +199,20 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
|||||||
|
|
||||||
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
|
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
|
||||||
// ensure that username is unique
|
// ensure that username is unique
|
||||||
checkUnique := dbutils.HasSingleColumnUniqueIndex(collection.OAuth2.MappedFields.Username, collection.Indexes)
|
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
|
||||||
if checkUnique {
|
if hasUniqueue {
|
||||||
if _, err := txApp.FindFirstRecordByData(collection, collection.OAuth2.MappedFields.Username, username); err == nil {
|
var expr dbx.Expression
|
||||||
return false // already exist
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +238,6 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
|||||||
|
|
||||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
// map known fields (unless the field was explicitly submitted as part of CreateData)
|
// map known fields (unless the field was explicitly submitted as part of CreateData)
|
||||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
|
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
|
||||||
payload[e.Collection.OAuth2.MappedFields.Id] = e.OAuth2User.Id
|
payload[e.Collection.OAuth2.MappedFields.Id] = e.OAuth2User.Id
|
||||||
@@ -292,7 +300,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
|||||||
// set random password for users with unverified email
|
// set random password for users with unverified email
|
||||||
// (this is in case a malicious actor has registered previously with the user email)
|
// (this is in case a malicious actor has registered previously with the user email)
|
||||||
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
|
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
|
||||||
e.Record.SetPassword(security.RandomString(30))
|
e.Record.SetRandomPassword()
|
||||||
needUpdate = true
|
needUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/pocketbase/pocketbase/tools/auth"
|
"github.com/pocketbase/pocketbase/tools/auth"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -292,6 +293,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
`"record":{`,
|
`"record":{`,
|
||||||
`"token":"`,
|
`"token":"`,
|
||||||
`"meta":{`,
|
`"meta":{`,
|
||||||
|
`"isNew":false`,
|
||||||
`"email":"test2@example.com"`,
|
`"email":"test2@example.com"`,
|
||||||
`"id":"oap640cot4yru2s"`,
|
`"id":"oap640cot4yru2s"`,
|
||||||
`"id":"test_id"`,
|
`"id":"test_id"`,
|
||||||
@@ -383,6 +385,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
`"record":{`,
|
`"record":{`,
|
||||||
`"token":"`,
|
`"token":"`,
|
||||||
`"meta":{`,
|
`"meta":{`,
|
||||||
|
`"isNew":false`,
|
||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"id":"4q1xlclmfloku33"`,
|
`"id":"4q1xlclmfloku33"`,
|
||||||
`"id":"test_id"`,
|
`"id":"test_id"`,
|
||||||
@@ -488,6 +491,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
`"record":{`,
|
`"record":{`,
|
||||||
`"token":"`,
|
`"token":"`,
|
||||||
`"meta":{`,
|
`"meta":{`,
|
||||||
|
`"isNew":false`,
|
||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"id":"4q1xlclmfloku33"`,
|
`"id":"4q1xlclmfloku33"`,
|
||||||
`"id":"test_id"`,
|
`"id":"test_id"`,
|
||||||
@@ -558,12 +562,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldTokenKey := user.TokenKey()
|
||||||
|
|
||||||
// manually unset the user email
|
// manually unset the user email
|
||||||
user.SetEmail("")
|
user.SetEmail("")
|
||||||
if err := app.Save(user); err != nil {
|
if err = app.Save(user); err != nil {
|
||||||
t.Fatal(err)
|
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
|
// register the test provider
|
||||||
auth.Providers["test"] = func() auth.Provider {
|
auth.Providers["test"] = func() auth.Provider {
|
||||||
return &oauth2MockProvider{
|
return &oauth2MockProvider{
|
||||||
@@ -592,6 +605,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
`"record":{`,
|
`"record":{`,
|
||||||
`"token":"`,
|
`"token":"`,
|
||||||
`"meta":{`,
|
`"meta":{`,
|
||||||
|
`"isNew":false`,
|
||||||
`"email":"test_oauth2@example.com"`,
|
`"email":"test_oauth2@example.com"`,
|
||||||
`"id":"4q1xlclmfloku33"`,
|
`"id":"4q1xlclmfloku33"`,
|
||||||
`"id":"test_id"`,
|
`"id":"test_id"`,
|
||||||
@@ -697,6 +711,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
`"record":{`,
|
`"record":{`,
|
||||||
`"token":"`,
|
`"token":"`,
|
||||||
`"meta":{`,
|
`"meta":{`,
|
||||||
|
`"isNew":false`,
|
||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"id":"4q1xlclmfloku33"`,
|
`"id":"4q1xlclmfloku33"`,
|
||||||
`"id":"test_id"`,
|
`"id":"test_id"`,
|
||||||
@@ -786,6 +801,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
`"record":{`,
|
`"record":{`,
|
||||||
`"token":"`,
|
`"token":"`,
|
||||||
`"meta":{`,
|
`"meta":{`,
|
||||||
|
`"isNew":true`,
|
||||||
`"email":""`,
|
`"email":""`,
|
||||||
`"id":"test_id"`,
|
`"id":"test_id"`,
|
||||||
`"verified":true`,
|
`"verified":true`,
|
||||||
@@ -949,6 +965,8 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
"createData": {
|
"createData": {
|
||||||
"email": "invalid",
|
"email": "invalid",
|
||||||
"emailVisibility": true,
|
"emailVisibility": true,
|
||||||
|
"password": "1234567890",
|
||||||
|
"passwordConfirm": "1234567890",
|
||||||
"name": "test_name",
|
"name": "test_name",
|
||||||
"username": "test_username",
|
"username": "test_username",
|
||||||
"rel": "0yxhwia2amd8gec"
|
"rel": "0yxhwia2amd8gec"
|
||||||
@@ -982,6 +1000,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
},
|
},
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
|
`"isNew":true`,
|
||||||
`"email":""`,
|
`"email":""`,
|
||||||
`"emailVisibility":true`,
|
`"emailVisibility":true`,
|
||||||
`"name":"test_name"`,
|
`"name":"test_name"`,
|
||||||
@@ -1018,6 +1037,16 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
"OnModelValidate": 4,
|
"OnModelValidate": 4,
|
||||||
"OnRecordValidate": 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 (with mapped OAuth2 fields and avatarURL->file field)",
|
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
|
||||||
@@ -1071,6 +1100,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
},
|
},
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
|
`"isNew":true`,
|
||||||
`"email":"oauth2@example.com"`,
|
`"email":"oauth2@example.com"`,
|
||||||
`"emailVisibility":true`,
|
`"emailVisibility":true`,
|
||||||
`"name":"test_name"`,
|
`"name":"test_name"`,
|
||||||
@@ -1153,6 +1183,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
},
|
},
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
|
`"isNew":true`,
|
||||||
`"email":"oauth2@example.com"`,
|
`"email":"oauth2@example.com"`,
|
||||||
`"emailVisibility":false`,
|
`"emailVisibility":false`,
|
||||||
`"verified":true`,
|
`"verified":true`,
|
||||||
@@ -1189,7 +1220,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,
|
Method: http.MethodPost,
|
||||||
URL: "/api/collections/users/auth-with-oauth2",
|
URL: "/api/collections/users/auth-with-oauth2",
|
||||||
Body: strings.NewReader(`{
|
Body: strings.NewReader(`{
|
||||||
@@ -1209,7 +1240,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
AuthUser: &auth.AuthUser{
|
AuthUser: &auth.AuthUser{
|
||||||
Id: "oauth2_id",
|
Id: "oauth2_id",
|
||||||
Email: "oauth2@example.com",
|
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",
|
Name: "oauth2_name",
|
||||||
AvatarURL: server.URL + "/oauth2_avatar.png",
|
AvatarURL: server.URL + "/oauth2_avatar.png",
|
||||||
},
|
},
|
||||||
@@ -1235,9 +1266,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
},
|
},
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
|
`"isNew":true`,
|
||||||
`"email":"oauth2@example.com"`,
|
`"email":"oauth2@example.com"`,
|
||||||
`"emailVisibility":false`,
|
`"emailVisibility":false`,
|
||||||
`"username":"oauth2_username"`,
|
`"username":"tESt2_username"`,
|
||||||
`"name":"http://127.`,
|
`"name":"http://127.`,
|
||||||
`"verified":true`,
|
`"verified":true`,
|
||||||
`"avatar":""`,
|
`"avatar":""`,
|
||||||
@@ -1273,7 +1305,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,
|
Method: http.MethodPost,
|
||||||
URL: "/api/collections/users/auth-with-oauth2",
|
URL: "/api/collections/users/auth-with-oauth2",
|
||||||
Body: strings.NewReader(`{
|
Body: strings.NewReader(`{
|
||||||
@@ -1293,13 +1325,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
AuthUser: &auth.AuthUser{
|
AuthUser: &auth.AuthUser{
|
||||||
Id: "oauth2_id",
|
Id: "oauth2_id",
|
||||||
Email: "oauth2@example.com",
|
Email: "oauth2@example.com",
|
||||||
Username: "test2_username",
|
Username: "tESt2_username",
|
||||||
Name: "oauth2_name",
|
Name: "oauth2_name",
|
||||||
},
|
},
|
||||||
Token: &oauth2.Token{AccessToken: "abc"},
|
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
|
// add the test provider in the collection
|
||||||
usersCol.MFA.Enabled = false
|
usersCol.MFA.Enabled = false
|
||||||
usersCol.OAuth2.Enabled = true
|
usersCol.OAuth2.Enabled = true
|
||||||
@@ -1317,6 +1357,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
},
|
},
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
|
`"isNew":true`,
|
||||||
`"email":"oauth2@example.com"`,
|
`"email":"oauth2@example.com"`,
|
||||||
`"emailVisibility":false`,
|
`"emailVisibility":false`,
|
||||||
`"verified":true`,
|
`"verified":true`,
|
||||||
@@ -1398,6 +1439,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
|||||||
},
|
},
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
|
`"isNew":true`,
|
||||||
`"email":"oauth2@example.com"`,
|
`"email":"oauth2@example.com"`,
|
||||||
`"emailVisibility":false`,
|
`"emailVisibility":false`,
|
||||||
`"verified":true`,
|
`"verified":true`,
|
||||||
|
|||||||
@@ -85,12 +85,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
|||||||
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
|
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package apis
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,12 +36,12 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
|||||||
var foundErr error
|
var foundErr error
|
||||||
|
|
||||||
if form.IdentityField != "" {
|
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 {
|
} else {
|
||||||
// prioritize email lookup
|
// prioritize email lookup
|
||||||
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
||||||
if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
|
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
|
// search by the other identity fields
|
||||||
@@ -47,7 +51,7 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
|||||||
continue // no need to search by the email field if it is not an email
|
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 {
|
if foundErr == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,38 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
|
|||||||
return validation.ValidateStruct(form,
|
return validation.ValidateStruct(form,
|
||||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
|
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
|
||||||
validation.Field(&form.Password, 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
|
||||||
|
}
|
||||||
@@ -8,11 +8,38 @@ import (
|
|||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecordAuthWithPassword(t *testing.T) {
|
func TestRecordAuthWithPassword(t *testing.T) {
|
||||||
t.Parallel()
|
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{
|
scenarios := []tests.ApiScenario{
|
||||||
{
|
{
|
||||||
Name: "disabled password auth",
|
Name: "disabled password auth",
|
||||||
@@ -164,6 +191,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
|||||||
"OnMailerRecordAuthAlertSend": 1,
|
"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",
|
Name: "valid identity field and valid password with mismatched explicit identityField",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
@@ -440,6 +483,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
|
// rate limit checks
|
||||||
// -----------------------------------------------------------
|
// -----------------------------------------------------------
|
||||||
{
|
{
|
||||||
|
|||||||
+135
-53
@@ -13,9 +13,11 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/forms"
|
"github.com/pocketbase/pocketbase/forms"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
"github.com/pocketbase/pocketbase/tools/search"
|
"github.com/pocketbase/pocketbase/tools/search"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bindRecordCrudApi registers the record crud api endpoints and
|
// bindRecordCrudApi registers the record crud api endpoints and
|
||||||
@@ -57,23 +59,27 @@ func recordsList(e *core.RequestEvent) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldsResolver := core.NewRecordFieldResolver(
|
query := e.App.RecordQuery(collection)
|
||||||
e.App,
|
|
||||||
collection,
|
|
||||||
requestInfo,
|
|
||||||
// hidden fields are searchable only by superusers
|
|
||||||
requestInfo.HasSuperuserAuth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
searchProvider := search.NewProvider(fieldsResolver).
|
fieldsResolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||||
Query(e.App.RecordQuery(collection))
|
|
||||||
|
|
||||||
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil {
|
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil && *collection.ListRule != "" {
|
||||||
searchProvider.AddFilter(search.FilterData(*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)
|
||||||
}
|
}
|
||||||
|
|
||||||
records := []*core.Record{}
|
// hidden fields are searchable only by superusers
|
||||||
|
fieldsResolver.SetAllowHiddenFields(requestInfo.HasSuperuserAuth())
|
||||||
|
|
||||||
|
searchProvider := search.NewProvider(fieldsResolver).Query(query)
|
||||||
|
|
||||||
|
records := []*core.Record{}
|
||||||
result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
|
result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return firstApiError(err, e.BadRequestError("", err))
|
return firstApiError(err, e.BadRequestError("", err))
|
||||||
@@ -92,9 +98,9 @@ func recordsList(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
// Add a randomized throttle in case of too many empty search filter attempts.
|
// 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.
|
// 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.
|
// 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.
|
// 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,7 +113,7 @@ func recordsList(e *core.RequestEvent) error {
|
|||||||
len(e.Records) == 0 &&
|
len(e.Records) == 0 &&
|
||||||
checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
|
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)
|
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 e.JSON(http.StatusOK, e.Result)
|
||||||
@@ -218,6 +224,16 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
|||||||
return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
|
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
|
// replace modifiers fields so that the resolved value is always
|
||||||
// available when accessing requestInfo.Body
|
// available when accessing requestInfo.Body
|
||||||
requestInfo.Body = data
|
requestInfo.Body = data
|
||||||
@@ -228,6 +244,13 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
|||||||
}
|
}
|
||||||
form.Load(data)
|
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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var isOptFinalizerCalled bool
|
var isOptFinalizerCalled bool
|
||||||
|
|
||||||
event := new(core.RecordRequestEvent)
|
event := new(core.RecordRequestEvent)
|
||||||
@@ -241,57 +264,74 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
|||||||
|
|
||||||
// temporary save the record and check it against the create and manage rules
|
// temporary save the record and check it against the create and manage rules
|
||||||
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
|
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
|
||||||
// temporary grant manager access level
|
dummyRecord := e.Record.Clone()
|
||||||
form.GrantManagerAccess()
|
|
||||||
|
|
||||||
// manually unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
|
||||||
initialVerified := e.Record.Verified()
|
|
||||||
if initialVerified {
|
// set an id if it doesn't have already
|
||||||
e.Record.SetVerified(false)
|
// (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
|
||||||
}
|
}
|
||||||
|
|
||||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
||||||
if *e.Collection.CreateRule == "" {
|
dummyRecord.SetVerified(false)
|
||||||
return nil // no create rule to resolve
|
|
||||||
}
|
|
||||||
|
|
||||||
resolver := core.NewRecordFieldResolver(e.App, e.Collection, requestInfo, true)
|
// export the dummy record data into db params
|
||||||
expr, err := search.FilterData(*e.Collection.CreateRule).BuildExpr(resolver)
|
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 := *e.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.DB().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 {
|
if err != nil {
|
||||||
return err
|
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
|
||||||
}
|
}
|
||||||
resolver.UpdateQuery(q)
|
ruleQuery.AndWhere(expr)
|
||||||
q.AndWhere(expr)
|
|
||||||
|
|
||||||
return nil
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testErr := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error {
|
// check for manage rule access
|
||||||
foundRecord, err := txApp.FindRecordById(drySavedRecord.Collection(), drySavedRecord.Id, createRuleFunc)
|
manageRuleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
|
||||||
if err != nil {
|
if !form.HasManageAccess() &&
|
||||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
|
||||||
}
|
form.GrantManagerAccess()
|
||||||
|
|
||||||
// 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()
|
err := form.Submit()
|
||||||
if err != nil {
|
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)
|
err = EnrichRecord(e.RequestEvent, e.Record)
|
||||||
@@ -411,7 +451,13 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
|||||||
hookErr := e.App.OnRecordUpdateRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
|
hookErr := e.App.OnRecordUpdateRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
|
||||||
form.SetApp(e.App)
|
form.SetApp(e.App)
|
||||||
form.SetRecord(e.Record)
|
form.SetRecord(e.Record)
|
||||||
if !form.HasManageAccess() && hasAuthManageAccess(e.App, requestInfo, e.Record) {
|
|
||||||
|
manageRuleQuery := e.App.DB().Select("(1)").From(e.Collection.Name).AndWhere(dbx.HashExp{
|
||||||
|
// note: use the original record id and not e.Record.Id because the record validations because may get overwritten
|
||||||
|
e.Collection.Name + ".id": e.Record.LastSavedPK(),
|
||||||
|
})
|
||||||
|
if !form.HasManageAccess() &&
|
||||||
|
hasAuthManageAccess(e.App, requestInfo, e.Collection, manageRuleQuery) {
|
||||||
form.GrantManagerAccess()
|
form.GrantManagerAccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,3 +690,39 @@ func extractUploadedFiles(re *core.RequestEvent, collection *core.Collection, pr
|
|||||||
|
|
||||||
return result, nil
|
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
|
||||||
|
}
|
||||||
@@ -259,6 +259,47 @@ func TestRecordCrudList(t *testing.T) {
|
|||||||
"OnRecordEnrich": 4,
|
"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",
|
Name: "authenticated regular record filtering with a hidden field",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
@@ -1747,11 +1788,16 @@ func TestRecordCrudCreate(t *testing.T) {
|
|||||||
`"code":"validation_not_unique"`,
|
`"code":"validation_not_unique"`,
|
||||||
},
|
},
|
||||||
ExpectedEvents: map[string]int{
|
ExpectedEvents: map[string]int{
|
||||||
"*": 0,
|
"*": 0,
|
||||||
"OnRecordCreateRequest": 1,
|
"OnRecordCreateRequest": 1,
|
||||||
// validate events are not fired because the unique check will fail during dry submit
|
"OnModelCreate": 1,
|
||||||
// "OnModelValidate": 1,
|
"OnModelCreateExecute": 1,
|
||||||
// "OnRecordValidate": 1,
|
"OnModelAfterCreateError": 1,
|
||||||
|
"OnModelValidate": 1,
|
||||||
|
"OnRecordCreate": 1,
|
||||||
|
"OnRecordCreateExecute": 1,
|
||||||
|
"OnRecordAfterCreateError": 1,
|
||||||
|
"OnRecordValidate": 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+25
-37
@@ -6,11 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/mails"
|
"github.com/pocketbase/pocketbase/mails"
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/routine"
|
||||||
"github.com/pocketbase/pocketbase/tools/search"
|
"github.com/pocketbase/pocketbase/tools/search"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
)
|
)
|
||||||
@@ -144,7 +146,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
|||||||
return true, err
|
return true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists bool
|
var exists int
|
||||||
|
|
||||||
query := e.App.RecordQuery(record.Collection()).
|
query := e.App.RecordQuery(record.Collection()).
|
||||||
Select("(1)").
|
Select("(1)").
|
||||||
@@ -163,7 +165,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
|||||||
return true, err
|
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.
|
// checkMFA handles any MFA auth checks that needs to be performed for the specified request event.
|
||||||
@@ -479,40 +481,6 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
|
|||||||
return nil
|
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 ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
|
||||||
var superuserOnlyRuleFields = []string{"@collection.", "@request."}
|
var superuserOnlyRuleFields = []string{"@collection.", "@request."}
|
||||||
|
|
||||||
@@ -603,8 +571,28 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// send email alert for the new origin auth (skip first login)
|
// 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 !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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-27
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,6 +16,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/hook"
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/routine"
|
||||||
"github.com/pocketbase/pocketbase/ui"
|
"github.com/pocketbase/pocketbase/ui"
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
@@ -158,6 +158,7 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
serveEvent.Router = pbRouter
|
serveEvent.Router = pbRouter
|
||||||
serveEvent.Server = server
|
serveEvent.Server = server
|
||||||
serveEvent.CertManager = certManager
|
serveEvent.CertManager = certManager
|
||||||
|
serveEvent.InstallerFunc = DefaultInstallerFunc
|
||||||
|
|
||||||
var listener net.Listener
|
var listener net.Listener
|
||||||
|
|
||||||
@@ -204,6 +205,8 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
}()
|
}()
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
var baseURL string
|
||||||
|
|
||||||
// trigger the OnServe hook and start the tcp listener
|
// trigger the OnServe hook and start the tcp listener
|
||||||
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||||
handler, err := e.Router.BuildMux()
|
handler, err := e.Router.BuildMux()
|
||||||
@@ -213,10 +216,20 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
|
|
||||||
e.Server.Handler = handler
|
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 == "" {
|
if addr == "" {
|
||||||
|
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
|
||||||
if config.HttpsAddr != "" {
|
if config.HttpsAddr != "" {
|
||||||
addr = ":https"
|
addr = ":https"
|
||||||
} else {
|
} else {
|
||||||
@@ -224,11 +237,22 @@ 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 {
|
if serveHookErr != nil {
|
||||||
return serveHookErr
|
return serveHookErr
|
||||||
@@ -238,17 +262,6 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?")
|
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 {
|
if config.ShowStartBanner {
|
||||||
date := new(strings.Builder)
|
date := new(strings.Builder)
|
||||||
log.New(date, "", log.LstdFlags).Print()
|
log.New(date, "", log.LstdFlags).Print()
|
||||||
@@ -262,16 +275,9 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
|
|
||||||
regular := color.New()
|
regular := color.New()
|
||||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
|
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
|
var serveErr error
|
||||||
if config.HttpsAddr != "" {
|
if config.HttpsAddr != "" {
|
||||||
if config.HttpAddr != "" {
|
if config.HttpAddr != "" {
|
||||||
@@ -280,10 +286,10 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// start HTTPS server
|
// start HTTPS server
|
||||||
serveErr = server.ServeTLS(listener, "", "")
|
serveErr = serveEvent.Server.ServeTLS(listener, "", "")
|
||||||
} else {
|
} else {
|
||||||
// OR start HTTP server
|
// OR start HTTP server
|
||||||
serveErr = server.Serve(listener)
|
serveErr = serveEvent.Server.Serve(listener)
|
||||||
}
|
}
|
||||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||||
return serveErr
|
return serveErr
|
||||||
@@ -292,6 +298,14 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
return nil
|
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 {
|
type serverErrorLogWriter struct {
|
||||||
app core.App
|
app core.App
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-9
@@ -71,7 +71,7 @@ type App interface {
|
|||||||
Settings() *Settings
|
Settings() *Settings
|
||||||
|
|
||||||
// Store returns the app runtime store.
|
// Store returns the app runtime store.
|
||||||
Store() *store.Store[any]
|
Store() *store.Store[string, any]
|
||||||
|
|
||||||
// Cron returns the app cron instance.
|
// Cron returns the app cron instance.
|
||||||
Cron() *cron.Cron
|
Cron() *cron.Cron
|
||||||
@@ -369,16 +369,9 @@ type App interface {
|
|||||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
// - 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.
|
// 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).
|
// - 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)
|
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
|
// FindCollectionReferences returns information for all relation
|
||||||
// fields referencing the provided collection.
|
// fields referencing the provided collection.
|
||||||
//
|
//
|
||||||
@@ -387,6 +380,32 @@ type App interface {
|
|||||||
// as the excludeIds argument.
|
// as the excludeIds argument.
|
||||||
FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
|
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.
|
// TruncateCollection deletes all records associated with the provided collection.
|
||||||
//
|
//
|
||||||
// The truncate operation is executed in a single transaction,
|
// The truncate operation is executed in a single transaction,
|
||||||
|
|||||||
+11
-3
@@ -70,7 +70,7 @@ var _ App = (*BaseApp)(nil)
|
|||||||
type BaseApp struct {
|
type BaseApp struct {
|
||||||
config *BaseAppConfig
|
config *BaseAppConfig
|
||||||
txInfo *txAppInfo
|
txInfo *txAppInfo
|
||||||
store *store.Store[any]
|
store *store.Store[string, any]
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
settings *Settings
|
settings *Settings
|
||||||
subscriptionsBroker *subscriptions.Broker
|
subscriptionsBroker *subscriptions.Broker
|
||||||
@@ -194,7 +194,7 @@ type BaseApp struct {
|
|||||||
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||||
app := &BaseApp{
|
app := &BaseApp{
|
||||||
settings: newDefaultSettings(),
|
settings: newDefaultSettings(),
|
||||||
store: store.New[any](nil),
|
store: store.New[string, any](nil),
|
||||||
cron: cron.New(),
|
cron: cron.New(),
|
||||||
subscriptionsBroker: subscriptions.NewBroker(),
|
subscriptionsBroker: subscriptions.NewBroker(),
|
||||||
config: &config,
|
config: &config,
|
||||||
@@ -532,7 +532,7 @@ func (app *BaseApp) Settings() *Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store returns the app runtime store.
|
// Store returns the app runtime store.
|
||||||
func (app *BaseApp) Store() *store.Store[any] {
|
func (app *BaseApp) Store() *store.Store[string, any] {
|
||||||
return app.store
|
return app.store
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,6 +601,14 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
|||||||
fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
|
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 = " "
|
const indentation = " "
|
||||||
if ae.Message.Text != "" {
|
if ae.Message.Text != "" {
|
||||||
textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
|
textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
|
||||||
|
|||||||
+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 a failure occure during the restore process the dir changes are reverted.
|
||||||
// If for whatever reason the revert is not possible, it panics.
|
// 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 {
|
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||||
if app.Store().Has(StoreKeyActiveBackup) {
|
if app.Store().Has(StoreKeyActiveBackup) {
|
||||||
return errors.New("try again later - another backup/restore operation has already been started")
|
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")
|
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()
|
fsys, err := e.App.NewBackupsFilesystem()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -170,57 +181,71 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
|||||||
|
|
||||||
fsys.SetContext(e.Context)
|
fsys.SetContext(e.Context)
|
||||||
|
|
||||||
// fetch the backup file in a temp location
|
if ok, _ := fsys.Exists(name); !ok {
|
||||||
br, err := fsys.GetFile(name)
|
return fmt.Errorf("missing or invalid backup file %q to restore", 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// create a temp zip file from the blob.Reader and try to extract it
|
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(8))
|
||||||
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))
|
|
||||||
defer os.RemoveAll(extractedDataDir)
|
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.GetFile(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")
|
extractedDB := filepath.Join(extractedDataDir, "data.db")
|
||||||
if _, err := os.Stat(extractedDB); err != nil {
|
if _, err := os.Stat(extractedDB); err != nil {
|
||||||
return fmt.Errorf("data.db file is missing or invalid: %w", err)
|
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
|
// move the current pb_data content to a special temp location
|
||||||
// that will hold the old data between dirs replace
|
// that will hold the old data between dirs replace
|
||||||
// (the temp dir will be automatically removed on the next app start)
|
// (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 {
|
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)
|
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.
|
// registerAutobackupHooks registers the autobackup app serve hooks.
|
||||||
func (app *BaseApp) registerAutobackupHooks() {
|
func (app *BaseApp) registerAutobackupHooks() {
|
||||||
const jobId = "__auto_pb_backup__"
|
const jobId = "__pbAutoBackup__"
|
||||||
|
|
||||||
loadJob := func() {
|
loadJob := func() {
|
||||||
rawSchedule := app.Settings().Backups.Cron
|
rawSchedule := app.Settings().Backups.Cron
|
||||||
|
|||||||
+56
-17
@@ -35,10 +35,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -50,10 +53,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -65,10 +71,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -80,10 +89,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -95,10 +107,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
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)
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -110,10 +125,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -125,10 +143,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -140,10 +161,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -155,10 +179,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
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)
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -170,10 +197,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -185,10 +215,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -200,10 +233,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -215,10 +251,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
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)
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -723,9 +762,9 @@ func (c *Collection) updateGeneratedIdIfExists(app App) {
|
|||||||
|
|
||||||
// add a number to the current id (if already exists)
|
// add a number to the current id (if already exists)
|
||||||
for i := 2; i < 1000; i++ {
|
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)
|
_ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
|
||||||
if !exists {
|
if exists == 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
newId = c.idChecksum() + strconv.Itoa(i)
|
newId = c.idChecksum() + strconv.Itoa(i)
|
||||||
@@ -950,7 +989,7 @@ func (c *Collection) initTokenKeyField() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensure that there is a unique index for the field
|
// 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(
|
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||||
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
|
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
|
||||||
c.fieldIndexName(FieldNameTokenKey),
|
c.fieldIndexName(FieldNameTokenKey),
|
||||||
@@ -976,7 +1015,7 @@ func (c *Collection) initEmailField() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensure that there is a unique index for the email field
|
// 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(
|
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||||
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
|
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
|
||||||
c.fieldIndexName(FieldNameEmail),
|
c.fieldIndexName(FieldNameEmail),
|
||||||
|
|||||||
+71
-28
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -75,16 +76,16 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
|||||||
return m, nil
|
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.
|
// but retrieves the Collection from the app cache instead of making a db call.
|
||||||
//
|
//
|
||||||
// NB! This method is suitable for read-only Collection operations.
|
// NB! This method is suitable for read-only Collection operations.
|
||||||
//
|
//
|
||||||
// Returns [sql.ErrNoRows] if no Collection is found for consistency
|
// 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,
|
// If you plan making changes to the returned Collection model,
|
||||||
// use [App.FindCollectionByNameOrId] instead.
|
// use [BaseApp.FindCollectionByNameOrId] instead.
|
||||||
//
|
//
|
||||||
// Caveats:
|
// 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,
|
// - 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.
|
// 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).
|
// - 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) {
|
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
|
||||||
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
||||||
if collections == nil {
|
if collections == nil {
|
||||||
@@ -111,30 +112,6 @@ func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection
|
|||||||
return nil, sql.ErrNoRows
|
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
|
// FindCollectionReferences returns information for all relation fields
|
||||||
// referencing the provided collection.
|
// referencing the provided collection.
|
||||||
//
|
//
|
||||||
@@ -168,6 +145,72 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
|
|||||||
return result, nil
|
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.
|
// TruncateCollection deletes all records associated with the provided collection.
|
||||||
//
|
//
|
||||||
// The truncate operation is executed in a single transaction,
|
// The truncate operation is executed in a single transaction,
|
||||||
|
|||||||
+109
-28
@@ -208,34 +208,6 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
|
|||||||
run(false)
|
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) {
|
func TestFindCollectionReferences(t *testing.T) {
|
||||||
t.Parallel()
|
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.DB().(*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) {
|
func TestFindCollectionTruncate(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
|
|||||||
SetParams(map[string]any{"fieldName": name})
|
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.").
|
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
|
||||||
SetParams(map[string]any{"fieldName": name})
|
SetParams(map[string]any{"fieldName": name})
|
||||||
}
|
}
|
||||||
@@ -666,7 +666,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
|||||||
if cv.new.IsAuth() {
|
if cv.new.IsAuth() {
|
||||||
requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
|
requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
|
||||||
for _, name := range requiredNames {
|
for _, name := range requiredNames {
|
||||||
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
|
if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
|
||||||
return validation.NewError(
|
return validation.NewError(
|
||||||
"validation_missing_required_unique_index",
|
"validation_missing_required_unique_index",
|
||||||
`Missing required unique index for field "{{.fieldName}}".`,
|
`Missing required unique index for field "{{.fieldName}}".`,
|
||||||
|
|||||||
+4
-5
@@ -59,7 +59,7 @@ func GenerateDefaultRandomId() string {
|
|||||||
|
|
||||||
// crc32Checksum generates a stringified crc32 checksum from the provided plain string.
|
// crc32Checksum generates a stringified crc32 checksum from the provided plain string.
|
||||||
func crc32Checksum(str string) 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 app.DB() query with preset
|
||||||
@@ -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 {
|
deleteErr := app.OnModelDelete().Trigger(event, func(e *ModelEvent) error {
|
||||||
pk := cast.ToString(e.Model.LastSavedPK())
|
pk := cast.ToString(e.Model.LastSavedPK())
|
||||||
|
if pk == "" {
|
||||||
if cast.ToString(pk) == "" {
|
|
||||||
return errors.New("the model can be deleted only if it is existing and has a non-empty primary key")
|
return errors.New("the model can be deleted only if it is existing and has a non-empty primary key")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +482,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
|
|||||||
return validation.NewError("validation_invalid_collection", "Missing or invalid collection.")
|
return validation.NewError("validation_invalid_collection", "Missing or invalid collection.")
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists bool
|
var exists int
|
||||||
|
|
||||||
rowErr := app.DB().Select("(1)").
|
rowErr := app.DB().Select("(1)").
|
||||||
From(collection.Name).
|
From(collection.Name).
|
||||||
@@ -491,7 +490,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
|
|||||||
Limit(1).
|
Limit(1).
|
||||||
Row(&exists)
|
Row(&exists)
|
||||||
|
|
||||||
if rowErr != nil || !exists {
|
if rowErr != nil || exists == 0 {
|
||||||
return validation.NewError("validation_invalid_record", "Missing or invalid record.")
|
return validation.NewError("validation_invalid_record", "Missing or invalid record.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -108,7 +108,7 @@ func (app *BaseApp) AuxHasTable(tableName string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
||||||
var exists bool
|
var exists int
|
||||||
|
|
||||||
err := db.Select("(1)").
|
err := db.Select("(1)").
|
||||||
From("sqlite_schema").
|
From("sqlite_schema").
|
||||||
@@ -117,7 +117,7 @@ func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
|||||||
Limit(1).
|
Limit(1).
|
||||||
Row(&exists)
|
Row(&exists)
|
||||||
|
|
||||||
return err == nil && exists
|
return err == nil && exists > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vacuum executes VACUUM on the current app.DB() instance
|
// Vacuum executes VACUUM on the current app.DB() instance
|
||||||
|
|||||||
@@ -168,16 +168,28 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
afterCreateHookCalls++
|
afterCreateHookCalls++
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
afterUpdateHookCalls++
|
afterUpdateHookCalls++
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
|
app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
afterDeleteHookCalls++
|
afterDeleteHookCalls++
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
@@ -233,3 +245,169 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
|
|||||||
t.Errorf("Expected afterDeleteHookCalls to be called 1 time, got %d", afterDeleteHookCalls)
|
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.
|
// RequestInfo parses the current request into RequestInfo instance.
|
||||||
//
|
//
|
||||||
// Note that the returned result is cached to avoid copying the request data multiple times
|
// 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) {
|
func (e *RequestEvent) RequestInfo() (*RequestInfo, error) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|||||||
+44
-3
@@ -103,6 +103,22 @@ type ServeEvent struct {
|
|||||||
Router *router.Router[*RequestEvent]
|
Router *router.Router[*RequestEvent]
|
||||||
Server *http.Server
|
Server *http.Server
|
||||||
CertManager *autocert.Manager
|
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 {
|
type ModelErrorEvent struct {
|
||||||
ModelEvent
|
|
||||||
Error error
|
Error error
|
||||||
|
ModelEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -196,8 +212,8 @@ type RecordEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecordErrorEvent struct {
|
type RecordErrorEvent struct {
|
||||||
RecordEvent
|
|
||||||
Error error
|
Error error
|
||||||
|
RecordEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncModelEventWithRecordEvent(me *ModelEvent, re *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) {
|
func newRecordEventFromModelEvent(me *ModelEvent) (*RecordEvent, bool) {
|
||||||
record, ok := me.Model.(*Record)
|
record, ok := me.Model.(*Record)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -253,6 +275,11 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
|
|||||||
me.Error = re.Error
|
me.Error = re.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
|
||||||
|
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
|
||||||
|
me.Error = re.Error
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Collection events data
|
// Collection events data
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -272,8 +299,8 @@ type CollectionEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CollectionErrorEvent struct {
|
type CollectionErrorEvent struct {
|
||||||
CollectionEvent
|
|
||||||
Error error
|
Error error
|
||||||
|
CollectionEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
||||||
@@ -283,6 +310,15 @@ func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
|||||||
me.Model = ce.Collection
|
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) {
|
func newCollectionEventFromModelEvent(me *ModelEvent) (*CollectionEvent, bool) {
|
||||||
record, ok := me.Model.(*Collection)
|
record, ok := me.Model.(*Collection)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -316,6 +352,11 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
|
|||||||
me.Error = ce.Error
|
me.Error = ce.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
|
||||||
|
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
|
||||||
|
me.Error = ce.Error
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// File API events data
|
// File API events data
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
var fieldNameRegex = regexp.MustCompile(`^\w+$`)
|
var fieldNameRegex = regexp.MustCompile(`^\w+$`)
|
||||||
|
|
||||||
|
const maxSafeJSONInt int64 = 1<<53 - 1
|
||||||
|
|
||||||
// Commonly used field names.
|
// Commonly used field names.
|
||||||
const (
|
const (
|
||||||
FieldNameId = "id"
|
FieldNameId = "id"
|
||||||
|
|||||||
@@ -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.
|
// If zero, a default limit of ~5MB is applied.
|
||||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||||
@@ -148,7 +148,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
|
|||||||
return validation.ValidateStruct(f,
|
return validation.ValidateStruct(f,
|
||||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
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{},
|
[]string{},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"MaxSize > safe json int",
|
||||||
|
func() *core.EditorField {
|
||||||
|
return &core.EditorField{
|
||||||
|
Id: "test",
|
||||||
|
Name: "test",
|
||||||
|
MaxSize: 1 << 53,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]string{"maxSize"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
|
|||||||
+10
-3
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -94,7 +95,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.
|
// If zero, a default limit of 5MB is applied.
|
||||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||||
@@ -223,8 +224,8 @@ func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *C
|
|||||||
return validation.ValidateStruct(f,
|
return validation.ValidateStruct(f,
|
||||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||||
validation.Field(&f.MaxSelect, validation.Min(0)),
|
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||||
validation.Field(&f.MaxSize, validation.Min(0)),
|
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||||
validation.Field(&f.Thumbs, validation.Each(
|
validation.Field(&f.Thumbs, validation.Each(
|
||||||
validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
|
validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
|
||||||
validation.Match(filesystem.ThumbSizeRegex),
|
validation.Match(filesystem.ThumbSizeRegex),
|
||||||
@@ -637,7 +638,13 @@ func (f *FileField) FindGetter(key string) GetterFunc {
|
|||||||
return func(record *Record) any {
|
return func(record *Record) any {
|
||||||
return record.GetRaw(f.Name)
|
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":
|
case f.Name + ":uploaded":
|
||||||
|
// deprecated
|
||||||
|
log.Println("[file field getter] please replace :uploaded with :unsaved")
|
||||||
return func(record *Record) any {
|
return func(record *Record) any {
|
||||||
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
|
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-2
@@ -539,6 +539,50 @@ func TestFileFieldValidateSettings(t *testing.T) {
|
|||||||
},
|
},
|
||||||
[]string{},
|
[]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 {
|
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}]`,
|
`["300_UhLKX91HVb.png",{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uploaded",
|
"unsaved",
|
||||||
field.GetName() + ":uploaded",
|
field.GetName() + ":unsaved",
|
||||||
true,
|
true,
|
||||||
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -51,7 +51,7 @@ 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 5MB is applied.
|
||||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||||
@@ -181,7 +181,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
|
|||||||
return validation.ValidateStruct(f,
|
return validation.ValidateStruct(f,
|
||||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||||
validation.Field(&f.MaxSize, validation.Min(0)),
|
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -200,7 +200,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
|||||||
expectErrors []string
|
expectErrors []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"< 0 MaxSize",
|
"MaxSize < 0",
|
||||||
func() *core.JSONField {
|
func() *core.JSONField {
|
||||||
return &core.JSONField{
|
return &core.JSONField{
|
||||||
Id: "test",
|
Id: "test",
|
||||||
@@ -211,7 +211,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
|||||||
[]string{"maxSize"},
|
[]string{"maxSize"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"= 0 MaxSize",
|
"MaxSize = 0",
|
||||||
func() *core.JSONField {
|
func() *core.JSONField {
|
||||||
return &core.JSONField{
|
return &core.JSONField{
|
||||||
Id: "test",
|
Id: "test",
|
||||||
@@ -221,7 +221,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
|||||||
[]string{},
|
[]string{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"> 0 MaxSize",
|
"MaxSize > 0",
|
||||||
func() *core.JSONField {
|
func() *core.JSONField {
|
||||||
return &core.JSONField{
|
return &core.JSONField{
|
||||||
Id: "test",
|
Id: "test",
|
||||||
@@ -231,6 +231,17 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
|||||||
},
|
},
|
||||||
[]string{},
|
[]string{},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"MaxSize > safe json int",
|
||||||
|
func() *core.JSONField {
|
||||||
|
return &core.JSONField{
|
||||||
|
Id: "test",
|
||||||
|
Name: "test",
|
||||||
|
MaxSize: 1 << 53,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]string{"maxSize"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
|
|||||||
+5
-5
@@ -193,14 +193,14 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
|
|||||||
//
|
//
|
||||||
// (@todo eventually may get replaced in the future with a system unique constraint to avoid races or wrapping the request in a transaction)
|
// (@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 {
|
if f.Pattern != defaultLowercaseRecordIdPattern {
|
||||||
var exists bool
|
var exists int
|
||||||
err := app.DB().
|
err := app.DB().
|
||||||
Select("(1)").
|
Select("(1)").
|
||||||
From(record.TableName()).
|
From(record.TableName()).
|
||||||
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": strings.ToLower(newVal)})).
|
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": newVal})).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Row(&exists)
|
Row(&exists)
|
||||||
if exists || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
|
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.")
|
return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,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.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.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
|
||||||
validation.Field(&f.Min, validation.Min(0)),
|
validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||||
validation.Field(&f.Max, validation.Min(f.Min)),
|
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.Pattern, validation.When(f.PrimaryKey, validation.Required), validation.By(validators.IsRegex)),
|
||||||
validation.Field(&f.Hidden, validation.When(f.PrimaryKey, validation.Empty)),
|
validation.Field(&f.Hidden, validation.When(f.PrimaryKey, validation.Empty)),
|
||||||
validation.Field(&f.Required, validation.When(f.PrimaryKey, validation.Required)),
|
validation.Field(&f.Required, validation.When(f.PrimaryKey, validation.Required)),
|
||||||
|
|||||||
@@ -430,6 +430,50 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
|||||||
},
|
},
|
||||||
[]string{"autogeneratePattern"},
|
[]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 {
|
for _, s := range scenarios {
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/spf13/cast"
|
"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).
|
// getColor returns [color.Color] object and cache it (if not already).
|
||||||
func getColor(attrs ...color.Attribute) (c *color.Color) {
|
func getColor(attrs ...color.Attribute) (c *color.Color) {
|
||||||
|
|||||||
+1
-1
@@ -122,7 +122,7 @@ func (app *BaseApp) registerMFAHooks() {
|
|||||||
recordRefHooks[*MFA](app, CollectionNameMFAs, CollectionTypeAuth)
|
recordRefHooks[*MFA](app, CollectionNameMFAs, CollectionTypeAuth)
|
||||||
|
|
||||||
// run on every hour to cleanup expired mfa sessions
|
// 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 {
|
if err := app.DeleteExpiredMFAs(); err != nil {
|
||||||
app.Logger().Warn("Failed to delete expired MFA sessions", "error", err)
|
app.Logger().Warn("Failed to delete expired MFA sessions", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,15 +80,11 @@ func (r *MigrationsRunner) Run(args ...string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
confirm := false
|
confirm := osutils.YesNoPrompt(fmt.Sprintf(
|
||||||
prompt := &survey.Confirm{
|
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
|
||||||
Message: fmt.Sprintf(
|
strings.Join(names, "\n"),
|
||||||
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
|
toRevertCount,
|
||||||
strings.Join(names, "\n"),
|
), false)
|
||||||
toRevertCount,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
survey.AskOne(prompt, &confirm)
|
|
||||||
if !confirm {
|
if !confirm {
|
||||||
fmt.Println("The command has been cancelled")
|
fmt.Println("The command has been cancelled")
|
||||||
return nil
|
return nil
|
||||||
@@ -267,15 +263,15 @@ func (r *MigrationsRunner) initMigrationsTable() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *MigrationsRunner) isMigrationApplied(txApp App, file string) bool {
|
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).
|
From(r.tableName).
|
||||||
Where(dbx.HashExp{"file": file}).
|
Where(dbx.HashExp{"file": file}).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Row(&exists)
|
Row(&exists)
|
||||||
|
|
||||||
return err == nil && exists
|
return err == nil && exists > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MigrationsRunner) saveAppliedMigration(txApp App, file string) error {
|
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 {
|
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).
|
From(core.DefaultMigrationsTable).
|
||||||
Where(dbx.HashExp{"file": file}).
|
Where(dbx.HashExp{"file": file}).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Row(&exists)
|
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)
|
recordRefHooks[*OTP](app, CollectionNameOTPs, CollectionTypeAuth)
|
||||||
|
|
||||||
// run on every hour to cleanup expired otp sessions
|
// 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 {
|
if err := app.DeleteExpiredOTPs(); err != nil {
|
||||||
app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
|
app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/tools/search"
|
"github.com/pocketbase/pocketbase/tools/search"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ const (
|
|||||||
eachModifier string = "each"
|
eachModifier string = "each"
|
||||||
issetModifier string = "isset"
|
issetModifier string = "isset"
|
||||||
lengthModifier string = "length"
|
lengthModifier string = "length"
|
||||||
|
lowerModifier string = "lower"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ensure that `search.FieldResolver` interface is implemented
|
// ensure that `search.FieldResolver` interface is implemented
|
||||||
@@ -47,6 +50,26 @@ type RecordFieldResolver struct {
|
|||||||
allowHiddenFields bool
|
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`.
|
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
|
||||||
func NewRecordFieldResolver(
|
func NewRecordFieldResolver(
|
||||||
app App,
|
app App,
|
||||||
@@ -193,7 +216,14 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
|||||||
resultVal = val
|
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{
|
return &search.ResolverResult{
|
||||||
Identifier: "{:" + placeholder + "}",
|
Identifier: "{:" + placeholder + "}",
|
||||||
@@ -271,6 +301,13 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
|
|||||||
return mapVal(m, keys...)
|
return mapVal(m, keys...)
|
||||||
case mapExtractor:
|
case mapExtractor:
|
||||||
return mapVal(m.AsMap(), keys...)
|
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
|
// slices
|
||||||
case []string:
|
case []string:
|
||||||
@@ -309,6 +346,8 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
|
|||||||
return arrVal(extracted, keys...)
|
return arrVal(extracted, keys...)
|
||||||
case []any:
|
case []any:
|
||||||
return arrVal(m, keys...)
|
return arrVal(m, keys...)
|
||||||
|
case []types.JSONRaw:
|
||||||
|
return arrVal(m, keys...)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("expected map or array, got %#v", rawData)
|
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] {
|
switch parts[1] {
|
||||||
case issetModifier,
|
case issetModifier,
|
||||||
eachModifier,
|
eachModifier,
|
||||||
lengthModifier:
|
lengthModifier,
|
||||||
|
lowerModifier:
|
||||||
return parts[0], parts[1], nil
|
return parts[0], parts[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
"github.com/pocketbase/pocketbase/tools/search"
|
"github.com/pocketbase/pocketbase/tools/search"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxNestedRels defines the max allowed nested relations depth.
|
// 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 {
|
if modifier == lengthModifier && len(r.activeProps) == 3 {
|
||||||
return r.processRequestInfoLengthModifier(bodyField)
|
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
|
// some other @request.* static field
|
||||||
@@ -191,6 +197,10 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *runner) processRequestAuthField() (*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
|
// plain auth field
|
||||||
// ---
|
// ---
|
||||||
if _, ok := plainRequestAuthFields[r.fieldName]; ok {
|
if _, ok := plainRequestAuthFields[r.fieldName]; ok {
|
||||||
@@ -199,10 +209,6 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
|||||||
|
|
||||||
// resolve the auth collection field
|
// 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()
|
collection := r.resolver.requestInfo.Auth.Collection()
|
||||||
|
|
||||||
r.activeCollectionName = collection.Name
|
r.activeCollectionName = collection.Name
|
||||||
@@ -262,6 +268,19 @@ func toSlice(value any) []any {
|
|||||||
return result
|
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) {
|
func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||||
if _, ok := bodyField.(MultiValuer); !ok {
|
if _, ok := bodyField.(MultiValuer); !ok {
|
||||||
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
|
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
|
||||||
@@ -396,6 +415,10 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
|||||||
|
|
||||||
field := collection.Fields.GetByName(prop)
|
field := collection.Fields.GetByName(prop)
|
||||||
|
|
||||||
|
if field != nil && field.GetHidden() && !r.allowHiddenFields {
|
||||||
|
return nil, fmt.Errorf("non-filterable field %q", prop)
|
||||||
|
}
|
||||||
|
|
||||||
// json field -> treat the rest of the props as json path
|
// json field -> treat the rest of the props as json path
|
||||||
if field != nil && field.Type() == FieldTypeJSON {
|
if field != nil && field.Type() == FieldTypeJSON {
|
||||||
var jsonPath strings.Builder
|
var jsonPath strings.Builder
|
||||||
@@ -460,6 +483,10 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
|||||||
return nil, fmt.Errorf("invalid back relation field %q", parts[2])
|
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)
|
backRelField, ok := backField.(*RelationField)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName())
|
return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName())
|
||||||
@@ -478,7 +505,8 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
|||||||
isBackRelMultiple := backRelField.IsMultiple()
|
isBackRelMultiple := backRelField.IsMultiple()
|
||||||
if !isBackRelMultiple {
|
if !isBackRelMultiple {
|
||||||
// additionally check if the rel field has a single column unique index
|
// 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 {
|
if !isBackRelMultiple {
|
||||||
@@ -713,11 +741,11 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
|||||||
// default
|
// default
|
||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
result := &search.ResolverResult{
|
result := &search.ResolverResult{
|
||||||
Identifier: fmt.Sprintf("[[%s.%s]]", r.activeTableAlias, cleanFieldName),
|
Identifier: "[[" + r.activeTableAlias + "." + cleanFieldName + "]]",
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.withMultiMatch {
|
if r.withMultiMatch {
|
||||||
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.%s]]", r.multiMatchActiveTableAlias, cleanFieldName)
|
r.multiMatch.valueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
|
||||||
result.MultiMatchSubQuery = r.multiMatch
|
result.MultiMatchSubQuery = r.multiMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,5 +771,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
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package core_test
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -10,9 +11,78 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
"github.com/pocketbase/pocketbase/tools/search"
|
"github.com/pocketbase/pocketbase/tools/search"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestRecordFieldResolverAllowedFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
collection, err := app.FindCollectionByNameOrId("demo1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := core.NewRecordFieldResolver(app, collection, nil, false)
|
||||||
|
|
||||||
|
fields := r.AllowedFields()
|
||||||
|
if len(fields) != 8 {
|
||||||
|
t.Fatalf("Expected %d original allowed fields, got %d", 8, len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// change the allowed fields
|
||||||
|
newFields := []string{"a", "b", "c"}
|
||||||
|
expected := slices.Clone(newFields)
|
||||||
|
r.SetAllowedFields(newFields)
|
||||||
|
|
||||||
|
// change the new fields to ensure that the slice was cloned
|
||||||
|
newFields[2] = "d"
|
||||||
|
|
||||||
|
fields = r.AllowedFields()
|
||||||
|
if len(fields) != len(expected) {
|
||||||
|
t.Fatalf("Expected %d changed allowed fields, got %d", len(expected), len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range expected {
|
||||||
|
if fields[i] != v {
|
||||||
|
t.Errorf("[%d] Expected field %q", i, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordFieldResolverAllowHiddenFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
collection, err := app.FindCollectionByNameOrId("demo1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := core.NewRecordFieldResolver(app, collection, nil, false)
|
||||||
|
|
||||||
|
allowHiddenFields := r.AllowHiddenFields()
|
||||||
|
if allowHiddenFields {
|
||||||
|
t.Fatalf("Expected original allowHiddenFields %v, got %v", allowHiddenFields, !allowHiddenFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// change the flag
|
||||||
|
expected := !allowHiddenFields
|
||||||
|
r.SetAllowHiddenFields(expected)
|
||||||
|
|
||||||
|
allowHiddenFields = r.AllowHiddenFields()
|
||||||
|
if allowHiddenFields != expected {
|
||||||
|
t.Fatalf("Expected changed allowHiddenFields %v, got %v", expected, allowHiddenFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
defer app.Cleanup()
|
||||||
|
|
||||||
@@ -138,7 +208,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"demo4",
|
"demo4",
|
||||||
"self_rel_many.self_rel_one > true",
|
"self_rel_many.self_rel_one > true",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] WHERE ((([[demo4_self_rel_many.self_rel_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many.self_rel_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))))",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] WHERE ((([[demo4_self_rel_many.self_rel_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many.self_rel_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nested complete relations (opt/any operator)",
|
"nested complete relations (opt/any operator)",
|
||||||
@@ -152,7 +222,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"demo4",
|
"demo4",
|
||||||
"self_rel_many.self_rel_one.title > true",
|
"self_rel_many.self_rel_one.title > true",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))))",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"repeated nested relations (opt/any operator)",
|
"repeated nested relations (opt/any operator)",
|
||||||
@@ -166,7 +236,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"demo4",
|
"demo4",
|
||||||
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.title > true",
|
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.title > true",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many` ON [[demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))))",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN json_each(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) `demo4_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many` ON [[demo4_self_rel_many.id]] = [[demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many` ON [[demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE ((([[demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.title]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4.self_rel_many]]) THEN [[__mm_demo4.self_rel_many]] ELSE json_array([[__mm_demo4.self_rel_many]]) END) `__mm_demo4_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many` ON [[__mm_demo4_self_rel_many.id]] = [[__mm_demo4_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) THEN [[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]] ELSE json_array([[__mm_demo4_self_rel_many_self_rel_one.self_rel_many]]) END) `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je` LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_je.value]] LEFT JOIN `demo4` `__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one` ON [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many_self_rel_one.id]] = [[__mm_demo4_self_rel_many_self_rel_one_self_rel_many.self_rel_one]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"multiple relations (opt/any operators)",
|
"multiple relations (opt/any operators)",
|
||||||
@@ -236,7 +306,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"demo4",
|
"demo4",
|
||||||
"@collection.demo1.text > true || @collection.demo2.active > true || @collection.demo1.file_one > true",
|
"@collection.demo1.text > true || @collection.demo2.active > true || @collection.demo1.file_one > true",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo1` `__collection_demo1` LEFT JOIN `demo2` `__collection_demo2` WHERE ((([[__collection_demo1.text]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo1.text]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm__collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))) OR (([[__collection_demo2.active]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo2.active]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo2` `__mm__collection_demo2` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))) OR (([[__collection_demo1.file_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo1.file_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm__collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{TEST}} WHERE ((NOT ([[TEST.multiMatchValue]] > 1)) OR ([[TEST.multiMatchValue]] IS NULL))))))",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo1` `__collection_demo1` LEFT JOIN `demo2` `__collection_demo2` WHERE ((([[__collection_demo1.text]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo1.text]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm__collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR (([[__collection_demo2.active]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo2.active]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo2` `__mm__collection_demo2` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR (([[__collection_demo1.file_one]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo1.file_one]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo1` `__mm__collection_demo1` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))))",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@request.auth fields",
|
"@request.auth fields",
|
||||||
@@ -257,14 +327,14 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"demo4",
|
"demo4",
|
||||||
"@collection.users.email > true || @request.auth.email > true",
|
"@collection.users.email > true || @request.auth.email > true",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__collection_users` WHERE ((([[__collection_users.email]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_users.email]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `users` `__mm__collection_users` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) OR {:TEST} > 1)",
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `users` `__collection_users` WHERE ((([[__collection_users.email]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_users.email]] as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `users` `__mm__collection_users` WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR {:TEST} > 1)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"hidden field (add emailVisibility)",
|
"hidden field (add emailVisibility)",
|
||||||
"users",
|
"users",
|
||||||
"id > true || email > true",
|
"id > true || email > true || email:lower > false",
|
||||||
false,
|
false,
|
||||||
"SELECT `users`.* FROM `users` WHERE ([[users.id]] > 1 OR (([[users.email]] > 1) AND ([[users.emailVisibility]] = TRUE)))",
|
"SELECT `users`.* FROM `users` WHERE ([[users.id]] > 1 OR (([[users.email]] > 1) AND ([[users.emailVisibility]] = TRUE)) OR ((LOWER([[users.email]]) > 0) AND ([[users.emailVisibility]] = TRUE)))",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"hidden field (force ignore emailVisibility)",
|
"hidden field (force ignore emailVisibility)",
|
||||||
@@ -274,7 +344,45 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"SELECT `users`.* FROM `users` WHERE [[users.email]] > 1",
|
"SELECT `users`.* FROM `users` WHERE [[users.email]] > 1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"isset key",
|
"static @request fields with :lower modifier",
|
||||||
|
"demo1",
|
||||||
|
"@request.body.a:lower > true ||" +
|
||||||
|
"@request.body.b:lower > true ||" +
|
||||||
|
"@request.body.c:lower > true ||" +
|
||||||
|
"@request.query.a:lower > true ||" +
|
||||||
|
"@request.query.b:lower > true ||" +
|
||||||
|
"@request.query.c:lower > true ||" +
|
||||||
|
"@request.headers.a:lower > true ||" +
|
||||||
|
"@request.headers.c:lower > true",
|
||||||
|
false,
|
||||||
|
"SELECT `demo1`.* FROM `demo1` WHERE (NULL > 1 OR LOWER({:TEST}) > 1 OR NULL > 1 OR LOWER({:TEST}) > 1 OR LOWER({:TEST}) > 1 OR NULL > 1 OR LOWER({:TEST}) > 1 OR NULL > 1)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection fields with :lower modifier",
|
||||||
|
"demo1",
|
||||||
|
"@request.body.rel_one:lower > true ||" +
|
||||||
|
"@request.body.rel_many:lower > true ||" +
|
||||||
|
"@request.body.rel_many.email:lower > true ||" +
|
||||||
|
"text:lower > true ||" +
|
||||||
|
"bool:lower > true ||" +
|
||||||
|
"url:lower > true ||" +
|
||||||
|
"select_one:lower > true ||" +
|
||||||
|
"select_many:lower > true ||" +
|
||||||
|
"file_one:lower > true ||" +
|
||||||
|
"file_many:lower > true ||" +
|
||||||
|
"number:lower > true ||" +
|
||||||
|
"email:lower > true ||" +
|
||||||
|
"datetime:lower > true ||" +
|
||||||
|
"json:lower > true ||" +
|
||||||
|
"rel_one:lower > true ||" +
|
||||||
|
"rel_many:lower > true ||" +
|
||||||
|
"rel_many.name:lower > true ||" +
|
||||||
|
"created:lower > true",
|
||||||
|
false,
|
||||||
|
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN `users` `__data_users_rel_many` ON [[__data_users_rel_many.id]] IN ({:p0}, {:p1}) LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[demo1_rel_many_je.value]] WHERE (LOWER({:infoLowerrel_oneTEST}) > 1 OR LOWER({:infoLowerrel_manyTEST}) > 1 OR ((LOWER([[__data_users_rel_many.email]]) > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT LOWER([[__data_mm_users_rel_many.email]]) as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users_rel_many` ON [[__data_mm_users_rel_many.id]] IN ({:p4}, {:p5}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR LOWER([[demo1.text]]) > 1 OR LOWER([[demo1.bool]]) > 1 OR LOWER([[demo1.url]]) > 1 OR LOWER([[demo1.select_one]]) > 1 OR LOWER([[demo1.select_many]]) > 1 OR LOWER([[demo1.file_one]]) > 1 OR LOWER([[demo1.file_many]]) > 1 OR LOWER([[demo1.number]]) > 1 OR LOWER([[demo1.email]]) > 1 OR LOWER([[demo1.datetime]]) > 1 OR LOWER((CASE WHEN json_valid([[demo1.json]]) THEN JSON_EXTRACT([[demo1.json]], '$') ELSE JSON_EXTRACT(json_object('pb', [[demo1.json]]), '$.pb') END)) > 1 OR LOWER([[demo1.rel_one]]) > 1 OR LOWER([[demo1.rel_many]]) > 1 OR ((LOWER([[demo1_rel_many.name]]) > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT LOWER([[__mm_demo1_rel_many.name]]) as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) OR LOWER([[demo1.created]]) > 1)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isset modifier",
|
||||||
"demo1",
|
"demo1",
|
||||||
"@request.body.a:isset > true ||" +
|
"@request.body.a:isset > true ||" +
|
||||||
"@request.body.b:isset > true ||" +
|
"@request.body.b:isset > true ||" +
|
||||||
@@ -314,7 +422,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"@request.body.rel_many:each > true &&" +
|
"@request.body.rel_many:each > true &&" +
|
||||||
"@request.body.rel_many:each ?< true",
|
"@request.body.rel_many:each ?< true",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_file_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_file_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_rel_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_rel_many_je` WHERE ([[__dataEach_select_one_je.value]] > 1 AND [[__dataEach_select_one_je.value]] < 1 AND (([[__dataEach_select_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[__dataEach_select_many_je.value]] < 1 AND [[__dataEach_file_one_je.value]] > 1 AND [[__dataEach_file_one_je.value]] < 1 AND (([[__dataEach_file_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_file_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_file_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[__dataEach_file_many_je.value]] < 1 AND [[__dataEach_rel_one_je.value]] > 1 AND [[__dataEach_rel_one_je.value]] < 1 AND (([[__dataEach_rel_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_rel_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_rel_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[__dataEach_rel_many_je.value]] < 1)",
|
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_file_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_file_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_rel_one_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_rel_many_je` WHERE ([[__dataEach_select_one_je.value]] > 1 AND [[__dataEach_select_one_je.value]] < 1 AND (([[__dataEach_select_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_select_many_je.value]] < 1 AND [[__dataEach_file_one_je.value]] > 1 AND [[__dataEach_file_one_je.value]] < 1 AND (([[__dataEach_file_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_file_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_file_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_file_many_je.value]] < 1 AND [[__dataEach_rel_one_je.value]] > 1 AND [[__dataEach_rel_one_je.value]] < 1 AND (([[__dataEach_rel_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__dataEach_rel_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_rel_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[__dataEach_rel_many_je.value]] < 1)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"regular arrayble:each fields",
|
"regular arrayble:each fields",
|
||||||
@@ -332,7 +440,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"rel_many:each > true &&" +
|
"rel_many:each > true &&" +
|
||||||
"rel_many:each ?< true",
|
"rel_many:each ?< true",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.file_one]]) THEN [[demo1.file_one]] ELSE json_array([[demo1.file_one]]) END) `demo1_file_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.file_many]]) THEN [[demo1.file_many]] ELSE json_array([[demo1.file_many]]) END) `demo1_file_many_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END) `demo1_rel_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` WHERE ([[demo1_select_one_je.value]] > 1 AND [[demo1_select_one_je.value]] < 1 AND (([[demo1_select_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_select_many_je.value]] < 1 AND [[demo1_file_one_je.value]] > 1 AND [[demo1_file_one_je.value]] < 1 AND (([[demo1_file_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_file_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.file_many]]) THEN [[__mm_demo1.file_many]] ELSE json_array([[__mm_demo1.file_many]]) END) `__mm_demo1_file_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_file_many_je.value]] < 1 AND [[demo1_rel_one_je.value]] > 1 AND [[demo1_rel_one_je.value]] < 1 AND (([[demo1_rel_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > 1)) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_rel_many_je.value]] < 1)",
|
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.file_one]]) THEN [[demo1.file_one]] ELSE json_array([[demo1.file_one]]) END) `demo1_file_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.file_many]]) THEN [[demo1.file_many]] ELSE json_array([[demo1.file_many]]) END) `demo1_file_many_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END) `demo1_rel_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` WHERE ([[demo1_select_one_je.value]] > 1 AND [[demo1_select_one_je.value]] < 1 AND (([[demo1_select_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[demo1_select_many_je.value]] < 1 AND [[demo1_file_one_je.value]] > 1 AND [[demo1_file_one_je.value]] < 1 AND (([[demo1_file_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_file_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.file_many]]) THEN [[__mm_demo1.file_many]] ELSE json_array([[__mm_demo1.file_many]]) END) `__mm_demo1_file_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[demo1_file_many_je.value]] < 1 AND [[demo1_rel_one_je.value]] > 1 AND [[demo1_rel_one_je.value]] < 1 AND (([[demo1_rel_many_je.value]] > 1) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > 1)))) AND [[demo1_rel_many_je.value]] < 1)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"arrayble:each vs arrayble:each",
|
"arrayble:each vs arrayble:each",
|
||||||
@@ -342,7 +450,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"select_many:each ?< select_one:each &&" +
|
"select_many:each ?< select_one:each &&" +
|
||||||
"select_many:each = @request.body.select_many:each",
|
"select_many:each = @request.body.select_many:each",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_many_je` WHERE (((COALESCE([[demo1_select_one_je.value]], '') IS NOT COALESCE([[demo1_select_many_je.value]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT (COALESCE([[demo1_select_one_je.value]], '') IS NOT COALESCE([[__smTEST.multiMatchValue]], ''))) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND (([[demo1_select_many_je.value]] > [[demo1_select_one_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] > [[demo1_select_one_je.value]])) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND [[demo1_select_many_je.value]] < [[demo1_select_one_je.value]] AND (([[demo1_select_many_je.value]] = [[__dataEach_select_many_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm__dataEach_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))))",
|
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_one]]) THEN [[demo1.select_one]] ELSE json_array([[demo1.select_one]]) END) `demo1_select_one_je` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.select_many]]) THEN [[demo1.select_many]] ELSE json_array([[demo1.select_many]]) END) `demo1_select_many_je` LEFT JOIN json_each({:dataEachTEST}) `__dataEach_select_many_je` WHERE (((COALESCE([[demo1_select_one_je.value]], '') IS NOT COALESCE([[demo1_select_many_je.value]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT (COALESCE([[demo1_select_one_je.value]], '') IS NOT COALESCE([[__smTEST.multiMatchValue]], ''))))) AND (([[demo1_select_many_je.value]] > [[demo1_select_one_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] > [[demo1_select_one_je.value]])))) AND [[demo1_select_many_je.value]] < [[demo1_select_one_je.value]] AND (([[demo1_select_many_je.value]] = [[__dataEach_select_many_je.value]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.select_many]]) THEN [[__mm_demo1.select_many]] ELSE json_array([[__mm_demo1.select_many]]) END) `__mm_demo1_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm__dataEach_select_many_je.value]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each({:mmdataEachTEST}) `__mm__dataEach_select_many_je` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))))",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"mixed multi-match vs multi-match",
|
"mixed multi-match vs multi-match",
|
||||||
@@ -354,7 +462,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"@collection.demo2.active ?= rel_many.rel.active &&" +
|
"@collection.demo2.active ?= rel_many.rel.active &&" +
|
||||||
"rel_many.email > @request.body.rel_many.email",
|
"rel_many.email > @request.body.rel_many.email",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[demo1_rel_many_je.value]] LEFT JOIN `demo2` `demo1_rel_many_rel` ON [[demo1_rel_many_rel.id]] = [[demo1_rel_many.rel]] LEFT JOIN `demo1` `demo1_rel_one` ON [[demo1_rel_one.id]] = [[demo1.rel_one]] LEFT JOIN `demo2` `__collection_demo2` LEFT JOIN `users` `__data_users_rel_many` ON [[__data_users_rel_many.id]] IN ({:p0}, {:p1}) WHERE (((COALESCE([[demo1_rel_many_rel.active]], '') IS NOT COALESCE([[demo1_rel_many.name]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many.name]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE ((NOT (COALESCE([[__mlTEST.multiMatchValue]], '') IS NOT COALESCE([[__mrTEST.multiMatchValue]], ''))) OR ([[__mlTEST.multiMatchValue]] IS NULL) OR ([[__mrTEST.multiMatchValue]] IS NULL))))) AND COALESCE([[demo1_rel_many_rel.active]], '') = COALESCE([[demo1_rel_many.name]], '') AND (([[demo1_rel_many_rel.title]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\') AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.title]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\')) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND ((COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo2.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `demo2` `__mm__collection_demo2` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))) AND COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '') AND (((([[demo1_rel_many.email]] > [[__data_users_rel_many.email]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__data_mm_users_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users_rel_many` ON [[__data_mm_users_rel_many.id]] IN ({:p2}, {:p3}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE ((NOT ([[__mlTEST.multiMatchValue]] > [[__mrTEST.multiMatchValue]])) OR ([[__mlTEST.multiMatchValue]] IS NULL) OR ([[__mrTEST.multiMatchValue]] IS NULL)))))) AND ([[demo1_rel_many.emailVisibility]] = TRUE)))",
|
"SELECT DISTINCT `demo1`.* FROM `demo1` LEFT JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) `demo1_rel_many_je` LEFT JOIN `users` `demo1_rel_many` ON [[demo1_rel_many.id]] = [[demo1_rel_many_je.value]] LEFT JOIN `demo2` `demo1_rel_many_rel` ON [[demo1_rel_many_rel.id]] = [[demo1_rel_many.rel]] LEFT JOIN `demo1` `demo1_rel_one` ON [[demo1_rel_one.id]] = [[demo1.rel_one]] LEFT JOIN `demo2` `__collection_demo2` LEFT JOIN `users` `__data_users_rel_many` ON [[__data_users_rel_many.id]] IN ({:p0}, {:p1}) WHERE (((COALESCE([[demo1_rel_many_rel.active]], '') IS NOT COALESCE([[demo1_rel_many.name]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many.name]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') IS NOT COALESCE([[__mrTEST.multiMatchValue]], ''))))) AND COALESCE([[demo1_rel_many_rel.active]], '') = COALESCE([[demo1_rel_many.name]], '') AND (([[demo1_rel_many_rel.title]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\') AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many_rel.title]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] LIKE ('%' || [[demo1_rel_one.email]] || '%') ESCAPE '\\')))) AND ((COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '')) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm__collection_demo2.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `demo2` `__mm__collection_demo2` WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__mm_demo1_rel_many_rel.active]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] LEFT JOIN `demo2` `__mm_demo1_rel_many_rel` ON [[__mm_demo1_rel_many_rel.id]] = [[__mm_demo1_rel_many.rel]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT (COALESCE([[__mlTEST.multiMatchValue]], '') = COALESCE([[__mrTEST.multiMatchValue]], ''))))) AND COALESCE([[__collection_demo2.active]], '') = COALESCE([[demo1_rel_many_rel.active]], '') AND (((([[demo1_rel_many.email]] > [[__data_users_rel_many.email]]) AND (NOT EXISTS (SELECT 1 FROM (SELECT [[__mm_demo1_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo1.rel_many]]) THEN [[__mm_demo1.rel_many]] ELSE json_array([[__mm_demo1.rel_many]]) END) `__mm_demo1_rel_many_je` LEFT JOIN `users` `__mm_demo1_rel_many` ON [[__mm_demo1_rel_many.id]] = [[__mm_demo1_rel_many_je.value]] WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mlTEST}} LEFT JOIN (SELECT [[__data_mm_users_rel_many.email]] as [[multiMatchValue]] FROM `demo1` `__mm_demo1` LEFT JOIN `users` `__data_mm_users_rel_many` ON [[__data_mm_users_rel_many.id]] IN ({:p2}, {:p3}) WHERE `__mm_demo1`.`id` = `demo1`.`id`) {{__mrTEST}} WHERE NOT ([[__mlTEST.multiMatchValue]] > [[__mrTEST.multiMatchValue]]))))) AND ([[demo1_rel_many.emailVisibility]] = TRUE)))",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@request.body.arrayable:length fields",
|
"@request.body.arrayable:length fields",
|
||||||
@@ -387,7 +495,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
|||||||
"self_rel_one.rel_many_cascade.files:length != 7 &&" +
|
"self_rel_one.rel_many_cascade.files:length != 7 &&" +
|
||||||
"self_rel_one.rel_many_cascade.files:length ?!= 8",
|
"self_rel_one.rel_many_cascade.files:length ?!= 8",
|
||||||
false,
|
false,
|
||||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `__data_demo4_self_rel_one` ON [[__data_demo4_self_rel_one.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_many_cascade` ON [[__data_demo3_rel_many_cascade.id]] IN ({:p1}, {:p2}) LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p3} LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_one.rel_many_cascade]]) THEN [[demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[demo4_self_rel_one.rel_many_cascade]]) END) `demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `demo4_self_rel_one_rel_many_cascade` ON [[demo4_self_rel_one_rel_many_cascade.id]] = [[demo4_self_rel_one_rel_many_cascade_je.value]] WHERE (json_array_length(CASE WHEN json_valid([[__data_demo4_self_rel_one.self_rel_many]]) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo4_self_rel_one.self_rel_many]]) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo3_rel_many_cascade.files]]) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST} AND ((json_array_length(CASE WHEN json_valid([[__data_demo3_rel_many_cascade.files]]) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__data_mm_demo3_rel_many_cascade.files]]) THEN [[__data_mm_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_mm_demo3_rel_many_cascade.files]] = '' OR [[__data_mm_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_mm_demo3_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__data_mm_demo3_rel_many_cascade` ON [[__data_mm_demo3_rel_many_cascade.id]] IN ({:p8}, {:p9}) WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] < {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND json_array_length(CASE WHEN json_valid([[__data_demo3_rel_one_cascade.files]]) THEN [[__data_demo3_rel_one_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_one_cascade.files]] = '' OR [[__data_demo3_rel_one_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_one_cascade.files]]) END) END) < {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND ((json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) END) IS NOT {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) THEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[__mm_demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm_demo4_self_rel_one` ON [[__mm_demo4_self_rel_one.id]] = [[__mm_demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_one.rel_many_cascade]]) THEN [[__mm_demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[__mm_demo4_self_rel_one.rel_many_Line truncated
|
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `__data_demo4_self_rel_one` ON [[__data_demo4_self_rel_one.id]]={:p0} LEFT JOIN `demo3` `__data_demo3_rel_many_cascade` ON [[__data_demo3_rel_many_cascade.id]] IN ({:p1}, {:p2}) LEFT JOIN `demo3` `__data_demo3_rel_one_cascade` ON [[__data_demo3_rel_one_cascade.id]]={:p3} LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_one.rel_many_cascade]]) THEN [[demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[demo4_self_rel_one.rel_many_cascade]]) END) `demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `demo4_self_rel_one_rel_many_cascade` ON [[demo4_self_rel_one_rel_many_cascade.id]] = [[demo4_self_rel_one_rel_many_cascade_je.value]] WHERE (json_array_length(CASE WHEN json_valid([[__data_demo4_self_rel_one.self_rel_many]]) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo4_self_rel_one.self_rel_many]]) THEN [[__data_demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[__data_demo4_self_rel_one.self_rel_many]] = '' OR [[__data_demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4_self_rel_one.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo3_rel_many_cascade.files]]) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST} AND ((json_array_length(CASE WHEN json_valid([[__data_demo3_rel_many_cascade.files]]) THEN [[__data_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_many_cascade.files]] = '' OR [[__data_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_many_cascade.files]]) END) END) < {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__data_mm_demo3_rel_many_cascade.files]]) THEN [[__data_mm_demo3_rel_many_cascade.files]] ELSE (CASE WHEN [[__data_mm_demo3_rel_many_cascade.files]] = '' OR [[__data_mm_demo3_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_mm_demo3_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__data_mm_demo3_rel_many_cascade` ON [[__data_mm_demo3_rel_many_cascade.id]] IN ({:p8}, {:p9}) WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE NOT ([[__smTEST.multiMatchValue]] < {:TEST})))) AND json_array_length(CASE WHEN json_valid([[__data_demo3_rel_one_cascade.files]]) THEN [[__data_demo3_rel_one_cascade.files]] ELSE (CASE WHEN [[__data_demo3_rel_one_cascade.files]] = '' OR [[__data_demo3_rel_one_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3_rel_one_cascade.files]]) END) END) < {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND ((json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) END) IS NOT {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) THEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[__mm_demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm_demo4_self_rel_one` ON [[__mm_demo4_self_rel_one.id]] = [[__mm_demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_one.rel_many_cascade]]) THEN [[__mm_demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[__mm_demo4_self_rel_one.rel_many_cascade]]) END) `__mm_demo4_self_rel_one_rel_mLine truncated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"json_extract and json_array_length COALESCE equal normalizations",
|
"json_extract and json_array_length COALESCE equal normalizations",
|
||||||
@@ -574,10 +682,14 @@ func TestRecordFieldResolverResolveStaticRequestInfoFields(t *testing.T) {
|
|||||||
"a": "123",
|
"a": "123",
|
||||||
},
|
},
|
||||||
Body: map[string]any{
|
Body: map[string]any{
|
||||||
"number": "10",
|
"number": "10",
|
||||||
"number_unknown": "20",
|
"number_unknown": "20",
|
||||||
"b": 456,
|
"raw_json_obj": types.JSONRaw(`{"a":123}`),
|
||||||
"c": map[string]int{"sub": 1},
|
"raw_json_arr1": types.JSONRaw(`[123, 456]`),
|
||||||
|
"raw_json_arr2": types.JSONRaw(`[{"a":123},{"b":456}]`),
|
||||||
|
"raw_json_simple": types.JSONRaw(`123`),
|
||||||
|
"b": 456,
|
||||||
|
"c": map[string]int{"sub": 1},
|
||||||
},
|
},
|
||||||
Headers: map[string]string{
|
Headers: map[string]string{
|
||||||
"d": "789",
|
"d": "789",
|
||||||
@@ -619,6 +731,14 @@ func TestRecordFieldResolverResolveStaticRequestInfoFields(t *testing.T) {
|
|||||||
{"@request.auth.emailVisibility", false, `false`},
|
{"@request.auth.emailVisibility", false, `false`},
|
||||||
{"@request.auth.email", false, `"test@example.com"`}, // should always be returned no matter of the emailVisibility state
|
{"@request.auth.email", false, `"test@example.com"`}, // should always be returned no matter of the emailVisibility state
|
||||||
{"@request.auth.missing", false, `NULL`},
|
{"@request.auth.missing", false, `NULL`},
|
||||||
|
{"@request.body.raw_json_simple", false, `"123"`},
|
||||||
|
{"@request.body.raw_json_simple.a", false, `NULL`},
|
||||||
|
{"@request.body.raw_json_obj.a", false, `123`},
|
||||||
|
{"@request.body.raw_json_obj.b", false, `NULL`},
|
||||||
|
{"@request.body.raw_json_arr1.1", false, `456`},
|
||||||
|
{"@request.body.raw_json_arr1.3", false, `NULL`},
|
||||||
|
{"@request.body.raw_json_arr2.0.a", false, `123`},
|
||||||
|
{"@request.body.raw_json_arr2.0.b", false, `NULL`},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
|
|||||||
+90
-33
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -37,9 +38,9 @@ var (
|
|||||||
type Record struct {
|
type Record struct {
|
||||||
collection *Collection
|
collection *Collection
|
||||||
originalData map[string]any
|
originalData map[string]any
|
||||||
customVisibility *store.Store[bool]
|
customVisibility *store.Store[string, bool]
|
||||||
data *store.Store[any]
|
data *store.Store[string, any]
|
||||||
expand *store.Store[any]
|
expand *store.Store[string, any]
|
||||||
|
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
@@ -55,10 +56,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -70,10 +74,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -85,10 +92,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -100,10 +110,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -115,10 +128,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
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)
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -130,10 +146,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -145,10 +164,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -160,10 +182,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -175,10 +200,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
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)
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -190,10 +218,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -205,10 +236,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -220,10 +254,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
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)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -235,10 +272,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
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)
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -498,8 +538,8 @@ func newRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringM
|
|||||||
func NewRecord(collection *Collection) *Record {
|
func NewRecord(collection *Collection) *Record {
|
||||||
record := &Record{
|
record := &Record{
|
||||||
collection: collection,
|
collection: collection,
|
||||||
data: store.New[any](nil),
|
data: store.New[string, any](nil),
|
||||||
customVisibility: store.New[bool](nil),
|
customVisibility: store.New[string, bool](nil),
|
||||||
originalData: make(map[string]any, len(collection.Fields)),
|
originalData: make(map[string]any, len(collection.Fields)),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,7 +682,7 @@ func (m *Record) Expand() map[string]any {
|
|||||||
// SetExpand replaces the current Record's expand with the provided expand arg data (shallow copied).
|
// SetExpand replaces the current Record's expand with the provided expand arg data (shallow copied).
|
||||||
func (m *Record) SetExpand(expand map[string]any) {
|
func (m *Record) SetExpand(expand map[string]any) {
|
||||||
if m.expand == nil {
|
if m.expand == nil {
|
||||||
m.expand = store.New[any](nil)
|
m.expand = store.New[string, any](nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.expand.Reset(expand)
|
m.expand.Reset(expand)
|
||||||
@@ -794,6 +834,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
|
// 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.
|
// 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 {
|
func (m *Record) IgnoreUnchangedFields(state bool) *Record {
|
||||||
m.ignoreUnchangedFields = state
|
m.ignoreUnchangedFields = state
|
||||||
return m
|
return m
|
||||||
@@ -930,20 +974,20 @@ func (m *Record) GetStringSlice(key string) []string {
|
|||||||
return list.ToUniqueStringSlice(m.Get(key))
|
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
|
// (aka. the current [*filesytem.File] values) so that you can apply further
|
||||||
// validations or modifications (including changing the file name or content before persisting).
|
// validations or modifications (including changing the file name or content before persisting).
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// files := record.GetUploadedFiles("documents")
|
// files := record.GetUnsavedFiles("documents")
|
||||||
// for _, f := range files {
|
// for _, f := range files {
|
||||||
// f.Name = "doc_" + f.Name // add a prefix to each file name
|
// 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
|
// 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 {
|
func (m *Record) GetUnsavedFiles(key string) []*filesystem.File {
|
||||||
if !strings.HasSuffix(key, ":uploaded") {
|
if !strings.HasSuffix(key, ":unsaved") {
|
||||||
key += ":uploaded"
|
key += ":unsaved"
|
||||||
}
|
}
|
||||||
|
|
||||||
values, _ := m.Get(key).([]*filesystem.File)
|
values, _ := m.Get(key).([]*filesystem.File)
|
||||||
@@ -951,6 +995,12 @@ func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
|
|||||||
return values
|
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".
|
// Retrieves the "key" json field value and unmarshals it into "result".
|
||||||
//
|
//
|
||||||
// Example
|
// Example
|
||||||
@@ -1370,12 +1420,18 @@ func onRecordValidate(e *RecordEvent) error {
|
|||||||
|
|
||||||
func onRecordSaveExecute(e *RecordEvent) error {
|
func onRecordSaveExecute(e *RecordEvent) error {
|
||||||
if e.Record.Collection().IsAuth() {
|
if e.Record.Collection().IsAuth() {
|
||||||
// ensure that the token key is different on password change
|
// ensure that the token key is regenerated on password change or email change
|
||||||
old := e.Record.Original()
|
if !e.Record.IsNew() {
|
||||||
if !e.Record.IsNew() &&
|
lastSavedRecord, err := e.App.FindRecordById(e.Record.Collection(), e.Record.Id)
|
||||||
old.TokenKey() == e.Record.TokenKey() &&
|
if err != nil {
|
||||||
old.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) {
|
return err
|
||||||
e.Record.RefreshTokenKey()
|
}
|
||||||
|
|
||||||
|
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.
|
// cross-check that the auth record id is unique across all auth collections.
|
||||||
@@ -1413,7 +1469,7 @@ func onRecordDeleteExecute(e *RecordEvent) error {
|
|||||||
//
|
//
|
||||||
// note: the select is outside of the transaction to minimize
|
// note: the select is outside of the transaction to minimize
|
||||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1452,12 +1508,13 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
|
|||||||
for _, refCollection := range sortedRefKeys {
|
for _, refCollection := range sortedRefKeys {
|
||||||
fields, ok := refs[refCollection]
|
fields, ok := refs[refCollection]
|
||||||
|
|
||||||
if refCollection.IsView() || !ok {
|
if !ok || refCollection.IsView() {
|
||||||
continue // skip missing or view collections
|
continue // skip missing or view collections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordTableName := inflector.Columnify(refCollection.Name)
|
||||||
|
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
recordTableName := inflector.Columnify(refCollection.Name)
|
|
||||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName())
|
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName())
|
||||||
|
|
||||||
query := app.RecordQuery(refCollection)
|
query := app.RecordQuery(refCollection)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
|
import "github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
|
||||||
// Email returns the "email" record field value (usually available with Auth collections).
|
// Email returns the "email" record field value (usually available with Auth collections).
|
||||||
func (m *Record) Email() string {
|
func (m *Record) Email() string {
|
||||||
return m.GetString(FieldNameEmail)
|
return m.GetString(FieldNameEmail)
|
||||||
@@ -51,6 +53,25 @@ func (m *Record) SetPassword(password string) {
|
|||||||
m.Set(FieldNamePassword, password)
|
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.
|
// ValidatePassword validates a plain password against the "password" record field.
|
||||||
//
|
//
|
||||||
// Returns false if the password is incorrect.
|
// Returns false if the password is incorrect.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package core_test
|
package core_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-28
@@ -1013,7 +1013,7 @@ func TestRecordGetStringSlice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordGetUploadedFiles(t *testing.T) {
|
func TestRecordGetUnsavedFiles(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
@@ -1054,14 +1054,14 @@ func TestRecordGetUploadedFiles(t *testing.T) {
|
|||||||
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
`[{"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}]`,
|
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, s := range scenarios {
|
for i, s := range scenarios {
|
||||||
t.Run(fmt.Sprintf("%d_%#v", i, s.key), func(t *testing.T) {
|
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)
|
raw, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1822,44 +1822,59 @@ func TestRecordSaveIdUpdateNoValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordSaveWithChangedPassword(t *testing.T) {
|
func TestRecordSaveWithAutoTokenKeyRefresh(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
defer app.Cleanup()
|
||||||
|
|
||||||
record, err := app.FindAuthRecordByEmail("nologin", "test@example.com")
|
scenarios := []struct {
|
||||||
if err != nil {
|
name string
|
||||||
t.Fatal(err)
|
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) {
|
originalTokenKey := record.TokenKey()
|
||||||
record.Set("name", "example")
|
|
||||||
|
|
||||||
if err := app.Save(record); err != nil {
|
record.Load(s.payload)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenKey := record.TokenKey()
|
err = app.Save(record)
|
||||||
if tokenKey == "" || originalTokenKey != tokenKey {
|
if err != nil {
|
||||||
t.Fatalf("Expected tokenKey to not change, got %q VS %q", originalTokenKey, tokenKey)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("password change should change the tokenKey", func(t *testing.T) {
|
newTokenKey := record.TokenKey()
|
||||||
record.Set("password", "1234567890")
|
|
||||||
|
|
||||||
if err := app.Save(record); err != nil {
|
hasChange := originalTokenKey != newTokenKey
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenKey := record.TokenKey()
|
if hasChange != s.expectedChange {
|
||||||
if tokenKey == "" || originalTokenKey == tokenKey {
|
t.Fatalf("Expected hasChange %v, got %v", s.expectedChange, hasChange)
|
||||||
t.Fatalf("Expected tokenKey to change, got %q VS %q", originalTokenKey, tokenKey)
|
}
|
||||||
}
|
})
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordDelete(t *testing.T) {
|
func TestRecordDelete(t *testing.T) {
|
||||||
|
|||||||
+18
-3
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
"github.com/pocketbase/pocketbase/tools/search"
|
"github.com/pocketbase/pocketbase/tools/search"
|
||||||
@@ -527,20 +528,34 @@ func (app *BaseApp) FindAuthRecordByToken(token string, validTypes ...string) (*
|
|||||||
|
|
||||||
// FindAuthRecordByEmail finds the auth record associated with the provided email.
|
// 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.
|
// 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) {
|
func (app *BaseApp) FindAuthRecordByEmail(collectionModelOrIdentifier any, email string) (*Record, error) {
|
||||||
collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
|
collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch auth collection: %w", err)
|
return nil, fmt.Errorf("failed to fetch auth collection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !collection.IsAuth() {
|
if !collection.IsAuth() {
|
||||||
return nil, fmt.Errorf("%q is not an auth collection", collection.Name)
|
return nil, fmt.Errorf("%q is not an auth collection", collection.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
record := &Record{}
|
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).
|
err = app.RecordQuery(collection).
|
||||||
AndWhere(dbx.HashExp{FieldNameEmail: email}).
|
AndWhere(expr).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
One(record)
|
One(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -584,7 +599,7 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists bool
|
var exists int
|
||||||
|
|
||||||
query := app.RecordQuery(record.Collection()).
|
query := app.RecordQuery(record.Collection()).
|
||||||
Select("(1)").
|
Select("(1)").
|
||||||
@@ -603,5 +618,5 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return exists, nil
|
return exists > 0, nil
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
|
|||||||
MaxSelect: 2147483647,
|
MaxSelect: 2147483647,
|
||||||
CollectionId: indirectRel.Id,
|
CollectionId: indirectRel.Id,
|
||||||
}
|
}
|
||||||
if dbutils.HasSingleColumnUniqueIndex(indirectRelField.GetName(), indirectRel.Indexes) {
|
if _, ok := dbutils.FindSingleColumnUniqueIndex(indirectRel.Indexes, indirectRelField.GetName()); ok {
|
||||||
relField.MaxSelect = 1
|
relField.MaxSelect = 1
|
||||||
}
|
}
|
||||||
relCollection = indirectRel
|
relCollection = indirectRel
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -966,23 +967,46 @@ func TestFindAuthRecordByToken(t *testing.T) {
|
|||||||
func TestFindAuthRecordByEmail(t *testing.T) {
|
func TestFindAuthRecordByEmail(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app, _ := tests.NewTestApp()
|
|
||||||
defer app.Cleanup()
|
|
||||||
|
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
collectionIdOrName string
|
collectionIdOrName string
|
||||||
email string
|
email string
|
||||||
|
nocaseIndex bool
|
||||||
expectError bool
|
expectError bool
|
||||||
}{
|
}{
|
||||||
{"missing", "test@example.com", true},
|
{"missing", "test@example.com", false, true},
|
||||||
{"demo2", "test@example.com", true},
|
{"demo2", "test@example.com", false, true},
|
||||||
{"users", "missing@example.com", true},
|
{"users", "missing@example.com", false, true},
|
||||||
{"users", "test@example.com", false},
|
{"users", "test@example.com", false, false},
|
||||||
{"clients", "test2@example.com", false},
|
{"clients", "test2@example.com", false, false},
|
||||||
|
// case-insensitive tests
|
||||||
|
{"clients", "TeSt2@example.com", false, true},
|
||||||
|
{"clients", "TeSt2@example.com", true, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
t.Run(fmt.Sprintf("%s_%s", s.collectionIdOrName, s.email), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s_%s", s.collectionIdOrName, s.email), func(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
collection, _ := app.FindCollectionByNameOrId(s.collectionIdOrName)
|
||||||
|
if collection != nil {
|
||||||
|
emailIndex, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, core.FieldNameEmail)
|
||||||
|
if ok {
|
||||||
|
if s.nocaseIndex {
|
||||||
|
emailIndex.Columns[0].Collate = "nocase"
|
||||||
|
} else {
|
||||||
|
emailIndex.Columns[0].Collate = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.RemoveIndex(emailIndex.IndexName)
|
||||||
|
collection.Indexes = append(collection.Indexes, emailIndex.Build())
|
||||||
|
err := app.Save(collection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to update email index: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
record, err := app.FindAuthRecordByEmail(s.collectionIdOrName, s.email)
|
record, err := app.FindAuthRecordByEmail(s.collectionIdOrName, s.email)
|
||||||
|
|
||||||
hasErr := err != nil
|
hasErr := err != nil
|
||||||
@@ -994,7 +1018,7 @@ func TestFindAuthRecordByEmail(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.Email() != s.email {
|
if !strings.EqualFold(record.Email(), s.email) {
|
||||||
t.Fatalf("Expected record with email %s, got %s", s.email, record.Email())
|
t.Fatalf("Expected record with email %s, got %s", s.email, record.Email())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []stri
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
if _, ok := err.(validation.Errors); ok {
|
if _, ok := err.(validation.Errors); ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -59,12 +58,15 @@ func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []stri
|
|||||||
|
|
||||||
// check for unique constraint failure
|
// check for unique constraint failure
|
||||||
if strings.Contains(msg, "unique constraint failed") {
|
if strings.Contains(msg, "unique constraint failed") {
|
||||||
|
// note: extra space to unify multi-columns lookup
|
||||||
|
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ") + " "
|
||||||
|
|
||||||
normalizedErrs := validation.Errors{}
|
normalizedErrs := validation.Errors{}
|
||||||
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ")
|
|
||||||
|
|
||||||
for _, name := range fieldNames {
|
for _, name := range fieldNames {
|
||||||
// blank space to unify multi-columns lookup
|
// note: extra spaces to exclude table name with suffix matching the current one
|
||||||
if strings.Contains(msg+" ", strings.ToLower(tableOrAlias+"."+name)) {
|
// OR other fields starting with the current field name
|
||||||
|
if strings.Contains(msg, strings.ToLower(" "+tableOrAlias+"."+name+" ")) {
|
||||||
normalizedErrs[name] = validation.NewError("validation_not_unique", "Value must be unique")
|
normalizedErrs[name] = validation.NewError("validation_not_unique", "Value must be unique")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ func TestNormalizeUniqueIndexError(t *testing.T) {
|
|||||||
[]string{"a", "b"},
|
[]string{"a", "b"},
|
||||||
nil,
|
nil,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"unique index error with table name suffix matching the specified one",
|
||||||
|
errors.New("UNIQUE constraint failed for fields test_suffix.a,test_suffix.b"),
|
||||||
|
"suffix",
|
||||||
|
[]string{"a", "b", "c"},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"unique index error but mismatched fields",
|
"unique index error but mismatched fields",
|
||||||
errors.New("UNIQUE constraint failed for fields test.a,test.b"),
|
errors.New("UNIQUE constraint failed for fields test.a,test.b"),
|
||||||
@@ -92,6 +99,13 @@ func TestNormalizeUniqueIndexError(t *testing.T) {
|
|||||||
[]string{"a", "b", "c"},
|
[]string{"a", "b", "c"},
|
||||||
[]string{"a", "b"},
|
[]string{"a", "b"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"unique index error with matching table name and field starting with the name of another non-unique field",
|
||||||
|
errors.New("UNIQUE constraint failed for fields test.a_2,test.c"),
|
||||||
|
"test",
|
||||||
|
[]string{"a", "a_2", "c"},
|
||||||
|
[]string{"a_2", "c"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
|
|||||||
+10
-8
@@ -437,9 +437,11 @@ func getQueryTableInfo(app App, selectQuery string) ([]*TableInfoRow, error) {
|
|||||||
// Raw query identifiers parser
|
// Raw query identifiers parser
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
var joinReplaceRegex = regexp.MustCompile(`(?im)\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+?`)
|
var (
|
||||||
var discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group\s+by|having|order|limit|with)\s+?`)
|
joinReplaceRegex = regexp.MustCompile(`(?im)\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+?`)
|
||||||
var commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]+\*\/)|(--.+$)`)
|
discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group\s+by|having|order|limit|with)\s+?`)
|
||||||
|
commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]*?\*\/)|(--.+$)`)
|
||||||
|
)
|
||||||
|
|
||||||
type identifier struct {
|
type identifier struct {
|
||||||
original string
|
original string
|
||||||
@@ -453,9 +455,9 @@ type identifiersParser struct {
|
|||||||
|
|
||||||
func (p *identifiersParser) parse(selectQuery string) error {
|
func (p *identifiersParser) parse(selectQuery string) error {
|
||||||
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
|
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||||
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
|
str = commentsReplaceRegex.ReplaceAllString(str, " ")
|
||||||
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
|
str = joinReplaceRegex.ReplaceAllString(str, " __pb_join__ ")
|
||||||
str = commentsReplaceRegex.ReplaceAllString(str, "")
|
str = discardReplaceRegex.ReplaceAllString(str, " __pb_discard__ ")
|
||||||
|
|
||||||
tk := tokenizer.NewFromString(str)
|
tk := tokenizer.NewFromString(str)
|
||||||
tk.Separators(',', ' ', '\n', '\t')
|
tk.Separators(',', ' ', '\n', '\t')
|
||||||
@@ -490,7 +492,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
|
|||||||
skip = false
|
skip = false
|
||||||
partType = "from"
|
partType = "from"
|
||||||
activeBuilder = &fromParts
|
activeBuilder = &fromParts
|
||||||
case "_join_":
|
case "__pb_join__":
|
||||||
skip = false
|
skip = false
|
||||||
|
|
||||||
// the previous part was also a join
|
// the previous part was also a join
|
||||||
@@ -500,7 +502,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
|
|||||||
|
|
||||||
partType = "join"
|
partType = "join"
|
||||||
activeBuilder = &joinParts
|
activeBuilder = &joinParts
|
||||||
case "_discard_":
|
case "__pb_discard__":
|
||||||
// skip following tokens
|
// skip following tokens
|
||||||
skip = true
|
skip = true
|
||||||
default:
|
default:
|
||||||
|
|||||||
+14
-4
@@ -246,11 +246,21 @@ func TestCreateViewFields(t *testing.T) {
|
|||||||
`
|
`
|
||||||
select
|
select
|
||||||
-- test single line
|
-- test single line
|
||||||
id,
|
demo1.id,
|
||||||
text,
|
demo1.text,
|
||||||
/* multi
|
/* multi
|
||||||
line comment */
|
* line comment block */
|
||||||
url, created, updated from demo1
|
demo1.url, demo1.created, demo1.updated from/* inline comment block with no spaces between the identifiers */demo1
|
||||||
|
-- comment before join
|
||||||
|
join demo2 ON (
|
||||||
|
-- comment inside join
|
||||||
|
demo2.id = demo1.id
|
||||||
|
)
|
||||||
|
-- comment before where
|
||||||
|
where (
|
||||||
|
-- comment inside where
|
||||||
|
demo2.id = demo1.id
|
||||||
|
)
|
||||||
`,
|
`,
|
||||||
false,
|
false,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func main() {
|
|||||||
&hooksWatch,
|
&hooksWatch,
|
||||||
"hooksWatch",
|
"hooksWatch",
|
||||||
true,
|
true,
|
||||||
"auto restart the app on pb_hooks file change",
|
"auto restart the app on pb_hooks file change; it has no effect on Windows",
|
||||||
)
|
)
|
||||||
|
|
||||||
var hooksPool int
|
var hooksPool int
|
||||||
@@ -76,7 +76,7 @@ func main() {
|
|||||||
&indexFallback,
|
&indexFallback,
|
||||||
"indexFallback",
|
"indexFallback",
|
||||||
true,
|
true,
|
||||||
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
|
"fallback the request to index.html on missing static path, e.g. when pretty urls are used with SPA",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.RootCmd.ParseFlags(os.Args[1:])
|
app.RootCmd.ParseFlags(os.Args[1:])
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/pocketbase/pocketbase/forms"
|
"github.com/pocketbase/pocketbase/forms"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
)
|
)
|
||||||
|
|||||||
+14
-14
@@ -27,9 +27,9 @@ type RecordUpsert struct {
|
|||||||
accessLevel int
|
accessLevel int
|
||||||
|
|
||||||
// extra password fields
|
// extra password fields
|
||||||
Password string `form:"password" json:"password"`
|
password string
|
||||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
passwordConfirm string
|
||||||
OldPassword string `form:"oldPassword" json:"oldPassword"`
|
oldPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
|
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
|
||||||
@@ -93,13 +93,13 @@ func (form *RecordUpsert) Load(data map[string]any) {
|
|||||||
// load the special auth form fields
|
// load the special auth form fields
|
||||||
if isAuth {
|
if isAuth {
|
||||||
if v, ok := data["password"]; ok {
|
if v, ok := data["password"]; ok {
|
||||||
form.Password = cast.ToString(v)
|
form.password = cast.ToString(v)
|
||||||
}
|
}
|
||||||
if v, ok := data["passwordConfirm"]; ok {
|
if v, ok := data["passwordConfirm"]; ok {
|
||||||
form.PasswordConfirm = cast.ToString(v)
|
form.passwordConfirm = cast.ToString(v)
|
||||||
}
|
}
|
||||||
if v, ok := data["oldPassword"]; ok {
|
if v, ok := data["oldPassword"]; ok {
|
||||||
form.OldPassword = cast.ToString(v)
|
form.oldPassword = cast.ToString(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
excludeFields = append(excludeFields, "passwordConfirm", "oldPassword") // skip non-schema password fields
|
excludeFields = append(excludeFields, "passwordConfirm", "oldPassword") // skip non-schema password fields
|
||||||
@@ -137,9 +137,9 @@ func (form *RecordUpsert) validateFormFields() error {
|
|||||||
validateData := map[string]any{
|
validateData := map[string]any{
|
||||||
"email": form.record.Email(),
|
"email": form.record.Email(),
|
||||||
"verified": form.record.Verified(),
|
"verified": form.record.Verified(),
|
||||||
"password": form.Password,
|
"password": form.password,
|
||||||
"passwordConfirm": form.PasswordConfirm,
|
"passwordConfirm": form.passwordConfirm,
|
||||||
"oldPassword": form.OldPassword,
|
"oldPassword": form.oldPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
return validation.Validate(validateData,
|
return validation.Validate(validateData,
|
||||||
@@ -165,17 +165,17 @@ func (form *RecordUpsert) validateFormFields() error {
|
|||||||
validation.Key(
|
validation.Key(
|
||||||
"password",
|
"password",
|
||||||
validation.When(
|
validation.When(
|
||||||
(isNew || form.PasswordConfirm != "" || form.OldPassword != ""),
|
(isNew || form.passwordConfirm != "" || form.oldPassword != ""),
|
||||||
validation.Required,
|
validation.Required,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
validation.Key(
|
validation.Key(
|
||||||
"passwordConfirm",
|
"passwordConfirm",
|
||||||
validation.When(
|
validation.When(
|
||||||
(isNew || form.Password != "" || form.OldPassword != ""),
|
(isNew || form.password != "" || form.oldPassword != ""),
|
||||||
validation.Required,
|
validation.Required,
|
||||||
),
|
),
|
||||||
validation.By(validators.Equal(form.Password)),
|
validation.By(validators.Equal(form.password)),
|
||||||
),
|
),
|
||||||
validation.Key(
|
validation.Key(
|
||||||
"oldPassword",
|
"oldPassword",
|
||||||
@@ -183,7 +183,7 @@ func (form *RecordUpsert) validateFormFields() error {
|
|||||||
// - form.HasManageAccess() is not satisfied
|
// - form.HasManageAccess() is not satisfied
|
||||||
// - changing the existing password
|
// - changing the existing password
|
||||||
validation.When(
|
validation.When(
|
||||||
!isNew && !form.HasManageAccess() && (form.Password != "" || form.PasswordConfirm != ""),
|
!isNew && !form.HasManageAccess() && (form.password != "" || form.passwordConfirm != ""),
|
||||||
validation.Required,
|
validation.Required,
|
||||||
validation.By(form.checkOldPassword),
|
validation.By(form.checkOldPassword),
|
||||||
),
|
),
|
||||||
@@ -205,7 +205,7 @@ func (form *RecordUpsert) checkOldPassword(value any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo consider removing and executing the Create API rule without dummy insert.
|
// Deprecated: It was previously used as part of the record create action but it is not needed anymore and will be removed in the future.
|
||||||
//
|
//
|
||||||
// DrySubmit performs a temp form submit within a transaction and reverts it at the end.
|
// DrySubmit performs a temp form submit within a transaction and reverts it at the end.
|
||||||
// For actual record persistence, check the [RecordUpsert.Submit()] method.
|
// For actual record persistence, check the [RecordUpsert.Submit()] method.
|
||||||
|
|||||||
+16
-14
@@ -64,15 +64,15 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
`"text":"test_text"`,
|
`"text":"test_text"`,
|
||||||
`"number":456`,
|
`"number":456`,
|
||||||
`"select_many":["optionB","optionC"]`,
|
`"select_many":["optionB","optionC"]`,
|
||||||
`"password":""`,
|
|
||||||
`"oldPassword":""`,
|
|
||||||
`"passwordConfirm":""`,
|
|
||||||
`"created":""`,
|
`"created":""`,
|
||||||
`"updated":""`,
|
`"updated":""`,
|
||||||
`"json":null`,
|
`"json":null`,
|
||||||
},
|
},
|
||||||
notExpected: []string{
|
notExpected: []string{
|
||||||
`"custom"`,
|
`"custom"`,
|
||||||
|
`"password"`,
|
||||||
|
`"oldPassword"`,
|
||||||
|
`"passwordConfirm"`,
|
||||||
`"select_many-"`,
|
`"select_many-"`,
|
||||||
`"select_many+"`,
|
`"select_many+"`,
|
||||||
},
|
},
|
||||||
@@ -89,9 +89,11 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
record: core.NewRecord(usersCol),
|
record: core.NewRecord(usersCol),
|
||||||
expected: []string{
|
expected: []string{
|
||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"oldPassword":"123"`,
|
|
||||||
`"password":"456"`,
|
`"password":"456"`,
|
||||||
`"passwordConfirm":"789"`,
|
},
|
||||||
|
notExpected: []string{
|
||||||
|
`"oldPassword"`,
|
||||||
|
`"passwordConfirm"`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,8 +112,10 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"tokenKey":""`,
|
`"tokenKey":""`,
|
||||||
`"password":"456"`,
|
`"password":"456"`,
|
||||||
`"oldPassword":"123"`,
|
},
|
||||||
`"passwordConfirm":"789"`,
|
notExpected: []string{
|
||||||
|
`"oldPassword"`,
|
||||||
|
`"passwordConfirm"`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -130,8 +134,10 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
`"email":"test@example.com"`,
|
`"email":"test@example.com"`,
|
||||||
`"tokenKey":"abc"`,
|
`"tokenKey":"abc"`,
|
||||||
`"password":"456"`,
|
`"password":"456"`,
|
||||||
`"oldPassword":"123"`,
|
},
|
||||||
`"passwordConfirm":"789"`,
|
notExpected: []string{
|
||||||
|
`"oldPassword"`,
|
||||||
|
`"passwordConfirm"`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -168,11 +174,7 @@ func TestRecordUpsertLoad(t *testing.T) {
|
|||||||
|
|
||||||
form.Load(s.data)
|
form.Load(s.data)
|
||||||
|
|
||||||
loaded := map[string]any{
|
loaded := map[string]any{}
|
||||||
"oldPassword": form.OldPassword,
|
|
||||||
"password": form.Password,
|
|
||||||
"passwordConfirm": form.PasswordConfirm,
|
|
||||||
}
|
|
||||||
maps.Copy(loaded, s.record.FieldsData())
|
maps.Copy(loaded, s.record.FieldsData())
|
||||||
maps.Copy(loaded, s.record.CustomData())
|
maps.Copy(loaded, s.record.CustomData())
|
||||||
|
|
||||||
|
|||||||
@@ -3,50 +3,49 @@ module github.com/pocketbase/pocketbase
|
|||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
github.com/aws/aws-sdk-go-v2 v1.36.1
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.6
|
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.6
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.47
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0
|
github.com/aws/smithy-go v1.22.2
|
||||||
github.com/aws/smithy-go v1.22.1
|
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2
|
github.com/domodwyer/mailyak/v3 v3.6.2
|
||||||
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3
|
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3
|
||||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
||||||
github.com/fatih/color v1.18.0
|
github.com/fatih/color v1.18.0
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.7
|
github.com/gabriel-vasile/mimetype v1.4.8
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1
|
github.com/ganigeorgiev/fexpr v0.4.1
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75
|
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119
|
||||||
github.com/spf13/cast v1.7.0
|
github.com/spf13/cast v1.7.1
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
gocloud.dev v0.40.0
|
gocloud.dev v0.40.0
|
||||||
golang.org/x/crypto v0.30.0
|
golang.org/x/crypto v0.33.0
|
||||||
golang.org/x/net v0.32.0
|
golang.org/x/net v0.35.0
|
||||||
golang.org/x/oauth2 v0.24.0
|
golang.org/x/oauth2 v0.26.0
|
||||||
golang.org/x/sync v0.10.0
|
golang.org/x/sync v0.11.0
|
||||||
modernc.org/sqlite v1.34.2
|
modernc.org/sqlite v1.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
|
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
@@ -54,34 +53,27 @@ require (
|
|||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.14.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/stretchr/testify v1.8.2 // indirect
|
github.com/stretchr/testify v1.8.2 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
||||||
golang.org/x/image v0.23.0 // indirect
|
golang.org/x/image v0.24.0 // indirect
|
||||||
golang.org/x/mod v0.22.0 // indirect
|
golang.org/x/mod v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/term v0.27.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/tools v0.30.0 // indirect
|
||||||
golang.org/x/tools v0.28.0 // indirect
|
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
google.golang.org/api v0.211.0 // indirect
|
google.golang.org/api v0.220.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 // indirect
|
||||||
google.golang.org/grpc v1.68.1 // indirect
|
google.golang.org/grpc v1.70.0 // indirect
|
||||||
google.golang.org/protobuf v1.35.2 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
|
modernc.org/libc v1.61.13 // indirect
|
||||||
modernc.org/libc v1.61.4 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/memory v1.8.2 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
|
||||||
modernc.org/strutil v1.2.0 // indirect
|
|
||||||
modernc.org/token v1.1.0 // indirect
|
|
||||||
)
|
)
|
||||||
@@ -1,74 +1,68 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
||||||
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
||||||
cloud.google.com/go/auth v0.12.1 h1:n2Bj25BUMM0nvE9D2XLTiImanwZhO3DkfWSYS/SAJP4=
|
cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0=
|
||||||
cloud.google.com/go/auth v0.12.1/go.mod h1:BFMu+TNpF3DmvfBO9ClqTR/SiqVIm7LukKF9mbendF4=
|
cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
|
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
|
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
||||||
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
||||||
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
||||||
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
||||||
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
|
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
|
|
||||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
|
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
|
github.com/aws/aws-sdk-go-v2 v1.36.1 h1:iTDl5U6oAhkNPba0e1t1hrwAo02ZMqbrGq4k5JBWM5E=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
|
github.com/aws/aws-sdk-go-v2 v1.36.1/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo=
|
github.com/aws/aws-sdk-go-v2/config v1.28.10 h1:fKODZHfqQu06pCzR69KJ3GuttraRJkhlC8g80RZ0Dfg=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko=
|
github.com/aws/aws-sdk-go-v2/config v1.28.10/go.mod h1:PvdxRYZ5Um9QMq9PQ0zHHNdtKK+he2NHtFCUFMXWXeg=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.51 h1:F/9Sm6Y6k4LqDesZDPJCLxQGXNNHd/ZtJiWd0lCZKRk=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.51/go.mod h1:TKbzCHm43AoPyA+iLGGcruXd4AFhF8tOmLex2R9jWNQ=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 h1:KwsodFKVQTlI5EyhRSugALzsV6mG/SGrdjlMXSZSdso=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28/go.mod h1:EY3APf9MzygVhKuPXAc5H+MkGb8k/DOSQjWS0LgkKqI=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 h1:iLdpkYZ4cXIQMO7ud+cqMWR1xK5ESbt1rvN77tRi1BY=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48 h1:XnXVe2zRyPf0+fAW5L05esmngvBpC6DQZK7oZB/z/Co=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43/go.mod h1:OgbsKPAswXDd5kxnR4vZov69p3oYjbvUyIRBAAV0y9o=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.48/go.mod h1:S3wey90OrS4f7kYxH6PT175YyEcHTORY07++HurMaRM=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 h1:BjUcr3X3K0wZPGFg2bxOWW3VPN8rkE3/61zhP+IHviA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32/go.mod h1:80+OGC/bgzzFFTUmcuwD0lb4YutwQeKLFpmt6hoWapU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 h1:m1GeXHVMJsRsUAqG6HjZWx9dj7F5TR+cF1bjyfYyBd4=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32/go.mod h1:IitoQxGfaKdVLNg0hD8/DXmAqNy0H4K2H2Sf91ti8sI=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 h1:OIHj/nAhVzIXGzbAE+4XmZ8FPvro3THr6NlqErJc3wY=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32/go.mod h1:LiBEsDo34OJXqdDlRGsilhlIiXR7DL+6Cx2f4p1EgzI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.0 h1:kT2WeWcFySdYpPgyqJMSUE7781Qucjtn6wBvrgm9P+M=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.0/go.mod h1:WYH1ABybY7JK9TITPnk6ZlP7gQB8psI4c9qDmMsnLSA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 h1:SYVGSFQHlchIcy6e7x12bsrxClCXSP5et8cqVhL8cuw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13/go.mod h1:kizuDaLX37bG5WZaoxGPQR/LNFXpxp0vsUnqfkWXfNE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13 h1:OBsrtam3rk8NfBEq7OLOMm5HtQ9Yyw32X4UQMya/wjw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13/go.mod h1:3U4gFA5pmoCOja7aq4nSaIAGbaOHv2Yl2ug018cmC+Q=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2 h1:a7aQ3RW+ug4IbhoQp29NZdc7vqrzKZZfWZSaQAXOZvQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2/go.mod h1:xMekrnhmJ5aqmyxtmALs7mlvXw5xRh+eYjOjvrIIFJ4=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 h1:/eE3DogBjYlvlbhd2ssWyeuovWunHLxfgw3s/OJa4GQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15/go.mod h1:2PCJYpi7EKeA5SkStAmZlF6fi0uUABuhtF8ILHjGc3Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 h1:M/zwXiL2iXUrHputuXgmO94TVNmcenPHxgLXLutodKE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14/go.mod h1:RVwIw3y/IqxC2YEXSIkAzRDdEU1iRabDPaYjpGCbCGQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 h1:TzeR06UCMUq+KA3bDkujxK1GVGy+G8qQN/QVYzGLkQE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14/go.mod h1:dspXf/oYWGWo6DEvj98wpaTeqt5+DMidZD0A9BYTizc=
|
||||||
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
|
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
||||||
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
|
||||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -98,8 +92,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
@@ -113,8 +107,8 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5Nq
|
|||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
@@ -142,8 +136,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -151,156 +145,128 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
|||||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
|
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
|
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
|
||||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75 h1:XSbmekxgmbI2uPrre/nkCz7y8VsV652TPb3hAYzPb74=
|
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119 h1:TjQtEReJDTpvlNFTRjuHvPQpJHAeJdcQF130eCAAT/o=
|
||||||
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
|
github.com/pocketbase/tygoja v0.0.0-20250103200817-ca580d8c5119/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
|
||||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
|
||||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
||||||
|
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
||||||
|
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
||||||
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
||||||
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
|
||||||
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
|
||||||
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
|
||||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
|
||||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||||
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
|
||||||
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
|
google.golang.org/api v0.220.0 h1:3oMI4gdBgB72WFVwE1nerDD8W3HUOS4kypK6rRLbGns=
|
||||||
google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
|
google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1GkZEmY=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
@@ -309,17 +275,17 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
|
|||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
|
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
|
||||||
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
|
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88=
|
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 h1:2duwAxN2+k0xLNpjnHTXoMUgnv6VPSp5fiqTuwSxjmI=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
|
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
|
||||||
google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
|
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -329,8 +295,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
@@ -340,28 +306,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/cc/v4 v4.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8=
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/ccgo/v4 v4.23.1 h1:N49a7JiWGWV7lkPE4yYcvjkBGZQi93/JabRYjdWmJXc=
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
modernc.org/ccgo/v4 v4.23.1/go.mod h1:JoIUegEIfutvoWV/BBfDFpPpfR2nc3U0jKucGcbmwDU=
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
modernc.org/libc v1.61.4 h1:wVyqEx6tlltte9lPTjq0kDAdtdM9c4JH8rU6M1ZVawA=
|
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||||
modernc.org/libc v1.61.4/go.mod h1:VfXVuM/Shh5XsMNrh3C6OkfL78G3loa4ZC/Ljv9k7xc=
|
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
modernc.org/sqlite v1.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y=
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
|
||||||
|
modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// Package mails implements various helper methods for sending user and admin
|
// Package mails implements various helper methods for sending common
|
||||||
// emails like forgotten password, verification, etc.
|
// emails like forgotten password, verification, etc.
|
||||||
package mails
|
package mails
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -1,8 +1,10 @@
|
|||||||
package mails
|
package mails
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/mails/templates"
|
"github.com/pocketbase/pocketbase/mails/templates"
|
||||||
@@ -232,6 +234,13 @@ func SendRecordChangeEmail(app core.App, authRecord *core.Record, newEmail strin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nonescapeTypes = []string{
|
||||||
|
core.FieldTypeAutodate,
|
||||||
|
core.FieldTypeDate,
|
||||||
|
core.FieldTypeBool,
|
||||||
|
core.FieldTypeNumber,
|
||||||
|
}
|
||||||
|
|
||||||
func resolveEmailTemplate(
|
func resolveEmailTemplate(
|
||||||
app core.App,
|
app core.App,
|
||||||
authRecord *core.Record,
|
authRecord *core.Record,
|
||||||
@@ -258,7 +267,15 @@ func resolveEmailTemplate(
|
|||||||
|
|
||||||
fieldPlacehodler := "{RECORD:" + field.GetName() + "}"
|
fieldPlacehodler := "{RECORD:" + field.GetName() + "}"
|
||||||
if _, ok := placeholders[fieldPlacehodler]; !ok {
|
if _, ok := placeholders[fieldPlacehodler]; !ok {
|
||||||
placeholders[fieldPlacehodler] = authRecord.Get(field.GetName())
|
val := authRecord.GetString(field.GetName())
|
||||||
|
|
||||||
|
// note: the escaping is not strictly necessary but for just in case
|
||||||
|
// the user decide to store and render the email as plain html
|
||||||
|
if !slices.Contains(nonescapeTypes, field.Type()) {
|
||||||
|
val = html.EscapeString(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders[fieldPlacehodler] = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-5
@@ -1,6 +1,7 @@
|
|||||||
package mails_test
|
package mails_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ func TestSendRecordAuthAlert(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordAuthAlert(testApp, user)
|
err := mails.SendRecordAuthAlert(testApp, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -26,7 +30,7 @@ func TestSendRecordAuthAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // public and private record placeholder checks
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // public and private record placeholder checks
|
||||||
"login to your " + testApp.Settings().Meta.AppName + " account from a new location",
|
"login to your " + testApp.Settings().Meta.AppName + " account from a new location",
|
||||||
"If this was you",
|
"If this was you",
|
||||||
"If this wasn't you",
|
"If this wasn't you",
|
||||||
@@ -46,6 +50,9 @@ func TestSendRecordPasswordReset(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordPasswordReset(testApp, user)
|
err := mails.SendRecordPasswordReset(testApp, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -56,7 +63,7 @@ func TestSendRecordPasswordReset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
||||||
"http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
"http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||||
}
|
}
|
||||||
for _, part := range expectedParts {
|
for _, part := range expectedParts {
|
||||||
@@ -74,6 +81,9 @@ func TestSendRecordVerification(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordVerification(testApp, user)
|
err := mails.SendRecordVerification(testApp, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -84,7 +94,7 @@ func TestSendRecordVerification(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
||||||
"http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
"http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||||
}
|
}
|
||||||
for _, part := range expectedParts {
|
for _, part := range expectedParts {
|
||||||
@@ -102,6 +112,9 @@ func TestSendRecordChangeEmail(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordChangeEmail(testApp, user, "new_test@example.com")
|
err := mails.SendRecordChangeEmail(testApp, user, "new_test@example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -112,7 +125,7 @@ func TestSendRecordChangeEmail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
||||||
"http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
"http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||||
}
|
}
|
||||||
for _, part := range expectedParts {
|
for _, part := range expectedParts {
|
||||||
@@ -130,6 +143,9 @@ func TestSendRecordOTP(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordOTP(testApp, user, "test_otp_id", "test_otp_code")
|
err := mails.SendRecordOTP(testApp, user, "test_otp_id", "test_otp_code")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -140,7 +156,7 @@ func TestSendRecordOTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
||||||
"one-time password",
|
"one-time password",
|
||||||
"test_otp_code",
|
"test_otp_code",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
// note: this migration will be deleted in future version
|
// note: this migration will be deleted in future version
|
||||||
|
|
||||||
func collectionIdChecksum(typ, name string) string {
|
func collectionIdChecksum(typ, name string) string {
|
||||||
return "pbc_" + strconv.Itoa(int(crc32.ChecksumIEEE([]byte(typ+name))))
|
return "pbc_" + strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(typ+name))), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fieldIdChecksum(typ, name string) string {
|
func fieldIdChecksum(typ, name string) string {
|
||||||
return typ + strconv.Itoa(int(crc32.ChecksumIEEE([]byte(name))))
|
return typ + strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(name))), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize system collection and field ids
|
// normalize system collection and field ids
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package pocketbase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
expectedDriverVersion = "v1.35.0"
|
||||||
|
expectedLibcVersion = "v1.61.13"
|
||||||
|
|
||||||
|
// ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
|
||||||
|
// It could be used for removing/unbinding the hook if you don't want the checks.
|
||||||
|
ModerncDepsCheckHookId = "pbModerncDepsCheck"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkModerncDeps checks whether the current binary was buit with the
|
||||||
|
// expected and tested modernc driver dependencies.
|
||||||
|
//
|
||||||
|
// This is needed because modernc.org/libc doesn't follow semantic versioning
|
||||||
|
// and using a version different from the one in the go.mod of modernc.org/sqlite
|
||||||
|
// could have unintended side-effects and cause obscure build and runtime bugs
|
||||||
|
// (https://github.com/pocketbase/pocketbase/issues/6136).
|
||||||
|
func checkModerncDeps(app core.App) {
|
||||||
|
info, ok := debug.ReadBuildInfo()
|
||||||
|
if !ok {
|
||||||
|
return // no build info (probably compiled without module support)
|
||||||
|
}
|
||||||
|
|
||||||
|
var driverVersion, libcVersion string
|
||||||
|
|
||||||
|
for _, dep := range info.Deps {
|
||||||
|
switch dep.Path {
|
||||||
|
case "modernc.org/libc":
|
||||||
|
libcVersion = dep.Version
|
||||||
|
case "modernc.org/sqlite":
|
||||||
|
driverVersion = dep.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
// no need to further search if both deps are located
|
||||||
|
if driverVersion != "" && libcVersion != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// not using the default driver
|
||||||
|
if driverVersion == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg string
|
||||||
|
if driverVersion != expectedDriverVersion {
|
||||||
|
msg = fmt.Sprintf(
|
||||||
|
"You are using modernc.org/sqlite %s which differs from the expected and tested %s.\n"+
|
||||||
|
"Make sure to either manually update in your go.mod the dependency version to the expected one OR if you want to keep yours "+
|
||||||
|
"ensure that its indirect modernc.org/libc dependency has the same version as in the https://gitlab.com/cznic/sqlite/-/blob/master/go.mod, "+
|
||||||
|
"otherwise it could result in unexpected build or runtime errors.",
|
||||||
|
driverVersion,
|
||||||
|
expectedDriverVersion,
|
||||||
|
)
|
||||||
|
app.Logger().Warn(msg, slog.String("current", driverVersion), slog.String("expected", expectedDriverVersion))
|
||||||
|
} else if libcVersion != expectedLibcVersion {
|
||||||
|
msg = fmt.Sprintf(
|
||||||
|
"You are using modernc.org/libc %s which differs from the expected and tested %s.\n"+
|
||||||
|
"Please update your go.mod and manually set modernc.org/libc to %s, otherwise it could result in unexpected build or runtime errors "+
|
||||||
|
"(you may have to also run 'go clean -modcache' to clear the cache if the warning persists).",
|
||||||
|
libcVersion,
|
||||||
|
expectedLibcVersion,
|
||||||
|
expectedLibcVersion,
|
||||||
|
)
|
||||||
|
app.Logger().Warn(msg, slog.String("current", libcVersion), slog.String("expected", expectedLibcVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that the message is printed to the default stderr too
|
||||||
|
// (when in dev mode this is not needed because we print all logs)
|
||||||
|
if msg != "" && !app.IsDev() {
|
||||||
|
color.Yellow("\nWARN " + msg + "\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/archive"
|
"github.com/pocketbase/pocketbase/tools/archive"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,11 +121,7 @@ func (p *plugin) updateCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if needConfirm {
|
if needConfirm {
|
||||||
confirm := false
|
confirm := osutils.YesNoPrompt("Do you want to proceed with the update?", false)
|
||||||
prompt := &survey.Confirm{
|
|
||||||
Message: "Do you want to proceed with the update?",
|
|
||||||
}
|
|
||||||
survey.AskOne(prompt, &confirm)
|
|
||||||
if !confirm {
|
if !confirm {
|
||||||
fmt.Println("The command has been cancelled.")
|
fmt.Println("The command has been cancelled.")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+87
-45
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
@@ -57,7 +58,7 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
|||||||
loader.Set(jsName, func(callback string, tags ...string) {
|
loader.Set(jsName, func(callback string, tags ...string) {
|
||||||
// overwrite the global $app with the hook scoped instance
|
// overwrite the global $app with the hook scoped instance
|
||||||
callback = `function(e) { $app = e.app; return (` + callback + `).call(undefined, e) }`
|
callback = `function(e) { $app = e.app; return (` + callback + `).call(undefined, e) }`
|
||||||
pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true)
|
pr := goja.MustCompile(defaultScriptPath, "{("+callback+").apply(undefined, __args)}", true)
|
||||||
|
|
||||||
tagsAsValues := make([]reflect.Value, len(tags))
|
tagsAsValues := make([]reflect.Value, len(tags))
|
||||||
for i, tag := range tags {
|
for i, tag := range tags {
|
||||||
@@ -102,7 +103,7 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
|||||||
|
|
||||||
func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
||||||
loader.Set("cronAdd", func(jobId, cronExpr, handler string) {
|
loader.Set("cronAdd", func(jobId, cronExpr, handler string) {
|
||||||
pr := goja.MustCompile("", "{("+handler+").apply(undefined)}", true)
|
pr := goja.MustCompile(defaultScriptPath, "{("+handler+").apply(undefined)}", true)
|
||||||
|
|
||||||
err := app.Cron().Add(jobId, cronExpr, func() {
|
err := app.Cron().Add(jobId, cronExpr, func() {
|
||||||
err := executors.run(func(executor *goja.Runtime) error {
|
err := executors.run(func(executor *goja.Runtime) error {
|
||||||
@@ -188,7 +189,7 @@ func wrapHandlerFunc(executors *vmsPool, handler goja.Value) (func(*core.Request
|
|||||||
// "native" handler func - no need to wrap
|
// "native" handler func - no need to wrap
|
||||||
return h, nil
|
return h, nil
|
||||||
case func(goja.FunctionCall) goja.Value, string:
|
case func(goja.FunctionCall) goja.Value, string:
|
||||||
pr := goja.MustCompile("", "{("+handler.String()+").apply(undefined, __args)}", true)
|
pr := goja.MustCompile(defaultScriptPath, "{("+handler.String()+").apply(undefined, __args)}", true)
|
||||||
|
|
||||||
wrappedHandler := func(e *core.RequestEvent) error {
|
wrappedHandler := func(e *core.RequestEvent) error {
|
||||||
return executors.run(func(executor *goja.Runtime) error {
|
return executors.run(func(executor *goja.Runtime) error {
|
||||||
@@ -242,7 +243,7 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
|
|||||||
return nil, errors.New("missing or invalid Middleware function")
|
return nil, errors.New("missing or invalid Middleware function")
|
||||||
}
|
}
|
||||||
|
|
||||||
pr := goja.MustCompile("", "{("+v.serializedFunc+").apply(undefined, __args)}", true)
|
pr := goja.MustCompile(defaultScriptPath, "{("+v.serializedFunc+").apply(undefined, __args)}", true)
|
||||||
|
|
||||||
wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
|
wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
|
||||||
Id: v.id,
|
Id: v.id,
|
||||||
@@ -266,7 +267,7 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
case func(goja.FunctionCall) goja.Value, string:
|
case func(goja.FunctionCall) goja.Value, string:
|
||||||
pr := goja.MustCompile("", "{("+m.String()+").apply(undefined, __args)}", true)
|
pr := goja.MustCompile(defaultScriptPath, "{("+m.String()+").apply(undefined, __args)}", true)
|
||||||
|
|
||||||
wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
|
wrappedMiddlewares[i] = &hook.Handler[*core.RequestEvent]{
|
||||||
Func: func(e *core.RequestEvent) error {
|
Func: func(e *core.RequestEvent) error {
|
||||||
@@ -295,6 +296,8 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
|
|||||||
return wrappedMiddlewares, nil
|
return wrappedMiddlewares, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cachedArrayOfTypes = store.New[reflect.Type, reflect.Type](nil)
|
||||||
|
|
||||||
func baseBinds(vm *goja.Runtime) {
|
func baseBinds(vm *goja.Runtime) {
|
||||||
vm.SetFieldNameMapper(FieldMapper{})
|
vm.SetFieldNameMapper(FieldMapper{})
|
||||||
|
|
||||||
@@ -348,10 +351,11 @@ func baseBinds(vm *goja.Runtime) {
|
|||||||
|
|
||||||
vm.Set("arrayOf", func(model any) any {
|
vm.Set("arrayOf", func(model any) any {
|
||||||
mt := reflect.TypeOf(model)
|
mt := reflect.TypeOf(model)
|
||||||
st := reflect.SliceOf(mt)
|
st := cachedArrayOfTypes.GetOrSet(mt, func() reflect.Type {
|
||||||
elem := reflect.New(st).Elem()
|
return reflect.SliceOf(mt)
|
||||||
|
})
|
||||||
|
|
||||||
return elem.Addr().Interface()
|
return reflect.New(st).Elem().Addr().Interface()
|
||||||
})
|
})
|
||||||
|
|
||||||
vm.Set("unmarshal", func(data, dst any) error {
|
vm.Set("unmarshal", func(data, dst any) error {
|
||||||
@@ -534,6 +538,21 @@ func baseBinds(vm *goja.Runtime) {
|
|||||||
return instanceValue
|
return instanceValue
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// note: named Timezone to avoid conflicts with the JS Location interface.
|
||||||
|
vm.Set("Timezone", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
name, _ := call.Argument(0).Export().(string)
|
||||||
|
|
||||||
|
instance, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
instance = time.UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||||
|
instanceValue.SetPrototype(call.This.Prototype())
|
||||||
|
|
||||||
|
return instanceValue
|
||||||
|
})
|
||||||
|
|
||||||
vm.Set("DateTime", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("DateTime", func(call goja.ConstructorCall) *goja.Object {
|
||||||
instance := types.NowDateTime()
|
instance := types.NowDateTime()
|
||||||
|
|
||||||
@@ -899,12 +918,42 @@ func httpClientBinds(vm *goja.Runtime) {
|
|||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// normalizeException checks if the provided error is a goja.Exception
|
||||||
|
// and attempts to return its underlying Go error.
|
||||||
|
//
|
||||||
|
// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil.
|
||||||
|
func normalizeException(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jsException, ok := err.(*goja.Exception)
|
||||||
|
if !ok {
|
||||||
|
return err // no exception
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := jsException.Value().Export().(type) {
|
||||||
|
case error:
|
||||||
|
err = v
|
||||||
|
case map[string]any: // goja.GoError
|
||||||
|
if vErr, ok := v["value"].(error); ok {
|
||||||
|
err = vErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cachedFactoryFuncTypes = store.New[string, reflect.Type](nil)
|
||||||
|
|
||||||
// registerFactoryAsConstructor registers the factory function as native JS constructor.
|
// registerFactoryAsConstructor registers the factory function as native JS constructor.
|
||||||
//
|
//
|
||||||
// If there is missing or nil arguments, their type zero value is used.
|
// If there is missing or nil arguments, their type zero value is used.
|
||||||
func registerFactoryAsConstructor(vm *goja.Runtime, constructorName string, factoryFunc any) {
|
func registerFactoryAsConstructor(vm *goja.Runtime, constructorName string, factoryFunc any) {
|
||||||
rv := reflect.ValueOf(factoryFunc)
|
rv := reflect.ValueOf(factoryFunc)
|
||||||
rt := reflect.TypeOf(factoryFunc)
|
rt := cachedFactoryFuncTypes.GetOrSet(constructorName, func() reflect.Type {
|
||||||
|
return reflect.TypeOf(factoryFunc)
|
||||||
|
})
|
||||||
totalArgs := rt.NumIn()
|
totalArgs := rt.NumIn()
|
||||||
|
|
||||||
vm.Set(constructorName, func(call goja.ConstructorCall) *goja.Object {
|
vm.Set(constructorName, func(call goja.ConstructorCall) *goja.Object {
|
||||||
@@ -980,7 +1029,31 @@ func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, ins
|
|||||||
// "total": 0,
|
// "total": 0,
|
||||||
// })
|
// })
|
||||||
func newDynamicModel(shape map[string]any) any {
|
func newDynamicModel(shape map[string]any) any {
|
||||||
shapeValues := make([]reflect.Value, 0, len(shape))
|
modelType := getDynamicModelStruct(shape)
|
||||||
|
|
||||||
|
rvShapeValues := make([]reflect.Value, len(modelType.shapeValues))
|
||||||
|
for i, v := range modelType.shapeValues {
|
||||||
|
rvShapeValues[i] = reflect.ValueOf(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
elem := reflect.New(modelType.structType).Elem()
|
||||||
|
|
||||||
|
for i, v := range rvShapeValues {
|
||||||
|
elem.Field(i).Set(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return elem.Addr().Interface()
|
||||||
|
}
|
||||||
|
|
||||||
|
type dynamicModelType struct {
|
||||||
|
structType reflect.Type
|
||||||
|
shapeValues []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDynamicModelStruct(shape map[string]any) *dynamicModelType {
|
||||||
|
result := new(dynamicModelType)
|
||||||
|
result.shapeValues = make([]any, 0, len(shape))
|
||||||
|
|
||||||
structFields := make([]reflect.StructField, 0, len(shape))
|
structFields := make([]reflect.StructField, 0, len(shape))
|
||||||
|
|
||||||
for k, v := range shape {
|
for k, v := range shape {
|
||||||
@@ -1001,7 +1074,7 @@ func newDynamicModel(shape map[string]any) any {
|
|||||||
vt = reflect.TypeOf(newV)
|
vt = reflect.TypeOf(newV)
|
||||||
}
|
}
|
||||||
|
|
||||||
shapeValues = append(shapeValues, reflect.ValueOf(v))
|
result.shapeValues = append(result.shapeValues, v)
|
||||||
|
|
||||||
structFields = append(structFields, reflect.StructField{
|
structFields = append(structFields, reflect.StructField{
|
||||||
Name: inflector.UcFirst(k), // ensures that the field is exportable
|
Name: inflector.UcFirst(k), // ensures that the field is exportable
|
||||||
@@ -1010,38 +1083,7 @@ func newDynamicModel(shape map[string]any) any {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
st := reflect.StructOf(structFields)
|
result.structType = reflect.StructOf(structFields)
|
||||||
elem := reflect.New(st).Elem()
|
|
||||||
|
|
||||||
for i, v := range shapeValues {
|
return result
|
||||||
elem.Field(i).Set(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return elem.Addr().Interface()
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeException checks if the provided error is a goja.Exception
|
|
||||||
// and attempts to return its underlying Go error.
|
|
||||||
//
|
|
||||||
// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil.
|
|
||||||
func normalizeException(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jsException, ok := err.(*goja.Exception)
|
|
||||||
if !ok {
|
|
||||||
return err // no exception
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := jsException.Value().Export().(type) {
|
|
||||||
case error:
|
|
||||||
err = v
|
|
||||||
case map[string]any: // goja.GoError
|
|
||||||
if vErr, ok := v["value"].(error); ok {
|
|
||||||
err = vErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ func TestBaseBindsCount(t *testing.T) {
|
|||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
baseBinds(vm)
|
baseBinds(vm)
|
||||||
|
|
||||||
testBindsCount(vm, "this", 32, t)
|
testBindsCount(vm, "this", 33, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseBindsSleep(t *testing.T) {
|
func TestBaseBindsSleep(t *testing.T) {
|
||||||
@@ -464,7 +464,7 @@ func TestBaseBindsMailerMessage(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":null}`
|
expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":null,"inlineAttachments":null}`
|
||||||
|
|
||||||
if string(raw) != expected {
|
if string(raw) != expected {
|
||||||
t.Fatalf("Expected \n%s, \ngot \n%s", expected, raw)
|
t.Fatalf("Expected \n%s, \ngot \n%s", expected, raw)
|
||||||
@@ -538,6 +538,31 @@ func TestBaseBindsMiddleware(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBaseBindsTimezone(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
baseBinds(vm)
|
||||||
|
|
||||||
|
_, err := vm.RunString(`
|
||||||
|
const v0 = (new Timezone()).string();
|
||||||
|
if (v0 != "UTC") {
|
||||||
|
throw new Error("(v0) Expected UTC got " + v0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const v1 = (new Timezone("invalid")).string();
|
||||||
|
if (v1 != "UTC") {
|
||||||
|
throw new Error("(v1) Expected UTC got " + v1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const v2 = (new Timezone("EET")).string();
|
||||||
|
if (v2 != "EET") {
|
||||||
|
throw new Error("(v2) Expected EET got " + v2)
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBaseBindsDateTime(t *testing.T) {
|
func TestBaseBindsDateTime(t *testing.T) {
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
baseBinds(vm)
|
baseBinds(vm)
|
||||||
@@ -1112,6 +1137,46 @@ func TestLoadingDynamicModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @todo revert the reflect caching and check other types
|
||||||
|
func TestDynamicModelMapFieldCaching(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
vm := goja.New()
|
||||||
|
baseBinds(vm)
|
||||||
|
dbxBinds(vm)
|
||||||
|
vm.Set("$app", app)
|
||||||
|
|
||||||
|
_, err := vm.RunString(`
|
||||||
|
let m1 = new DynamicModel({
|
||||||
|
obj: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
let m2 = new DynamicModel({
|
||||||
|
obj: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
m1.obj.set("a", 1)
|
||||||
|
|
||||||
|
m2.obj.set("b", 1)
|
||||||
|
|
||||||
|
let m1Expected = '{"obj":{"a":1}}';
|
||||||
|
let m1Serialized = JSON.stringify(m1);
|
||||||
|
if (m1Serialized != m1Expected) {
|
||||||
|
throw new Error("Expected m1 \n" + m1Expected + "\ngot\n" + m1Serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let m2Expected = '{"obj":{"b":1}}';
|
||||||
|
let m2Serialized = JSON.stringify(m2);
|
||||||
|
if (m2Serialized != m2Expected) {
|
||||||
|
throw new Error("Expected m2 \n" + m2Expected + "\ngot\n" + m2Serialized);
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadingArrayOf(t *testing.T) {
|
func TestLoadingArrayOf(t *testing.T) {
|
||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
defer app.Cleanup()
|
||||||
|
|||||||
+4777
-4863
File diff suppressed because it is too large.
Load diff
@@ -579,6 +579,32 @@ declare class Middleware {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Timezone extends time.Location{} // merge
|
||||||
|
/**
|
||||||
|
* Timezone returns the timezone location with the given name.
|
||||||
|
*
|
||||||
|
* The name is expected to be a location name corresponding to a file
|
||||||
|
* in the IANA Time Zone database, such as "America/New_York".
|
||||||
|
*
|
||||||
|
* If the name is "Local", LoadLocation returns Local.
|
||||||
|
*
|
||||||
|
* If the name is "", invalid or "UTC", returns UTC.
|
||||||
|
*
|
||||||
|
* The constructor is equivalent to calling the Go ` + "`" + `time.LoadLocation(name)` + "`" + ` method.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ` + "```" + `js
|
||||||
|
* const zone = new Timezone("America/New_York")
|
||||||
|
* $app.cron().setTimezone(zone)
|
||||||
|
* ` + "```" + `
|
||||||
|
*
|
||||||
|
* @group PocketBase
|
||||||
|
*/
|
||||||
|
declare class Timezone implements time.Location {
|
||||||
|
constructor(name?: string)
|
||||||
|
}
|
||||||
|
|
||||||
interface DateTime extends types.DateTime{} // merge
|
interface DateTime extends types.DateTime{} // merge
|
||||||
/**
|
/**
|
||||||
* DateTime defines a single DateTime type instance.
|
* DateTime defines a single DateTime type instance.
|
||||||
|
|||||||
+21
-3
@@ -35,6 +35,24 @@ import (
|
|||||||
|
|
||||||
const typesFileName = "types.d.ts"
|
const typesFileName = "types.d.ts"
|
||||||
|
|
||||||
|
var defaultScriptPath = "pb.js"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// For backward compatibility and consistency with the Go exposed
|
||||||
|
// methods that operate with relative paths (e.g. `$os.writeFile`),
|
||||||
|
// we define the "current JS module" as if it is a file in the current working directory
|
||||||
|
// (the filename itself doesn't really matter and in our case the hook handlers are executed as separate "programs").
|
||||||
|
//
|
||||||
|
// This is necessary for `require(module)` to properly traverse parents node_modules (goja_nodejs#95).
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
// truly rare case, log just for debug purposes
|
||||||
|
color.Yellow("Failed to retrieve the current working directory: %v", err)
|
||||||
|
} else {
|
||||||
|
defaultScriptPath = filepath.Join(cwd, defaultScriptPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Config defines the config options of the jsvm plugin.
|
// Config defines the config options of the jsvm plugin.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// OnInit is an optional function that will be called
|
// OnInit is an optional function that will be called
|
||||||
@@ -191,7 +209,7 @@ func (p *plugin) registerMigrations() error {
|
|||||||
p.config.OnInit(vm)
|
p.config.OnInit(vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := vm.RunString(string(content))
|
_, err := vm.RunScript(defaultScriptPath, string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to run migration %s: %w", file, err)
|
return fmt.Errorf("failed to run migration %s: %w", file, err)
|
||||||
}
|
}
|
||||||
@@ -306,7 +324,7 @@ func (p *plugin) registerHooks() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := loader.RunString(string(content))
|
_, err := loader.RunScript(defaultScriptPath, string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -389,7 +407,7 @@ func (p *plugin) watchHooks() error {
|
|||||||
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
|
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
|
||||||
// app restart is currently not supported on Windows
|
// app restart is currently not supported on Windows
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
color.Yellow("File %s changed, please restart the app", event.Name)
|
color.Yellow("File %s changed, please restart the app manually", event.Name)
|
||||||
} else {
|
} else {
|
||||||
color.Yellow("File %s changed, restarting...", event.Name)
|
color.Yellow("File %s changed, restarting...", event.Name)
|
||||||
if err := p.app.Restart(); err != nil {
|
if err := p.app.Restart(); err != nil {
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,11 +156,7 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv
|
|||||||
resultFilePath := path.Join(dir, filename)
|
resultFilePath := path.Join(dir, filename)
|
||||||
|
|
||||||
if interactive {
|
if interactive {
|
||||||
confirm := false
|
confirm := osutils.YesNoPrompt(fmt.Sprintf("Do you really want to create migration %q?", resultFilePath), false)
|
||||||
prompt := &survey.Confirm{
|
|
||||||
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
|
|
||||||
}
|
|
||||||
survey.AskOne(prompt, &confirm)
|
|
||||||
if !confirm {
|
if !confirm {
|
||||||
fmt.Println("The command has been cancelled")
|
fmt.Println("The command has been cancelled")
|
||||||
return "", nil
|
return "", nil
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import (
|
|||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/pocketbase/pocketbase/cmd"
|
"github.com/pocketbase/pocketbase/cmd"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/routine"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
_ "github.com/pocketbase/pocketbase/migrations"
|
_ "github.com/pocketbase/pocketbase/migrations"
|
||||||
@@ -137,6 +139,24 @@ func NewWithConfig(config Config) *PocketBase {
|
|||||||
// hide the default help command (allow only `--help` flag)
|
// hide the default help command (allow only `--help` flag)
|
||||||
pb.RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
|
pb.RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
|
||||||
|
|
||||||
|
// https://github.com/pocketbase/pocketbase/issues/6136
|
||||||
|
pb.OnBootstrap().Bind(&hook.Handler[*core.BootstrapEvent]{
|
||||||
|
Id: ModerncDepsCheckHookId,
|
||||||
|
Func: func(be *core.BootstrapEvent) error {
|
||||||
|
if err := be.Next(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// run separately to avoid blocking
|
||||||
|
app := be.App
|
||||||
|
routine.FireAndForget(func() {
|
||||||
|
checkModerncDeps(app)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return pb
|
return pb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-12
@@ -70,27 +70,43 @@ func (t *TestApp) registerEventCall(name string) {
|
|||||||
|
|
||||||
// NewTestApp creates and initializes a test application instance.
|
// NewTestApp creates and initializes a test application instance.
|
||||||
//
|
//
|
||||||
// It is the caller's responsibility to call `app.Cleanup()`
|
// It is the caller's responsibility to call app.Cleanup() when the app is no longer needed.
|
||||||
// when the app is no longer needed.
|
|
||||||
func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
|
func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
|
||||||
var testDataDir string
|
var testDataDir string
|
||||||
if len(optTestDataDir) == 0 || optTestDataDir[0] == "" {
|
if len(optTestDataDir) > 0 {
|
||||||
// fallback to the default test data directory
|
|
||||||
_, currentFile, _, _ := runtime.Caller(0)
|
|
||||||
testDataDir = filepath.Join(path.Dir(currentFile), "data")
|
|
||||||
} else {
|
|
||||||
testDataDir = optTestDataDir[0]
|
testDataDir = optTestDataDir[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
tempDir, err := TempDirClone(testDataDir)
|
return NewTestAppWithConfig(core.BaseAppConfig{
|
||||||
|
DataDir: testDataDir,
|
||||||
|
EncryptionEnv: "pb_test_env",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestAppWithConfig creates and initializes a test application instance
|
||||||
|
// from the provided config.
|
||||||
|
//
|
||||||
|
// If config.DataDir is not set it fallbacks to the default internal test data directory.
|
||||||
|
//
|
||||||
|
// config.DataDir is cloned for each new test application instance.
|
||||||
|
//
|
||||||
|
// It is the caller's responsibility to call app.Cleanup() when the app is no longer needed.
|
||||||
|
func NewTestAppWithConfig(config core.BaseAppConfig) (*TestApp, error) {
|
||||||
|
if config.DataDir == "" {
|
||||||
|
// fallback to the default test data directory
|
||||||
|
_, currentFile, _, _ := runtime.Caller(0)
|
||||||
|
config.DataDir = filepath.Join(path.Dir(currentFile), "data")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir, err := TempDirClone(config.DataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
app := core.NewBaseApp(core.BaseAppConfig{
|
// replace with the clone
|
||||||
DataDir: tempDir,
|
config.DataDir = tempDir
|
||||||
EncryptionEnv: "pb_test_env",
|
|
||||||
})
|
app := core.NewBaseApp(config)
|
||||||
|
|
||||||
// load data dir and db connections
|
// load data dir and db connections
|
||||||
if err := app.Bootstrap(); err != nil {
|
if err := app.Bootstrap(); err != nil {
|
||||||
|
|||||||
+9
-10
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
@@ -138,19 +138,18 @@ func (p *Apple) parseAndVerifyIdToken(idToken string) (jwt.MapClaims, error) {
|
|||||||
|
|
||||||
// validate common claims per https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user#3383769
|
// validate common claims per https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user#3383769
|
||||||
// ---
|
// ---
|
||||||
err = claims.Valid() // exp, iat, etc.
|
jwtValidator := jwt.NewValidator(
|
||||||
|
jwt.WithExpirationRequired(),
|
||||||
|
jwt.WithIssuedAt(),
|
||||||
|
jwt.WithLeeway(idTokenLeeway),
|
||||||
|
jwt.WithIssuer("https://appleid.apple.com"),
|
||||||
|
jwt.WithAudience(p.clientId),
|
||||||
|
)
|
||||||
|
err = jwtValidator.Validate(claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !claims.VerifyIssuer("https://appleid.apple.com", true) {
|
|
||||||
return nil, errors.New("iss must be https://appleid.apple.com")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !claims.VerifyAudience(p.clientId, true) {
|
|
||||||
return nil, errors.New("aud must be the developer's client_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate id_token signature
|
// validate id_token signature
|
||||||
//
|
//
|
||||||
// note: this step could be technically considered optional because we trust
|
// note: this step could be technically considered optional because we trust
|
||||||
|
|||||||
+10
-1
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestProvidersCount(t *testing.T) {
|
func TestProvidersCount(t *testing.T) {
|
||||||
expected := 29
|
expected := 30
|
||||||
|
|
||||||
if total := len(auth.Providers); total != expected {
|
if total := len(auth.Providers); total != expected {
|
||||||
t.Fatalf("Expected %d providers, got %d", expected, total)
|
t.Fatalf("Expected %d providers, got %d", expected, total)
|
||||||
@@ -287,4 +287,13 @@ func TestNewProviderByName(t *testing.T) {
|
|||||||
if _, ok := p.(*auth.Linear); !ok {
|
if _, ok := p.(*auth.Linear); !ok {
|
||||||
t.Error("Expected to be instance of *auth.Linear")
|
t.Error("Expected to be instance of *auth.Linear")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trakt
|
||||||
|
p, err = auth.NewProviderByName(auth.NameTrakt)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil, got error %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := p.(*auth.Trakt); !ok {
|
||||||
|
t.Error("Expected to be instance of *auth.Trakt")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -51,18 +51,18 @@ func (p *Gitea) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extracted := struct {
|
extracted := struct {
|
||||||
Id int `json:"id"`
|
|
||||||
Name string `json:"full_name"`
|
Name string `json:"full_name"`
|
||||||
Username string `json:"login"`
|
Username string `json:"login"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Id int64 `json:"id"`
|
||||||
}{}
|
}{}
|
||||||
if err := json.Unmarshal(data, &extracted); err != nil {
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &AuthUser{
|
user := &AuthUser{
|
||||||
Id: strconv.Itoa(extracted.Id),
|
Id: strconv.FormatInt(extracted.Id, 10),
|
||||||
Name: extracted.Name,
|
Name: extracted.Name,
|
||||||
Username: extracted.Username,
|
Username: extracted.Username,
|
||||||
Email: extracted.Email,
|
Email: extracted.Email,
|
||||||
|
|||||||
+2
-2
@@ -54,17 +54,17 @@ func (p *Gitee) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
|
|
||||||
extracted := struct {
|
extracted := struct {
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
Id int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Id int64 `json:"id"`
|
||||||
}{}
|
}{}
|
||||||
if err := json.Unmarshal(data, &extracted); err != nil {
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &AuthUser{
|
user := &AuthUser{
|
||||||
Id: strconv.Itoa(extracted.Id),
|
Id: strconv.FormatInt(extracted.Id, 10),
|
||||||
Name: extracted.Name,
|
Name: extracted.Name,
|
||||||
Username: extracted.Login,
|
Username: extracted.Login,
|
||||||
AvatarURL: extracted.AvatarURL,
|
AvatarURL: extracted.AvatarURL,
|
||||||
|
|||||||
@@ -54,17 +54,17 @@ func (p *Github) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
|
|
||||||
extracted := struct {
|
extracted := struct {
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
Id int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Id int64 `json:"id"`
|
||||||
}{}
|
}{}
|
||||||
if err := json.Unmarshal(data, &extracted); err != nil {
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &AuthUser{
|
user := &AuthUser{
|
||||||
Id: strconv.Itoa(extracted.Id),
|
Id: strconv.FormatInt(extracted.Id, 10),
|
||||||
Name: extracted.Name,
|
Name: extracted.Name,
|
||||||
Username: extracted.Login,
|
Username: extracted.Login,
|
||||||
Email: extracted.Email,
|
Email: extracted.Email,
|
||||||
|
|||||||
@@ -51,18 +51,18 @@ func (p *Gitlab) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extracted := struct {
|
extracted := struct {
|
||||||
Id int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Id int64 `json:"id"`
|
||||||
}{}
|
}{}
|
||||||
if err := json.Unmarshal(data, &extracted); err != nil {
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &AuthUser{
|
user := &AuthUser{
|
||||||
Id: strconv.Itoa(extracted.Id),
|
Id: strconv.FormatInt(extracted.Id, 10),
|
||||||
Name: extracted.Name,
|
Name: extracted.Name,
|
||||||
Username: extracted.Username,
|
Username: extracted.Username,
|
||||||
Email: extracted.Email,
|
Email: extracted.Email,
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ func NewGoogleProvider() *Google {
|
|||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
},
|
},
|
||||||
authURL: "https://accounts.google.com/o/oauth2/auth",
|
authURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
tokenURL: "https://accounts.google.com/o/oauth2/token",
|
tokenURL: "https://oauth2.googleapis.com/token",
|
||||||
userInfoURL: "https://www.googleapis.com/oauth2/v1/userinfo",
|
userInfoURL: "https://www.googleapis.com/oauth2/v3/userinfo",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +51,11 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extracted := struct {
|
extracted := struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"sub"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
VerifiedEmail bool `json:"verified_email"`
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
}{}
|
}{}
|
||||||
if err := json.Unmarshal(data, &extracted); err != nil {
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -72,7 +72,7 @@ func (p *Google) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
|
|
||||||
user.Expiry, _ = types.ParseDateTime(token.Expiry)
|
user.Expiry, _ = types.ParseDateTime(token.Expiry)
|
||||||
|
|
||||||
if extracted.VerifiedEmail {
|
if extracted.EmailVerified {
|
||||||
user.Email = extracted.Email
|
user.Email = extracted.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -52,7 +52,6 @@ func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extracted := struct {
|
extracted := struct {
|
||||||
Id int `json:"id"`
|
|
||||||
Profile struct {
|
Profile struct {
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
ImageURL string `json:"profile_image"`
|
ImageURL string `json:"profile_image"`
|
||||||
@@ -62,13 +61,14 @@ func (p *Kakao) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
IsEmailVerified bool `json:"is_email_verified"`
|
IsEmailVerified bool `json:"is_email_verified"`
|
||||||
IsEmailValid bool `json:"is_email_valid"`
|
IsEmailValid bool `json:"is_email_valid"`
|
||||||
} `json:"kakao_account"`
|
} `json:"kakao_account"`
|
||||||
|
Id int64 `json:"id"`
|
||||||
}{}
|
}{}
|
||||||
if err := json.Unmarshal(data, &extracted); err != nil {
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &AuthUser{
|
user := &AuthUser{
|
||||||
Id: strconv.Itoa(extracted.Id),
|
Id: strconv.FormatInt(extracted.Id, 10),
|
||||||
Username: extracted.Profile.Nickname,
|
Username: extracted.Profile.Nickname,
|
||||||
AvatarURL: extracted.Profile.ImageURL,
|
AvatarURL: extracted.Profile.ImageURL,
|
||||||
RawUser: rawUser,
|
RawUser: rawUser,
|
||||||
|
|||||||
+27
-9
@@ -10,18 +10,35 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// idTokenLeeway is the optional leeway for the id_token timestamp fields validation.
|
||||||
|
//
|
||||||
|
// It can be changed externally using the PB_ID_TOKEN_LEEWAY env variable
|
||||||
|
// (the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute).
|
||||||
|
var idTokenLeeway time.Duration = 5 * time.Minute
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
Providers[NameOIDC] = wrapFactory(NewOIDCProvider)
|
Providers[NameOIDC] = wrapFactory(NewOIDCProvider)
|
||||||
Providers[NameOIDC+"2"] = wrapFactory(NewOIDCProvider)
|
Providers[NameOIDC+"2"] = wrapFactory(NewOIDCProvider)
|
||||||
Providers[NameOIDC+"3"] = wrapFactory(NewOIDCProvider)
|
Providers[NameOIDC+"3"] = wrapFactory(NewOIDCProvider)
|
||||||
|
|
||||||
|
if leewayStr := os.Getenv("PB_ID_TOKEN_LEEWAY"); leewayStr != "" {
|
||||||
|
leeway, err := strconv.Atoi(leewayStr)
|
||||||
|
if err == nil {
|
||||||
|
idTokenLeeway = time.Duration(leeway) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Provider = (*OIDC)(nil)
|
var _ Provider = (*OIDC)(nil)
|
||||||
@@ -128,24 +145,25 @@ func (p *OIDC) parseIdToken(token *oauth2.Token) (jwt.MapClaims, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate common claims like exp, iat, etc.
|
// validate common claims
|
||||||
err = claims.Valid()
|
jwtValidator := jwt.NewValidator(
|
||||||
|
jwt.WithIssuedAt(),
|
||||||
|
jwt.WithLeeway(idTokenLeeway),
|
||||||
|
jwt.WithAudience(p.clientId),
|
||||||
|
)
|
||||||
|
err = jwtValidator.Validate(claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate aud
|
|
||||||
if !claims.VerifyAudience(p.clientId, true) {
|
|
||||||
return nil, errors.New("aud must be the developer's client_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate iss (if "issuers" extra config is set)
|
// validate iss (if "issuers" extra config is set)
|
||||||
issuers := cast.ToStringSlice(p.Extra()["issuers"])
|
issuers := cast.ToStringSlice(p.Extra()["issuers"])
|
||||||
if len(issuers) > 0 {
|
if len(issuers) > 0 {
|
||||||
var isIssValid bool
|
var isIssValid bool
|
||||||
|
claimIssuer, _ := claims.GetIssuer()
|
||||||
|
|
||||||
for _, issuer := range issuers {
|
for _, issuer := range issuers {
|
||||||
if claims.VerifyIssuer(issuer, true) {
|
if security.Equal(claimIssuer, issuer) {
|
||||||
isIssValid = true
|
isIssValid = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extracted := struct {
|
extracted := struct {
|
||||||
Id int `json:"id"`
|
Id int64 `json:"id"`
|
||||||
FirstName string `json:"firstname"`
|
FirstName string `json:"firstname"`
|
||||||
LastName string `json:"lastname"`
|
LastName string `json:"lastname"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -78,7 +78,7 @@ func (p *Strava) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
user.Expiry, _ = types.ParseDateTime(token.Expiry)
|
user.Expiry, _ = types.ParseDateTime(token.Expiry)
|
||||||
|
|
||||||
if extracted.Id != 0 {
|
if extracted.Id != 0 {
|
||||||
user.Id = strconv.Itoa(extracted.Id)
|
user.Id = strconv.FormatInt(extracted.Id, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Providers[NameTrakt] = wrapFactory(NewTraktProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Provider = (*Trakt)(nil)
|
||||||
|
|
||||||
|
// NameTrakt is the unique name of the Trakt provider.
|
||||||
|
const NameTrakt string = "trakt"
|
||||||
|
|
||||||
|
// Trakt allows authentication via Trakt OAuth2.
|
||||||
|
type Trakt struct {
|
||||||
|
BaseProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTraktProvider creates new Trakt provider instance with some defaults.
|
||||||
|
func NewTraktProvider() *Trakt {
|
||||||
|
return &Trakt{BaseProvider{
|
||||||
|
ctx: context.Background(),
|
||||||
|
displayName: "Trakt",
|
||||||
|
pkce: true,
|
||||||
|
authURL: "https://trakt.tv/oauth/authorize",
|
||||||
|
tokenURL: "https://api.trakt.tv/oauth/token",
|
||||||
|
userInfoURL: "https://api.trakt.tv/users/settings",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAuthUser returns an AuthUser instance based on Trakt's user settings API.
|
||||||
|
// API reference: https://trakt.docs.apiary.io/#reference/users/settings/retrieve-settings
|
||||||
|
func (p *Trakt) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
||||||
|
data, err := p.FetchRawUserInfo(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawUser := map[string]any{}
|
||||||
|
if err := json.Unmarshal(data, &rawUser); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extracted := struct {
|
||||||
|
User struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Ids struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
} `json:"ids"`
|
||||||
|
Images struct {
|
||||||
|
Avatar struct {
|
||||||
|
Full string `json:"full"`
|
||||||
|
} `json:"avatar"`
|
||||||
|
} `json:"images"`
|
||||||
|
} `json:"user"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &AuthUser{
|
||||||
|
Id: extracted.User.Ids.UUID,
|
||||||
|
Username: extracted.User.Username,
|
||||||
|
Name: extracted.User.Name,
|
||||||
|
AvatarURL: extracted.User.Images.Avatar.Full,
|
||||||
|
RawUser: rawUser,
|
||||||
|
AccessToken: token.AccessToken,
|
||||||
|
RefreshToken: token.RefreshToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Expiry, _ = types.ParseDateTime(token.Expiry)
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method.
|
||||||
|
//
|
||||||
|
// This differ from BaseProvider because Trakt requires a number of
|
||||||
|
// mandatory headers for all requests
|
||||||
|
// (https://trakt.docs.apiary.io/#introduction/required-headers).
|
||||||
|
func (p *Trakt) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(p.ctx, "GET", p.userInfoURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-type", "application/json")
|
||||||
|
req.Header.Set("trakt-api-key", p.clientId)
|
||||||
|
req.Header.Set("trakt-api-version", "2")
|
||||||
|
|
||||||
|
return p.sendRawUserInfoRequest(req, token)
|
||||||
|
}
|
||||||
+2
-2
@@ -58,7 +58,7 @@ func (p *VK) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
|
|
||||||
extracted := struct {
|
extracted := struct {
|
||||||
Response []struct {
|
Response []struct {
|
||||||
Id int `json:"id"`
|
Id int64 `json:"id"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Username string `json:"screen_name"`
|
Username string `json:"screen_name"`
|
||||||
@@ -75,7 +75,7 @@ func (p *VK) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := &AuthUser{
|
user := &AuthUser{
|
||||||
Id: strconv.Itoa(extracted.Response[0].Id),
|
Id: strconv.FormatInt(extracted.Response[0].Id, 10),
|
||||||
Name: strings.TrimSpace(extracted.Response[0].FirstName + " " + extracted.Response[0].LastName),
|
Name: strings.TrimSpace(extracted.Response[0].FirstName + " " + extracted.Response[0].LastName),
|
||||||
Username: extracted.Response[0].Username,
|
Username: extracted.Response[0].Username,
|
||||||
AvatarURL: extracted.Response[0].AvatarURL,
|
AvatarURL: extracted.Response[0].AvatarURL,
|
||||||
|
|||||||
+41
-19
@@ -11,22 +11,18 @@ package cron
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type job struct {
|
|
||||||
schedule *Schedule
|
|
||||||
run func()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cron is a crontab-like struct for tasks/jobs scheduling.
|
// Cron is a crontab-like struct for tasks/jobs scheduling.
|
||||||
type Cron struct {
|
type Cron struct {
|
||||||
timezone *time.Location
|
timezone *time.Location
|
||||||
ticker *time.Ticker
|
ticker *time.Ticker
|
||||||
startTimer *time.Timer
|
startTimer *time.Timer
|
||||||
jobs map[string]*job
|
|
||||||
tickerDone chan bool
|
tickerDone chan bool
|
||||||
|
jobs []*Job
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
mux sync.RWMutex
|
mux sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -40,7 +36,7 @@ func New() *Cron {
|
|||||||
return &Cron{
|
return &Cron{
|
||||||
interval: 1 * time.Minute,
|
interval: 1 * time.Minute,
|
||||||
timezone: time.UTC,
|
timezone: time.UTC,
|
||||||
jobs: map[string]*job{},
|
jobs: []*Job{},
|
||||||
tickerDone: make(chan bool),
|
tickerDone: make(chan bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,23 +78,30 @@ func (c *Cron) MustAdd(jobId string, cronExpr string, run func()) {
|
|||||||
//
|
//
|
||||||
// cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour).
|
// cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour).
|
||||||
// Check cron.NewSchedule() for the supported tokens.
|
// Check cron.NewSchedule() for the supported tokens.
|
||||||
func (c *Cron) Add(jobId string, cronExpr string, run func()) error {
|
func (c *Cron) Add(jobId string, cronExpr string, fn func()) error {
|
||||||
if run == nil {
|
if fn == nil {
|
||||||
return errors.New("failed to add new cron job: run must be non-nil function")
|
return errors.New("failed to add new cron job: fn must be non-nil function")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mux.Lock()
|
|
||||||
defer c.mux.Unlock()
|
|
||||||
|
|
||||||
schedule, err := NewSchedule(cronExpr)
|
schedule, err := NewSchedule(cronExpr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to add new cron job: %w", err)
|
return fmt.Errorf("failed to add new cron job: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.jobs[jobId] = &job{
|
c.mux.Lock()
|
||||||
|
defer c.mux.Unlock()
|
||||||
|
|
||||||
|
// remove previous (if any)
|
||||||
|
c.jobs = slices.DeleteFunc(c.jobs, func(j *Job) bool {
|
||||||
|
return j.Id() == jobId
|
||||||
|
})
|
||||||
|
|
||||||
|
// add new
|
||||||
|
c.jobs = append(c.jobs, &Job{
|
||||||
|
id: jobId,
|
||||||
|
fn: fn,
|
||||||
schedule: schedule,
|
schedule: schedule,
|
||||||
run: run,
|
})
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -108,7 +111,13 @@ func (c *Cron) Remove(jobId string) {
|
|||||||
c.mux.Lock()
|
c.mux.Lock()
|
||||||
defer c.mux.Unlock()
|
defer c.mux.Unlock()
|
||||||
|
|
||||||
delete(c.jobs, jobId)
|
if c.jobs == nil {
|
||||||
|
return // nothing to remove
|
||||||
|
}
|
||||||
|
|
||||||
|
c.jobs = slices.DeleteFunc(c.jobs, func(j *Job) bool {
|
||||||
|
return j.Id() == jobId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAll removes all registered cron jobs.
|
// RemoveAll removes all registered cron jobs.
|
||||||
@@ -116,7 +125,7 @@ func (c *Cron) RemoveAll() {
|
|||||||
c.mux.Lock()
|
c.mux.Lock()
|
||||||
defer c.mux.Unlock()
|
defer c.mux.Unlock()
|
||||||
|
|
||||||
c.jobs = map[string]*job{}
|
c.jobs = []*Job{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total returns the current total number of registered cron jobs.
|
// Total returns the current total number of registered cron jobs.
|
||||||
@@ -127,6 +136,19 @@ func (c *Cron) Total() int {
|
|||||||
return len(c.jobs)
|
return len(c.jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jobs returns a shallow copy of the currently registered cron jobs.
|
||||||
|
func (c *Cron) Jobs() []*Job {
|
||||||
|
c.mux.RLock()
|
||||||
|
defer c.mux.RUnlock()
|
||||||
|
|
||||||
|
copy := make([]*Job, len(c.jobs))
|
||||||
|
for i, j := range c.jobs {
|
||||||
|
copy[i] = j
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
// Stop stops the current cron ticker (if not already).
|
// Stop stops the current cron ticker (if not already).
|
||||||
//
|
//
|
||||||
// You can resume the ticker by calling Start().
|
// You can resume the ticker by calling Start().
|
||||||
@@ -200,7 +222,7 @@ func (c *Cron) runDue(t time.Time) {
|
|||||||
|
|
||||||
for _, j := range c.jobs {
|
for _, j := range c.jobs {
|
||||||
if j.schedule.IsDue(moment) {
|
if j.schedule.IsDue(moment) {
|
||||||
go j.run()
|
go j.Run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user