mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
288
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c054c3b0a5 | ||
|
|
3da16b2d74 | ||
|
|
dd5afd44d2 | ||
|
|
501c49012e | ||
|
|
48a9b82024 | ||
|
|
4155f50fe1 | ||
|
|
653f2d8b16 | ||
|
|
eb3129d1f3 | ||
|
|
4db497c5e1 | ||
|
|
973916bb48 | ||
|
|
d607695600 | ||
|
|
3f51fb941b | ||
|
|
4d40463d8d | ||
|
|
543b8f5e7a | ||
|
|
8e39998f63 | ||
|
|
e8a554b4ad | ||
|
|
5aa380927a | ||
|
|
aaa3d67659 | ||
|
|
5c41938cb9 | ||
|
|
f1d44c7847 | ||
|
|
c0b7762abd | ||
|
|
26f0df36bc | ||
|
|
6a7f3a21fb | ||
|
|
2e26f61cb7 | ||
|
|
59f98cac99 | ||
|
|
2a1fdc1613 | ||
|
|
f767af0ded | ||
|
|
920e893e11 | ||
|
|
048e534f0d | ||
|
|
9da7a8f72b | ||
|
|
9856c59de0 | ||
|
|
acd72101c6 | ||
|
|
fa343c37e6 | ||
|
|
d3aac570b2 | ||
|
|
b066d9c429 | ||
|
|
5d889d00ad | ||
|
|
49baa69e8d | ||
|
|
923b791675 | ||
|
|
4b489b511d | ||
|
|
9b901fcee8 | ||
|
|
c5bd42a23f | ||
|
|
1bc0b78c83 | ||
|
|
6f3abe7c2d | ||
|
|
33340a6977 | ||
|
|
c101798516 | ||
|
|
65440314ce | ||
|
|
91d4ca5c06 | ||
|
|
a4a228b368 | ||
|
|
0ebe9c4faa | ||
|
|
7da875be14 | ||
|
|
2124b77a2a | ||
|
|
b0e2f67733 | ||
|
|
baf4857bee | ||
|
|
37ff943f67 | ||
|
|
c7322eec66 | ||
|
|
7964669a20 | ||
|
|
b8ea953059 | ||
|
|
f935e2139e | ||
|
|
25dd858c18 | ||
|
|
c70ca97888 | ||
|
|
0798b5ccbb | ||
|
|
e4d637e6e0 | ||
|
|
f4108cb354 | ||
|
|
2317695011 | ||
|
|
8b89bce2a8 | ||
|
|
023428ce8f | ||
|
|
7175992ce4 | ||
|
|
63fb2b6506 | ||
|
|
81f4db855b | ||
|
|
e103d987ce | ||
|
|
18d0b47aeb | ||
|
|
1e9847e924 | ||
|
|
c1c499fc1f | ||
|
|
accc51bf65 | ||
|
|
0aa21c1d04 | ||
|
|
cad16fac6b | ||
|
|
b150a3a98a | ||
|
|
41f1ff2b5f | ||
|
|
1286b59f54 | ||
|
|
9414986ca0 | ||
|
|
6628cdf893 | ||
|
|
e521e5343a | ||
|
|
412341fd78 | ||
|
|
ff3d51ce30 | ||
|
|
dadbca5248 | ||
|
|
1e2a923433 | ||
|
|
9e46d811d6 | ||
|
|
a8b9aee24e | ||
|
|
36df16aaa8 | ||
|
|
44a3e8478d | ||
|
|
73f1b223ff | ||
|
|
c8b29edf9d | ||
|
|
a43f4bf155 | ||
|
|
3074ed3c5e | ||
|
|
26cb1cef37 | ||
|
|
0155e9333f | ||
|
|
118399cc12 | ||
|
|
b062bc6d16 | ||
|
|
a8952cfca2 | ||
|
|
2af9b554ad | ||
|
|
2ef5459698 | ||
|
|
074e977e90 | ||
|
|
07fb052da1 | ||
|
|
6c53352643 | ||
|
|
00372711fd | ||
|
|
d34c8ec048 | ||
|
|
d8c0b11271 | ||
|
|
56f951e5a2 | ||
|
|
ed1917b307 | ||
|
|
47bd4ca11e | ||
|
|
4824701b6c | ||
|
|
39df26ee21 | ||
|
|
e18116d859 | ||
|
|
a8dbca64b2 | ||
|
|
3f25c71780 | ||
|
|
bae5421d62 | ||
|
|
f27d9f1dc9 | ||
|
|
aa52711cde | ||
|
|
07552c2809 | ||
|
|
f6407b903b | ||
|
|
2ebc6aecac | ||
|
|
884a3dec4a | ||
|
|
cb0335c2b6 | ||
|
|
274d499279 | ||
|
|
a3377c992b | ||
|
|
7147633f96 | ||
|
|
c847a6bc88 | ||
|
|
78a35a339f | ||
|
|
c97af83ed1 | ||
|
|
803941705c | ||
|
|
ef0170cf0b | ||
|
|
76b9051011 | ||
|
|
0d720c3c9d | ||
|
|
3e5e02a32c | ||
|
|
9b4200a65c | ||
|
|
cb3936a499 | ||
|
|
e34c25858c | ||
|
|
fd9ae0fd1c | ||
|
|
7ddb0db9a4 | ||
|
|
011f323bcc | ||
|
|
d5d764f83e | ||
|
|
239daf2023 | ||
|
|
2b2dafaf88 | ||
|
|
3098c2dcd8 | ||
|
|
8f32825cff | ||
|
|
20d378cd76 | ||
|
|
8e63e81561 | ||
|
|
efe4ef500b | ||
|
|
09a24e1de6 | ||
|
|
9490a220bc | ||
|
|
4f35fb74c8 | ||
|
|
f533320722 | ||
|
|
e51456bce2 | ||
|
|
35196674e6 | ||
|
|
7481c3f7f4 | ||
|
|
3ec10a9c7d | ||
|
|
9efd68ff4c | ||
|
|
3634fd9c26 | ||
|
|
9747f46c1d | ||
|
|
88a1867169 | ||
|
|
f7c85940c4 | ||
|
|
545a4eb47c | ||
|
|
5f660d8671 | ||
|
|
85c31ba068 | ||
|
|
6edb344ab3 | ||
|
|
c91d889da3 | ||
|
|
6a4e04533c | ||
|
|
e8f49c31e4 | ||
|
|
1abd6ca5c0 | ||
|
|
6f55695fa1 | ||
|
|
7c01441392 | ||
|
|
c6695b6a75 | ||
|
|
b0276ad605 | ||
|
|
376627b4cc | ||
|
|
0661de0604 | ||
|
|
f80de83234 | ||
|
|
2d828ef9eb | ||
|
|
77ac44a49a | ||
|
|
fb2763a697 | ||
|
|
5835a51111 | ||
|
|
3c2d43d37b | ||
|
|
6ee25cbe12 | ||
|
|
67e6be8073 | ||
|
|
d69e81922f | ||
|
|
51ac522e7f | ||
|
|
bcb2dca44e | ||
|
|
79f6f4ee60 | ||
|
|
ab7194a639 | ||
|
|
d92016af81 | ||
|
|
6b9d1b559c | ||
|
|
44f097f7d2 | ||
|
|
fcf65dcc77 | ||
|
|
06acaf38d8 | ||
|
|
cb2b27f6ed | ||
|
|
6e26cb5d88 | ||
|
|
f1b199b35c | ||
|
|
0ac4a388c0 | ||
|
|
aff8abccc7 | ||
|
|
51dfbc251a | ||
|
|
c933190db8 | ||
|
|
fab334fca6 | ||
|
|
5a5211d7f2 | ||
|
|
8f6e91c485 | ||
|
|
64fa4b9cef | ||
|
|
d5dddf3ead | ||
|
|
62a4795a4c | ||
|
|
fd18eb520f | ||
|
|
73361370f0 | ||
|
|
ef71daae65 | ||
|
|
5936fc3ac3 | ||
|
|
0efca0f936 | ||
|
|
1e92b51cf7 | ||
|
|
e9ece220d6 | ||
|
|
2dd4e38e1d | ||
|
|
b34453afc7 | ||
|
|
c3b347af4b | ||
|
|
68e9c55925 | ||
|
|
e5800875c2 | ||
|
|
2e43518bb4 | ||
|
|
e5f1bc3c37 | ||
|
|
e3bf81cb79 | ||
|
|
305e183f58 | ||
|
|
7795a086ef | ||
|
|
5d8a8dd7d8 | ||
|
|
31d3c27f43 | ||
|
|
c3ac28a8e6 | ||
|
|
ab26941d3f | ||
|
|
a18df14f4f | ||
|
|
c2e7ab8d41 | ||
|
|
7ee6b11e9d | ||
|
|
8ab02ce402 | ||
|
|
779f6c1a74 | ||
|
|
34d7ac0808 | ||
|
|
d0795bd849 | ||
|
|
08f2190ad1 | ||
|
|
d919d55b5e | ||
|
|
52c64318c5 | ||
|
|
9fe4f87e5b | ||
|
|
48328bf33f | ||
|
|
52e85a8036 | ||
|
|
37538f2a6d | ||
|
|
70df03ffbb | ||
|
|
487e83c84e | ||
|
|
14aaeb06fc | ||
|
|
19ea9cca36 | ||
|
|
6090b29070 | ||
|
|
957ba6266d | ||
|
|
e1472d922a | ||
|
|
3653e70a6d | ||
|
|
7f2fcc0046 | ||
|
|
32e0eadc2f | ||
|
|
18a7549e50 | ||
|
|
e53c30ca4d | ||
|
|
4adc4b28d2 | ||
|
|
c5554e22e0 | ||
|
|
846136dcfb | ||
|
|
bc378d33f6 | ||
|
|
cc833ad643 | ||
|
|
396aa0f97c | ||
|
|
9f606bdeca | ||
|
|
10a5c685ab | ||
|
|
1ca90e5e8b | ||
|
|
db57572a54 | ||
|
|
0af8f3cc66 | ||
|
|
438ecd88a0 | ||
|
|
45628a919f | ||
|
|
fc133d8665 | ||
|
|
5e6d4d2126 | ||
|
|
c38e7c36a6 | ||
|
|
a89960db71 | ||
|
|
7119ff4716 | ||
|
|
339399b0a4 | ||
|
|
9cb6adab4d | ||
|
|
f6aef4471d | ||
|
|
0e56521e8a | ||
|
|
4f8f320280 | ||
|
|
0daa584461 | ||
|
|
57f615a58c | ||
|
|
1bd0baf328 | ||
|
|
6694215909 | ||
|
|
241a81e1fc | ||
|
|
bed45beb13 | ||
|
|
e4cd6810ab | ||
|
|
f38700982c | ||
|
|
9506669095 | ||
|
|
4f67dba6cb | ||
|
|
47354f5aa9 | ||
|
|
2f35ef29fe |
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '>=1.23.0'
|
||||
go-version: '>=1.23.6'
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -15,4 +15,6 @@ coverage.out
|
||||
# generated markdown previews
|
||||
README.html
|
||||
CHANGELOG.html
|
||||
CHANGELOG_16_22.html
|
||||
CHANGELOG_8_15.html
|
||||
LICENSE.html
|
||||
|
||||
@@ -24,13 +24,23 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
- s390x
|
||||
- ppc64le
|
||||
goarm:
|
||||
- 7
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: s390x
|
||||
- goos: windows
|
||||
goarch: ppc64le
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: s390x
|
||||
- goos: darwin
|
||||
goarch: ppc64le
|
||||
|
||||
release:
|
||||
draft: true
|
||||
|
||||
+290
-234
@@ -1,277 +1,327 @@
|
||||
## v0.23.0-rc10
|
||||
## v0.26.0 (WIP)
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation (@todo docs and tests)
|
||||
|
||||
- Restore the CRC32 checksum autogeneration for the collection/field ids in order to maintain deterministic default identifier value and minimize conflicts between custom migrations and full collections snapshots.
|
||||
_There is a system migration that will attempt to normalize existing system collections ids, but if you already migrated to v0.23.0-rc and have generated a full collections snapshot migration, you have to delete it and regenerate a new one._
|
||||
- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth.
|
||||
|
||||
- Change the behavior of the default generated collections snapshot migration to act as "extend" instead of "replace" to prevent accidental data deletion.
|
||||
_I think this would be rare but if you want the old behaviour you can edit the generated snapshot file and replace the second argument (`deleteMissing`) of `App.ImportCollection/App.ImportCollectionsByMarshaledJSON` from `false` to `true`._
|
||||
- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)).
|
||||
This could be useful in case you want to target the auth method as part of the MFA and Auth API rules.
|
||||
For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule.
|
||||
(@todo docs)
|
||||
|
||||
- Added `app.SubscriptionsBroker().TotalClients()` helper method to return the total registered realtime clients ([#5793](https://github.com/pocketbase/pocketbase/issues/5793)).
|
||||
- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)).
|
||||
|
||||
- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner.
|
||||
|
||||
- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_usually used for unit testing_).
|
||||
|
||||
- Updatated to `modernc.org/sqlite` 1.36.0 (SQLite 3.49.0).
|
||||
|
||||
|
||||
## v0.23.0-rc9
|
||||
## v0.25.8
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
- 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_).
|
||||
|
||||
- Fixed auto `www.` redirect due to missing URI schema.
|
||||
|
||||
- Fixed collection and field renaming when reusing an old collection/field name ([#5741](https://github.com/pocketbase/pocketbase/issues/5741)).
|
||||
## v0.25.7
|
||||
|
||||
- Update the "API preview" section to include information about the batch api.
|
||||
- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)).
|
||||
|
||||
- Exported `core.DefaultDBConnect` function that could be used as a fallback when initializing custom SQLite drivers and builds.
|
||||
|
||||
- ⚠️ No longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition ...` linker errors in case different CGO SQLite drivers or builds are used.
|
||||
This means that no matter of the `CGO_ENABLED` value, now out of the box PocketBase will always use only the pure Go driver ([`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite)).
|
||||
This will be documented properly in the new website but if you want to continue using `mattn/go-sqlite3` (e.g. because of the icu or other builtin extension) you could register it as follow:
|
||||
```go
|
||||
package main
|
||||
## v0.25.6
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)).
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
)
|
||||
- Updated npm dependencies.
|
||||
|
||||
func init() {
|
||||
// initialize default PRAGMAs for each new connection
|
||||
sql.Register("pb_sqlite3",
|
||||
&sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
_, err := conn.Exec(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -16000;
|
||||
`, nil)
|
||||
|
||||
return err
|
||||
},
|
||||
},
|
||||
)
|
||||
## v0.25.5
|
||||
|
||||
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
|
||||
}
|
||||
- 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.
|
||||
|
||||
func main() {
|
||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
||||
DBConnect: func(dbPath string) (*dbx.DB, error) {
|
||||
return dbx.Open("pb_sqlite3", dbPath)
|
||||
},
|
||||
})
|
||||
- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
|
||||
|
||||
// custom hooks and plugins...
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
## 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:
|
||||
```
|
||||
Also note that if you are not planning to use the `core.DefaultDBConnect` fallback as part of your custom driver registration you can exclude the default pure Go driver from the build with the build tag `-tags no_default_driver` to reduce the binary size a little.
|
||||
// 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
|
||||
|
||||
- ⚠️ Removed JSVM `BaseCollection()`, `AuthCollection()`, `ViewCollection()` class aliases for simplicity and to avoid confusion with the accepted constructor arguments (_you can simply use as before `new Collection({ type: "base", ... })`; this will also initialize the default type specific options_).
|
||||
|
||||
- Other minor improvements (added validator for duplicated index definitions, updated the impersonate popup styles, added query param support for loading a collection based on its name, etc.).
|
||||
|
||||
|
||||
## v0.23.0-rc8
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
|
||||
- Lock the `_otps` and `_mfas` system collections Delete API rule for superusers only.
|
||||
|
||||
- Reassign in the JSVM executors the global `$app` variable with the hook scoped `e.app` value to minimize the risk of a deadlock when a hook or middleware is wrapped in a transaction.
|
||||
|
||||
- Reuse the OAuth2 created user record pointer to ensure that all its following hooks operate on the same record instance.
|
||||
|
||||
- Added tags support for the `OnFileTokenRequest` hook.
|
||||
|
||||
- Other minor changes (added index for the `_collections` type column, added more detailed godoc for the collection fields and `core.App` methods, fixed flaky record enrich tests, etc.).
|
||||
|
||||
|
||||
## v0.23.0-rc7
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
|
||||
- Register the default panic-recover middleware after the activity logger so that we can log the error.
|
||||
|
||||
- Updated the `RequestEvent.BindBody` FormData type inferring rules to convert numeric strings into float64 only if the resulting minimal number string representation matches the initial FormData string value ([#5687](https://github.com/pocketbase/pocketbase/issues/5687)).
|
||||
|
||||
- Fixed the JSVM types to include properly generated function declarations when the related Go functions have shortened/combined return values.
|
||||
|
||||
- Reorganized the record table fields<->columns syncing to remove the `PRAGMA writable_schema` usage.
|
||||
|
||||
|
||||
## v0.23.0-rc6
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
|
||||
- Fixed realtime 403 API error on resubscribe ([#5674](https://github.com/pocketbase/pocketbase/issues/5674)).
|
||||
|
||||
- Fixed the auto OAuth2 avatar mapped field assignment when the OAuth2 provider doesn't return an avatar URL ([#5673](https://github.com/pocketbase/pocketbase/pull/5673)).
|
||||
_In case the avatar retrieval fails and the mapped record field "Required" option is not set, the error is silenced and only logged with WARN level._
|
||||
|
||||
- Added `Router.SEARCH(path, action)` helper method for registering `SEARCH` endpoints.
|
||||
|
||||
- Changed all builtin middlewares to return `*hook.Handler[*core.RequestEvent]` with a default middleware id for consistency and to allow removal.
|
||||
Or in other words, replace `.BindFunc(apis.Gzip())` with `.Bind(apis.Gzip())`.
|
||||
|
||||
- Updated the JSVM types to reflect the recent changes.
|
||||
|
||||
|
||||
## v0.23.0-rc5
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
|
||||
- Added Notion OAuth2 provider ([#4999](https://github.com/pocketbase/pocketbase/pull/4999); thanks @s-li1).
|
||||
|
||||
- Added monday.com OAuth2 provider ([#5346](https://github.com/pocketbase/pocketbase/pull/5346); thanks @Jaytpa01).
|
||||
|
||||
- Added option to retrieve the OIDC OAuth2 user info from the `id_token` payload for the cases when the provider doesn't have a dedicated user info endpoint.
|
||||
|
||||
- Fixed the relation record picker to sort by default by `@rowid` instead of the `created` field as the latter is optional ([#5641](https://github.com/pocketbase/pocketbase/discussions/5641)).
|
||||
|
||||
- Fixed the UI "Set Superusers only" button click not properly resetting the input state.
|
||||
|
||||
- Fixed the OAuth2 providers logo path shown in the "Authorized providers" UI.
|
||||
|
||||
- Fixed the single value UI for the `select`, `file` and `relation` fields ([#5646](https://github.com/pocketbase/pocketbase/discussions/5646))
|
||||
|
||||
|
||||
## v0.23.0-rc4
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
|
||||
- Fixed the UI settings update form to prevent sending empty string for the mail password or the S3 secret options on resave of the form.
|
||||
|
||||
- ⚠️ Added an exception for the `OAuth2` field in the GO->JSVM name mapping rules:
|
||||
```
|
||||
// old -> new
|
||||
collection.oAuth2.* -> collection.oauth2.*
|
||||
```
|
||||
|
||||
- Added more user friendly view collection truncate error message.
|
||||
|
||||
- Added an extra suffix character to the name of autogenerated template migration file for `*test` suffixed collections to prevent acidentally resulting in `_test.go` migration files.
|
||||
|
||||
- Added `FieldsList.AddMarshaledJSON([]byte)` helper method to load a serialized json array of objects or a single json object into an existing collection fields list.
|
||||
|
||||
- Fixed the autogenerated Go migration template when updating a collection ([#5631](https://github.com/pocketbase/pocketbase/discussions/5631)).
|
||||
|
||||
⚠️ If you have already used a previous prerelease and have autogenerated Go migration files, please check the migration files named **`{timestamp}_updated_{collection}.go`** and manually change:
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<th width="50%">Old (broken)</th>
|
||||
<th width="50%">New</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
```go
|
||||
// add field / update field
|
||||
if err := json.Unmarshal([]byte(`[{
|
||||
...
|
||||
}]`), &collection.Fields); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
```go
|
||||
// add field / update field
|
||||
if err := collection.Fields.AddMarshaledJSON([]byte(`{
|
||||
...
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
To test that your Go migration files work correctly you can try to start PocketBase with a new temp pb_data, e.g.:
|
||||
|
||||
```go
|
||||
go run . serve --dir="pb_data_temp"
|
||||
// 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.23.0-rc3
|
||||
## v0.25.3
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
|
||||
- Make `PRAGMA optimize` statement optional in case it is not supported by the driver ([#5611](https://github.com/pocketbase/pocketbase/discussions/5611)).
|
||||
|
||||
- Reapply the minimum required `pb_data/auxiliary.db` migrations if the db file was manually deleted ([#5618](https://github.com/pocketbase/pocketbase/discussions/5618)).
|
||||
|
||||
- To avoid confusion and unnecessary casting, the `hook.HandlerFunc[T]` type has been removed and instead everywhere we now use directly the underlying function definition, aka.:
|
||||
```go
|
||||
func(T) error
|
||||
```
|
||||
|
||||
- Fixed the UI input field type of the OTP.length field ([#5617](https://github.com/pocketbase/pocketbase/issues/5617)).
|
||||
|
||||
- Other minor fixes (fixed API preview and examples error message typos, better hint for combined/multi-spaced view query columns, fixed the path for the HTTPS green favicon path, etc.).
|
||||
- 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.23.0-rc2
|
||||
## v0.25.2
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
|
||||
- Small update to the earlier v0.23.0-rc that uses `pb_data/auxiliary.db` instead of `pb_data/aux.db` because it seems that on Windows `aux` is disallowed as file name ([#5607](https://github.com/pocketbase/pocketbase/issues/5607)).
|
||||
_If you have already upgraded to v0.23.0-rc please rename manually your `pb_data/aux.db` file to `pb_data/auxiliary.db`._
|
||||
- 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.23.0-rc
|
||||
## v0.25.1
|
||||
|
||||
> [!CAUTION]
|
||||
> **This is a prerelease intended for test and experimental purposes only!**
|
||||
>
|
||||
> It introduces many Go/JSVM breaking changes and requires manual migration steps.
|
||||
>
|
||||
> All new features will be reflected in the new website documentation with the final v0.23.0 release.
|
||||
- Fixed the batch API Preview success sample response.
|
||||
|
||||
- Bumped GitHub action min Go version to 1.23.6 as it comes with a [minor security fix](https://github.com/golang/go/issues?q=milestone%3AGo1.23.6+label%3ACherryPickApproved) for the ppc64le build.
|
||||
|
||||
|
||||
## v0.25.0
|
||||
|
||||
- ⚠️ Upgraded Google OAuth2 auth, token and userinfo endpoints to their latest versions.
|
||||
_For users that don't do anything custom with the Google OAuth2 data or the OAuth2 auth URL, this should be a non-breaking change. The exceptions that I could find are:_
|
||||
- `/v3/userinfo` auth response changes:
|
||||
```
|
||||
meta.rawUser.id => meta.rawUser.sub
|
||||
meta.rawUser.verified_email => meta.rawUser.email_verified
|
||||
```
|
||||
- `/v2/auth` query parameters changes:
|
||||
If you are specifying custom `approval_prompt=force` query parameter for the OAuth2 auth URL, you'll have to replace it with **`prompt=consent`**.
|
||||
|
||||
- Added Trakt OAuth2 provider ([#6338](https://github.com/pocketbase/pocketbase/pull/6338); thanks @aidan-)
|
||||
|
||||
- Added support for case-insensitive password auth based on the related UNIQUE index field collation ([#6337](https://github.com/pocketbase/pocketbase/discussions/6337)).
|
||||
|
||||
- Enforced `when_required` for the new AWS SDK request and response checksum validations to allow other non-AWS vendors to catch up with new AWS SDK changes (see [#6313](https://github.com/pocketbase/pocketbase/discussions/6313) and [aws/aws-sdk-go-v2#2960](https://github.com/aws/aws-sdk-go-v2/discussions/2960)).
|
||||
_You can set the environment variables `AWS_REQUEST_CHECKSUM_CALCULATION` and `AWS_RESPONSE_CHECKSUM_VALIDATION` to `when_supported` if your S3 vendor supports the [new default integrity protections](https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html)._
|
||||
|
||||
- Soft-deprecated `Record.GetUploadedFiles` in favor of `Record.GetUnsavedFiles` to minimize the ambiguities what the method do ([#6269](https://github.com/pocketbase/pocketbase/discussions/6269)).
|
||||
|
||||
- Replaced archived `github.com/AlecAivazis/survey` dependency with a simpler `osutils.YesNoPrompt(message, fallback)` helper.
|
||||
|
||||
- Upgraded to `golang-jwt/jwt/v5`.
|
||||
|
||||
- Added JSVM `new Timezone(name)` binding for constructing `time.Location` value ([#6219](https://github.com/pocketbase/pocketbase/discussions/6219)).
|
||||
|
||||
- Added `inflector.Camelize(str)` and `inflector.Singularize(str)` helper methods.
|
||||
|
||||
- Use the non-transactional app instance during the realtime records delete access checks to ensure that cascade deleted records with API rules relying on the parent will be resolved.
|
||||
|
||||
- Other minor improvements (_replaced all `bool` exists db scans with `int` for broader drivers compatibility, updated API Preview sample error responses, updated UI dependencies, etc._)
|
||||
|
||||
|
||||
## v0.24.4
|
||||
|
||||
- Fixed fields extraction for view query with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
|
||||
|
||||
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
|
||||
|
||||
|
||||
## v0.24.3
|
||||
|
||||
- Fixed incorrectly reported unique validator error for fields starting with name of another field ([#6281](https://github.com/pocketbase/pocketbase/pull/6281); thanks @svobol13).
|
||||
|
||||
- Reload the created/edited records data in the RecordsPicker UI.
|
||||
|
||||
- Updated Go dependencies.
|
||||
|
||||
|
||||
## v0.24.2
|
||||
|
||||
- Fixed display fields extraction when there are multiple "Presentable" `relation` fields in a single related collection ([#6229](https://github.com/pocketbase/pocketbase/issues/6229)).
|
||||
|
||||
|
||||
## v0.24.1
|
||||
|
||||
- Added missing time macros in the UI autocomplete.
|
||||
|
||||
- Fixed JSVM types for structs and functions with multiple generic parameters.
|
||||
|
||||
|
||||
## v0.24.0
|
||||
|
||||
- ⚠️ Removed the "dry submit" when executing the collections Create API rule
|
||||
(you can find more details why this change was introduced and how it could affect your app in https://github.com/pocketbase/pocketbase/discussions/6073).
|
||||
For most users it should be non-breaking change, BUT if you have Create API rules that uses self-references or view counters you may have to adjust them manually.
|
||||
With this change the "multi-match" operators are also normalized in case the targeted collection doesn't have any records
|
||||
(_or in other words, `@collection.example.someField != "test"` will result to `true` if `example` collection has no records because it satisfies the condition that all available "example" records mustn't have `someField` equal to "test"_).
|
||||
As a side-effect of all of the above minor changes, the record create API performance has been also improved ~4x times in high concurrent scenarios (500 concurrent clients inserting total of 50k records - [old (58.409064001s)](https://github.com/pocketbase/benchmarks/blob/54140be5fb0102f90034e1370c7f168fbcf0ddf0/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestdatapublicisset--true) vs [new (13.580098262s)](https://github.com/pocketbase/benchmarks/blob/7df0466ac9bd62fe0a1056270d20ef82012f0234/results/hetzner_cax41_cgo.md#creating-50000-posts100k-reqs50000-conc500-rulerequestauthid----requestbodypublicisset--true)).
|
||||
|
||||
- ⚠️ Changed the type definition of `store.Store[T any]` to `store.Store[K comparable, T any]` to allow support for custom store key types.
|
||||
For most users it should be non-breaking change, BUT if you are calling `store.New[any](nil)` instances you'll have to specify the store key type, aka. `store.New[string, any](nil)`.
|
||||
|
||||
- Added `@yesterday` and `@tomorrow` datetime filter macros.
|
||||
|
||||
- Added `:lower` filter modifier (e.g. `title:lower = "lorem"`).
|
||||
|
||||
- Added `mailer.Message.InlineAttachments` field for attaching inline files to an email (_aka. `cid` links_).
|
||||
|
||||
- Added cache for the JSVM `arrayOf(m)`, `DynamicModel`, etc. dynamic `reflect` created types.
|
||||
|
||||
- Added auth collection select for the settings "Send test email" popup ([#6166](https://github.com/pocketbase/pocketbase/issues/6166)).
|
||||
|
||||
- Added `record.SetRandomPassword()` to simplify random password generation usually used in the OAuth2 or OTP record creation flows.
|
||||
_The generated ~30 chars random password is assigned directly as bcrypt hash and ignores the `password` field plain value validators like min/max length or regex pattern._
|
||||
|
||||
- Added option to list and trigger the registered app level cron jobs via the Web API and UI.
|
||||
|
||||
- Added extra validators for the collection field `int64` options (e.g. `FileField.MaxSize`) restricting them to the max safe JSON number (2^53-1).
|
||||
|
||||
- Added option to unset/overwrite the default PocketBase superuser installer using `ServeEvent.InstallerFunc`.
|
||||
|
||||
- Added `app.FindCachedCollectionReferences(collection, excludeIds)` to speedup records cascade delete almost twice for projects with many collections.
|
||||
|
||||
- Added `tests.NewTestAppWithConfig(config)` helper if you need more control over the test configurations like `IsDev`, the number of allowed connections, etc.
|
||||
|
||||
- Invalidate all record tokens when the auth record email is changed programmatically or by a superuser ([#5964](https://github.com/pocketbase/pocketbase/issues/5964)).
|
||||
|
||||
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
|
||||
|
||||
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule.
|
||||
|
||||
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
|
||||
|
||||
|
||||
## v0.23.12
|
||||
|
||||
- Added warning logs in case of mismatched `modernc.org/sqlite` and `modernc.org/libc` versions ([#6136](https://github.com/pocketbase/pocketbase/issues/6136#issuecomment-2556336962)).
|
||||
|
||||
- Skipped the default body size limit middleware for the backup upload endpoint ([#6152](https://github.com/pocketbase/pocketbase/issues/6152)).
|
||||
|
||||
|
||||
## v0.23.11
|
||||
|
||||
- Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
|
||||
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
|
||||
|
||||
|
||||
## v0.23.10
|
||||
|
||||
- Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
|
||||
|
||||
- Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
|
||||
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
|
||||
|
||||
|
||||
## v0.23.9
|
||||
|
||||
- Replaced `strconv.Itoa` with `strconv.FormatInt` to avoid the int64->int conversion overflow on 32-bit platforms ([#6132](https://github.com/pocketbase/pocketbase/discussions/6132)).
|
||||
|
||||
|
||||
## v0.23.8
|
||||
|
||||
- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
|
||||
|
||||
- Other minor improvements (updated Go and npm deps, added extra escaping for the default mail record params in case the emails are stored as html files, fixed code comment typos, etc.).
|
||||
|
||||
|
||||
## v0.23.7
|
||||
|
||||
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
|
||||
|
||||
|
||||
## v0.23.6
|
||||
|
||||
- Fixed `$filesystem.fileFromURL` documentation and generated type ([#6058](https://github.com/pocketbase/pocketbase/issues/6058)).
|
||||
|
||||
- Fixed `X-Forwarded-For` header typo in the suggested UI "Common trusted proxy" headers ([#6063](https://github.com/pocketbase/pocketbase/pull/6063)).
|
||||
|
||||
- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)).
|
||||
|
||||
- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.).
|
||||
|
||||
|
||||
## v0.23.5
|
||||
|
||||
- Fixed UI logs search not properly accounting for the "Include requests by superusers" toggle when multiple search expressions are used.
|
||||
|
||||
- Fixed `text` field max validation error message ([#6053](https://github.com/pocketbase/pocketbase/issues/6053)).
|
||||
|
||||
- Other minor fixes (comment typos, JSVM types update).
|
||||
|
||||
- Updated Go deps and the min Go release GitHub action version to 1.23.4.
|
||||
|
||||
|
||||
## v0.23.4
|
||||
|
||||
- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)).
|
||||
|
||||
- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)).
|
||||
|
||||
- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)).
|
||||
|
||||
- Updated Go and npm dependencies.
|
||||
|
||||
|
||||
## v0.23.3
|
||||
|
||||
- Fixed Gzip middleware not applying when serving static files.
|
||||
|
||||
- Fixed `Record.Fresh()`/`Record.Clone()` methods not properly cloning `autodate` fields ([#5973](https://github.com/pocketbase/pocketbase/discussions/5973)).
|
||||
|
||||
|
||||
## v0.23.2
|
||||
|
||||
- Fixed `RecordQuery()` custom struct scanning ([#5958](https://github.com/pocketbase/pocketbase/discussions/5958)).
|
||||
|
||||
- Fixed `--dev` log query print formatting.
|
||||
|
||||
- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router.
|
||||
|
||||
- Added collection rules change list in the confirmation popup
|
||||
(_to avoid getting anoying during development, the rules confirmation currently is enabled only when using https_).
|
||||
|
||||
|
||||
## v0.23.1
|
||||
|
||||
- Added `RequestEvent.Blob(status, contentType, bytes)` response write helper ([#5940](https://github.com/pocketbase/pocketbase/discussions/5940)).
|
||||
|
||||
- Added more descriptive error messages.
|
||||
|
||||
|
||||
## v0.23.0
|
||||
|
||||
> [!NOTE]
|
||||
> Please note that you don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
|
||||
> You don't have to upgrade to PocketBase v0.23.0 if you are not planning further developing
|
||||
> your existing app and/or are satisfied with the v0.22.x features set. There are no identified critical issues
|
||||
> with PocketBase v0.22.x yet and in the case of critical bugs and security vulnerabilities, the fixes
|
||||
> will be backported for at least until Q1 of 2025 (_if not longer_).
|
||||
>
|
||||
> If you don't plan upgrading just make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:
|
||||
> **If you don't plan upgrading make sure to pin the SDKs version to their latest PocketBase v0.22.x compatible:**
|
||||
> - JS SDK: `<0.22.0`
|
||||
> - Dart SDK: `<0.19.0`
|
||||
|
||||
PocketBase v0.23.0-rc is a major refactor of the internals with the overall goal of making PocketBase an easier to use Go framework.
|
||||
> [!CAUTION]
|
||||
> This release introduces many Go/JSVM and Web APIs breaking changes!
|
||||
>
|
||||
> Existing `pb_data` will be automatically upgraded with the start of the new executable,
|
||||
> but custom Go or JSVM (`pb_hooks`, `pb_migrations`) and JS/Dart SDK code will have to be migrated manually.
|
||||
> Please refer to the below upgrade guides:
|
||||
> - Go: https://pocketbase.io/v023upgrade/go/.
|
||||
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
|
||||
>
|
||||
> If you had already switched to some of the earlier `<v0.23.0-rc14` versions and have generated a full collections snapshot migration (aka. `./pocketbase migrate collections`), then you may have to regenerate the migration file to ensure that it includes the latest changes.
|
||||
|
||||
There are many changes but to highlight some of the most notable ones:
|
||||
PocketBase v0.23.0 is a major refactor of the internals with the overall goal of making PocketBase an easier to use Go framework.
|
||||
There are a lot of changes but to highlight some of the most notable ones:
|
||||
|
||||
- New and more [detailed documentation](https://pocketbase.io/docs/).
|
||||
_The old documentation could be accessed at [pocketbase.io/old](https://pocketbase.io/old/)._
|
||||
- Replaced `echo` with a new router built on top of the Go 1.22 `net/http` mux enhancements.
|
||||
- Merged `daos` packages in `core.App` to simplify the DB operations (_the `models` package structs are also migrated in `core`_).
|
||||
- Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds.
|
||||
_Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._
|
||||
- New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_).
|
||||
- Various `Record` model improvements (_support for get/set modifiers, simplfied file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
|
||||
- Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically.
|
||||
- Option to mark field as Private/Hidden, disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
|
||||
- Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
|
||||
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
|
||||
- Admins are now system `_superusers` auth records.
|
||||
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
|
||||
@@ -281,23 +331,25 @@ There are many changes but to highlight some of the most notable ones:
|
||||
- One-Time Password (OTP) auth method (_via email code_).
|
||||
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
|
||||
- Support for Record "proxy/projection" in preparation for the planned autogeneration of typed Go record models.
|
||||
- Linear OAuth2 provider ([#5909](https://github.com/pocketbase/pocketbase/pull/5909); thanks @chnfyi).
|
||||
- WakaTime OAuth2 provider ([#5829](https://github.com/pocketbase/pocketbase/pull/5829); thanks @tigawanna).
|
||||
- Notion OAuth2 provider ([#4999](https://github.com/pocketbase/pocketbase/pull/4999); thanks @s-li1).
|
||||
- monday.com OAuth2 provider ([#5346](https://github.com/pocketbase/pocketbase/pull/5346); thanks @Jaytpa01).
|
||||
- New Instagram provider compatible with the new Instagram Login APIs ([#5588](https://github.com/pocketbase/pocketbase/pull/5588); thanks @pnmcosta).
|
||||
_The provider key is `instagram2` to prevent conflicts with existing linked users._
|
||||
- Option to retrieve the OIDC OAuth2 user info from the `id_token` payload for the cases when the provider doesn't have a dedicated user info endpoint.
|
||||
- Various minor UI improvements (_recursive `Presentable` view, slightly different collection options organization, zoom/pan for the logs chart, etc._)
|
||||
- and many more...
|
||||
|
||||
In terms of performance, the Go standard router mux is known to be slightly slower compared to Gin, Echo, etc. implementations, but based on my local tests the difference is negliable.
|
||||
The [benchmarks repo](https://github.com/pocketbase/benchmarks) will be updated with the final v0.23.0 release (_currently there seems to be ~10% memory consumption increase which I'll have to investigate to see whether it is from the router change or from the new hooks_).
|
||||
|
||||
#### Go/JSVM APIs changes
|
||||
|
||||
For upgrading to PocketBase v0.23.0, please refer to:
|
||||
|
||||
- Go: https://pocketbase.io/v023upgrade/go/.
|
||||
- JSVM: https://pocketbase.io/v023upgrade/jsvm/.
|
||||
> - Go: https://pocketbase.io/v023upgrade/go/.
|
||||
> - JSVM: https://pocketbase.io/v023upgrade/jsvm/.
|
||||
|
||||
#### SDKs changes
|
||||
|
||||
- [JS SDK v0.22.0-rc](https://github.com/pocketbase/js-sdk/blob/develop/CHANGELOG.md)
|
||||
- [Dart SDK v0.19.0-rc](https://github.com/pocketbase/dart-sdk/blob/develop/CHANGELOG.md)
|
||||
- [JS SDK v0.22.0](https://github.com/pocketbase/js-sdk/blob/master/CHANGELOG.md)
|
||||
- [Dart SDK v0.19.0](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
|
||||
|
||||
#### Web APIs changes
|
||||
|
||||
@@ -313,6 +365,10 @@ For upgrading to PocketBase v0.23.0, please refer to:
|
||||
|
||||
- New `POST /api/collections/{collection}/impersonate/{id}` endpoint.
|
||||
|
||||
- ⚠️ If you are constructing requests to `/api/*` routes manually remove the trailing slash (_there is no longer trailing slash removal middleware registered by default_).
|
||||
|
||||
- ⚠️ Removed `/api/admins/*` endpoints because admins are converted to `_superusers` auth collection records.
|
||||
|
||||
- ⚠️ Previously when uploading new files to a multiple `file` field, new files were automatically appended to the existing field values.
|
||||
This behaviour has changed with v0.23+ and for consistency with the other multi-valued fields when uploading new files they will replace the old ones. If you want to prepend or append new files to an existing multiple `file` field value you can use the `+` prefix or suffix:
|
||||
```js
|
||||
|
||||
+58
-1
@@ -2,6 +2,63 @@
|
||||
> 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
|
||||
|
||||
- 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)).
|
||||
|
||||
|
||||
## v0.22.26
|
||||
|
||||
- (_Backported from v0.23.0-rc_) Added manual WAL checkpoints before creating the zip backup to minimize copying unnecessary data.
|
||||
|
||||
|
||||
## v0.22.25
|
||||
|
||||
- Refresh the old collections state in the Import UI after successful import submission ([#5861](https://github.com/pocketbase/pocketbase/issues/5861)).
|
||||
|
||||
- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibity of eventual side-channel attacks.
|
||||
|
||||
|
||||
## v0.22.24
|
||||
|
||||
- Delete new uploaded record files in case of DB persist error ([#5845](https://github.com/pocketbase/pocketbase/issues/5845)).
|
||||
|
||||
|
||||
## v0.22.23
|
||||
|
||||
- Updated the hooks watcher to account for the case when hooksDir is a symlink ([#5789](https://github.com/pocketbase/pocketbase/issues/5789)).
|
||||
|
||||
- _(Backported from v0.23.0-rc)_ Registered a default `http.Server.ErrorLog` handler to report general server connection errors as app Debug level logs (e.g. invalid TLS handshakes caused by bots trying to access your server via its IP or other similar errors).
|
||||
|
||||
- Other minor fixes (updated npm dev deps to fix the vulnerabilities warning, added more user friendly realtime topic length error, regenerated JSVM types, etc.)
|
||||
|
||||
|
||||
## v0.22.22
|
||||
|
||||
- Added deprecation log in case Instagram OAuth2 is used (_related to [#5652](https://github.com/pocketbase/pocketbase/discussions/5652)_).
|
||||
@@ -1112,7 +1169,7 @@
|
||||
|
||||
- Updated the schema fields Admin UI for "tidier" fields visualization.
|
||||
|
||||
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
|
||||
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forwarded-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
|
||||
|
||||
- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
|
||||
|
||||
|
||||
@@ -25,10 +25,13 @@
|
||||
|
||||
## API SDK clients
|
||||
|
||||
The easiest way to interact with the API is to use one of the official SDK clients:
|
||||
The easiest way to interact with the PocketBase Web APIs is to use one of the official SDK clients:
|
||||
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_Browser, Node.js, React Native_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_Web, Mobile, Desktop, CLI_)
|
||||
|
||||
You could also check the recommendations in https://pocketbase.io/docs/how-to-use/.
|
||||
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -54,7 +57,6 @@ Here is a minimal example:
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -65,7 +67,7 @@ Here is a minimal example:
|
||||
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
// registers new "GET /hello" route
|
||||
se.Router.Get("/hello", func(re *core.RequestEvent) error {
|
||||
se.Router.GET("/hello", func(re *core.RequestEvent) error {
|
||||
return re.String(200, "Hello world!")
|
||||
})
|
||||
|
||||
@@ -84,19 +86,13 @@ Here is a minimal example:
|
||||
|
||||
4. To build a statically linked executable, you can run `CGO_ENABLED=0 go build` and then start the created executable with `./myapp serve`.
|
||||
|
||||
> [!NOTE]
|
||||
> PocketBase embeds SQLite, but doesn't require CGO.
|
||||
>
|
||||
> If CGO is enabled (aka. `CGO_ENABLED=1`), it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
|
||||
> Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
|
||||
|
||||
_For more details please refer to [Extend with Go](https://pocketbase.io/docs/go-overview/)._
|
||||
|
||||
### Building and running the repo main.go example
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
|
||||
|
||||
0. [Install Go 1.21+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
1. Clone/download the repo
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
|
||||
@@ -6,6 +6,11 @@ import "github.com/pocketbase/pocketbase/tools/router"
|
||||
// and for consistency with the JSVM binds.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// ToApiError wraps err into ApiError instance (if not already).
|
||||
func ToApiError(err error) *router.ApiError {
|
||||
return router.ToApiError(err)
|
||||
}
|
||||
|
||||
// NewApiError is an alias for [router.NewApiError].
|
||||
func NewApiError(status int, message string, errData any) *router.ApiError {
|
||||
return router.NewApiError(status, message, errData)
|
||||
|
||||
+2
-2
@@ -18,10 +18,10 @@ func bindBackupApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
sub := rg.Group("/backups")
|
||||
sub.GET("", backupsList).Bind(RequireSuperuserAuth())
|
||||
sub.POST("", backupCreate).Bind(RequireSuperuserAuth())
|
||||
sub.POST("/upload", backupUpload).Bind(RequireSuperuserAuthOnlyIfAny())
|
||||
sub.POST("/upload", backupUpload).Bind(BodyLimit(0), RequireSuperuserAuth())
|
||||
sub.GET("/{key}", backupDownload) // relies on superuser file token
|
||||
sub.DELETE("/{key}", backupDelete).Bind(RequireSuperuserAuth())
|
||||
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuthOnlyIfAny())
|
||||
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuth())
|
||||
}
|
||||
|
||||
type backupFileInfo struct {
|
||||
|
||||
+10
-39
@@ -10,9 +10,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"gocloud.dev/blob"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
|
||||
)
|
||||
|
||||
func TestBackupsList(t *testing.T) {
|
||||
@@ -347,28 +348,17 @@ func TestBackupUpload(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "unauthorized with 0 superusers (valid file)",
|
||||
Name: "ensure that the default body limit is skipped",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups/upload",
|
||||
Body: bodies[5].buffer,
|
||||
Body: bytes.NewBuffer(make([]byte, apis.DefaultMaxBodySize+100)),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": bodies[5].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all superusers
|
||||
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
files, _ := getBackupFiles(app)
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected %d backup file, got %d", 1, total)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
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},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -500,7 +490,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
@@ -780,25 +770,6 @@ func TestBackupsRestore(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "unauthorized with no superusers (checks only access)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups/missing.zip/restore",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all superusers
|
||||
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -41,6 +41,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
bindRecordAuthApi(app, apiGroup)
|
||||
bindLogsApi(app, apiGroup)
|
||||
bindBackupApi(app, apiGroup)
|
||||
bindCronApi(app, apiGroup)
|
||||
bindFileApi(app, apiGroup)
|
||||
bindBatchApi(app, apiGroup)
|
||||
bindRealtimeApi(app, apiGroup)
|
||||
|
||||
@@ -338,6 +338,12 @@ func processInternalRequest(
|
||||
// apply batch request specific headers
|
||||
// ---
|
||||
for k, v := range ir.Headers {
|
||||
// individual Authorization header keys don't have affect
|
||||
// because the auth state is populated from the base event
|
||||
if strings.EqualFold(k, "authorization") {
|
||||
continue
|
||||
}
|
||||
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
r.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
+3
-3
@@ -294,9 +294,9 @@ func TestBatchRequest(t *testing.T) {
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"requests": [
|
||||
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}},
|
||||
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3"},
|
||||
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}}
|
||||
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}, "headers": {"Authorization": "ignored"}},
|
||||
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
|
||||
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}}
|
||||
]
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
|
||||
@@ -724,7 +724,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"name":"new"`,
|
||||
`"type":"view"`,
|
||||
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
|
||||
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
const installerParam = "pbinstal"
|
||||
|
||||
var wildcardPlaceholderRegex = regexp.MustCompile(`/{.+\.\.\.}$`)
|
||||
|
||||
func stripWildcard(pattern string) string {
|
||||
return wildcardPlaceholderRegex.ReplaceAllString(pattern, "")
|
||||
}
|
||||
|
||||
// installerRedirect redirects the user to the installer dashboard UI page
|
||||
// when the application needs some preliminary configurations to be done.
|
||||
func installerRedirect(app core.App, cpPath string) func(*core.RequestEvent) error {
|
||||
// note: to avoid locks contention it is not concurrent safe but it
|
||||
// is expected to be updated only once during initialization
|
||||
var hasSuperuser bool
|
||||
|
||||
// strip named wildcard
|
||||
cpPath = stripWildcard(cpPath)
|
||||
|
||||
updateHasSuperuser := func(app core.App) error {
|
||||
total, err := app.CountRecords(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasSuperuser = total > 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// load initial state on app init
|
||||
app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateHasSuperuser(e.App)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for existing superuser: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// update on superuser create
|
||||
app.OnRecordCreateRequest(core.CollectionNameSuperusers).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !hasSuperuser {
|
||||
hasSuperuser = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return func(e *core.RequestEvent) error {
|
||||
if hasSuperuser {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
isAPI := strings.HasPrefix(e.Request.URL.Path, "/api/")
|
||||
isControlPanel := strings.HasPrefix(e.Request.URL.Path, cpPath)
|
||||
wildcard := e.Request.PathValue(StaticWildcardParam)
|
||||
|
||||
// skip redirect checks for API and non-root level dashboard index.html requests (css, images, etc.)
|
||||
if isAPI || (isControlPanel && wildcard != "" && wildcard != router.IndexPage) {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// check again in case the superuser was created by some other process
|
||||
if err := updateHasSuperuser(e.App); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hasSuperuser {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
_, hasInstallerParam := e.Request.URL.Query()[installerParam]
|
||||
|
||||
// redirect to the installer page
|
||||
if !hasInstallerParam {
|
||||
return e.Redirect(http.StatusTemporaryRedirect, cpPath+"?"+installerParam+"#")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardRemoveInstallerParam redirects to a non-installer
|
||||
// query param in case there is already a superuser created.
|
||||
//
|
||||
// Note: intended to be registered only for the dashboard route
|
||||
// to prevent excessive checks for every other route in installerRedirect.
|
||||
func dashboardRemoveInstallerParam() func(*core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
_, hasInstallerParam := e.Request.URL.Query()[installerParam]
|
||||
if !hasInstallerParam {
|
||||
return e.Next() // nothing to remove
|
||||
}
|
||||
|
||||
// clear installer param
|
||||
total, _ := e.App.CountRecords(core.CollectionNameSuperusers)
|
||||
if total > 0 {
|
||||
return e.Redirect(http.StatusTemporaryRedirect, "?")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardCacheControl adds default Cache-Control header for all
|
||||
// dashboard UI resources (ignoring the root index.html path)
|
||||
func dashboardCacheControl() func(*core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
if e.Request.PathValue(StaticWildcardParam) != "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
@@ -22,10 +24,20 @@ var defaultThumbSizes = []string{"100x100"}
|
||||
|
||||
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
func bindFileApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
maxWorkers := cast.ToInt64(os.Getenv("PB_THUMBS_MAX_WORKERS"))
|
||||
if maxWorkers <= 0 {
|
||||
maxWorkers = int64(runtime.NumCPU() + 2) // the value is arbitrary chosen and may change in the future
|
||||
}
|
||||
|
||||
maxWait := cast.ToInt64(os.Getenv("PB_THUMBS_MAX_WAIT"))
|
||||
if maxWait <= 0 {
|
||||
maxWait = 60
|
||||
}
|
||||
|
||||
api := fileApi{
|
||||
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 2)), // the value is arbitrary chosen and may change in the future
|
||||
thumbGenPending: new(singleflight.Group),
|
||||
thumbGenMaxWait: 60 * time.Second,
|
||||
thumbGenSem: semaphore.NewWeighted(maxWorkers),
|
||||
thumbGenMaxWait: time.Duration(maxWait) * time.Second,
|
||||
}
|
||||
|
||||
sub := rg.Group("/files")
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
)
|
||||
|
||||
// DefaultInstallerFunc is the default PocketBase installer function.
|
||||
//
|
||||
// It will attempt to open a link in the browser (with a short-lived auth
|
||||
// token for the systemSuperuser) to the installer UI so that users can
|
||||
// create their own custom superuser record.
|
||||
//
|
||||
// See https://github.com/pocketbase/pocketbase/discussions/5814.
|
||||
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
|
||||
token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// launch url (ignore errors and always print a help text as fallback)
|
||||
url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token)
|
||||
_ = osutils.LaunchURL(url)
|
||||
color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
|
||||
color.New(color.Bold).Add(color.FgCyan).Println(url)
|
||||
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", os.Args[0])
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadInstaller(
|
||||
app core.App,
|
||||
baseURL string,
|
||||
installerFunc func(app core.App, systemSuperuser *core.Record, baseURL string) error,
|
||||
) error {
|
||||
if installerFunc == nil || !needInstallerSuperuser(app) {
|
||||
return nil
|
||||
}
|
||||
|
||||
superuser, err := findOrCreateInstallerSuperuser(app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return installerFunc(app, superuser, baseURL)
|
||||
}
|
||||
|
||||
func needInstallerSuperuser(app core.App) bool {
|
||||
total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{
|
||||
"email": core.DefaultInstallerEmail,
|
||||
}))
|
||||
|
||||
return err == nil && total == 0
|
||||
}
|
||||
|
||||
func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
|
||||
col, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record, err := app.FindAuthRecordByEmail(col, core.DefaultInstallerEmail)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record = core.NewRecord(col)
|
||||
record.SetEmail(core.DefaultInstallerEmail)
|
||||
record.SetRandomPassword()
|
||||
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
+9
-36
@@ -48,7 +48,6 @@ const (
|
||||
DefaultRequireGuestOnlyMiddlewareId = "pbRequireGuestOnly"
|
||||
DefaultRequireAuthMiddlewareId = "pbRequireAuth"
|
||||
DefaultRequireSuperuserAuthMiddlewareId = "pbRequireSuperuserAuth"
|
||||
DefaultRequireSuperuserAuthOnlyIfAnyMiddlewareId = "pbRequireSuperuserAuthOnlyIfAny"
|
||||
DefaultRequireSuperuserOrOwnerAuthMiddlewareId = "pbRequireSuperuserOrOwnerAuth"
|
||||
DefaultRequireSameCollectionContextAuthMiddlewareId = "pbRequireSameCollectionContextAuth"
|
||||
)
|
||||
@@ -110,31 +109,6 @@ func RequireSuperuserAuth() *hook.Handler[*core.RequestEvent] {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSuperuserAuthOnlyIfAny middleware requires a request to have
|
||||
// a valid superuser Authorization header ONLY if the application has
|
||||
// at least 1 existing superuser.
|
||||
func RequireSuperuserAuthOnlyIfAny() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: DefaultRequireSuperuserAuthOnlyIfAnyMiddlewareId,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.HasSuperuserAuth() {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
totalSuperusers, err := e.App.CountRecords(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to fetch superusers info.", err)
|
||||
}
|
||||
|
||||
if totalSuperusers == 0 {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
return requireAuth(core.CollectionNameSuperusers)(e)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSuperuserOrOwnerAuth middleware requires a request to have
|
||||
// a valid superuser or regular record owner Authorization header set.
|
||||
//
|
||||
@@ -196,7 +170,9 @@ func RequireSameCollectionContextAuth(collectionPathParam string) *hook.Handler[
|
||||
|
||||
// loadAuthToken attempts to load the auth context based on the "Authorization: TOKEN" header value.
|
||||
//
|
||||
// This middleware does nothing in case of missing, invalid or expired token.
|
||||
// This middleware does nothing in case of:
|
||||
// - missing, invalid or expired token
|
||||
// - e.Auth is already loaded by another middleware
|
||||
//
|
||||
// This middleware is registered by default for all routes.
|
||||
//
|
||||
@@ -207,6 +183,11 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
|
||||
Id: DefaultLoadAuthTokenMiddlewareId,
|
||||
Priority: DefaultLoadAuthTokenMiddlewarePriority,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
// already loaded by another middleware
|
||||
if e.Auth != nil {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
token := getAuthTokenFromRequest(e)
|
||||
if token == "" {
|
||||
return e.Next()
|
||||
@@ -416,17 +397,9 @@ func logRequest(event *core.RequestEvent, err error) {
|
||||
}
|
||||
|
||||
if event.App.Settings().Logs.LogIP {
|
||||
var userIP string
|
||||
if len(event.App.Settings().TrustedProxy.Headers) > 0 {
|
||||
userIP = event.RealIP()
|
||||
} else {
|
||||
// fallback to the legacy behavior (it is "safe" since it is only for log purposes)
|
||||
userIP = cutStr(event.UnsafeRealIP(), 50)
|
||||
}
|
||||
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("userIP", userIP),
|
||||
slog.String("userIP", event.RealIP()),
|
||||
slog.String("remoteIP", event.RemoteIP()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
DefaultBodyLimitMiddlewarePriority = DefaultRateLimitMiddlewarePriority + 10
|
||||
)
|
||||
|
||||
// BodyLimit returns a middleware function that changes the default request body size limit.
|
||||
// BodyLimit returns a middleware handler that changes the default request body size limit.
|
||||
//
|
||||
// If limitBytes <= 0, no limit is applied.
|
||||
//
|
||||
|
||||
@@ -13,6 +13,7 @@ package apis
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -124,8 +125,8 @@ var DefaultCORSConfig = CORSConfig{
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}
|
||||
|
||||
// CORSWithConfig returns a CORS middleware with config.
|
||||
func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||
// CORS returns a CORS middleware.
|
||||
func CORS(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||
// Defaults
|
||||
if len(config.AllowOrigins) == 0 {
|
||||
config.AllowOrigins = DefaultCORSConfig.AllowOrigins
|
||||
@@ -134,13 +135,26 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||
config.AllowMethods = DefaultCORSConfig.AllowMethods
|
||||
}
|
||||
|
||||
allowOriginPatterns := []string{}
|
||||
allowOriginPatterns := make([]*regexp.Regexp, 0, len(config.AllowOrigins))
|
||||
for _, origin := range config.AllowOrigins {
|
||||
if origin == "*" {
|
||||
continue // "*" is handled differently and does not need regexp
|
||||
}
|
||||
|
||||
pattern := regexp.QuoteMeta(origin)
|
||||
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
|
||||
pattern = strings.ReplaceAll(pattern, "\\?", ".")
|
||||
pattern = "^" + pattern + "$"
|
||||
allowOriginPatterns = append(allowOriginPatterns, pattern)
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// This is to preserve previous behaviour - invalid patterns were just ignored.
|
||||
// If we would turn this to panic, users with invalid patterns
|
||||
// would have applications crashing in production due unrecovered panic.
|
||||
log.Println("invalid AllowOrigins pattern", origin)
|
||||
continue
|
||||
}
|
||||
allowOriginPatterns = append(allowOriginPatterns, re)
|
||||
}
|
||||
|
||||
allowMethods := strings.Join(config.AllowMethods, ",")
|
||||
@@ -210,7 +224,7 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||
}
|
||||
if checkPatterns {
|
||||
for _, re := range allowOriginPatterns {
|
||||
if match, _ := regexp.MatchString(re, origin); match {
|
||||
if match := re.MatchString(origin); match {
|
||||
allowOrigin = origin
|
||||
break
|
||||
}
|
||||
@@ -272,6 +286,7 @@ func matchSubdomain(domain, pattern string) bool {
|
||||
if !matchScheme(domain, pattern) {
|
||||
return false
|
||||
}
|
||||
|
||||
didx := strings.Index(domain, "://")
|
||||
pidx := strings.Index(pattern, "://")
|
||||
if didx == -1 || pidx == -1 {
|
||||
@@ -307,5 +322,6 @@ func matchSubdomain(domain, pattern string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
+20
-17
@@ -221,23 +221,26 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
|
||||
if w.wroteHeader {
|
||||
w.ResponseWriter.WriteHeader(w.code)
|
||||
}
|
||||
|
||||
rw := w.ResponseWriter
|
||||
for {
|
||||
switch rf := rw.(type) {
|
||||
case io.ReaderFrom:
|
||||
return rf.ReadFrom(r)
|
||||
case router.RWUnwrapper:
|
||||
rw = rf.Unwrap()
|
||||
default:
|
||||
return io.Copy(w.ResponseWriter, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: Disable the implementation for now because in case the platform
|
||||
// supports the sendfile fast-path it won't run gzipResponseWriter.Write,
|
||||
// preventing compression on the fly.
|
||||
//
|
||||
// func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
|
||||
// if w.wroteHeader {
|
||||
// w.ResponseWriter.WriteHeader(w.code)
|
||||
// }
|
||||
// rw := w.ResponseWriter
|
||||
// for {
|
||||
// switch rf := rw.(type) {
|
||||
// case io.ReaderFrom:
|
||||
// return rf.ReadFrom(r)
|
||||
// case router.RWUnwrapper:
|
||||
// rw = rf.Unwrap()
|
||||
// default:
|
||||
// return io.Copy(w.ResponseWriter, r)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
func (w *gzipResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
|
||||
@@ -32,9 +32,12 @@ func rateLimit() *hook.Handler[*core.RequestEvent] {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(defaultRateLimitLabels(e))
|
||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(
|
||||
defaultRateLimitLabels(e),
|
||||
defaultRateLimitAudience(e)...,
|
||||
)
|
||||
if ok {
|
||||
err := checkRateLimit(e, e.Request.Pattern, rule)
|
||||
err := checkRateLimit(e, rule.Label+rule.Audience, rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -94,9 +97,9 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
||||
}
|
||||
labels = append(labels, defaultRateLimitLabels(e)...)
|
||||
|
||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels)
|
||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels, defaultRateLimitAudience(e)...)
|
||||
if ok {
|
||||
return checkRateLimit(e, rtId, rule)
|
||||
return checkRateLimit(e, rtId+rule.Audience, rule)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -104,11 +107,46 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// @todo consider exporting as RateLimit helper?
|
||||
// @todo consider exporting as helper?
|
||||
//
|
||||
//nolint:unused
|
||||
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
|
||||
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
|
||||
if !ok || rateLimiters == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rt, ok := rateLimiters.GetOk(rtId)
|
||||
if !ok || rt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client, ok := rt.getClient(e.RealIP())
|
||||
if !ok || client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return client.available <= 0 && time.Now().Unix()-client.lastConsume < client.interval
|
||||
}
|
||||
|
||||
// @todo consider exporting as helper?
|
||||
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
|
||||
switch rule.Audience {
|
||||
case core.RateLimitRuleAudienceAll:
|
||||
// valid for both guest and regular users
|
||||
case core.RateLimitRuleAudienceGuest:
|
||||
if e.Auth != nil {
|
||||
return nil
|
||||
}
|
||||
case core.RateLimitRuleAudienceAuth:
|
||||
if e.Auth == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
|
||||
return initRateLimitersStore(e.App)
|
||||
}).(*store.Store[*rateLimiter])
|
||||
}).(*store.Store[string, *rateLimiter])
|
||||
if rateLimiters == nil {
|
||||
e.App.Logger().Warn("Failed to retrieve app rate limiters store")
|
||||
return nil
|
||||
@@ -139,6 +177,17 @@ func skipRateLimit(e *core.RequestEvent) bool {
|
||||
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
|
||||
}
|
||||
|
||||
var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
|
||||
var defaultGuestAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceGuest}
|
||||
|
||||
func defaultRateLimitAudience(e *core.RequestEvent) []string {
|
||||
if e.Auth != nil {
|
||||
return defaultAuthAudience
|
||||
}
|
||||
|
||||
return defaultGuestAudience
|
||||
}
|
||||
|
||||
func defaultRateLimitLabels(e *core.RequestEvent) []string {
|
||||
return []string{e.Request.Method + " " + e.Request.URL.Path, e.Request.URL.Path}
|
||||
}
|
||||
@@ -149,9 +198,9 @@ func destroyRateLimitersStore(app core.App) {
|
||||
app.Store().Remove(rateLimitersStoreKey)
|
||||
}
|
||||
|
||||
func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
|
||||
func initRateLimitersStore(app core.App) *store.Store[string, *rateLimiter] {
|
||||
app.Cron().Add(rateLimitersCronKey, "2 * * * *", func() { // offset a little since too many cleanup tasks execute at 00
|
||||
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[*rateLimiter])
|
||||
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -176,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 {
|
||||
@@ -199,6 +248,15 @@ type rateLimiter struct {
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (rt *rateLimiter) getClient(key string) (*fixedWindow, bool) {
|
||||
rt.RLock()
|
||||
client, ok := rt.clients[key]
|
||||
rt.RUnlock()
|
||||
|
||||
return client, ok
|
||||
}
|
||||
|
||||
func (rt *rateLimiter) isAllowed(key string) bool {
|
||||
// lock only reads to minimize locks contention
|
||||
rt.RLock()
|
||||
|
||||
@@ -31,6 +31,18 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
MaxRequests: 1,
|
||||
Duration: 1,
|
||||
},
|
||||
{
|
||||
Label: "/rate/guest",
|
||||
MaxRequests: 1,
|
||||
Duration: 1,
|
||||
Audience: core.RateLimitRuleAudienceGuest,
|
||||
},
|
||||
{
|
||||
Label: "/rate/auth",
|
||||
MaxRequests: 1,
|
||||
Duration: 1,
|
||||
Audience: core.RateLimitRuleAudienceAuth,
|
||||
},
|
||||
}
|
||||
|
||||
pbRouter, err := apis.NewRouter(app)
|
||||
@@ -48,6 +60,12 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
pbRouter.GET("/rate/b", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "b")
|
||||
})
|
||||
pbRouter.GET("/rate/guest", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "guest")
|
||||
})
|
||||
pbRouter.GET("/rate/auth", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "auth")
|
||||
})
|
||||
|
||||
mux, err := pbRouter.BuildMux()
|
||||
if err != nil {
|
||||
@@ -57,30 +75,53 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
url string
|
||||
wait float64
|
||||
authenticated bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
|
||||
{"/rate/a", 0, 200},
|
||||
{"/rate/a", 0, 200},
|
||||
{"/rate/a", 0, 429},
|
||||
{"/rate/a", 0, 429},
|
||||
{"/rate/a", 1.1, 200},
|
||||
{"/rate/a", 0, 200},
|
||||
{"/rate/a", 0, 429},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 1.1, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 429},
|
||||
{"/rate/b", 1.1, 200},
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 429},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
{"/rate/b", 1.1, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
|
||||
// "auth" with guest (should fallback to the /rate/ rule)
|
||||
{"/rate/auth", 0, false, 200},
|
||||
{"/rate/auth", 0, false, 200},
|
||||
{"/rate/auth", 0, false, 429},
|
||||
{"/rate/auth", 0, false, 429},
|
||||
|
||||
// "auth" rule with regular user (should match the /rate/auth rule)
|
||||
{"/rate/auth", 0, true, 200},
|
||||
{"/rate/auth", 0, true, 429},
|
||||
{"/rate/auth", 0, true, 429},
|
||||
|
||||
// "guest" with guest (should match the /rate/guest rule)
|
||||
{"/rate/guest", 0, false, 200},
|
||||
{"/rate/guest", 0, false, 429},
|
||||
{"/rate/guest", 0, false, 429},
|
||||
|
||||
// "guest" rule with regular user (should fallback to the /rate/ rule)
|
||||
{"/rate/guest", 1, true, 200},
|
||||
{"/rate/guest", 0, true, 200},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -91,6 +132,21 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", s.url, nil)
|
||||
|
||||
if s.authenticated {
|
||||
auth, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token, err := auth.NewAuthToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", token)
|
||||
}
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
@@ -302,95 +302,6 @@ func TestRequireSuperuserAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSuperuserAuthOnlyIfAny(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest (while having at least 1 existing superuser)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "guest (while having 0 existing superusers)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all superusers
|
||||
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "expired/invalid token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjE2NDA5OTE2NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.0pDcBPGDpL2Khh76ivlRi7ugiLBSYvasct3qpHV3rfs",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid regular user token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid superuser auth token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+78
-46
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -83,11 +84,10 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
Data: []byte(`{"clientId":"` + ce.Client.Id() + `"}`),
|
||||
}
|
||||
connectMsgErr := ce.App.OnRealtimeMessageSend().Trigger(connectMsgEvent, func(me *core.RealtimeMessageEvent) error {
|
||||
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
|
||||
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
|
||||
me.Response.Write([]byte("data:"))
|
||||
me.Response.Write(me.Message.Data)
|
||||
me.Response.Write([]byte("\n\n"))
|
||||
err := me.Message.WriteSSE(me.Response, me.Client.Id())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return me.Flush()
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
@@ -122,11 +122,10 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
msgEvent.Client = ce.Client
|
||||
msgEvent.Message = &msg
|
||||
msgErr := ce.App.OnRealtimeMessageSend().Trigger(msgEvent, func(me *core.RealtimeMessageEvent) error {
|
||||
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
|
||||
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
|
||||
me.Response.Write([]byte("data:"))
|
||||
me.Response.Write(me.Message.Data)
|
||||
me.Response.Write([]byte("\n\n"))
|
||||
err := me.Message.WriteSSE(me.Response, me.Client.Id())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return me.Flush()
|
||||
})
|
||||
if msgErr != nil {
|
||||
@@ -242,8 +241,8 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
|
||||
func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
|
||||
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
|
||||
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
@@ -255,7 +254,7 @@ func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
|
||||
if clientAuth != nil &&
|
||||
clientAuth.Id == authModel.PK() &&
|
||||
clientAuth.Collection().Name == authModel.TableName() {
|
||||
app.SubscriptionsBroker().Unregister(client.Id())
|
||||
client.Unset(RealtimeClientAuthKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +292,7 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
||||
if collection != nil && collection.IsAuth() {
|
||||
if err := realtimeUnregisterClientsByAuth(e.App, e.Model); err != nil {
|
||||
if err := realtimeUnsetClientsAuthState(e.App, e.Model); err != nil {
|
||||
app.Logger().Warn(
|
||||
"Failed to remove client(s) associated to the deleted auth model",
|
||||
slog.Any("id", e.Model.PK()),
|
||||
@@ -353,7 +352,10 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeBroadcastRecord(e.App, "delete", record, true)
|
||||
// note: use the outside scoped app instance for the access checks so that the API rules
|
||||
// are performed out of the delete transaction ensuring that they would still work even if
|
||||
// a cascade-deleted record's API rule relies on an already deleted parent record
|
||||
err := realtimeBroadcastRecord(e.App, "delete", record, true, app)
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to dry cache record delete",
|
||||
@@ -372,14 +374,17 @@ func bindRealtimeEvents(app core.App) {
|
||||
// delete: broadcast
|
||||
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeBroadcastDryCachedRecord(e.App, "delete", record)
|
||||
// note: only ensure that it is a collection record
|
||||
// and don't use realtimeResolveRecord because in case of a
|
||||
// custom model it'll fail to resolve since the record is already deleted
|
||||
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
||||
if collection != nil {
|
||||
err := realtimeBroadcastDryCacheKey(e.App, getDryCacheKey("delete", e.Model))
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to broadcast record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.Any("id", e.Model.PK()),
|
||||
slog.String("collectionName", collection.Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
@@ -395,7 +400,7 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelErrorEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeUnsetDryCachedRecord(e.App, "delete", record)
|
||||
err := realtimeUnsetDryCacheKey(e.App, getDryCacheKey("delete", record))
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to cleanup after broadcast record delete failure",
|
||||
@@ -415,7 +420,14 @@ func bindRealtimeEvents(app core.App) {
|
||||
// resolveRecord converts *if possible* the provided model interface to a Record.
|
||||
// This is usually helpful if the provided model is a custom Record model struct.
|
||||
func realtimeResolveRecord(app core.App, model core.Model, optCollectionType string) *core.Record {
|
||||
record, _ := model.(*core.Record)
|
||||
var record *core.Record
|
||||
switch m := model.(type) {
|
||||
case *core.Record:
|
||||
record = m
|
||||
case core.RecordProxy:
|
||||
record = m.ProxyRecord()
|
||||
}
|
||||
|
||||
if record != nil {
|
||||
if optCollectionType == "" || record.Collection().Type == optCollectionType {
|
||||
return record
|
||||
@@ -444,14 +456,20 @@ func realtimeResolveRecord(app core.App, model core.Model, optCollectionType str
|
||||
// realtimeResolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
|
||||
// This is usually helpful if the provided model is a custom Record model struct.
|
||||
func realtimeResolveRecordCollection(app core.App, model core.Model) (collection *core.Collection) {
|
||||
if record, ok := model.(*core.Record); ok {
|
||||
collection = record.Collection()
|
||||
} else {
|
||||
// check if it is custom Record model struct (ignore "private" tables)
|
||||
collection, _ = app.FindCachedCollectionByNameOrId(model.TableName())
|
||||
switch m := model.(type) {
|
||||
case *core.Record:
|
||||
return m.Collection()
|
||||
case core.RecordProxy:
|
||||
return m.ProxyRecord().Collection()
|
||||
default:
|
||||
// check if it is custom Record model struct
|
||||
collection, err := app.FindCachedCollectionByNameOrId(model.TableName())
|
||||
if err == nil {
|
||||
return collection
|
||||
}
|
||||
}
|
||||
|
||||
return collection
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordData represents the broadcasted record subscrition message data.
|
||||
@@ -460,7 +478,11 @@ type recordData struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool) error {
|
||||
// Note: the optAccessCheckApp is there in case you want the access check
|
||||
// to be performed against different db app context (e.g. out of a transaction).
|
||||
// If set, it is expected that optAccessCheckApp instance is used for read-only operations to avoid deadlocks.
|
||||
// If not set, it fallbacks to app.
|
||||
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool, optAccessCheckApp ...core.App) error {
|
||||
collection := record.Collection()
|
||||
if collection == nil {
|
||||
return errors.New("[broadcastRecord] Record collection not set")
|
||||
@@ -482,10 +504,15 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
(collection.Id + "?"): collection.ListRule,
|
||||
}
|
||||
|
||||
dryCacheKey := action + "/" + record.Id
|
||||
dryCacheKey := getDryCacheKey(action, record)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
accessCheckApp := app
|
||||
if len(optAccessCheckApp) > 0 {
|
||||
accessCheckApp = optAccessCheckApp[0]
|
||||
}
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
var clientAuth *core.Record
|
||||
@@ -502,10 +529,6 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
clientAuth, _ = client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
|
||||
for sub, options := range subs {
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know yet which exact fields the client subscription has permissions to access
|
||||
cleanRecord := record.Fresh()
|
||||
|
||||
// mock request data
|
||||
requestInfo := &core.RequestInfo{
|
||||
Context: core.RequestInfoContextRealtime,
|
||||
@@ -515,10 +538,14 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
Auth: clientAuth,
|
||||
}
|
||||
|
||||
if !realtimeCanAccessRecord(app, cleanRecord, requestInfo, rule) {
|
||||
if !realtimeCanAccessRecord(accessCheckApp, record, requestInfo, rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
// create a clean record copy without expand and unknown fields because we don't know yet
|
||||
// which exact fields the client subscription requested or has permissions to access
|
||||
cleanRecord := record.Fresh()
|
||||
|
||||
// trigger the enrich hooks
|
||||
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
|
||||
// apply expand
|
||||
@@ -541,7 +568,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
// for auth owner, superuser or manager
|
||||
if collection.IsAuth() {
|
||||
if isSameAuth(clientAuth, cleanRecord) ||
|
||||
realtimeCanAccessRecord(app, cleanRecord, requestInfo, collection.ManageRule) {
|
||||
realtimeCanAccessRecord(accessCheckApp, cleanRecord, requestInfo, collection.ManageRule) {
|
||||
cleanRecord.IgnoreEmailVisibility(true)
|
||||
}
|
||||
}
|
||||
@@ -622,15 +649,13 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeBroadcastDryCachedRecord broadcasts all cached record related messages.
|
||||
func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.Record) error {
|
||||
// realtimeBroadcastDryCacheKey broadcasts the dry cached key related messages.
|
||||
func realtimeBroadcastDryCacheKey(app core.App, key string) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
if len(chunks) == 0 {
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
key := action + "/" + record.Id
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
@@ -659,15 +684,13 @@ func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeUnsetDryCachedRecord removes the dry cached record related messages.
|
||||
func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Record) error {
|
||||
// realtimeUnsetDryCacheKey removes the dry cached key related messages.
|
||||
func realtimeUnsetDryCacheKey(app core.App, key string) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
if len(chunks) == 0 {
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
key := action + "/" + record.Id
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
@@ -685,6 +708,15 @@ func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Reco
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
func getDryCacheKey(action string, model core.Model) string {
|
||||
pkStr, ok := model.PK().(string)
|
||||
if !ok {
|
||||
pkStr = fmt.Sprintf("%v", model.PK())
|
||||
}
|
||||
|
||||
return action + "/" + model.TableName() + "/" + pkStr
|
||||
}
|
||||
|
||||
func isSameAuth(authA, authB *core.Record) bool {
|
||||
if authA == nil {
|
||||
return authB == nil
|
||||
@@ -722,7 +754,7 @@ func realtimeCanAccessRecord(
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
q := app.DB().Select("(1)").
|
||||
From(record.Collection().Name).
|
||||
@@ -739,5 +771,5 @@ func realtimeCanAccessRecord(
|
||||
|
||||
err = q.Limit(1).Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
+315
-14
@@ -2,10 +2,13 @@ package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRealtimeConnect(t *testing.T) {
|
||||
@@ -427,26 +431,51 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
|
||||
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.RealtimeClientAuthKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client1)
|
||||
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client2)
|
||||
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Set(apis.RealtimeClientAuthKey, authRecord2)
|
||||
testApp.SubscriptionsBroker().Register(client3)
|
||||
|
||||
// mock delete event
|
||||
e := new(core.ModelEvent)
|
||||
e.App = testApp
|
||||
e.Type = core.ModelEventTypeDelete
|
||||
e.Context = context.Background()
|
||||
e.Model = authRecord
|
||||
e.Model = authRecord1
|
||||
|
||||
testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
||||
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", total)
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
|
||||
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
|
||||
}
|
||||
|
||||
if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
|
||||
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
|
||||
}
|
||||
|
||||
if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
|
||||
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
|
||||
}
|
||||
|
||||
if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
|
||||
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,17 +553,30 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
|
||||
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.RealtimeClientAuthKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client1)
|
||||
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client2)
|
||||
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Set(apis.RealtimeClientAuthKey, authRecord2)
|
||||
testApp.SubscriptionsBroker().Register(client3)
|
||||
|
||||
// refetch the authRecord as CustomUser
|
||||
customUser, err := findCustomUserByEmail(testApp, "test@example.com")
|
||||
customUser, err := findCustomUserByEmail(testApp, authRecord1.Email())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -544,8 +586,20 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", total)
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
|
||||
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
|
||||
}
|
||||
|
||||
if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
|
||||
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
|
||||
}
|
||||
|
||||
if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
|
||||
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
|
||||
}
|
||||
|
||||
if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
|
||||
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,3 +636,250 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ core.Model = (*CustomModelResolve)(nil)
|
||||
|
||||
type CustomModelResolve struct {
|
||||
core.BaseModel
|
||||
tableName string
|
||||
|
||||
Created string `db:"created"`
|
||||
}
|
||||
|
||||
func (m *CustomModelResolve) TableName() string {
|
||||
return m.tableName
|
||||
}
|
||||
|
||||
func TestRealtimeRecordResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const testCollectionName = "realtime_test_collection"
|
||||
|
||||
testRecordId := core.GenerateDefaultRandomId()
|
||||
|
||||
client0 := subscriptions.NewDefaultClient()
|
||||
client0.Subscribe(testCollectionName + "/*")
|
||||
client0.Discard()
|
||||
// ---
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Subscribe(testCollectionName + "/*")
|
||||
// ---
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Subscribe(testCollectionName + "/" + testRecordId)
|
||||
// ---
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Subscribe("demo1/*")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
op func(testApp core.App) error
|
||||
expected map[string][]string // clientId -> [events]
|
||||
}{
|
||||
{
|
||||
"core.Record",
|
||||
func(testApp core.App) error {
|
||||
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r := core.NewRecord(c)
|
||||
r.Id = testRecordId
|
||||
|
||||
// create
|
||||
err = testApp.Save(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
err = testApp.Save(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"core.RecordProxy",
|
||||
func(testApp core.App) error {
|
||||
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r := core.NewRecord(c)
|
||||
|
||||
proxy := &struct {
|
||||
core.BaseRecordProxy
|
||||
}{}
|
||||
proxy.SetProxyRecord(r)
|
||||
proxy.Id = testRecordId
|
||||
|
||||
// create
|
||||
err = testApp.Save(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
err = testApp.Save(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"custom model struct",
|
||||
func(testApp core.App) error {
|
||||
m := &CustomModelResolve{tableName: testCollectionName}
|
||||
m.Id = testRecordId
|
||||
|
||||
// create
|
||||
err := testApp.Save(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
m.Created = "123"
|
||||
err = testApp.Save(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
|
||||
// create new test collection with public read access
|
||||
testCollection := core.NewBaseCollection(testCollectionName)
|
||||
testCollection.Fields.Add(&core.AutodateField{Name: "created", OnCreate: true, OnUpdate: true})
|
||||
testCollection.ListRule = types.Pointer("")
|
||||
testCollection.ViewRule = types.Pointer("")
|
||||
err := testApp.Save(testCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testApp.SubscriptionsBroker().Register(client0)
|
||||
testApp.SubscriptionsBroker().Register(client1)
|
||||
testApp.SubscriptionsBroker().Register(client2)
|
||||
testApp.SubscriptionsBroker().Register(client3)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var notifications = map[string][]string{}
|
||||
|
||||
var mu sync.Mutex
|
||||
notify := func(clientId string, eventData []byte) {
|
||||
data := struct{ Action string }{}
|
||||
_ = json.Unmarshal(eventData, &data)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if notifications[clientId] == nil {
|
||||
notifications[clientId] = []string{}
|
||||
}
|
||||
notifications[clientId] = append(notifications[clientId], data.Action)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
timeout := time.After(250 * time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-client0.Channel():
|
||||
if ok {
|
||||
notify(client0.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client1.Channel():
|
||||
if ok {
|
||||
notify(client1.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client2.Channel():
|
||||
if ok {
|
||||
notify(client2.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client3.Channel():
|
||||
if ok {
|
||||
notify(client3.Id(), e.Data)
|
||||
}
|
||||
case <-timeout:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = s.op(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(s.expected) != len(notifications) {
|
||||
t.Fatalf("Expected %d notified clients, got %d:\n%v", len(s.expected), len(notifications), notifications)
|
||||
}
|
||||
|
||||
for id, events := range s.expected {
|
||||
if len(events) != len(notifications[id]) {
|
||||
t.Fatalf("[%s] Expected %d events, got %d:\n%v\n%v", id, len(events), len(notifications[id]), s.expected, notifications)
|
||||
}
|
||||
for _, event := range events {
|
||||
if !slices.Contains(notifications[id], event) {
|
||||
t.Fatalf("[%s] Missing expected event %q in %v", id, event, notifications[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,8 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
|
||||
event.NewEmail = newEmail
|
||||
|
||||
return e.App.OnRecordConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeRequestEvent) error {
|
||||
authRecord.Set(core.FieldNameEmail, e.NewEmail)
|
||||
authRecord.Set(core.FieldNameVerified, true)
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
e.Record.SetEmail(e.NewEmail)
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -32,12 +33,10 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
record, err := e.App.FindAuthRecordByEmail(collection, form.Email)
|
||||
if err != nil {
|
||||
// eagerly write a dummy 200 response as a very rudimentary user emails enumeration protection
|
||||
e.JSON(http.StatusOK, map[string]string{
|
||||
"otpId": core.GenerateDefaultRandomId(),
|
||||
})
|
||||
return fmt.Errorf("failed to fetch %s record with email %s: %w", collection.Name, form.Email, err)
|
||||
|
||||
// ignore not found errors to allow custom record find implementations
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordCreateOTPRequestEvent)
|
||||
@@ -46,7 +45,18 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
originalApp := e.App
|
||||
|
||||
return e.App.OnRecordRequestOTPRequest().Trigger(event, func(e *core.RecordCreateOTPRequestEvent) error {
|
||||
if e.Record == nil {
|
||||
// write a dummy 200 response as a very rudimentary emails enumeration "protection"
|
||||
e.JSON(http.StatusOK, map[string]string{
|
||||
"otpId": core.GenerateDefaultRandomId(),
|
||||
})
|
||||
|
||||
return fmt.Errorf("missing or invalid %s OTP auth record with email %s", collection.Name, form.Email)
|
||||
}
|
||||
|
||||
var otp *core.OTP
|
||||
|
||||
// limit the new OTP creations for a single user
|
||||
@@ -90,11 +100,10 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
||||
// send OTP email
|
||||
// (in the background as a very basic timing attacks and emails enumeration protection)
|
||||
// ---
|
||||
app := e.App
|
||||
routine.FireAndForget(func() {
|
||||
err = mails.SendRecordOTP(app, e.Record, otp.Id, e.Password)
|
||||
err = mails.SendRecordOTP(originalApp, e.Record, otp.Id, e.Password)
|
||||
if err != nil {
|
||||
app.Logger().Error("Failed to send OTP email", "error", errors.Join(err, e.App.Delete(otp)))
|
||||
originalApp.Logger().Error("Failed to send OTP email", "error", errors.Join(err, originalApp.Delete(otp)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,7 +86,10 @@ func TestRecordRequestOTP(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"otpId":"`, // some fake random generated string
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordRequestOTPRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 0 {
|
||||
t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
|
||||
@@ -137,6 +140,57 @@ func TestRecordRequestOTP(t *testing.T) {
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 2, // + 1 for the OTP update after the email send
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 2,
|
||||
// OTP update
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 1 {
|
||||
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
|
||||
}
|
||||
|
||||
// ensure that sentTo is set
|
||||
otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
|
||||
if err != nil || len(otps) != 1 {
|
||||
t.Fatalf("Expected to find 1 OTP with sentTo %q, found %d", "test@example.com", len(otps))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing auth record with intercepted email (with < 9 non-expired)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-otp",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// prevent email sent
|
||||
app.OnMailerRecordOTPSend("users").BindFunc(func(e *core.MailerRecordEvent) error {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"otpId":"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"otpId":"otp_`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordRequestOTPRequest": 1,
|
||||
"OnMailerRecordOTPSend": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
@@ -144,8 +198,14 @@ func TestRecordRequestOTP(t *testing.T) {
|
||||
"OnRecordValidate": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 1 {
|
||||
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
|
||||
if app.TestMailer.TotalSend() != 0 {
|
||||
t.Fatalf("Expected 0 emails, got %d", app.TestMailer.TotalSend())
|
||||
}
|
||||
|
||||
// ensure that there is no OTP with user email as sentTo
|
||||
otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
|
||||
if err != nil || len(otps) != 0 {
|
||||
t.Fatalf("Expected to find 0 OTPs with sentTo %q, found %d", "test@example.com", len(otps))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,12 +47,12 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
err = form.app.Save(authRecord)
|
||||
err = e.App.Save(authRecord)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to set new password.", err))
|
||||
}
|
||||
|
||||
form.app.Store().Remove(getPasswordResetResendKey(authRecord))
|
||||
e.App.Store().Remove(getPasswordResetResendKey(authRecord))
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
@@ -186,11 +186,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
t.Fatal("Expected the user to be unverified")
|
||||
}
|
||||
|
||||
oldTokenKey := user.TokenKey()
|
||||
|
||||
// manually change the email to check whether the verified state will be updated
|
||||
user.SetEmail("test_update@example.com")
|
||||
if err := app.Save(user); err != nil {
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to update user test email: %v", err)
|
||||
}
|
||||
|
||||
// resave with the old token key since the email change above
|
||||
// would change it and will make the password token invalid
|
||||
user.SetTokenKey(oldTokenKey)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to restore original user tokenKey: %v", err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
_, err := app.FindAuthRecordByToken(
|
||||
|
||||
@@ -2,20 +2,21 @@ package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -34,6 +35,8 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
fallbackAuthRecord = e.Auth
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
|
||||
|
||||
form := new(recordOAuth2LoginForm)
|
||||
form.collection = collection
|
||||
if err = e.BindBody(form); err != nil {
|
||||
@@ -95,6 +98,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
"provider": form.Provider,
|
||||
"providerId": authUser.Id,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return e.InternalServerError("Failed OAuth2 relation check.", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err == nil && externalAuthRel != nil:
|
||||
authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
|
||||
@@ -106,7 +113,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
authRecord = fallbackAuthRecord
|
||||
case authUser.Email != "":
|
||||
// look for an existing auth record by the external auth record's email
|
||||
authRecord, _ = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
authRecord, err = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return e.InternalServerError("Failed OAuth2 auth record check.", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -126,13 +136,17 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
|
||||
}
|
||||
|
||||
meta := struct {
|
||||
*auth.AuthUser
|
||||
IsNew bool `json:"isNew"`
|
||||
}{
|
||||
AuthUser: e.OAuth2User,
|
||||
IsNew: e.IsNewRecord,
|
||||
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
|
||||
meta := map[string]any{}
|
||||
rawOAuth2User, err := json.Marshal(e.OAuth2User)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.Unmarshal(rawOAuth2User, &meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta["isNew"] = e.IsNewRecord
|
||||
|
||||
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
|
||||
})
|
||||
@@ -167,7 +181,7 @@ type recordOAuth2LoginForm struct {
|
||||
|
||||
func (form *recordOAuth2LoginForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.RedirectURL, validation.Required),
|
||||
)
|
||||
@@ -178,7 +192,7 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
||||
|
||||
_, ok := form.collection.OAuth2.GetProviderConfig(name)
|
||||
if !ok {
|
||||
return validation.NewError("validation_invalid_provider", fmt.Sprintf("Provider with name %q is missing or is not enabled.", name)).
|
||||
return validation.NewError("validation_invalid_provider", "Provider with name {{.name}} is missing or is not enabled.").
|
||||
SetParams(map[string]any{"name": name})
|
||||
}
|
||||
|
||||
@@ -187,10 +201,20 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
||||
|
||||
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
|
||||
// ensure that username is unique
|
||||
checkUnique := dbutils.HasSingleColumnUniqueIndex(collection.OAuth2.MappedFields.Username, collection.Indexes)
|
||||
if checkUnique {
|
||||
if _, err := txApp.FindFirstRecordByData(collection, collection.OAuth2.MappedFields.Username, username); err == nil {
|
||||
return false // already exist
|
||||
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
|
||||
if hasUniqueue {
|
||||
var expr dbx.Expression
|
||||
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
|
||||
} else {
|
||||
expr = dbx.HashExp{"username": username}
|
||||
}
|
||||
|
||||
var exists int
|
||||
_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
|
||||
if exists > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,12 +238,10 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
payload = map[string]any{}
|
||||
}
|
||||
|
||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||
|
||||
// set a random password if none is set
|
||||
if v, _ := payload[core.FieldNamePassword].(string); v == "" {
|
||||
payload[core.FieldNamePassword] = security.RandomString(30)
|
||||
payload[core.FieldNamePassword+"Confirm"] = payload[core.FieldNamePassword]
|
||||
// assign the OAuth2 user email only if the user hasn't submitted one
|
||||
// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
|
||||
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
|
||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||
}
|
||||
|
||||
// map known fields (unless the field was explicitly submitted as part of CreateData)
|
||||
@@ -237,7 +259,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
|
||||
}
|
||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.AvatarURL]; !ok &&
|
||||
// no existing OAuth2 mapping
|
||||
// no explicit avatar payload value and existing OAuth2 mapping
|
||||
e.Collection.OAuth2.MappedFields.AvatarURL != "" &&
|
||||
// non-empty OAuth2 avatar url
|
||||
e.OAuth2User.AvatarURL != "" {
|
||||
@@ -284,7 +306,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
// set random password for users with unverified email
|
||||
// (this is in case a malicious actor has registered previously with the user email)
|
||||
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
|
||||
e.Record.SetPassword(security.RandomString(30))
|
||||
e.Record.SetRandomPassword()
|
||||
needUpdate = true
|
||||
}
|
||||
|
||||
|
||||
@@ -56,18 +56,22 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// note: don't exit because it is usually called as part of a separate goroutine
|
||||
checkClientMessages := func(t testing.TB, clientId string, msg subscriptions.Message, expectedMessages map[string][]string) {
|
||||
if len(expectedMessages[clientId]) == 0 {
|
||||
t.Fatalf("Unexpected client %q message, got %s:\n%s", clientId, msg.Name, msg.Data)
|
||||
t.Errorf("Unexpected client %q message, got %q:\n%q", clientId, msg.Name, msg.Data)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Name != "@oauth2" {
|
||||
t.Fatalf("Expected @oauth2 msg.Name, got %q", msg.Name)
|
||||
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
|
||||
return
|
||||
}
|
||||
|
||||
for _, txt := range expectedMessages[clientId] {
|
||||
if !strings.Contains(string(msg.Data), txt) {
|
||||
t.Fatalf("Failed to find %q in \n%s", txt, msg.Data)
|
||||
t.Errorf("Failed to find %q in \n%s", txt, msg.Data)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,19 +95,37 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-clients["c1"].Channel():
|
||||
checkClientMessages(t, "c1", msg, expectedMessages)
|
||||
case msg := <-clients["c2"].Channel():
|
||||
checkClientMessages(t, "c2", msg, expectedMessages)
|
||||
case msg := <-clients["c3"].Channel():
|
||||
checkClientMessages(t, "c3", msg, expectedMessages)
|
||||
case msg := <-clients["c4"].Channel():
|
||||
checkClientMessages(t, "c4", msg, expectedMessages)
|
||||
case msg := <-clients["c5"].Channel():
|
||||
checkClientMessages(t, "c5", msg, expectedMessages)
|
||||
case msg, ok := <-clients["c1"].Channel():
|
||||
if ok {
|
||||
checkClientMessages(t, "c1", msg, expectedMessages)
|
||||
} else {
|
||||
t.Errorf("Unexpected c1 closed channel")
|
||||
}
|
||||
case msg, ok := <-clients["c2"].Channel():
|
||||
if ok {
|
||||
checkClientMessages(t, "c2", msg, expectedMessages)
|
||||
} else {
|
||||
t.Errorf("Unexpected c2 closed channel")
|
||||
}
|
||||
case msg, ok := <-clients["c3"].Channel():
|
||||
if ok {
|
||||
checkClientMessages(t, "c3", msg, expectedMessages)
|
||||
} else {
|
||||
t.Errorf("Unexpected c3 closed channel")
|
||||
}
|
||||
case msg, ok := <-clients["c4"].Channel():
|
||||
if ok {
|
||||
checkClientMessages(t, "c4", msg, expectedMessages)
|
||||
} else {
|
||||
t.Errorf("Unexpected c4 closed channel")
|
||||
}
|
||||
case _, ok := <-clients["c5"].Channel():
|
||||
if ok {
|
||||
t.Errorf("Expected c5 channel to be closed")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
for _, c := range clients {
|
||||
close(c.Channel())
|
||||
c.Discard()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -174,6 +175,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
if err := app.Save(ea); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextOAuth2 {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOAuth2, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -292,6 +307,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test2@example.com"`,
|
||||
`"id":"oap640cot4yru2s"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -383,6 +399,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -488,6 +505,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -558,12 +576,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
|
||||
oldTokenKey := user.TokenKey()
|
||||
|
||||
// manually unset the user email
|
||||
user.SetEmail("")
|
||||
if err := app.Save(user); err != nil {
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// resave with the old token key since the email change above
|
||||
// would change it and will make the password token invalid
|
||||
user.SetTokenKey(oldTokenKey)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to restore original user tokenKey: %v", err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
@@ -592,6 +619,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test_oauth2@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -697,6 +725,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -786,6 +815,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":true`,
|
||||
`"email":""`,
|
||||
`"id":"test_id"`,
|
||||
`"verified":true`,
|
||||
@@ -867,7 +897,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"verified":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"email":`, // the value is always overwritten with the OAuth2 user email
|
||||
`"email":`, // ignored because the record validator never ran
|
||||
`"rel":`, // ignored because the record validator never ran
|
||||
`"file":`, // ignored because the record validator never ran
|
||||
},
|
||||
@@ -920,12 +950,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"email":{"code":"validation_is_email"`,
|
||||
`"rel":{"code":"validation_missing_rel_records"`,
|
||||
`"file":{"code":"validation_invalid_file"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"email":`, // the value is always overwritten with the OAuth2 user email
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
@@ -939,7 +967,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (valid create data)",
|
||||
Name: "creating user (valid create data with empty submitted email)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -947,8 +975,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"email": "invalid",
|
||||
"email": "",
|
||||
"emailVisibility": true,
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
"name": "test_name",
|
||||
"username": "test_username",
|
||||
"rel": "0yxhwia2amd8gec"
|
||||
@@ -982,6 +1012,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":""`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
@@ -1018,6 +1049,105 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnModelValidate": 4,
|
||||
"OnRecordValidate": 4,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindFirstRecordByData("users", "username", "test_username")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (valid create data with non-empty valid submitted email)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "test",
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"email": "test_create@example.com",
|
||||
"emailVisibility": true,
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
"name": "test_name",
|
||||
"username": "test_username",
|
||||
"rel": "0yxhwia2amd8gec"
|
||||
}
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
usersCol, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "test_id",
|
||||
Email: "oauth2@example.com", // should be ignored because of the explicit submitted email
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
usersCol.MFA.Enabled = false
|
||||
usersCol.OAuth2.Enabled = true
|
||||
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: "test",
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
if err := app.Save(usersCol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test_create@example.com"`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
`"username":"test_username"`,
|
||||
`"verified":false`,
|
||||
`"rel":"0yxhwia2amd8gec"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnRecordEnrich": 2, // the auth response and from the create request
|
||||
// ---
|
||||
"OnModelCreate": 3, // record + authOrigins + externalAuths
|
||||
"OnModelCreateExecute": 3,
|
||||
"OnModelAfterCreateSuccess": 3,
|
||||
"OnRecordCreate": 3,
|
||||
"OnRecordCreateExecute": 3,
|
||||
"OnRecordAfterCreateSuccess": 3,
|
||||
// ---
|
||||
"OnModelValidate": 3,
|
||||
"OnRecordValidate": 3,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindFirstRecordByData("users", "username", "test_username")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
|
||||
@@ -1071,6 +1201,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
@@ -1153,6 +1284,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1189,7 +1321,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and avatarURL->non-file field)",
|
||||
Name: "creating user (with mapped OAuth2 fields, case-sensitive username and avatarURL->non-file field)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -1209,7 +1341,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "oauth2_username",
|
||||
Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
|
||||
Name: "oauth2_name",
|
||||
AvatarURL: server.URL + "/oauth2_avatar.png",
|
||||
},
|
||||
@@ -1235,9 +1367,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"username":"oauth2_username"`,
|
||||
`"username":"tESt2_username"`,
|
||||
`"name":"http://127.`,
|
||||
`"verified":true`,
|
||||
`"avatar":""`,
|
||||
@@ -1273,7 +1406,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and duplicated username)",
|
||||
Name: "creating user (with mapped OAuth2 fields and duplicated case-insensitive username)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -1293,13 +1426,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "test2_username",
|
||||
Username: "tESt2_username",
|
||||
Name: "oauth2_name",
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// make the username index case-insensitive to ensure that case-insensitive match is used
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(usersCol.Indexes, "username")
|
||||
if ok {
|
||||
index.Columns[0].Collate = "nocase"
|
||||
usersCol.RemoveIndex(index.IndexName)
|
||||
usersCol.Indexes = append(usersCol.Indexes, index.Build())
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
usersCol.MFA.Enabled = false
|
||||
usersCol.OAuth2.Enabled = true
|
||||
@@ -1317,6 +1458,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1398,6 +1540,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
|
||||
@@ -26,6 +26,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOTP)
|
||||
|
||||
event := new(core.RecordAuthWithOTPRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = collection
|
||||
@@ -51,8 +53,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
|
||||
}
|
||||
|
||||
// since otps are usually simple digit numbers we enforce an extra rate limit rule to prevent enumerations
|
||||
err = checkRateLimit(e, "@pb_otp_"+event.OTP.Id+event.Record.Id, core.RateLimitRule{MaxRequests: 4, Duration: 180})
|
||||
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumaration protection
|
||||
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
|
||||
if err != nil {
|
||||
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
|
||||
}
|
||||
@@ -63,24 +65,29 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
// ---
|
||||
|
||||
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
|
||||
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// try to delete the used otp
|
||||
if e.OTP != nil {
|
||||
err = e.App.Delete(e.OTP)
|
||||
// update the user email verified state in case the OTP originate from an email address matching the current record one
|
||||
//
|
||||
// note: don't wait for success auth response (it could fail because of MFA) and because we already validated the OTP above
|
||||
otpSentTo := e.OTP.SentTo()
|
||||
if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
|
||||
e.Record.SetVerified(true)
|
||||
err = e.App.Save(e.Record)
|
||||
if err != nil {
|
||||
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
|
||||
e.App.Logger().Error("Failed to update record verified state after successful OTP validation",
|
||||
"error", err,
|
||||
"otpId", e.OTP.Id,
|
||||
"recordId", e.Record.Id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// note: we don't update the user verified state the same way as in the password reset confirmation
|
||||
// at the moment because it is not clear whether the otp confirmation came from the user email
|
||||
// (e.g. it could be from an sms or some other channel)
|
||||
// try to delete the used otp
|
||||
err = e.App.Delete(e.OTP)
|
||||
if err != nil {
|
||||
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
|
||||
}
|
||||
|
||||
return nil
|
||||
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid otp with valid password",
|
||||
Name: "valid otp with valid password (enabled MFA)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-otp",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -236,7 +236,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid otp with valid password (disabled MFA)",
|
||||
Name: "valid otp with valid password and empty sentTo (disabled MFA)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-otp",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -249,8 +249,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ensure that the user is unverified
|
||||
user.SetVerified(false)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// disable MFA
|
||||
user.Collection().MFA.Enabled = false
|
||||
if err := app.Save(user.Collection()); err != nil {
|
||||
if err = app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -262,6 +269,20 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
if err := app.Save(otp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextOTP {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOTP, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -297,6 +318,106 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
"OnRecordDeleteExecute": 1,
|
||||
"OnRecordAfterDeleteSuccess": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatal("Expected the user to remain unverified because sentTo != email")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid otp with valid password and nonempty sentTo=email (disabled MFA)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-otp",
|
||||
Body: strings.NewReader(`{
|
||||
"otpId":"` + strings.Repeat("a", 15) + `",
|
||||
"password":"123456"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ensure that the user is unverified
|
||||
user.SetVerified(false)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// disable MFA
|
||||
user.Collection().MFA.Enabled = false
|
||||
if err = app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
otp := core.NewOTP(app)
|
||||
otp.Id = strings.Repeat("a", 15)
|
||||
otp.SetCollectionRef(user.Collection().Id)
|
||||
otp.SetRecordRef(user.Id)
|
||||
otp.SetPassword("123456")
|
||||
otp.SetSentTo(user.Email())
|
||||
if err := app.Save(otp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":"`,
|
||||
`"record":{`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"meta":`,
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOTPRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// ---
|
||||
"OnModelValidate": 2, // +1 because of the verified user update
|
||||
// authOrigin create
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
// OTP delete
|
||||
"OnModelDelete": 1,
|
||||
"OnModelDeleteExecute": 1,
|
||||
"OnModelAfterDeleteSuccess": 1,
|
||||
// user verified update
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
// ---
|
||||
"OnRecordValidate": 2,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordDelete": 1,
|
||||
"OnRecordDeleteExecute": 1,
|
||||
"OnRecordAfterDeleteSuccess": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatal("Expected the user to be marked as verified")
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
@@ -387,11 +508,13 @@ func TestRecordAuthWithOTPManualRateLimiterCheck(t *testing.T) {
|
||||
}{
|
||||
{otpAId, "12345", 400},
|
||||
{otpAId, "12345", 400},
|
||||
{otpAId, "12345", 400},
|
||||
{otpAId, "12345", 400},
|
||||
{otpAId, "123456", 429},
|
||||
{otpBId, "12345", 400},
|
||||
{otpBId, "123456", 200},
|
||||
{otpBId, "12345", 400},
|
||||
{otpBId, "12345", 400},
|
||||
{otpAId, "12345", 429},
|
||||
{otpAId, "123456", 429}, // reject even if it is correct
|
||||
{otpAId, "123456", 429},
|
||||
{otpBId, "123456", 429},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -3,10 +3,14 @@ package apis
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
@@ -28,16 +32,18 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextPasswordAuth)
|
||||
|
||||
var foundRecord *core.Record
|
||||
var foundErr error
|
||||
|
||||
if form.IdentityField != "" {
|
||||
foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, form.IdentityField, form.Identity)
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
|
||||
} else {
|
||||
// prioritize email lookup
|
||||
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
||||
if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
|
||||
foundRecord, foundErr = e.App.FindAuthRecordByEmail(collection.Id, form.Identity)
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
|
||||
}
|
||||
|
||||
// search by the other identity fields
|
||||
@@ -47,7 +53,7 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
||||
continue // no need to search by the email field if it is not an email
|
||||
}
|
||||
|
||||
foundRecord, foundErr = e.App.FindFirstRecordByData(collection.Id, name, form.Identity)
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
|
||||
if foundErr == nil {
|
||||
break
|
||||
}
|
||||
@@ -92,6 +98,38 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.IdentityField, validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...)),
|
||||
validation.Field(
|
||||
&form.IdentityField,
|
||||
validation.Length(1, 255),
|
||||
validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
|
||||
if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
|
||||
return nil, errors.New("invalid identity field " + field)
|
||||
}
|
||||
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field)
|
||||
if !ok {
|
||||
return nil, errors.New("missing " + field + " unique index constraint")
|
||||
}
|
||||
|
||||
var expr dbx.Expression
|
||||
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("[["+field+"]] = {:identity} COLLATE NOCASE", dbx.Params{"identity": value})
|
||||
} else {
|
||||
expr = dbx.HashExp{field: value}
|
||||
}
|
||||
|
||||
record := &core.Record{}
|
||||
|
||||
err := app.RecordQuery(collection).AndWhere(expr).Limit(1).One(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
@@ -8,11 +8,38 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
)
|
||||
|
||||
func TestRecordAuthWithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
updateIdentityIndex := func(collectionIdOrName string, fieldCollateMap map[string]string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
collection, err := app.FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for column, collate := range fieldCollateMap {
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, column)
|
||||
if !ok {
|
||||
t.Fatalf("Missing unique identityField index for column %q", column)
|
||||
}
|
||||
|
||||
index.Columns[0].Collate = collate
|
||||
|
||||
collection.RemoveIndex(index.IndexName)
|
||||
collection.Indexes = append(collection.Indexes, index.Build())
|
||||
}
|
||||
|
||||
err = app.Save(collection)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update identityField index: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "disabled password auth",
|
||||
@@ -99,6 +126,21 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextPasswordAuth {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextPasswordAuth, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
@@ -164,6 +206,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unknown explicit identityField",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identityField": "created",
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"identityField":{"code":"validation_in_invalid"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid identity field and valid password with mismatched explicit identityField",
|
||||
Method: http.MethodPost,
|
||||
@@ -440,6 +498,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
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
|
||||
+193
-64
@@ -1,17 +1,23 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
cryptoRand "crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// bindRecordCrudApi registers the record crud api endpoints and
|
||||
@@ -53,23 +59,27 @@ func recordsList(e *core.RequestEvent) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fieldsResolver := core.NewRecordFieldResolver(
|
||||
e.App,
|
||||
collection,
|
||||
requestInfo,
|
||||
// hidden fields are searchable only by superusers
|
||||
requestInfo.HasSuperuserAuth(),
|
||||
)
|
||||
query := e.App.RecordQuery(collection)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(e.App.RecordQuery(collection))
|
||||
fieldsResolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil && *collection.ListRule != "" {
|
||||
expr, err := search.FilterData(*collection.ListRule).BuildExpr(fieldsResolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.AndWhere(expr)
|
||||
|
||||
// will be applied by the search provider right before executing the query
|
||||
// fieldsResolver.UpdateQuery(query)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("", err))
|
||||
@@ -86,10 +96,44 @@ func recordsList(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.InternalServerError("Failed to enrich records", err))
|
||||
}
|
||||
|
||||
// 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 possibility of eventual
|
||||
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
|
||||
// This is by design and it is an accepted trade off between performance, usability and correctness.
|
||||
//
|
||||
// While technically the below doesn't fully guarantee protection against filter timing attacks, in practice combined with the network latency it makes them even less feasible.
|
||||
// A properly configured rate limiter or individual fields Hidden checks are better suited if you are really concerned about eventual information disclosure by side-channel attacks.
|
||||
//
|
||||
// In all cases it doesn't really matter that much because it doesn't affect the builtin PocketBase security sensitive fields (e.g. password and tokenKey) since they
|
||||
// are not client-side filterable and in the few places where they need to be compared against an external value, a constant time check is used.
|
||||
if !e.HasSuperuserAuth() &&
|
||||
(collection.ListRule != nil && *collection.ListRule != "") &&
|
||||
(requestInfo.Query["filter"] != "") &&
|
||||
len(e.Records) == 0 &&
|
||||
checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
|
||||
e.App.Logger().Debug("Randomized throttle because of too many failed searches", "collectionId", collection.Id)
|
||||
randomizedThrottle(150)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
|
||||
var listTimingRateLimitRule = core.RateLimitRule{MaxRequests: 3, Duration: 3}
|
||||
|
||||
func randomizedThrottle(softMax int64) {
|
||||
var timeout int64
|
||||
randRange, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(softMax))
|
||||
if err == nil {
|
||||
timeout = randRange.Int64()
|
||||
} else {
|
||||
timeout = softMax
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(timeout) * time.Millisecond)
|
||||
}
|
||||
|
||||
func recordView(e *core.RequestEvent) error {
|
||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
if err != nil || collection == nil {
|
||||
@@ -169,17 +213,7 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
|
||||
hasSuperuserAuth := requestInfo.HasSuperuserAuth()
|
||||
canSkipRuleCheck := hasSuperuserAuth
|
||||
|
||||
// special case for the first superuser creation
|
||||
// ---
|
||||
if !canSkipRuleCheck && collection.Name == core.CollectionNameSuperusers {
|
||||
total, totalErr := e.App.CountRecords(core.CollectionNameSuperusers)
|
||||
canSkipRuleCheck = totalErr == nil && total == 0
|
||||
}
|
||||
// ---
|
||||
|
||||
if !canSkipRuleCheck && collection.CreateRule == nil {
|
||||
if !hasSuperuserAuth && collection.CreateRule == nil {
|
||||
return e.ForbiddenError("Only superusers can perform this action.", nil)
|
||||
}
|
||||
|
||||
@@ -190,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))
|
||||
}
|
||||
|
||||
// set a random password for the OAuth2 ignoring its plain password validators
|
||||
var skipPlainPasswordRecordValidators bool
|
||||
if requestInfo.Context == core.RequestInfoContextOAuth2 {
|
||||
if _, ok := data[core.FieldNamePassword]; !ok {
|
||||
data[core.FieldNamePassword] = security.RandomString(30)
|
||||
data[core.FieldNamePassword+"Confirm"] = data[core.FieldNamePassword]
|
||||
skipPlainPasswordRecordValidators = true
|
||||
}
|
||||
}
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestInfo.Body
|
||||
requestInfo.Body = data
|
||||
@@ -200,6 +244,13 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
form.Load(data)
|
||||
|
||||
if skipPlainPasswordRecordValidators {
|
||||
// unset the plain value to skip the plain password field validators
|
||||
if raw, ok := record.GetRaw(core.FieldNamePassword).(*core.PasswordFieldValue); ok {
|
||||
raw.Plain = ""
|
||||
}
|
||||
}
|
||||
|
||||
var isOptFinalizerCalled bool
|
||||
|
||||
event := new(core.RecordRequestEvent)
|
||||
@@ -212,58 +263,75 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
form.SetRecord(e.Record)
|
||||
|
||||
// temporary save the record and check it against the create and manage rules
|
||||
if !canSkipRuleCheck && e.Collection.CreateRule != nil {
|
||||
// temporary grant manager access level
|
||||
form.GrantManagerAccess()
|
||||
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
|
||||
dummyRecord := e.Record.Clone()
|
||||
|
||||
// manually unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
||||
initialVerified := e.Record.Verified()
|
||||
if initialVerified {
|
||||
e.Record.SetVerified(false)
|
||||
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
|
||||
|
||||
// set an id if it doesn't have already
|
||||
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
|
||||
if dummyRecord.Id == "" {
|
||||
dummyRecord.Id = "__temp_id__" + dummyRandomPart
|
||||
}
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *e.Collection.CreateRule == "" {
|
||||
return nil // no create rule to resolve
|
||||
}
|
||||
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
||||
dummyRecord.SetVerified(false)
|
||||
|
||||
resolver := core.NewRecordFieldResolver(e.App, e.Collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*e.Collection.CreateRule).BuildExpr(resolver)
|
||||
// export the dummy record data into db params
|
||||
dummyExport, err := dummyRecord.DBExport(e.App)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
|
||||
}
|
||||
|
||||
dummyParams := make(dbx.Params, len(dummyExport))
|
||||
selects := make([]string, 0, len(dummyExport))
|
||||
var param string
|
||||
for k, v := range dummyExport {
|
||||
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
|
||||
param = "__pb_create__" + k
|
||||
dummyParams[param] = v
|
||||
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
|
||||
}
|
||||
|
||||
// shallow clone the current collection
|
||||
dummyCollection := *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 {
|
||||
return err
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
ruleQuery.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 {
|
||||
foundRecord, err := txApp.FindRecordById(drySavedRecord.Collection(), drySavedRecord.Id, createRuleFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
|
||||
// reset the form access level in case it satisfies the Manage API rule
|
||||
if !hasAuthManageAccess(txApp, requestInfo, foundRecord) {
|
||||
form.ResetAccess()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if testErr != nil {
|
||||
return e.BadRequestError("Failed to create record.", testErr)
|
||||
}
|
||||
|
||||
// restore initial verified state (it will be further validated on submit)
|
||||
if initialVerified != e.Record.Verified() {
|
||||
e.Record.SetVerified(initialVerified)
|
||||
// check for manage rule access
|
||||
manageRuleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
|
||||
if !form.HasManageAccess() &&
|
||||
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to create record.", err))
|
||||
return firstApiError(err, e.BadRequestError("Failed to create record", err))
|
||||
}
|
||||
|
||||
err = EnrichRecord(e.RequestEvent, e.Record)
|
||||
@@ -383,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 {
|
||||
form.SetApp(e.App)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -534,9 +608,28 @@ func recordDataFromRequest(e *core.RequestEvent, record *core.Record) (map[strin
|
||||
return nil, err
|
||||
}
|
||||
if len(uploadedFiles) > 0 {
|
||||
for k, v := range uploadedFiles {
|
||||
result[k] = v
|
||||
for k, files := range uploadedFiles {
|
||||
uploaded := make([]any, 0, len(files))
|
||||
|
||||
// if not remove/prepend/append -> merge with the submitted
|
||||
// info.Body values to prevent accidental old files deletion
|
||||
if info.Body[k] != nil &&
|
||||
!strings.HasPrefix(k, "+") &&
|
||||
!strings.HasSuffix(k, "+") &&
|
||||
!strings.HasSuffix(k, "-") {
|
||||
existing := list.ToUniqueStringSlice(info.Body[k])
|
||||
for _, name := range existing {
|
||||
uploaded = append(uploaded, name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
uploaded = append(uploaded, file)
|
||||
}
|
||||
|
||||
result[k] = uploaded
|
||||
}
|
||||
|
||||
result = record.ReplaceModifiers(result)
|
||||
}
|
||||
|
||||
@@ -597,3 +690,39 @@ func extractUploadedFiles(re *core.RequestEvent, collection *core.Collection, pr
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// hasAuthManageAccess checks whether the client is allowed to have
|
||||
// [forms.RecordUpsert] auth management permissions
|
||||
// (e.g. allowing to change system auth fields without oldPassword).
|
||||
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection *core.Collection, query *dbx.SelectQuery) bool {
|
||||
if !collection.IsAuth() {
|
||||
return false
|
||||
}
|
||||
|
||||
manageRule := collection.ManageRule
|
||||
|
||||
if manageRule == nil || *manageRule == "" {
|
||||
return false // only for superusers (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestInfo == nil || requestInfo.Auth == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
app.Logger().Error("Manage rule build expression error", "error", err, "collectionId", collection.Id)
|
||||
return false
|
||||
}
|
||||
query.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
var exists int
|
||||
|
||||
err = query.Limit(1).Row(&exists)
|
||||
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
@@ -229,41 +229,6 @@ func TestRecordCrudSuperuserCreate(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "guest creating first superuser",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
|
||||
Body: body(),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all superusers
|
||||
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedContent: []string{
|
||||
`"collectionName":"_superusers"`,
|
||||
`"verified":true`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// because the action has no auth the email field shouldn't be returned if emailVisibility is not set
|
||||
`"email"`,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "superusers auth",
|
||||
Method: http.MethodPost,
|
||||
|
||||
+117
-6
@@ -259,6 +259,110 @@ func TestRecordCrudList(t *testing.T) {
|
||||
"OnRecordEnrich": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated regular record that matches the collection list rule with hidden field",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo3/records",
|
||||
Headers: map[string]string{
|
||||
// clients, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
col, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock hidden field
|
||||
col.Fields.GetByName("title").SetHidden(true)
|
||||
|
||||
col.ListRule = types.Pointer("title ~ 'test'")
|
||||
|
||||
if err = app.Save(col); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":4`,
|
||||
`"items":[{`,
|
||||
`"id":"1tmknxy2868d869"`,
|
||||
`"id":"lcl9d87w22ml6jy"`,
|
||||
`"id":"7nwo8tuiatetxdm"`,
|
||||
`"id":"mk5fmymtx4wsprk"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordsListRequest": 1,
|
||||
"OnRecordEnrich": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated regular record filtering with a hidden field",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo3/records?filter=title~'test'",
|
||||
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)
|
||||
|
||||
if err = app.Save(col); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superuser filtering with a hidden field",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo3/records?filter=title~'test'",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
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)
|
||||
|
||||
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: ":rule modifer",
|
||||
Method: http.MethodGet,
|
||||
@@ -1684,11 +1788,16 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
// validate events are not fired because the unique check will fail during dry submit
|
||||
// "OnModelValidate": 1,
|
||||
// "OnRecordValidate": 1,
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateError": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateError": 1,
|
||||
"OnRecordValidate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2345,7 +2454,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
|
||||
router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123}`,
|
||||
router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123, "files":"300_JdfBOieXAW.png"}`,
|
||||
}, "files")
|
||||
if err3 != nil {
|
||||
t.Fatal(err3)
|
||||
@@ -2686,6 +2795,8 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
`"id":"mk5fmymtx4wsprk"`,
|
||||
`"title":"title_test3"`,
|
||||
`"files":["`,
|
||||
`"300_JdfBOieXAW.png"`,
|
||||
`"tmpfile_`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
|
||||
+48
-51
@@ -6,11 +6,13 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -20,6 +22,8 @@ const (
|
||||
fieldsQueryParam = "fields"
|
||||
)
|
||||
|
||||
var ErrMFA = errors.New("mfa required")
|
||||
|
||||
// RecordAuthResponse writes standardized json record auth response
|
||||
// into the specified request context.
|
||||
//
|
||||
@@ -70,9 +74,12 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
|
||||
|
||||
// require additional authentication
|
||||
if mfaId != "" {
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{
|
||||
// eagerly write the mfa response and return an err so that
|
||||
// external middlewars are aware that the auth response requires an extra step
|
||||
e.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"mfaId": mfaId,
|
||||
})
|
||||
return ErrMFA
|
||||
}
|
||||
// ---
|
||||
|
||||
@@ -109,20 +116,25 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"token": e.Token,
|
||||
"record": e.Record,
|
||||
result := struct {
|
||||
Meta any `json:"meta,omitempty"`
|
||||
Record *core.Record `json:"record"`
|
||||
Token string `json:"token"`
|
||||
}{
|
||||
Token: e.Token,
|
||||
Record: e.Record,
|
||||
}
|
||||
|
||||
if e.Meta != nil {
|
||||
result["meta"] = e.Meta
|
||||
result.Meta = e.Meta
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule.
|
||||
// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule
|
||||
// (note: returns true even in case of an error as a safer default).
|
||||
func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
rule := record.Collection().MFA.Rule
|
||||
if rule == "" {
|
||||
@@ -131,10 +143,10 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
|
||||
requestInfo, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return true, err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
query := e.App.RecordQuery(record.Collection()).
|
||||
Select("(1)").
|
||||
@@ -144,16 +156,16 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
resolver := core.NewRecordFieldResolver(e.App, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(rule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return true, err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
err = query.AndWhere(expr).Limit(1).Row(&exists)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, 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.
|
||||
@@ -166,11 +178,10 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
|
||||
}
|
||||
|
||||
ok, err := wantsMFA(e, authRecord)
|
||||
if err != nil {
|
||||
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
|
||||
}
|
||||
if !ok {
|
||||
if err != nil {
|
||||
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
|
||||
}
|
||||
|
||||
return "", nil // no mfa needed for this auth record
|
||||
}
|
||||
|
||||
@@ -214,7 +225,7 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
|
||||
}
|
||||
if err != nil || mfa.HasExpired(authRecord.Collection().MFA.DurationTime()) {
|
||||
deleteMFA()
|
||||
return "", firstApiError(err, e.BadRequestError("Invalid or expired MFA session.", err))
|
||||
return "", e.BadRequestError("Invalid or expired MFA session.", err)
|
||||
}
|
||||
|
||||
if mfa.RecordRef() != authRecord.Id || mfa.CollectionRef() != authRecord.Collection().Id {
|
||||
@@ -470,40 +481,6 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAuthManageAccess checks whether the client is allowed to have
|
||||
// [forms.RecordUpsert] auth management permissions
|
||||
// (e.g. allowing to change system auth fields without oldPassword).
|
||||
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, record *core.Record) bool {
|
||||
if !record.Collection().IsAuth() {
|
||||
return false
|
||||
}
|
||||
|
||||
manageRule := record.Collection().ManageRule
|
||||
|
||||
if manageRule == nil || *manageRule == "" {
|
||||
return false // only for superusers (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestInfo == nil || requestInfo.Auth == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := core.NewRecordFieldResolver(app, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, findErr := app.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
|
||||
return findErr == nil
|
||||
}
|
||||
|
||||
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
|
||||
var superuserOnlyRuleFields = []string{"@collection.", "@request."}
|
||||
|
||||
@@ -594,8 +571,28 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
|
||||
}
|
||||
|
||||
// send email alert for the new origin auth (skip first login)
|
||||
//
|
||||
// Note: The "fake" timeout is a temp solution to avoid blocking
|
||||
// for too long when the SMTP server is not accessible, due
|
||||
// to the lack of context cancellation support in the underlying
|
||||
// mailer and net/smtp package.
|
||||
// The goroutine technically "leaks" but we assume that the OS will
|
||||
// terminate the connection after some time (usually after 3-4 mins).
|
||||
if !isFirstLogin && currentOrigin.IsNew() && authRecord.Email() != "" {
|
||||
if err := mails.SendRecordAuthAlert(e.App, authRecord); err != nil {
|
||||
mailSent := make(chan error, 1)
|
||||
|
||||
timer := time.AfterFunc(15*time.Second, func() {
|
||||
mailSent <- errors.New("auth alert mail send wait timeout reached")
|
||||
})
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
err := mails.SendRecordAuthAlert(e.App, authRecord)
|
||||
timer.Stop()
|
||||
mailSent <- err
|
||||
})
|
||||
|
||||
err = <-mailSent
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -584,8 +585,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
|
||||
user.Collection().MFA.Rule = "1=1"
|
||||
|
||||
err = apis.RecordAuthResponse(event, user, "example", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected nil, got error: %v", err)
|
||||
if !errors.Is(err, apis.ErrMFA) {
|
||||
t.Fatalf("Expected ErrMFA, got: %v", err)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
@@ -602,8 +603,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
|
||||
resetMFAs(user)
|
||||
|
||||
err = apis.RecordAuthResponse(event, user, "example", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected nil, got error: %v", err)
|
||||
if !errors.Is(err, apis.ErrMFA) {
|
||||
t.Fatalf("Expected ErrMFA, got: %v", err)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
|
||||
+57
-38
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
@@ -26,12 +27,6 @@ type ServeConfig struct {
|
||||
// ShowStartBanner indicates whether to show or hide the server start console message.
|
||||
ShowStartBanner bool
|
||||
|
||||
// DashboardPath specifies the route path to the superusers dashboard interface
|
||||
// (default to "/_/{path...}").
|
||||
//
|
||||
// Note: Must include the "{path...}" wildcard parameter.
|
||||
DashboardPath string
|
||||
|
||||
// HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80").
|
||||
HttpAddr string
|
||||
|
||||
@@ -66,12 +61,6 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
config.AllowedOrigins = []string{"*"}
|
||||
}
|
||||
|
||||
if config.DashboardPath == "" {
|
||||
config.DashboardPath = "/_/{path...}"
|
||||
} else if !strings.HasSuffix(config.DashboardPath, "{path...}") {
|
||||
return errors.New("invalid dashboard path - missing {path...} wildcard")
|
||||
}
|
||||
|
||||
// ensure that the latest migrations are applied before starting the server
|
||||
err := app.RunAllMigrations()
|
||||
if err != nil {
|
||||
@@ -83,16 +72,25 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
pbRouter.Bind(CORSWithConfig(CORSConfig{
|
||||
pbRouter.Bind(CORS(CORSConfig{
|
||||
AllowOrigins: config.AllowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
pbRouter.BindFunc(installerRedirect(app, config.DashboardPath))
|
||||
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
|
||||
BindFunc(func(e *core.RequestEvent) error {
|
||||
// ignore root path
|
||||
if e.Request.PathValue(StaticWildcardParam) != "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
pbRouter.GET(config.DashboardPath, Static(ui.DistDirFS, false)).
|
||||
BindFunc(dashboardRemoveInstallerParam()).
|
||||
BindFunc(dashboardCacheControl()).
|
||||
// add a default CSP
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
|
||||
// start http server
|
||||
@@ -160,6 +158,7 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
serveEvent.Router = pbRouter
|
||||
serveEvent.Server = server
|
||||
serveEvent.CertManager = certManager
|
||||
serveEvent.InstallerFunc = DefaultInstallerFunc
|
||||
|
||||
var listener net.Listener
|
||||
|
||||
@@ -206,6 +205,8 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}()
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
var baseURL string
|
||||
|
||||
// trigger the OnServe hook and start the tcp listener
|
||||
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||
handler, err := e.Router.BuildMux()
|
||||
@@ -215,10 +216,20 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
|
||||
e.Server.Handler = handler
|
||||
|
||||
addr := e.Server.Addr
|
||||
if config.HttpsAddr == "" {
|
||||
baseURL = "http://" + serverAddrToHost(serveEvent.Server.Addr)
|
||||
} else {
|
||||
baseURL = "https://"
|
||||
if len(config.CertificateDomains) > 0 {
|
||||
baseURL += config.CertificateDomains[0]
|
||||
} else {
|
||||
baseURL += serverAddrToHost(serveEvent.Server.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
|
||||
addr := e.Server.Addr
|
||||
if addr == "" {
|
||||
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
|
||||
if config.HttpsAddr != "" {
|
||||
addr = ":https"
|
||||
} else {
|
||||
@@ -226,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 {
|
||||
return serveHookErr
|
||||
@@ -241,17 +263,6 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}
|
||||
|
||||
if config.ShowStartBanner {
|
||||
schema := "http"
|
||||
addr := server.Addr
|
||||
|
||||
if config.HttpsAddr != "" {
|
||||
schema = "https"
|
||||
|
||||
if len(config.CertificateDomains) > 0 {
|
||||
addr = config.CertificateDomains[0]
|
||||
}
|
||||
}
|
||||
|
||||
date := new(strings.Builder)
|
||||
log.New(date, "", log.LstdFlags).Print()
|
||||
|
||||
@@ -259,12 +270,12 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, addr),
|
||||
color.CyanString("%s", baseURL),
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
|
||||
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
|
||||
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
|
||||
}
|
||||
|
||||
var serveErr error
|
||||
@@ -275,10 +286,10 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}
|
||||
|
||||
// start HTTPS server
|
||||
serveErr = server.ServeTLS(listener, "", "")
|
||||
serveErr = serveEvent.Server.ServeTLS(listener, "", "")
|
||||
} else {
|
||||
// OR start HTTP server
|
||||
serveErr = server.Serve(listener)
|
||||
serveErr = serveEvent.Server.Serve(listener)
|
||||
}
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
return serveErr
|
||||
@@ -287,6 +298,14 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverAddrToHost loosely converts http.Server.Addr string into a host to print.
|
||||
func serverAddrToHost(addr string) string {
|
||||
if addr == "" || strings.HasSuffix(addr, ":http") || strings.HasSuffix(addr, ":https") {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
type serverErrorLogWriter struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
var allowedOrigins []string
|
||||
var httpAddr string
|
||||
var httpsAddr string
|
||||
var dashboardPath string
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "serve [domain(s)]",
|
||||
@@ -40,7 +39,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
err := apis.Serve(app, apis.ServeConfig{
|
||||
HttpAddr: httpAddr,
|
||||
HttpsAddr: httpsAddr,
|
||||
DashboardPath: dashboardPath,
|
||||
ShowStartBanner: showStartBanner,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
CertificateDomains: args,
|
||||
@@ -75,12 +73,5 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
"TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version",
|
||||
)
|
||||
|
||||
command.PersistentFlags().StringVar(
|
||||
&dashboardPath,
|
||||
"dashboard",
|
||||
"/_/{path...}",
|
||||
"The route path to the superusers dashboard; must include the '{path...}' wildcard parameter",
|
||||
)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
+51
-6
@@ -7,21 +7,23 @@ import (
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewSuperuserCommand creates and returns new command for managing
|
||||
// superuser accounts (create, update, delete).
|
||||
// superuser accounts (create, update, upsert, delete).
|
||||
func NewSuperuserCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "superuser",
|
||||
Short: "Manages superuser accounts",
|
||||
Short: "Manage superusers",
|
||||
}
|
||||
|
||||
command.AddCommand(superuserUpsertCommand(app))
|
||||
command.AddCommand(superuserCreateCommand(app))
|
||||
command.AddCommand(superuserUpdateCommand(app))
|
||||
command.AddCommand(superuserDeleteCommand(app))
|
||||
command.AddCommand(superuserOTPCommand(app))
|
||||
|
||||
return command
|
||||
}
|
||||
@@ -30,7 +32,7 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "upsert",
|
||||
Example: "superuser upsert test@example.com 1234567890",
|
||||
Short: "Creates, or updates if email exists, a single superuser account",
|
||||
Short: "Creates, or updates if email exists, a single superuser",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
@@ -70,7 +72,7 @@ func superuserCreateCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "create",
|
||||
Example: "superuser create test@example.com 1234567890",
|
||||
Short: "Creates a new superuser account",
|
||||
Short: "Creates a new superuser",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
@@ -106,7 +108,7 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "update",
|
||||
Example: "superuser update test@example.com 1234567890",
|
||||
Short: "Changes the password of a single superuser account",
|
||||
Short: "Changes the password of a single superuser",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
@@ -140,7 +142,7 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "delete",
|
||||
Example: "superuser delete test@example.com",
|
||||
Short: "Deletes an existing superuser account",
|
||||
Short: "Deletes an existing superuser",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
@@ -164,3 +166,46 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func superuserOTPCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "otp",
|
||||
Example: "superuser otp test@example.com",
|
||||
Short: "Creates a new OTP for the specified superuser",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Invalid or missing email address.")
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
|
||||
}
|
||||
|
||||
if !superuser.Collection().OTP.Enabled {
|
||||
return errors.New("OTP is not enabled for the _superusers collection.")
|
||||
}
|
||||
|
||||
pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
|
||||
|
||||
otp := core.NewOTP(app)
|
||||
otp.SetCollectionRef(superuser.Collection().Id)
|
||||
otp.SetRecordRef(superuser.Id)
|
||||
otp.SetPassword(pass)
|
||||
|
||||
err = app.Save(otp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create OTP: %w", err)
|
||||
}
|
||||
|
||||
color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
|
||||
color.Green("\n├─ Id: %s", otp.Id)
|
||||
color.Green("├─ Pass: %s", pass)
|
||||
color.Green("└─ Valid: %ds\n\n", superuser.Collection().OTP.Duration)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
@@ -308,3 +308,96 @@ func TestSuperuserDeleteCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperuserOTPCommand(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// remove all existing otps
|
||||
otps, err := app.FindAllOTPsByCollection(superusersCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, otp := range otps {
|
||||
err = app.Delete(otp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
email string
|
||||
enabled bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid",
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"nonexisting superuser",
|
||||
"test_missing@example.com",
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing superuser",
|
||||
"test@example.com",
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"existing superuser with disabled OTP",
|
||||
"test@example.com",
|
||||
false,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
command := cmd.NewSuperuserCommand(app)
|
||||
command.SetArgs([]string{"otp", s.email})
|
||||
|
||||
superusersCollection.OTP.Enabled = s.enabled
|
||||
if err = app.SaveNoValidate(superusersCollection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(superusersCollection, s.email)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
otps, _ := app.FindAllOTPsByRecord(superuser)
|
||||
if total := len(otps); total != 1 {
|
||||
t.Fatalf("Expected 1 OTP, got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+33
-11
@@ -71,7 +71,7 @@ type App interface {
|
||||
Settings() *Settings
|
||||
|
||||
// Store returns the app runtime store.
|
||||
Store() *store.Store[any]
|
||||
Store() *store.Store[string, any]
|
||||
|
||||
// Cron returns the app cron instance.
|
||||
Cron() *cron.Cron
|
||||
@@ -323,10 +323,10 @@ type App interface {
|
||||
// FindLogById finds a single Log entry by its id.
|
||||
FindLogById(id string) (*Log, error)
|
||||
|
||||
// LogsStatsItem defines the total number of logs for a specific time period.
|
||||
// LogsStatsItem returns hourly grouped logs statistics.
|
||||
LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error)
|
||||
|
||||
// DeleteOldLogs delete all requests that are created before createdBefore.
|
||||
// DeleteOldLogs delete all logs that are created before createdBefore.
|
||||
DeleteOldLogs(createdBefore time.Time) error
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -369,16 +369,9 @@ type App interface {
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections()]
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections]
|
||||
FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error)
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
IsCollectionNameUnique(name string, excludeIds ...string) bool
|
||||
|
||||
// FindCollectionReferences returns information for all relation
|
||||
// fields referencing the provided collection.
|
||||
//
|
||||
@@ -387,6 +380,32 @@ type App interface {
|
||||
// as the excludeIds argument.
|
||||
FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
|
||||
|
||||
// FindCachedCollectionReferences is similar to [App.FindCollectionReferences]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [App.FindCollectionReferences] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
// - The returned Collection should be used only for read-only operations.
|
||||
// Avoid directly modifying the returned cached Collection as it will affect
|
||||
// the global cached value even if you don't persist the changes in the database!
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections].
|
||||
FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
IsCollectionNameUnique(name string, excludeIds ...string) bool
|
||||
|
||||
// TruncateCollection deletes all records associated with the provided collection.
|
||||
//
|
||||
// The truncate operation is executed in a single transaction,
|
||||
@@ -1361,6 +1380,9 @@ type App interface {
|
||||
// OnRecordRequestOTPRequest hook is triggered on each Record
|
||||
// request OTP API request.
|
||||
//
|
||||
// [RecordCreateOTPRequestEvent.Record] could be nil if no matching identity is found, allowing
|
||||
// you to manually create or locate a different Record model (by reassigning [RecordCreateOTPRequestEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
|
||||
+25
-9
@@ -70,7 +70,7 @@ var _ App = (*BaseApp)(nil)
|
||||
type BaseApp struct {
|
||||
config *BaseAppConfig
|
||||
txInfo *txAppInfo
|
||||
store *store.Store[any]
|
||||
store *store.Store[string, any]
|
||||
cron *cron.Cron
|
||||
settings *Settings
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
@@ -194,7 +194,7 @@ type BaseApp struct {
|
||||
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
settings: newDefaultSettings(),
|
||||
store: store.New[any](nil),
|
||||
store: store.New[string, any](nil),
|
||||
cron: cron.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
config: &config,
|
||||
@@ -340,8 +340,6 @@ func (app *BaseApp) initHooks() {
|
||||
// NB! Note that using the returned app instance may cause data integrity errors
|
||||
// since the Record validations and data normalizations (including files uploads)
|
||||
// rely on the app hooks to work.
|
||||
//
|
||||
// @todo consider caching the created instance?
|
||||
func (app *BaseApp) UnsafeWithoutHooks() App {
|
||||
clone := *app
|
||||
|
||||
@@ -534,7 +532,7 @@ func (app *BaseApp) Settings() *Settings {
|
||||
}
|
||||
|
||||
// Store returns the app runtime store.
|
||||
func (app *BaseApp) Store() *store.Store[any] {
|
||||
func (app *BaseApp) Store() *store.Store[string, any] {
|
||||
return app.store
|
||||
}
|
||||
|
||||
@@ -603,6 +601,14 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
|
||||
}
|
||||
|
||||
if len(ae.Message.InlineAttachments) > 0 {
|
||||
attachmentKeys := make([]string, 0, len(ae.Message.InlineAttachments))
|
||||
for k := range ae.Message.InlineAttachments {
|
||||
attachmentKeys = append(attachmentKeys, k)
|
||||
}
|
||||
fmt.Fprintf(mailLog, "├─ InlineAttachments: %v\n", attachmentKeys)
|
||||
}
|
||||
|
||||
const indentation = " "
|
||||
if ae.Message.Text != "" {
|
||||
textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
|
||||
@@ -1140,8 +1146,8 @@ var sqlLogReplacements = map[string]string{
|
||||
"]]": "`",
|
||||
"<nil>": "NULL",
|
||||
}
|
||||
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[(.+)\.(.+)\]\]`)
|
||||
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{(.+)\.(.+)\}\}`)
|
||||
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`)
|
||||
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`)
|
||||
|
||||
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
|
||||
// The query is still not suitable for execution and should be used only for log and debug purposes
|
||||
@@ -1238,8 +1244,18 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
Priority: 999,
|
||||
})
|
||||
|
||||
app.Cron().Add("__pbPragmaOptimize__", "0 0 * * *", func() {
|
||||
_, execErr := app.DB().NewQuery("PRAGMA optimize").Execute()
|
||||
app.Cron().Add("__pbDBOptimize__", "0 0 * * *", func() {
|
||||
_, execErr := app.NonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
if execErr != nil {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the main DB", slog.String("error", execErr.Error()))
|
||||
}
|
||||
|
||||
_, execErr = app.AuxNonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
if execErr != nil {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
|
||||
}
|
||||
|
||||
_, execErr = app.DB().NewQuery("PRAGMA optimize").Execute()
|
||||
if execErr != nil {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
|
||||
}
|
||||
|
||||
+69
-39
@@ -76,6 +76,11 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
|
||||
createErr := e.App.RunInTransaction(func(txApp App) error {
|
||||
return txApp.AuxRunInTransaction(func(txApp App) error {
|
||||
// run manual checkpoint and truncate the WAL files
|
||||
// (errors are ignored because it is not that important and the PRAGMA may not be supported by the used driver)
|
||||
txApp.DB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
txApp.AuxDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
|
||||
return archive.Create(txApp.DataDir(), tempPath, e.Exclude...)
|
||||
})
|
||||
})
|
||||
@@ -137,6 +142,10 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
//
|
||||
// Note that if your pb_data has custom network mounts as subdirectories, then
|
||||
// it is possible the restore to fail during the `os.Rename` operations
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/4647).
|
||||
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
@@ -157,6 +166,13 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
return errors.New("restore is not supported on Windows")
|
||||
}
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
fsys, err := e.App.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -165,57 +181,71 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
fsys.SetContext(e.Context)
|
||||
|
||||
// fetch the backup file in a temp location
|
||||
br, err := fsys.GetFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
if ok, _ := fsys.Exists(name); !ok {
|
||||
return fmt.Errorf("missing or invalid backup file %q to restore", name)
|
||||
}
|
||||
|
||||
// create a temp zip file from the blob.Reader and try to extract it
|
||||
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tempZip.Name())
|
||||
|
||||
if _, err := io.Copy(tempZip, br); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(8))
|
||||
defer os.RemoveAll(extractedDataDir)
|
||||
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
|
||||
return err
|
||||
|
||||
// extract the zip
|
||||
if e.App.Settings().Backups.S3.Enabled {
|
||||
br, err := fsys.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")
|
||||
if _, err := os.Stat(extractedDB); err != nil {
|
||||
return fmt.Errorf("data.db file is missing or invalid: %w", err)
|
||||
}
|
||||
|
||||
// remove the extracted zip file since we no longer need it
|
||||
// (this is in case the app restarts and the defer calls are not called)
|
||||
if err := os.Remove(tempZip.Name()); err != nil {
|
||||
e.App.Logger().Debug(
|
||||
"[RestoreBackup] Failed to remove the temp zip backup file",
|
||||
slog.String("file", tempZip.Name()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// move the current pb_data content to a special temp location
|
||||
// that will hold the old data between dirs replace
|
||||
// (the temp dir will be automatically removed on the next app start)
|
||||
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
|
||||
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
|
||||
if err := osutils.MoveDirContent(e.App.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
|
||||
}
|
||||
@@ -252,7 +282,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// registerAutobackupHooks registers the autobackup app serve hooks.
|
||||
func (app *BaseApp) registerAutobackupHooks() {
|
||||
const jobId = "__auto_pb_backup__"
|
||||
const jobId = "__pbAutoBackup__"
|
||||
|
||||
loadJob := func() {
|
||||
rawSchedule := app.Settings().Backups.Cron
|
||||
|
||||
@@ -86,6 +86,12 @@ func (app *BaseApp) ImportCollections(toImport []map[string]any, deleteMissing b
|
||||
continue
|
||||
}
|
||||
if imported.Fields.GetById(f.GetId()) == nil {
|
||||
// replace with the existing id to prevent accidental column deletion
|
||||
// since otherwise the imported field will be treated as a new one
|
||||
found := imported.Fields.GetByName(f.GetName())
|
||||
if found != nil && found.Type() == f.Type() {
|
||||
found.SetId(f.GetId())
|
||||
}
|
||||
imported.Fields.Add(f)
|
||||
}
|
||||
}
|
||||
|
||||
+62
-18
@@ -28,15 +28,20 @@ const (
|
||||
|
||||
const systemHookIdCollection = "__pbCollectionSystemHook__"
|
||||
|
||||
const defaultLowercaseRecordIdPattern = "^[a-z0-9]+$"
|
||||
|
||||
func (app *BaseApp) registerCollectionHooks() {
|
||||
app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -48,10 +53,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -63,10 +71,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -78,10 +89,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -93,10 +107,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -108,10 +125,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -123,10 +143,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -138,10 +161,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -153,10 +179,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -168,10 +197,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -183,10 +215,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -198,10 +233,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -213,10 +251,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -721,9 +762,9 @@ func (c *Collection) updateGeneratedIdIfExists(app App) {
|
||||
|
||||
// add a number to the current id (if already exists)
|
||||
for i := 2; i < 1000; i++ {
|
||||
var exists bool
|
||||
var exists int
|
||||
_ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
|
||||
if !exists {
|
||||
if exists == 0 {
|
||||
break
|
||||
}
|
||||
newId = c.idChecksum() + strconv.Itoa(i)
|
||||
@@ -890,7 +931,7 @@ func (c *Collection) initIdField() {
|
||||
Required: true,
|
||||
Min: 15,
|
||||
Max: 15,
|
||||
Pattern: `^[a-z0-9]+$`,
|
||||
Pattern: defaultLowercaseRecordIdPattern,
|
||||
AutogeneratePattern: `[a-z0-9]{15}`,
|
||||
}
|
||||
|
||||
@@ -902,6 +943,9 @@ func (c *Collection) initIdField() {
|
||||
field.Required = true
|
||||
field.PrimaryKey = true
|
||||
field.Hidden = false
|
||||
if field.Pattern == "" {
|
||||
field.Pattern = defaultLowercaseRecordIdPattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,7 +989,7 @@ func (c *Collection) initTokenKeyField() {
|
||||
}
|
||||
|
||||
// ensure that there is a unique index for the field
|
||||
if !dbutils.HasSingleColumnUniqueIndex(FieldNameTokenKey, c.Indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
|
||||
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
|
||||
c.fieldIndexName(FieldNameTokenKey),
|
||||
@@ -971,7 +1015,7 @@ func (c *Collection) initEmailField() {
|
||||
}
|
||||
|
||||
// ensure that there is a unique index for the email field
|
||||
if !dbutils.HasSingleColumnUniqueIndex(FieldNameEmail, c.Indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
|
||||
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
|
||||
c.fieldIndexName(FieldNameEmail),
|
||||
|
||||
@@ -445,7 +445,7 @@ func checkForDuplicatedProviders(value any) error {
|
||||
if _, ok := existing[c.Name]; ok {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.Errors{
|
||||
"name": validation.NewError("validation_duplicated_provider", "The provider "+c.Name+" is already registered.").
|
||||
"name": validation.NewError("validation_duplicated_provider", "The provider {{.name}} is already registered.").
|
||||
SetParams(map[string]any{"name": c.Name}),
|
||||
},
|
||||
}
|
||||
@@ -493,7 +493,7 @@ func checkProviderName(value any) error {
|
||||
}
|
||||
|
||||
if _, err := auth.NewProviderByName(name); err != nil {
|
||||
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name "+name+".").
|
||||
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name {{.name}}.").
|
||||
SetParams(map[string]any{"name": name})
|
||||
}
|
||||
|
||||
|
||||
+71
-28
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -75,16 +76,16 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
|
||||
// FindCachedCollectionByNameOrId is similar to [BaseApp.FindCollectionByNameOrId]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// Returns [sql.ErrNoRows] if no Collection is found for consistency
|
||||
// with the [App.FindCollectionByNameOrId] method.
|
||||
// with the [BaseApp.FindCollectionByNameOrId] method.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [App.FindCollectionByNameOrId] instead.
|
||||
// use [BaseApp.FindCollectionByNameOrId] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
@@ -94,7 +95,7 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections()]
|
||||
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
|
||||
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
|
||||
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
||||
if collections == nil {
|
||||
@@ -111,30 +112,6 @@ func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
query := app.CollectionQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindCollectionReferences returns information for all relation fields
|
||||
// referencing the provided collection.
|
||||
//
|
||||
@@ -168,6 +145,72 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FindCachedCollectionReferences is similar to [BaseApp.FindCollectionReferences]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [BaseApp.FindCollectionReferences] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
// - The returned Collection should be used only for read-only operations.
|
||||
// Avoid directly modifying the returned cached Collection as it will affect
|
||||
// the global cached value even if you don't persist the changes in the database!
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
|
||||
func (app *BaseApp) FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
|
||||
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
||||
if collections == nil {
|
||||
// cache is not initialized yet (eg. run in a system migration)
|
||||
return app.FindCollectionReferences(collection, excludeIds...)
|
||||
}
|
||||
|
||||
result := map[*Collection][]Field{}
|
||||
|
||||
for _, c := range collections {
|
||||
if slices.Contains(excludeIds, c.Id) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rawField := range c.Fields {
|
||||
f, ok := rawField.(*RelationField)
|
||||
if ok && f.CollectionId == collection.Id {
|
||||
result[c] = append(result[c], f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
query := app.CollectionQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var total int
|
||||
|
||||
return query.Row(&total) == nil && total == 0
|
||||
}
|
||||
|
||||
// TruncateCollection deletes all records associated with the provided collection.
|
||||
//
|
||||
// The truncate operation is executed in a single transaction,
|
||||
|
||||
+109
-28
@@ -208,34 +208,6 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
|
||||
run(false)
|
||||
}
|
||||
|
||||
func TestIsCollectionNameUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
excludeId string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"demo1", "", false},
|
||||
{"Demo1", "", false},
|
||||
{"new", "", true},
|
||||
{"demo1", "wsmn24bux7wo113", true},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
|
||||
result := app.IsCollectionNameUnique(s.name, s.excludeId)
|
||||
if result != s.expected {
|
||||
t.Errorf("Expected %v, got %v", s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -288,6 +260,115 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCachedCollectionReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
totalQueries := 0
|
||||
app.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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -349,9 +349,7 @@ func createCollectionIndexes(app App, collection *Collection) error {
|
||||
errs[strconv.Itoa(i)] = validation.NewError(
|
||||
"validation_invalid_index_expression",
|
||||
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
|
||||
).SetParams(map[string]any{
|
||||
"indexName": parsed.IndexName,
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
+26
-10
@@ -268,7 +268,7 @@ func (validator *collectionValidator) checkFieldDuplicates(value any) error {
|
||||
strconv.Itoa(i): validation.Errors{
|
||||
"name": validation.NewError(
|
||||
"validation_duplicated_field_name",
|
||||
fmt.Sprintf("Duplicated or invalid field name %q", field.GetName()),
|
||||
"Duplicated or invalid field name {{.fieldName}}",
|
||||
).SetParams(map[string]any{
|
||||
"fieldName": field.GetName(),
|
||||
}),
|
||||
@@ -452,12 +452,12 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
|
||||
for _, name := range names {
|
||||
field := cv.new.Fields.GetByName(name)
|
||||
if field == nil {
|
||||
return validation.NewError("validation_missing_field", fmt.Sprintf("Invalid or missing field %q", name)).
|
||||
return validation.NewError("validation_missing_field", "Invalid or missing field {{.fieldName}}").
|
||||
SetParams(map[string]any{"fieldName": name})
|
||||
}
|
||||
|
||||
if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
|
||||
return validation.NewError("validation_missing_unique_constraint", fmt.Sprintf("The field %q doesn't have a UNIQUE constraint.", name)).
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(cv.new.Indexes, name); !ok {
|
||||
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
|
||||
SetParams(map[string]any{"fieldName": name})
|
||||
}
|
||||
}
|
||||
@@ -566,7 +566,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.NewError(
|
||||
"validation_existing_index_name",
|
||||
"The index name is already used in "+usedTblName+" collection.",
|
||||
"The index name is already used in {{.usedTableName}} collection.",
|
||||
).SetParams(map[string]any{"usedTableName": usedTblName}),
|
||||
}
|
||||
}
|
||||
@@ -608,6 +608,12 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// reset collate and sort since they are not important for the unique constraint
|
||||
for i := range oldParsed.Columns {
|
||||
oldParsed.Columns[i].Collate = ""
|
||||
oldParsed.Columns[i].Sort = ""
|
||||
}
|
||||
|
||||
oldParsedStr := oldParsed.Build()
|
||||
|
||||
for _, column := range oldParsed.Columns {
|
||||
@@ -621,19 +627,29 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
newParsed := dbutils.ParseIndex(newIndex)
|
||||
|
||||
// exclude the non-important identifiers from the check
|
||||
newParsed.SchemaName = oldParsed.SchemaName
|
||||
newParsed.IndexName = oldParsed.IndexName
|
||||
newParsed.TableName = oldParsed.TableName
|
||||
|
||||
// exclude partial constraints
|
||||
newParsed.Where = oldParsed.Where
|
||||
|
||||
// reset collate and sort
|
||||
for i := range newParsed.Columns {
|
||||
newParsed.Columns[i].Collate = ""
|
||||
newParsed.Columns[i].Sort = ""
|
||||
}
|
||||
|
||||
if oldParsedStr == newParsed.Build() {
|
||||
hasMatch = true
|
||||
continue
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasMatch {
|
||||
return validation.NewError(
|
||||
"validation_unique_system_field_index_change",
|
||||
fmt.Sprintf("Unique index definition on system fields (%q) cannot be changed.", f.GetName()),
|
||||
"validation_invalid_unique_system_field_index",
|
||||
"Unique index definition on system fields ({{.fieldName}}) is invalid or missing.",
|
||||
).SetParams(map[string]any{"fieldName": f.GetName()})
|
||||
}
|
||||
|
||||
@@ -650,10 +666,10 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
if cv.new.IsAuth() {
|
||||
requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
|
||||
for _, name := range requiredNames {
|
||||
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
|
||||
return validation.NewError(
|
||||
"validation_missing_required_unique_index",
|
||||
`Missing required unique index for field "`+name+`".`,
|
||||
`Missing required unique index for field "{{.fieldName}}".`,
|
||||
).SetParams(map[string]any{"fieldName": name})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +550,7 @@ func TestCollectionValidate(t *testing.T) {
|
||||
expectedErrors: []string{"indexes"},
|
||||
},
|
||||
{
|
||||
name: "changing index on system field",
|
||||
name: "changing partial constraint of existing index on system field",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
demo2, err := app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
@@ -571,7 +571,91 @@ func TestCollectionValidate(t *testing.T) {
|
||||
|
||||
// replace the index with a partial one
|
||||
demo2.RemoveIndex("idx_unique_demo2_title")
|
||||
demo2.AddIndex("idx_unique_demo2_title", true, "title", "1 = 1")
|
||||
demo2.AddIndex("idx_new_demo2_title", true, "title", "1 = 1")
|
||||
|
||||
return demo2, nil
|
||||
},
|
||||
expectedErrors: []string{},
|
||||
},
|
||||
{
|
||||
name: "changing column sort and collate of existing index on system field",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
demo2, err := app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// mark the title field as system
|
||||
demo2.Fields.GetByName("title").SetSystem(true)
|
||||
if err = app.Save(demo2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// refresh
|
||||
demo2, err = app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// replace the index with a new one for the same column but with collate and sort
|
||||
demo2.RemoveIndex("idx_unique_demo2_title")
|
||||
demo2.AddIndex("idx_new_demo2_title", true, "title COLLATE test ASC", "")
|
||||
|
||||
return demo2, nil
|
||||
},
|
||||
expectedErrors: []string{},
|
||||
},
|
||||
{
|
||||
name: "adding new column to index on system field",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
demo2, err := app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// mark the title field as system
|
||||
demo2.Fields.GetByName("title").SetSystem(true)
|
||||
if err = app.Save(demo2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// refresh
|
||||
demo2, err = app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// replace the index with a non-unique one
|
||||
demo2.RemoveIndex("idx_unique_demo2_title")
|
||||
demo2.AddIndex("idx_new_title", false, "title, id", "")
|
||||
|
||||
return demo2, nil
|
||||
},
|
||||
expectedErrors: []string{"indexes"},
|
||||
},
|
||||
{
|
||||
name: "changing index type on system field",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
demo2, err := app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// mark the title field as system
|
||||
demo2.Fields.GetByName("title").SetSystem(true)
|
||||
if err = app.Save(demo2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// refresh
|
||||
demo2, err = app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// replace the index with a non-unique one (partial constraints are ignored)
|
||||
demo2.RemoveIndex("idx_unique_demo2_title")
|
||||
demo2.AddIndex("idx_new_title", false, "title", "1=1")
|
||||
|
||||
return demo2, nil
|
||||
},
|
||||
@@ -620,7 +704,7 @@ func TestCollectionValidate(t *testing.T) {
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
c := core.NewBaseCollection("new_auth")
|
||||
c.Fields = core.NewFieldsList(
|
||||
&core.TextField{Name: "id", PrimaryKey: true, Required: true},
|
||||
&core.TextField{Name: "id", PrimaryKey: true, Required: true, Pattern: `\w+`},
|
||||
)
|
||||
return c, nil
|
||||
},
|
||||
@@ -631,7 +715,7 @@ func TestCollectionValidate(t *testing.T) {
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
c := core.NewBaseCollection("new_auth")
|
||||
c.Fields = core.NewFieldsList(
|
||||
&core.TextField{Name: "id", PrimaryKey: true, Required: true},
|
||||
&core.TextField{Name: "id", PrimaryKey: true, Required: true, Pattern: `\w+`},
|
||||
&core.TextField{Id: "f1", Name: "Test"}, // case-insensitive
|
||||
&core.BoolField{Id: "f2", Name: "test"},
|
||||
)
|
||||
|
||||
+5
-6
@@ -59,7 +59,7 @@ func GenerateDefaultRandomId() string {
|
||||
|
||||
// crc32Checksum generates a stringified crc32 checksum from the provided plain string.
|
||||
func crc32Checksum(str string) string {
|
||||
return strconv.Itoa(int(crc32.ChecksumIEEE([]byte(str))))
|
||||
return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
|
||||
}
|
||||
|
||||
// ModelQuery creates a new preconfigured select app.DB() query with preset
|
||||
@@ -116,8 +116,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
|
||||
|
||||
deleteErr := app.OnModelDelete().Trigger(event, func(e *ModelEvent) error {
|
||||
pk := cast.ToString(e.Model.LastSavedPK())
|
||||
|
||||
if cast.ToString(pk) == "" {
|
||||
if pk == "" {
|
||||
return errors.New("the model can be deleted only if it is existing and has a non-empty primary key")
|
||||
}
|
||||
|
||||
@@ -464,7 +463,7 @@ func validateCollectionId(app App, optTypes ...string) validation.RuleFunc {
|
||||
return validation.NewError(
|
||||
"validation_invalid_collection_type",
|
||||
fmt.Sprintf("Invalid collection type - must be %s.", strings.Join(optTypes, ", ")),
|
||||
).SetParams(map[string]any{"types": optTypes})
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -483,7 +482,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
|
||||
return validation.NewError("validation_invalid_collection", "Missing or invalid collection.")
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
rowErr := app.DB().Select("(1)").
|
||||
From(collection.Name).
|
||||
@@ -491,7 +490,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
if rowErr != nil || !exists {
|
||||
if rowErr != nil || exists == 0 {
|
||||
return validation.NewError("validation_invalid_record", "Missing or invalid record.")
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -46,14 +46,16 @@ func baseLockRetry(op func(attempt int) error, maxRetries int) error {
|
||||
Retry:
|
||||
err := op(attempt)
|
||||
|
||||
if err != nil &&
|
||||
attempt <= maxRetries &&
|
||||
// we are checking the plain error text to handle both cgo and noncgo errors
|
||||
strings.Contains(err.Error(), "database is locked") {
|
||||
// wait and retry
|
||||
time.Sleep(getDefaultRetryInterval(attempt))
|
||||
attempt++
|
||||
goto Retry
|
||||
if err != nil && attempt <= maxRetries {
|
||||
errStr := err.Error()
|
||||
// we are checking the error against the plain error texts since the codes could vary between drivers
|
||||
if strings.Contains(errStr, "database is locked") ||
|
||||
strings.Contains(errStr, "table is locked") {
|
||||
// wait and retry
|
||||
time.Sleep(getDefaultRetryInterval(attempt))
|
||||
attempt++
|
||||
goto Retry
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestBaseLockRetry(t *testing.T) {
|
||||
{nil, 3, 1},
|
||||
{errors.New("test"), 3, 1},
|
||||
{errors.New("database is locked"), 3, 3},
|
||||
{errors.New("table is locked"), 3, 3},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
|
||||
+2
-2
@@ -108,7 +108,7 @@ func (app *BaseApp) AuxHasTable(tableName string) bool {
|
||||
}
|
||||
|
||||
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
err := db.Select("(1)").
|
||||
From("sqlite_schema").
|
||||
@@ -117,7 +117,7 @@ func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
// Vacuum executes VACUUM on the current app.DB() instance
|
||||
|
||||
@@ -168,16 +168,28 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
|
||||
})
|
||||
|
||||
app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
afterCreateHookCalls++
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
afterUpdateHookCalls++
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
afterDeleteHookCalls++
|
||||
return e.Next()
|
||||
})
|
||||
@@ -233,3 +245,169 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
|
||||
t.Errorf("Expected afterDeleteHookCalls to be called 1 time, got %d", afterDeleteHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionFromInnerCreateHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.OnRecordCreateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
originalApp := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() {
|
||||
e.App = originalApp
|
||||
}()
|
||||
|
||||
nextErr := e.Next()
|
||||
|
||||
return nextErr
|
||||
})
|
||||
})
|
||||
|
||||
app.OnRecordAfterCreateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
// perform a db query with the app instance to ensure that it is still valid
|
||||
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
|
||||
record.Set("title", "test_inner_tx")
|
||||
|
||||
if err = app.Save(record); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
expectedHookCalls := map[string]int{
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
}
|
||||
for k, total := range expectedHookCalls {
|
||||
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionFromInnerUpdateHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.OnRecordUpdateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
originalApp := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() {
|
||||
e.App = originalApp
|
||||
}()
|
||||
|
||||
nextErr := e.Next()
|
||||
|
||||
return nextErr
|
||||
})
|
||||
})
|
||||
|
||||
app.OnRecordAfterUpdateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
// perform a db query with the app instance to ensure that it is still valid
|
||||
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = app.Save(existingModel); err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
expectedHookCalls := map[string]int{
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
}
|
||||
for k, total := range expectedHookCalls {
|
||||
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionFromInnerDeleteHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.OnRecordDeleteExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
originalApp := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() {
|
||||
e.App = originalApp
|
||||
}()
|
||||
|
||||
nextErr := e.Next()
|
||||
|
||||
return nextErr
|
||||
})
|
||||
})
|
||||
|
||||
app.OnRecordAfterDeleteSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||
if e.App.IsTransactional() {
|
||||
t.Fatal("Expected e.App to be non-transactional")
|
||||
}
|
||||
|
||||
// perform a db query with the app instance to ensure that it is still valid
|
||||
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = app.Delete(existingModel); err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
expectedHookCalls := map[string]int{
|
||||
"OnRecordDeleteExecute": 1,
|
||||
"OnRecordAfterDeleteSuccess": 1,
|
||||
}
|
||||
for k, total := range expectedHookCalls {
|
||||
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func (e *RequestEvent) HasSuperuserAuth() bool {
|
||||
// RequestInfo parses the current request into RequestInfo instance.
|
||||
//
|
||||
// Note that the returned result is cached to avoid copying the request data multiple times
|
||||
// but the auth state and other common store items are always refreshed in case they were changed my another handler.
|
||||
// but the auth state and other common store items are always refreshed in case they were changed by another handler.
|
||||
func (e *RequestEvent) RequestInfo() (*RequestInfo, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
@@ -154,8 +154,10 @@ const (
|
||||
RequestInfoContextExpand = "expand"
|
||||
RequestInfoContextRealtime = "realtime"
|
||||
RequestInfoContextProtectedFile = "protectedFile"
|
||||
RequestInfoContextOAuth2 = "oauth2"
|
||||
RequestInfoContextBatch = "batch"
|
||||
RequestInfoContextOAuth2 = "oauth2"
|
||||
RequestInfoContextOTP = "otp"
|
||||
RequestInfoContextPasswordAuth = "password"
|
||||
)
|
||||
|
||||
// RequestInfo defines a HTTP request data struct, usually used
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestEventRequestRealIP(t *testing.T) {
|
||||
"CF-Connecting-IP": {"1.2.3.4", "1.1.1.1"},
|
||||
"Fly-Client-IP": {"1.2.3.4", "1.1.1.2"},
|
||||
"X-Real-IP": {"1.2.3.4", "1.1.1.3,1.1.1.4"},
|
||||
"X-Forward-For": {"1.2.3.4", "invalid,1.1.1.5,1.1.1.6,invalid"},
|
||||
"X-Forwarded-For": {"1.2.3.4", "invalid,1.1.1.5,1.1.1.6,invalid"},
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
@@ -44,28 +44,28 @@ func TestEventRequestRealIP(t *testing.T) {
|
||||
{
|
||||
"trusted X-Real-IP (rightmost)",
|
||||
headers,
|
||||
[]string{"header1", "x-real-ip", "x-forward-for"},
|
||||
[]string{"header1", "x-real-ip", "x-forwarded-for"},
|
||||
false,
|
||||
"1.1.1.4",
|
||||
},
|
||||
{
|
||||
"trusted X-Real-IP (leftmost)",
|
||||
headers,
|
||||
[]string{"header1", "x-real-ip", "x-forward-for"},
|
||||
[]string{"header1", "x-real-ip", "x-forwarded-for"},
|
||||
true,
|
||||
"1.1.1.3",
|
||||
},
|
||||
{
|
||||
"trusted X-Forward-For (rightmost)",
|
||||
"trusted X-Forwarded-For (rightmost)",
|
||||
headers,
|
||||
[]string{"header1", "x-forward-for"},
|
||||
[]string{"header1", "x-forwarded-for"},
|
||||
false,
|
||||
"1.1.1.6",
|
||||
},
|
||||
{
|
||||
"trusted X-Forward-For (leftmost)",
|
||||
"trusted X-Forwarded-For (leftmost)",
|
||||
headers,
|
||||
[]string{"header1", "x-forward-for"},
|
||||
[]string{"header1", "x-forwarded-for"},
|
||||
true,
|
||||
"1.1.1.5",
|
||||
},
|
||||
|
||||
+44
-3
@@ -103,6 +103,22 @@ type ServeEvent struct {
|
||||
Router *router.Router[*RequestEvent]
|
||||
Server *http.Server
|
||||
CertManager *autocert.Manager
|
||||
|
||||
// InstallerFunc is the "installer" function that is called after
|
||||
// successful server tcp bind but only if there is no explicit
|
||||
// superuser record created yet.
|
||||
//
|
||||
// It runs in a separate goroutine and its default value is [apis.DefaultInstallerFunc].
|
||||
//
|
||||
// It receives a system superuser record as argument that you can use to generate
|
||||
// a short-lived auth token (e.g. systemSuperuser.NewStaticAuthToken(30 * time.Minute))
|
||||
// and concatenate it as query param for your installer page
|
||||
// (if you are using the client-side SDKs, you can then load the
|
||||
// token with pb.authStore.save(token) and perform any Web API request
|
||||
// e.g. creating a new superuser).
|
||||
//
|
||||
// Set it to nil if you want to skip the installer.
|
||||
InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -173,8 +189,8 @@ type ModelEvent struct {
|
||||
}
|
||||
|
||||
type ModelErrorEvent struct {
|
||||
ModelEvent
|
||||
Error error
|
||||
ModelEvent
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -196,8 +212,8 @@ type RecordEvent struct {
|
||||
}
|
||||
|
||||
type RecordErrorEvent struct {
|
||||
RecordEvent
|
||||
Error error
|
||||
RecordEvent
|
||||
}
|
||||
|
||||
func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
|
||||
@@ -216,6 +232,12 @@ func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
|
||||
// }
|
||||
}
|
||||
|
||||
func syncRecordEventWithModelEvent(re *RecordEvent, me *ModelEvent) {
|
||||
re.App = me.App
|
||||
re.Context = me.Context
|
||||
re.Type = me.Type
|
||||
}
|
||||
|
||||
func newRecordEventFromModelEvent(me *ModelEvent) (*RecordEvent, bool) {
|
||||
record, ok := me.Model.(*Record)
|
||||
if !ok {
|
||||
@@ -253,6 +275,11 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
|
||||
me.Error = re.Error
|
||||
}
|
||||
|
||||
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
|
||||
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
|
||||
me.Error = re.Error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Collection events data
|
||||
// -------------------------------------------------------------------
|
||||
@@ -272,8 +299,8 @@ type CollectionEvent struct {
|
||||
}
|
||||
|
||||
type CollectionErrorEvent struct {
|
||||
CollectionEvent
|
||||
Error error
|
||||
CollectionEvent
|
||||
}
|
||||
|
||||
func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
||||
@@ -283,6 +310,15 @@ func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
||||
me.Model = ce.Collection
|
||||
}
|
||||
|
||||
func syncCollectionEventWithModelEvent(ce *CollectionEvent, me *ModelEvent) {
|
||||
ce.App = me.App
|
||||
ce.Context = me.Context
|
||||
ce.Type = me.Type
|
||||
if c, ok := me.Model.(*Collection); ok {
|
||||
ce.Collection = c
|
||||
}
|
||||
}
|
||||
|
||||
func newCollectionEventFromModelEvent(me *ModelEvent) (*CollectionEvent, bool) {
|
||||
record, ok := me.Model.(*Collection)
|
||||
if !ok {
|
||||
@@ -316,6 +352,11 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
|
||||
me.Error = ce.Error
|
||||
}
|
||||
|
||||
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
|
||||
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
|
||||
me.Error = ce.Error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// File API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
+4
-2
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
var fieldNameRegex = regexp.MustCompile(`^\w+$`)
|
||||
|
||||
const maxSafeJSONInt int64 = 1<<53 - 1
|
||||
|
||||
// Commonly used field names.
|
||||
const (
|
||||
FieldNameId = "id"
|
||||
@@ -191,7 +193,7 @@ func DefaultFieldIdValidationRule(value any) error {
|
||||
|
||||
rules := []validation.Rule{
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
validation.Length(1, 100),
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
@@ -217,7 +219,7 @@ func DefaultFieldNameValidationRule(value any) error {
|
||||
|
||||
rules := []validation.Rule{
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
validation.Length(1, 100),
|
||||
validation.Match(fieldNameRegex),
|
||||
validation.NotIn(excludeNames...),
|
||||
validation.By(checkForVia),
|
||||
|
||||
+41
-16
@@ -16,6 +16,9 @@ func init() {
|
||||
|
||||
const FieldTypeAutodate = "autodate"
|
||||
|
||||
// used to keep track of the last set autodate value
|
||||
const autodateLastKnownPrefix = internalCustomFieldKeyPrefix + "_last_autodate_"
|
||||
|
||||
var (
|
||||
_ Field = (*AutodateField)(nil)
|
||||
_ SetterFinder = (*AutodateField)(nil)
|
||||
@@ -167,24 +170,46 @@ func (f *AutodateField) Intercept(
|
||||
actionFunc func() error,
|
||||
) error {
|
||||
switch actionName {
|
||||
case InterceptorActionCreate:
|
||||
// ignore for custom date manually set with record.SetRaw()
|
||||
if f.OnCreate && !f.hasBeenManuallyChanged(record) {
|
||||
record.SetRaw(f.Name, types.NowDateTime())
|
||||
case InterceptorActionCreateExecute:
|
||||
// ignore if a date different from the old one was manually set with SetRaw
|
||||
if f.OnCreate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
|
||||
now := types.NowDateTime()
|
||||
record.SetRaw(f.Name, now)
|
||||
record.SetRaw(autodateLastKnownPrefix+f.Name, now) // eagerly set so that it can be renewed on resave after failure
|
||||
}
|
||||
case InterceptorActionUpdate:
|
||||
// ignore for custom date manually set with record.SetRaw()
|
||||
if f.OnUpdate && !f.hasBeenManuallyChanged(record) {
|
||||
record.SetRaw(f.Name, types.NowDateTime())
|
||||
|
||||
if err := actionFunc(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record.SetRaw(autodateLastKnownPrefix+f.Name, record.GetRaw(f.Name))
|
||||
|
||||
return nil
|
||||
case InterceptorActionUpdateExecute:
|
||||
// ignore if a date different from the old one was manually set with SetRaw
|
||||
if f.OnUpdate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
|
||||
now := types.NowDateTime()
|
||||
record.SetRaw(f.Name, now)
|
||||
record.SetRaw(autodateLastKnownPrefix+f.Name, now) // eagerly set so that it can be renewed on resave after failure
|
||||
}
|
||||
|
||||
if err := actionFunc(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record.SetRaw(autodateLastKnownPrefix+f.Name, record.GetRaw(f.Name))
|
||||
|
||||
return nil
|
||||
default:
|
||||
return actionFunc()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *AutodateField) getLastKnownValue(record *Record) types.DateTime {
|
||||
v := record.GetDateTime(autodateLastKnownPrefix + f.Name)
|
||||
if !v.IsZero() {
|
||||
return v
|
||||
}
|
||||
|
||||
return actionFunc()
|
||||
}
|
||||
|
||||
func (f *AutodateField) hasBeenManuallyChanged(record *Record) bool {
|
||||
vNew, _ := record.GetRaw(f.Name).(types.DateTime)
|
||||
vOld, _ := record.Original().GetRaw(f.Name).(types.DateTime)
|
||||
|
||||
return vNew.String() != vOld.String()
|
||||
return record.Original().GetDateTime(f.Name)
|
||||
}
|
||||
|
||||
+102
-10
@@ -2,6 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -226,6 +228,13 @@ func TestAutodateFieldFindSetter(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func cutMilliseconds(datetime string) string {
|
||||
if len(datetime) > 19 {
|
||||
return datetime[:19]
|
||||
}
|
||||
return datetime
|
||||
}
|
||||
|
||||
func TestAutodateFieldIntercept(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -255,7 +264,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"create with zero value (disabled onCreate)",
|
||||
core.InterceptorActionCreate,
|
||||
core.InterceptorActionCreateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: false, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
return core.NewRecord(collection)
|
||||
@@ -264,7 +273,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"create with zero value",
|
||||
core.InterceptorActionCreate,
|
||||
core.InterceptorActionCreateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
return core.NewRecord(collection)
|
||||
@@ -273,7 +282,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"create with non-zero value",
|
||||
core.InterceptorActionCreate,
|
||||
core.InterceptorActionCreateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
@@ -284,7 +293,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"update with zero value (disabled onUpdate)",
|
||||
core.InterceptorActionUpdate,
|
||||
core.InterceptorActionUpdateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: false},
|
||||
func() *core.Record {
|
||||
return core.NewRecord(collection)
|
||||
@@ -293,7 +302,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"update with zero value",
|
||||
core.InterceptorActionUpdate,
|
||||
core.InterceptorActionUpdateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
return core.NewRecord(collection)
|
||||
@@ -302,7 +311,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"update with non-zero value",
|
||||
core.InterceptorActionUpdate,
|
||||
core.InterceptorActionUpdateExecute,
|
||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
@@ -341,9 +350,92 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func cutMilliseconds(datetime string) string {
|
||||
if len(datetime) > 19 {
|
||||
return datetime[:19]
|
||||
func TestAutodateRecordResave(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record, err := app.FindRecordById(collection, "llvuca81nly1qls")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lastUpdated := record.GetDateTime("updated")
|
||||
|
||||
// save with autogenerated date
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newUpdated := record.GetDateTime("updated")
|
||||
if newUpdated.Equal(lastUpdated) {
|
||||
t.Fatalf("[0] Expected updated to change, got %v", newUpdated)
|
||||
}
|
||||
lastUpdated = newUpdated
|
||||
|
||||
// save with custom date
|
||||
manualUpdated := lastUpdated.Add(-1 * time.Minute)
|
||||
record.SetRaw("updated", manualUpdated)
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newUpdated = record.GetDateTime("updated")
|
||||
if !newUpdated.Equal(manualUpdated) {
|
||||
t.Fatalf("[1] Expected updated to be the manual set date %v, got %v", manualUpdated, newUpdated)
|
||||
}
|
||||
lastUpdated = newUpdated
|
||||
|
||||
// save again with autogenerated date
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newUpdated = record.GetDateTime("updated")
|
||||
if newUpdated.Equal(lastUpdated) {
|
||||
t.Fatalf("[2] Expected updated to change, got %v", newUpdated)
|
||||
}
|
||||
lastUpdated = newUpdated
|
||||
|
||||
// simulate save failure
|
||||
app.OnRecordUpdateExecute(collection.Id).Bind(&hook.Handler[*core.RecordEvent]{
|
||||
Id: "test_failure",
|
||||
Func: func(*core.RecordEvent) error {
|
||||
return errors.New("test")
|
||||
},
|
||||
Priority: 9999999999, // as latest as possible
|
||||
})
|
||||
|
||||
// save again with autogenerated date (should fail)
|
||||
err = app.Save(record)
|
||||
if err == nil {
|
||||
t.Fatal("Expected save failure")
|
||||
}
|
||||
|
||||
// updated should still be set even after save failure
|
||||
newUpdated = record.GetDateTime("updated")
|
||||
if newUpdated.Equal(lastUpdated) {
|
||||
t.Fatalf("[3] Expected updated to change, got %v", newUpdated)
|
||||
}
|
||||
lastUpdated = newUpdated
|
||||
|
||||
// cleanup the error and resave again
|
||||
app.OnRecordUpdateExecute(collection.Id).Unbind("test_failure")
|
||||
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newUpdated = record.GetDateTime("updated")
|
||||
if newUpdated.Equal(lastUpdated) {
|
||||
t.Fatalf("[4] Expected updated to change, got %v", newUpdated)
|
||||
}
|
||||
return datetime
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
@@ -48,7 +47,7 @@ type EditorField struct {
|
||||
|
||||
// ---
|
||||
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes).
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||
//
|
||||
// If zero, a default limit of ~5MB is applied.
|
||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||
@@ -137,7 +136,7 @@ func (f *EditorField) ValidateValue(ctx context.Context, app App, record *Record
|
||||
if int64(len(val)) > maxSize {
|
||||
return validation.NewError(
|
||||
"validation_content_size_limit",
|
||||
fmt.Sprintf("The maximum allowed content size is %v bytes", maxSize),
|
||||
"The maximum allowed content size is {{.maxSize}} bytes",
|
||||
).SetParams(map[string]any{"maxSize": maxSize})
|
||||
}
|
||||
|
||||
@@ -149,7 +148,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,17 @@ func TestEditorFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MaxSize > safe json int",
|
||||
func() *core.EditorField {
|
||||
return &core.EditorField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSize: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
+26
-10
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"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.
|
||||
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,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.MaxSelect, validation.Min(0)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0)),
|
||||
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Thumbs, validation.Each(
|
||||
validation.NotIn("0x0", "0x0t", "0x0b", "0x0f"),
|
||||
validation.Match(filesystem.ThumbSizeRegex),
|
||||
@@ -250,13 +251,22 @@ func (f *FileField) ValidateValue(ctx context.Context, app App, record *Record)
|
||||
addedStrings := f.excludeFiles(existingStrings, oldExistingStrings)
|
||||
|
||||
if len(addedStrings) > 0 {
|
||||
return validation.NewError("validation_invalid_file", "Invalid files:"+strings.Join(cast.ToStringSlice(addedStrings), ", ")).
|
||||
SetParams(map[string]any{"invalidFiles": addedStrings})
|
||||
invalidFiles := make([]string, len(addedStrings))
|
||||
for i, invalid := range addedStrings {
|
||||
invalidStr := cast.ToString(invalid)
|
||||
if len(invalidStr) > 250 {
|
||||
invalidStr = invalidStr[:250]
|
||||
}
|
||||
invalidFiles[i] = invalidStr
|
||||
}
|
||||
|
||||
return validation.NewError("validation_invalid_file", "Invalid new files: {{.invalidFiles}}.").
|
||||
SetParams(map[string]any{"invalidFiles": invalidFiles})
|
||||
}
|
||||
|
||||
maxSelect := f.maxSelect()
|
||||
if len(files) > maxSelect {
|
||||
return validation.NewError("validation_too_many_files", fmt.Sprintf("The maximum allowed files is %d", maxSelect)).
|
||||
return validation.NewError("validation_too_many_files", "The maximum allowed files is {{.maxSelect}}").
|
||||
SetParams(map[string]any{"maxSelect": maxSelect})
|
||||
}
|
||||
|
||||
@@ -391,7 +401,7 @@ func (f *FileField) Intercept(
|
||||
}
|
||||
func (f *FileField) getLatestOldValue(app App, record *Record) any {
|
||||
if !record.IsNew() {
|
||||
latestOriginal, err := app.FindRecordById(record.Collection(), record.Id)
|
||||
latestOriginal, err := app.FindRecordById(record.Collection(), cast.ToString(record.LastSavedPK()))
|
||||
if err == nil {
|
||||
return latestOriginal.GetRaw(f.Name)
|
||||
}
|
||||
@@ -628,7 +638,13 @@ func (f *FileField) FindGetter(key string) GetterFunc {
|
||||
return func(record *Record) any {
|
||||
return record.GetRaw(f.Name)
|
||||
}
|
||||
case f.Name + ":unsaved":
|
||||
return func(record *Record) any {
|
||||
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
|
||||
}
|
||||
case f.Name + ":uploaded":
|
||||
// deprecated
|
||||
log.Println("[file field getter] please replace :uploaded with :unsaved")
|
||||
return func(record *Record) any {
|
||||
return f.extractUploadableFiles(f.toSliceValue(record.GetRaw(f.Name)))
|
||||
}
|
||||
@@ -739,14 +755,14 @@ func (f *FileField) toSliceValue(raw any) []any {
|
||||
}
|
||||
|
||||
func (f *FileField) uniqueFiles(files []any) []any {
|
||||
existing := make(map[string]struct{}, len(files))
|
||||
found := make(map[string]struct{}, len(files))
|
||||
result := make([]any, 0, len(files))
|
||||
|
||||
for _, fv := range files {
|
||||
name := f.getFileName(fv)
|
||||
if _, ok := existing[name]; !ok {
|
||||
if _, ok := found[name]; !ok {
|
||||
result = append(result, fv)
|
||||
existing[name] = struct{}{}
|
||||
found[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-2
@@ -539,6 +539,50 @@ func TestFileFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MaxSize > safe json int",
|
||||
func() *core.FileField {
|
||||
return &core.FileField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSize: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
{
|
||||
"MaxSize < 0",
|
||||
func() *core.FileField {
|
||||
return &core.FileField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSize: -1,
|
||||
}
|
||||
},
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
{
|
||||
"MaxSelect > safe json int",
|
||||
func() *core.FileField {
|
||||
return &core.FileField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSelect: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"maxSelect"},
|
||||
},
|
||||
{
|
||||
"MaxSelect < 0",
|
||||
func() *core.FileField {
|
||||
return &core.FileField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSelect: -1,
|
||||
}
|
||||
},
|
||||
[]string{"maxSelect"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -628,8 +672,8 @@ func TestFileFieldFindGetter(t *testing.T) {
|
||||
`["300_UhLKX91HVb.png",{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||
},
|
||||
{
|
||||
"uploaded",
|
||||
field.GetName() + ":uploaded",
|
||||
"unsaved",
|
||||
field.GetName() + ":unsaved",
|
||||
true,
|
||||
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||
},
|
||||
|
||||
+3
-4
@@ -2,7 +2,6 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -52,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.
|
||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||
@@ -160,7 +159,7 @@ func (f *JSONField) ValidateValue(ctx context.Context, app App, record *Record)
|
||||
if int64(len(raw)) > maxSize {
|
||||
return validation.NewError(
|
||||
"validation_json_size_limit",
|
||||
fmt.Sprintf("The maximum allowed JSON size is %v bytes", maxSize),
|
||||
"The maximum allowed JSON size is {{.maxSize}} bytes",
|
||||
).SetParams(map[string]any{"maxSize": maxSize})
|
||||
}
|
||||
|
||||
@@ -182,7 +181,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+14
-3
@@ -200,7 +200,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
expectErrors []string
|
||||
}{
|
||||
{
|
||||
"< 0 MaxSize",
|
||||
"MaxSize < 0",
|
||||
func() *core.JSONField {
|
||||
return &core.JSONField{
|
||||
Id: "test",
|
||||
@@ -211,7 +211,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
{
|
||||
"= 0 MaxSize",
|
||||
"MaxSize = 0",
|
||||
func() *core.JSONField {
|
||||
return &core.JSONField{
|
||||
Id: "test",
|
||||
@@ -221,7 +221,7 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"> 0 MaxSize",
|
||||
"MaxSize > 0",
|
||||
func() *core.JSONField {
|
||||
return &core.JSONField{
|
||||
Id: "test",
|
||||
@@ -231,6 +231,17 @@ func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MaxSize > safe json int",
|
||||
func() *core.JSONField {
|
||||
return &core.JSONField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
MaxSize: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"maxSize"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -3,7 +3,6 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -202,13 +201,13 @@ func (f *RelationField) ValidateValue(ctx context.Context, app App, record *Reco
|
||||
}
|
||||
|
||||
if f.MinSelect > 0 && len(ids) < f.MinSelect {
|
||||
return validation.NewError("validation_not_enough_values", fmt.Sprintf("Select at least %d", f.MinSelect)).
|
||||
return validation.NewError("validation_not_enough_values", "Select at least {{.minSelect}}").
|
||||
SetParams(map[string]any{"minSelect": f.MinSelect})
|
||||
}
|
||||
|
||||
maxSelect := max(f.MaxSelect, 1)
|
||||
if len(ids) > maxSelect {
|
||||
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", maxSelect)).
|
||||
return validation.NewError("validation_too_many_values", "Select no more than {{.maxSelect}}").
|
||||
SetParams(map[string]any{"maxSelect": maxSelect})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -192,14 +191,14 @@ func (f *SelectField) ValidateValue(ctx context.Context, app App, record *Record
|
||||
|
||||
// check max selected items
|
||||
if len(normalizedVal) > maxSelect {
|
||||
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", maxSelect)).
|
||||
return validation.NewError("validation_too_many_values", "Select no more than {{.maxSelect}}").
|
||||
SetParams(map[string]any{"maxSelect": maxSelect})
|
||||
}
|
||||
|
||||
// check against the allowed values
|
||||
for _, val := range normalizedVal {
|
||||
if !slices.Contains(f.Values, val) {
|
||||
return validation.NewError("validation_invalid_value", "Invalid value "+val).
|
||||
return validation.NewError("validation_invalid_value", "Invalid value {{.value}}").
|
||||
SetParams(map[string]any{"value": val})
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -91,7 +91,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
|
||||
"invalid length",
|
||||
func() core.Field {
|
||||
f := core.Fields[fieldType]()
|
||||
f.SetId(strings.Repeat("a", 256))
|
||||
f.SetId(strings.Repeat("a", 101))
|
||||
return f
|
||||
},
|
||||
true,
|
||||
@@ -100,7 +100,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
|
||||
"valid length",
|
||||
func() core.Field {
|
||||
f := core.Fields[fieldType]()
|
||||
f.SetId(strings.Repeat("a", 255))
|
||||
f.SetId(strings.Repeat("a", 100))
|
||||
return f
|
||||
},
|
||||
false,
|
||||
@@ -142,7 +142,7 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
|
||||
"invalid length",
|
||||
func() core.Field {
|
||||
f := core.Fields[fieldType]()
|
||||
f.SetName(strings.Repeat("a", 256))
|
||||
f.SetName(strings.Repeat("a", 101))
|
||||
return f
|
||||
},
|
||||
true,
|
||||
@@ -151,7 +151,7 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
|
||||
"valid length",
|
||||
func() core.Field {
|
||||
f := core.Fields[fieldType]()
|
||||
f.SetName(strings.Repeat("a", 255))
|
||||
f.SetName(strings.Repeat("a", 100))
|
||||
return f
|
||||
},
|
||||
false,
|
||||
|
||||
+52
-14
@@ -2,10 +2,14 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
@@ -151,6 +155,8 @@ func (f *TextField) PrepareValue(record *Record, raw any) (any, error) {
|
||||
return cast.ToString(raw), nil
|
||||
}
|
||||
|
||||
var forbiddenPKChars = []string{"/", "\\"}
|
||||
|
||||
// ValidateValue implements [Field.ValidateValue] interface method.
|
||||
func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record) error {
|
||||
newVal, ok := record.GetRaw(f.Name).(string)
|
||||
@@ -158,14 +164,46 @@ func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record)
|
||||
return validators.ErrUnsupportedValueType
|
||||
}
|
||||
|
||||
// disallow PK change
|
||||
if f.PrimaryKey && !record.IsNew() {
|
||||
oldVal := record.LastSavedPK()
|
||||
if oldVal != newVal {
|
||||
return validation.NewError("validation_pk_change", "The record primary key cannot be changed.")
|
||||
}
|
||||
if oldVal != "" {
|
||||
return nil // no need to further validate since the id can't be updated anyway
|
||||
if f.PrimaryKey {
|
||||
// disallow PK change
|
||||
if !record.IsNew() {
|
||||
oldVal := record.LastSavedPK()
|
||||
if oldVal != newVal {
|
||||
return validation.NewError("validation_pk_change", "The record primary key cannot be changed.")
|
||||
}
|
||||
if oldVal != "" {
|
||||
// no need to further validate because the id can't be updated
|
||||
// and because the id could have been inserted manually by migration from another system
|
||||
// that may not comply with the user defined PocketBase validations
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// disallow PK special characters no matter of the Pattern validator to minimize
|
||||
// side-effects when the primary key is used for example in a directory path
|
||||
for _, c := range forbiddenPKChars {
|
||||
if strings.Contains(newVal, c) {
|
||||
return validation.NewError("validation_pk_forbidden", "The record primary key contains forbidden characters.").
|
||||
SetParams(map[string]any{"forbidden": c})
|
||||
}
|
||||
}
|
||||
|
||||
// this technically shouldn't be necessarily but again to
|
||||
// minimize misuse of the Pattern validator that could cause
|
||||
// side-effects on some platforms check for duplicates in a case-insensitive manner
|
||||
//
|
||||
// (@todo eventually may get replaced in the future with a system unique constraint to avoid races or wrapping the request in a transaction)
|
||||
if f.Pattern != defaultLowercaseRecordIdPattern {
|
||||
var exists int
|
||||
err := app.DB().
|
||||
Select("(1)").
|
||||
From(record.TableName()).
|
||||
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": newVal})).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
if exists > 0 || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
|
||||
return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +226,7 @@ func (f *TextField) ValidatePlainValue(value string) error {
|
||||
length := len([]rune(value))
|
||||
|
||||
if f.Min > 0 && length < f.Min {
|
||||
return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", f.Min)).
|
||||
return validation.NewError("validation_min_text_constraint", "Must be at least {{.min}} character(s)").
|
||||
SetParams(map[string]any{"min": f.Min})
|
||||
}
|
||||
|
||||
@@ -198,8 +236,8 @@ func (f *TextField) ValidatePlainValue(value string) error {
|
||||
}
|
||||
|
||||
if max > 0 && length > max {
|
||||
return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", max)).
|
||||
SetParams(map[string]any{"max": f.Max})
|
||||
return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s)").
|
||||
SetParams(map[string]any{"max": max})
|
||||
}
|
||||
|
||||
if f.Pattern != "" {
|
||||
@@ -221,9 +259,9 @@ func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
validation.When(f.PrimaryKey, validation.In(idColumn).Error(`The primary key must be named "id".`)),
|
||||
),
|
||||
validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
|
||||
validation.Field(&f.Min, validation.Min(0)),
|
||||
validation.Field(&f.Max, validation.Min(f.Min)),
|
||||
validation.Field(&f.Pattern, validation.By(validators.IsRegex)),
|
||||
validation.Field(&f.Min, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Max, validation.Min(f.Min), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Pattern, validation.When(f.PrimaryKey, validation.Required), validation.By(validators.IsRegex)),
|
||||
validation.Field(&f.Hidden, validation.When(f.PrimaryKey, validation.Empty)),
|
||||
validation.Field(&f.Required, validation.When(f.PrimaryKey, validation.Required)),
|
||||
validation.Field(&f.AutogeneratePattern, validation.By(validators.IsRegex), validation.By(f.checkAutogeneratePattern)),
|
||||
|
||||
+120
-2
@@ -68,7 +68,15 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := core.NewBaseCollection("test_collection")
|
||||
collection, err := app.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
existingRecord, err := app.FindFirstRecordByFilter(collection, "id != ''")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -116,6 +124,46 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"special forbidden character / (non-primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: false},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "/")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"special forbidden character \\ (non-primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: false},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "\\")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"special forbidden character / (primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "/")
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"special forbidden character \\ (primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "\\")
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"zero field value (primaryKey)",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
@@ -131,11 +179,21 @@ func TestTextFieldValidateValue(t *testing.T) {
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", "abc")
|
||||
record.SetRaw("test", "abcd")
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"case-insensitive duplicated primary key check",
|
||||
&core.TextField{Name: "test", PrimaryKey: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", strings.ToUpper(existingRecord.Id))
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"< min",
|
||||
&core.TextField{Name: "test", Min: 4},
|
||||
@@ -259,10 +317,23 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
||||
Id: "test",
|
||||
Name: "id",
|
||||
PrimaryKey: true,
|
||||
Pattern: `\d+`,
|
||||
}
|
||||
},
|
||||
[]string{"required"},
|
||||
},
|
||||
{
|
||||
"primaryKey without pattern",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "id",
|
||||
PrimaryKey: true,
|
||||
Required: true,
|
||||
}
|
||||
},
|
||||
[]string{"pattern"},
|
||||
},
|
||||
{
|
||||
"primaryKey with hidden",
|
||||
func() *core.TextField {
|
||||
@@ -272,6 +343,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
||||
Required: true,
|
||||
PrimaryKey: true,
|
||||
Hidden: true,
|
||||
Pattern: `\d+`,
|
||||
}
|
||||
},
|
||||
[]string{"hidden"},
|
||||
@@ -284,6 +356,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
||||
Name: "test",
|
||||
PrimaryKey: true,
|
||||
Required: true,
|
||||
Pattern: `\d+`,
|
||||
}
|
||||
},
|
||||
[]string{"name"},
|
||||
@@ -295,6 +368,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
||||
Id: "test2",
|
||||
Name: "id",
|
||||
PrimaryKey: true,
|
||||
Pattern: `\d+`,
|
||||
Required: true,
|
||||
}
|
||||
},
|
||||
@@ -356,6 +430,50 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{"autogeneratePattern"},
|
||||
},
|
||||
{
|
||||
"Max > safe json int",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Max: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"max"},
|
||||
},
|
||||
{
|
||||
"Max < 0",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Max: -1,
|
||||
}
|
||||
},
|
||||
[]string{"max"},
|
||||
},
|
||||
{
|
||||
"Min > safe json int",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Min: 1 << 53,
|
||||
}
|
||||
},
|
||||
[]string{"min"},
|
||||
},
|
||||
{
|
||||
"Min < 0",
|
||||
func() *core.TextField {
|
||||
return &core.TextField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Min: -1,
|
||||
}
|
||||
},
|
||||
[]string{"min"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
+92
-19
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
@@ -12,7 +13,7 @@ func NewFieldsList(fields ...Field) FieldsList {
|
||||
l := make(FieldsList, 0, len(fields))
|
||||
|
||||
for _, f := range fields {
|
||||
l.Add(f)
|
||||
l.add(-1, f)
|
||||
}
|
||||
|
||||
return l
|
||||
@@ -116,7 +117,26 @@ func (l *FieldsList) RemoveByName(fieldName string) {
|
||||
// (the id value doesn't really matter and it is mostly used as a stable identifier in case of a field rename).
|
||||
func (l *FieldsList) Add(fields ...Field) {
|
||||
for _, f := range fields {
|
||||
l.add(f)
|
||||
l.add(-1, f)
|
||||
}
|
||||
}
|
||||
|
||||
// AddAt is the same as Add but insert/move the fields at the specific position.
|
||||
//
|
||||
// If pos < 0, then this method acts the same as calling Add.
|
||||
//
|
||||
// If pos > FieldsList total items, then the specified fields are inserted/moved at the end of the list.
|
||||
func (l *FieldsList) AddAt(pos int, fields ...Field) {
|
||||
total := len(*l)
|
||||
|
||||
for i, f := range fields {
|
||||
if pos < 0 {
|
||||
l.add(-1, f)
|
||||
} else if pos > total {
|
||||
l.add(total+i, f)
|
||||
} else {
|
||||
l.add(pos+i, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,13 +152,42 @@ func (l *FieldsList) Add(fields ...Field) {
|
||||
// l.AddMarshaledJSON([]byte{`{"type":"text", name: "test"}`})
|
||||
// l.AddMarshaledJSON([]byte{`[{"type":"text", name: "test1"}, {"type":"text", name: "test2"}]`})
|
||||
func (l *FieldsList) AddMarshaledJSON(rawJSON []byte) error {
|
||||
extractedFields, err := marshaledJSONtoFieldsList(rawJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.Add(extractedFields...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMarshaledJSONAt is the same as AddMarshaledJSON but insert/move the fields at the specific position.
|
||||
//
|
||||
// If pos < 0, then this method acts the same as calling AddMarshaledJSON.
|
||||
//
|
||||
// If pos > FieldsList total items, then the specified fields are inserted/moved at the end of the list.
|
||||
func (l *FieldsList) AddMarshaledJSONAt(pos int, rawJSON []byte) error {
|
||||
extractedFields, err := marshaledJSONtoFieldsList(rawJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.AddAt(pos, extractedFields...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshaledJSONtoFieldsList(rawJSON []byte) (FieldsList, error) {
|
||||
extractedFields := FieldsList{}
|
||||
|
||||
// nothing to add
|
||||
if len(rawJSON) == 0 {
|
||||
return nil // nothing to add
|
||||
return extractedFields, nil
|
||||
}
|
||||
|
||||
// try to unmarshal first into a new fieds list
|
||||
// (assuming that rawJSON is array of objects)
|
||||
extractedFields := FieldsList{}
|
||||
err := json.Unmarshal(rawJSON, &extractedFields)
|
||||
if err != nil {
|
||||
// try again but wrap the rawJSON in []
|
||||
@@ -149,21 +198,25 @@ func (l *FieldsList) AddMarshaledJSON(rawJSON []byte) error {
|
||||
wrapped = append(wrapped, ']')
|
||||
err = json.Unmarshal(wrapped, &extractedFields)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal the provided JSON - expects array of objects or just single object: %w", err)
|
||||
return nil, fmt.Errorf("failed to unmarshal the provided JSON - expects array of objects or just single object: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range extractedFields {
|
||||
l.add(f)
|
||||
}
|
||||
|
||||
return nil
|
||||
return extractedFields, nil
|
||||
}
|
||||
|
||||
func (l *FieldsList) add(newField Field) {
|
||||
func (l *FieldsList) add(pos int, newField Field) {
|
||||
fields := *l
|
||||
|
||||
var replaceByName bool
|
||||
var replaceInPlace bool
|
||||
|
||||
if pos < 0 {
|
||||
replaceInPlace = true
|
||||
pos = len(fields)
|
||||
} else if pos > len(fields) {
|
||||
pos = len(fields)
|
||||
}
|
||||
|
||||
newFieldId := newField.GetId()
|
||||
|
||||
@@ -182,24 +235,44 @@ func (l *FieldsList) add(newField Field) {
|
||||
newField.SetId(newFieldId)
|
||||
}
|
||||
|
||||
// replace existing
|
||||
// try to replace existing
|
||||
for i, field := range fields {
|
||||
if replaceByName {
|
||||
if name := newField.GetName(); name != "" && field.GetName() == name {
|
||||
// reuse the original id
|
||||
newField.SetId(field.GetId())
|
||||
(*l)[i] = newField
|
||||
return
|
||||
|
||||
if replaceInPlace {
|
||||
(*l)[i] = newField
|
||||
return
|
||||
} else {
|
||||
// remove the current field and insert it later at the specific position
|
||||
*l = slices.Delete(*l, i, i+1)
|
||||
if total := len(*l); pos > total {
|
||||
pos = total
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if field.GetId() == newFieldId {
|
||||
(*l)[i] = newField
|
||||
return
|
||||
if replaceInPlace {
|
||||
(*l)[i] = newField
|
||||
return
|
||||
} else {
|
||||
// remove the current field and insert it later at the specific position
|
||||
*l = slices.Delete(*l, i, i+1)
|
||||
if total := len(*l); pos > total {
|
||||
pos = total
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add new field
|
||||
*l = append(fields, newField)
|
||||
// insert the new field
|
||||
*l = slices.Insert(*l, pos, newField)
|
||||
}
|
||||
|
||||
// String returns the string representation of the current list.
|
||||
@@ -252,7 +325,7 @@ func (l *FieldsList) UnmarshalJSON(data []byte) error {
|
||||
*l = []Field{} // reset
|
||||
|
||||
for _, fwt := range fwts {
|
||||
l.Add(fwt.Field)
|
||||
l.add(-1, fwt.Field)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -301,6 +304,96 @@ func TestFieldsListAddMarshaledJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldsListAddAt(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
position int
|
||||
expected []string
|
||||
}{
|
||||
{-2, []string{"test1", "test2_new", "test3", "test4"}},
|
||||
{-1, []string{"test1", "test2_new", "test3", "test4"}},
|
||||
{0, []string{"test2_new", "test4", "test1", "test3"}},
|
||||
{1, []string{"test1", "test2_new", "test4", "test3"}},
|
||||
{2, []string{"test1", "test3", "test2_new", "test4"}},
|
||||
{3, []string{"test1", "test3", "test2_new", "test4"}},
|
||||
{4, []string{"test1", "test3", "test2_new", "test4"}},
|
||||
{5, []string{"test1", "test3", "test2_new", "test4"}},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(strconv.Itoa(s.position), func(t *testing.T) {
|
||||
f1 := &core.TextField{Id: "f1Id", Name: "test1"}
|
||||
f2 := &core.TextField{Id: "f2Id", Name: "test2"}
|
||||
f3 := &core.TextField{Id: "f3Id", Name: "test3"}
|
||||
testFieldsList := core.NewFieldsList(f1, f2, f3)
|
||||
|
||||
f2New := &core.EmailField{Id: "f2Id", Name: "test2_new"}
|
||||
f4 := &core.URLField{Name: "test4"}
|
||||
testFieldsList.AddAt(s.position, f2New, f4)
|
||||
|
||||
rawNames, err := json.Marshal(testFieldsList.FieldNames())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rawExpected, err := json.Marshal(s.expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(rawNames, rawExpected) {
|
||||
t.Fatalf("Expected fields\n%s\ngot\n%s", rawExpected, rawNames)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldsListAddMarshaledJSONAt(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
position int
|
||||
expected []string
|
||||
}{
|
||||
{-2, []string{"test1", "test2_new", "test3", "test4"}},
|
||||
{-1, []string{"test1", "test2_new", "test3", "test4"}},
|
||||
{0, []string{"test2_new", "test4", "test1", "test3"}},
|
||||
{1, []string{"test1", "test2_new", "test4", "test3"}},
|
||||
{2, []string{"test1", "test3", "test2_new", "test4"}},
|
||||
{3, []string{"test1", "test3", "test2_new", "test4"}},
|
||||
{4, []string{"test1", "test3", "test2_new", "test4"}},
|
||||
{5, []string{"test1", "test3", "test2_new", "test4"}},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(strconv.Itoa(s.position), func(t *testing.T) {
|
||||
f1 := &core.TextField{Id: "f1Id", Name: "test1"}
|
||||
f2 := &core.TextField{Id: "f2Id", Name: "test2"}
|
||||
f3 := &core.TextField{Id: "f3Id", Name: "test3"}
|
||||
testFieldsList := core.NewFieldsList(f1, f2, f3)
|
||||
|
||||
err := testFieldsList.AddMarshaledJSONAt(s.position, []byte(`[
|
||||
{"id":"f2Id", "name":"test2_new", "type": "text"},
|
||||
{"name": "test4", "type": "text"}
|
||||
]`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rawNames, err := json.Marshal(testFieldsList.FieldNames())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rawExpected, err := json.Marshal(s.expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(rawNames, rawExpected) {
|
||||
t.Fatalf("Expected fields\n%s\ngot\n%s", rawExpected, rawNames)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldsListStringAndValue(t *testing.T) {
|
||||
t.Run("empty list", func(t *testing.T) {
|
||||
testFieldsList := core.NewFieldsList()
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
var cachedColors = store.New[*color.Color](nil)
|
||||
var cachedColors = store.New[string, *color.Color](nil)
|
||||
|
||||
// getColor returns [color.Color] object and cache it (if not already).
|
||||
func getColor(attrs ...color.Attribute) (c *color.Color) {
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ type LogsStatsItem struct {
|
||||
Total int `db:"total" json:"total"`
|
||||
}
|
||||
|
||||
// LogsStats returns hourly grouped requests logs statistics.
|
||||
// LogsStats returns hourly grouped logs statistics.
|
||||
func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
|
||||
result := []*LogsStatsItem{}
|
||||
|
||||
@@ -51,7 +51,7 @@ func (app *BaseApp) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteOldLogs delete all requests that are created before createdBefore.
|
||||
// DeleteOldLogs delete all logs that are created before createdBefore.
|
||||
//
|
||||
// For better performance the logs delete is executed as plain SQL statement,
|
||||
// aka. no delete model hook events will be fired.
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ func (app *BaseApp) registerMFAHooks() {
|
||||
recordRefHooks[*MFA](app, CollectionNameMFAs, CollectionTypeAuth)
|
||||
|
||||
// run on every hour to cleanup expired mfa sessions
|
||||
app.Cron().Add("__mfasCleanup__", "0 * * * *", func() {
|
||||
app.Cron().Add("__pbMFACleanup__", "0 * * * *", func() {
|
||||
if err := app.DeleteExpiredMFAs(); err != nil {
|
||||
app.Logger().Warn("Failed to delete expired MFA sessions", "error", err)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
@@ -80,15 +80,11 @@ func (r *MigrationsRunner) Run(args ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
confirm := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf(
|
||||
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
|
||||
strings.Join(names, "\n"),
|
||||
toRevertCount,
|
||||
),
|
||||
}
|
||||
survey.AskOne(prompt, &confirm)
|
||||
confirm := osutils.YesNoPrompt(fmt.Sprintf(
|
||||
"\n%v\nDo you really want to revert the last %d applied migration(s)?",
|
||||
strings.Join(names, "\n"),
|
||||
toRevertCount,
|
||||
), false)
|
||||
if !confirm {
|
||||
fmt.Println("The command has been cancelled")
|
||||
return nil
|
||||
@@ -267,15 +263,15 @@ func (r *MigrationsRunner) initMigrationsTable() error {
|
||||
}
|
||||
|
||||
func (r *MigrationsRunner) isMigrationApplied(txApp App, file string) bool {
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
err := txApp.DB().Select("count(*)").
|
||||
err := txApp.DB().Select("(1)").
|
||||
From(r.tableName).
|
||||
Where(dbx.HashExp{"file": file}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
func (r *MigrationsRunner) saveAppliedMigration(txApp App, file string) error {
|
||||
|
||||
@@ -200,38 +200,13 @@ func TestMigrationsRunnerRemoveMissingAppliedMigrations(t *testing.T) {
|
||||
}
|
||||
|
||||
func isMigrationApplied(app core.App, file string) bool {
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
err := app.DB().Select("count(*)").
|
||||
err := app.DB().Select("(1)").
|
||||
From(core.DefaultMigrationsTable).
|
||||
Where(dbx.HashExp{"file": file}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
// // -------------------------------------------------------------------
|
||||
|
||||
// type testDB struct {
|
||||
// *dbx.DB
|
||||
// CalledQueries []string
|
||||
// }
|
||||
|
||||
// // NB! Don't forget to call `db.Close()` at the end of the test.
|
||||
// func createTestDB() (*testDB, error) {
|
||||
// sqlDB, err := sql.Open("sqlite", ":memory:")
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
// db := testDB{DB: dbx.NewFromDB(sqlDB, "sqlite")}
|
||||
// db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
// db.CalledQueries = append(db.CalledQueries, sql)
|
||||
// }
|
||||
// db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
// db.CalledQueries = append(db.CalledQueries, sql)
|
||||
// }
|
||||
|
||||
// return &db, nil
|
||||
// }
|
||||
|
||||
+15
-1
@@ -85,6 +85,20 @@ func (m *OTP) SetRecordRef(recordId string) {
|
||||
m.Set("recordRef", recordId)
|
||||
}
|
||||
|
||||
// SentTo returns the "sentTo" record field value.
|
||||
//
|
||||
// It could be any string value (email, phone, message app id, etc.)
|
||||
// and usually is used as part of the auth flow to update the verified
|
||||
// user state in case for example the sentTo value matches with the user record email.
|
||||
func (m *OTP) SentTo() string {
|
||||
return m.GetString("sentTo")
|
||||
}
|
||||
|
||||
// SetSentTo updates the "sentTo" record field value.
|
||||
func (m *OTP) SetSentTo(val string) {
|
||||
m.Set("sentTo", val)
|
||||
}
|
||||
|
||||
// Created returns the "created" record field value.
|
||||
func (m *OTP) Created() types.DateTime {
|
||||
return m.GetDateTime("created")
|
||||
@@ -105,7 +119,7 @@ func (app *BaseApp) registerOTPHooks() {
|
||||
recordRefHooks[*OTP](app, CollectionNameOTPs, CollectionTypeAuth)
|
||||
|
||||
// run on every hour to cleanup expired otp sessions
|
||||
app.Cron().Add("__otpsCleanup__", "0 * * * *", func() {
|
||||
app.Cron().Add("__pbOTPCleanup__", "0 * * * *", func() {
|
||||
if err := app.DeleteExpiredOTPs(); err != nil {
|
||||
app.Logger().Warn("Failed to delete expired OTP sessions", "error", err)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,30 @@ func TestOTPCollectionRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTPSentTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
otp := core.NewOTP(app)
|
||||
|
||||
testValues := []string{"test_1", "test2", ""}
|
||||
for i, testValue := range testValues {
|
||||
t.Run(fmt.Sprintf("%d_%q", i, testValue), func(t *testing.T) {
|
||||
otp.SetSentTo(testValue)
|
||||
|
||||
if v := otp.SentTo(); v != testValue {
|
||||
t.Fatalf("Expected getter %q, got %q", testValue, v)
|
||||
}
|
||||
|
||||
if v := otp.GetString("sentTo"); v != testValue {
|
||||
t.Fatalf("Expected field value %q, got %q", testValue, v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTPCreated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,7 @@ const (
|
||||
eachModifier string = "each"
|
||||
issetModifier string = "isset"
|
||||
lengthModifier string = "length"
|
||||
lowerModifier string = "lower"
|
||||
)
|
||||
|
||||
// ensure that `search.FieldResolver` interface is implemented
|
||||
@@ -47,6 +50,26 @@ type RecordFieldResolver struct {
|
||||
allowHiddenFields bool
|
||||
}
|
||||
|
||||
// AllowedFields returns a copy of the resolver's allowed fields.
|
||||
func (r *RecordFieldResolver) AllowedFields() []string {
|
||||
return slices.Clone(r.allowedFields)
|
||||
}
|
||||
|
||||
// SetAllowedFields replaces the resolver's allowed fields with the new ones.
|
||||
func (r *RecordFieldResolver) SetAllowedFields(newAllowedFields []string) {
|
||||
r.allowedFields = slices.Clone(newAllowedFields)
|
||||
}
|
||||
|
||||
// AllowHiddenFields returns whether the current resolver allows filtering hidden fields.
|
||||
func (r *RecordFieldResolver) AllowHiddenFields() bool {
|
||||
return r.allowHiddenFields
|
||||
}
|
||||
|
||||
// SetAllowHiddenFields enables or disables hidden fields filtering.
|
||||
func (r *RecordFieldResolver) SetAllowHiddenFields(allowHiddenFields bool) {
|
||||
r.allowHiddenFields = allowHiddenFields
|
||||
}
|
||||
|
||||
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
|
||||
func NewRecordFieldResolver(
|
||||
app App,
|
||||
@@ -193,7 +216,14 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
||||
resultVal = val
|
||||
}
|
||||
|
||||
placeholder := "f" + security.PseudorandomString(6)
|
||||
placeholder := "f" + security.PseudorandomString(8)
|
||||
|
||||
if modifier == lowerModifier {
|
||||
return &search.ResolverResult{
|
||||
Identifier: "LOWER({:" + placeholder + "})",
|
||||
Params: dbx.Params{placeholder: resultVal},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &search.ResolverResult{
|
||||
Identifier: "{:" + placeholder + "}",
|
||||
@@ -271,6 +301,13 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
|
||||
return mapVal(m, keys...)
|
||||
case mapExtractor:
|
||||
return mapVal(m.AsMap(), keys...)
|
||||
case types.JSONRaw:
|
||||
var raw any
|
||||
err := json.Unmarshal(m, &raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal raw JSON in order extract nested value from: %w", err)
|
||||
}
|
||||
return extractNestedVal(raw, keys...)
|
||||
|
||||
// slices
|
||||
case []string:
|
||||
@@ -309,6 +346,8 @@ func extractNestedVal(rawData any, keys ...string) (any, error) {
|
||||
return arrVal(extracted, keys...)
|
||||
case []any:
|
||||
return arrVal(m, keys...)
|
||||
case []types.JSONRaw:
|
||||
return arrVal(m, keys...)
|
||||
default:
|
||||
return nil, fmt.Errorf("expected map or array, got %#v", rawData)
|
||||
}
|
||||
@@ -355,7 +394,8 @@ func splitModifier(combined string) (string, string, error) {
|
||||
switch parts[1] {
|
||||
case issetModifier,
|
||||
eachModifier,
|
||||
lengthModifier:
|
||||
lengthModifier,
|
||||
lowerModifier:
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// maxNestedRels defines the max allowed nested relations depth.
|
||||
@@ -113,6 +114,11 @@ func (r *runner) run() (*search.ResolverResult, error) {
|
||||
if modifier == lengthModifier && len(r.activeProps) == 3 {
|
||||
return r.processRequestInfoLengthModifier(bodyField)
|
||||
}
|
||||
|
||||
// check for body arrayble fields ":lower" modifier
|
||||
if modifier == lowerModifier && len(r.activeProps) == 3 {
|
||||
return r.processRequestInfoLowerModifier(bodyField)
|
||||
}
|
||||
}
|
||||
|
||||
// some other @request.* static field
|
||||
@@ -191,6 +197,10 @@ func (r *runner) processCollectionField() (*search.ResolverResult, error) {
|
||||
}
|
||||
|
||||
func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
|
||||
// plain auth field
|
||||
// ---
|
||||
if _, ok := plainRequestAuthFields[r.fieldName]; ok {
|
||||
@@ -199,10 +209,6 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
|
||||
// resolve the auth collection field
|
||||
// ---
|
||||
if r.resolver.requestInfo == nil || r.resolver.requestInfo.Auth == nil || r.resolver.requestInfo.Auth.Collection() == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
|
||||
collection := r.resolver.requestInfo.Auth.Collection()
|
||||
|
||||
r.activeCollectionName = collection.Name
|
||||
@@ -262,6 +268,19 @@ func toSlice(value any) []any {
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *runner) processRequestInfoLowerModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
rawValue := cast.ToString(r.resolver.requestInfo.Body[bodyField.GetName()])
|
||||
|
||||
placeholder := "infoLower" + bodyField.GetName() + security.PseudorandomString(6)
|
||||
|
||||
result := &search.ResolverResult{
|
||||
Identifier: "LOWER({:" + placeholder + "})",
|
||||
Params: dbx.Params{placeholder: rawValue},
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestInfoLengthModifier(bodyField Field) (*search.ResolverResult, error) {
|
||||
if _, ok := bodyField.(MultiValuer); !ok {
|
||||
return nil, fmt.Errorf("field %q doesn't support multivalue operations", bodyField.GetName())
|
||||
@@ -396,6 +415,10 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
|
||||
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
|
||||
if field != nil && field.Type() == FieldTypeJSON {
|
||||
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])
|
||||
}
|
||||
|
||||
if backField.GetHidden() && !r.allowHiddenFields {
|
||||
return nil, fmt.Errorf("non-filterable back relation field %q", backField.GetName())
|
||||
}
|
||||
|
||||
backRelField, ok := backField.(*RelationField)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to initialize back relation field %q", backField.GetName())
|
||||
@@ -478,7 +505,8 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
isBackRelMultiple := backRelField.IsMultiple()
|
||||
if !isBackRelMultiple {
|
||||
// additionally check if the rel field has a single column unique index
|
||||
isBackRelMultiple = !dbutils.HasSingleColumnUniqueIndex(backRelField.Name, backCollection.Indexes)
|
||||
_, hasUniqueIndex := dbutils.FindSingleColumnUniqueIndex(backCollection.Indexes, backRelField.Name)
|
||||
isBackRelMultiple = !hasUniqueIndex
|
||||
}
|
||||
|
||||
if !isBackRelMultiple {
|
||||
@@ -713,11 +741,11 @@ func (r *runner) processLastProp(collection *Collection, prop string) (*search.R
|
||||
// default
|
||||
// -------------------------------------------------------
|
||||
result := &search.ResolverResult{
|
||||
Identifier: fmt.Sprintf("[[%s.%s]]", r.activeTableAlias, cleanFieldName),
|
||||
Identifier: "[[" + r.activeTableAlias + "." + cleanFieldName + "]]",
|
||||
}
|
||||
|
||||
if r.withMultiMatch {
|
||||
r.multiMatch.valueIdentifier = fmt.Sprintf("[[%s.%s]]", r.multiMatchActiveTableAlias, cleanFieldName)
|
||||
r.multiMatch.valueIdentifier = "[[" + r.multiMatchActiveTableAlias + "." + cleanFieldName + "]]"
|
||||
result.MultiMatchSubQuery = r.multiMatch
|
||||
}
|
||||
|
||||
@@ -743,5 +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
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+119
-43
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -37,9 +38,9 @@ var (
|
||||
type Record struct {
|
||||
collection *Collection
|
||||
originalData map[string]any
|
||||
customVisibility *store.Store[bool]
|
||||
data *store.Store[any]
|
||||
expand *store.Store[any]
|
||||
customVisibility *store.Store[string, bool]
|
||||
data *store.Store[string, any]
|
||||
expand *store.Store[string, any]
|
||||
|
||||
BaseModel
|
||||
|
||||
@@ -55,10 +56,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -70,10 +74,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -85,10 +92,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -100,10 +110,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -115,10 +128,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
err := me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -130,10 +146,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -145,10 +164,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -160,10 +182,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -175,10 +200,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
err := me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -190,10 +218,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -205,10 +236,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -220,10 +254,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||
return me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
err := me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
defer syncRecordEventWithModelEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithRecordEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -235,10 +272,13 @@ func (app *BaseApp) registerRecordHooks() {
|
||||
Id: systemHookIdRecord,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
err := me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -498,18 +538,22 @@ func newRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringM
|
||||
func NewRecord(collection *Collection) *Record {
|
||||
record := &Record{
|
||||
collection: collection,
|
||||
data: store.New[any](nil),
|
||||
customVisibility: store.New[bool](nil),
|
||||
data: store.New[string, any](nil),
|
||||
customVisibility: store.New[string, bool](nil),
|
||||
originalData: make(map[string]any, len(collection.Fields)),
|
||||
}
|
||||
|
||||
// initialize default field values
|
||||
var fieldName string
|
||||
for _, field := range collection.Fields {
|
||||
if field.GetName() == FieldNameId {
|
||||
fieldName = field.GetName()
|
||||
|
||||
if fieldName == FieldNameId {
|
||||
continue
|
||||
}
|
||||
|
||||
value, _ := field.PrepareValue(record, nil)
|
||||
record.originalData[field.GetName()] = value
|
||||
record.originalData[fieldName] = value
|
||||
}
|
||||
|
||||
return record
|
||||
@@ -589,8 +633,12 @@ func (m *Record) Original() *Record {
|
||||
func (m *Record) Fresh() *Record {
|
||||
newRecord := m.Original()
|
||||
|
||||
// note: this will also load the Id field though m.Get
|
||||
newRecord.Load(m.FieldsData())
|
||||
// note: this will also load the Id field through m.GetRaw
|
||||
var fieldName string
|
||||
for _, field := range m.collection.Fields {
|
||||
fieldName = field.GetName()
|
||||
newRecord.SetRaw(fieldName, m.GetRaw(fieldName))
|
||||
}
|
||||
|
||||
return newRecord
|
||||
}
|
||||
@@ -609,7 +657,10 @@ func (m *Record) Clone() *Record {
|
||||
newRecord.ignoreUnchangedFields = m.ignoreUnchangedFields
|
||||
newRecord.customVisibility.Reset(m.customVisibility.GetAll())
|
||||
|
||||
newRecord.Load(m.data.GetAll())
|
||||
data := m.data.GetAll()
|
||||
for k, v := range data {
|
||||
newRecord.SetRaw(k, v)
|
||||
}
|
||||
|
||||
if m.expand != nil {
|
||||
newRecord.SetExpand(m.expand.GetAll())
|
||||
@@ -631,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).
|
||||
func (m *Record) SetExpand(expand map[string]any) {
|
||||
if m.expand == nil {
|
||||
m.expand = store.New[any](nil)
|
||||
m.expand = store.New[string, any](nil)
|
||||
}
|
||||
|
||||
m.expand.Reset(expand)
|
||||
@@ -722,8 +773,10 @@ func (m *Record) MergeExpand(expand map[string]any) {
|
||||
func (m *Record) FieldsData() map[string]any {
|
||||
result := make(map[string]any, len(m.collection.Fields))
|
||||
|
||||
var fieldName string
|
||||
for _, field := range m.collection.Fields {
|
||||
result[field.GetName()] = m.Get(field.GetName())
|
||||
fieldName = field.GetName()
|
||||
result[fieldName] = m.Get(fieldName)
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -781,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
|
||||
// without overwrite other untouched fields in case of concurrent update.
|
||||
//
|
||||
// Note that the fields change comparison is based on the current fields against m.Original()
|
||||
// (aka. if you have performed save on the same Record instance multiple times you may have to refetch it,
|
||||
// so that m.Original() could reflect the last saved change).
|
||||
func (m *Record) IgnoreUnchangedFields(state bool) *Record {
|
||||
m.ignoreUnchangedFields = state
|
||||
return m
|
||||
@@ -917,20 +974,20 @@ func (m *Record) GetStringSlice(key string) []string {
|
||||
return list.ToUniqueStringSlice(m.Get(key))
|
||||
}
|
||||
|
||||
// GetUploadedFiles returns the uploaded files for the provided "file" field key,
|
||||
// GetUnsavedFiles returns the uploaded files for the provided "file" field key,
|
||||
// (aka. the current [*filesytem.File] values) so that you can apply further
|
||||
// validations or modifications (including changing the file name or content before persisting).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// files := record.GetUploadedFiles("documents")
|
||||
// files := record.GetUnsavedFiles("documents")
|
||||
// for _, f := range files {
|
||||
// f.Name = "doc_" + f.Name // add a prefix to each file name
|
||||
// }
|
||||
// app.Save(record) // the files are pointers so the applied changes will transparently reflect on the record value
|
||||
func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
|
||||
if !strings.HasSuffix(key, ":uploaded") {
|
||||
key += ":uploaded"
|
||||
func (m *Record) GetUnsavedFiles(key string) []*filesystem.File {
|
||||
if !strings.HasSuffix(key, ":unsaved") {
|
||||
key += ":unsaved"
|
||||
}
|
||||
|
||||
values, _ := m.Get(key).([]*filesystem.File)
|
||||
@@ -938,6 +995,12 @@ func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
|
||||
return values
|
||||
}
|
||||
|
||||
// Deprecated: replaced with GetUnsavedFiles.
|
||||
func (m *Record) GetUploadedFiles(key string) []*filesystem.File {
|
||||
log.Println("Please replace GetUploadedFiles with GetUnsavedFiles")
|
||||
return m.GetUnsavedFiles(key)
|
||||
}
|
||||
|
||||
// Retrieves the "key" json field value and unmarshals it into "result".
|
||||
//
|
||||
// Example
|
||||
@@ -1057,15 +1120,18 @@ func (m *Record) dbExport() (map[string]any, error) {
|
||||
|
||||
result := make(map[string]any, len(fields))
|
||||
|
||||
var fieldName string
|
||||
for _, field := range fields {
|
||||
fieldName = field.GetName()
|
||||
|
||||
if f, ok := field.(DriverValuer); ok {
|
||||
v, err := f.DriverValue(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[field.GetName()] = v
|
||||
result[fieldName] = v
|
||||
} else {
|
||||
result[field.GetName()] = m.GetRaw(field.GetName())
|
||||
result[fieldName] = m.GetRaw(fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,8 +1257,11 @@ func (record *Record) PublicExport() map[string]any {
|
||||
customVisibility := record.customVisibility.GetAll()
|
||||
|
||||
// export schema fields
|
||||
var fieldName string
|
||||
for _, f := range record.collection.Fields {
|
||||
isVisible, hasCustomVisibility = customVisibility[f.GetName()]
|
||||
fieldName = f.GetName()
|
||||
|
||||
isVisible, hasCustomVisibility = customVisibility[fieldName]
|
||||
if !hasCustomVisibility {
|
||||
isVisible = !f.GetHidden()
|
||||
}
|
||||
@@ -1201,7 +1270,7 @@ func (record *Record) PublicExport() map[string]any {
|
||||
continue
|
||||
}
|
||||
|
||||
export[f.GetName()] = record.Get(f.GetName())
|
||||
export[fieldName] = record.Get(fieldName)
|
||||
}
|
||||
|
||||
// export custom fields
|
||||
@@ -1351,12 +1420,18 @@ func onRecordValidate(e *RecordEvent) error {
|
||||
|
||||
func onRecordSaveExecute(e *RecordEvent) error {
|
||||
if e.Record.Collection().IsAuth() {
|
||||
// ensure that the token key is different on password change
|
||||
old := e.Record.Original()
|
||||
if !e.Record.IsNew() &&
|
||||
old.TokenKey() == e.Record.TokenKey() &&
|
||||
old.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) {
|
||||
e.Record.RefreshTokenKey()
|
||||
// ensure that the token key is regenerated on password change or email change
|
||||
if !e.Record.IsNew() {
|
||||
lastSavedRecord, err := e.App.FindRecordById(e.Record.Collection(), e.Record.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lastSavedRecord.TokenKey() == e.Record.TokenKey() &&
|
||||
(lastSavedRecord.Get(FieldNamePassword) != e.Record.Get(FieldNamePassword) ||
|
||||
lastSavedRecord.Email() != e.Record.Email()) {
|
||||
e.Record.RefreshTokenKey()
|
||||
}
|
||||
}
|
||||
|
||||
// cross-check that the auth record id is unique across all auth collections.
|
||||
@@ -1394,7 +1469,7 @@ func onRecordDeleteExecute(e *RecordEvent) error {
|
||||
//
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||
refs, err := e.App.FindCollectionReferences(e.Record.Collection())
|
||||
refs, err := e.App.FindCachedCollectionReferences(e.Record.Collection())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1433,12 +1508,13 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
|
||||
for _, refCollection := range sortedRefKeys {
|
||||
fields, ok := refs[refCollection]
|
||||
|
||||
if refCollection.IsView() || !ok {
|
||||
if !ok || refCollection.IsView() {
|
||||
continue // skip missing or view collections
|
||||
}
|
||||
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
|
||||
for _, field := range fields {
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName())
|
||||
|
||||
query := app.RecordQuery(refCollection)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package core
|
||||
|
||||
import "github.com/pocketbase/pocketbase/tools/security"
|
||||
|
||||
// Email returns the "email" record field value (usually available with Auth collections).
|
||||
func (m *Record) Email() string {
|
||||
return m.GetString(FieldNameEmail)
|
||||
@@ -51,6 +53,25 @@ func (m *Record) SetPassword(password string) {
|
||||
m.Set(FieldNamePassword, password)
|
||||
}
|
||||
|
||||
// SetRandomPassword sets the "password" auth record field to a random autogenerated value.
|
||||
//
|
||||
// The autogenerated password is ~30 characters and it is set directly as hash,
|
||||
// aka. the field plain password value validators (length, pattern, etc.) are ignored
|
||||
// (this is usually used as part of the auto created OTP or OAuth2 user flows).
|
||||
func (m *Record) SetRandomPassword() string {
|
||||
pass := security.RandomString(30)
|
||||
|
||||
m.Set(FieldNamePassword, pass)
|
||||
m.RefreshTokenKey() // manually refresh the token key because the plain password is resetted
|
||||
|
||||
// unset the plain value to skip the field validators
|
||||
if raw, ok := m.GetRaw(FieldNamePassword).(*PasswordFieldValue); ok {
|
||||
raw.Plain = ""
|
||||
}
|
||||
|
||||
return pass
|
||||
}
|
||||
|
||||
// ValidatePassword validates a plain password against the "password" record field.
|
||||
//
|
||||
// Returns false if the password is incorrect.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@@ -117,3 +119,42 @@ func TestRecordPassword(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSetRandomPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldTokenKey := "old_tokenKey"
|
||||
record := core.NewRecord(core.NewAuthCollection("test"))
|
||||
record.SetTokenKey(oldTokenKey)
|
||||
|
||||
pass := record.SetRandomPassword()
|
||||
|
||||
if pass == "" {
|
||||
t.Fatal("Expected non-empty generated random password")
|
||||
}
|
||||
|
||||
if !record.ValidatePassword(pass) {
|
||||
t.Fatal("Expected the generated random password to be valid")
|
||||
}
|
||||
|
||||
if record.TokenKey() == oldTokenKey {
|
||||
t.Fatal("Expected token key to change")
|
||||
}
|
||||
|
||||
f, ok := record.Collection().Fields.GetByName(core.FieldNamePassword).(*core.PasswordField)
|
||||
if !ok {
|
||||
t.Fatal("Expected *core.PasswordField")
|
||||
}
|
||||
|
||||
// ensure that the field validators will be ignored
|
||||
f.Min = 1
|
||||
f.Max = 2
|
||||
f.Pattern = `\d+`
|
||||
|
||||
if err := f.ValidateValue(context.Background(), app, record); err != nil {
|
||||
t.Fatalf("Expected password field plain value validators to be ignored, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
@@ -9,6 +11,10 @@ import (
|
||||
|
||||
const CollectionNameSuperusers = "_superusers"
|
||||
|
||||
// DefaultInstallerEmail is the default superuser email address
|
||||
// for the initial autogenerated superuser account.
|
||||
const DefaultInstallerEmail = "__pbinstaller@example.com"
|
||||
|
||||
func (app *BaseApp) registerSuperuserHooks() {
|
||||
app.OnRecordDelete(CollectionNameSuperusers).Bind(&hook.Handler[*RecordEvent]{
|
||||
Id: "pbSuperusersRecordDelete",
|
||||
@@ -39,13 +45,45 @@ func (app *BaseApp) registerSuperuserHooks() {
|
||||
Id: "pbSuperusersRecordSaveExec",
|
||||
Func: func(e *RecordEvent) error {
|
||||
e.Record.SetVerified(true) // always mark superusers as verified
|
||||
return e.Next()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure that the installer superuser is deleted
|
||||
if e.Type == ModelEventTypeCreate && e.Record.Email() != DefaultInstallerEmail {
|
||||
record, err := app.FindAuthRecordByEmail(CollectionNameSuperusers, DefaultInstallerEmail)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// already deleted
|
||||
} else if err != nil {
|
||||
e.App.Logger().Warn("Failed to fetch installer superuser", "error", err)
|
||||
} else {
|
||||
err = e.App.Delete(record)
|
||||
if err != nil {
|
||||
e.App.Logger().Warn("Failed to delete installer superuser", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Priority: -99,
|
||||
}
|
||||
app.OnRecordCreateExecute(CollectionNameSuperusers).Bind(recordSaveHandler)
|
||||
app.OnRecordUpdateExecute(CollectionNameSuperusers).Bind(recordSaveHandler)
|
||||
|
||||
// prevent sending password reset emails to the installer address
|
||||
app.OnMailerRecordPasswordResetSend(CollectionNameSuperusers).Bind(&hook.Handler[*MailerRecordEvent]{
|
||||
Id: "pbSuperusersInstallerPasswordReset",
|
||||
Func: func(e *MailerRecordEvent) error {
|
||||
if e.Record.Email() == DefaultInstallerEmail {
|
||||
return errors.New("cannot reset the password for the installer superuser")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
})
|
||||
|
||||
collectionSaveHandler := &hook.Handler[*CollectionEvent]{
|
||||
Id: "pbSuperusersCollectionSaveExec",
|
||||
Func: func(e *CollectionEvent) error {
|
||||
|
||||
+73
-32
@@ -244,10 +244,13 @@ func TestRecordFresh(t *testing.T) {
|
||||
|
||||
extraFieldsCheck := []string{`"email":`, `"custom":`}
|
||||
|
||||
autodateTest := types.NowDateTime()
|
||||
|
||||
// change the fields
|
||||
record.Id = "changed"
|
||||
record.Set("name", "name_new")
|
||||
record.Set("custom", "test_custom")
|
||||
record.SetRaw("created", autodateTest)
|
||||
record.SetExpand(map[string]any{"test": 123})
|
||||
record.IgnoreEmailVisibility(true)
|
||||
record.IgnoreUnchangedFields(true)
|
||||
@@ -270,6 +273,9 @@ func TestRecordFresh(t *testing.T) {
|
||||
if v := record.GetString("name"); v != "name_new" {
|
||||
t.Fatalf("Expected name to be %q, got %q", "name_new", v)
|
||||
}
|
||||
if v := record.GetDateTime("created").String(); v != autodateTest.String() {
|
||||
t.Fatalf("Expected created to be %q, got %q", autodateTest.String(), v)
|
||||
}
|
||||
if v := record.GetString("custom"); v != "test_custom" {
|
||||
t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
|
||||
}
|
||||
@@ -287,6 +293,12 @@ func TestRecordFresh(t *testing.T) {
|
||||
if v := record.Fresh().GetString("name"); v != record.GetString("name") {
|
||||
t.Fatalf("Expected the fresh name to be %q, got %q", record.GetString("name"), v)
|
||||
}
|
||||
if v := record.Fresh().GetDateTime("created").String(); v != autodateTest.String() {
|
||||
t.Fatalf("Expected the fresh created to be %q, got %q", autodateTest.String(), v)
|
||||
}
|
||||
if v := record.Fresh().GetDateTime("updated").String(); v != record.GetDateTime("updated").String() {
|
||||
t.Fatalf("Expected the fresh updated to be %q, got %q", record.GetDateTime("updated").String(), v)
|
||||
}
|
||||
if v := record.Fresh().GetString("custom"); v != "" {
|
||||
t.Fatalf("Expected the fresh custom to be %q, got %q", "", v)
|
||||
}
|
||||
@@ -321,10 +333,13 @@ func TestRecordClone(t *testing.T) {
|
||||
|
||||
extraFieldsCheck := []string{`"email":`, `"custom":`}
|
||||
|
||||
autodateTest := types.NowDateTime()
|
||||
|
||||
// change the fields
|
||||
record.Id = "changed"
|
||||
record.Set("name", "name_new")
|
||||
record.Set("custom", "test_custom")
|
||||
record.SetRaw("created", autodateTest)
|
||||
record.SetExpand(map[string]any{"test": 123})
|
||||
record.IgnoreEmailVisibility(true)
|
||||
record.WithCustomData(true)
|
||||
@@ -346,6 +361,9 @@ func TestRecordClone(t *testing.T) {
|
||||
if v := record.GetString("name"); v != "name_new" {
|
||||
t.Fatalf("Expected name to be %q, got %q", "name_new", v)
|
||||
}
|
||||
if v := record.GetDateTime("created").String(); v != autodateTest.String() {
|
||||
t.Fatalf("Expected created to be %q, got %q", autodateTest.String(), v)
|
||||
}
|
||||
if v := record.GetString("custom"); v != "test_custom" {
|
||||
t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
|
||||
}
|
||||
@@ -363,6 +381,12 @@ func TestRecordClone(t *testing.T) {
|
||||
if v := record.Clone().GetString("name"); v != record.GetString("name") {
|
||||
t.Fatalf("Expected the clone name to be %q, got %q", record.GetString("name"), v)
|
||||
}
|
||||
if v := record.Clone().GetDateTime("created").String(); v != autodateTest.String() {
|
||||
t.Fatalf("Expected the clone created to be %q, got %q", autodateTest.String(), v)
|
||||
}
|
||||
if v := record.Clone().GetDateTime("updated").String(); v != record.GetDateTime("updated").String() {
|
||||
t.Fatalf("Expected the clone updated to be %q, got %q", record.GetDateTime("updated").String(), v)
|
||||
}
|
||||
if v := record.Clone().GetString("custom"); v != "test_custom" {
|
||||
t.Fatalf("Expected the clone custom to be %q, got %q", "test_custom", v)
|
||||
}
|
||||
@@ -989,7 +1013,7 @@ func TestRecordGetStringSlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordGetUploadedFiles(t *testing.T) {
|
||||
func TestRecordGetUnsavedFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
@@ -1030,14 +1054,14 @@ func TestRecordGetUploadedFiles(t *testing.T) {
|
||||
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||
},
|
||||
{
|
||||
"files:uploaded",
|
||||
"files:unsaved",
|
||||
`[{"name":"f1","originalName":"f1","size":4},{"name":"f2","originalName":"f2","size":4}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%#v", i, s.key), func(t *testing.T) {
|
||||
v := record.GetUploadedFiles(s.key)
|
||||
v := record.GetUnsavedFiles(s.key)
|
||||
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@@ -1564,13 +1588,15 @@ func TestRecordValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := core.NewBaseCollection("test")
|
||||
|
||||
// dummy collection to ensure that the specified field validators are triggered
|
||||
collection := core.NewBaseCollection("validate_test")
|
||||
collection.Fields.Add(
|
||||
// dummy fields to ensure that its validators are triggered
|
||||
&core.TextField{Name: "f1", Min: 3},
|
||||
&core.NumberField{Name: "f2", Required: true},
|
||||
)
|
||||
if err := app.Save(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Id = "!invalid"
|
||||
@@ -1585,7 +1611,7 @@ func TestRecordValidate(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("satisfying the fields validations", func(t *testing.T) {
|
||||
record.Id = strings.Repeat("a", 15)
|
||||
record.Id = strings.Repeat("b", 15)
|
||||
record.Set("f1", "abc")
|
||||
record.Set("f2", 1)
|
||||
tests.TestValidationErrors(t, app.Validate(record), nil)
|
||||
@@ -1796,44 +1822,59 @@ func TestRecordSaveIdUpdateNoValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSaveWithChangedPassword(t *testing.T) {
|
||||
func TestRecordSaveWithAutoTokenKeyRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
record, err := app.FindAuthRecordByEmail("nologin", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
scenarios := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
expectedChange bool
|
||||
}{
|
||||
{
|
||||
"no email or password change",
|
||||
map[string]any{"name": "example"},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"password change",
|
||||
map[string]any{"password": "1234567890"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"email change",
|
||||
map[string]any{"email": "test_update@example.com"},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
originalTokenKey := record.TokenKey()
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
record, err := app.FindFirstRecordByFilter("nologin", "1=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("no password change shouldn't change the tokenKey", func(t *testing.T) {
|
||||
record.Set("name", "example")
|
||||
originalTokenKey := record.TokenKey()
|
||||
|
||||
if err := app.Save(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.Load(s.payload)
|
||||
|
||||
tokenKey := record.TokenKey()
|
||||
if tokenKey == "" || originalTokenKey != tokenKey {
|
||||
t.Fatalf("Expected tokenKey to not change, got %q VS %q", originalTokenKey, tokenKey)
|
||||
}
|
||||
})
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("password change should change the tokenKey", func(t *testing.T) {
|
||||
record.Set("password", "1234567890")
|
||||
newTokenKey := record.TokenKey()
|
||||
|
||||
if err := app.Save(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hasChange := originalTokenKey != newTokenKey
|
||||
|
||||
tokenKey := record.TokenKey()
|
||||
if tokenKey == "" || originalTokenKey == tokenKey {
|
||||
t.Fatalf("Expected tokenKey to change, got %q VS %q", originalTokenKey, tokenKey)
|
||||
}
|
||||
})
|
||||
if hasChange != s.expectedChange {
|
||||
t.Fatalf("Expected hasChange %v, got %v", s.expectedChange, hasChange)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDelete(t *testing.T) {
|
||||
|
||||
+28
-13
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
@@ -102,11 +103,6 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
|
||||
|
||||
return nil
|
||||
default: // expects []RecordProxy slice
|
||||
records, err := resolveRecordAllHook(collection, op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
||||
return errors.New("must be a pointer")
|
||||
@@ -118,11 +114,6 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
|
||||
return errors.New("must be a slice of RecordSetters")
|
||||
}
|
||||
|
||||
// create an empty slice
|
||||
if rv.IsNil() {
|
||||
rv.Set(reflect.MakeSlice(rv.Type(), 0, len(records)))
|
||||
}
|
||||
|
||||
et := rv.Type().Elem()
|
||||
|
||||
var isSliceOfPointers bool
|
||||
@@ -135,6 +126,16 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
records, err := resolveRecordAllHook(collection, op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create an empty slice
|
||||
if rv.IsNil() {
|
||||
rv.Set(reflect.MakeSlice(rv.Type(), 0, len(records)))
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
ev := reflect.New(et)
|
||||
|
||||
@@ -527,20 +528,34 @@ func (app *BaseApp) FindAuthRecordByToken(token string, validTypes ...string) (*
|
||||
|
||||
// FindAuthRecordByEmail finds the auth record associated with the provided email.
|
||||
//
|
||||
// The email check would be case-insensitive if the related collection
|
||||
// email unique index has COLLATE NOCASE specified for the email column.
|
||||
//
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (app *BaseApp) FindAuthRecordByEmail(collectionModelOrIdentifier any, email string) (*Record, error) {
|
||||
collection, err := getCollectionByModelOrIdentifier(app, collectionModelOrIdentifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection: %w", err)
|
||||
}
|
||||
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collection.Name)
|
||||
}
|
||||
|
||||
record := &Record{}
|
||||
|
||||
var expr dbx.Expression
|
||||
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, FieldNameEmail)
|
||||
if ok && strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("[["+FieldNameEmail+"]] = {:email} COLLATE NOCASE", dbx.Params{"email": email})
|
||||
} else {
|
||||
expr = dbx.HashExp{FieldNameEmail: email}
|
||||
}
|
||||
|
||||
err = app.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{FieldNameEmail: email}).
|
||||
AndWhere(expr).
|
||||
Limit(1).
|
||||
One(record)
|
||||
if err != nil {
|
||||
@@ -584,7 +599,7 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
query := app.RecordQuery(record.Collection()).
|
||||
Select("(1)").
|
||||
@@ -603,5 +618,5 @@ func (app *BaseApp) CanAccessRecord(record *Record, requestInfo *RequestInfo, ac
|
||||
return false, err
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
return exists > 0, nil
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func (app *BaseApp) expandRecords(records []*Record, expandPath string, fetchFun
|
||||
MaxSelect: 2147483647,
|
||||
CollectionId: indirectRel.Id,
|
||||
}
|
||||
if dbutils.HasSingleColumnUniqueIndex(indirectRelField.GetName(), indirectRel.Indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(indirectRel.Indexes, indirectRelField.GetName()); ok {
|
||||
relField.MaxSelect = 1
|
||||
}
|
||||
relCollection = indirectRel
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user