mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-11-13 15:28:37 +01:00
Compare commits
117
Commits
v0.23.0-rc11
...
v0.23.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b4200a65c | ||
|
|
cb3936a499 | ||
|
|
e34c25858c | ||
|
|
3098c2dcd8 | ||
|
|
4f35fb74c8 | ||
|
|
f533320722 | ||
|
|
9efd68ff4c | ||
|
|
9747f46c1d | ||
|
|
88a1867169 | ||
|
|
545a4eb47c | ||
|
|
5f660d8671 | ||
|
|
85c31ba068 | ||
|
|
6f55695fa1 | ||
|
|
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 |
No files matched your search
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '>=1.23.0'
|
go-version: '>=1.23.4'
|
||||||
|
|
||||||
# This step usually is not needed because the /ui/dist is pregenerated locally
|
# This step usually is not needed because the /ui/dist is pregenerated locally
|
||||||
# but its here to ensure that each release embeds the latest admin ui artifacts.
|
# but its here to ensure that each release embeds the latest admin ui artifacts.
|
||||||
|
|||||||
@@ -15,4 +15,6 @@ coverage.out
|
|||||||
# generated markdown previews
|
# generated markdown previews
|
||||||
README.html
|
README.html
|
||||||
CHANGELOG.html
|
CHANGELOG.html
|
||||||
|
CHANGELOG_16_22.html
|
||||||
|
CHANGELOG_8_15.html
|
||||||
LICENSE.html
|
LICENSE.html
|
||||||
@@ -24,13 +24,23 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
- s390x
|
||||||
|
- ppc64le
|
||||||
goarm:
|
goarm:
|
||||||
- 7
|
- 7
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: windows
|
||||||
|
goarch: s390x
|
||||||
|
- goos: windows
|
||||||
|
goarch: ppc64le
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: darwin
|
||||||
|
goarch: s390x
|
||||||
|
- goos: darwin
|
||||||
|
goarch: ppc64le
|
||||||
|
|
||||||
release:
|
release:
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
+66
-248
@@ -1,298 +1,110 @@
|
|||||||
## v0.23.0-rc11
|
## v0.23.8
|
||||||
|
|
||||||
> [!CAUTION]
|
- Fixed Model->Record and Model->Collection hook events sync for nested and/or inner-hook transactions ([#6122](https://github.com/pocketbase/pocketbase/discussions/6122)).
|
||||||
> **This is a prerelease intended for test and experimental purposes only!**
|
|
||||||
|
|
||||||
- Fixed JSVM types errors ([#5797](https://github.com/pocketbase/pocketbase/issues/5797)).
|
- 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.).
|
||||||
|
|
||||||
- Skip the default `loadAuthToken` middleware if `e.Auth` is already loaded ([#5800](https://github.com/pocketbase/pocketbase/discussions/5800)).
|
|
||||||
|
|
||||||
- Restored the API rules for the default initial "users" collection migration to be the same as in <= v0.22.x.
|
## v0.23.7
|
||||||
|
|
||||||
- ⚠️ Replaced the old Instagram provider with a new one that is compatible with the new Instagram Login APIs ([#5588](https://github.com/pocketbase/pocketbase/pull/5588); thanks @pnmcosta).
|
- Fixed JSVM exception -> Go error unwrapping when throwing errors from non-request hooks ([#6102](https://github.com/pocketbase/pocketbase/discussions/6102)).
|
||||||
_The provider key is `instagram2` to prevent conflicts with existing linked users._
|
|
||||||
|
|
||||||
- ⚠️ Changes regarding the first superuser creation based on [#5814](https://github.com/pocketbase/pocketbase/discussions/5814).
|
|
||||||
|
|
||||||
- ⚠️ Removed `apis.RequireSuperuserAuthOnlyIfAny()` middleware.
|
## v0.23.6
|
||||||
|
|
||||||
- ⚠️ Removed `RequestEvent.UnsafeRealIP()` to prevent misuse and confusion with `RequestEvent.RealIP()` (_the latter is considered safer because it checks the trusted proxy headers settings_).
|
- 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)).
|
||||||
|
|
||||||
## v0.23.0-rc10
|
- Updated the `text` field max length validator error message to make it more clear ([#6066](https://github.com/pocketbase/pocketbase/issues/6066)).
|
||||||
|
|
||||||
> [!CAUTION]
|
- Other minor fixes (updated Go deps, skipped unnecessary validator check when the default primary key pattern is used, updated JSVM types, etc.).
|
||||||
> **This is a prerelease intended for test and experimental purposes only!**
|
|
||||||
|
|
||||||
- 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._
|
|
||||||
|
|
||||||
- Change the behavior of the default generated collections snapshot migration to act as "extend" instead of "replace" to prevent accidental data deletion.
|
## v0.23.5
|
||||||
_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`._
|
|
||||||
|
|
||||||
- Added `app.SubscriptionsBroker().TotalClients()` helper method to return the total registered realtime clients ([#5793](https://github.com/pocketbase/pocketbase/issues/5793)).
|
- 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)).
|
||||||
|
|
||||||
## v0.23.0-rc9
|
- Other minor fixes (comment typos, JSVM types update).
|
||||||
|
|
||||||
> [!CAUTION]
|
- Updated Go deps and the min Go releleaser GitHub action version to 1.23.4.
|
||||||
> **This is a prerelease intended for test and experimental purposes only!**
|
|
||||||
|
|
||||||
- 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.23.4
|
||||||
|
|
||||||
- Update the "API preview" section to include information about the batch api.
|
- Fixed `autodate` fields not refreshing when calling `Save` multiple times on the same `Record` instance ([#6000](https://github.com/pocketbase/pocketbase/issues/6000)).
|
||||||
|
|
||||||
- Exported `core.DefaultDBConnect` function that could be used as a fallback when initializing custom SQLite drivers and builds.
|
- Added more descriptive test OTP id and failure log message ([#5982](https://github.com/pocketbase/pocketbase/discussions/5982)).
|
||||||
|
|
||||||
- ⚠️ 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.
|
- Moved the default UI CSP from meta tag to response header ([#5995](https://github.com/pocketbase/pocketbase/discussions/5995)).
|
||||||
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
|
|
||||||
|
|
||||||
import (
|
- Updated Go and npm dependencies.
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/mattn/go-sqlite3"
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
## v0.23.3
|
||||||
// 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
|
- Fixed Gzip middleware not applying when serving static files.
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
|
- Fixed `Record.Fresh()`/`Record.Clone()` methods not properly cloning `autodate` fields ([#5973](https://github.com/pocketbase/pocketbase/discussions/5973)).
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
app := pocketbase.NewWithConfig(pocketbase.Config{
|
|
||||||
DBConnect: func(dbPath string) (*dbx.DB, error) {
|
|
||||||
return dbx.Open("pb_sqlite3", dbPath)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// custom hooks and plugins...
|
## v0.23.2
|
||||||
|
|
||||||
if err := app.Start(); err != nil {
|
- Fixed `RecordQuery()` custom struct scanning ([#5958](https://github.com/pocketbase/pocketbase/discussions/5958)).
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
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.
|
|
||||||
|
|
||||||
- ⚠️ 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_).
|
- Fixed `--dev` log query print formatting.
|
||||||
|
|
||||||
- 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.).
|
- 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.0-rc8
|
|
||||||
|
|
||||||
> [!CAUTION]
|
## v0.23.1
|
||||||
> **This is a prerelease intended for test and experimental purposes only!**
|
|
||||||
|
|
||||||
- Lock the `_otps` and `_mfas` system collections Delete API rule for superusers only.
|
- Added `RequestEvent.Blob(status, contentType, bytes)` response write helper ([#5940](https://github.com/pocketbase/pocketbase/discussions/5940)).
|
||||||
|
|
||||||
- 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.
|
- Added more descriptive error messages.
|
||||||
|
|
||||||
- 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.
|
## v0.23.0
|
||||||
|
|
||||||
- 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"
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## v0.23.0-rc3
|
|
||||||
|
|
||||||
> [!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.).
|
|
||||||
|
|
||||||
|
|
||||||
## v0.23.0-rc2
|
|
||||||
|
|
||||||
> [!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`._
|
|
||||||
|
|
||||||
|
|
||||||
## v0.23.0-rc
|
|
||||||
|
|
||||||
> [!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.
|
|
||||||
|
|
||||||
> [!NOTE]
|
> [!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
|
> 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
|
> 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_).
|
> 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`
|
> - JS SDK: `<0.22.0`
|
||||||
> - Dart SDK: `<0.19.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.
|
- 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`_).
|
- 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.
|
- 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_).
|
- 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._).
|
- 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.
|
- 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.).
|
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
|
||||||
- Admins are now system `_superusers` auth records.
|
- Admins are now system `_superusers` auth records.
|
||||||
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
|
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
|
||||||
@@ -302,23 +114,25 @@ There are many changes but to highlight some of the most notable ones:
|
|||||||
- One-Time Password (OTP) auth method (_via email code_).
|
- One-Time Password (OTP) auth method (_via email code_).
|
||||||
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
|
- 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.
|
- 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._)
|
- Various minor UI improvements (_recursive `Presentable` view, slightly different collection options organization, zoom/pan for the logs chart, etc._)
|
||||||
- and many more...
|
- 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
|
#### 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
|
#### SDKs changes
|
||||||
|
|
||||||
- [JS SDK v0.22.0-rc](https://github.com/pocketbase/js-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-rc](https://github.com/pocketbase/dart-sdk/blob/develop/CHANGELOG.md)
|
- [Dart SDK v0.19.0](https://github.com/pocketbase/dart-sdk/blob/master/CHANGELOG.md)
|
||||||
|
|
||||||
#### Web APIs changes
|
#### Web APIs changes
|
||||||
|
|
||||||
@@ -334,6 +148,10 @@ For upgrading to PocketBase v0.23.0, please refer to:
|
|||||||
|
|
||||||
- New `POST /api/collections/{collection}/impersonate/{id}` endpoint.
|
- 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.
|
- ⚠️ 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:
|
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
|
```js
|
||||||
|
|||||||
+23
-1
@@ -2,6 +2,28 @@
|
|||||||
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v0.22.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
|
## 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)).
|
- Updated the hooks watcher to account for the case when hooksDir is a symlink ([#5789](https://github.com/pocketbase/pocketbase/issues/5789)).
|
||||||
@@ -1121,7 +1143,7 @@
|
|||||||
|
|
||||||
- Updated the schema fields Admin UI for "tidier" fields visualization.
|
- 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_).
|
- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ Here is a minimal example:
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -65,7 +64,7 @@ Here is a minimal example:
|
|||||||
|
|
||||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||||
// registers new "GET /hello" route
|
// 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!")
|
return re.String(200, "Hello world!")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -84,19 +83,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`.
|
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/)._
|
_For more details please refer to [Extend with Go](https://pocketbase.io/docs/go-overview/)._
|
||||||
|
|
||||||
### Building and running the repo main.go example
|
### 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:
|
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
|
1. Clone/download the repo
|
||||||
2. Navigate to `examples/base`
|
2. Navigate to `examples/base`
|
||||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
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.
|
// 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].
|
// NewApiError is an alias for [router.NewApiError].
|
||||||
func NewApiError(status int, message string, errData any) *router.ApiError {
|
func NewApiError(status int, message string, errData any) *router.ApiError {
|
||||||
return router.NewApiError(status, message, errData)
|
return router.NewApiError(status, message, errData)
|
||||||
|
|||||||
@@ -338,6 +338,12 @@ func processInternalRequest(
|
|||||||
// apply batch request specific headers
|
// apply batch request specific headers
|
||||||
// ---
|
// ---
|
||||||
for k, v := range ir.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(k, v)
|
||||||
}
|
}
|
||||||
r.Header.Set("Content-Type", mw.FormDataContentType())
|
r.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
|||||||
+3
-3
@@ -294,9 +294,9 @@ func TestBatchRequest(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Body: strings.NewReader(`{
|
Body: strings.NewReader(`{
|
||||||
"requests": [
|
"requests": [
|
||||||
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}},
|
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}, "headers": {"Authorization": "ignored"}},
|
||||||
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3"},
|
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
|
||||||
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}}
|
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}}
|
||||||
]
|
]
|
||||||
}`),
|
}`),
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
|
|||||||
@@ -724,7 +724,7 @@ func TestCollectionCreate(t *testing.T) {
|
|||||||
ExpectedContent: []string{
|
ExpectedContent: []string{
|
||||||
`"name":"new"`,
|
`"name":"new"`,
|
||||||
`"type":"view"`,
|
`"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{
|
ExpectedEvents: map[string]int{
|
||||||
"*": 0,
|
"*": 0,
|
||||||
|
|||||||
+14
-2
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/list"
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
|
"github.com/spf13/cast"
|
||||||
"golang.org/x/sync/semaphore"
|
"golang.org/x/sync/semaphore"
|
||||||
"golang.org/x/sync/singleflight"
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
@@ -22,10 +24,20 @@ var defaultThumbSizes = []string{"100x100"}
|
|||||||
|
|
||||||
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
||||||
func bindFileApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
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{
|
api := fileApi{
|
||||||
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 2)), // the value is arbitrary chosen and may change in the future
|
|
||||||
thumbGenPending: new(singleflight.Group),
|
thumbGenPending: new(singleflight.Group),
|
||||||
thumbGenMaxWait: 60 * time.Second,
|
thumbGenSem: semaphore.NewWeighted(maxWorkers),
|
||||||
|
thumbGenMaxWait: time.Duration(maxWait) * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
sub := rg.Group("/files")
|
sub := rg.Group("/files")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const (
|
|||||||
DefaultBodyLimitMiddlewarePriority = DefaultRateLimitMiddlewarePriority + 10
|
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.
|
// If limitBytes <= 0, no limit is applied.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ package apis
|
|||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -124,8 +125,8 @@ var DefaultCORSConfig = CORSConfig{
|
|||||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORSWithConfig returns a CORS middleware with config.
|
// CORS returns a CORS middleware.
|
||||||
func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
func CORS(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||||
// Defaults
|
// Defaults
|
||||||
if len(config.AllowOrigins) == 0 {
|
if len(config.AllowOrigins) == 0 {
|
||||||
config.AllowOrigins = DefaultCORSConfig.AllowOrigins
|
config.AllowOrigins = DefaultCORSConfig.AllowOrigins
|
||||||
@@ -134,13 +135,26 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
|||||||
config.AllowMethods = DefaultCORSConfig.AllowMethods
|
config.AllowMethods = DefaultCORSConfig.AllowMethods
|
||||||
}
|
}
|
||||||
|
|
||||||
allowOriginPatterns := []string{}
|
allowOriginPatterns := make([]*regexp.Regexp, 0, len(config.AllowOrigins))
|
||||||
for _, origin := range config.AllowOrigins {
|
for _, origin := range config.AllowOrigins {
|
||||||
|
if origin == "*" {
|
||||||
|
continue // "*" is handled differently and does not need regexp
|
||||||
|
}
|
||||||
|
|
||||||
pattern := regexp.QuoteMeta(origin)
|
pattern := regexp.QuoteMeta(origin)
|
||||||
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
|
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
|
||||||
pattern = strings.ReplaceAll(pattern, "\\?", ".")
|
pattern = strings.ReplaceAll(pattern, "\\?", ".")
|
||||||
pattern = "^" + 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, ",")
|
allowMethods := strings.Join(config.AllowMethods, ",")
|
||||||
@@ -210,7 +224,7 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
|||||||
}
|
}
|
||||||
if checkPatterns {
|
if checkPatterns {
|
||||||
for _, re := range allowOriginPatterns {
|
for _, re := range allowOriginPatterns {
|
||||||
if match, _ := regexp.MatchString(re, origin); match {
|
if match := re.MatchString(origin); match {
|
||||||
allowOrigin = origin
|
allowOrigin = origin
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -272,6 +286,7 @@ func matchSubdomain(domain, pattern string) bool {
|
|||||||
if !matchScheme(domain, pattern) {
|
if !matchScheme(domain, pattern) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
didx := strings.Index(domain, "://")
|
didx := strings.Index(domain, "://")
|
||||||
pidx := strings.Index(pattern, "://")
|
pidx := strings.Index(pattern, "://")
|
||||||
if didx == -1 || pidx == -1 {
|
if didx == -1 || pidx == -1 {
|
||||||
@@ -307,5 +322,6 @@ func matchSubdomain(domain, pattern string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
// Note: Disable the implementation for now because in case the platform
|
||||||
if w.wroteHeader {
|
// supports the sendfile fast-path it won't run gzipResponseWriter.Write,
|
||||||
w.ResponseWriter.WriteHeader(w.code)
|
// preventing compression on the fly.
|
||||||
}
|
//
|
||||||
|
// func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
|
||||||
rw := w.ResponseWriter
|
// if w.wroteHeader {
|
||||||
for {
|
// w.ResponseWriter.WriteHeader(w.code)
|
||||||
switch rf := rw.(type) {
|
// }
|
||||||
case io.ReaderFrom:
|
// rw := w.ResponseWriter
|
||||||
return rf.ReadFrom(r)
|
// for {
|
||||||
case router.RWUnwrapper:
|
// switch rf := rw.(type) {
|
||||||
rw = rf.Unwrap()
|
// case io.ReaderFrom:
|
||||||
default:
|
// return rf.ReadFrom(r)
|
||||||
return io.Copy(w.ResponseWriter, r)
|
// case router.RWUnwrapper:
|
||||||
}
|
// rw = rf.Unwrap()
|
||||||
}
|
// default:
|
||||||
}
|
// return io.Copy(w.ResponseWriter, r)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
func (w *gzipResponseWriter) Unwrap() http.ResponseWriter {
|
func (w *gzipResponseWriter) Unwrap() http.ResponseWriter {
|
||||||
return w.ResponseWriter
|
return w.ResponseWriter
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ func rateLimit() *hook.Handler[*core.RequestEvent] {
|
|||||||
return e.Next()
|
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 {
|
if ok {
|
||||||
err := checkRateLimit(e, e.Request.Pattern, rule)
|
err := checkRateLimit(e, rule.Label+rule.Audience, rule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -94,9 +97,9 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
|||||||
}
|
}
|
||||||
labels = append(labels, defaultRateLimitLabels(e)...)
|
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 {
|
if ok {
|
||||||
return checkRateLimit(e, rtId, rule)
|
return checkRateLimit(e, rtId+rule.Audience, rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -104,8 +107,43 @@ 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[*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 {
|
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 {
|
rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
|
||||||
return initRateLimitersStore(e.App)
|
return initRateLimitersStore(e.App)
|
||||||
}).(*store.Store[*rateLimiter])
|
}).(*store.Store[*rateLimiter])
|
||||||
@@ -139,6 +177,17 @@ func skipRateLimit(e *core.RequestEvent) bool {
|
|||||||
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
|
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 {
|
func defaultRateLimitLabels(e *core.RequestEvent) []string {
|
||||||
return []string{e.Request.Method + " " + e.Request.URL.Path, e.Request.URL.Path}
|
return []string{e.Request.Method + " " + e.Request.URL.Path, e.Request.URL.Path}
|
||||||
}
|
}
|
||||||
@@ -199,6 +248,15 @@ type rateLimiter struct {
|
|||||||
sync.RWMutex
|
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 {
|
func (rt *rateLimiter) isAllowed(key string) bool {
|
||||||
// lock only reads to minimize locks contention
|
// lock only reads to minimize locks contention
|
||||||
rt.RLock()
|
rt.RLock()
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
|||||||
MaxRequests: 1,
|
MaxRequests: 1,
|
||||||
Duration: 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)
|
pbRouter, err := apis.NewRouter(app)
|
||||||
@@ -48,6 +60,12 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
|||||||
pbRouter.GET("/rate/b", func(e *core.RequestEvent) error {
|
pbRouter.GET("/rate/b", func(e *core.RequestEvent) error {
|
||||||
return e.String(200, "b")
|
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()
|
mux, err := pbRouter.BuildMux()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,30 +75,53 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
|||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
url string
|
url string
|
||||||
wait float64
|
wait float64
|
||||||
|
authenticated bool
|
||||||
expectedStatus int
|
expectedStatus int
|
||||||
}{
|
}{
|
||||||
{"/norate", 0, 200},
|
{"/norate", 0, false, 200},
|
||||||
{"/norate", 0, 200},
|
{"/norate", 0, false, 200},
|
||||||
{"/norate", 0, 200},
|
{"/norate", 0, false, 200},
|
||||||
{"/norate", 0, 200},
|
{"/norate", 0, false, 200},
|
||||||
{"/norate", 0, 200},
|
{"/norate", 0, false, 200},
|
||||||
|
|
||||||
{"/rate/a", 0, 200},
|
{"/rate/a", 0, false, 200},
|
||||||
{"/rate/a", 0, 200},
|
{"/rate/a", 0, false, 200},
|
||||||
{"/rate/a", 0, 429},
|
{"/rate/a", 0, false, 429},
|
||||||
{"/rate/a", 0, 429},
|
{"/rate/a", 0, false, 429},
|
||||||
{"/rate/a", 1.1, 200},
|
{"/rate/a", 1.1, false, 200},
|
||||||
{"/rate/a", 0, 200},
|
{"/rate/a", 0, false, 200},
|
||||||
{"/rate/a", 0, 429},
|
{"/rate/a", 0, false, 429},
|
||||||
|
|
||||||
{"/rate/b", 0, 200},
|
{"/rate/b", 0, false, 200},
|
||||||
{"/rate/b", 0, 200},
|
{"/rate/b", 0, false, 200},
|
||||||
{"/rate/b", 0, 200},
|
{"/rate/b", 0, false, 200},
|
||||||
{"/rate/b", 0, 429},
|
{"/rate/b", 0, false, 429},
|
||||||
{"/rate/b", 1.1, 200},
|
{"/rate/b", 1.1, false, 200},
|
||||||
{"/rate/b", 0, 200},
|
{"/rate/b", 0, false, 200},
|
||||||
{"/rate/b", 0, 200},
|
{"/rate/b", 0, false, 200},
|
||||||
{"/rate/b", 0, 429},
|
{"/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 {
|
for _, s := range scenarios {
|
||||||
@@ -91,6 +132,21 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest("GET", s.url, nil)
|
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)
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
result := rec.Result()
|
result := rec.Result()
|
||||||
|
|||||||
+4
-4
@@ -242,8 +242,8 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
|
|||||||
return group.Wait()
|
return group.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
|
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
|
||||||
func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
|
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
|
||||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||||
|
|
||||||
group := new(errgroup.Group)
|
group := new(errgroup.Group)
|
||||||
@@ -255,7 +255,7 @@ func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
|
|||||||
if clientAuth != nil &&
|
if clientAuth != nil &&
|
||||||
clientAuth.Id == authModel.PK() &&
|
clientAuth.Id == authModel.PK() &&
|
||||||
clientAuth.Collection().Name == authModel.TableName() {
|
clientAuth.Collection().Name == authModel.TableName() {
|
||||||
app.SubscriptionsBroker().Unregister(client.Id())
|
client.Unset(RealtimeClientAuthKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ func bindRealtimeEvents(app core.App) {
|
|||||||
Func: func(e *core.ModelEvent) error {
|
Func: func(e *core.ModelEvent) error {
|
||||||
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
||||||
if collection != nil && collection.IsAuth() {
|
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(
|
app.Logger().Warn(
|
||||||
"Failed to remove client(s) associated to the deleted auth model",
|
"Failed to remove client(s) associated to the deleted auth model",
|
||||||
slog.Any("id", e.Model.PK()),
|
slog.Any("id", e.Model.PK()),
|
||||||
|
|||||||
+64
-14
@@ -427,26 +427,51 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
|||||||
// init realtime handlers
|
// init realtime handlers
|
||||||
apis.NewRouter(testApp)
|
apis.NewRouter(testApp)
|
||||||
|
|
||||||
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := subscriptions.NewDefaultClient()
|
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
|
||||||
client.Set(apis.RealtimeClientAuthKey, authRecord)
|
if err != nil {
|
||||||
testApp.SubscriptionsBroker().Register(client)
|
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
|
// mock delete event
|
||||||
e := new(core.ModelEvent)
|
e := new(core.ModelEvent)
|
||||||
e.App = testApp
|
e.App = testApp
|
||||||
e.Type = core.ModelEventTypeDelete
|
e.Type = core.ModelEventTypeDelete
|
||||||
e.Context = context.Background()
|
e.Context = context.Background()
|
||||||
e.Model = authRecord
|
e.Model = authRecord1
|
||||||
|
|
||||||
testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
||||||
|
|
||||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
|
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
|
||||||
t.Fatalf("Expected no subscription clients, found %d", total)
|
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 +549,30 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
|||||||
// init realtime handlers
|
// init realtime handlers
|
||||||
apis.NewRouter(testApp)
|
apis.NewRouter(testApp)
|
||||||
|
|
||||||
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := subscriptions.NewDefaultClient()
|
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
|
||||||
client.Set(apis.RealtimeClientAuthKey, authRecord)
|
if err != nil {
|
||||||
testApp.SubscriptionsBroker().Register(client)
|
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
|
// refetch the authRecord as CustomUser
|
||||||
customUser, err := findCustomUserByEmail(testApp, "test@example.com")
|
customUser, err := findCustomUserByEmail(testApp, authRecord1.Email())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -544,8 +582,20 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
|
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
|
||||||
t.Fatalf("Expected no subscription clients, found %d", total)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package apis
|
package apis
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -32,12 +33,10 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
record, err := e.App.FindAuthRecordByEmail(collection, form.Email)
|
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
|
// ignore not found errors to allow custom record find implementations
|
||||||
e.JSON(http.StatusOK, map[string]string{
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
"otpId": core.GenerateDefaultRandomId(),
|
return e.InternalServerError("", err)
|
||||||
})
|
|
||||||
return fmt.Errorf("failed to fetch %s record with email %s: %w", collection.Name, form.Email, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
event := new(core.RecordCreateOTPRequestEvent)
|
event := new(core.RecordCreateOTPRequestEvent)
|
||||||
@@ -46,7 +45,18 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
|||||||
event.Collection = collection
|
event.Collection = collection
|
||||||
event.Record = record
|
event.Record = record
|
||||||
|
|
||||||
|
originalApp := e.App
|
||||||
|
|
||||||
return e.App.OnRecordRequestOTPRequest().Trigger(event, func(e *core.RecordCreateOTPRequestEvent) error {
|
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
|
var otp *core.OTP
|
||||||
|
|
||||||
// limit the new OTP creations for a single user
|
// limit the new OTP creations for a single user
|
||||||
@@ -90,11 +100,10 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
|||||||
// send OTP email
|
// send OTP email
|
||||||
// (in the background as a very basic timing attacks and emails enumeration protection)
|
// (in the background as a very basic timing attacks and emails enumeration protection)
|
||||||
// ---
|
// ---
|
||||||
app := e.App
|
|
||||||
routine.FireAndForget(func() {
|
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 {
|
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{
|
ExpectedContent: []string{
|
||||||
`"otpId":"`, // some fake random generated 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) {
|
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||||
if app.TestMailer.TotalSend() != 0 {
|
if app.TestMailer.TotalSend() != 0 {
|
||||||
t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
|
t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
|
||||||
@@ -137,6 +140,57 @@ func TestRecordRequestOTP(t *testing.T) {
|
|||||||
"OnModelCreate": 1,
|
"OnModelCreate": 1,
|
||||||
"OnModelCreateExecute": 1,
|
"OnModelCreateExecute": 1,
|
||||||
"OnModelAfterCreateSuccess": 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,
|
"OnModelValidate": 1,
|
||||||
"OnRecordCreate": 1,
|
"OnRecordCreate": 1,
|
||||||
"OnRecordCreateExecute": 1,
|
"OnRecordCreateExecute": 1,
|
||||||
@@ -144,8 +198,14 @@ func TestRecordRequestOTP(t *testing.T) {
|
|||||||
"OnRecordValidate": 1,
|
"OnRecordValidate": 1,
|
||||||
},
|
},
|
||||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||||
if app.TestMailer.TotalSend() != 1 {
|
if app.TestMailer.TotalSend() != 0 {
|
||||||
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
|
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))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package apis
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -95,6 +96,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
|||||||
"provider": form.Provider,
|
"provider": form.Provider,
|
||||||
"providerId": authUser.Id,
|
"providerId": authUser.Id,
|
||||||
})
|
})
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return e.InternalServerError("Failed OAuth2 relation check.", err)
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err == nil && externalAuthRel != nil:
|
case err == nil && externalAuthRel != nil:
|
||||||
authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
|
authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
|
||||||
@@ -106,7 +111,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
|||||||
authRecord = fallbackAuthRecord
|
authRecord = fallbackAuthRecord
|
||||||
case authUser.Email != "":
|
case authUser.Email != "":
|
||||||
// look for an existing auth record by the external auth record's 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -167,7 +175,7 @@ type recordOAuth2LoginForm struct {
|
|||||||
|
|
||||||
func (form *recordOAuth2LoginForm) validate() error {
|
func (form *recordOAuth2LoginForm) validate() error {
|
||||||
return validation.ValidateStruct(form,
|
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.Code, validation.Required),
|
||||||
validation.Field(&form.RedirectURL, validation.Required),
|
validation.Field(&form.RedirectURL, validation.Required),
|
||||||
)
|
)
|
||||||
@@ -178,7 +186,7 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
|||||||
|
|
||||||
_, ok := form.collection.OAuth2.GetProviderConfig(name)
|
_, ok := form.collection.OAuth2.GetProviderConfig(name)
|
||||||
if !ok {
|
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})
|
SetParams(map[string]any{"name": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +245,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
|||||||
payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
|
payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
|
||||||
}
|
}
|
||||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.AvatarURL]; !ok &&
|
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 != "" &&
|
e.Collection.OAuth2.MappedFields.AvatarURL != "" &&
|
||||||
// non-empty OAuth2 avatar url
|
// non-empty OAuth2 avatar url
|
||||||
e.OAuth2User.AvatarURL != "" {
|
e.OAuth2User.AvatarURL != "" {
|
||||||
|
|||||||
@@ -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) {
|
checkClientMessages := func(t testing.TB, clientId string, msg subscriptions.Message, expectedMessages map[string][]string) {
|
||||||
if len(expectedMessages[clientId]) == 0 {
|
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" {
|
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] {
|
for _, txt := range expectedMessages[clientId] {
|
||||||
if !strings.Contains(string(msg.Data), txt) {
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case msg := <-clients["c1"].Channel():
|
case msg, ok := <-clients["c1"].Channel():
|
||||||
checkClientMessages(t, "c1", msg, expectedMessages)
|
if ok {
|
||||||
case msg := <-clients["c2"].Channel():
|
checkClientMessages(t, "c1", msg, expectedMessages)
|
||||||
checkClientMessages(t, "c2", msg, expectedMessages)
|
} else {
|
||||||
case msg := <-clients["c3"].Channel():
|
t.Errorf("Unexpected c1 closed channel")
|
||||||
checkClientMessages(t, "c3", msg, expectedMessages)
|
}
|
||||||
case msg := <-clients["c4"].Channel():
|
case msg, ok := <-clients["c2"].Channel():
|
||||||
checkClientMessages(t, "c4", msg, expectedMessages)
|
if ok {
|
||||||
case msg := <-clients["c5"].Channel():
|
checkClientMessages(t, "c2", msg, expectedMessages)
|
||||||
checkClientMessages(t, "c5", 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():
|
case <-ctx.Done():
|
||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
close(c.Channel())
|
c.Discard()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
|||||||
return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
|
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
|
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumaration protection
|
||||||
err = checkRateLimit(e, "@pb_otp_"+event.OTP.Id+event.Record.Id, core.RateLimitRule{MaxRequests: 4, Duration: 180})
|
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
|
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
|
||||||
}
|
}
|
||||||
@@ -63,23 +63,33 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
|||||||
// ---
|
// ---
|
||||||
|
|
||||||
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
|
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
|
||||||
|
// 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 update record verified state after successful OTP validation",
|
||||||
|
"error", err,
|
||||||
|
"otpId", e.OTP.Id,
|
||||||
|
"recordId", e.Record.Id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to delete the used otp
|
|
||||||
if e.OTP != nil {
|
|
||||||
err = e.App.Delete(e.OTP)
|
|
||||||
if err != nil {
|
|
||||||
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.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)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
|||||||
ExpectedEvents: map[string]int{"*": 0},
|
ExpectedEvents: map[string]int{"*": 0},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "valid otp with valid password",
|
Name: "valid otp with valid password (enabled MFA)",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: "/api/collections/users/auth-with-otp",
|
URL: "/api/collections/users/auth-with-otp",
|
||||||
Body: strings.NewReader(`{
|
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,
|
Method: http.MethodPost,
|
||||||
URL: "/api/collections/users/auth-with-otp",
|
URL: "/api/collections/users/auth-with-otp",
|
||||||
Body: strings.NewReader(`{
|
Body: strings.NewReader(`{
|
||||||
@@ -249,8 +249,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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
|
user.Collection().MFA.Enabled = false
|
||||||
if err := app.Save(user.Collection()); err != nil {
|
if err = app.Save(user.Collection()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +304,106 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
|||||||
"OnRecordDeleteExecute": 1,
|
"OnRecordDeleteExecute": 1,
|
||||||
"OnRecordAfterDeleteSuccess": 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
|
// rate limit checks
|
||||||
@@ -387,11 +494,13 @@ func TestRecordAuthWithOTPManualRateLimiterCheck(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{otpAId, "12345", 400},
|
{otpAId, "12345", 400},
|
||||||
{otpAId, "12345", 400},
|
{otpAId, "12345", 400},
|
||||||
{otpAId, "12345", 400},
|
|
||||||
{otpAId, "12345", 400},
|
|
||||||
{otpAId, "123456", 429},
|
|
||||||
{otpBId, "12345", 400},
|
{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 {
|
for _, s := range scenarios {
|
||||||
|
|||||||
+59
-2
@@ -1,15 +1,19 @@
|
|||||||
package apis
|
package apis
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
cryptoRand "crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/forms"
|
"github.com/pocketbase/pocketbase/forms"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/list"
|
||||||
"github.com/pocketbase/pocketbase/tools/router"
|
"github.com/pocketbase/pocketbase/tools/router"
|
||||||
"github.com/pocketbase/pocketbase/tools/search"
|
"github.com/pocketbase/pocketbase/tools/search"
|
||||||
)
|
)
|
||||||
@@ -86,10 +90,44 @@ func recordsList(e *core.RequestEvent) error {
|
|||||||
return firstApiError(err, e.InternalServerError("Failed to enrich records", err))
|
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 possibity of eventual
|
||||||
|
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
|
||||||
|
// This is by design and it is an accepted tradeoff between performance, usability and correctness.
|
||||||
|
//
|
||||||
|
// 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(100)
|
||||||
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, e.Result)
|
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 {
|
func recordView(e *core.RequestEvent) error {
|
||||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||||
if err != nil || collection == nil {
|
if err != nil || collection == nil {
|
||||||
@@ -524,9 +562,28 @@ func recordDataFromRequest(e *core.RequestEvent, record *core.Record) (map[strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(uploadedFiles) > 0 {
|
if len(uploadedFiles) > 0 {
|
||||||
for k, v := range uploadedFiles {
|
for k, files := range uploadedFiles {
|
||||||
result[k] = v
|
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)
|
result = record.ReplaceModifiers(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,69 @@ func TestRecordCrudList(t *testing.T) {
|
|||||||
"OnRecordEnrich": 4,
|
"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",
|
Name: ":rule modifer",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
@@ -2345,7 +2408,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
|
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")
|
}, "files")
|
||||||
if err3 != nil {
|
if err3 != nil {
|
||||||
t.Fatal(err3)
|
t.Fatal(err3)
|
||||||
@@ -2686,6 +2749,8 @@ func TestRecordCrudUpdate(t *testing.T) {
|
|||||||
`"id":"mk5fmymtx4wsprk"`,
|
`"id":"mk5fmymtx4wsprk"`,
|
||||||
`"title":"title_test3"`,
|
`"title":"title_test3"`,
|
||||||
`"files":["`,
|
`"files":["`,
|
||||||
|
`"300_JdfBOieXAW.png"`,
|
||||||
|
`"tmpfile_`,
|
||||||
},
|
},
|
||||||
ExpectedEvents: map[string]int{
|
ExpectedEvents: map[string]int{
|
||||||
"*": 0,
|
"*": 0,
|
||||||
|
|||||||
+23
-14
@@ -20,6 +20,8 @@ const (
|
|||||||
fieldsQueryParam = "fields"
|
fieldsQueryParam = "fields"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrMFA = errors.New("mfa required")
|
||||||
|
|
||||||
// RecordAuthResponse writes standardized json record auth response
|
// RecordAuthResponse writes standardized json record auth response
|
||||||
// into the specified request context.
|
// into the specified request context.
|
||||||
//
|
//
|
||||||
@@ -70,9 +72,12 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
|
|||||||
|
|
||||||
// require additional authentication
|
// require additional authentication
|
||||||
if mfaId != "" {
|
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,
|
"mfaId": mfaId,
|
||||||
})
|
})
|
||||||
|
return ErrMFA
|
||||||
}
|
}
|
||||||
// ---
|
// ---
|
||||||
|
|
||||||
@@ -109,20 +114,25 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]any{
|
result := struct {
|
||||||
"token": e.Token,
|
Meta any `json:"meta,omitempty"`
|
||||||
"record": e.Record,
|
Record *core.Record `json:"record"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}{
|
||||||
|
Token: e.Token,
|
||||||
|
Record: e.Record,
|
||||||
}
|
}
|
||||||
|
|
||||||
if e.Meta != nil {
|
if e.Meta != nil {
|
||||||
result["meta"] = e.Meta
|
result.Meta = e.Meta
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, result)
|
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) {
|
func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||||
rule := record.Collection().MFA.Rule
|
rule := record.Collection().MFA.Rule
|
||||||
if rule == "" {
|
if rule == "" {
|
||||||
@@ -131,7 +141,7 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
|||||||
|
|
||||||
requestInfo, err := e.RequestInfo()
|
requestInfo, err := e.RequestInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists bool
|
var exists bool
|
||||||
@@ -144,13 +154,13 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
|||||||
resolver := core.NewRecordFieldResolver(e.App, record.Collection(), requestInfo, true)
|
resolver := core.NewRecordFieldResolver(e.App, record.Collection(), requestInfo, true)
|
||||||
expr, err := search.FilterData(rule).BuildExpr(resolver)
|
expr, err := search.FilterData(rule).BuildExpr(resolver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return true, err
|
||||||
}
|
}
|
||||||
resolver.UpdateQuery(query)
|
resolver.UpdateQuery(query)
|
||||||
|
|
||||||
err = query.AndWhere(expr).Limit(1).Row(&exists)
|
err = query.AndWhere(expr).Limit(1).Row(&exists)
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
return false, err
|
return true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return exists, nil
|
return exists, nil
|
||||||
@@ -166,11 +176,10 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
|
|||||||
}
|
}
|
||||||
|
|
||||||
ok, err := wantsMFA(e, authRecord)
|
ok, err := wantsMFA(e, authRecord)
|
||||||
|
if err != nil {
|
||||||
|
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
|
||||||
|
}
|
||||||
if !ok {
|
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
|
return "", nil // no mfa needed for this auth record
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +223,7 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
|
|||||||
}
|
}
|
||||||
if err != nil || mfa.HasExpired(authRecord.Collection().MFA.DurationTime()) {
|
if err != nil || mfa.HasExpired(authRecord.Collection().MFA.DurationTime()) {
|
||||||
deleteMFA()
|
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 {
|
if mfa.RecordRef() != authRecord.Id || mfa.CollectionRef() != authRecord.Collection().Id {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package apis_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -584,8 +585,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
|
|||||||
user.Collection().MFA.Rule = "1=1"
|
user.Collection().MFA.Rule = "1=1"
|
||||||
|
|
||||||
err = apis.RecordAuthResponse(event, user, "example", nil)
|
err = apis.RecordAuthResponse(event, user, "example", nil)
|
||||||
if err != nil {
|
if !errors.Is(err, apis.ErrMFA) {
|
||||||
t.Fatalf("Expected nil, got error: %v", err)
|
t.Fatalf("Expected ErrMFA, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := rec.Body.String()
|
body := rec.Body.String()
|
||||||
@@ -602,8 +603,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
|
|||||||
resetMFAs(user)
|
resetMFAs(user)
|
||||||
|
|
||||||
err = apis.RecordAuthResponse(event, user, "example", nil)
|
err = apis.RecordAuthResponse(event, user, "example", nil)
|
||||||
if err != nil {
|
if !errors.Is(err, apis.ErrMFA) {
|
||||||
t.Fatalf("Expected nil, got error: %v", err)
|
t.Fatalf("Expected ErrMFA, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := rec.Body.String()
|
body := rec.Body.String()
|
||||||
|
|||||||
+10
-18
@@ -27,11 +27,6 @@ type ServeConfig struct {
|
|||||||
// ShowStartBanner indicates whether to show or hide the server start console message.
|
// ShowStartBanner indicates whether to show or hide the server start console message.
|
||||||
ShowStartBanner bool
|
ShowStartBanner bool
|
||||||
|
|
||||||
// DashboardPath specifies the route path to the superusers dashboard (default to "_").
|
|
||||||
//
|
|
||||||
// Currently it is limited to a single path segment (this is because the UI is not extendable at the moment).
|
|
||||||
DashboardPath string
|
|
||||||
|
|
||||||
// HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80").
|
// HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80").
|
||||||
HttpAddr string
|
HttpAddr string
|
||||||
|
|
||||||
@@ -66,15 +61,6 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
config.AllowedOrigins = []string{"*"}
|
config.AllowedOrigins = []string{"*"}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.DashboardPath == "" {
|
|
||||||
config.DashboardPath = "_"
|
|
||||||
} else {
|
|
||||||
config.DashboardPath = strings.Trim(config.DashboardPath, "/")
|
|
||||||
if strings.Contains(config.DashboardPath, "/") {
|
|
||||||
return errors.New("the dashboard path must be single path segment: _, admin, etc.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure that the latest migrations are applied before starting the server
|
// ensure that the latest migrations are applied before starting the server
|
||||||
err := app.RunAllMigrations()
|
err := app.RunAllMigrations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,17 +72,23 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
pbRouter.Bind(CORSWithConfig(CORSConfig{
|
pbRouter.Bind(CORS(CORSConfig{
|
||||||
AllowOrigins: config.AllowedOrigins,
|
AllowOrigins: config.AllowedOrigins,
|
||||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
pbRouter.GET("/"+config.DashboardPath+"/{path...}", Static(ui.DistDirFS, false)).
|
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
|
||||||
BindFunc(func(e *core.RequestEvent) error {
|
BindFunc(func(e *core.RequestEvent) error {
|
||||||
// ingore root path
|
// ignore root path
|
||||||
if e.Request.PathValue(StaticWildcardParam) != "" {
|
if e.Request.PathValue(StaticWildcardParam) != "" {
|
||||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add a default CSP
|
||||||
|
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||||
|
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
|
||||||
|
}
|
||||||
|
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}).
|
}).
|
||||||
Bind(Gzip())
|
Bind(Gzip())
|
||||||
@@ -255,7 +247,7 @@ func Serve(app core.App, config ServeConfig) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
baseURL := fmt.Sprintf("%s://%s", schema, addr)
|
baseURL := fmt.Sprintf("%s://%s", schema, addr)
|
||||||
dashboardURL := fmt.Sprintf("%s/%s", baseURL, config.DashboardPath)
|
dashboardURL := fmt.Sprintf("%s/_", baseURL)
|
||||||
|
|
||||||
if config.ShowStartBanner {
|
if config.ShowStartBanner {
|
||||||
date := new(strings.Builder)
|
date := new(strings.Builder)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
|||||||
var allowedOrigins []string
|
var allowedOrigins []string
|
||||||
var httpAddr string
|
var httpAddr string
|
||||||
var httpsAddr string
|
var httpsAddr string
|
||||||
var dashboardPath string
|
|
||||||
|
|
||||||
command := &cobra.Command{
|
command := &cobra.Command{
|
||||||
Use: "serve [domain(s)]",
|
Use: "serve [domain(s)]",
|
||||||
@@ -40,7 +39,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
|||||||
err := apis.Serve(app, apis.ServeConfig{
|
err := apis.Serve(app, apis.ServeConfig{
|
||||||
HttpAddr: httpAddr,
|
HttpAddr: httpAddr,
|
||||||
HttpsAddr: httpsAddr,
|
HttpsAddr: httpsAddr,
|
||||||
DashboardPath: dashboardPath,
|
|
||||||
ShowStartBanner: showStartBanner,
|
ShowStartBanner: showStartBanner,
|
||||||
AllowedOrigins: allowedOrigins,
|
AllowedOrigins: allowedOrigins,
|
||||||
CertificateDomains: args,
|
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",
|
"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",
|
|
||||||
"_",
|
|
||||||
"The route path to the superusers dashboard (currently limited to a single path segment)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
+50
-5
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,13 +16,14 @@ import (
|
|||||||
func NewSuperuserCommand(app core.App) *cobra.Command {
|
func NewSuperuserCommand(app core.App) *cobra.Command {
|
||||||
command := &cobra.Command{
|
command := &cobra.Command{
|
||||||
Use: "superuser",
|
Use: "superuser",
|
||||||
Short: "Manages superuser accounts",
|
Short: "Manage superusers",
|
||||||
}
|
}
|
||||||
|
|
||||||
command.AddCommand(superuserUpsertCommand(app))
|
command.AddCommand(superuserUpsertCommand(app))
|
||||||
command.AddCommand(superuserCreateCommand(app))
|
command.AddCommand(superuserCreateCommand(app))
|
||||||
command.AddCommand(superuserUpdateCommand(app))
|
command.AddCommand(superuserUpdateCommand(app))
|
||||||
command.AddCommand(superuserDeleteCommand(app))
|
command.AddCommand(superuserDeleteCommand(app))
|
||||||
|
command.AddCommand(superuserOTPCommand(app))
|
||||||
|
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
@@ -30,7 +32,7 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
|
|||||||
command := &cobra.Command{
|
command := &cobra.Command{
|
||||||
Use: "upsert",
|
Use: "upsert",
|
||||||
Example: "superuser upsert test@example.com 1234567890",
|
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,
|
SilenceUsage: true,
|
||||||
RunE: func(command *cobra.Command, args []string) error {
|
RunE: func(command *cobra.Command, args []string) error {
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
@@ -70,7 +72,7 @@ func superuserCreateCommand(app core.App) *cobra.Command {
|
|||||||
command := &cobra.Command{
|
command := &cobra.Command{
|
||||||
Use: "create",
|
Use: "create",
|
||||||
Example: "superuser create test@example.com 1234567890",
|
Example: "superuser create test@example.com 1234567890",
|
||||||
Short: "Creates a new superuser account",
|
Short: "Creates a new superuser",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(command *cobra.Command, args []string) error {
|
RunE: func(command *cobra.Command, args []string) error {
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
@@ -106,7 +108,7 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
|
|||||||
command := &cobra.Command{
|
command := &cobra.Command{
|
||||||
Use: "update",
|
Use: "update",
|
||||||
Example: "superuser update test@example.com 1234567890",
|
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,
|
SilenceUsage: true,
|
||||||
RunE: func(command *cobra.Command, args []string) error {
|
RunE: func(command *cobra.Command, args []string) error {
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
@@ -140,7 +142,7 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
|
|||||||
command := &cobra.Command{
|
command := &cobra.Command{
|
||||||
Use: "delete",
|
Use: "delete",
|
||||||
Example: "superuser delete test@example.com",
|
Example: "superuser delete test@example.com",
|
||||||
Short: "Deletes an existing superuser account",
|
Short: "Deletes an existing superuser",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(command *cobra.Command, args []string) error {
|
RunE: func(command *cobra.Command, args []string) error {
|
||||||
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
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
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1361,6 +1361,9 @@ type App interface {
|
|||||||
// OnRecordRequestOTPRequest hook is triggered on each Record
|
// OnRecordRequestOTPRequest hook is triggered on each Record
|
||||||
// request OTP API request.
|
// 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,
|
// If the optional "tags" list (Collection ids or names) is specified,
|
||||||
// then all event handlers registered via the created hook will be
|
// then all event handlers registered via the created hook will be
|
||||||
// triggered and called only if their event data origin matches the tags.
|
// triggered and called only if their event data origin matches the tags.
|
||||||
|
|||||||
+14
-6
@@ -340,8 +340,6 @@ func (app *BaseApp) initHooks() {
|
|||||||
// NB! Note that using the returned app instance may cause data integrity errors
|
// NB! Note that using the returned app instance may cause data integrity errors
|
||||||
// since the Record validations and data normalizations (including files uploads)
|
// since the Record validations and data normalizations (including files uploads)
|
||||||
// rely on the app hooks to work.
|
// rely on the app hooks to work.
|
||||||
//
|
|
||||||
// @todo consider caching the created instance?
|
|
||||||
func (app *BaseApp) UnsafeWithoutHooks() App {
|
func (app *BaseApp) UnsafeWithoutHooks() App {
|
||||||
clone := *app
|
clone := *app
|
||||||
|
|
||||||
@@ -1140,8 +1138,8 @@ var sqlLogReplacements = map[string]string{
|
|||||||
"]]": "`",
|
"]]": "`",
|
||||||
"<nil>": "NULL",
|
"<nil>": "NULL",
|
||||||
}
|
}
|
||||||
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[(.+)\.(.+)\]\]`)
|
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`)
|
||||||
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{(.+)\.(.+)\}\}`)
|
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`)
|
||||||
|
|
||||||
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
|
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
|
||||||
// The query is still not suitable for execution and should be used only for log and debug purposes
|
// The query is still not suitable for execution and should be used only for log and debug purposes
|
||||||
@@ -1238,8 +1236,18 @@ func (app *BaseApp) registerBaseHooks() {
|
|||||||
Priority: 999,
|
Priority: 999,
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Cron().Add("__pbPragmaOptimize__", "0 0 * * *", func() {
|
app.Cron().Add("__pbDBOptimize__", "0 0 * * *", func() {
|
||||||
_, execErr := app.DB().NewQuery("PRAGMA optimize").Execute()
|
_, 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 {
|
if execErr != nil {
|
||||||
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
|
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
|||||||
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
|
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
|
||||||
createErr := e.App.RunInTransaction(func(txApp App) error {
|
createErr := e.App.RunInTransaction(func(txApp App) error {
|
||||||
return txApp.AuxRunInTransaction(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...)
|
return archive.Create(txApp.DataDir(), tempPath, e.Exclude...)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ func (app *BaseApp) ImportCollections(toImport []map[string]any, deleteMissing b
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if imported.Fields.GetById(f.GetId()) == nil {
|
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)
|
imported.Fields.Add(f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-14
@@ -28,15 +28,20 @@ const (
|
|||||||
|
|
||||||
const systemHookIdCollection = "__pbCollectionSystemHook__"
|
const systemHookIdCollection = "__pbCollectionSystemHook__"
|
||||||
|
|
||||||
|
const defaultLowercaseRecordIdPattern = "^[a-z0-9]+$"
|
||||||
|
|
||||||
func (app *BaseApp) registerCollectionHooks() {
|
func (app *BaseApp) registerCollectionHooks() {
|
||||||
app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
|
app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
|
||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -48,10 +53,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -63,10 +71,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -78,10 +89,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -93,10 +107,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||||
return me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
err := me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -108,10 +125,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -123,10 +143,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -138,10 +161,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -153,10 +179,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||||
return me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
err := me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -168,10 +197,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -183,10 +215,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -198,10 +233,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||||
return me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
err := me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||||
syncModelEventWithCollectionEvent(me, ce)
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
defer syncCollectionEventWithModelEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithCollectionEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -213,10 +251,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
|||||||
Id: systemHookIdCollection,
|
Id: systemHookIdCollection,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||||
return me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
err := me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -890,7 +931,7 @@ func (c *Collection) initIdField() {
|
|||||||
Required: true,
|
Required: true,
|
||||||
Min: 15,
|
Min: 15,
|
||||||
Max: 15,
|
Max: 15,
|
||||||
Pattern: `^[a-z0-9]+$`,
|
Pattern: defaultLowercaseRecordIdPattern,
|
||||||
AutogeneratePattern: `[a-z0-9]{15}`,
|
AutogeneratePattern: `[a-z0-9]{15}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,6 +943,9 @@ func (c *Collection) initIdField() {
|
|||||||
field.Required = true
|
field.Required = true
|
||||||
field.PrimaryKey = true
|
field.PrimaryKey = true
|
||||||
field.Hidden = false
|
field.Hidden = false
|
||||||
|
if field.Pattern == "" {
|
||||||
|
field.Pattern = defaultLowercaseRecordIdPattern
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ func checkForDuplicatedProviders(value any) error {
|
|||||||
if _, ok := existing[c.Name]; ok {
|
if _, ok := existing[c.Name]; ok {
|
||||||
return validation.Errors{
|
return validation.Errors{
|
||||||
strconv.Itoa(i): 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}),
|
SetParams(map[string]any{"name": c.Name}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -493,7 +493,7 @@ func checkProviderName(value any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := auth.NewProviderByName(name); err != nil {
|
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})
|
SetParams(map[string]any{"name": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,16 +75,16 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
|
// FindCachedCollectionByNameOrId is similar to [BaseApp.FindCollectionByNameOrId]
|
||||||
// but retrieves the Collection from the app cache instead of making a db call.
|
// but retrieves the Collection from the app cache instead of making a db call.
|
||||||
//
|
//
|
||||||
// NB! This method is suitable for read-only Collection operations.
|
// NB! This method is suitable for read-only Collection operations.
|
||||||
//
|
//
|
||||||
// Returns [sql.ErrNoRows] if no Collection is found for consistency
|
// Returns [sql.ErrNoRows] if no Collection is found for consistency
|
||||||
// with the [App.FindCollectionByNameOrId] method.
|
// with the [BaseApp.FindCollectionByNameOrId] method.
|
||||||
//
|
//
|
||||||
// If you plan making changes to the returned Collection model,
|
// If you plan making changes to the returned Collection model,
|
||||||
// use [App.FindCollectionByNameOrId] instead.
|
// use [BaseApp.FindCollectionByNameOrId] instead.
|
||||||
//
|
//
|
||||||
// Caveats:
|
// Caveats:
|
||||||
//
|
//
|
||||||
@@ -94,7 +94,7 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
|||||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||||
// To manually reload the cache you can call [App.ReloadCachedCollections()]
|
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections]
|
||||||
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
|
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
|
||||||
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
||||||
if collections == nil {
|
if collections == nil {
|
||||||
|
|||||||
@@ -349,9 +349,7 @@ func createCollectionIndexes(app App, collection *Collection) error {
|
|||||||
errs[strconv.Itoa(i)] = validation.NewError(
|
errs[strconv.Itoa(i)] = validation.NewError(
|
||||||
"validation_invalid_index_expression",
|
"validation_invalid_index_expression",
|
||||||
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
|
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
|
||||||
).SetParams(map[string]any{
|
)
|
||||||
"indexName": parsed.IndexName,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ func (validator *collectionValidator) checkFieldDuplicates(value any) error {
|
|||||||
strconv.Itoa(i): validation.Errors{
|
strconv.Itoa(i): validation.Errors{
|
||||||
"name": validation.NewError(
|
"name": validation.NewError(
|
||||||
"validation_duplicated_field_name",
|
"validation_duplicated_field_name",
|
||||||
fmt.Sprintf("Duplicated or invalid field name %q", field.GetName()),
|
"Duplicated or invalid field name {{.fieldName}}",
|
||||||
).SetParams(map[string]any{
|
).SetParams(map[string]any{
|
||||||
"fieldName": field.GetName(),
|
"fieldName": field.GetName(),
|
||||||
}),
|
}),
|
||||||
@@ -452,12 +452,12 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
field := cv.new.Fields.GetByName(name)
|
field := cv.new.Fields.GetByName(name)
|
||||||
if field == nil {
|
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})
|
SetParams(map[string]any{"fieldName": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
|
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)).
|
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
|
||||||
SetParams(map[string]any{"fieldName": name})
|
SetParams(map[string]any{"fieldName": name})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,7 +566,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
|||||||
return validation.Errors{
|
return validation.Errors{
|
||||||
strconv.Itoa(i): validation.NewError(
|
strconv.Itoa(i): validation.NewError(
|
||||||
"validation_existing_index_name",
|
"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}),
|
).SetParams(map[string]any{"usedTableName": usedTblName}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,6 +608,12 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
|||||||
continue
|
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()
|
oldParsedStr := oldParsed.Build()
|
||||||
|
|
||||||
for _, column := range oldParsed.Columns {
|
for _, column := range oldParsed.Columns {
|
||||||
@@ -621,19 +627,29 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
|||||||
newParsed := dbutils.ParseIndex(newIndex)
|
newParsed := dbutils.ParseIndex(newIndex)
|
||||||
|
|
||||||
// exclude the non-important identifiers from the check
|
// exclude the non-important identifiers from the check
|
||||||
|
newParsed.SchemaName = oldParsed.SchemaName
|
||||||
newParsed.IndexName = oldParsed.IndexName
|
newParsed.IndexName = oldParsed.IndexName
|
||||||
newParsed.TableName = oldParsed.TableName
|
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() {
|
if oldParsedStr == newParsed.Build() {
|
||||||
hasMatch = true
|
hasMatch = true
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasMatch {
|
if !hasMatch {
|
||||||
return validation.NewError(
|
return validation.NewError(
|
||||||
"validation_unique_system_field_index_change",
|
"validation_invalid_unique_system_field_index",
|
||||||
fmt.Sprintf("Unique index definition on system fields (%q) cannot be changed.", f.GetName()),
|
"Unique index definition on system fields ({{.fieldName}}) is invalid or missing.",
|
||||||
).SetParams(map[string]any{"fieldName": f.GetName()})
|
).SetParams(map[string]any{"fieldName": f.GetName()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +669,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
|||||||
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
|
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
|
||||||
return validation.NewError(
|
return validation.NewError(
|
||||||
"validation_missing_required_unique_index",
|
"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})
|
).SetParams(map[string]any{"fieldName": name})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ func TestCollectionValidate(t *testing.T) {
|
|||||||
expectedErrors: []string{"indexes"},
|
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) {
|
collection: func(app core.App) (*core.Collection, error) {
|
||||||
demo2, err := app.FindCollectionByNameOrId("demo2")
|
demo2, err := app.FindCollectionByNameOrId("demo2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -571,7 +571,91 @@ func TestCollectionValidate(t *testing.T) {
|
|||||||
|
|
||||||
// replace the index with a partial one
|
// replace the index with a partial one
|
||||||
demo2.RemoveIndex("idx_unique_demo2_title")
|
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
|
return demo2, nil
|
||||||
},
|
},
|
||||||
@@ -620,7 +704,7 @@ func TestCollectionValidate(t *testing.T) {
|
|||||||
collection: func(app core.App) (*core.Collection, error) {
|
collection: func(app core.App) (*core.Collection, error) {
|
||||||
c := core.NewBaseCollection("new_auth")
|
c := core.NewBaseCollection("new_auth")
|
||||||
c.Fields = core.NewFieldsList(
|
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
|
return c, nil
|
||||||
},
|
},
|
||||||
@@ -631,7 +715,7 @@ func TestCollectionValidate(t *testing.T) {
|
|||||||
collection: func(app core.App) (*core.Collection, error) {
|
collection: func(app core.App) (*core.Collection, error) {
|
||||||
c := core.NewBaseCollection("new_auth")
|
c := core.NewBaseCollection("new_auth")
|
||||||
c.Fields = core.NewFieldsList(
|
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.TextField{Id: "f1", Name: "Test"}, // case-insensitive
|
||||||
&core.BoolField{Id: "f2", Name: "test"},
|
&core.BoolField{Id: "f2", Name: "test"},
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -464,7 +464,7 @@ func validateCollectionId(app App, optTypes ...string) validation.RuleFunc {
|
|||||||
return validation.NewError(
|
return validation.NewError(
|
||||||
"validation_invalid_collection_type",
|
"validation_invalid_collection_type",
|
||||||
fmt.Sprintf("Invalid collection type - must be %s.", strings.Join(optTypes, ", ")),
|
fmt.Sprintf("Invalid collection type - must be %s.", strings.Join(optTypes, ", ")),
|
||||||
).SetParams(map[string]any{"types": optTypes})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+10
-8
@@ -46,14 +46,16 @@ func baseLockRetry(op func(attempt int) error, maxRetries int) error {
|
|||||||
Retry:
|
Retry:
|
||||||
err := op(attempt)
|
err := op(attempt)
|
||||||
|
|
||||||
if err != nil &&
|
if err != nil && attempt <= maxRetries {
|
||||||
attempt <= maxRetries &&
|
errStr := err.Error()
|
||||||
// we are checking the plain error text to handle both cgo and noncgo errors
|
// we are checking the error against the plain error texts since the codes could vary between drivers
|
||||||
strings.Contains(err.Error(), "database is locked") {
|
if strings.Contains(errStr, "database is locked") ||
|
||||||
// wait and retry
|
strings.Contains(errStr, "table is locked") {
|
||||||
time.Sleep(getDefaultRetryInterval(attempt))
|
// wait and retry
|
||||||
attempt++
|
time.Sleep(getDefaultRetryInterval(attempt))
|
||||||
goto Retry
|
attempt++
|
||||||
|
goto Retry
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func TestBaseLockRetry(t *testing.T) {
|
|||||||
{nil, 3, 1},
|
{nil, 3, 1},
|
||||||
{errors.New("test"), 3, 1},
|
{errors.New("test"), 3, 1},
|
||||||
{errors.New("database is locked"), 3, 3},
|
{errors.New("database is locked"), 3, 3},
|
||||||
|
{errors.New("table is locked"), 3, 3},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, s := range scenarios {
|
for i, s := range scenarios {
|
||||||
|
|||||||
@@ -168,16 +168,28 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
app.OnModelAfterCreateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
afterCreateHookCalls++
|
afterCreateHookCalls++
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
app.OnModelAfterUpdateSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
afterUpdateHookCalls++
|
afterUpdateHookCalls++
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
|
app.OnModelAfterDeleteSuccess().BindFunc(func(e *core.ModelEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
afterDeleteHookCalls++
|
afterDeleteHookCalls++
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
@@ -233,3 +245,169 @@ func TestTransactionHooksCallsOnSuccess(t *testing.T) {
|
|||||||
t.Errorf("Expected afterDeleteHookCalls to be called 1 time, got %d", afterDeleteHookCalls)
|
t.Errorf("Expected afterDeleteHookCalls to be called 1 time, got %d", afterDeleteHookCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTransactionFromInnerCreateHook(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
app.OnRecordCreateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
originalApp := e.App
|
||||||
|
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
e.App = txApp
|
||||||
|
defer func() {
|
||||||
|
e.App = originalApp
|
||||||
|
}()
|
||||||
|
|
||||||
|
nextErr := e.Next()
|
||||||
|
|
||||||
|
return nextErr
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.OnRecordAfterCreateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform a db query with the app instance to ensure that it is still valid
|
||||||
|
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
collection, err := app.FindCollectionByNameOrId("demo2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record := core.NewRecord(collection)
|
||||||
|
|
||||||
|
record.Set("title", "test_inner_tx")
|
||||||
|
|
||||||
|
if err = app.Save(record); err != nil {
|
||||||
|
t.Fatalf("Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHookCalls := map[string]int{
|
||||||
|
"OnRecordCreateExecute": 1,
|
||||||
|
"OnRecordAfterCreateSuccess": 1,
|
||||||
|
}
|
||||||
|
for k, total := range expectedHookCalls {
|
||||||
|
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||||
|
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransactionFromInnerUpdateHook(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
app.OnRecordUpdateExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
originalApp := e.App
|
||||||
|
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
e.App = txApp
|
||||||
|
defer func() {
|
||||||
|
e.App = originalApp
|
||||||
|
}()
|
||||||
|
|
||||||
|
nextErr := e.Next()
|
||||||
|
|
||||||
|
return nextErr
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.OnRecordAfterUpdateSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform a db query with the app instance to ensure that it is still valid
|
||||||
|
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = app.Save(existingModel); err != nil {
|
||||||
|
t.Fatalf("Update failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHookCalls := map[string]int{
|
||||||
|
"OnRecordUpdateExecute": 1,
|
||||||
|
"OnRecordAfterUpdateSuccess": 1,
|
||||||
|
}
|
||||||
|
for k, total := range expectedHookCalls {
|
||||||
|
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||||
|
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransactionFromInnerDeleteHook(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
app.OnRecordDeleteExecute("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
originalApp := e.App
|
||||||
|
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
e.App = txApp
|
||||||
|
defer func() {
|
||||||
|
e.App = originalApp
|
||||||
|
}()
|
||||||
|
|
||||||
|
nextErr := e.Next()
|
||||||
|
|
||||||
|
return nextErr
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.OnRecordAfterDeleteSuccess("demo2").BindFunc(func(e *core.RecordEvent) error {
|
||||||
|
if e.App.IsTransactional() {
|
||||||
|
t.Fatal("Expected e.App to be non-transactional")
|
||||||
|
}
|
||||||
|
|
||||||
|
// perform a db query with the app instance to ensure that it is still valid
|
||||||
|
_, err := e.App.FindFirstRecordByFilter("demo2", "1=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to perform a db query after tx success: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
existingModel, err := app.FindFirstRecordByFilter("demo2", "1=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = app.Delete(existingModel); err != nil {
|
||||||
|
t.Fatalf("Delete failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedHookCalls := map[string]int{
|
||||||
|
"OnRecordDeleteExecute": 1,
|
||||||
|
"OnRecordAfterDeleteSuccess": 1,
|
||||||
|
}
|
||||||
|
for k, total := range expectedHookCalls {
|
||||||
|
if found, ok := app.EventCalls[k]; !ok || total != found {
|
||||||
|
t.Fatalf("Expected %q %d calls, got %d", k, total, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ func TestEventRequestRealIP(t *testing.T) {
|
|||||||
"CF-Connecting-IP": {"1.2.3.4", "1.1.1.1"},
|
"CF-Connecting-IP": {"1.2.3.4", "1.1.1.1"},
|
||||||
"Fly-Client-IP": {"1.2.3.4", "1.1.1.2"},
|
"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-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 {
|
scenarios := []struct {
|
||||||
@@ -44,28 +44,28 @@ func TestEventRequestRealIP(t *testing.T) {
|
|||||||
{
|
{
|
||||||
"trusted X-Real-IP (rightmost)",
|
"trusted X-Real-IP (rightmost)",
|
||||||
headers,
|
headers,
|
||||||
[]string{"header1", "x-real-ip", "x-forward-for"},
|
[]string{"header1", "x-real-ip", "x-forwarded-for"},
|
||||||
false,
|
false,
|
||||||
"1.1.1.4",
|
"1.1.1.4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"trusted X-Real-IP (leftmost)",
|
"trusted X-Real-IP (leftmost)",
|
||||||
headers,
|
headers,
|
||||||
[]string{"header1", "x-real-ip", "x-forward-for"},
|
[]string{"header1", "x-real-ip", "x-forwarded-for"},
|
||||||
true,
|
true,
|
||||||
"1.1.1.3",
|
"1.1.1.3",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"trusted X-Forward-For (rightmost)",
|
"trusted X-Forwarded-For (rightmost)",
|
||||||
headers,
|
headers,
|
||||||
[]string{"header1", "x-forward-for"},
|
[]string{"header1", "x-forwarded-for"},
|
||||||
false,
|
false,
|
||||||
"1.1.1.6",
|
"1.1.1.6",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"trusted X-Forward-For (leftmost)",
|
"trusted X-Forwarded-For (leftmost)",
|
||||||
headers,
|
headers,
|
||||||
[]string{"header1", "x-forward-for"},
|
[]string{"header1", "x-forwarded-for"},
|
||||||
true,
|
true,
|
||||||
"1.1.1.5",
|
"1.1.1.5",
|
||||||
},
|
},
|
||||||
|
|||||||
+28
-3
@@ -173,8 +173,8 @@ type ModelEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelErrorEvent struct {
|
type ModelErrorEvent struct {
|
||||||
ModelEvent
|
|
||||||
Error error
|
Error error
|
||||||
|
ModelEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -196,8 +196,8 @@ type RecordEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecordErrorEvent struct {
|
type RecordErrorEvent struct {
|
||||||
RecordEvent
|
|
||||||
Error error
|
Error error
|
||||||
|
RecordEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
|
func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
|
||||||
@@ -216,6 +216,12 @@ func syncModelEventWithRecordEvent(me *ModelEvent, re *RecordEvent) {
|
|||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncRecordEventWithModelEvent(re *RecordEvent, me *ModelEvent) {
|
||||||
|
re.App = me.App
|
||||||
|
re.Context = me.Context
|
||||||
|
re.Type = me.Type
|
||||||
|
}
|
||||||
|
|
||||||
func newRecordEventFromModelEvent(me *ModelEvent) (*RecordEvent, bool) {
|
func newRecordEventFromModelEvent(me *ModelEvent) (*RecordEvent, bool) {
|
||||||
record, ok := me.Model.(*Record)
|
record, ok := me.Model.(*Record)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -253,6 +259,11 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
|
|||||||
me.Error = re.Error
|
me.Error = re.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
|
||||||
|
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
|
||||||
|
me.Error = re.Error
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Collection events data
|
// Collection events data
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -272,8 +283,8 @@ type CollectionEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CollectionErrorEvent struct {
|
type CollectionErrorEvent struct {
|
||||||
CollectionEvent
|
|
||||||
Error error
|
Error error
|
||||||
|
CollectionEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
||||||
@@ -283,6 +294,15 @@ func syncModelEventWithCollectionEvent(me *ModelEvent, ce *CollectionEvent) {
|
|||||||
me.Model = ce.Collection
|
me.Model = ce.Collection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncCollectionEventWithModelEvent(ce *CollectionEvent, me *ModelEvent) {
|
||||||
|
ce.App = me.App
|
||||||
|
ce.Context = me.Context
|
||||||
|
ce.Type = me.Type
|
||||||
|
if c, ok := me.Model.(*Collection); ok {
|
||||||
|
ce.Collection = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newCollectionEventFromModelEvent(me *ModelEvent) (*CollectionEvent, bool) {
|
func newCollectionEventFromModelEvent(me *ModelEvent) (*CollectionEvent, bool) {
|
||||||
record, ok := me.Model.(*Collection)
|
record, ok := me.Model.(*Collection)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -316,6 +336,11 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
|
|||||||
me.Error = ce.Error
|
me.Error = ce.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
|
||||||
|
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
|
||||||
|
me.Error = ce.Error
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// File API events data
|
// File API events data
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -191,7 +191,7 @@ func DefaultFieldIdValidationRule(value any) error {
|
|||||||
|
|
||||||
rules := []validation.Rule{
|
rules := []validation.Rule{
|
||||||
validation.Required,
|
validation.Required,
|
||||||
validation.Length(1, 255),
|
validation.Length(1, 100),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
@@ -217,7 +217,7 @@ func DefaultFieldNameValidationRule(value any) error {
|
|||||||
|
|
||||||
rules := []validation.Rule{
|
rules := []validation.Rule{
|
||||||
validation.Required,
|
validation.Required,
|
||||||
validation.Length(1, 255),
|
validation.Length(1, 100),
|
||||||
validation.Match(fieldNameRegex),
|
validation.Match(fieldNameRegex),
|
||||||
validation.NotIn(excludeNames...),
|
validation.NotIn(excludeNames...),
|
||||||
validation.By(checkForVia),
|
validation.By(checkForVia),
|
||||||
|
|||||||
+41
-16
@@ -16,6 +16,9 @@ func init() {
|
|||||||
|
|
||||||
const FieldTypeAutodate = "autodate"
|
const FieldTypeAutodate = "autodate"
|
||||||
|
|
||||||
|
// used to keep track of the last set autodate value
|
||||||
|
const autodateLastKnownPrefix = internalCustomFieldKeyPrefix + "_last_autodate_"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
_ Field = (*AutodateField)(nil)
|
_ Field = (*AutodateField)(nil)
|
||||||
_ SetterFinder = (*AutodateField)(nil)
|
_ SetterFinder = (*AutodateField)(nil)
|
||||||
@@ -167,24 +170,46 @@ func (f *AutodateField) Intercept(
|
|||||||
actionFunc func() error,
|
actionFunc func() error,
|
||||||
) error {
|
) error {
|
||||||
switch actionName {
|
switch actionName {
|
||||||
case InterceptorActionCreate:
|
case InterceptorActionCreateExecute:
|
||||||
// ignore for custom date manually set with record.SetRaw()
|
// ignore if a date different from the old one was manually set with SetRaw
|
||||||
if f.OnCreate && !f.hasBeenManuallyChanged(record) {
|
if f.OnCreate && record.GetDateTime(f.Name).Equal(f.getLastKnownValue(record)) {
|
||||||
record.SetRaw(f.Name, types.NowDateTime())
|
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 err := actionFunc(); err != nil {
|
||||||
if f.OnUpdate && !f.hasBeenManuallyChanged(record) {
|
return err
|
||||||
record.SetRaw(f.Name, types.NowDateTime())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
return record.Original().GetDateTime(f.Name)
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
+102
-10
@@ -2,6 +2,7 @@ package core_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"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) {
|
func TestAutodateFieldIntercept(t *testing.T) {
|
||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
defer app.Cleanup()
|
||||||
@@ -255,7 +264,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"create with zero value (disabled onCreate)",
|
"create with zero value (disabled onCreate)",
|
||||||
core.InterceptorActionCreate,
|
core.InterceptorActionCreateExecute,
|
||||||
&core.AutodateField{Name: "test", OnCreate: false, OnUpdate: true},
|
&core.AutodateField{Name: "test", OnCreate: false, OnUpdate: true},
|
||||||
func() *core.Record {
|
func() *core.Record {
|
||||||
return core.NewRecord(collection)
|
return core.NewRecord(collection)
|
||||||
@@ -264,7 +273,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"create with zero value",
|
"create with zero value",
|
||||||
core.InterceptorActionCreate,
|
core.InterceptorActionCreateExecute,
|
||||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||||
func() *core.Record {
|
func() *core.Record {
|
||||||
return core.NewRecord(collection)
|
return core.NewRecord(collection)
|
||||||
@@ -273,7 +282,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"create with non-zero value",
|
"create with non-zero value",
|
||||||
core.InterceptorActionCreate,
|
core.InterceptorActionCreateExecute,
|
||||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||||
func() *core.Record {
|
func() *core.Record {
|
||||||
record := core.NewRecord(collection)
|
record := core.NewRecord(collection)
|
||||||
@@ -284,7 +293,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"update with zero value (disabled onUpdate)",
|
"update with zero value (disabled onUpdate)",
|
||||||
core.InterceptorActionUpdate,
|
core.InterceptorActionUpdateExecute,
|
||||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: false},
|
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: false},
|
||||||
func() *core.Record {
|
func() *core.Record {
|
||||||
return core.NewRecord(collection)
|
return core.NewRecord(collection)
|
||||||
@@ -293,7 +302,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"update with zero value",
|
"update with zero value",
|
||||||
core.InterceptorActionUpdate,
|
core.InterceptorActionUpdateExecute,
|
||||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||||
func() *core.Record {
|
func() *core.Record {
|
||||||
return core.NewRecord(collection)
|
return core.NewRecord(collection)
|
||||||
@@ -302,7 +311,7 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"update with non-zero value",
|
"update with non-zero value",
|
||||||
core.InterceptorActionUpdate,
|
core.InterceptorActionUpdateExecute,
|
||||||
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
&core.AutodateField{Name: "test", OnCreate: true, OnUpdate: true},
|
||||||
func() *core.Record {
|
func() *core.Record {
|
||||||
record := core.NewRecord(collection)
|
record := core.NewRecord(collection)
|
||||||
@@ -341,9 +350,92 @@ func TestAutodateFieldIntercept(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cutMilliseconds(datetime string) string {
|
func TestAutodateRecordResave(t *testing.T) {
|
||||||
if len(datetime) > 19 {
|
app, _ := tests.NewTestApp()
|
||||||
return datetime[:19]
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
"github.com/pocketbase/pocketbase/core/validators"
|
"github.com/pocketbase/pocketbase/core/validators"
|
||||||
@@ -137,7 +136,7 @@ func (f *EditorField) ValidateValue(ctx context.Context, app App, record *Record
|
|||||||
if int64(len(val)) > maxSize {
|
if int64(len(val)) > maxSize {
|
||||||
return validation.NewError(
|
return validation.NewError(
|
||||||
"validation_content_size_limit",
|
"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})
|
).SetParams(map[string]any{"maxSize": maxSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-7
@@ -250,13 +250,22 @@ func (f *FileField) ValidateValue(ctx context.Context, app App, record *Record)
|
|||||||
addedStrings := f.excludeFiles(existingStrings, oldExistingStrings)
|
addedStrings := f.excludeFiles(existingStrings, oldExistingStrings)
|
||||||
|
|
||||||
if len(addedStrings) > 0 {
|
if len(addedStrings) > 0 {
|
||||||
return validation.NewError("validation_invalid_file", "Invalid files:"+strings.Join(cast.ToStringSlice(addedStrings), ", ")).
|
invalidFiles := make([]string, len(addedStrings))
|
||||||
SetParams(map[string]any{"invalidFiles": 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()
|
maxSelect := f.maxSelect()
|
||||||
if len(files) > 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})
|
SetParams(map[string]any{"maxSelect": maxSelect})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +400,7 @@ func (f *FileField) Intercept(
|
|||||||
}
|
}
|
||||||
func (f *FileField) getLatestOldValue(app App, record *Record) any {
|
func (f *FileField) getLatestOldValue(app App, record *Record) any {
|
||||||
if !record.IsNew() {
|
if !record.IsNew() {
|
||||||
latestOriginal, err := app.FindRecordById(record.Collection(), record.Id)
|
latestOriginal, err := app.FindRecordById(record.Collection(), cast.ToString(record.LastSavedPK()))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return latestOriginal.GetRaw(f.Name)
|
return latestOriginal.GetRaw(f.Name)
|
||||||
}
|
}
|
||||||
@@ -739,14 +748,14 @@ func (f *FileField) toSliceValue(raw any) []any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *FileField) uniqueFiles(files []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))
|
result := make([]any, 0, len(files))
|
||||||
|
|
||||||
for _, fv := range files {
|
for _, fv := range files {
|
||||||
name := f.getFileName(fv)
|
name := f.getFileName(fv)
|
||||||
if _, ok := existing[name]; !ok {
|
if _, ok := found[name]; !ok {
|
||||||
result = append(result, fv)
|
result = append(result, fv)
|
||||||
existing[name] = struct{}{}
|
found[name] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -2,7 +2,6 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -160,7 +159,7 @@ func (f *JSONField) ValidateValue(ctx context.Context, app App, record *Record)
|
|||||||
if int64(len(raw)) > maxSize {
|
if int64(len(raw)) > maxSize {
|
||||||
return validation.NewError(
|
return validation.NewError(
|
||||||
"validation_json_size_limit",
|
"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})
|
).SetParams(map[string]any{"maxSize": maxSize})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package core
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
"github.com/pocketbase/dbx"
|
"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 {
|
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})
|
SetParams(map[string]any{"minSelect": f.MinSelect})
|
||||||
}
|
}
|
||||||
|
|
||||||
maxSelect := max(f.MaxSelect, 1)
|
maxSelect := max(f.MaxSelect, 1)
|
||||||
if len(ids) > maxSelect {
|
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})
|
SetParams(map[string]any{"maxSelect": maxSelect})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package core
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"fmt"
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
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
|
// check max selected items
|
||||||
if len(normalizedVal) > maxSelect {
|
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})
|
SetParams(map[string]any{"maxSelect": maxSelect})
|
||||||
}
|
}
|
||||||
|
|
||||||
// check against the allowed values
|
// check against the allowed values
|
||||||
for _, val := range normalizedVal {
|
for _, val := range normalizedVal {
|
||||||
if !slices.Contains(f.Values, val) {
|
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})
|
SetParams(map[string]any{"value": val})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -91,7 +91,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
|
|||||||
"invalid length",
|
"invalid length",
|
||||||
func() core.Field {
|
func() core.Field {
|
||||||
f := core.Fields[fieldType]()
|
f := core.Fields[fieldType]()
|
||||||
f.SetId(strings.Repeat("a", 256))
|
f.SetId(strings.Repeat("a", 101))
|
||||||
return f
|
return f
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
@@ -100,7 +100,7 @@ func testDefaultFieldIdValidation(t *testing.T, fieldType string) {
|
|||||||
"valid length",
|
"valid length",
|
||||||
func() core.Field {
|
func() core.Field {
|
||||||
f := core.Fields[fieldType]()
|
f := core.Fields[fieldType]()
|
||||||
f.SetId(strings.Repeat("a", 255))
|
f.SetId(strings.Repeat("a", 100))
|
||||||
return f
|
return f
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
@@ -142,7 +142,7 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
|
|||||||
"invalid length",
|
"invalid length",
|
||||||
func() core.Field {
|
func() core.Field {
|
||||||
f := core.Fields[fieldType]()
|
f := core.Fields[fieldType]()
|
||||||
f.SetName(strings.Repeat("a", 256))
|
f.SetName(strings.Repeat("a", 101))
|
||||||
return f
|
return f
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
@@ -151,7 +151,7 @@ func testDefaultFieldNameValidation(t *testing.T, fieldType string) {
|
|||||||
"valid length",
|
"valid length",
|
||||||
func() core.Field {
|
func() core.Field {
|
||||||
f := core.Fields[fieldType]()
|
f := core.Fields[fieldType]()
|
||||||
f.SetName(strings.Repeat("a", 255))
|
f.SetName(strings.Repeat("a", 100))
|
||||||
return f
|
return f
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
|||||||
+50
-12
@@ -2,10 +2,14 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core/validators"
|
"github.com/pocketbase/pocketbase/core/validators"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
@@ -151,6 +155,8 @@ func (f *TextField) PrepareValue(record *Record, raw any) (any, error) {
|
|||||||
return cast.ToString(raw), nil
|
return cast.ToString(raw), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var forbiddenPKChars = []string{"/", "\\"}
|
||||||
|
|
||||||
// ValidateValue implements [Field.ValidateValue] interface method.
|
// ValidateValue implements [Field.ValidateValue] interface method.
|
||||||
func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record) error {
|
func (f *TextField) ValidateValue(ctx context.Context, app App, record *Record) error {
|
||||||
newVal, ok := record.GetRaw(f.Name).(string)
|
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
|
return validators.ErrUnsupportedValueType
|
||||||
}
|
}
|
||||||
|
|
||||||
// disallow PK change
|
if f.PrimaryKey {
|
||||||
if f.PrimaryKey && !record.IsNew() {
|
// disallow PK change
|
||||||
oldVal := record.LastSavedPK()
|
if !record.IsNew() {
|
||||||
if oldVal != newVal {
|
oldVal := record.LastSavedPK()
|
||||||
return validation.NewError("validation_pk_change", "The record primary key cannot be changed.")
|
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 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 bool
|
||||||
|
err := app.DB().
|
||||||
|
Select("(1)").
|
||||||
|
From(record.TableName()).
|
||||||
|
Where(dbx.NewExp("id = {:id} COLLATE NOCASE", dbx.Params{"id": strings.ToLower(newVal)})).
|
||||||
|
Limit(1).
|
||||||
|
Row(&exists)
|
||||||
|
if exists || (err != nil && !errors.Is(err, sql.ErrNoRows)) {
|
||||||
|
return validation.NewError("validation_pk_invalid", "The record primary key is invalid or already exists.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +226,7 @@ func (f *TextField) ValidatePlainValue(value string) error {
|
|||||||
length := len([]rune(value))
|
length := len([]rune(value))
|
||||||
|
|
||||||
if f.Min > 0 && length < f.Min {
|
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})
|
SetParams(map[string]any{"min": f.Min})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,8 +236,8 @@ func (f *TextField) ValidatePlainValue(value string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if max > 0 && length > max {
|
if max > 0 && length > max {
|
||||||
return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", max)).
|
return validation.NewError("validation_max_text_constraint", "Must be no more than {{.max}} character(s)").
|
||||||
SetParams(map[string]any{"max": f.Max})
|
SetParams(map[string]any{"max": max})
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.Pattern != "" {
|
if f.Pattern != "" {
|
||||||
@@ -223,7 +261,7 @@ func (f *TextField) ValidateSettings(ctx context.Context, app App, collection *C
|
|||||||
validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
|
validation.Field(&f.PrimaryKey, validation.By(f.checkOtherFieldsForPK(collection))),
|
||||||
validation.Field(&f.Min, validation.Min(0)),
|
validation.Field(&f.Min, validation.Min(0)),
|
||||||
validation.Field(&f.Max, validation.Min(f.Min)),
|
validation.Field(&f.Max, validation.Min(f.Min)),
|
||||||
validation.Field(&f.Pattern, validation.By(validators.IsRegex)),
|
validation.Field(&f.Pattern, validation.When(f.PrimaryKey, validation.Required), validation.By(validators.IsRegex)),
|
||||||
validation.Field(&f.Hidden, validation.When(f.PrimaryKey, validation.Empty)),
|
validation.Field(&f.Hidden, validation.When(f.PrimaryKey, validation.Empty)),
|
||||||
validation.Field(&f.Required, validation.When(f.PrimaryKey, validation.Required)),
|
validation.Field(&f.Required, validation.When(f.PrimaryKey, validation.Required)),
|
||||||
validation.Field(&f.AutogeneratePattern, validation.By(validators.IsRegex), validation.By(f.checkAutogeneratePattern)),
|
validation.Field(&f.AutogeneratePattern, validation.By(validators.IsRegex), validation.By(f.checkAutogeneratePattern)),
|
||||||
|
|||||||
+76
-2
@@ -68,7 +68,15 @@ func TestTextFieldValidateValue(t *testing.T) {
|
|||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
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 {
|
scenarios := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -116,6 +124,46 @@ func TestTextFieldValidateValue(t *testing.T) {
|
|||||||
},
|
},
|
||||||
false,
|
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)",
|
"zero field value (primaryKey)",
|
||||||
&core.TextField{Name: "test", PrimaryKey: true},
|
&core.TextField{Name: "test", PrimaryKey: true},
|
||||||
@@ -131,11 +179,21 @@ func TestTextFieldValidateValue(t *testing.T) {
|
|||||||
&core.TextField{Name: "test", PrimaryKey: true},
|
&core.TextField{Name: "test", PrimaryKey: true},
|
||||||
func() *core.Record {
|
func() *core.Record {
|
||||||
record := core.NewRecord(collection)
|
record := core.NewRecord(collection)
|
||||||
record.SetRaw("test", "abc")
|
record.SetRaw("test", "abcd")
|
||||||
return record
|
return record
|
||||||
},
|
},
|
||||||
false,
|
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",
|
"< min",
|
||||||
&core.TextField{Name: "test", Min: 4},
|
&core.TextField{Name: "test", Min: 4},
|
||||||
@@ -259,10 +317,23 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
|||||||
Id: "test",
|
Id: "test",
|
||||||
Name: "id",
|
Name: "id",
|
||||||
PrimaryKey: true,
|
PrimaryKey: true,
|
||||||
|
Pattern: `\d+`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]string{"required"},
|
[]string{"required"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"primaryKey without pattern",
|
||||||
|
func() *core.TextField {
|
||||||
|
return &core.TextField{
|
||||||
|
Id: "test",
|
||||||
|
Name: "id",
|
||||||
|
PrimaryKey: true,
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]string{"pattern"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"primaryKey with hidden",
|
"primaryKey with hidden",
|
||||||
func() *core.TextField {
|
func() *core.TextField {
|
||||||
@@ -272,6 +343,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
|||||||
Required: true,
|
Required: true,
|
||||||
PrimaryKey: true,
|
PrimaryKey: true,
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
|
Pattern: `\d+`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]string{"hidden"},
|
[]string{"hidden"},
|
||||||
@@ -284,6 +356,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
|||||||
Name: "test",
|
Name: "test",
|
||||||
PrimaryKey: true,
|
PrimaryKey: true,
|
||||||
Required: true,
|
Required: true,
|
||||||
|
Pattern: `\d+`,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]string{"name"},
|
[]string{"name"},
|
||||||
@@ -295,6 +368,7 @@ func TestTextFieldValidateSettings(t *testing.T) {
|
|||||||
Id: "test2",
|
Id: "test2",
|
||||||
Name: "id",
|
Name: "id",
|
||||||
PrimaryKey: true,
|
PrimaryKey: true,
|
||||||
|
Pattern: `\d+`,
|
||||||
Required: true,
|
Required: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+92
-19
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ func NewFieldsList(fields ...Field) FieldsList {
|
|||||||
l := make(FieldsList, 0, len(fields))
|
l := make(FieldsList, 0, len(fields))
|
||||||
|
|
||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
l.Add(f)
|
l.add(-1, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
return l
|
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).
|
// (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) {
|
func (l *FieldsList) Add(fields ...Field) {
|
||||||
for _, f := range fields {
|
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: "test"}`})
|
||||||
// l.AddMarshaledJSON([]byte{`[{"type":"text", name: "test1"}, {"type":"text", name: "test2"}]`})
|
// l.AddMarshaledJSON([]byte{`[{"type":"text", name: "test1"}, {"type":"text", name: "test2"}]`})
|
||||||
func (l *FieldsList) AddMarshaledJSON(rawJSON []byte) error {
|
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 {
|
if len(rawJSON) == 0 {
|
||||||
return nil // nothing to add
|
return extractedFields, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to unmarshal first into a new fieds list
|
// try to unmarshal first into a new fieds list
|
||||||
// (assuming that rawJSON is array of objects)
|
// (assuming that rawJSON is array of objects)
|
||||||
extractedFields := FieldsList{}
|
|
||||||
err := json.Unmarshal(rawJSON, &extractedFields)
|
err := json.Unmarshal(rawJSON, &extractedFields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// try again but wrap the rawJSON in []
|
// try again but wrap the rawJSON in []
|
||||||
@@ -149,21 +198,25 @@ func (l *FieldsList) AddMarshaledJSON(rawJSON []byte) error {
|
|||||||
wrapped = append(wrapped, ']')
|
wrapped = append(wrapped, ']')
|
||||||
err = json.Unmarshal(wrapped, &extractedFields)
|
err = json.Unmarshal(wrapped, &extractedFields)
|
||||||
if err != nil {
|
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 {
|
return extractedFields, nil
|
||||||
l.add(f)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *FieldsList) add(newField Field) {
|
func (l *FieldsList) add(pos int, newField Field) {
|
||||||
fields := *l
|
fields := *l
|
||||||
|
|
||||||
var replaceByName bool
|
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()
|
newFieldId := newField.GetId()
|
||||||
|
|
||||||
@@ -182,24 +235,44 @@ func (l *FieldsList) add(newField Field) {
|
|||||||
newField.SetId(newFieldId)
|
newField.SetId(newFieldId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace existing
|
// try to replace existing
|
||||||
for i, field := range fields {
|
for i, field := range fields {
|
||||||
if replaceByName {
|
if replaceByName {
|
||||||
if name := newField.GetName(); name != "" && field.GetName() == name {
|
if name := newField.GetName(); name != "" && field.GetName() == name {
|
||||||
|
// reuse the original id
|
||||||
newField.SetId(field.GetId())
|
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 {
|
} else {
|
||||||
if field.GetId() == newFieldId {
|
if field.GetId() == newFieldId {
|
||||||
(*l)[i] = newField
|
if replaceInPlace {
|
||||||
return
|
(*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
|
// insert the new field
|
||||||
*l = append(fields, newField)
|
*l = slices.Insert(*l, pos, newField)
|
||||||
}
|
}
|
||||||
|
|
||||||
// String returns the string representation of the current list.
|
// String returns the string representation of the current list.
|
||||||
@@ -252,7 +325,7 @@ func (l *FieldsList) UnmarshalJSON(data []byte) error {
|
|||||||
*l = []Field{} // reset
|
*l = []Field{} // reset
|
||||||
|
|
||||||
for _, fwt := range fwts {
|
for _, fwt := range fwts {
|
||||||
l.Add(fwt.Field)
|
l.add(-1, fwt.Field)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package core_test
|
package core_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestFieldsListStringAndValue(t *testing.T) {
|
||||||
t.Run("empty list", func(t *testing.T) {
|
t.Run("empty list", func(t *testing.T) {
|
||||||
testFieldsList := core.NewFieldsList()
|
testFieldsList := core.NewFieldsList()
|
||||||
|
|||||||
@@ -85,6 +85,20 @@ func (m *OTP) SetRecordRef(recordId string) {
|
|||||||
m.Set("recordRef", recordId)
|
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.
|
// Created returns the "created" record field value.
|
||||||
func (m *OTP) Created() types.DateTime {
|
func (m *OTP) Created() types.DateTime {
|
||||||
return m.GetDateTime("created")
|
return m.GetDateTime("created")
|
||||||
|
|||||||
@@ -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) {
|
func TestOTPCreated(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+81
-23
@@ -55,10 +55,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordValidate().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -70,10 +73,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordCreate().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -85,10 +91,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordCreateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -100,10 +109,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordAfterCreateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -115,10 +127,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||||
return me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
|
err := me.App.OnRecordAfterCreateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -130,10 +145,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordUpdate().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -145,10 +163,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordUpdateExecute().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -160,10 +181,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordAfterUpdateSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -175,10 +199,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||||
return me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
|
err := me.App.OnRecordAfterUpdateError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -190,10 +217,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordDelete().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -205,10 +235,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordDeleteExecute().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -220,10 +253,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelEvent) error {
|
Func: func(me *ModelEvent) error {
|
||||||
if re, ok := newRecordEventFromModelEvent(me); ok {
|
if re, ok := newRecordEventFromModelEvent(me); ok {
|
||||||
return me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
|
err := me.App.OnRecordAfterDeleteSuccess().Trigger(re, func(re *RecordEvent) error {
|
||||||
syncModelEventWithRecordEvent(me, re)
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
defer syncRecordEventWithModelEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelEventWithRecordEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -235,10 +271,13 @@ func (app *BaseApp) registerRecordHooks() {
|
|||||||
Id: systemHookIdRecord,
|
Id: systemHookIdRecord,
|
||||||
Func: func(me *ModelErrorEvent) error {
|
Func: func(me *ModelErrorEvent) error {
|
||||||
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
if re, ok := newRecordErrorEventFromModelErrorEvent(me); ok {
|
||||||
return me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
|
err := me.App.OnRecordAfterDeleteError().Trigger(re, func(re *RecordErrorEvent) error {
|
||||||
syncModelErrorEventWithRecordErrorEvent(me, re)
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
defer syncRecordErrorEventWithModelErrorEvent(re, me)
|
||||||
return me.Next()
|
return me.Next()
|
||||||
})
|
})
|
||||||
|
syncModelErrorEventWithRecordErrorEvent(me, re)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return me.Next()
|
return me.Next()
|
||||||
@@ -504,12 +543,16 @@ func NewRecord(collection *Collection) *Record {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initialize default field values
|
// initialize default field values
|
||||||
|
var fieldName string
|
||||||
for _, field := range collection.Fields {
|
for _, field := range collection.Fields {
|
||||||
if field.GetName() == FieldNameId {
|
fieldName = field.GetName()
|
||||||
|
|
||||||
|
if fieldName == FieldNameId {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
value, _ := field.PrepareValue(record, nil)
|
value, _ := field.PrepareValue(record, nil)
|
||||||
record.originalData[field.GetName()] = value
|
record.originalData[fieldName] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
return record
|
return record
|
||||||
@@ -589,8 +632,12 @@ func (m *Record) Original() *Record {
|
|||||||
func (m *Record) Fresh() *Record {
|
func (m *Record) Fresh() *Record {
|
||||||
newRecord := m.Original()
|
newRecord := m.Original()
|
||||||
|
|
||||||
// note: this will also load the Id field though m.Get
|
// note: this will also load the Id field through m.GetRaw
|
||||||
newRecord.Load(m.FieldsData())
|
var fieldName string
|
||||||
|
for _, field := range m.collection.Fields {
|
||||||
|
fieldName = field.GetName()
|
||||||
|
newRecord.SetRaw(fieldName, m.GetRaw(fieldName))
|
||||||
|
}
|
||||||
|
|
||||||
return newRecord
|
return newRecord
|
||||||
}
|
}
|
||||||
@@ -609,7 +656,10 @@ func (m *Record) Clone() *Record {
|
|||||||
newRecord.ignoreUnchangedFields = m.ignoreUnchangedFields
|
newRecord.ignoreUnchangedFields = m.ignoreUnchangedFields
|
||||||
newRecord.customVisibility.Reset(m.customVisibility.GetAll())
|
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 {
|
if m.expand != nil {
|
||||||
newRecord.SetExpand(m.expand.GetAll())
|
newRecord.SetExpand(m.expand.GetAll())
|
||||||
@@ -722,8 +772,10 @@ func (m *Record) MergeExpand(expand map[string]any) {
|
|||||||
func (m *Record) FieldsData() map[string]any {
|
func (m *Record) FieldsData() map[string]any {
|
||||||
result := make(map[string]any, len(m.collection.Fields))
|
result := make(map[string]any, len(m.collection.Fields))
|
||||||
|
|
||||||
|
var fieldName string
|
||||||
for _, field := range m.collection.Fields {
|
for _, field := range m.collection.Fields {
|
||||||
result[field.GetName()] = m.Get(field.GetName())
|
fieldName = field.GetName()
|
||||||
|
result[fieldName] = m.Get(fieldName)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -1057,15 +1109,18 @@ func (m *Record) dbExport() (map[string]any, error) {
|
|||||||
|
|
||||||
result := make(map[string]any, len(fields))
|
result := make(map[string]any, len(fields))
|
||||||
|
|
||||||
|
var fieldName string
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
|
fieldName = field.GetName()
|
||||||
|
|
||||||
if f, ok := field.(DriverValuer); ok {
|
if f, ok := field.(DriverValuer); ok {
|
||||||
v, err := f.DriverValue(m)
|
v, err := f.DriverValue(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result[field.GetName()] = v
|
result[fieldName] = v
|
||||||
} else {
|
} else {
|
||||||
result[field.GetName()] = m.GetRaw(field.GetName())
|
result[fieldName] = m.GetRaw(fieldName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,8 +1246,11 @@ func (record *Record) PublicExport() map[string]any {
|
|||||||
customVisibility := record.customVisibility.GetAll()
|
customVisibility := record.customVisibility.GetAll()
|
||||||
|
|
||||||
// export schema fields
|
// export schema fields
|
||||||
|
var fieldName string
|
||||||
for _, f := range record.collection.Fields {
|
for _, f := range record.collection.Fields {
|
||||||
isVisible, hasCustomVisibility = customVisibility[f.GetName()]
|
fieldName = f.GetName()
|
||||||
|
|
||||||
|
isVisible, hasCustomVisibility = customVisibility[fieldName]
|
||||||
if !hasCustomVisibility {
|
if !hasCustomVisibility {
|
||||||
isVisible = !f.GetHidden()
|
isVisible = !f.GetHidden()
|
||||||
}
|
}
|
||||||
@@ -1201,7 +1259,7 @@ func (record *Record) PublicExport() map[string]any {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
export[f.GetName()] = record.Get(f.GetName())
|
export[fieldName] = record.Get(fieldName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// export custom fields
|
// export custom fields
|
||||||
|
|||||||
@@ -244,10 +244,13 @@ func TestRecordFresh(t *testing.T) {
|
|||||||
|
|
||||||
extraFieldsCheck := []string{`"email":`, `"custom":`}
|
extraFieldsCheck := []string{`"email":`, `"custom":`}
|
||||||
|
|
||||||
|
autodateTest := types.NowDateTime()
|
||||||
|
|
||||||
// change the fields
|
// change the fields
|
||||||
record.Id = "changed"
|
record.Id = "changed"
|
||||||
record.Set("name", "name_new")
|
record.Set("name", "name_new")
|
||||||
record.Set("custom", "test_custom")
|
record.Set("custom", "test_custom")
|
||||||
|
record.SetRaw("created", autodateTest)
|
||||||
record.SetExpand(map[string]any{"test": 123})
|
record.SetExpand(map[string]any{"test": 123})
|
||||||
record.IgnoreEmailVisibility(true)
|
record.IgnoreEmailVisibility(true)
|
||||||
record.IgnoreUnchangedFields(true)
|
record.IgnoreUnchangedFields(true)
|
||||||
@@ -270,6 +273,9 @@ func TestRecordFresh(t *testing.T) {
|
|||||||
if v := record.GetString("name"); v != "name_new" {
|
if v := record.GetString("name"); v != "name_new" {
|
||||||
t.Fatalf("Expected name to be %q, got %q", "name_new", v)
|
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" {
|
if v := record.GetString("custom"); v != "test_custom" {
|
||||||
t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
|
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") {
|
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)
|
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 != "" {
|
if v := record.Fresh().GetString("custom"); v != "" {
|
||||||
t.Fatalf("Expected the fresh custom to be %q, got %q", "", 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":`}
|
extraFieldsCheck := []string{`"email":`, `"custom":`}
|
||||||
|
|
||||||
|
autodateTest := types.NowDateTime()
|
||||||
|
|
||||||
// change the fields
|
// change the fields
|
||||||
record.Id = "changed"
|
record.Id = "changed"
|
||||||
record.Set("name", "name_new")
|
record.Set("name", "name_new")
|
||||||
record.Set("custom", "test_custom")
|
record.Set("custom", "test_custom")
|
||||||
|
record.SetRaw("created", autodateTest)
|
||||||
record.SetExpand(map[string]any{"test": 123})
|
record.SetExpand(map[string]any{"test": 123})
|
||||||
record.IgnoreEmailVisibility(true)
|
record.IgnoreEmailVisibility(true)
|
||||||
record.WithCustomData(true)
|
record.WithCustomData(true)
|
||||||
@@ -346,6 +361,9 @@ func TestRecordClone(t *testing.T) {
|
|||||||
if v := record.GetString("name"); v != "name_new" {
|
if v := record.GetString("name"); v != "name_new" {
|
||||||
t.Fatalf("Expected name to be %q, got %q", "name_new", v)
|
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" {
|
if v := record.GetString("custom"); v != "test_custom" {
|
||||||
t.Fatalf("Expected custom to be %q, got %q", "test_custom", v)
|
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") {
|
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)
|
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" {
|
if v := record.Clone().GetString("custom"); v != "test_custom" {
|
||||||
t.Fatalf("Expected the clone custom to be %q, got %q", "test_custom", v)
|
t.Fatalf("Expected the clone custom to be %q, got %q", "test_custom", v)
|
||||||
}
|
}
|
||||||
@@ -1564,13 +1588,15 @@ func TestRecordValidate(t *testing.T) {
|
|||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
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(
|
collection.Fields.Add(
|
||||||
// dummy fields to ensure that its validators are triggered
|
|
||||||
&core.TextField{Name: "f1", Min: 3},
|
&core.TextField{Name: "f1", Min: 3},
|
||||||
&core.NumberField{Name: "f2", Required: true},
|
&core.NumberField{Name: "f2", Required: true},
|
||||||
)
|
)
|
||||||
|
if err := app.Save(collection); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
record := core.NewRecord(collection)
|
record := core.NewRecord(collection)
|
||||||
record.Id = "!invalid"
|
record.Id = "!invalid"
|
||||||
@@ -1585,7 +1611,7 @@ func TestRecordValidate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("satisfying the fields validations", func(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("f1", "abc")
|
||||||
record.Set("f2", 1)
|
record.Set("f2", 1)
|
||||||
tests.TestValidationErrors(t, app.Validate(record), nil)
|
tests.TestValidationErrors(t, app.Validate(record), nil)
|
||||||
|
|||||||
+10
-10
@@ -102,11 +102,6 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
default: // expects []RecordProxy slice
|
default: // expects []RecordProxy slice
|
||||||
records, err := resolveRecordAllHook(collection, op)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
rv := reflect.ValueOf(v)
|
rv := reflect.ValueOf(v)
|
||||||
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
||||||
return errors.New("must be a pointer")
|
return errors.New("must be a pointer")
|
||||||
@@ -118,11 +113,6 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
|
|||||||
return errors.New("must be a slice of RecordSetters")
|
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()
|
et := rv.Type().Elem()
|
||||||
|
|
||||||
var isSliceOfPointers bool
|
var isSliceOfPointers bool
|
||||||
@@ -135,6 +125,16 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
|
|||||||
return op(sliceA)
|
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 {
|
for _, record := range records {
|
||||||
ev := reflect.New(et)
|
ev := reflect.New(et)
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestRecordQueryOne(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
collection string
|
collection string
|
||||||
recordId string
|
recordId string
|
||||||
model core.Model
|
model any
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"record model",
|
"record model",
|
||||||
@@ -82,6 +82,14 @@ func TestRecordQueryOne(t *testing.T) {
|
|||||||
core.BaseRecordProxy
|
core.BaseRecordProxy
|
||||||
}{},
|
}{},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"custom struct",
|
||||||
|
"demo1",
|
||||||
|
"84nmscqy84lsi1t",
|
||||||
|
&struct {
|
||||||
|
Id string `db:"id" json:"id"`
|
||||||
|
}{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
@@ -98,8 +106,14 @@ func TestRecordQueryOne(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.model.PK() != s.recordId {
|
raw, err := json.Marshal(s.model)
|
||||||
t.Fatalf("Expected record with id %q, got %q", s.recordId, s.model.PK())
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rawStr := string(raw)
|
||||||
|
|
||||||
|
if !strings.Contains(rawStr, fmt.Sprintf(`"id":%q`, s.recordId)) {
|
||||||
|
t.Fatalf("Missing id %q in\n%s", s.recordId, rawStr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -111,6 +125,10 @@ func TestRecordQueryAll(t *testing.T) {
|
|||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
type customStructs struct {
|
||||||
|
Id string `db:"id" json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
type mockRecordProxy struct {
|
type mockRecordProxy struct {
|
||||||
core.BaseRecordProxy
|
core.BaseRecordProxy
|
||||||
}
|
}
|
||||||
@@ -145,6 +163,18 @@ func TestRecordQueryAll(t *testing.T) {
|
|||||||
[]any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
|
[]any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
|
||||||
&[]mockRecordProxy{},
|
&[]mockRecordProxy{},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"slice of custom structs",
|
||||||
|
"demo1",
|
||||||
|
[]any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
|
||||||
|
&[]customStructs{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slice of pointer custom structs",
|
||||||
|
"demo1",
|
||||||
|
[]any{"84nmscqy84lsi1t", "al1h9ijdeojtsjy"},
|
||||||
|
&[]customStructs{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
|
|||||||
+44
-13
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -123,10 +124,10 @@ type settings struct {
|
|||||||
Backups BackupsConfig `form:"backups" json:"backups"`
|
Backups BackupsConfig `form:"backups" json:"backups"`
|
||||||
S3 S3Config `form:"s3" json:"s3"`
|
S3 S3Config `form:"s3" json:"s3"`
|
||||||
Meta MetaConfig `form:"meta" json:"meta"`
|
Meta MetaConfig `form:"meta" json:"meta"`
|
||||||
Logs LogsConfig `form:"logs" json:"logs"`
|
|
||||||
Batch BatchConfig `form:"batch" json:"batch"`
|
|
||||||
RateLimits RateLimitsConfig `form:"rateLimits" json:"rateLimits"`
|
RateLimits RateLimitsConfig `form:"rateLimits" json:"rateLimits"`
|
||||||
TrustedProxy TrustedProxyConfig `form:"trustedProxy" json:"trustedProxy"`
|
TrustedProxy TrustedProxyConfig `form:"trustedProxy" json:"trustedProxy"`
|
||||||
|
Batch BatchConfig `form:"batch" json:"batch"`
|
||||||
|
Logs LogsConfig `form:"logs" json:"logs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings defines the PocketBase app settings.
|
// Settings defines the PocketBase app settings.
|
||||||
@@ -529,7 +530,7 @@ type TrustedProxyConfig struct {
|
|||||||
|
|
||||||
// UseLeftmostIP specifies to use the left-mostish IP from the trusted headers.
|
// UseLeftmostIP specifies to use the left-mostish IP from the trusted headers.
|
||||||
//
|
//
|
||||||
// Note that this could be insecure when used with X-Forward-For header
|
// Note that this could be insecure when used with X-Forwarded-For header
|
||||||
// because some proxies like AWS ELB allow users to prepend their own header value
|
// because some proxies like AWS ELB allow users to prepend their own header value
|
||||||
// before appending the trusted ones.
|
// before appending the trusted ones.
|
||||||
UseLeftmostIP bool `form:"useLeftmostIP" json:"useLeftmostIP"`
|
UseLeftmostIP bool `form:"useLeftmostIP" json:"useLeftmostIP"`
|
||||||
@@ -560,13 +561,17 @@ type RateLimitsConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindRateLimitRule returns the first matching rule based on the provided labels.
|
// FindRateLimitRule returns the first matching rule based on the provided labels.
|
||||||
func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string) (RateLimitRule, bool) {
|
//
|
||||||
|
// Optionally you can further specify a list of valid RateLimitRule.Audience values to further filter the matching rule
|
||||||
|
// (aka. the rule Audience will have to exist in one of the specified options).
|
||||||
|
func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string, optOnlyAudience ...string) (RateLimitRule, bool) {
|
||||||
var prefixRules []int
|
var prefixRules []int
|
||||||
|
|
||||||
for i, label := range searchLabels {
|
for i, label := range searchLabels {
|
||||||
// check for direct match
|
// check for direct match
|
||||||
for j := range c.Rules {
|
for j := range c.Rules {
|
||||||
if label == c.Rules[j].Label {
|
if label == c.Rules[j].Label &&
|
||||||
|
(len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[j].Audience)) {
|
||||||
return c.Rules[j], true
|
return c.Rules[j], true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,7 +583,8 @@ func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string) (RateLimitRu
|
|||||||
// check for prefix match
|
// check for prefix match
|
||||||
if len(prefixRules) > 0 {
|
if len(prefixRules) > 0 {
|
||||||
for j := range prefixRules {
|
for j := range prefixRules {
|
||||||
if strings.HasPrefix(label+"/", c.Rules[prefixRules[j]].Label) {
|
if strings.HasPrefix(label+"/", c.Rules[prefixRules[j]].Label) &&
|
||||||
|
(len(optOnlyAudience) == 0 || slices.Contains(optOnlyAudience, c.Rules[prefixRules[j]].Audience)) {
|
||||||
return c.Rules[prefixRules[j]], true
|
return c.Rules[prefixRules[j]], true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,19 +623,28 @@ func checkUniqueRuleLabel(value any) error {
|
|||||||
return validators.ErrUnsupportedValueType
|
return validators.ErrUnsupportedValueType
|
||||||
}
|
}
|
||||||
|
|
||||||
labels := make(map[string]struct{}, len(rules))
|
existing := make([]string, 0, len(rules))
|
||||||
|
|
||||||
for i, rule := range rules {
|
for i, rule := range rules {
|
||||||
_, ok := labels[rule.Label]
|
fullKey := rule.Label + "@@" + rule.Audience
|
||||||
if ok {
|
|
||||||
|
var conflicts bool
|
||||||
|
for _, key := range existing {
|
||||||
|
if strings.HasPrefix(key, fullKey) || strings.HasPrefix(fullKey, key) {
|
||||||
|
conflicts = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if conflicts {
|
||||||
return validation.Errors{
|
return validation.Errors{
|
||||||
strconv.Itoa(i): validation.Errors{
|
strconv.Itoa(i): validation.Errors{
|
||||||
"label": validation.NewError("validation_duplicated_rate_limit_tag", "Rate limit tag with label "+rule.Label+" already exists.").
|
"label": validation.NewError("validation_conflicting_rate_limit_rule", "Rate limit rule configuration with label {{.label}} already exists or conflicts with another rule.").
|
||||||
SetParams(map[string]any{"label": rule.Label}),
|
SetParams(map[string]any{"label": rule.Label}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
labels[rule.Label] = struct{}{}
|
existing = append(existing, fullKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +653,13 @@ func checkUniqueRuleLabel(value any) error {
|
|||||||
|
|
||||||
var rateLimitRuleLabelRegex = regexp.MustCompile(`^(\w+\ \/[\w\/-]*|\/[\w\/-]*|^\w+\:\w+|\*\:\w+|\w+)$`)
|
var rateLimitRuleLabelRegex = regexp.MustCompile(`^(\w+\ \/[\w\/-]*|\/[\w\/-]*|^\w+\:\w+|\*\:\w+|\w+)$`)
|
||||||
|
|
||||||
|
// The allowed RateLimitRule.Audience values
|
||||||
|
const (
|
||||||
|
RateLimitRuleAudienceAll = ""
|
||||||
|
RateLimitRuleAudienceGuest = "@guest"
|
||||||
|
RateLimitRuleAudienceAuth = "@auth"
|
||||||
|
)
|
||||||
|
|
||||||
type RateLimitRule struct {
|
type RateLimitRule struct {
|
||||||
// Label is the identifier of the current rule.
|
// Label is the identifier of the current rule.
|
||||||
//
|
//
|
||||||
@@ -652,12 +674,18 @@ type RateLimitRule struct {
|
|||||||
// - POST /api/collections/
|
// - POST /api/collections/
|
||||||
Label string `form:"label" json:"label"`
|
Label string `form:"label" json:"label"`
|
||||||
|
|
||||||
// MaxRequests is the max allowed number of requests per Duration.
|
// Audience specifies the auth group the rule should apply for:
|
||||||
MaxRequests int `form:"maxRequests" json:"maxRequests"`
|
// - "" - both guests and authenticated users (default)
|
||||||
|
// - "guest" - only for guests
|
||||||
|
// - "auth" - only for authenticated users
|
||||||
|
Audience string `form:"audience" json:"audience"`
|
||||||
|
|
||||||
// Duration specifies the interval (in seconds) per which to reset
|
// Duration specifies the interval (in seconds) per which to reset
|
||||||
// the counted/accumulated rate limiter tokens.
|
// the counted/accumulated rate limiter tokens.
|
||||||
Duration int64 `form:"duration" json:"duration"`
|
Duration int64 `form:"duration" json:"duration"`
|
||||||
|
|
||||||
|
// MaxRequests is the max allowed number of requests per Duration.
|
||||||
|
MaxRequests int `form:"maxRequests" json:"maxRequests"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface.
|
// Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface.
|
||||||
@@ -666,6 +694,9 @@ func (c RateLimitRule) Validate() error {
|
|||||||
validation.Field(&c.Label, validation.Required, validation.Match(rateLimitRuleLabelRegex)),
|
validation.Field(&c.Label, validation.Required, validation.Match(rateLimitRuleLabelRegex)),
|
||||||
validation.Field(&c.MaxRequests, validation.Required, validation.Min(1)),
|
validation.Field(&c.MaxRequests, validation.Required, validation.Min(1)),
|
||||||
validation.Field(&c.Duration, validation.Required, validation.Min(1)),
|
validation.Field(&c.Duration, validation.Required, validation.Min(1)),
|
||||||
|
validation.Field(&c.Audience,
|
||||||
|
validation.In(RateLimitRuleAudienceAll, RateLimitRuleAudienceGuest, RateLimitRuleAudienceAuth),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+139
-18
@@ -106,7 +106,7 @@ func TestSettingsMarshalJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rawStr := string(raw)
|
rawStr := string(raw)
|
||||||
|
|
||||||
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false}}`
|
expected := `{"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
|
||||||
|
|
||||||
if rawStr != expected {
|
if rawStr != expected {
|
||||||
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
|
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
|
||||||
@@ -539,6 +539,86 @@ func TestRateLimitsConfigValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
[]string{},
|
[]string{},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"duplicated rules with the same audience",
|
||||||
|
core.RateLimitsConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Rules: []core.RateLimitRule{
|
||||||
|
{
|
||||||
|
Label: "/a",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "/a",
|
||||||
|
Duration: 2,
|
||||||
|
MaxRequests: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]string{"rules"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duplicated rule with conflicting audience (A)",
|
||||||
|
core.RateLimitsConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Rules: []core.RateLimitRule{
|
||||||
|
{
|
||||||
|
Label: "/a",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "/a",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 2,
|
||||||
|
Audience: core.RateLimitRuleAudienceGuest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]string{"rules"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duplicated rule with conflicting audience (B)",
|
||||||
|
core.RateLimitsConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Rules: []core.RateLimitRule{
|
||||||
|
{
|
||||||
|
Label: "/a",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 2,
|
||||||
|
Audience: core.RateLimitRuleAudienceAuth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "/a",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]string{"rules"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"duplicated rule with non-conflicting audience",
|
||||||
|
core.RateLimitsConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Rules: []core.RateLimitRule{
|
||||||
|
{
|
||||||
|
Label: "/a",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 2,
|
||||||
|
Audience: core.RateLimitRuleAudienceAuth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "/a",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 2,
|
||||||
|
Audience: core.RateLimitRuleAudienceGuest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]string{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
@@ -554,33 +634,43 @@ func TestRateLimitsFindRateLimitRule(t *testing.T) {
|
|||||||
limits := core.RateLimitsConfig{
|
limits := core.RateLimitsConfig{
|
||||||
Rules: []core.RateLimitRule{
|
Rules: []core.RateLimitRule{
|
||||||
{Label: "abc"},
|
{Label: "abc"},
|
||||||
{Label: "POST /test/a/"},
|
{Label: "def", Audience: core.RateLimitRuleAudienceGuest},
|
||||||
{Label: "/test/a/"},
|
{Label: "/test/a", Audience: core.RateLimitRuleAudienceGuest},
|
||||||
{Label: "POST /test/a"},
|
{Label: "POST /test/a"},
|
||||||
{Label: "/test/a"},
|
{Label: "/test/a/", Audience: core.RateLimitRuleAudienceAuth},
|
||||||
|
{Label: "POST /test/a/"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
labels []string
|
labels []string
|
||||||
|
audience []string
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{[]string{}, ""},
|
{[]string{}, []string{}, ""},
|
||||||
{[]string{"missing"}, ""},
|
{[]string{"missing"}, []string{}, ""},
|
||||||
{[]string{"abc"}, "abc"},
|
{[]string{"abc"}, []string{}, "abc"},
|
||||||
{[]string{"/test"}, ""},
|
{[]string{"abc"}, []string{core.RateLimitRuleAudienceGuest}, ""},
|
||||||
{[]string{"/test/a"}, "/test/a"},
|
{[]string{"abc"}, []string{core.RateLimitRuleAudienceAuth}, ""},
|
||||||
{[]string{"GET /test/a"}, ""},
|
{[]string{"def"}, []string{core.RateLimitRuleAudienceGuest}, "def"},
|
||||||
{[]string{"POST /test/a"}, "POST /test/a"},
|
{[]string{"def"}, []string{core.RateLimitRuleAudienceAuth}, ""},
|
||||||
{[]string{"/test/a/b/c"}, "/test/a/"},
|
{[]string{"/test"}, []string{}, ""},
|
||||||
{[]string{"GET /test/a/b/c"}, ""},
|
{[]string{"/test/a"}, []string{}, "/test/a"},
|
||||||
{[]string{"POST /test/a/b/c"}, "POST /test/a/"},
|
{[]string{"/test/a"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"},
|
||||||
{[]string{"/test/a", "abc"}, "/test/a"}, // priority checks
|
{[]string{"/test/a"}, []string{core.RateLimitRuleAudienceGuest}, "/test/a"},
|
||||||
|
{[]string{"GET /test/a"}, []string{}, ""},
|
||||||
|
{[]string{"POST /test/a"}, []string{}, "POST /test/a"},
|
||||||
|
{[]string{"/test/a/b/c"}, []string{}, "/test/a/"},
|
||||||
|
{[]string{"/test/a/b/c"}, []string{core.RateLimitRuleAudienceAuth}, "/test/a/"},
|
||||||
|
{[]string{"/test/a/b/c"}, []string{core.RateLimitRuleAudienceGuest}, ""},
|
||||||
|
{[]string{"GET /test/a/b/c"}, []string{}, ""},
|
||||||
|
{[]string{"POST /test/a/b/c"}, []string{}, "POST /test/a/"},
|
||||||
|
{[]string{"/test/a", "abc"}, []string{}, "/test/a"}, // priority checks
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
t.Run(strings.Join(s.labels, ""), func(t *testing.T) {
|
t.Run(strings.Join(s.labels, "_")+":"+strings.Join(s.audience, "_"), func(t *testing.T) {
|
||||||
rule, ok := limits.FindRateLimitRule(s.labels)
|
rule, ok := limits.FindRateLimitRule(s.labels, s.audience...)
|
||||||
|
|
||||||
hasLabel := rule.Label != ""
|
hasLabel := rule.Label != ""
|
||||||
if hasLabel != ok {
|
if hasLabel != ok {
|
||||||
@@ -611,8 +701,9 @@ func TestRateLimitRuleValidate(t *testing.T) {
|
|||||||
Label: "@abc",
|
Label: "@abc",
|
||||||
Duration: -1,
|
Duration: -1,
|
||||||
MaxRequests: -1,
|
MaxRequests: -1,
|
||||||
|
Audience: "invalid",
|
||||||
},
|
},
|
||||||
[]string{"label", "duration", "maxRequests"},
|
[]string{"label", "duration", "maxRequests", "audience"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"valid data (name)",
|
"valid data (name)",
|
||||||
@@ -659,6 +750,36 @@ func TestRateLimitRuleValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
[]string{},
|
[]string{},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"invalid audience",
|
||||||
|
core.RateLimitRule{
|
||||||
|
Label: "/a/b/",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 1,
|
||||||
|
Audience: "invalid",
|
||||||
|
},
|
||||||
|
[]string{"audience"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"valid audience - " + core.RateLimitRuleAudienceGuest,
|
||||||
|
core.RateLimitRule{
|
||||||
|
Label: "POST /a/b/",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 1,
|
||||||
|
Audience: core.RateLimitRuleAudienceGuest,
|
||||||
|
},
|
||||||
|
[]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"valid audience - " + core.RateLimitRuleAudienceAuth,
|
||||||
|
core.RateLimitRule{
|
||||||
|
Label: "POST /a/b/",
|
||||||
|
Duration: 1,
|
||||||
|
MaxRequests: 1,
|
||||||
|
Audience: core.RateLimitRuleAudienceAuth,
|
||||||
|
},
|
||||||
|
[]string{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range scenarios {
|
for _, s := range scenarios {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func UploadedFileSize(maxBytes int64) validation.RuleFunc {
|
|||||||
if v.Size > maxBytes {
|
if v.Size > maxBytes {
|
||||||
return validation.NewError(
|
return validation.NewError(
|
||||||
"validation_file_size_limit",
|
"validation_file_size_limit",
|
||||||
fmt.Sprintf("Failed to upload %q - the maximum allowed file size is %v bytes.", v.OriginalName, maxBytes),
|
"Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
|
||||||
).SetParams(map[string]any{
|
).SetParams(map[string]any{
|
||||||
"file": v.OriginalName,
|
"file": v.OriginalName,
|
||||||
"maxSize": maxBytes,
|
"maxSize": maxBytes,
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, er
|
|||||||
System: true,
|
System: true,
|
||||||
Required: true,
|
Required: true,
|
||||||
PrimaryKey: true,
|
PrimaryKey: true,
|
||||||
|
Pattern: `^[a-z0-9]+$`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -193,9 +193,9 @@ func (form *RecordUpsert) validateFormFields() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (form *RecordUpsert) checkOldPassword(value any) error {
|
func (form *RecordUpsert) checkOldPassword(value any) error {
|
||||||
v, _ := value.(string)
|
v, ok := value.(string)
|
||||||
if v == "" || form.record.IsNew() {
|
if !ok {
|
||||||
return nil // nothing to check
|
return validators.ErrUnsupportedValueType
|
||||||
}
|
}
|
||||||
|
|
||||||
if !form.record.Original().ValidatePassword(v) {
|
if !form.record.Original().ValidatePassword(v) {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (form *TestEmailSend) Submit() error {
|
|||||||
case TestTemplateEmailChange:
|
case TestTemplateEmailChange:
|
||||||
return mails.SendRecordChangeEmail(form.app, record, form.Email)
|
return mails.SendRecordChangeEmail(form.app, record, form.Email)
|
||||||
case TestTemplateOTP:
|
case TestTemplateOTP:
|
||||||
return mails.SendRecordOTP(form.app, record, "OTP_ID", "123456")
|
return mails.SendRecordOTP(form.app, record, "_PB_TEST_OTP_ID_", "123456")
|
||||||
case TestTemplateAuthAlert:
|
case TestTemplateAuthAlert:
|
||||||
return mails.SendRecordAuthAlert(form.app, record)
|
return mails.SendRecordAuthAlert(form.app, record)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -4,56 +4,57 @@ go 1.23
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.2
|
github.com/aws/aws-sdk-go-v2 v1.32.6
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.0
|
github.com/aws/aws-sdk-go-v2/config v1.28.6
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.41
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.47
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0
|
||||||
github.com/aws/smithy-go v1.22.0
|
github.com/aws/smithy-go v1.22.1
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2
|
github.com/domodwyer/mailyak/v3 v3.6.2
|
||||||
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3
|
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3
|
||||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
||||||
github.com/fatih/color v1.17.0
|
github.com/fatih/color v1.18.0
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6
|
github.com/gabriel-vasile/mimetype v1.4.7
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1
|
github.com/ganigeorgiev/fexpr v0.4.1
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
github.com/golang-jwt/jwt/v4 v4.5.1
|
||||||
github.com/pocketbase/dbx v1.10.1
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75
|
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75
|
||||||
github.com/spf13/cast v1.7.0
|
github.com/spf13/cast v1.7.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
gocloud.dev v0.40.0
|
gocloud.dev v0.40.0
|
||||||
golang.org/x/crypto v0.28.0
|
golang.org/x/crypto v0.31.0
|
||||||
golang.org/x/net v0.30.0
|
golang.org/x/net v0.32.0
|
||||||
golang.org/x/oauth2 v0.23.0
|
golang.org/x/oauth2 v0.24.0
|
||||||
golang.org/x/sync v0.8.0
|
golang.org/x/sync v0.10.0
|
||||||
modernc.org/sqlite v1.33.1
|
modernc.org/sqlite v1.34.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
|
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.14.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
@@ -65,20 +66,20 @@ require (
|
|||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/stretchr/testify v1.8.2 // indirect
|
github.com/stretchr/testify v1.8.2 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
|
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
||||||
golang.org/x/image v0.21.0 // indirect
|
golang.org/x/image v0.23.0 // indirect
|
||||||
golang.org/x/mod v0.21.0 // indirect
|
golang.org/x/mod v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/term v0.25.0 // indirect
|
golang.org/x/term v0.27.0 // indirect
|
||||||
golang.org/x/text v0.19.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
golang.org/x/tools v0.26.0 // indirect
|
golang.org/x/tools v0.28.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
google.golang.org/api v0.201.0 // indirect
|
google.golang.org/api v0.211.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||||
google.golang.org/grpc v1.67.1 // indirect
|
google.golang.org/grpc v1.69.0 // indirect
|
||||||
google.golang.org/protobuf v1.35.1 // indirect
|
google.golang.org/protobuf v1.35.2 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 // indirect
|
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
modernc.org/libc v1.61.4 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
modernc.org/memory v1.8.0 // indirect
|
||||||
modernc.org/strutil v1.2.0 // indirect
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
||||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
||||||
cloud.google.com/go/auth v0.9.8 h1:+CSJ0Gw9iVeSENVCKJoLHhdUykDgXSc4Qn+gu2BRtR8=
|
cloud.google.com/go/auth v0.12.1 h1:n2Bj25BUMM0nvE9D2XLTiImanwZhO3DkfWSYS/SAJP4=
|
||||||
cloud.google.com/go/auth v0.9.8/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI=
|
cloud.google.com/go/auth v0.12.1/go.mod h1:BFMu+TNpF3DmvfBO9ClqTR/SiqVIm7LukKF9mbendF4=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
|
||||||
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
|
||||||
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
||||||
@@ -25,44 +25,44 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
|
||||||
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.2 h1:AkNLZEyYMLnx/Q/mSKkcMqwNFXMAvFto9bNsHqcTduI=
|
github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.32.2/go.mod h1:2SK5n0a2karNTv5tbP1SjsX0uhttou00v/HpXKM1ZUo=
|
github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.0 h1:FosVYWcqEtWNxHn8gB/Vs6jOlNwSoyOCA/g/sxyySOQ=
|
github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.28.0/go.mod h1:pYhbtvg1siOOg8h5an77rXle9tVG8T+BWLWAo7cOukc=
|
github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.41 h1:7gXo+Axmp+R4Z+AK8YFQO0ZV3L0gizGINCOWxSLY9W8=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.41/go.mod h1:u4Eb8d3394YLubphT4jLEwN1rLNq2wFOlT6OuxFwPzU=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17 h1:TMH3f/SCAWdNtXXVPPu5D6wrr4G5hI1rAxbcocKfC7Q=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.17/go.mod h1:1ZRXLdTpzdJb9fwTMXiLipENRxkGMTn1sfKexGllQCw=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33 h1:X+4YY5kZRI/cOoSMVMGTqFXHAMg1bvvay7IBcqHpybQ=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43 h1:iLdpkYZ4cXIQMO7ud+cqMWR1xK5ESbt1rvN77tRi1BY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.33/go.mod h1:DPynzu+cn92k5UQ6tZhX+wfTB4ah6QDU/NgdHqatmvk=
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.43/go.mod h1:OgbsKPAswXDd5kxnR4vZov69p3oYjbvUyIRBAAV0y9o=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21 h1:UAsR3xA31QGf79WzpG/ixT9FZvQlh5HY1NRqSHBNOCk=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.21/go.mod h1:JNr43NFf5L9YaG3eKTm7HQzls9J+A9YYcGI5Quh1r2Y=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21 h1:6jZVETqmYCadGFvrYEQfC5fAQmlo80CeL5psbno6r0s=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.21/go.mod h1:1SR0GbLlnN3QUmYaflZNiH1ql+1qrSiB2vwcJ+4UM60=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21 h1:7edmS3VOBDhK00b/MwGtGglCm7hhwNYnjJs/PgFdMQE=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.21/go.mod h1:Q9o5h4HoIWG8XfzxqiuK/CGUbepCJ8uTlaE3bAbxytQ=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 h1:TToQNkvGguu209puTojY/ozlqy2d/SFNcoLIqTFi42g=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0/go.mod h1:0jp+ltwkf+SwG2fm/PKo8t4y8pJSgOCO4D8Lz3k0aHQ=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2 h1:4FMHqLfk0efmTqhXVRL5xYRqlEBNBiRI7N6w4jsEdd4=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.2/go.mod h1:LWoqeWlK9OZeJxsROW2RqrSPvQHKTpp69r/iDjwsSaw=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2 h1:s7NA1SOw8q/5c0wr8477yOPp0z+uBaXBnLE0XYb0POA=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.2/go.mod h1:fnjjWyAW/Pj5HYOxl9LJqWtEwS7W2qgcRLWP+uWbss0=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2 h1:t7iUP9+4wdc5lt3E41huP+GvQZJD38WLsgVp4iOtAjg=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.2/go.mod h1:/niFCtmuQNxqx9v8WAPq5qh7EH25U4BF6tjoyq9bObM=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0 h1:xA6XhTF7PE89BCNHJbQi8VvPzcgMtmGC5dr8S8N7lHk=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.66.0/go.mod h1:cB6oAuus7YXRZhWCc1wIwPywwZ1XwweNp2TVAEGYeB8=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2 h1:bSYXVyUzoTHoKalBmwaZxs97HU9DWWI3ehHSAMa7xOk=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.2/go.mod h1:skMqY7JElusiOUjMJMOv1jJsP7YUg7DrhgqZZWuzu1U=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2 h1:AhmO1fHINP9vFYUE0LHzCWg/LfUWUF+zFPEcY9QXb7o=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.2/go.mod h1:o8aQygT2+MVP0NaV6kbdE1YnnIM8RRVQzoeUH45GOdI=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2 h1:CiS7i0+FUe+/YY1GvIBLLrR/XNGZ4CtM1Ll0XavNuVo=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.32.2/go.mod h1:HtaiBI8CjYoNVde8arShXb94UbQQi9L4EMr6D+xGBwo=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8=
|
||||||
github.com/aws/smithy-go v1.22.0 h1:uunKnWlcoL3zO7q+gG2Pk53joueEOsnNB28QdMsmiMM=
|
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
|
||||||
github.com/aws/smithy-go v1.22.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
@@ -78,6 +78,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
|
|||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
|
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
|
||||||
|
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
|
||||||
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3 h1:MXsAuToxwsTn5BEEYm2DheqIiC4jWGmkEJ1uy+KFhvQ=
|
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3 h1:MXsAuToxwsTn5BEEYm2DheqIiC4jWGmkEJ1uy+KFhvQ=
|
||||||
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=
|
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=
|
||||||
@@ -88,16 +90,16 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
|
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
|
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
@@ -111,12 +113,12 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5Nq
|
|||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
@@ -149,8 +151,8 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
|||||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
github.com/googleapis/gax-go/v2 v2.14.0 h1:f+jMrjBPl+DL9nI4IQzLUxMq7XrAqFYB7hBPqMNIe8o=
|
||||||
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
github.com/googleapis/gax-go/v2 v2.14.0/go.mod h1:lhBCnjdLrWRaPvLWhmc8IS24m9mr07qSYnHncrgo+zk=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||||
@@ -179,8 +181,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
|||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75 h1:XSbmekxgmbI2uPrre/nkCz7y8VsV652TPb3hAYzPb74=
|
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75 h1:XSbmekxgmbI2uPrre/nkCz7y8VsV652TPb3hAYzPb74=
|
||||||
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
|
github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
@@ -212,31 +214,35 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5
|
|||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
|
||||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
|
||||||
|
go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys=
|
||||||
|
go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A=
|
||||||
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
||||||
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -247,17 +253,17 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
|||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -269,21 +275,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
@@ -291,33 +297,33 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
|
|||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
|
||||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
google.golang.org/api v0.201.0 h1:+7AD9JNM3tREtawRMu8sOjSbb8VYcYXJG/2eEOmfDu0=
|
google.golang.org/api v0.211.0 h1:IUpLjq09jxBSV1lACO33CGY3jsRcbctfGzhj+ZSE/Bg=
|
||||||
google.golang.org/api v0.201.0/go.mod h1:HVY0FCHVs89xIW9fzf/pBvOEm+OolHa86G/txFezyq4=
|
google.golang.org/api v0.211.0/go.mod h1:XOloB4MXFH4UTlQSGuNUxw0UT74qdENK8d6JNsXKLi0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9 h1:nFS3IivktIU5Mk6KQa+v6RKkHUpdQpphqGNLxqNnbEk=
|
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
|
||||||
google.golang.org/genproto v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:tEzYTYZxbmVNOu0OAFH9HzdJtLn6h4Aj89zzlBCdHms=
|
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
|
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
|
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
google.golang.org/grpc v1.69.0 h1:quSiOM1GJPmPH5XtU+BCoVXcDVJJAzNcoyfC2cCjGkI=
|
||||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
google.golang.org/grpc v1.69.0/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -327,8 +333,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
@@ -338,18 +344,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
modernc.org/ccgo/v4 v4.23.1 h1:N49a7JiWGWV7lkPE4yYcvjkBGZQi93/JabRYjdWmJXc=
|
||||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
modernc.org/ccgo/v4 v4.23.1/go.mod h1:JoIUegEIfutvoWV/BBfDFpPpfR2nc3U0jKucGcbmwDU=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852 h1:IYXPPTTjjoSHvUClZIYexDiO7g+4x+XveKT4gCIAwiY=
|
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9 h1:ovz6yUKX71igz2yvk4NpiCL5fvdjZAI+DhuDEGx1xyU=
|
||||||
modernc.org/gc/v3 v3.0.0-20241004144649-1aea3fae8852/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/gc/v3 v3.0.0-20241213165251-3bc300f6d0c9/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
modernc.org/libc v1.61.4 h1:wVyqEx6tlltte9lPTjq0kDAdtdM9c4JH8rU6M1ZVawA=
|
||||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
modernc.org/libc v1.61.4/go.mod h1:VfXVuM/Shh5XsMNrh3C6OkfL78G3loa4ZC/Ljv9k7xc=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
@@ -358,8 +364,8 @@ modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
|||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
modernc.org/sqlite v1.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y=
|
||||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU=
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
+58
-2
@@ -1,8 +1,10 @@
|
|||||||
package mails
|
package mails
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/mails/templates"
|
"github.com/pocketbase/pocketbase/mails/templates"
|
||||||
@@ -40,6 +42,8 @@ func SendRecordAuthAlert(app core.App, authRecord *core.Record) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendRecordOTP sends OTP email to the specified auth record.
|
// SendRecordOTP sends OTP email to the specified auth record.
|
||||||
|
//
|
||||||
|
// This method will also update the "sentTo" field of the related OTP record to the mail sent To address (if the OTP exists and not already assigned).
|
||||||
func SendRecordOTP(app core.App, authRecord *core.Record, otpId string, pass string) error {
|
func SendRecordOTP(app core.App, authRecord *core.Record, otpId string, pass string) error {
|
||||||
mailClient := app.NewMailClient()
|
mailClient := app.NewMailClient()
|
||||||
|
|
||||||
@@ -72,7 +76,44 @@ func SendRecordOTP(app core.App, authRecord *core.Record, otpId string, pass str
|
|||||||
}
|
}
|
||||||
|
|
||||||
return app.OnMailerRecordOTPSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
return app.OnMailerRecordOTPSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||||
return e.Mailer.Send(e.Message)
|
err := e.Mailer.Send(e.Message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var toAddress string
|
||||||
|
if len(e.Message.To) > 0 {
|
||||||
|
toAddress = e.Message.To[0].Address
|
||||||
|
}
|
||||||
|
if toAddress == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
otp, err := e.App.FindOTPById(otpId)
|
||||||
|
if err != nil {
|
||||||
|
e.App.Logger().Warn(
|
||||||
|
"Unable to find OTP to update its sentTo field (either it was already deleted or the id is nonexisting)",
|
||||||
|
"error", err,
|
||||||
|
"otpId", otpId,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if otp.SentTo() != "" {
|
||||||
|
return nil // was already sent to another target
|
||||||
|
}
|
||||||
|
|
||||||
|
otp.SetSentTo(toAddress)
|
||||||
|
if err = e.App.Save(otp); err != nil {
|
||||||
|
e.App.Logger().Error(
|
||||||
|
"Failed to update OTP sentTo field",
|
||||||
|
"error", err,
|
||||||
|
"otpId", otpId,
|
||||||
|
"to", toAddress,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +234,13 @@ func SendRecordChangeEmail(app core.App, authRecord *core.Record, newEmail strin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nonescapeTypes = []string{
|
||||||
|
core.FieldTypeAutodate,
|
||||||
|
core.FieldTypeDate,
|
||||||
|
core.FieldTypeBool,
|
||||||
|
core.FieldTypeNumber,
|
||||||
|
}
|
||||||
|
|
||||||
func resolveEmailTemplate(
|
func resolveEmailTemplate(
|
||||||
app core.App,
|
app core.App,
|
||||||
authRecord *core.Record,
|
authRecord *core.Record,
|
||||||
@@ -219,7 +267,15 @@ func resolveEmailTemplate(
|
|||||||
|
|
||||||
fieldPlacehodler := "{RECORD:" + field.GetName() + "}"
|
fieldPlacehodler := "{RECORD:" + field.GetName() + "}"
|
||||||
if _, ok := placeholders[fieldPlacehodler]; !ok {
|
if _, ok := placeholders[fieldPlacehodler]; !ok {
|
||||||
placeholders[fieldPlacehodler] = authRecord.Get(field.GetName())
|
val := authRecord.GetString(field.GetName())
|
||||||
|
|
||||||
|
// note: the escaping is not strictly necessary but for just in case
|
||||||
|
// the user decide to store and render the email as plain html
|
||||||
|
if !slices.Contains(nonescapeTypes, field.Type()) {
|
||||||
|
val = html.EscapeString(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders[fieldPlacehodler] = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-5
@@ -1,6 +1,7 @@
|
|||||||
package mails_test
|
package mails_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ func TestSendRecordAuthAlert(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordAuthAlert(testApp, user)
|
err := mails.SendRecordAuthAlert(testApp, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -26,7 +30,7 @@ func TestSendRecordAuthAlert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // public and private record placeholder checks
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // public and private record placeholder checks
|
||||||
"login to your " + testApp.Settings().Meta.AppName + " account from a new location",
|
"login to your " + testApp.Settings().Meta.AppName + " account from a new location",
|
||||||
"If this was you",
|
"If this was you",
|
||||||
"If this wasn't you",
|
"If this wasn't you",
|
||||||
@@ -46,6 +50,9 @@ func TestSendRecordPasswordReset(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordPasswordReset(testApp, user)
|
err := mails.SendRecordPasswordReset(testApp, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -56,7 +63,7 @@ func TestSendRecordPasswordReset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
||||||
"http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
"http://localhost:8090/_/#/auth/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||||
}
|
}
|
||||||
for _, part := range expectedParts {
|
for _, part := range expectedParts {
|
||||||
@@ -74,6 +81,9 @@ func TestSendRecordVerification(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordVerification(testApp, user)
|
err := mails.SendRecordVerification(testApp, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -84,7 +94,7 @@ func TestSendRecordVerification(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
||||||
"http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
"http://localhost:8090/_/#/auth/confirm-verification/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||||
}
|
}
|
||||||
for _, part := range expectedParts {
|
for _, part := range expectedParts {
|
||||||
@@ -102,6 +112,9 @@ func TestSendRecordChangeEmail(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordChangeEmail(testApp, user, "new_test@example.com")
|
err := mails.SendRecordChangeEmail(testApp, user, "new_test@example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -112,7 +125,7 @@ func TestSendRecordChangeEmail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
||||||
"http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
"http://localhost:8090/_/#/auth/confirm-email-change/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.",
|
||||||
}
|
}
|
||||||
for _, part := range expectedParts {
|
for _, part := range expectedParts {
|
||||||
@@ -130,6 +143,9 @@ func TestSendRecordOTP(t *testing.T) {
|
|||||||
|
|
||||||
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
user, _ := testApp.FindFirstRecordByData("users", "email", "test@example.com")
|
||||||
|
|
||||||
|
// to test that it is escaped
|
||||||
|
user.Set("name", "<p>"+user.GetString("name")+"</p>")
|
||||||
|
|
||||||
err := mails.SendRecordOTP(testApp, user, "test_otp_id", "test_otp_code")
|
err := mails.SendRecordOTP(testApp, user, "test_otp_id", "test_otp_code")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -140,7 +156,7 @@ func TestSendRecordOTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedParts := []string{
|
expectedParts := []string{
|
||||||
user.GetString("name") + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
html.EscapeString(user.GetString("name")) + "{RECORD:tokenKey}", // the record name as {RECORD:name}
|
||||||
"one-time password",
|
"one-time password",
|
||||||
"test_otp_code",
|
"test_otp_code",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,12 @@ func createOTPsCollection(txApp core.App) error {
|
|||||||
System: true,
|
System: true,
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
Required: true,
|
Required: true,
|
||||||
Cost: 8, // low cost for better performce and because it is not critical
|
Cost: 8, // low cost for better performance and because it is not critical
|
||||||
|
})
|
||||||
|
col.Fields.Add(&core.TextField{
|
||||||
|
Name: "sentTo",
|
||||||
|
System: true,
|
||||||
|
Hidden: true,
|
||||||
})
|
})
|
||||||
col.Fields.Add(&core.AutodateField{
|
col.Fields.Add(&core.AutodateField{
|
||||||
Name: "created",
|
Name: "created",
|
||||||
|
|||||||
@@ -365,8 +365,10 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
|
|
||||||
c.Options["manageRule"] = nil
|
c.Options["manageRule"] = nil
|
||||||
if options["manageRule"] != nil {
|
if options["manageRule"] != nil {
|
||||||
manageRule := cast.ToString(options["manageRule"])
|
manageRule, err := cast.ToStringE(options["manageRule"])
|
||||||
c.Options["manageRule"] = &manageRule
|
if err == nil && manageRule != "" {
|
||||||
|
c.Options["manageRule"] = migrateRule(&manageRule)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// passwordAuth
|
// passwordAuth
|
||||||
@@ -494,15 +496,15 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
// ---
|
// ---
|
||||||
c.Indexes = append(types.JSONArray[string]{
|
c.Indexes = append(types.JSONArray[string]{
|
||||||
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_username_idx` ON `%s` (username COLLATE NOCASE)", c.Id, c.Name),
|
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_username_idx` ON `%s` (username COLLATE NOCASE)", c.Id, c.Name),
|
||||||
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_email_idx` ON `%s` (email) WHERE email != ''", c.Id, c.Name),
|
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_email_idx` ON `%s` (`email`) WHERE `email` != ''", c.Id, c.Name),
|
||||||
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_tokenKey_idx` ON `%s` (tokenKey)", c.Id, c.Name),
|
fmt.Sprintf("CREATE UNIQUE INDEX `_%s_tokenKey_idx` ON `%s` (`tokenKey`)", c.Id, c.Name),
|
||||||
}, c.Indexes...)
|
}, c.Indexes...)
|
||||||
|
|
||||||
// prepend the auth system fields
|
// prepend the auth system fields
|
||||||
// ---
|
// ---
|
||||||
tokenKeyField := map[string]any{
|
tokenKeyField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("text", "tokenKey"),
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "_pbf_auth_tokenKey_",
|
|
||||||
"name": "tokenKey",
|
"name": "tokenKey",
|
||||||
"system": true,
|
"system": true,
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
@@ -515,8 +517,8 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
"autogeneratePattern": "[a-zA-Z0-9_]{50}",
|
"autogeneratePattern": "[a-zA-Z0-9_]{50}",
|
||||||
}
|
}
|
||||||
passwordField := map[string]any{
|
passwordField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("password", "password"),
|
||||||
"type": "password",
|
"type": "password",
|
||||||
"id": "_pbf_auth_password_",
|
|
||||||
"name": "password",
|
"name": "password",
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"system": true,
|
"system": true,
|
||||||
@@ -527,8 +529,8 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
"cost": bcrypt.DefaultCost, // new default
|
"cost": bcrypt.DefaultCost, // new default
|
||||||
}
|
}
|
||||||
emailField := map[string]any{
|
emailField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("email", "email"),
|
||||||
"type": "email",
|
"type": "email",
|
||||||
"id": "_pbf_auth_email_",
|
|
||||||
"name": "email",
|
"name": "email",
|
||||||
"system": true,
|
"system": true,
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
@@ -538,8 +540,8 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
"onlyDomains": cast.ToStringSlice(options["onlyEmailDomains"]),
|
"onlyDomains": cast.ToStringSlice(options["onlyEmailDomains"]),
|
||||||
}
|
}
|
||||||
emailVisibilityField := map[string]any{
|
emailVisibilityField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("bool", "emailVisibility"),
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
"id": "_pbf_auth_emailVisibility_",
|
|
||||||
"name": "emailVisibility",
|
"name": "emailVisibility",
|
||||||
"system": true,
|
"system": true,
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
@@ -547,8 +549,8 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
"required": false,
|
"required": false,
|
||||||
}
|
}
|
||||||
verifiedField := map[string]any{
|
verifiedField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("bool", "verified"),
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
"id": "_pbf_auth_verified_",
|
|
||||||
"name": "verified",
|
"name": "verified",
|
||||||
"system": true,
|
"system": true,
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
@@ -556,8 +558,8 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
"required": false,
|
"required": false,
|
||||||
}
|
}
|
||||||
usernameField := map[string]any{
|
usernameField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("text", "username"),
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "_pbf_auth_username_",
|
|
||||||
"name": "username",
|
"name": "username",
|
||||||
"system": false,
|
"system": false,
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
@@ -597,8 +599,8 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
|
|
||||||
// prepend the id field
|
// prepend the id field
|
||||||
idField := map[string]any{
|
idField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("text", "id"),
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "_pbf_text_id_",
|
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"system": true,
|
"system": true,
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -631,8 +633,8 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
|
|
||||||
if addCreated {
|
if addCreated {
|
||||||
createdField := map[string]any{
|
createdField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("autodate", "created"),
|
||||||
"type": "autodate",
|
"type": "autodate",
|
||||||
"id": "_pbf_autodate_created_",
|
|
||||||
"name": "created",
|
"name": "created",
|
||||||
"system": false,
|
"system": false,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
@@ -645,8 +647,8 @@ func migrateOldCollections(txApp core.App, oldSettings *oldSettingsModel) error
|
|||||||
|
|
||||||
if addUpdated {
|
if addUpdated {
|
||||||
updatedField := map[string]any{
|
updatedField := map[string]any{
|
||||||
|
"id": fieldIdChecksum("autodate", "updated"),
|
||||||
"type": "autodate",
|
"type": "autodate",
|
||||||
"id": "_pbf_autodate_updated_",
|
|
||||||
"name": "updated",
|
"name": "updated",
|
||||||
"system": false,
|
"system": false,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
|
|
||||||
// note: this migration will be deleted in future version
|
// note: this migration will be deleted in future version
|
||||||
|
|
||||||
func collectionIdChecksum(c *core.Collection) string {
|
func collectionIdChecksum(typ, name string) string {
|
||||||
return "pbc_" + strconv.Itoa(int(crc32.ChecksumIEEE([]byte(c.Type+c.Name))))
|
return "pbc_" + strconv.Itoa(int(crc32.ChecksumIEEE([]byte(typ+name))))
|
||||||
}
|
}
|
||||||
|
|
||||||
func fieldIdChecksum(f core.Field) string {
|
func fieldIdChecksum(typ, name string) string {
|
||||||
return f.Type() + strconv.Itoa(int(crc32.ChecksumIEEE([]byte(f.GetName()))))
|
return typ + strconv.Itoa(int(crc32.ChecksumIEEE([]byte(name))))
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize system collection and field ids
|
// normalize system collection and field ids
|
||||||
@@ -62,7 +62,7 @@ func init() {
|
|||||||
originalId := c.Id
|
originalId := c.Id
|
||||||
|
|
||||||
// normalize collection id
|
// normalize collection id
|
||||||
if checksum := collectionIdChecksum(c); c.Id != checksum {
|
if checksum := collectionIdChecksum(c.Type, c.Name); c.Id != checksum {
|
||||||
c.Id = checksum
|
c.Id = checksum
|
||||||
needUpdate = true
|
needUpdate = true
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func init() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if checksum := fieldIdChecksum(f); f.GetId() != checksum {
|
if checksum := fieldIdChecksum(f.Type(), f.GetName()); f.GetId() != checksum {
|
||||||
f.SetId(checksum)
|
f.SetId(checksum)
|
||||||
needUpdate = true
|
needUpdate = true
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,13 @@ func init() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure that the cached collection id is also updated
|
||||||
|
cached, err := txApp.FindCachedCollectionByNameOrId(c.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cached.Id = c.Id
|
||||||
|
|
||||||
// update collection references
|
// update collection references
|
||||||
for refCollection, fields := range references {
|
for refCollection, fields := range references {
|
||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
@@ -103,7 +110,7 @@ func init() {
|
|||||||
|
|
||||||
relationField.CollectionId = c.Id
|
relationField.CollectionId = c.Id
|
||||||
}
|
}
|
||||||
if err = txApp.Save(refCollection); err != nil {
|
if err = txApp.SaveNoValidate(refCollection); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +118,7 @@ func init() {
|
|||||||
// update mfas references
|
// update mfas references
|
||||||
for _, item := range mfas {
|
for _, item := range mfas {
|
||||||
item.SetCollectionRef(c.Id)
|
item.SetCollectionRef(c.Id)
|
||||||
if err = txApp.Save(item); err != nil {
|
if err = txApp.SaveNoValidate(item); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +126,7 @@ func init() {
|
|||||||
// update otps references
|
// update otps references
|
||||||
for _, item := range otps {
|
for _, item := range otps {
|
||||||
item.SetCollectionRef(c.Id)
|
item.SetCollectionRef(c.Id)
|
||||||
if err = txApp.Save(item); err != nil {
|
if err = txApp.SaveNoValidate(item); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +134,7 @@ func init() {
|
|||||||
// update authOrigins references
|
// update authOrigins references
|
||||||
for _, item := range authOrigins {
|
for _, item := range authOrigins {
|
||||||
item.SetCollectionRef(c.Id)
|
item.SetCollectionRef(c.Id)
|
||||||
if err = txApp.Save(item); err != nil {
|
if err = txApp.SaveNoValidate(item); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// note: this migration will be deleted in future version
|
||||||
|
|
||||||
|
// add new OTP sentTo text field (if not already)
|
||||||
|
func init() {
|
||||||
|
core.SystemMigrations.Register(func(txApp core.App) error {
|
||||||
|
otpCollection, err := txApp.FindCollectionByNameOrId(core.CollectionNameOTPs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
field := otpCollection.Fields.GetByName("sentTo")
|
||||||
|
if field != nil {
|
||||||
|
return nil // already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
otpCollection.Fields.Add(&core.TextField{
|
||||||
|
Name: "sentTo",
|
||||||
|
System: true,
|
||||||
|
Hidden: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return txApp.Save(otpCollection)
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
+35
-9
@@ -81,14 +81,14 @@ func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
|||||||
res, err := executor.RunProgram(pr)
|
res, err := executor.RunProgram(pr)
|
||||||
executor.Set("__args", goja.Undefined())
|
executor.Set("__args", goja.Undefined())
|
||||||
|
|
||||||
// check for returned error value
|
// (legacy) check for returned Go error value
|
||||||
if res != nil {
|
if res != nil {
|
||||||
if resErr, ok := res.Export().(error); ok {
|
if resErr, ok := res.Export().(error); ok {
|
||||||
return resErr
|
return resErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return normalizeException(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
return []reflect.Value{reflect.ValueOf(&err).Elem()}
|
return []reflect.Value{reflect.ValueOf(&err).Elem()}
|
||||||
@@ -197,14 +197,14 @@ func wrapHandlerFunc(executors *vmsPool, handler goja.Value) (func(*core.Request
|
|||||||
res, err := executor.RunProgram(pr)
|
res, err := executor.RunProgram(pr)
|
||||||
executor.Set("__args", goja.Undefined())
|
executor.Set("__args", goja.Undefined())
|
||||||
|
|
||||||
// check for returned error
|
// (legacy) check for returned Go error value
|
||||||
if res != nil {
|
if res != nil {
|
||||||
if v, ok := res.Export().(error); ok {
|
if v, ok := res.Export().(error); ok {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return normalizeException(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,9 +215,9 @@ func wrapHandlerFunc(executors *vmsPool, handler goja.Value) (func(*core.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
type gojaHookHandler struct {
|
type gojaHookHandler struct {
|
||||||
priority int
|
|
||||||
id string
|
id string
|
||||||
serializedFunc string
|
serializedFunc string
|
||||||
|
priority int
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.Handler[*core.RequestEvent], error) {
|
func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.Handler[*core.RequestEvent], error) {
|
||||||
@@ -254,14 +254,14 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
|
|||||||
res, err := executor.RunProgram(pr)
|
res, err := executor.RunProgram(pr)
|
||||||
executor.Set("__args", goja.Undefined())
|
executor.Set("__args", goja.Undefined())
|
||||||
|
|
||||||
// check for returned error
|
// (legacy) check for returned Go error value
|
||||||
if res != nil {
|
if res != nil {
|
||||||
if v, ok := res.Export().(error); ok {
|
if v, ok := res.Export().(error); ok {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return normalizeException(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -276,14 +276,14 @@ func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]*hook.
|
|||||||
res, err := executor.RunProgram(pr)
|
res, err := executor.RunProgram(pr)
|
||||||
executor.Set("__args", goja.Undefined())
|
executor.Set("__args", goja.Undefined())
|
||||||
|
|
||||||
// check for returned error
|
// (legacy) check for returned Go error value
|
||||||
if res != nil {
|
if res != nil {
|
||||||
if v, ok := res.Export().(error); ok {
|
if v, ok := res.Export().(error); ok {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return normalizeException(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1019,3 +1019,29 @@ func newDynamicModel(shape map[string]any) any {
|
|||||||
|
|
||||||
return elem.Addr().Interface()
|
return elem.Addr().Interface()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeException checks if the provided error is a goja.Exception
|
||||||
|
// and attempts to return its underlying Go error.
|
||||||
|
//
|
||||||
|
// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil.
|
||||||
|
func normalizeException(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jsException, ok := err.(*goja.Exception)
|
||||||
|
if !ok {
|
||||||
|
return err // no exception
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := jsException.Value().Export().(type) {
|
||||||
|
case error:
|
||||||
|
err = v
|
||||||
|
case map[string]any: // goja.GoError
|
||||||
|
if vErr, ok := v["value"].(error); ok {
|
||||||
|
err = vErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package jsvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -1456,6 +1457,47 @@ func TestHooksBinds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHooksExceptionUnwrapping(t *testing.T) {
|
||||||
|
app, _ := tests.NewTestApp()
|
||||||
|
defer app.Cleanup()
|
||||||
|
|
||||||
|
goErr := errors.New("test")
|
||||||
|
|
||||||
|
vmFactory := func() *goja.Runtime {
|
||||||
|
vm := goja.New()
|
||||||
|
baseBinds(vm)
|
||||||
|
vm.Set("$app", app)
|
||||||
|
vm.Set("goErr", goErr)
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := newPool(1, vmFactory)
|
||||||
|
|
||||||
|
vm := vmFactory()
|
||||||
|
hooksBinds(app, vm, pool)
|
||||||
|
|
||||||
|
_, err := vm.RunString(`
|
||||||
|
onModelUpdate((e) => {
|
||||||
|
throw goErr
|
||||||
|
}, "demo1")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := app.FindFirstRecordByFilter("demo1", "1=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Set("text", "update")
|
||||||
|
|
||||||
|
err = app.Save(record)
|
||||||
|
if !errors.Is(err, goErr) {
|
||||||
|
t.Fatalf("Expected goError, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRouterBindsCount(t *testing.T) {
|
func TestRouterBindsCount(t *testing.T) {
|
||||||
app, _ := tests.NewTestApp()
|
app, _ := tests.NewTestApp()
|
||||||
defer app.Cleanup()
|
defer app.Cleanup()
|
||||||
|
|||||||
+3783
-3701
File diff suppressed because it is too large.
Load diff
@@ -771,20 +771,20 @@ declare namespace $filesystem {
|
|||||||
let fileFromMultipart: filesystem.newFileFromMultipart
|
let fileFromMultipart: filesystem.newFileFromMultipart
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fileFromUrl creates a new File from the provided url by
|
* fileFromURL creates a new File from the provided url by
|
||||||
* downloading the resource and creating a BytesReader.
|
* downloading the resource and creating a BytesReader.
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
*
|
*
|
||||||
* ` + "```" + `js
|
* ` + "```" + `js
|
||||||
* // with default max timeout of 120sec
|
* // with default max timeout of 120sec
|
||||||
* const file1 = $filesystem.fileFromUrl("https://...")
|
* const file1 = $filesystem.fileFromURL("https://...")
|
||||||
*
|
*
|
||||||
* // with custom timeout of 15sec
|
* // with custom timeout of 15sec
|
||||||
* const file2 = $filesystem.fileFromUrl("https://...", 15)
|
* const file2 = $filesystem.fileFromURL("https://...", 15)
|
||||||
* ` + "```" + `
|
* ` + "```" + `
|
||||||
*/
|
*/
|
||||||
export function fileFromUrl(url: string, secTimeout?: number): filesystem.File
|
export function fileFromURL(url: string, secTimeout?: number): filesystem.File
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -1002,9 +1002,20 @@ declare namespace $apis {
|
|||||||
let skipSuccessActivityLog: apis.skipSuccessActivityLog
|
let skipSuccessActivityLog: apis.skipSuccessActivityLog
|
||||||
let gzip: apis.gzip
|
let gzip: apis.gzip
|
||||||
let bodyLimit: apis.bodyLimit
|
let bodyLimit: apis.bodyLimit
|
||||||
let recordAuthResponse: apis.recordAuthResponse
|
|
||||||
let enrichRecord: apis.enrichRecord
|
let enrichRecord: apis.enrichRecord
|
||||||
let enrichRecords: apis.enrichRecords
|
let enrichRecords: apis.enrichRecords
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecordAuthResponse writes standardized json record auth response
|
||||||
|
* into the specified request event.
|
||||||
|
*
|
||||||
|
* The authMethod argument specify the name of the current authentication method (eg. password, oauth2, etc.)
|
||||||
|
* that it is used primarily as an auth identifier during MFA and for login alerts.
|
||||||
|
*
|
||||||
|
* Set authMethod to empty string if you want to ignore the MFA checks and the login alerts
|
||||||
|
* (can be also adjusted additionally via the onRecordAuthRequest hook).
|
||||||
|
*/
|
||||||
|
export function recordAuthResponse(e: core.RequestEvent, authRecord: core.Record, authMethod: string, meta?: any): void
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|||||||
+7
-16
@@ -4,7 +4,7 @@
|
|||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// jsvm.MustRegister(app, jsvm.Config{
|
// jsvm.MustRegister(app, jsvm.Config{
|
||||||
// WatchHooks: true,
|
// HooksWatch: true,
|
||||||
// })
|
// })
|
||||||
package jsvm
|
package jsvm
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
|
"github.com/dop251/goja_nodejs/buffer"
|
||||||
"github.com/dop251/goja_nodejs/console"
|
"github.com/dop251/goja_nodejs/console"
|
||||||
"github.com/dop251/goja_nodejs/process"
|
"github.com/dop251/goja_nodejs/process"
|
||||||
"github.com/dop251/goja_nodejs/require"
|
"github.com/dop251/goja_nodejs/require"
|
||||||
@@ -169,9 +170,12 @@ func (p *plugin) registerMigrations() error {
|
|||||||
|
|
||||||
for file, content := range files {
|
for file, content := range files {
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
|
|
||||||
registry.Enable(vm)
|
registry.Enable(vm)
|
||||||
console.Enable(vm)
|
console.Enable(vm)
|
||||||
process.Enable(vm)
|
process.Enable(vm)
|
||||||
|
buffer.Enable(vm)
|
||||||
|
|
||||||
baseBinds(vm)
|
baseBinds(vm)
|
||||||
dbxBinds(vm)
|
dbxBinds(vm)
|
||||||
securityBinds(vm)
|
securityBinds(vm)
|
||||||
@@ -252,6 +256,7 @@ func (p *plugin) registerHooks() error {
|
|||||||
requireRegistry.Enable(vm)
|
requireRegistry.Enable(vm)
|
||||||
console.Enable(vm)
|
console.Enable(vm)
|
||||||
process.Enable(vm)
|
process.Enable(vm)
|
||||||
|
buffer.Enable(vm)
|
||||||
|
|
||||||
baseBinds(vm)
|
baseBinds(vm)
|
||||||
dbxBinds(vm)
|
dbxBinds(vm)
|
||||||
@@ -321,21 +326,7 @@ func (p *plugin) normalizeServeExceptions(e *core.RequestEvent) error {
|
|||||||
return err // no error or already committed
|
return err // no error or already committed
|
||||||
}
|
}
|
||||||
|
|
||||||
jsException, ok := err.(*goja.Exception)
|
return normalizeException(err)
|
||||||
if !ok {
|
|
||||||
return err // no exception
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := jsException.Value().Export().(type) {
|
|
||||||
case error:
|
|
||||||
err = v
|
|
||||||
case map[string]any: // goja.GoError
|
|
||||||
if vErr, ok := v["value"].(error); ok {
|
|
||||||
err = vErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// watchHooks initializes a hooks file watcher that will restart the
|
// watchHooks initializes a hooks file watcher that will restart the
|
||||||
|
|||||||
@@ -921,7 +921,7 @@ migrate((app) => {
|
|||||||
collection.fields.removeById("f3_id")
|
collection.fields.removeById("f3_id")
|
||||||
|
|
||||||
// add field
|
// add field
|
||||||
collection.fields.add(new Field({
|
collection.fields.addAt(8, new Field({
|
||||||
"autogeneratePattern": "",
|
"autogeneratePattern": "",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "f4_id",
|
"id": "f4_id",
|
||||||
@@ -937,7 +937,7 @@ migrate((app) => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// update field
|
// update field
|
||||||
collection.fields.add(new Field({
|
collection.fields.addAt(7, new Field({
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "f2_id",
|
"id": "f2_id",
|
||||||
"max": null,
|
"max": null,
|
||||||
@@ -975,7 +975,7 @@ migrate((app) => {
|
|||||||
}, collection)
|
}, collection)
|
||||||
|
|
||||||
// add field
|
// add field
|
||||||
collection.fields.add(new Field({
|
collection.fields.addAt(8, new Field({
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "f3_id",
|
"id": "f3_id",
|
||||||
"name": "f3_name",
|
"name": "f3_name",
|
||||||
@@ -989,7 +989,7 @@ migrate((app) => {
|
|||||||
collection.fields.removeById("f4_id")
|
collection.fields.removeById("f4_id")
|
||||||
|
|
||||||
// update field
|
// update field
|
||||||
collection.fields.add(new Field({
|
collection.fields.addAt(7, new Field({
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "f2_id",
|
"id": "f2_id",
|
||||||
"max": null,
|
"max": null,
|
||||||
@@ -1052,7 +1052,7 @@ func init() {
|
|||||||
collection.Fields.RemoveById("f3_id")
|
collection.Fields.RemoveById("f3_id")
|
||||||
|
|
||||||
// add field
|
// add field
|
||||||
if err := collection.Fields.AddMarshaledJSON([]byte(` + "`" + `{
|
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
|
||||||
"autogeneratePattern": "",
|
"autogeneratePattern": "",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "f4_id",
|
"id": "f4_id",
|
||||||
@@ -1070,7 +1070,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update field
|
// update field
|
||||||
if err := collection.Fields.AddMarshaledJSON([]byte(` + "`" + `{
|
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "f2_id",
|
"id": "f2_id",
|
||||||
"max": null,
|
"max": null,
|
||||||
@@ -1115,7 +1115,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// add field
|
// add field
|
||||||
if err := collection.Fields.AddMarshaledJSON([]byte(` + "`" + `{
|
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "f3_id",
|
"id": "f3_id",
|
||||||
"name": "f3_name",
|
"name": "f3_name",
|
||||||
@@ -1131,7 +1131,7 @@ func init() {
|
|||||||
collection.Fields.RemoveById("f4_id")
|
collection.Fields.RemoveById("f4_id")
|
||||||
|
|
||||||
// update field
|
// update field
|
||||||
if err := collection.Fields.AddMarshaledJSON([]byte(` + "`" + `{
|
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"id": "f2_id",
|
"id": "f2_id",
|
||||||
"max": null,
|
"max": null,
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ func (p *plugin) jsDiffTemplate(new *core.Collection, old *core.Collection) (str
|
|||||||
upParts = append(upParts, fmt.Sprintf("%s.fields.removeById(%q)\n", varName, oldField.GetId()))
|
upParts = append(upParts, fmt.Sprintf("%s.fields.removeById(%q)\n", varName, oldField.GetId()))
|
||||||
|
|
||||||
downParts = append(downParts, "// add field")
|
downParts = append(downParts, "// add field")
|
||||||
downParts = append(downParts, fmt.Sprintf("%s.fields.add(new Field(%s))\n", varName, rawOldField))
|
downParts = append(downParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawOldField))
|
||||||
}
|
}
|
||||||
|
|
||||||
// created fields
|
// created fields
|
||||||
@@ -223,13 +223,14 @@ func (p *plugin) jsDiffTemplate(new *core.Collection, old *core.Collection) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
upParts = append(upParts, "// add field")
|
upParts = append(upParts, "// add field")
|
||||||
upParts = append(upParts, fmt.Sprintf("%s.fields.add(new Field(%s))\n", varName, rawNewField))
|
upParts = append(upParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawNewField))
|
||||||
|
|
||||||
downParts = append(downParts, "// remove field")
|
downParts = append(downParts, "// remove field")
|
||||||
downParts = append(downParts, fmt.Sprintf("%s.fields.removeById(%q)\n", varName, newField.GetId()))
|
downParts = append(downParts, fmt.Sprintf("%s.fields.removeById(%q)\n", varName, newField.GetId()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// modified fields
|
// modified fields
|
||||||
|
// (note currently ignoring order-only changes as it comes with too many edge-cases)
|
||||||
for i, newField := range new.Fields {
|
for i, newField := range new.Fields {
|
||||||
var rawNewField, rawOldField []byte
|
var rawNewField, rawOldField []byte
|
||||||
|
|
||||||
@@ -238,12 +239,15 @@ func (p *plugin) jsDiffTemplate(new *core.Collection, old *core.Collection) (str
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var oldFieldIndex int
|
||||||
|
|
||||||
for j, oldField := range old.Fields {
|
for j, oldField := range old.Fields {
|
||||||
if oldField.GetId() == newField.GetId() {
|
if oldField.GetId() == newField.GetId() {
|
||||||
rawOldField, err = marhshalWithoutEscape(oldFieldsSlice[j], " ", " ")
|
rawOldField, err = marhshalWithoutEscape(oldFieldsSlice[j], " ", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
oldFieldIndex = j
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,10 +257,10 @@ func (p *plugin) jsDiffTemplate(new *core.Collection, old *core.Collection) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
upParts = append(upParts, "// update field")
|
upParts = append(upParts, "// update field")
|
||||||
upParts = append(upParts, fmt.Sprintf("%s.fields.add(new Field(%s))\n", varName, rawNewField))
|
upParts = append(upParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, i, rawNewField))
|
||||||
|
|
||||||
downParts = append(downParts, "// update field")
|
downParts = append(downParts, "// update field")
|
||||||
downParts = append(downParts, fmt.Sprintf("%s.fields.add(new Field(%s))\n", varName, rawOldField))
|
downParts = append(downParts, fmt.Sprintf("%s.fields.addAt(%d, new Field(%s))\n", varName, oldFieldIndex, rawOldField))
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
@@ -546,7 +550,7 @@ func (p *plugin) goDiffTemplate(new *core.Collection, old *core.Collection) (str
|
|||||||
upParts = append(upParts, fmt.Sprintf("%s.Fields.RemoveById(%q)\n", varName, oldField.GetId()))
|
upParts = append(upParts, fmt.Sprintf("%s.Fields.RemoveById(%q)\n", varName, oldField.GetId()))
|
||||||
|
|
||||||
downParts = append(downParts, "// add field")
|
downParts = append(downParts, "// add field")
|
||||||
downParts = append(downParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSON([]byte(`%s`))", varName, escapeBacktick(string(rawOldField)))))
|
downParts = append(downParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawOldField)))))
|
||||||
}
|
}
|
||||||
|
|
||||||
// created fields
|
// created fields
|
||||||
@@ -561,13 +565,14 @@ func (p *plugin) goDiffTemplate(new *core.Collection, old *core.Collection) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
upParts = append(upParts, "// add field")
|
upParts = append(upParts, "// add field")
|
||||||
upParts = append(upParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSON([]byte(`%s`))", varName, escapeBacktick(string(rawNewField)))))
|
upParts = append(upParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawNewField)))))
|
||||||
|
|
||||||
downParts = append(downParts, "// remove field")
|
downParts = append(downParts, "// remove field")
|
||||||
downParts = append(downParts, fmt.Sprintf("%s.Fields.RemoveById(%q)\n", varName, newField.GetId()))
|
downParts = append(downParts, fmt.Sprintf("%s.Fields.RemoveById(%q)\n", varName, newField.GetId()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// modified fields
|
// modified fields
|
||||||
|
// (note currently ignoring order-only changes as it comes with too many edge-cases)
|
||||||
for i, newField := range new.Fields {
|
for i, newField := range new.Fields {
|
||||||
var rawNewField, rawOldField []byte
|
var rawNewField, rawOldField []byte
|
||||||
|
|
||||||
@@ -576,12 +581,15 @@ func (p *plugin) goDiffTemplate(new *core.Collection, old *core.Collection) (str
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var oldFieldIndex int
|
||||||
|
|
||||||
for j, oldField := range old.Fields {
|
for j, oldField := range old.Fields {
|
||||||
if oldField.GetId() == newField.GetId() {
|
if oldField.GetId() == newField.GetId() {
|
||||||
rawOldField, err = marhshalWithoutEscape(oldFieldsSlice[j], "\t\t", "\t")
|
rawOldField, err = marhshalWithoutEscape(oldFieldsSlice[j], "\t\t", "\t")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
oldFieldIndex = j
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -591,10 +599,10 @@ func (p *plugin) goDiffTemplate(new *core.Collection, old *core.Collection) (str
|
|||||||
}
|
}
|
||||||
|
|
||||||
upParts = append(upParts, "// update field")
|
upParts = append(upParts, "// update field")
|
||||||
upParts = append(upParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSON([]byte(`%s`))", varName, escapeBacktick(string(rawNewField)))))
|
upParts = append(upParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, i, escapeBacktick(string(rawNewField)))))
|
||||||
|
|
||||||
downParts = append(downParts, "// update field")
|
downParts = append(downParts, "// update field")
|
||||||
downParts = append(downParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSON([]byte(`%s`))", varName, escapeBacktick(string(rawOldField)))))
|
downParts = append(downParts, goErrIf(fmt.Sprintf("%s.Fields.AddMarshaledJSONAt(%d, []byte(`%s`))", varName, oldFieldIndex, escapeBacktick(string(rawOldField)))))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
+6
-6
@@ -64,9 +64,9 @@ type Config struct {
|
|||||||
//
|
//
|
||||||
// Note that the application will not be initialized/bootstrapped yet,
|
// Note that the application will not be initialized/bootstrapped yet,
|
||||||
// aka. DB connections, migrations, app settings, etc. will not be accessible.
|
// aka. DB connections, migrations, app settings, etc. will not be accessible.
|
||||||
// Everything will be initialized when [PocketBase.Start()] is executed.
|
// Everything will be initialized when [PocketBase.Start] is executed.
|
||||||
// If you want to initialize the application before calling [PocketBase.Start()],
|
// If you want to initialize the application before calling [PocketBase.Start],
|
||||||
// then you'll have to manually call [PocketBase.Bootstrap()].
|
// then you'll have to manually call [PocketBase.Bootstrap].
|
||||||
func New() *PocketBase {
|
func New() *PocketBase {
|
||||||
_, isUsingGoRun := inspectRuntime()
|
_, isUsingGoRun := inspectRuntime()
|
||||||
|
|
||||||
@@ -79,9 +79,9 @@ func New() *PocketBase {
|
|||||||
//
|
//
|
||||||
// Note that the application will not be initialized/bootstrapped yet,
|
// Note that the application will not be initialized/bootstrapped yet,
|
||||||
// aka. DB connections, migrations, app settings, etc. will not be accessible.
|
// aka. DB connections, migrations, app settings, etc. will not be accessible.
|
||||||
// Everything will be initialized when [PocketBase.Start()] is executed.
|
// Everything will be initialized when [PocketBase.Start] is executed.
|
||||||
// If you want to initialize the application before calling [PocketBase..Start()],
|
// If you want to initialize the application before calling [PocketBase.Start],
|
||||||
// then you'll have to manually call [PocketBase.Bootstrap()].
|
// then you'll have to manually call [PocketBase.Bootstrap].
|
||||||
func NewWithConfig(config Config) *PocketBase {
|
func NewWithConfig(config Config) *PocketBase {
|
||||||
// initialize a default data directory based on the executable baseDir
|
// initialize a default data directory based on the executable baseDir
|
||||||
if config.DefaultDataDir == "" {
|
if config.DefaultDataDir == "" {
|
||||||
|
|||||||
Binary file not shown.
+19
-1
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestProvidersCount(t *testing.T) {
|
func TestProvidersCount(t *testing.T) {
|
||||||
expected := 27
|
expected := 29
|
||||||
|
|
||||||
if total := len(auth.Providers); total != expected {
|
if total := len(auth.Providers); total != expected {
|
||||||
t.Fatalf("Expected %d providers, got %d", expected, total)
|
t.Fatalf("Expected %d providers, got %d", expected, total)
|
||||||
@@ -269,4 +269,22 @@ func TestNewProviderByName(t *testing.T) {
|
|||||||
if _, ok := p.(*auth.Monday); !ok {
|
if _, ok := p.(*auth.Monday); !ok {
|
||||||
t.Error("Expected to be instance of *auth.Monday")
|
t.Error("Expected to be instance of *auth.Monday")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wakatime
|
||||||
|
p, err = auth.NewProviderByName(auth.NameWakatime)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil, got error %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := p.(*auth.Wakatime); !ok {
|
||||||
|
t.Error("Expected to be instance of *auth.Wakatime")
|
||||||
|
}
|
||||||
|
|
||||||
|
// linear
|
||||||
|
p, err = auth.NewProviderByName(auth.NameLinear)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil, got error %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := p.(*auth.Linear); !ok {
|
||||||
|
t.Error("Expected to be instance of *auth.Linear")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Providers[NameLinear] = wrapFactory(NewLinearProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Provider = (*Linear)(nil)
|
||||||
|
|
||||||
|
// NameLinear is the unique name of the Linear provider.
|
||||||
|
const NameLinear string = "linear"
|
||||||
|
|
||||||
|
// Linear allows authentication via Linear OAuth2.
|
||||||
|
type Linear struct {
|
||||||
|
BaseProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLinearProvider creates new Linear provider instance with some defaults.
|
||||||
|
//
|
||||||
|
// API reference: https://developers.linear.app/docs/oauth/authentication
|
||||||
|
func NewLinearProvider() *Linear {
|
||||||
|
return &Linear{BaseProvider{
|
||||||
|
ctx: context.Background(),
|
||||||
|
displayName: "Linear",
|
||||||
|
pkce: false, // Linear doesn't support PKCE at the moment and returns an error if enabled
|
||||||
|
scopes: []string{"read"},
|
||||||
|
authURL: "https://linear.app/oauth/authorize",
|
||||||
|
tokenURL: "https://api.linear.app/oauth/token",
|
||||||
|
userInfoURL: "https://api.linear.app/graphql",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAuthUser returns an AuthUser instance based on the Linear's user api.
|
||||||
|
//
|
||||||
|
// API reference: https://developers.linear.app/docs/graphql/working-with-the-graphql-api#authentication
|
||||||
|
func (p *Linear) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
||||||
|
data, err := p.FetchRawUserInfo(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawUser := map[string]any{}
|
||||||
|
if err := json.Unmarshal(data, &rawUser); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extracted := struct {
|
||||||
|
Data struct {
|
||||||
|
Viewer struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatarUrl"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
} `json:"viewer"`
|
||||||
|
} `json:"data"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !extracted.Data.Viewer.Active {
|
||||||
|
return nil, errors.New("the Linear user account is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &AuthUser{
|
||||||
|
Id: extracted.Data.Viewer.Id,
|
||||||
|
Name: extracted.Data.Viewer.Name,
|
||||||
|
Username: extracted.Data.Viewer.DisplayName,
|
||||||
|
Email: extracted.Data.Viewer.Email,
|
||||||
|
AvatarURL: extracted.Data.Viewer.AvatarURL,
|
||||||
|
RawUser: rawUser,
|
||||||
|
AccessToken: token.AccessToken,
|
||||||
|
RefreshToken: token.RefreshToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Expiry, _ = types.ParseDateTime(token.Expiry)
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface method.
|
||||||
|
//
|
||||||
|
// Linear doesn't have a UserInfo endpoint and information on the user
|
||||||
|
// is retrieved using their GraphQL API (https://developers.linear.app/docs/graphql/working-with-the-graphql-api#queries-and-mutations)
|
||||||
|
func (p *Linear) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
|
||||||
|
query := []byte(`{"query": "query Me { viewer { id displayName name email avatarUrl active } }"}`)
|
||||||
|
bodyReader := bytes.NewReader(query)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(p.ctx, "POST", p.userInfoURL, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
return p.sendRawUserInfoRequest(req, token)
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ func NewMondayProvider() *Monday {
|
|||||||
//
|
//
|
||||||
// API reference: https://developer.monday.com/api-reference/reference/me
|
// API reference: https://developer.monday.com/api-reference/reference/me
|
||||||
func (p *Monday) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
func (p *Monday) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
||||||
data, err := p.FetchRawUserData(token)
|
data, err := p.FetchRawUserInfo(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -90,11 +90,11 @@ func (p *Monday) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchRawUserData implements Provider.FetchRawUserData interface.
|
// FetchRawUserInfo implements Provider.FetchRawUserInfo interface.
|
||||||
//
|
//
|
||||||
// monday.com doesn't have a UserInfo endpoint and information on the user
|
// monday.com doesn't have a UserInfo endpoint and information on the user
|
||||||
// is retrieved using their GraphQL API (https://developer.monday.com/api-reference/reference/me#queries)
|
// is retrieved using their GraphQL API (https://developer.monday.com/api-reference/reference/me#queries)
|
||||||
func (p *Monday) FetchRawUserData(token *oauth2.Token) ([]byte, error) {
|
func (p *Monday) FetchRawUserInfo(token *oauth2.Token) ([]byte, error) {
|
||||||
query := []byte(`{"query": "query { me { id enabled name email is_verified photo_small }}"}`)
|
query := []byte(`{"query": "query { me { id enabled name email is_verified photo_small }}"}`)
|
||||||
bodyReader := bytes.NewReader(query)
|
bodyReader := bytes.NewReader(query)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Providers[NameWakatime] = wrapFactory(NewWakatimeProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Provider = (*Wakatime)(nil)
|
||||||
|
|
||||||
|
// NameWakatime is the unique name of the Wakatime provider.
|
||||||
|
const NameWakatime = "wakatime"
|
||||||
|
|
||||||
|
// Wakatime is an auth provider for Wakatime.
|
||||||
|
type Wakatime struct {
|
||||||
|
BaseProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWakatimeProvider creates a new Wakatime provider instance with some defaults.
|
||||||
|
func NewWakatimeProvider() *Wakatime {
|
||||||
|
return &Wakatime{BaseProvider{
|
||||||
|
ctx: context.Background(),
|
||||||
|
displayName: "WakaTime",
|
||||||
|
pkce: true,
|
||||||
|
scopes: []string{"email"},
|
||||||
|
authURL: "https://wakatime.com/oauth/authorize",
|
||||||
|
tokenURL: "https://wakatime.com/oauth/token",
|
||||||
|
userInfoURL: "https://wakatime.com/api/v1/users/current",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAuthUser returns an AuthUser instance based on the Wakatime's user API.
|
||||||
|
//
|
||||||
|
// API reference: https://wakatime.com/developers#users
|
||||||
|
func (p *Wakatime) FetchAuthUser(token *oauth2.Token) (*AuthUser, error) {
|
||||||
|
data, err := p.FetchRawUserInfo(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawUser := map[string]any{}
|
||||||
|
if err := json.Unmarshal(data, &rawUser); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
extracted := struct {
|
||||||
|
Data struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Photo string `json:"photo"`
|
||||||
|
IsPhotoPublic bool `json:"photo_public"`
|
||||||
|
IsEmailConfirmed bool `json:"is_email_confirmed"`
|
||||||
|
} `json:"data"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &extracted); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &AuthUser{
|
||||||
|
Id: extracted.Data.Id,
|
||||||
|
Name: extracted.Data.DisplayName,
|
||||||
|
Username: extracted.Data.Username,
|
||||||
|
RawUser: rawUser,
|
||||||
|
AccessToken: token.AccessToken,
|
||||||
|
RefreshToken: token.RefreshToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: we don't check for is_email_public field because PocketBase
|
||||||
|
// has its own emailVisibility flag which is false by default
|
||||||
|
if extracted.Data.IsEmailConfirmed {
|
||||||
|
user.Email = extracted.Data.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
if extracted.Data.IsPhotoPublic {
|
||||||
|
user.AvatarURL = extracted.Data.Photo
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Expiry, _ = types.ParseDateTime(token.Expiry)
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
+8
-6
@@ -112,15 +112,17 @@ func (h *Hook[T]) BindFunc(fn func(e T) error) string {
|
|||||||
return h.Bind(&Handler[T]{Func: fn})
|
return h.Bind(&Handler[T]{Func: fn})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unbind removes a single hook handler by its id.
|
// Unbind removes one or many hook handler by their id.
|
||||||
func (h *Hook[T]) Unbind(id string) {
|
func (h *Hook[T]) Unbind(idsToRemove ...string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
for i := len(h.handlers) - 1; i >= 0; i-- {
|
for _, id := range idsToRemove {
|
||||||
if h.handlers[i].Id == id {
|
for i := len(h.handlers) - 1; i >= 0; i-- {
|
||||||
h.handlers = append(h.handlers[:i], h.handlers[i+1:]...)
|
if h.handlers[i].Id == id {
|
||||||
break // for now stop on the first occurrence since we don't allow handlers with duplicated ids
|
h.handlers = append(h.handlers[:i], h.handlers[i+1:]...)
|
||||||
|
break // for now stop on the first occurrence since we don't allow handlers with duplicated ids
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func TestHookUnbind(t *testing.T) {
|
|||||||
|
|
||||||
calls := ""
|
calls := ""
|
||||||
|
|
||||||
|
id0 := h.BindFunc(func(e *Event) error { calls += "0"; return e.Next() })
|
||||||
id1 := h.BindFunc(func(e *Event) error { calls += "1"; return e.Next() })
|
id1 := h.BindFunc(func(e *Event) error { calls += "1"; return e.Next() })
|
||||||
h.BindFunc(func(e *Event) error { calls += "2"; return e.Next() })
|
h.BindFunc(func(e *Event) error { calls += "2"; return e.Next() })
|
||||||
h.Bind(&Handler[*Event]{
|
h.Bind(&Handler[*Event]{
|
||||||
@@ -78,11 +79,11 @@ func TestHookUnbind(t *testing.T) {
|
|||||||
|
|
||||||
h.Unbind("missing") // should do nothing and not panic
|
h.Unbind("missing") // should do nothing and not panic
|
||||||
|
|
||||||
if total := len(h.handlers); total != 3 {
|
if total := len(h.handlers); total != 4 {
|
||||||
t.Fatalf("Expected %d handlers, got %d", 3, total)
|
t.Fatalf("Expected %d handlers, got %d", 4, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Unbind(id1)
|
h.Unbind(id1, id0)
|
||||||
|
|
||||||
if total := len(h.handlers); total != 2 {
|
if total := len(h.handlers); total != 2 {
|
||||||
t.Fatalf("Expected %d handlers, got %d", 2, total)
|
t.Fatalf("Expected %d handlers, got %d", 2, total)
|
||||||
|
|||||||
@@ -221,6 +221,14 @@ func (e *Event) Stream(status int, contentType string, reader io.Reader) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blob writes a blob (bytes slice) response.
|
||||||
|
func (e *Event) Blob(status int, contentType string, b []byte) error {
|
||||||
|
e.setResponseHeaderIfEmpty(headerContentType, contentType)
|
||||||
|
e.Response.WriteHeader(status)
|
||||||
|
_, err := e.Response.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// FileFS serves the specified filename from fsys.
|
// FileFS serves the specified filename from fsys.
|
||||||
//
|
//
|
||||||
// It is similar to [echo.FileFS] for consistency with earlier versions.
|
// It is similar to [echo.FileFS] for consistency with earlier versions.
|
||||||
|
|||||||
@@ -500,6 +500,26 @@ func TestEventStream(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEventBlob(t *testing.T) {
|
||||||
|
scenarios := []testResponseWriteScenario[[]byte]{
|
||||||
|
{
|
||||||
|
name: "blob",
|
||||||
|
status: 234,
|
||||||
|
headers: map[string]string{"content-type": "text/test"},
|
||||||
|
body: []byte("test"),
|
||||||
|
expectedStatus: 234,
|
||||||
|
expectedHeaders: map[string]string{"content-type": "text/test"},
|
||||||
|
expectedBody: "test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range scenarios {
|
||||||
|
testEventResponseWrite(t, s, func(e *router.Event) error {
|
||||||
|
return e.Blob(s.status, s.headers["content-type"], s.body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEventNoContent(t *testing.T) {
|
func TestEventNoContent(t *testing.T) {
|
||||||
s := testResponseWriteScenario[any]{
|
s := testResponseWriteScenario[any]{
|
||||||
name: "no content",
|
name: "no content",
|
||||||
|
|||||||
+31
-8
@@ -34,9 +34,24 @@ var parsedFilterData = store.New(make(map[string][]fexpr.ExprGroup, 50))
|
|||||||
//
|
//
|
||||||
// The filter string can also contain dbx placeholder parameters (eg. "title = {:name}"),
|
// The filter string can also contain dbx placeholder parameters (eg. "title = {:name}"),
|
||||||
// that will be safely replaced and properly quoted inplace with the placeholderReplacements values.
|
// that will be safely replaced and properly quoted inplace with the placeholderReplacements values.
|
||||||
|
//
|
||||||
|
// The parsed expressions are limited up to DefaultFilterExprLimit.
|
||||||
|
// Use [FilterData.BuildExprWithLimit] if you want to set a custom limit.
|
||||||
func (f FilterData) BuildExpr(
|
func (f FilterData) BuildExpr(
|
||||||
fieldResolver FieldResolver,
|
fieldResolver FieldResolver,
|
||||||
placeholderReplacements ...dbx.Params,
|
placeholderReplacements ...dbx.Params,
|
||||||
|
) (dbx.Expression, error) {
|
||||||
|
return f.BuildExprWithLimit(fieldResolver, DefaultFilterExprLimit, placeholderReplacements...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildExpr parses the current filter data and returns a new db WHERE expression.
|
||||||
|
//
|
||||||
|
// The filter string can also contain dbx placeholder parameters (eg. "title = {:name}"),
|
||||||
|
// that will be safely replaced and properly quoted inplace with the placeholderReplacements values.
|
||||||
|
func (f FilterData) BuildExprWithLimit(
|
||||||
|
fieldResolver FieldResolver,
|
||||||
|
maxExpressions int,
|
||||||
|
placeholderReplacements ...dbx.Params,
|
||||||
) (dbx.Expression, error) {
|
) (dbx.Expression, error) {
|
||||||
raw := string(f)
|
raw := string(f)
|
||||||
|
|
||||||
@@ -64,8 +79,10 @@ func (f FilterData) BuildExpr(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if data, ok := parsedFilterData.GetOk(raw); ok {
|
cacheKey := raw + "/" + strconv.Itoa(maxExpressions)
|
||||||
return buildParsedFilterExpr(data, fieldResolver)
|
|
||||||
|
if data, ok := parsedFilterData.GetOk(cacheKey); ok {
|
||||||
|
return buildParsedFilterExpr(data, fieldResolver, &maxExpressions)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := fexpr.Parse(raw)
|
data, err := fexpr.Parse(raw)
|
||||||
@@ -82,14 +99,14 @@ func (f FilterData) BuildExpr(
|
|||||||
|
|
||||||
// store in cache
|
// store in cache
|
||||||
// (the limit size is arbitrary and it is there to prevent the cache growing too big)
|
// (the limit size is arbitrary and it is there to prevent the cache growing too big)
|
||||||
parsedFilterData.SetIfLessThanLimit(raw, data, 500)
|
parsedFilterData.SetIfLessThanLimit(cacheKey, data, 500)
|
||||||
|
|
||||||
return buildParsedFilterExpr(data, fieldResolver)
|
return buildParsedFilterExpr(data, fieldResolver, &maxExpressions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildParsedFilterExpr(data []fexpr.ExprGroup, fieldResolver FieldResolver) (dbx.Expression, error) {
|
func buildParsedFilterExpr(data []fexpr.ExprGroup, fieldResolver FieldResolver, maxExpressions *int) (dbx.Expression, error) {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil, errors.New("empty filter expression")
|
return nil, fexpr.ErrEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &concatExpr{separator: " "}
|
result := &concatExpr{separator: " "}
|
||||||
@@ -100,11 +117,17 @@ func buildParsedFilterExpr(data []fexpr.ExprGroup, fieldResolver FieldResolver)
|
|||||||
|
|
||||||
switch item := group.Item.(type) {
|
switch item := group.Item.(type) {
|
||||||
case fexpr.Expr:
|
case fexpr.Expr:
|
||||||
|
if *maxExpressions <= 0 {
|
||||||
|
return nil, ErrFilterExprLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
*maxExpressions--
|
||||||
|
|
||||||
expr, exprErr = resolveTokenizedExpr(item, fieldResolver)
|
expr, exprErr = resolveTokenizedExpr(item, fieldResolver)
|
||||||
case fexpr.ExprGroup:
|
case fexpr.ExprGroup:
|
||||||
expr, exprErr = buildParsedFilterExpr([]fexpr.ExprGroup{item}, fieldResolver)
|
expr, exprErr = buildParsedFilterExpr([]fexpr.ExprGroup{item}, fieldResolver, maxExpressions)
|
||||||
case []fexpr.ExprGroup:
|
case []fexpr.ExprGroup:
|
||||||
expr, exprErr = buildParsedFilterExpr(item, fieldResolver)
|
expr, exprErr = buildParsedFilterExpr(item, fieldResolver, maxExpressions)
|
||||||
default:
|
default:
|
||||||
exprErr = errors.New("unsupported expression item")
|
exprErr = errors.New("unsupported expression item")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package search_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -239,6 +240,35 @@ func TestFilterDataBuildExprWithParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterDataBuildExprWithLimit(t *testing.T) {
|
||||||
|
resolver := search.NewSimpleFieldResolver(`^\w+$`)
|
||||||
|
|
||||||
|
scenarios := []struct {
|
||||||
|
limit int
|
||||||
|
filter search.FilterData
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{1, "1 = 1", false},
|
||||||
|
{0, "1 = 1", true}, // new cache entry should be created
|
||||||
|
{2, "1 = 1 || 1 = 1", false},
|
||||||
|
{1, "1 = 1 || 1 = 1", true},
|
||||||
|
{3, "1 = 1 || 1 = 1", false},
|
||||||
|
{6, "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", false},
|
||||||
|
{5, "(1=1 || 1=1) && (1=1 || (1=1 || 1=1)) && (1=1)", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s := range scenarios {
|
||||||
|
t.Run(fmt.Sprintf("limit_%d:%d", i, s.limit), func(t *testing.T) {
|
||||||
|
_, err := s.filter.BuildExprWithLimit(resolver, s.limit)
|
||||||
|
|
||||||
|
hasErr := err != nil
|
||||||
|
if hasErr != s.expectError {
|
||||||
|
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLikeParamsWrapping(t *testing.T) {
|
func TestLikeParamsWrapping(t *testing.T) {
|
||||||
// create a dummy db
|
// create a dummy db
|
||||||
sqlDB, err := sql.Open("sqlite", "file::memory:?cache=shared")
|
sqlDB, err := sql.Open("sqlite", "file::memory:?cache=shared")
|
||||||
|
|||||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user